git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/main.rs (16.1K)

  1 //! `cce-vault`: the vault index from a shell — for scripts, agents and
  2 //! shadow tests, and the quickest way to check what the apps will see.
  3 
  4 use std::path::PathBuf;
  5 use std::process::ExitCode;
  6 
  7 use cce_vault::{config, search, FileKind, Index};
  8 use chrono::{Duration, Local, NaiveDate};
  9 use serde_json::{json, Value};
 10 
 11 const HELP: &str = "\
 12 cce-vault — query and edit the notes vault
 13 
 14 usage: cce-vault [--vault DIR] [--json] [--cache] <command> [args]
 15 
 16   path                        the vault root
 17   stats                       files, links, tags and tasks; index timing
 18   find <query>                fuzzy-match note names, aliases and paths
 19   search <query>              full text; every word must match, \"quote phrases\"
 20   resolve <link> [--from N]   the file link text reaches (from note N)
 21   links <note>                outgoing links and where each resolves
 22   backlinks <note>            links pointing at the note
 23   mentions <note>             unlinked mentions of the note's name or aliases
 24   unresolved                  links that reach no file
 25   tags [tag]                  tag counts, or the notes carrying a tag
 26   tasks [--done|--all] [note] open tasks (vault-wide, or one note)
 27   properties <note>           frontmatter as JSON
 28   daily [date] [--create]     the daily note's path; date is YYYY-MM-DD,
 29                               today, yesterday, tomorrow, or +N / -N days
 30   rename <note> <to> [--dry-run]
 31                               move a file and rewrite every link to it; a
 32                               bare new name stays in the same folder
 33   watch                       apply and print changes as they happen
 34 
 35 The vault is --vault, else $CCE_VAULT, else `vault { path \"…\" }` in
 36 ~/.config/cce/config.kdl. Lines are printed 1-based. --cache reuses (and
 37 writes) the parse cache under ~/.cache/cce/vault; off by default, since a
 38 parallel parse of a warm vault is faster than loading it.
 39 ";
 40 
 41 struct Opts {
 42     vault: Option<PathBuf>,
 43     json: bool,
 44     cache: bool,
 45     args: Vec<String>,
 46 }
 47 
 48 fn parse_opts() -> Result<Opts, String> {
 49     let mut opts = Opts { vault: None, json: false, cache: false, args: Vec::new() };
 50     let mut it = std::env::args().skip(1);
 51     while let Some(a) = it.next() {
 52         match a.as_str() {
 53             "--vault" => opts.vault = Some(it.next().ok_or("--vault needs a directory")?.into()),
 54             "--json" => opts.json = true,
 55             "--cache" => opts.cache = true,
 56             "-h" | "--help" | "help" => opts.args = vec!["help".into()],
 57             _ => opts.args.push(a),
 58         }
 59     }
 60     Ok(opts)
 61 }
 62 
 63 /// Pull `--flag` out of the positional args.
 64 fn take_flag(args: &mut Vec<String>, flag: &str) -> bool {
 65     let before = args.len();
 66     args.retain(|a| a != flag);
 67     args.len() != before
 68 }
 69 
 70 fn take_value(args: &mut Vec<String>, flag: &str) -> Option<String> {
 71     let i = args.iter().position(|a| a == flag)?;
 72     args.remove(i);
 73     (i < args.len()).then(|| args.remove(i))
 74 }
 75 
 76 fn main() -> ExitCode {
 77     env_logger_lite();
 78     let opts = match parse_opts() {
 79         Ok(o) => o,
 80         Err(e) => return fail(&e),
 81     };
 82     if opts.args.is_empty() || opts.args[0] == "help" {
 83         print!("{HELP}");
 84         return ExitCode::SUCCESS;
 85     }
 86     let root = match config::vault_root(opts.vault.as_deref()) {
 87         Ok(r) => r,
 88         Err(e) => return fail(&e.to_string()),
 89     };
 90     let mut index = match Index::open(&root, opts.cache) {
 91         Ok(i) => i,
 92         Err(e) => return fail(&format!("{}: {e}", root.display())),
 93     };
 94     let result = run(&mut index, &opts);
 95     if opts.cache {
 96         if let Err(e) = index.save_cache() {
 97             log_warn(&format!("could not write the index cache: {e}"));
 98         }
 99     }
100     match result {
101         Ok(()) => ExitCode::SUCCESS,
102         Err(e) => fail(&e),
103     }
104 }
105 
106 fn fail(msg: &str) -> ExitCode {
107     eprintln!("cce-vault: {msg}");
108     ExitCode::FAILURE
109 }
110 
111 fn log_warn(msg: &str) {
112     eprintln!("cce-vault: warning: {msg}");
113 }
114 
115 /// `RUST_LOG=debug` shows the crate's log lines on stderr without pulling
116 /// in a logging stack for a CLI.
117 fn env_logger_lite() {
118     struct Stderr(log::LevelFilter);
119     impl log::Log for Stderr {
120         fn enabled(&self, m: &log::Metadata) -> bool {
121             m.level() <= self.0
122         }
123         fn log(&self, r: &log::Record) {
124             if self.enabled(r.metadata()) {
125                 eprintln!("[{}] {}", r.level(), r.args());
126             }
127         }
128         fn flush(&self) {}
129     }
130     let level = match std::env::var("RUST_LOG").unwrap_or_default().as_str() {
131         "trace" => log::LevelFilter::Trace,
132         "debug" => log::LevelFilter::Debug,
133         "info" => log::LevelFilter::Info,
134         _ => log::LevelFilter::Warn,
135     };
136     let _ = log::set_logger(Box::leak(Box::new(Stderr(level)))).map(|_| log::set_max_level(level));
137 }
138 
139 /// A note named on the command line, or an error that suggests names.
140 fn note_arg(index: &Index, arg: Option<&String>) -> Result<String, String> {
141     let q = arg.ok_or("which note?")?;
142     if let Some(p) = index.lookup(q) {
143         return Ok(p);
144     }
145     let near: Vec<String> = index.find(q, 3).into_iter().map(|m| m.path).collect();
146     if near.is_empty() {
147         Err(format!("no note matches {q:?}"))
148     } else {
149         Err(format!("no note matches {q:?}; did you mean: {}", near.join(", ")))
150     }
151 }
152 
153 fn out_json(v: Value) {
154     println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
155 }
156 
157 fn run(index: &mut Index, opts: &Opts) -> Result<(), String> {
158     let mut args = opts.args.clone();
159     let cmd = args.remove(0);
160     let json = opts.json;
161     match cmd.as_str() {
162         "path" => println!("{}", index.root().display()),
163 
164         "stats" => {
165             let count = |k| index.files().values().filter(|e| e.kind == k).count();
166             let links: usize = index.documents().map(|(_, n)| n.links.len()).sum();
167             let unresolved: usize = index.unresolved().values().map(Vec::len).sum();
168             let tasks: Vec<_> = index.tasks().collect();
169             let open = tasks.iter().filter(|(_, t)| t.is_open()).count();
170             let rows: Vec<(&str, Value)> = vec![
171                 ("root", json!(index.root())),
172                 ("notes", json!(count(FileKind::Note))),
173                 ("canvases", json!(count(FileKind::Canvas))),
174                 ("attachments", json!(count(FileKind::Attachment))),
175                 ("links", json!(links)),
176                 ("unresolved_links", json!(unresolved)),
177                 ("tags", json!(index.tags().len())),
178                 ("tasks", json!(tasks.len())),
179                 ("open_tasks", json!(open)),
180                 ("index.parsed", json!(index.stats.parsed)),
181                 ("index.reused", json!(index.stats.reused)),
182                 ("index.millis", json!(index.stats.millis)),
183             ];
184             if json {
185                 out_json(Value::Object(rows.into_iter().map(|(k, v)| (k.to_string(), v)).collect()));
186             } else {
187                 for (k, v) in rows {
188                     match v {
189                         Value::String(s) => println!("{k:18} {s}"),
190                         other => println!("{k:18} {other}"),
191                     }
192                 }
193             }
194         }
195 
196         "find" => {
197             let q = args.join(" ");
198             let hits = index.find(&q, 20);
199             if json {
200                 out_json(json!(hits));
201             } else {
202                 for h in hits {
203                     if h.matched == cce_vault::index::stem(&h.path) || h.matched == h.path {
204                         println!("{}", h.path);
205                     } else {
206                         println!("{}  (as {:?})", h.path, h.matched);
207                     }
208                 }
209             }
210         }
211 
212         "search" => {
213             let q = args.join(" ");
214             let hits = index.search(&q, 50);
215             print_hits(&hits, json);
216         }
217 
218         "resolve" => {
219             let from = match take_value(&mut args, "--from") {
220                 Some(f) => Some(note_arg(index, Some(&f))?),
221                 None => None,
222             };
223             let text = args.join(" ");
224             let hit = index.resolve_text(from.as_deref(), &text);
225             if json {
226                 out_json(json!({ "link": text, "from": from, "path": hit }));
227             } else {
228                 println!("{}", hit.ok_or_else(|| format!("{text:?} reaches no file"))?);
229             }
230         }
231 
232         "links" => {
233             let path = note_arg(index, args.first())?;
234             let out = index.outgoing(&path);
235             if json {
236                 out_json(json!(out
237                     .iter()
238                     .map(|(l, t)| json!({ "line": l.line + 1, "link": l, "resolved": t }))
239                     .collect::<Vec<_>>()));
240             } else {
241                 for (l, t) in out {
242                     let sub = l.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
243                     let to = t.map(String::from).unwrap_or_else(|| "(unresolved)".into());
244                     println!("{path}:{}: {}{sub} -> {to}", l.line + 1, l.target);
245                 }
246             }
247         }
248 
249         "backlinks" => {
250             let path = note_arg(index, args.first())?;
251             let bl = index.backlinks(&path);
252             if json {
253                 out_json(json!(bl
254                     .iter()
255                     .map(|b| json!({ "source": b.source, "line": b.link.line + 1, "link": b.link }))
256                     .collect::<Vec<_>>()));
257             } else {
258                 for b in bl {
259                     let sub = b.link.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
260                     let node = b.link.node.as_ref().map(|n| format!(" (node {n})")).unwrap_or_default();
261                     println!("{}:{}: {}{sub}{node}", b.source, b.link.line + 1, b.link.target);
262                 }
263             }
264         }
265 
266         "mentions" => {
267             let path = note_arg(index, args.first())?;
268             print_hits(&index.unlinked_mentions(&path), json);
269         }
270 
271         "unresolved" => {
272             let un = index.unresolved();
273             if json {
274                 out_json(json!(un
275                     .iter()
276                     .map(|(t, bl)| (
277                         t.clone(),
278                         json!(bl.iter().map(|b| json!({ "source": b.source, "line": b.link.line + 1 })).collect::<Vec<_>>())
279                     ))
280                     .collect::<serde_json::Map<_, _>>()));
281             } else {
282                 for (target, bl) in un {
283                     let from: Vec<String> = bl.iter().map(|b| format!("{}:{}", b.source, b.link.line + 1)).collect();
284                     println!("{target}  <- {}", from.join(", "));
285                 }
286             }
287         }
288 
289         "tags" => match args.first() {
290             Some(tag) => {
291                 let notes = index.tagged(tag);
292                 if json {
293                     out_json(json!(notes));
294                 } else {
295                     notes.iter().for_each(|n| println!("{n}"));
296                 }
297             }
298             None => {
299                 let tags = index.tags();
300                 if json {
301                     out_json(json!(tags.iter().map(|(t, n)| (t.clone(), json!(n))).collect::<serde_json::Map<_, _>>()));
302                 } else {
303                     for (t, n) in tags {
304                         println!("{n:5} #{t}");
305                     }
306                 }
307             }
308         },
309 
310         "tasks" => {
311             let done = take_flag(&mut args, "--done");
312             let all = take_flag(&mut args, "--all");
313             let only = match args.first() {
314                 Some(a) => Some(note_arg(index, Some(a))?),
315                 None => None,
316             };
317             let tasks: Vec<_> = index
318                 .tasks()
319                 .filter(|(p, _)| only.as_deref().is_none_or(|o| o == *p))
320                 .filter(|(_, t)| all || (t.is_open() != done))
321                 .collect();
322             if json {
323                 out_json(json!(tasks
324                     .iter()
325                     .map(|(p, t)| json!({ "path": p, "line": t.line + 1, "status": t.status.to_string(), "text": t.text }))
326                     .collect::<Vec<_>>()));
327             } else {
328                 for (p, t) in tasks {
329                     println!("{p}:{}: [{}] {}", t.line + 1, t.status, t.text);
330                 }
331             }
332         }
333 
334         "properties" => {
335             let path = note_arg(index, args.first())?;
336             let props = index.note(&path).map(|n| n.properties.clone()).unwrap_or_default();
337             println!("{}", serde_json::to_string_pretty(&props).unwrap_or_default());
338         }
339 
340         "daily" => {
341             let create = take_flag(&mut args, "--create");
342             let date = parse_date(args.first().map(String::as_str).unwrap_or("today"))?;
343             let (path, created) = index.daily(date, create).map_err(|e| e.to_string())?;
344             if json {
345                 let exists = index.entry(&path).is_some();
346                 out_json(json!({ "path": path, "date": date.to_string(), "exists": exists, "created": created }));
347             } else {
348                 println!("{path}");
349             }
350         }
351 
352         "rename" => {
353             let dry = take_flag(&mut args, "--dry-run");
354             let from = note_arg(index, args.first())?;
355             let to_arg = args.get(1).ok_or("rename to what?")?;
356             let mut to = if to_arg.contains('/') {
357                 to_arg.trim_start_matches('/').to_string()
358             } else {
359                 match cce_vault::index::parent(&from) {
360                     "" => to_arg.clone(),
361                     dir => format!("{dir}/{to_arg}"),
362                 }
363             };
364             // `rename Old New` for a note means New.md.
365             if FileKind::of(&from) == FileKind::Note && FileKind::of(&to) != FileKind::Note {
366                 to.push_str(".md");
367             }
368             let plan = if dry { index.plan_rename(&from, &to) } else { index.rename(&from, &to) }
369                 .map_err(|e| e.to_string())?;
370             if json {
371                 out_json(json!({ "dry_run": dry, "plan": plan }));
372             } else {
373                 println!("{}{} -> {}", if dry { "would move " } else { "moved " }, plan.from, plan.to);
374                 for e in &plan.edits {
375                     println!("  {}:{}: {} -> {}", e.path, e.line + 1, e.old, e.new);
376                 }
377                 println!("{} link{} {}", plan.edits.len(), if plan.edits.len() == 1 { "" } else { "s" },
378                     if dry { "would change" } else { "rewritten" });
379             }
380         }
381 
382         "watch" => {
383             let (tx, rx) = std::sync::mpsc::channel();
384             let _w = cce_vault::VaultWatcher::spawn(index.root(), move |batch| {
385                 let _ = tx.send(batch);
386             })
387             .map_err(|e| e.to_string())?;
388             eprintln!("watching {} (ctrl-c to stop)", index.root().display());
389             for batch in rx {
390                 let ch = index.apply_changes(&batch);
391                 if ch.is_empty() {
392                     continue;
393                 }
394                 if json {
395                     println!("{}", json!(ch));
396                 } else {
397                     ch.updated.iter().for_each(|p| println!("updated {p}"));
398                     ch.removed.iter().for_each(|p| println!("removed {p}"));
399                 }
400                 if opts.cache {
401                     let _ = index.save_cache();
402                 }
403             }
404         }
405 
406         other => return Err(format!("unknown command {other:?} (try --help)")),
407     }
408     Ok(())
409 }
410 
411 fn print_hits(hits: &[search::FileHits], json: bool) {
412     if json {
413         out_json(json!(hits));
414         return;
415     }
416     for h in hits {
417         for l in &h.lines {
418             println!("{}:{}: {}", h.path, l.line + 1, l.text);
419         }
420         if h.total > h.lines.len() {
421             println!("{}: … {} more", h.path, h.total - h.lines.len());
422         }
423     }
424 }
425 
426 fn parse_date(s: &str) -> Result<NaiveDate, String> {
427     let today = Local::now().date_naive();
428     match s {
429         "today" => Ok(today),
430         "yesterday" => Ok(today - Duration::days(1)),
431         "tomorrow" => Ok(today + Duration::days(1)),
432         _ if s.starts_with('+') || s.starts_with('-') => s
433             .parse::<i64>()
434             .map(|n| today + Duration::days(n))
435             .map_err(|_| format!("not a day offset: {s}")),
436         _ => NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}")),
437     }
438 }