Skip to main content

lore/
cli.rs

1//! Command line surface.
2
3use 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/// A command library that lives in your shell.
23#[derive(Parser)]
24// Words that are not a command are a search, so a half remembered command is
25// answered rather than refused, and a mistyped one searches instead of failing.
26#[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    /// Print the shell integration snippet for the given shell.
35    Init {
36        shell: Shell,
37
38        /// Key that opens the picker, written as ctrl-g or alt-r.
39        #[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
40        key: Chord,
41    },
42
43    /// Install the shell integration into the active shell profile.
44    Setup {
45        /// Shell to set up. Detected from the environment when omitted.
46        #[arg(long)]
47        shell: Option<Shell>,
48
49        /// Key that opens the picker, written as ctrl-g or alt-r.
50        #[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
51        key: Chord,
52
53        /// Do not ask before writing to a profile.
54        #[arg(long, short = 'y')]
55        yes: bool,
56    },
57
58    /// Remove the shell integration from the active shell profile.
59    Uninstall {
60        /// Shell to clean up. Detected from the environment when omitted.
61        #[arg(long)]
62        shell: Option<Shell>,
63    },
64
65    /// Open the picker and print the selected command to stdout.
66    Pick {
67        /// Shell the picker was invoked from, used to select command variants.
68        #[arg(long)]
69        shell: Option<Shell>,
70
71        /// Print the cursor offset on a line of its own before the command.
72        ///
73        /// The shell integration passes this; running the picker by hand still
74        /// gets nothing but the command.
75        #[arg(long)]
76        print_cursor: bool,
77
78        /// File to write the result into instead of stdout.
79        // The picker has to ask the terminal where the cursor is, and the
80        // library it uses writes that question to stdout. A shell that captured
81        // stdout to read the result would swallow the question, no answer would
82        // come back, and the panel would never open. The result travels in a
83        // file so stdout can stay attached to the terminal.
84        #[arg(long)]
85        output: Option<PathBuf>,
86
87        /// File of the calling shell's recent commands, newest first, one a
88        /// line.
89        // A file rather than arguments. Windows hands a child one string and
90        // lets it split its own arguments, so a command ending in a backslash
91        // escapes the quote that was meant to close it and swallows whatever
92        // came next. `cd C:\\projects\\` is enough to do it.
93        #[arg(long)]
94        history: Option<PathBuf>,
95
96        /// File holding what was already typed at the prompt.
97        ///
98        /// It opens the picker filtered by it, and is offered first when
99        /// saving. In a file for the same reason the history is.
100        #[arg(long)]
101        line: Option<PathBuf>,
102    },
103
104    /// Save a command to the user library without opening the picker.
105    Save {
106        command: String,
107
108        /// What the command is for. This is how you will find it again. Asked
109        /// for when omitted. Words written as #tag become tags.
110        #[arg(long)]
111        desc: Option<String>,
112
113        /// Comma separated keywords, on top of the ones taken from the command.
114        #[arg(long)]
115        tags: Option<String>,
116    },
117
118    /// Change a command already in the library.
119    Edit {
120        id: String,
121
122        /// Shell whose command variant to replace. Detected when omitted.
123        #[arg(long)]
124        shell: Option<Shell>,
125
126        #[arg(long)]
127        cmd: Option<String>,
128
129        #[arg(long)]
130        desc: Option<String>,
131
132        /// Comma separated keywords, replacing the ones already there.
133        #[arg(long)]
134        tags: Option<String>,
135    },
136
137    /// Take a command out of the library.
138    #[command(alias = "remove")]
139    Rm { id: String },
140
141    /// List commands without opening the picker.
142    List {
143        /// Shell to resolve command variants for.
144        #[arg(long)]
145        shell: Option<Shell>,
146    },
147
148    /// Find commands in your library without opening the picker.
149    Find {
150        /// Words to look for, in the command, its description or its tags.
151        #[arg(required = true)]
152        words: Vec<String>,
153
154        /// Print only the best match's command, and nothing else.
155        #[arg(long, short = '1')]
156        first: bool,
157
158        /// Print every match, however many there are.
159        #[arg(long, short = 'a')]
160        all: bool,
161
162        /// Shell to resolve command variants for.
163        #[arg(long)]
164        shell: Option<Shell>,
165    },
166
167    /// Show this version, the newest release, and how to upgrade.
168    Version,
169
170    /// Words that are not a command at all, taken as a search.
171    #[command(external_subcommand)]
172    Search(Vec<String>),
173
174    /// Look for a newer lore and remember what it found. Run by lore itself.
175    #[command(hide = true)]
176    CheckUpdate {
177        #[arg(long)]
178        background: bool,
179    },
180
181    /// Keep your library the same on every machine, through a private git
182    /// repository you own. Run with nothing after it to sync now.
183    Sync {
184        #[command(subcommand)]
185        action: Option<SyncAction>,
186
187        /// Sync quietly and record any failure, as the automatic sync after a
188        /// change does.
189        #[arg(long, hide = true)]
190        background: bool,
191    },
192}
193
194#[derive(Subcommand)]
195enum SyncAction {
196    /// Connect this machine to a repository. Without an address, a private
197    /// repository is created with the GitHub CLI, or the one an earlier
198    /// machine created is used.
199    Init {
200        /// The repository's address, as you would give it to git clone.
201        url: Option<String>,
202    },
203
204    /// Show where this machine syncs, and when it last did.
205    Status,
206
207    /// Stop syncing on this machine. Your library and the repository stay.
208    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            // Anything that is not a command at all, such as `lore docker
257            // logs`, searches for it.
258            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                // Nobody is watching the daily check, and a machine with no
266                // network is not a machine with a problem.
267                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        // Nobody is watching a background sync. Its failure is recorded for
290        // `lore sync status` and the picker to report, and the exit is clean.
291        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
313/// Opens the picker and writes the chosen command to stdout.
314///
315/// Runs the picker and hands back the chosen command. Pressing enter on it
316/// stays the user's decision.
317///
318/// The result goes to `output` when one is given and to stdout otherwise. The
319/// shell integration always gives one: the picker asks the terminal where the
320/// cursor is by writing to stdout, so a shell that captured stdout to read the
321/// result would swallow the question and the panel would never open.
322///
323/// With `print_cursor` the offset comes first, on its own line, and the command
324/// is everything after it. The offset leads so that the command stays the tail
325/// and needs no parsing to recover.
326fn 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    // Whatever was already typed is both what to search for and the first
338    // thing offered when saving, so it leads the history the shell handed
339    // over.
340    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    // A sync that failed is about the user's own library and outranks news
350    // about a release they can install whenever they like.
351    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
389/// What the user had typed at the prompt, if anything.
390fn 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
396/// The shell's recent commands, or nothing at all.
397///
398/// A history that cannot be read is not worth refusing to open the picker over:
399/// everything else it does still works without one.
400fn 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
456/// Asks one question on one line, the way a shell script would.
457///
458/// Refuses rather than waiting when there is nobody to answer, so a script
459/// that forgot an argument fails instead of hanging.
460fn 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
473/// Applies the given changes to an entry, leaving every other field alone.
474///
475/// A builtin is written to the user's library under its own id rather than
476/// changed inside the binary, which the loader turns into an override.
477fn 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    // Only the variant for this shell is replaced. The others were never named
495    // and are none of this edit's business.
496    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
529/// Takes an entry out of the picker.
530///
531/// A builtin lives inside the binary and cannot be deleted, so it is added to
532/// the user's disabled list instead. Either way it stops appearing, which is
533/// what was asked for.
534fn 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
554/// Writes the library to stdout.
555///
556/// Through a writer that returns its errors rather than `println!`, which
557/// panics when the reader goes away. `lore list | head` closes the pipe after
558/// ten lines, and that has to end the listing quietly: see `main`.
559/// Prints the entries matching `query`, best first.
560///
561/// Exits as a search does when it finds nothing, so a pipeline can tell the
562/// difference between no answer and an empty one.
563/// How many matches to print of `total`.
564///
565/// Everything when asked for, and everything when the output is going
566/// somewhere other than a person, such as into grep.
567fn showing(total: usize, all: bool, to_a_terminal: bool) -> usize {
568    /// Enough to see the shape of the answer without losing the prompt.
569    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    // One command and nothing else, so it can be used as an argument.
600    if first {
601        writeln!(out, "{}", candidates[best].cmd)?;
602        return Ok(out.flush()?);
603    }
604
605    // A word like `git` matches most of a namespace, and a page of matches
606    // rolling past is no answer at all. Whatever reads the output in a
607    // pipeline wants all of them, so the cap is for people only.
608    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    /// A page of matches rolling past is no answer, but a pipeline wants
688    /// every one of them.
689    #[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    /// The reason the history travels in a file. Every one of these survives
714    /// being written to disk and read back, and none of them survives being
715    /// rebuilt out of a Windows command line.
716    #[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}