/ src / main.rs
main.rs
  1  use std::{
  2      collections::HashMap,
  3      path::{Path, PathBuf},
  4  };
  5  
  6  use clap::Parser;
  7  use directories_next::ProjectDirs;
  8  use serde::{Deserialize, Serialize};
  9  use tempfile::NamedTempFile;
 10  
 11  const APP: &str = "clab";
 12  
 13  fn main() -> anyhow::Result<()> {
 14      let mut opt = Opt::parse();
 15      let book = if let Some(filename) = &opt.db {
 16          AddressBook::load(filename)?
 17      } else {
 18          let proj_dirs = ProjectDirs::from("", "", APP).expect("couldn't find home directory");
 19          let filename = proj_dirs.data_dir().join("address-book.yaml");
 20          opt.db = Some(filename.clone());
 21          if filename.exists() {
 22              AddressBook::load(&filename)?
 23          } else {
 24              AddressBook::default()
 25          }
 26      };
 27      match &opt.cmd {
 28          Cmd::Config(x) => x.run(&opt, &book),
 29          Cmd::Lint(x) => x.run(&opt, &book),
 30          Cmd::List(x) => x.run(&opt, &book)?,
 31          Cmd::Search(x) => x.run(&opt, &book)?,
 32          Cmd::Tagged(x) => x.run(&opt, &book)?,
 33          Cmd::MuttQuery(x) => x.run(&opt, &book),
 34          Cmd::Reformat(x) => x.run(&opt, &book)?,
 35      }
 36      Ok(())
 37  }
 38  
 39  #[derive(Clone, Debug, Deserialize, Serialize)]
 40  #[serde(deny_unknown_fields)]
 41  struct Entry {
 42      name: String,
 43      #[serde(skip_serializing_if = "Option::is_none")]
 44      org: Option<String>,
 45      #[serde(skip_serializing_if = "Option::is_none")]
 46      url: Option<Vec<String>>,
 47      #[serde(skip_serializing_if = "Option::is_none")]
 48      notes: Option<String>,
 49      #[serde(skip_serializing_if = "Option::is_none")]
 50      aliases: Option<Vec<String>>,
 51      #[serde(skip_serializing_if = "Option::is_none")]
 52      email: Option<HashMap<String, String>>,
 53      #[serde(skip_serializing_if = "Option::is_none")]
 54      phone: Option<HashMap<String, String>>,
 55      #[serde(skip_serializing_if = "Option::is_none")]
 56      irc: Option<HashMap<String, String>>,
 57      #[serde(skip_serializing_if = "Option::is_none")]
 58      address: Option<HashMap<String, String>>,
 59      #[serde(skip_serializing_if = "Option::is_none")]
 60      tags: Option<Vec<String>>,
 61      last_checked: String,
 62  }
 63  
 64  impl Entry {
 65      fn is_match(&self, needle: &str) -> bool {
 66          let text = serde_norway::to_string(self).unwrap();
 67          contains(&text, needle)
 68      }
 69  
 70      fn emails(&self) -> Vec<String> {
 71          if let Some(map) = &self.email {
 72              map.values().map(|x| x.to_string()).collect()
 73          } else {
 74              vec![]
 75          }
 76      }
 77  }
 78  
 79  fn output_entries(entries: &[Entry]) -> anyhow::Result<()> {
 80      if !entries.is_empty() {
 81          serde_norway::to_writer(std::io::stdout(), entries)?;
 82      }
 83      Ok(())
 84  }
 85  
 86  fn contains(haystack: &str, needle: &str) -> bool {
 87      let haystack = haystack.to_lowercase();
 88      let needle = needle.to_lowercase();
 89      haystack.contains(&needle)
 90  }
 91  
 92  #[derive(std::default::Default)]
 93  struct AddressBook {
 94      filename: PathBuf,
 95      entries: Vec<Entry>,
 96  }
 97  
 98  impl AddressBook {
 99      fn load(db: &Path) -> anyhow::Result<Self> {
100          let mut book = Self {
101              filename: db.to_path_buf(),
102              entries: vec![],
103          };
104          book.add_from(db)?;
105          Ok(book)
106      }
107  
108      fn filename(&self) -> &Path {
109          &self.filename
110      }
111  
112      fn add_from(&mut self, filename: &Path) -> anyhow::Result<()> {
113          let text = std::fs::read(filename)?;
114          let mut entries: Vec<Entry> = serde_norway::from_slice(&text)?;
115          self.entries.append(&mut entries);
116          Ok(())
117      }
118  
119      fn entries(&self) -> &[Entry] {
120          &self.entries
121      }
122  
123      fn iter(&self) -> impl Iterator<Item = &Entry> {
124          self.entries.iter()
125      }
126  }
127  
128  /// Command line address book.
129  #[derive(Debug, Parser)]
130  #[clap(version)]
131  struct Opt {
132      /// Path to address book.
133      #[clap(long)]
134      db: Option<PathBuf>,
135  
136      #[clap(subcommand)]
137      cmd: Cmd,
138  }
139  
140  #[derive(Debug, Parser)]
141  enum Cmd {
142      Config(ConfigCommand),
143      Lint(LintCommand),
144      List(ListCommand),
145      Search(SearchCommand),
146      Tagged(TaggedCommand),
147      MuttQuery(MuttCommand),
148      Reformat(Reformat),
149  }
150  
151  /// Show run time configuration.
152  #[derive(Debug, Parser)]
153  struct ConfigCommand {}
154  
155  impl ConfigCommand {
156      fn run(&self, opt: &Opt, _book: &AddressBook) {
157          println!("{opt:#?}");
158      }
159  }
160  
161  /// Check that address book looks OK.
162  #[derive(Debug, Parser)]
163  struct LintCommand {}
164  
165  impl LintCommand {
166      fn run(&self, _opt: &Opt, _book: &AddressBook) {}
167  }
168  
169  /// List all entries in address book.
170  #[derive(Debug, Parser)]
171  struct ListCommand {}
172  
173  impl ListCommand {
174      fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
175          output_entries(book.entries())
176      }
177  }
178  
179  /// Search for entries using full text search.
180  #[derive(Debug, Parser)]
181  #[clap(alias = "find")]
182  struct SearchCommand {
183      words: Vec<String>,
184  }
185  
186  impl SearchCommand {
187      fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
188          let matches: Vec<Entry> = book.iter().filter(|e| self.is_match(e)).cloned().collect();
189          output_entries(&matches)
190      }
191  
192      fn is_match(&self, entry: &Entry) -> bool {
193          for word in self.words.iter() {
194              if !entry.is_match(word) {
195                  return false;
196              }
197          }
198          true
199      }
200  }
201  
202  /// Search for entries with specific tags.
203  #[derive(Debug, Parser)]
204  struct TaggedCommand {
205      /// Wanted tags.
206      wanted_tags: Vec<String>,
207  }
208  
209  impl TaggedCommand {
210      fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
211          let matches: Vec<Entry> = book.iter().filter(|e| self.is_match(e)).cloned().collect();
212          output_entries(&matches)
213      }
214  
215      fn is_match(&self, entry: &Entry) -> bool {
216          if let Some(actual_tags) = &entry.tags {
217              for wanted_tag in self.wanted_tags.iter() {
218                  if !actual_tags.contains(wanted_tag) {
219                      return false;
220                  }
221              }
222              true
223          } else {
224              false
225          }
226      }
227  }
228  
229  /// Like `search`, but output in mutt search format.
230  #[derive(Debug, Parser)]
231  struct MuttCommand {
232      word: String,
233  }
234  
235  impl MuttCommand {
236      fn run(&self, _opt: &Opt, book: &AddressBook) {
237          let matches: Vec<Entry> = book.iter().filter(|e| self.is_match(e)).cloned().collect();
238          if matches.is_empty() {
239              println!("clab found no matches");
240              std::process::exit(1);
241          }
242  
243          println!("clab found matches:");
244          for e in matches {
245              for email in e.emails() {
246                  println!("{}\t{}", email, e.name);
247              }
248          }
249      }
250  
251      fn is_match(&self, entry: &Entry) -> bool {
252          entry.is_match(&self.word)
253      }
254  }
255  
256  /// Reformat database.
257  #[derive(Debug, Parser)]
258  struct Reformat {
259      /// Write to the standard output. Default is to write back to normal location.
260      #[clap(short, long)]
261      stdout: bool,
262  }
263  
264  impl Reformat {
265      fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> {
266          let mut entries: Vec<Entry> = book.entries().to_vec();
267          entries.sort_by_cached_key(|e| e.name.clone());
268          if self.stdout {
269              serde_norway::to_writer(std::io::stdout(), &entries)?;
270          } else {
271              let filename = book.filename();
272              let dirname = match filename.parent() {
273                  None => Path::new("/"),
274                  Some(x) if x.display().to_string().is_empty() => Path::new("."),
275                  Some(x) => x,
276              };
277              let temp = NamedTempFile::new_in(dirname)?;
278              serde_norway::to_writer(&temp, &entries)?;
279              std::fs::rename(temp.path(), filename)?;
280          }
281          Ok(())
282      }
283  }