iforgor 0.3.3

The CLI tool for all those commands you forget about
Documentation
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(&current_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()?;

    // Build TOML and append (preserves existing content/comments).
    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(())
}

// -- TOML generation --

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('"', "\\\"")
}

// -- File/dir picking --

/// Returns (path, optional [source] header for new files).
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);

    // Collect existing files, tracking which are global.
    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)))
}

/// Build list: home first, then discovered, then potential parent dirs.
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
}

// -- Enum pickers --

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,
    })
}

// -- Helpers --

/// Run an ichoose single-select picker, return selected key.
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\"""#));
    }
}