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 }