use {
anyhow::{anyhow, bail, Context},
std::{
io::Write,
path::{Path, PathBuf},
},
};
use crate::{
command::{AfterRun, PredefinedShell, Shell},
discover, find_entry_line, open_in_editor,
};
pub fn run_new_command_wizard(app_path: &Path) -> anyhow::Result<()> {
let current_dir = std::env::current_dir().context("unable to fetch current dir")?;
let (target_file, source_header) = pick_target_file(¤t_dir, app_path)?;
println!();
let name = prompt_text("Command name", None)?;
if name.is_empty() {
bail!("Name is required");
}
let description = prompt_text_optional("Description (Enter to skip)")?;
let tags: Vec<String> = prompt_text("Tags (comma-separated, Enter to skip)", None)?
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let shell = pick_shell()?;
let risky = prompt_yes_no("Risky?", false)?;
let after_run = pick_after_run()?;
let toml_block = build_entry_toml(
&name,
description.as_deref(),
&tags,
shell,
risky,
after_run,
);
if let Some(parent) = target_file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&target_file)?;
if let Some(header) = source_header {
file.write_all(header.as_bytes())?;
}
file.write_all(toml_block.as_bytes())?;
println!("Added \"{}\" to {}", name, target_file.display());
let line = find_entry_line(&target_file, &name);
open_in_editor(&target_file, line)?;
Ok(())
}
fn build_entry_toml(
name: &str,
description: Option<&str>,
tags: &[String],
shell: Option<Shell>,
risky: bool,
after_run: Option<AfterRun>,
) -> String {
let mut lines = vec![
"\n# --------\n".into(),
"[[entries]]".into(),
format!("name = \"{}\"", escape_toml(name)),
];
if let Some(desc) = description {
lines.push(format!("description = \"{}\"", escape_toml(desc)));
}
if !tags.is_empty() {
let tags_str: Vec<_> = tags.iter().map(|t| format!("\"{t}\"")).collect();
lines.push(format!("tags = [{}]", tags_str.join(", ")));
}
if let Some(ref shell) = shell {
lines.push(format!("shell = \"{shell}\""));
}
if risky {
lines.push("risky = true".into());
}
match after_run {
Some(AfterRun::Wait) => lines.push("after_run = \"wait\"".into()),
Some(AfterRun::Delay(s)) => lines.push(format!("after_run = {{ Delay = {s} }}")),
_ => {}
}
lines.push("script = '''\n\n'''\n".into());
lines.join("\n")
}
fn escape_toml(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn pick_target_file(
current_dir: &Path,
app_path: &Path,
) -> anyhow::Result<(PathBuf, Option<String>)> {
let home_iforgor = app_path.to_path_buf();
let all_dirs = collect_all_iforgor_dirs(current_dir, &home_iforgor);
let files: Vec<_> = all_dirs
.iter()
.filter(|d| d.exists())
.flat_map(|d| {
let is_global = *d == home_iforgor;
discover::find_domain_files(d)
.into_iter()
.map(move |f| (f, is_global))
})
.collect();
let entries: Vec<_> = std::iter::once(ichoose::ListEntry {
key: 0usize,
name: "+ Create new file".into(),
description: None,
})
.chain(
files
.iter()
.enumerate()
.map(|(i, (f, is_global))| ichoose::ListEntry {
key: i + 1,
name: f.display().to_string(),
description: is_global.then(|| "(global)".into()),
}),
)
.collect();
let idx = pick_one(" Select target file ", &entries)?;
if idx == 0 {
pick_new_file(&all_dirs, &home_iforgor)
} else {
let (path, _) = &files[idx - 1];
Ok((path.clone(), None))
}
}
fn pick_new_file(
all_dirs: &[PathBuf],
home_iforgor: &Path,
) -> anyhow::Result<(PathBuf, Option<String>)> {
let dir_entries: Vec<_> = all_dirs
.iter()
.enumerate()
.map(|(i, d)| {
let is_global = d == home_iforgor;
let desc = match (is_global, d.exists()) {
(true, true) => "(global)",
(true, false) => "(global, will be created)",
(false, false) => "(will be created)",
(false, true) => "",
};
ichoose::ListEntry {
key: i,
name: d.display().to_string(),
description: if desc.is_empty() {
None
} else {
Some(desc.into())
},
}
})
.collect();
let dir_idx = pick_one(" Select .iforgor/ directory ", &dir_entries)?;
let target_dir = &all_dirs[dir_idx];
let filename = prompt_text("File name (without .toml)", None)?;
if filename.is_empty() {
bail!("File name is required");
}
let source_name = prompt_text("Source name (for this file)", None)?;
let source_desc = prompt_text_optional("Source description (Enter to skip)")?;
let mut header = "[source]\n".to_string();
if !source_name.is_empty() {
header.push_str(&format!("name = \"{}\"\n", escape_toml(&source_name)));
}
if let Some(desc) = source_desc {
header.push_str(&format!("description = \"{}\"\n", escape_toml(&desc)));
}
Ok((target_dir.join(format!("{filename}.toml")), Some(header)))
}
fn collect_all_iforgor_dirs(current_dir: &Path, home_iforgor: &Path) -> Vec<PathBuf> {
let discovered = discover::discover_iforgor_dirs(current_dir);
let mut dirs = vec![home_iforgor.to_path_buf()];
for d in &discovered {
if !dirs.contains(d) {
dirs.push(d.clone());
}
}
let mut ancestor = Some(current_dir.to_path_buf());
while let Some(dir) = ancestor {
let candidate = dir.join(".iforgor");
if !dirs.contains(&candidate) {
dirs.push(candidate);
}
ancestor = dir.parent().map(|p| p.to_path_buf());
}
dirs
}
fn pick_shell() -> anyhow::Result<Option<Shell>> {
let options = ["(default)", "sh", "bash", "zsh", "fish"];
let entries: Vec<_> = options
.iter()
.enumerate()
.map(|(i, name)| ichoose::ListEntry {
key: i,
name: name.to_string(),
description: None,
})
.collect();
let idx = pick_one(" Shell ", &entries)?;
Ok(match idx {
1 => Some(Shell::Predefined(PredefinedShell::Sh)),
2 => Some(Shell::Predefined(PredefinedShell::Bash)),
3 => Some(Shell::Predefined(PredefinedShell::Zsh)),
4 => Some(Shell::Predefined(PredefinedShell::Fish)),
_ => None,
})
}
fn pick_after_run() -> anyhow::Result<Option<AfterRun>> {
let options = ["auto (default)", "wait", "delay"];
let entries: Vec<_> = options
.iter()
.enumerate()
.map(|(i, name)| ichoose::ListEntry {
key: i,
name: name.to_string(),
description: None,
})
.collect();
let idx = pick_one(" After run ", &entries)?;
Ok(match idx {
1 => Some(AfterRun::Wait),
2 => {
let secs = prompt_text("Delay (seconds)", Some("3"))?;
Some(AfterRun::Delay(secs.parse().unwrap_or(3)))
}
_ => None,
})
}
fn pick_one<K: Ord + Clone + Default>(
title: &str,
entries: &[ichoose::ListEntry<K>],
) -> anyhow::Result<K> {
ichoose::ListSearch {
items: entries,
filter_callback: None,
preview_callback: None,
extra: ichoose::ListSearchExtra {
title: title.to_string(),
preserve_order: true,
..Default::default()
},
}
.run()?
.selected
.into_iter()
.next()
.ok_or(anyhow!("Wizard cancelled"))
}
fn prompt_text(prompt: &str, default: Option<&str>) -> anyhow::Result<String> {
if let Some(def) = default {
print!("- {prompt} [Default: {def}]: ");
} else {
print!("- {prompt}: ");
}
std::io::stdout().flush()?;
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
let val = buf.trim();
if val.is_empty() {
Ok(default.unwrap_or("").to_string())
} else {
Ok(val.to_string())
}
}
fn prompt_text_optional(prompt: &str) -> anyhow::Result<Option<String>> {
let val = prompt_text(prompt, None)?;
Ok(if val.is_empty() { None } else { Some(val) })
}
fn prompt_yes_no(prompt: &str, default: bool) -> anyhow::Result<bool> {
let hint = if default { "[Y/n]" } else { "[y/N]" };
print!("- {prompt} {hint}: ");
std::io::stdout().flush()?;
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
let val = buf.trim().to_lowercase();
Ok(if val.is_empty() {
default
} else {
["y", "yes"].contains(&val.as_str())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_entry_minimal() {
let toml = build_entry_toml("hello", None, &[], None, false, None);
assert!(toml.contains("[[entries]]"));
assert!(toml.contains("name = \"hello\""));
assert!(toml.contains("script = '''"));
assert!(!toml.contains("description"));
assert!(!toml.contains("tags"));
assert!(!toml.contains("shell"));
assert!(!toml.contains("risky"));
assert!(!toml.contains("after_run"));
}
#[test]
fn build_entry_all_fields() {
let toml = build_entry_toml(
"deploy",
Some("Deploy to prod"),
&["ci".into(), "docker".into()],
Some(Shell::Predefined(PredefinedShell::Bash)),
true,
Some(AfterRun::Wait),
);
assert!(toml.contains("name = \"deploy\""));
assert!(toml.contains("description = \"Deploy to prod\""));
assert!(toml.contains("tags = [\"ci\", \"docker\"]"));
assert!(toml.contains("shell = \"bash\""));
assert!(toml.contains("risky = true"));
assert!(toml.contains("after_run = \"wait\""));
assert!(toml.contains("# --------"));
}
#[test]
fn build_entry_delay() {
let toml = build_entry_toml("x", None, &[], None, false, Some(AfterRun::Delay(5)));
assert!(toml.contains("after_run = { Delay = 5 }"));
}
#[test]
fn build_entry_escapes_quotes() {
let toml = build_entry_toml("say \"hi\"", Some("a \"test\""), &[], None, false, None);
assert!(toml.contains(r#"name = "say \"hi\"""#));
assert!(toml.contains(r#"description = "a \"test\"""#));
}
}