pub mod arg_history;
pub mod command;
pub mod config;
pub mod ctrlc_handler;
pub mod discover;
pub mod execute;
pub mod history;
pub mod migrate;
mod on_disk;
pub mod runner;
pub mod wizard;
pub use on_disk::OnDisk;
use {
anyhow::{anyhow, bail, Context},
command::{filter_only_in_dir, AfterRun, CommandId, UserCommand},
history::History,
runner::LegacySources,
std::{
collections::BTreeMap,
io::Write,
path::{Path, PathBuf},
},
};
#[derive(clap::Parser, Debug)]
#[command(version, about)]
#[command(about = "The CLI tool for all those commands you forget about")]
#[command(
long_about = "The CLI tool for all those commands you forget about.\n\n\
TUI keys:\n \
Ctrl+E Edit selected command in $EDITOR\n \
Ctrl+N New command wizard\n \
Ctrl+R Reload commands from disk\n \
Tab Toggle script preview\n \
Enter Run selected command\n \
Esc Quit"
)]
pub struct Cli {
#[arg(long)]
purge_all: bool,
#[arg(long)]
purge_history: bool,
#[arg(long)]
info: bool,
#[command(subcommand)]
command: Option<CliCommands>,
}
#[derive(clap::Subcommand, Debug)]
pub enum CliCommands {
Source {
#[command(subcommand)]
inner: SourceCommands,
},
Reload,
Run {
name: String,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
id: Option<String>,
#[arg(long)]
source: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Edit {
path: Option<PathBuf>,
},
New,
}
#[derive(clap::Subcommand, Debug)]
pub enum SourceCommands {
Add { path: PathBuf },
List,
Remove { path: PathBuf },
}
impl Cli {
pub fn run(self) -> anyhow::Result<()> {
let mut app_path = home::home_dir().ok_or(anyhow!("unable to fetch home dir"))?;
app_path.push(".iforgor");
migrate::check_migration(&app_path)?;
let sources_path = app_path.join(".legacy-sources.toml");
let history_path = app_path.join(".history.toml");
if self.info {
println!("App dir: {}", app_path.display());
println!(
"Platform: {} (use this value for `only_on`)",
std::env::consts::OS
);
return Ok(());
}
if self.purge_all {
OnDisk::<LegacySources>::new_from_default(sources_path).save()?;
OnDisk::<History>::new_from_default(history_path).save()?;
println!("🗑️ Purged sources and history!");
return Ok(());
}
if self.purge_history {
OnDisk::<History>::new_from_default(history_path).save()?;
println!("🗑️ Purged history!");
return Ok(());
}
let mut legacy_sources =
OnDisk::<LegacySources>::open_or_default(sources_path.clone())?;
let mut history = OnDisk::<History>::open_or_default(history_path.clone())?;
let mut commands = load_all_commands(&legacy_sources.sources)?;
let Some(command) = self.command else {
return run_tui_loop(
&legacy_sources,
&mut commands,
&mut history,
&history_path,
&app_path,
);
};
match command {
CliCommands::Source {
inner: SourceCommands::Add { path },
} => {
let path = std::fs::canonicalize(path)?;
println!("Adding legacy source \"{}\"", path.display());
legacy_sources.sources.insert(path);
commands = load_all_commands(&legacy_sources.sources)?;
}
CliCommands::Source {
inner: SourceCommands::List,
} => {
for source in &legacy_sources.sources {
println!("{}", source.display());
}
}
CliCommands::Source {
inner: SourceCommands::Remove { path },
} => {
let removed = if legacy_sources.sources.remove(&path) {
Some(path.display().to_string())
} else {
let canonical = std::fs::canonicalize(&path)?;
if legacy_sources.sources.remove(&canonical) {
Some(canonical.display().to_string())
} else {
None
}
};
match removed {
Some(display_path) => {
println!("Removed source \"{display_path}\"");
commands = load_all_commands(&legacy_sources.sources)?;
history.prune(&commands);
}
None => bail!("Path was not a registered source"),
}
}
CliCommands::Reload => {
commands = load_all_commands(&legacy_sources.sources)?;
history.prune(&commands);
}
CliCommands::Run {
name,
tag,
id,
source,
dry_run,
args,
} => {
let current_dir =
std::env::current_dir().context("unable to fetch current dir path")?;
let matches: Vec<_> = commands
.iter()
.filter(|(_, cmd)| filter_only_in_dir(¤t_dir, cmd))
.filter(|(cid, cmd)| {
if let Some(ref filter_id) = id {
return cmd.id.as_ref() == Some(filter_id) || *cid == filter_id;
}
cmd.name == name || cmd.name.to_lowercase().contains(&name.to_lowercase())
})
.filter(|(_, cmd)| {
tag.as_ref().is_none_or(|t| {
cmd.tags
.iter()
.any(|ct| ct.to_lowercase() == t.to_lowercase())
})
})
.filter(|(_, cmd)| {
source.as_ref().is_none_or(|filter_source| {
cmd.source_path
.as_ref()
.is_some_and(|p| p.ends_with(filter_source) || p == filter_source)
})
})
.map(|(cid, cmd)| (cid.clone(), cmd.clone()))
.collect();
match matches.len() {
0 => bail!("No command found matching \"{name}\""),
1 => {
let (cid, cmd) = &matches[0];
if dry_run {
let shell_str = cmd
.shell
.as_ref()
.map_or("default".into(), |s| format!("{s}"));
println!("--- {} (shell: {}) ---", cmd.name, shell_str);
if let Some(ref desc) = cmd.description {
println!("Description: {desc}");
}
if !cmd.tags.is_empty() {
println!("Tags: {}", cmd.tags.join(", "));
}
println!("---\n{}", cmd.script);
} else if args.is_empty() {
runner::run_script_by_id(&commands,cid, &app_path)?;
} else {
execute::execute_command(cmd, &args)?;
}
}
n => {
eprintln!("Ambiguous: {n} commands match \"{name}\":");
for (_, cmd) in &matches {
let tags = if cmd.tags.is_empty() {
String::new()
} else {
format!(" [{}]", cmd.tags.join(", "))
};
eprintln!(" - {}{tags}", cmd.name);
}
bail!("Use --tag, --id, or a more specific name to disambiguate");
}
}
}
CliCommands::Edit { path } => {
let path = match path {
Some(p) => std::fs::canonicalize(p)?,
None => {
let sources: Vec<_> = legacy_sources
.sources
.iter()
.enumerate()
.map(|(i, p)| ichoose::ListEntry {
key: i,
name: p.display().to_string(),
description: None,
})
.collect();
if sources.is_empty() {
bail!("No sources registered. Add .iforgor/ folders or run `iforgor source add <path>`.");
}
let choice = ichoose::ListSearch {
items: &sources,
filter_callback: None,
preview_callback: None,
extra: ichoose::ListSearchExtra {
title: " Select source to edit ".to_string(),
..Default::default()
},
}
.run()?;
let idx = choice
.selected
.into_iter()
.next()
.ok_or(anyhow!("No source selected"))?;
legacy_sources
.sources
.iter()
.nth(idx)
.cloned()
.ok_or(anyhow!("Invalid selection"))?
}
};
open_in_editor(&path, None)?;
commands = load_all_commands(&legacy_sources.sources)?;
}
CliCommands::New => {
wizard::run_new_command_wizard(&app_path)?;
commands = load_all_commands(&legacy_sources.sources)?;
}
}
legacy_sources.save()?;
history.save()?;
Ok(())
}
}
fn load_all_commands(
legacy_sources: &std::collections::BTreeSet<PathBuf>,
) -> anyhow::Result<BTreeMap<CommandId, UserCommand>> {
let current_dir = std::env::current_dir().context("unable to fetch current dir path")?;
Ok(runner::load_all_commands(¤t_dir, legacy_sources))
}
fn run_tui_loop(
legacy_sources: &OnDisk<LegacySources>,
all_commands: &mut BTreeMap<CommandId, UserCommand>,
history: &mut OnDisk<History>,
history_path: &Path,
app_path: &Path,
) -> anyhow::Result<()> {
history.prune(all_commands);
loop {
let current_dir = std::env::current_dir().context("unable to fetch current dir path")?;
let visible: Vec<_> = all_commands
.iter()
.filter(|(_, command)| filter_only_in_dir(¤t_dir, command))
.map(|(id, command)| make_list_entry(id, command))
.collect();
let help_text = if visible.is_empty() && !all_commands.is_empty() {
format!(
"No commands match this directory ({} hidden by only_in_dir). Ctrl+N to create.",
all_commands.len()
)
} else if visible.is_empty() {
"No commands found. Add .iforgor/ folders or Ctrl+N to create.".to_string()
} else {
"Search: comma = AND. Filters: tag:x, shell:x, source:x, risky:yes.\n\
Empty = history. Space = full list."
.to_string()
};
let history_list: Vec<_> = history
.history
.iter()
.filter_map(|id| all_commands.get(id).map(|c| (id, c)))
.filter(|(_, command)| filter_only_in_dir(¤t_dir, command))
.map(|(id, c)| make_list_entry(id, c))
.collect();
let history_list: Vec<_> = history_list.into_iter().rev().collect();
let history_list = if history_list.is_empty() {
None
} else {
Some(history_list.as_slice())
};
let filter_cb = make_filter_callback(all_commands);
let preview_cb = make_preview_callback(all_commands);
let result = ichoose::ListSearch {
items: &visible,
filter_callback: Some(filter_cb),
preview_callback: Some(preview_cb),
extra: ichoose::ListSearchExtra {
empty_search_list: history_list,
title: " iforgor ".to_string(),
text: help_text,
action_keys: vec![
(ichoose::ActionKey::Ctrl('e'), "Edit".into()),
(ichoose::ActionKey::Ctrl('n'), "New".into()),
(ichoose::ActionKey::Ctrl('r'), "Reload".into()),
],
..Default::default()
},
}
.run()?;
match result.action.as_deref() {
Some("Edit") => {
let Some(choice) = result.selected.into_iter().next() else {
continue;
};
let Some(cmd) = all_commands.get(&choice) else {
continue;
};
let Some(ref source_path) = cmd.source_path else {
eprintln!(
"Cannot edit: command \"{}\" has no source file (legacy source?)",
cmd.name
);
continue;
};
let line = find_entry_line(source_path, &cmd.name);
open_in_editor(source_path, line)?;
}
Some("New") => {
if let Err(e) = wizard::run_new_command_wizard(app_path) {
eprintln!("Wizard error: {e}");
}
}
Some("Reload") => {
*all_commands = load_all_commands(&legacy_sources.sources)?;
println!("Reloaded commands.");
continue;
}
_ => {
let Some(choice) = result.selected.iter().next().cloned() else {
break;
};
if let Ok(h) = OnDisk::<History>::open(history_path.to_path_buf()) {
*history = h;
}
history.add_entry(&choice);
history.save()?;
match runner::run_script_by_id(all_commands, &choice, app_path) {
Err(e) => eprintln!("Encountered an error when running command: {e}"),
Ok(run_result) => {
let status_msg = match run_result.status.code() {
Some(code) => format!("code {code}"),
None => "signal".to_string(),
};
handle_after_run(&run_result.after_run, &status_msg)?;
}
}
println!("━━━━━━━━━━━━━━━");
}
}
*all_commands = load_all_commands(&legacy_sources.sources)?;
}
Ok(())
}
fn handle_after_run(after_run: &AfterRun, status_msg: &str) -> anyhow::Result<()> {
match after_run {
AfterRun::Auto => {
println!("\n🏁 Finished ({status_msg})");
}
AfterRun::Wait => {
print!("\n🏁 Finished ({status_msg}), press Enter to proceed.");
std::io::stdout().flush()?;
let mut buf = String::new();
ctrlc_handler::set_mode(ctrlc_handler::Mode::Ignore);
std::io::stdin().read_line(&mut buf)?;
ctrlc_handler::set_mode(ctrlc_handler::Mode::Kill);
}
AfterRun::Delay(seconds) => {
for remaining in (1..=*seconds).rev() {
print!("\r🏁 Finished ({status_msg}), returning in {remaining}s... ");
std::io::stdout().flush()?;
std::thread::sleep(std::time::Duration::from_secs(1));
}
println!();
}
}
Ok(())
}
fn make_list_entry(id: &CommandId, command: &UserCommand) -> ichoose::ListEntry<CommandId> {
let description = match (&command.description, command.tags.is_empty()) {
(Some(desc), true) => Some(desc.clone()),
(Some(desc), false) => Some(format!("{desc} [{}]", command.tags.join(", "))),
(None, false) => Some(format!("[{}]", command.tags.join(", "))),
(None, true) => None,
};
ichoose::ListEntry {
key: id.clone(),
name: command.name.clone(),
description,
}
}
fn make_filter_callback(
commands: &BTreeMap<CommandId, UserCommand>,
) -> ichoose::FilterCallback<CommandId> {
struct CmdFilterData {
tags: Vec<String>,
shell: String,
domain: Option<String>,
risky: bool,
}
let cmd_data: BTreeMap<CommandId, CmdFilterData> = commands
.iter()
.map(|(id, cmd)| {
(
id.clone(),
CmdFilterData {
tags: cmd.tags.clone(),
shell: cmd
.shell
.as_ref()
.map_or("default".into(), |s| format!("{s}")),
domain: cmd.domain.clone(),
risky: cmd.risky,
},
)
})
.collect();
Box::new(move |key, value, entry| {
let Some(data) = cmd_data.get(&entry.key) else {
return false;
};
match key {
"tag" => data.tags.iter().any(|t| t.to_lowercase().contains(value)),
"shell" => data.shell.to_lowercase().contains(value),
"source" => data
.domain
.as_ref()
.is_some_and(|d| d.to_lowercase().contains(value)),
"risky" => match value {
"true" | "yes" => data.risky,
"false" | "no" => !data.risky,
_ => false,
},
_ => false,
}
})
}
fn make_preview_callback(
commands: &BTreeMap<CommandId, UserCommand>,
) -> ichoose::PreviewCallback<CommandId> {
let previews: BTreeMap<CommandId, String> = commands
.iter()
.map(|(id, cmd)| {
let mut header = Vec::new();
if let Some(ref shell) = cmd.shell {
header.push(format!("Shell: {shell}"));
}
if !cmd.tags.is_empty() {
header.push(format!("Tags: {}", cmd.tags.join(", ")));
}
if !cmd.args.is_empty() {
let names: Vec<_> = cmd.args.iter().map(|a| a.name.as_str()).collect();
header.push(format!("Args: {}", names.join(", ")));
}
if cmd.risky {
header.push("Risky: yes".into());
}
if let Some(ref wd) = cmd.working_dir {
header.push(format!("Dir: {wd}"));
}
let preview = if header.is_empty() {
cmd.script.clone()
} else {
format!("{}\n---\n{}", header.join(" | "), cmd.script)
};
(id.clone(), preview)
})
.collect();
Box::new(move |key| previews.get(key).cloned())
}
fn find_entry_line(file_path: &Path, command_name: &str) -> Option<usize> {
let content = std::fs::read_to_string(file_path).ok()?;
let mut last_entries_line = None;
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed == "[[entries]]" {
last_entries_line = Some(i + 1); }
if let Some(entries_line) = last_entries_line {
if let Some(name_val) = trimmed.strip_prefix("name") {
let name_val = name_val.trim_start().strip_prefix('=')?;
let name_val = name_val.trim().trim_matches('"');
if name_val == command_name {
return Some(entries_line);
}
}
}
}
None
}
fn open_in_editor(file_path: &Path, line: Option<usize>) -> anyhow::Result<()> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let mut cmd = std::process::Command::new(&editor);
if let Some(n) = line {
if editor.contains("code") {
cmd.arg("--goto");
cmd.arg(format!("{}:{n}", file_path.display()));
} else {
cmd.arg(format!("+{n}"));
cmd.arg(file_path);
}
} else {
cmd.arg(file_path);
}
cmd.status().context("failed to launch editor")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_entry_line_first_entry() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
r#"[source]
name = "Test"
[[entries]]
name = "hello"
script = "echo hi"
[[entries]]
name = "world"
script = "echo world"
"#,
)
.unwrap();
assert_eq!(find_entry_line(tmp.path(), "hello"), Some(4));
assert_eq!(find_entry_line(tmp.path(), "world"), Some(8));
assert_eq!(find_entry_line(tmp.path(), "missing"), None);
}
#[test]
fn find_entry_line_whitespace_around_entries() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
" [[entries]] \nname = \"spaced\"\nscript = \"echo\"\n",
)
.unwrap();
assert_eq!(find_entry_line(tmp.path(), "spaced"), Some(1));
}
#[test]
fn find_entry_line_name_with_spaces_around_equals() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
"[[entries]]\nname = \"gappy\"\nscript = \"echo\"\n",
)
.unwrap();
assert_eq!(find_entry_line(tmp.path(), "gappy"), Some(1));
}
#[test]
fn find_entry_line_name_on_nonadjacent_line() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
"[[entries]]\ndescription = \"first\"\nname = \"later\"\nscript = \"echo\"\n",
)
.unwrap();
assert_eq!(find_entry_line(tmp.path(), "later"), Some(1));
}
}