use directories_next::ProjectDirs; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use structopt::StructOpt; use tempfile::NamedTempFile; const APP: &str = "clab"; fn main() -> anyhow::Result<()> { let mut opt = Opt::from_args(); let book = if let Some(filename) = &opt.db { AddressBook::load(filename)? } else { let proj_dirs = ProjectDirs::from("", "", APP).expect("couldn't find home directory"); let filename = proj_dirs.data_dir().join("address-book.yaml"); opt.db = Some(filename.clone()); if filename.exists() { AddressBook::load(&filename)? } else { AddressBook::default() } }; match &opt.cmd { Cmd::Config(x) => x.run(&opt, &book), Cmd::Lint(x) => x.run(&opt, &book), Cmd::List(x) => x.run(&opt, &book)?, Cmd::Search(x) => x.run(&opt, &book)?, Cmd::Tagged(x) => x.run(&opt, &book)?, Cmd::MuttQuery(x) => x.run(&opt, &book), Cmd::Reformat(x) => x.run(&opt, &book)?, } Ok(()) } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Entry { name: String, #[serde(skip_serializing_if = "Option::is_none")] org: Option, #[serde(skip_serializing_if = "Option::is_none")] url: Option>, #[serde(skip_serializing_if = "Option::is_none")] notes: Option, #[serde(skip_serializing_if = "Option::is_none")] aliases: Option>, #[serde(skip_serializing_if = "Option::is_none")] email: Option>, #[serde(skip_serializing_if = "Option::is_none")] phone: Option>, #[serde(skip_serializing_if = "Option::is_none")] irc: Option>, #[serde(skip_serializing_if = "Option::is_none")] address: Option>, #[serde(skip_serializing_if = "Option::is_none")] tags: Option>, last_checked: String, } impl Entry { fn is_match(&self, needle: &str) -> bool { let text = serde_yaml::to_string(self).unwrap(); contains(&text, needle) } fn emails(&self) -> Vec { if let Some(map) = &self.email { map.values().map(|x| x.to_string()).collect() } else { vec![] } } } fn output_entries(entries: &[Entry]) -> anyhow::Result<()> { if !entries.is_empty() { serde_yaml::to_writer(std::io::stdout(), entries)?; } Ok(()) } fn contains(haystack: &str, needle: &str) -> bool { let haystack = haystack.to_lowercase(); let needle = needle.to_lowercase(); haystack.contains(&needle) } #[derive(std::default::Default)] struct AddressBook { filename: PathBuf, entries: Vec, } impl AddressBook { fn load(db: &Path) -> anyhow::Result { let mut book = Self { filename: db.to_path_buf(), entries: vec![], }; book.add_from(db)?; Ok(book) } fn filename(&self) -> &Path { &self.filename } fn add_from(&mut self, filename: &Path) -> anyhow::Result<()> { let text = std::fs::read(filename)?; let mut entries: Vec = serde_yaml::from_slice(&text)?; self.entries.append(&mut entries); Ok(()) } fn entries(&self) -> &[Entry] { &self.entries } fn iter(&self) -> impl Iterator { self.entries.iter() } } #[derive(Debug, StructOpt)] struct Opt { #[structopt(long, parse(from_os_str))] db: Option, #[structopt(subcommand)] cmd: Cmd, } #[derive(Debug, StructOpt)] enum Cmd { Config(ConfigCommand), Lint(LintCommand), List(ListCommand), Search(SearchCommand), Tagged(TaggedCommand), MuttQuery(MuttCommand), Reformat(Reformat), } #[derive(Debug, StructOpt)] struct ConfigCommand {} impl ConfigCommand { fn run(&self, opt: &Opt, _book: &AddressBook) { println!("{:#?}", opt); } } #[derive(Debug, StructOpt)] struct LintCommand {} impl LintCommand { fn run(&self, _opt: &Opt, _book: &AddressBook) {} } #[derive(Debug, StructOpt)] struct ListCommand {} impl ListCommand { fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { output_entries(book.entries()) } } #[derive(Debug, StructOpt)] #[structopt(alias = "find")] struct SearchCommand { #[structopt()] words: Vec, } impl SearchCommand { fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { let matches: Vec = book.iter().filter(|e| self.is_match(e)).cloned().collect(); output_entries(&matches) } fn is_match(&self, entry: &Entry) -> bool { for word in self.words.iter() { if !entry.is_match(word) { return false; } } true } } #[derive(Debug, StructOpt)] #[structopt(alias = "find")] struct TaggedCommand { #[structopt()] wanted_tags: Vec, } impl TaggedCommand { fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { let matches: Vec = book.iter().filter(|e| self.is_match(e)).cloned().collect(); output_entries(&matches) } fn is_match(&self, entry: &Entry) -> bool { if let Some(actual_tags) = &entry.tags { for wanted_tag in self.wanted_tags.iter() { if !actual_tags.contains(wanted_tag) { return false; } } true } else { false } } } #[derive(Debug, StructOpt)] struct MuttCommand { #[structopt()] word: String, } impl MuttCommand { fn run(&self, _opt: &Opt, book: &AddressBook) { let matches: Vec = book.iter().filter(|e| self.is_match(e)).cloned().collect(); if matches.is_empty() { println!("clab found no matches"); std::process::exit(1); } println!("clab found matches:"); for e in matches { for email in e.emails() { println!("{}\t{}", email, e.name); } } } fn is_match(&self, entry: &Entry) -> bool { entry.is_match(&self.word) } } #[derive(Debug, StructOpt)] struct Reformat { #[structopt(short, long)] stdout: bool, } impl Reformat { fn run(&self, _opt: &Opt, book: &AddressBook) -> anyhow::Result<()> { let mut entries: Vec = book.entries().to_vec(); entries.sort_by_cached_key(|e| e.name.clone()); if self.stdout { serde_yaml::to_writer(std::io::stdout(), &entries)?; } else { let filename = book.filename(); let dirname = match filename.parent() { None => Path::new("/"), Some(x) if x.display().to_string().is_empty() => Path::new("."), Some(x) => x, }; let temp = NamedTempFile::new_in(dirname)?; serde_yaml::to_writer(&temp, &entries)?; std::fs::rename(temp.path(), filename)?; } Ok(()) } }