use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use crate::model::{CommandBody, Entry, Layer, ShellFamily};
use crate::search::{self, Candidate};
use crate::shell::chord::{self, Chord};
use crate::shell::{self, Shell};
use crate::store::definitions::{self, NewEntry, Written};
use crate::store::stats::{self, Stats};
use crate::store::{self};
use crate::sync;
use crate::tui::{App, Outcome};
use crate::update;
#[derive(Parser)]
#[command(name = "lore", version, about, allow_external_subcommands = true)]
pub struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
shell: Shell,
#[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
key: Chord,
},
Setup {
#[arg(long)]
shell: Option<Shell>,
#[arg(long, value_name = "CHORD", default_value = chord::DEFAULT)]
key: Chord,
#[arg(long, short = 'y')]
yes: bool,
},
Uninstall {
#[arg(long)]
shell: Option<Shell>,
},
Pick {
#[arg(long)]
shell: Option<Shell>,
#[arg(long)]
print_cursor: bool,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
history: Option<PathBuf>,
#[arg(long)]
line: Option<PathBuf>,
},
Save {
command: String,
#[arg(long)]
desc: Option<String>,
#[arg(long)]
tags: Option<String>,
},
Edit {
id: String,
#[arg(long)]
shell: Option<Shell>,
#[arg(long)]
cmd: Option<String>,
#[arg(long)]
desc: Option<String>,
#[arg(long)]
tags: Option<String>,
},
#[command(alias = "remove")]
Rm { id: String },
List {
#[arg(long)]
shell: Option<Shell>,
},
Find {
#[arg(required = true)]
words: Vec<String>,
#[arg(long, short = '1')]
first: bool,
#[arg(long, short = 'a')]
all: bool,
#[arg(long)]
shell: Option<Shell>,
},
Version,
#[command(external_subcommand)]
Search(Vec<String>),
#[command(hide = true)]
CheckUpdate {
#[arg(long)]
background: bool,
},
Sync {
#[command(subcommand)]
action: Option<SyncAction>,
#[arg(long, hide = true)]
background: bool,
},
}
#[derive(Subcommand)]
enum SyncAction {
Init {
url: Option<String>,
},
Status,
Disconnect,
}
impl Cli {
pub fn run(self) -> Result<()> {
match self.command {
Command::Init { shell, key } => {
let mut out = io::stdout().lock();
out.write_all(shell::snippet(shell, key).as_bytes())?;
out.flush()?;
Ok(())
}
Command::Setup { shell, key, yes } => shell::install(resolve(shell)?, key, yes),
Command::Uninstall { shell } => shell::uninstall(resolve(shell)?),
Command::Pick {
shell,
print_cursor,
output,
history,
line,
} => pick(
family(shell),
print_cursor,
output.as_deref(),
history.as_deref(),
line.as_deref(),
),
Command::Save {
command,
desc,
tags,
} => save(command, desc, tags),
Command::Edit {
id,
shell,
cmd,
desc,
tags,
} => edit(id, family(shell), cmd, desc, tags),
Command::Rm { id } => remove(id),
Command::List { shell } => list(family(shell)),
Command::Sync { action, background } => run_sync(action, background),
Command::Find {
words,
first,
all,
shell,
} => find(&words.join(" "), first, all, family(shell)),
Command::Search(words) => find(&words.join(" "), false, false, family(None)),
Command::Version => {
println!("{}", update::status());
Ok(())
}
Command::CheckUpdate { background } => {
let found = update::check();
match (found, background) {
(_, true) => Ok(()),
(Ok(Some(version)), false) => {
println!("The newest release is {version}");
Ok(())
}
(Ok(None), false) => {
println!("Could not tell what the newest release is");
Ok(())
}
(Err(error), false) => Err(error),
}
}
}
}
}
fn run_sync(action: Option<SyncAction>, background: bool) -> Result<()> {
match action {
Some(SyncAction::Init { url }) => println!("{}", sync::init(url)?.summary()),
Some(SyncAction::Status) => sync::status()?,
Some(SyncAction::Disconnect) => sync::disconnect()?,
None if background => {
let _ = sync::run(sync::Mode::Background);
}
None => println!("{}", sync::run(sync::Mode::Interactive)?.summary()),
}
Ok(())
}
fn resolve(shell: Option<Shell>) -> Result<Shell> {
match shell.or_else(shell::detect) {
Some(shell) => Ok(shell),
None => bail!("could not tell which shell you are using, pass --shell"),
}
}
fn family(shell: Option<Shell>) -> ShellFamily {
shell
.or_else(shell::detect)
.map(ShellFamily::from)
.unwrap_or(ShellFamily::Posix)
}
fn pick(
family: ShellFamily,
print_cursor: bool,
output: Option<&Path>,
history: Option<&Path>,
line: Option<&Path>,
) -> Result<()> {
let library = store::user_library()?;
let entries = definitions::load(Some(&library))?;
let stats = Stats::open(&store::stats_database()?)?;
let typed = line.and_then(read_line);
let mut history = read_history(history);
if let Some(typed) = &typed {
history.insert(0, typed.clone());
}
let mut app = App::new(entries, family, stats, library, history, stats::now())?;
if let Some(typed) = typed {
app.search(typed);
}
if let Some(error) = sync::last_error() {
app.notice(format!("Sync failed: {error}. Run lore sync"));
} else if let Some(update) = update::notice() {
app.notice(update);
}
sync::refresh_if_stale();
update::refresh_in_background();
let outcome = crate::tui::run(&mut app)?;
if app.changed() {
sync::spawn();
}
let Outcome::Insert { command, cursor } = outcome else {
return Ok(());
};
let mut result = String::new();
if print_cursor {
let offset = cursor.unwrap_or(command.chars().count());
result.push_str(&format!("{offset}\n"));
}
result.push_str(&command);
result.push('\n');
match output {
Some(path) => fs::write(path, result)
.with_context(|| format!("failed to write {}", path.display()))?,
None => {
let mut out = io::stdout().lock();
out.write_all(result.as_bytes())?;
out.flush()?;
}
}
Ok(())
}
fn read_line(path: &Path) -> Option<String> {
let typed = fs::read_to_string(path).unwrap_or_default();
let typed = typed.trim();
(!typed.is_empty()).then(|| typed.to_string())
}
fn read_history(path: Option<&Path>) -> Vec<String> {
let Some(path) = path else {
return Vec::new();
};
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
fn save(command: String, desc: Option<String>, tags: Option<String>) -> Result<()> {
let command = command.trim().to_string();
if command.is_empty() {
bail!("nothing to save, the command is empty");
}
let purpose = match desc {
Some(desc) => desc,
None => ask("What is it for? ")?,
};
let (desc, mut given) = definitions::split_purpose(&purpose);
if desc.is_empty() {
bail!("say what the command is for, so you can find it later");
}
for tag in definitions::parse_tags(&tags.unwrap_or_default()) {
if !given.contains(&tag) {
given.push(tag);
}
}
let library = store::user_library()?;
let taken: BTreeSet<String> = definitions::load(Some(&library))?
.into_iter()
.map(|entry| entry.id)
.collect();
let entry = NewEntry {
id: definitions::suggest_id(&command, &taken),
tags: definitions::merge_tags(given, &command),
cmd: CommandBody::Shared(command),
desc,
params: BTreeMap::new(),
danger: false,
};
let stats = Stats::open(&store::stats_database()?)?;
stats.record_new(&entry.id, stats::now())?;
definitions::append(&library, &entry)?;
println!("Saved as {} in {}", entry.id, library.display());
sync::spawn();
Ok(())
}
fn ask(question: &str) -> Result<String> {
if !io::stdin().is_terminal() {
bail!("pass --desc to say what the command is for");
}
print!("{question}");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
Ok(answer.trim().to_string())
}
fn edit(
id: String,
family: ShellFamily,
cmd: Option<String>,
desc: Option<String>,
tags: Option<String>,
) -> Result<()> {
if cmd.is_none() && desc.is_none() && tags.is_none() {
bail!("nothing to change, pass at least one of --cmd, --desc or --tags");
}
let library = store::user_library()?;
let entries = definitions::load(Some(&library))?;
let Some(entry) = entries.iter().find(|entry| entry.id == id) else {
bail!("no command with the id {id}");
};
let body = match (&entry.cmd, cmd) {
(_, None) => entry.cmd.clone(),
(CommandBody::Shared(_), Some(cmd)) => CommandBody::Shared(cmd),
(CommandBody::PerShell(variants), Some(cmd)) => {
let mut variants = variants.clone();
variants.insert(family, cmd);
CommandBody::PerShell(variants)
}
};
let edited = NewEntry {
id: id.clone(),
cmd: body,
desc: desc.unwrap_or_else(|| entry.desc.clone()),
tags: tags
.map(|tags| definitions::parse_tags(&tags))
.unwrap_or_else(|| entry.tags.clone()),
params: entry.params.clone(),
danger: entry.danger,
};
match definitions::upsert(&library, &edited)? {
Written::Replaced => println!("Updated {id} in {}", library.display()),
Written::Appended => println!(
"Saved {id} to {}, overriding the builtin",
library.display()
),
}
sync::spawn();
Ok(())
}
fn remove(id: String) -> Result<()> {
let library = store::user_library()?;
let entries = definitions::load(Some(&library))?;
let Some(entry) = entries.iter().find(|entry| entry.id == id) else {
bail!("no command with the id {id}");
};
if entry.layer == Layer::User {
definitions::remove(&library, &id)?;
println!("Removed {id} from {}", library.display());
} else {
definitions::disable(&library, &id)?;
println!("Hid {id}, listed under disabled in {}", library.display());
}
Stats::open(&store::stats_database()?)?.forget(&id)?;
sync::spawn();
Ok(())
}
fn showing(total: usize, all: bool, to_a_terminal: bool) -> usize {
const CAP: usize = 10;
if all || !to_a_terminal {
total
} else {
total.min(CAP)
}
}
fn find(query: &str, first: bool, all: bool, family: ShellFamily) -> Result<()> {
let library = store::user_library().ok();
let entries = definitions::load(library.as_deref())?;
let stats = Stats::open(&store::stats_database()?).ok();
let scores = stats
.map(|stats| stats.scores(stats::now()))
.transpose()?
.unwrap_or_default();
let candidates: Vec<Candidate<'_>> = entries
.iter()
.filter_map(|entry| entry.cmd_for(family).map(|cmd| Candidate { entry, cmd }))
.collect();
let ranked = search::rank(&candidates, &scores, query);
let Some(&best) = ranked.first() else {
bail!("nothing in your library matches `{query}`");
};
let mut out = io::BufWriter::new(io::stdout().lock());
if first {
writeln!(out, "{}", candidates[best].cmd)?;
return Ok(out.flush()?);
}
let showing = showing(ranked.len(), all, io::stdout().is_terminal());
let width = ranked
.iter()
.take(showing)
.map(|&index| candidates[index].entry.id.chars().count())
.max()
.unwrap_or(0);
for &index in ranked.iter().take(showing) {
let candidate = candidates[index];
writeln!(
out,
"{:width$} {}",
candidate.entry.id,
candidate.cmd,
width = width
)?;
writeln!(
out,
"{:width$} {}",
"",
candidate.entry.desc,
width = width
)?;
}
let hidden = ranked.len() - showing;
if hidden > 0 {
writeln!(
out,
"\n{hidden} more. Add a word to narrow it, or pass --all"
)?;
}
Ok(out.flush()?)
}
fn list(family: ShellFamily) -> Result<()> {
let library = store::user_library().ok();
let entries = definitions::load(library.as_deref())?;
let mut out = io::BufWriter::new(io::stdout().lock());
for entry in entries.iter().filter(|e| e.cmd_for(family).is_some()) {
print(&mut out, entry, family)?;
}
out.flush()?;
Ok(())
}
fn print(out: &mut impl Write, entry: &Entry, family: ShellFamily) -> io::Result<()> {
let cmd = entry.cmd_for(family).expect("caller filtered on this");
let danger = if entry.danger { " [destructive]" } else { "" };
writeln!(out, "{}{danger}", entry.id)?;
writeln!(out, " {}", entry.desc)?;
writeln!(out, " {cmd}")?;
for name in crate::params::names(cmd) {
let desc = entry
.params
.get(&name)
.and_then(|spec| spec.desc.as_deref())
.unwrap_or("no description");
writeln!(out, " <{name}> {desc}")?;
}
if !entry.tags.is_empty() {
writeln!(out, " tags: {}", entry.tags.join(", "))?;
}
writeln!(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_are_capped_for_a_person_and_never_for_a_pipeline() {
assert_eq!(showing(40, false, true), 10);
assert_eq!(showing(3, false, true), 3);
assert_eq!(showing(40, true, true), 40, "--all was ignored");
assert_eq!(showing(40, false, false), 40, "a pipeline was cut short");
}
fn history_of(arguments: &[&str]) -> Option<PathBuf> {
match Cli::try_parse_from(arguments)
.expect("arguments should parse")
.command
{
Command::Pick { history, .. } => history,
_ => panic!("expected pick"),
}
}
#[test]
fn omitting_the_history_is_allowed() {
assert!(history_of(&["lore", "pick", "--shell", "powershell"]).is_none());
assert!(read_history(None).is_empty());
}
#[test]
fn a_history_file_carries_commands_an_argument_list_cannot() {
let path = std::env::temp_dir().join(format!("lore-history-{}.txt", std::process::id()));
let written = "cd C:\\projects\\\ngit commit -m \"fix the thing\"\n-Verbose\n";
fs::write(&path, written).unwrap();
assert_eq!(
history_of(&[
"lore",
"pick",
"--shell",
"powershell",
"--history",
path.to_str().unwrap()
]),
Some(path.clone())
);
assert_eq!(
read_history(Some(&path)),
vec![
"cd C:\\projects\\".to_string(),
"git commit -m \"fix the thing\"".to_string(),
"-Verbose".to_string(),
]
);
let _ = fs::remove_file(&path);
}
#[test]
fn an_unreadable_history_leaves_the_picker_openable() {
assert!(read_history(Some(Path::new("no-such-file-anywhere"))).is_empty());
}
}