1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::io::{self, IsTerminal, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, bail};
9use clap::{Parser, Subcommand};
10
11use crate::model::{CommandBody, Entry, Layer, ShellFamily};
12use crate::search::{self, Candidate};
13use crate::shell::chord::{self, Chord};
14use crate::shell::{self, Shell};
15use crate::store::definitions::{self, NewEntry, Written};
16use crate::store::stats::{self, Stats};
17use crate::store::{self};
18use crate::sync;
19use crate::tui::{App, Outcome};
20use crate::update;
21
22#[derive(Parser)]
24#[command(name = "lore", version, about, allow_external_subcommands = true)]
27pub struct Cli {
28 #[command(subcommand)]
29 command: Command,
30}
31
32#[derive(Subcommand)]
33enum Command {
34 Init {
36 shell: Shell,
37
38 #[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
40 key: Chord,
41 },
42
43 Setup {
45 #[arg(long)]
47 shell: Option<Shell>,
48
49 #[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
51 key: Chord,
52
53 #[arg(long, short = 'y')]
55 yes: bool,
56 },
57
58 Uninstall {
60 #[arg(long)]
62 shell: Option<Shell>,
63 },
64
65 Pick {
67 #[arg(long)]
69 shell: Option<Shell>,
70
71 #[arg(long)]
76 print_cursor: bool,
77
78 #[arg(long)]
85 output: Option<PathBuf>,
86
87 #[arg(long)]
94 history: Option<PathBuf>,
95
96 #[arg(long)]
101 line: Option<PathBuf>,
102 },
103
104 Save {
106 command: String,
107
108 #[arg(long)]
111 desc: Option<String>,
112
113 #[arg(long)]
115 tags: Option<String>,
116 },
117
118 Edit {
120 id: String,
121
122 #[arg(long)]
124 shell: Option<Shell>,
125
126 #[arg(long)]
127 cmd: Option<String>,
128
129 #[arg(long)]
130 desc: Option<String>,
131
132 #[arg(long)]
134 tags: Option<String>,
135 },
136
137 #[command(alias = "remove")]
139 Rm { id: String },
140
141 List {
143 #[arg(long)]
145 shell: Option<Shell>,
146 },
147
148 Find {
150 #[arg(required = true)]
152 words: Vec<String>,
153
154 #[arg(long, short = '1')]
156 first: bool,
157
158 #[arg(long, short = 'a')]
160 all: bool,
161
162 #[arg(long)]
164 shell: Option<Shell>,
165 },
166
167 Version,
169
170 #[command(external_subcommand)]
172 Search(Vec<String>),
173
174 #[command(hide = true)]
176 CheckUpdate {
177 #[arg(long)]
178 background: bool,
179 },
180
181 Sync {
184 #[command(subcommand)]
185 action: Option<SyncAction>,
186
187 #[arg(long, hide = true)]
190 background: bool,
191 },
192}
193
194#[derive(Subcommand)]
195enum SyncAction {
196 Init {
200 url: Option<String>,
202 },
203
204 Status,
206
207 Disconnect,
209}
210
211impl Cli {
212 pub fn run(self) -> Result<()> {
213 match self.command {
214 Command::Init { shell, key } => {
215 let mut out = io::stdout().lock();
216 out.write_all(shell::snippet(shell, key).as_bytes())?;
217 out.flush()?;
218 Ok(())
219 }
220 Command::Setup { shell, key, yes } => shell::install(resolve(shell)?, key, yes),
221 Command::Uninstall { shell } => shell::uninstall(resolve(shell)?),
222 Command::Pick {
223 shell,
224 print_cursor,
225 output,
226 history,
227 line,
228 } => pick(
229 family(shell),
230 print_cursor,
231 output.as_deref(),
232 history.as_deref(),
233 line.as_deref(),
234 ),
235 Command::Save {
236 command,
237 desc,
238 tags,
239 } => save(command, desc, tags),
240 Command::Edit {
241 id,
242 shell,
243 cmd,
244 desc,
245 tags,
246 } => edit(id, family(shell), cmd, desc, tags),
247 Command::Rm { id } => remove(id),
248 Command::List { shell } => list(family(shell)),
249 Command::Sync { action, background } => run_sync(action, background),
250 Command::Find {
251 words,
252 first,
253 all,
254 shell,
255 } => find(&words.join(" "), first, all, family(shell)),
256 Command::Search(words) => find(&words.join(" "), false, false, family(None)),
259 Command::Version => {
260 println!("{}", update::status());
261 Ok(())
262 }
263 Command::CheckUpdate { background } => {
264 let found = update::check();
265 match (found, background) {
268 (_, true) => Ok(()),
269 (Ok(Some(version)), false) => {
270 println!("The newest release is {version}");
271 Ok(())
272 }
273 (Ok(None), false) => {
274 println!("Could not tell what the newest release is");
275 Ok(())
276 }
277 (Err(error), false) => Err(error),
278 }
279 }
280 }
281 }
282}
283
284fn run_sync(action: Option<SyncAction>, background: bool) -> Result<()> {
285 match action {
286 Some(SyncAction::Init { url }) => println!("{}", sync::init(url)?.summary()),
287 Some(SyncAction::Status) => sync::status()?,
288 Some(SyncAction::Disconnect) => sync::disconnect()?,
289 None if background => {
292 let _ = sync::run(sync::Mode::Background);
293 }
294 None => println!("{}", sync::run(sync::Mode::Interactive)?.summary()),
295 }
296 Ok(())
297}
298
299fn resolve(shell: Option<Shell>) -> Result<Shell> {
300 match shell.or_else(shell::detect) {
301 Some(shell) => Ok(shell),
302 None => bail!("could not tell which shell you are using, pass --shell"),
303 }
304}
305
306fn family(shell: Option<Shell>) -> ShellFamily {
307 shell
308 .or_else(shell::detect)
309 .map(ShellFamily::from)
310 .unwrap_or(ShellFamily::Posix)
311}
312
313fn pick(
327 family: ShellFamily,
328 print_cursor: bool,
329 output: Option<&Path>,
330 history: Option<&Path>,
331 line: Option<&Path>,
332) -> Result<()> {
333 let library = store::user_library()?;
334 let entries = definitions::load(Some(&library))?;
335 let stats = Stats::open(&store::stats_database()?)?;
336
337 let typed = line.and_then(read_line);
341 let mut history = read_history(history);
342 if let Some(typed) = &typed {
343 history.insert(0, typed.clone());
344 }
345 let mut app = App::new(entries, family, stats, library, history, stats::now())?;
346 if let Some(typed) = typed {
347 app.search(typed);
348 }
349 if let Some(error) = sync::last_error() {
352 app.notice(format!("Sync failed: {error}. Run lore sync"));
353 } else if let Some(update) = update::notice() {
354 app.notice(update);
355 }
356 sync::refresh_if_stale();
357 update::refresh_in_background();
358
359 let outcome = crate::tui::run(&mut app)?;
360 if app.changed() {
361 sync::spawn();
362 }
363
364 let Outcome::Insert { command, cursor } = outcome else {
365 return Ok(());
366 };
367
368 let mut result = String::new();
369 if print_cursor {
370 let offset = cursor.unwrap_or(command.chars().count());
371 result.push_str(&format!("{offset}\n"));
372 }
373 result.push_str(&command);
374 result.push('\n');
375
376 match output {
377 Some(path) => fs::write(path, result)
378 .with_context(|| format!("failed to write {}", path.display()))?,
379 None => {
380 let mut out = io::stdout().lock();
381 out.write_all(result.as_bytes())?;
382 out.flush()?;
383 }
384 }
385
386 Ok(())
387}
388
389fn read_line(path: &Path) -> Option<String> {
391 let typed = fs::read_to_string(path).unwrap_or_default();
392 let typed = typed.trim();
393 (!typed.is_empty()).then(|| typed.to_string())
394}
395
396fn read_history(path: Option<&Path>) -> Vec<String> {
401 let Some(path) = path else {
402 return Vec::new();
403 };
404
405 fs::read_to_string(path)
406 .unwrap_or_default()
407 .lines()
408 .map(str::to_string)
409 .collect()
410}
411
412fn save(command: String, desc: Option<String>, tags: Option<String>) -> Result<()> {
413 let command = command.trim().to_string();
414 if command.is_empty() {
415 bail!("nothing to save, the command is empty");
416 }
417
418 let purpose = match desc {
419 Some(desc) => desc,
420 None => ask("What is it for? ")?,
421 };
422 let (desc, mut given) = definitions::split_purpose(&purpose);
423 if desc.is_empty() {
424 bail!("say what the command is for, so you can find it later");
425 }
426 for tag in definitions::parse_tags(&tags.unwrap_or_default()) {
427 if !given.contains(&tag) {
428 given.push(tag);
429 }
430 }
431
432 let library = store::user_library()?;
433 let taken: BTreeSet<String> = definitions::load(Some(&library))?
434 .into_iter()
435 .map(|entry| entry.id)
436 .collect();
437
438 let entry = NewEntry {
439 id: definitions::suggest_id(&command, &taken),
440 tags: definitions::merge_tags(given, &command),
441 cmd: CommandBody::Shared(command),
442 desc,
443 params: BTreeMap::new(),
444 danger: false,
445 };
446
447 let stats = Stats::open(&store::stats_database()?)?;
448 stats.record_new(&entry.id, stats::now())?;
449 definitions::append(&library, &entry)?;
450
451 println!("Saved as {} in {}", entry.id, library.display());
452 sync::spawn();
453 Ok(())
454}
455
456fn ask(question: &str) -> Result<String> {
461 if !io::stdin().is_terminal() {
462 bail!("pass --desc to say what the command is for");
463 }
464
465 print!("{question}");
466 io::stdout().flush()?;
467
468 let mut answer = String::new();
469 io::stdin().read_line(&mut answer)?;
470 Ok(answer.trim().to_string())
471}
472
473fn edit(
478 id: String,
479 family: ShellFamily,
480 cmd: Option<String>,
481 desc: Option<String>,
482 tags: Option<String>,
483) -> Result<()> {
484 if cmd.is_none() && desc.is_none() && tags.is_none() {
485 bail!("nothing to change, pass at least one of --cmd, --desc or --tags");
486 }
487
488 let library = store::user_library()?;
489 let entries = definitions::load(Some(&library))?;
490 let Some(entry) = entries.iter().find(|entry| entry.id == id) else {
491 bail!("no command with the id {id}");
492 };
493
494 let body = match (&entry.cmd, cmd) {
497 (_, None) => entry.cmd.clone(),
498 (CommandBody::Shared(_), Some(cmd)) => CommandBody::Shared(cmd),
499 (CommandBody::PerShell(variants), Some(cmd)) => {
500 let mut variants = variants.clone();
501 variants.insert(family, cmd);
502 CommandBody::PerShell(variants)
503 }
504 };
505
506 let edited = NewEntry {
507 id: id.clone(),
508 cmd: body,
509 desc: desc.unwrap_or_else(|| entry.desc.clone()),
510 tags: tags
511 .map(|tags| definitions::parse_tags(&tags))
512 .unwrap_or_else(|| entry.tags.clone()),
513 params: entry.params.clone(),
514 danger: entry.danger,
515 };
516
517 match definitions::upsert(&library, &edited)? {
518 Written::Replaced => println!("Updated {id} in {}", library.display()),
519 Written::Appended => println!(
520 "Saved {id} to {}, overriding the builtin",
521 library.display()
522 ),
523 }
524
525 sync::spawn();
526 Ok(())
527}
528
529fn remove(id: String) -> Result<()> {
535 let library = store::user_library()?;
536 let entries = definitions::load(Some(&library))?;
537 let Some(entry) = entries.iter().find(|entry| entry.id == id) else {
538 bail!("no command with the id {id}");
539 };
540
541 if entry.layer == Layer::User {
542 definitions::remove(&library, &id)?;
543 println!("Removed {id} from {}", library.display());
544 } else {
545 definitions::disable(&library, &id)?;
546 println!("Hid {id}, listed under disabled in {}", library.display());
547 }
548
549 Stats::open(&store::stats_database()?)?.forget(&id)?;
550 sync::spawn();
551 Ok(())
552}
553
554fn showing(total: usize, all: bool, to_a_terminal: bool) -> usize {
568 const CAP: usize = 10;
570
571 if all || !to_a_terminal {
572 total
573 } else {
574 total.min(CAP)
575 }
576}
577
578fn find(query: &str, first: bool, all: bool, family: ShellFamily) -> Result<()> {
579 let library = store::user_library().ok();
580 let entries = definitions::load(library.as_deref())?;
581 let stats = Stats::open(&store::stats_database()?).ok();
582 let scores = stats
583 .map(|stats| stats.scores(stats::now()))
584 .transpose()?
585 .unwrap_or_default();
586
587 let candidates: Vec<Candidate<'_>> = entries
588 .iter()
589 .filter_map(|entry| entry.cmd_for(family).map(|cmd| Candidate { entry, cmd }))
590 .collect();
591
592 let ranked = search::rank(&candidates, &scores, query);
593 let Some(&best) = ranked.first() else {
594 bail!("nothing in your library matches `{query}`");
595 };
596
597 let mut out = io::BufWriter::new(io::stdout().lock());
598
599 if first {
601 writeln!(out, "{}", candidates[best].cmd)?;
602 return Ok(out.flush()?);
603 }
604
605 let showing = showing(ranked.len(), all, io::stdout().is_terminal());
609
610 let width = ranked
611 .iter()
612 .take(showing)
613 .map(|&index| candidates[index].entry.id.chars().count())
614 .max()
615 .unwrap_or(0);
616
617 for &index in ranked.iter().take(showing) {
618 let candidate = candidates[index];
619 writeln!(
620 out,
621 "{:width$} {}",
622 candidate.entry.id,
623 candidate.cmd,
624 width = width
625 )?;
626 writeln!(
627 out,
628 "{:width$} {}",
629 "",
630 candidate.entry.desc,
631 width = width
632 )?;
633 }
634
635 let hidden = ranked.len() - showing;
636 if hidden > 0 {
637 writeln!(
638 out,
639 "\n{hidden} more. Add a word to narrow it, or pass --all"
640 )?;
641 }
642
643 Ok(out.flush()?)
644}
645
646fn list(family: ShellFamily) -> Result<()> {
647 let library = store::user_library().ok();
648 let entries = definitions::load(library.as_deref())?;
649
650 let mut out = io::BufWriter::new(io::stdout().lock());
651 for entry in entries.iter().filter(|e| e.cmd_for(family).is_some()) {
652 print(&mut out, entry, family)?;
653 }
654 out.flush()?;
655
656 Ok(())
657}
658
659fn print(out: &mut impl Write, entry: &Entry, family: ShellFamily) -> io::Result<()> {
660 let cmd = entry.cmd_for(family).expect("caller filtered on this");
661 let danger = if entry.danger { " [destructive]" } else { "" };
662
663 writeln!(out, "{}{danger}", entry.id)?;
664 writeln!(out, " {}", entry.desc)?;
665 writeln!(out, " {cmd}")?;
666
667 for name in crate::params::names(cmd) {
668 let desc = entry
669 .params
670 .get(&name)
671 .and_then(|spec| spec.desc.as_deref())
672 .unwrap_or("no description");
673 writeln!(out, " <{name}> {desc}")?;
674 }
675
676 if !entry.tags.is_empty() {
677 writeln!(out, " tags: {}", entry.tags.join(", "))?;
678 }
679
680 writeln!(out)
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 #[test]
690 fn matches_are_capped_for_a_person_and_never_for_a_pipeline() {
691 assert_eq!(showing(40, false, true), 10);
692 assert_eq!(showing(3, false, true), 3);
693 assert_eq!(showing(40, true, true), 40, "--all was ignored");
694 assert_eq!(showing(40, false, false), 40, "a pipeline was cut short");
695 }
696
697 fn history_of(arguments: &[&str]) -> Option<PathBuf> {
698 match Cli::try_parse_from(arguments)
699 .expect("arguments should parse")
700 .command
701 {
702 Command::Pick { history, .. } => history,
703 _ => panic!("expected pick"),
704 }
705 }
706
707 #[test]
708 fn omitting_the_history_is_allowed() {
709 assert!(history_of(&["lore", "pick", "--shell", "powershell"]).is_none());
710 assert!(read_history(None).is_empty());
711 }
712
713 #[test]
717 fn a_history_file_carries_commands_an_argument_list_cannot() {
718 let path = std::env::temp_dir().join(format!("lore-history-{}.txt", std::process::id()));
719 let written = "cd C:\\projects\\\ngit commit -m \"fix the thing\"\n-Verbose\n";
720 fs::write(&path, written).unwrap();
721
722 assert_eq!(
723 history_of(&[
724 "lore",
725 "pick",
726 "--shell",
727 "powershell",
728 "--history",
729 path.to_str().unwrap()
730 ]),
731 Some(path.clone())
732 );
733 assert_eq!(
734 read_history(Some(&path)),
735 vec![
736 "cd C:\\projects\\".to_string(),
737 "git commit -m \"fix the thing\"".to_string(),
738 "-Verbose".to_string(),
739 ]
740 );
741
742 let _ = fs::remove_file(&path);
743 }
744
745 #[test]
746 fn an_unreadable_history_leaves_the_picker_openable() {
747 assert!(read_history(Some(Path::new("no-such-file-anywhere"))).is_empty());
748 }
749}