//! `wai setup-agent` (catalogue `agent.setup`): make a project ready for coding agents to use
//! WAI. In the project directory it writes the instructions agents read (`AGENTS.md`), adds an
//! import of them to a `CLAUDE.md` that is already there (one that is not is left uncreated),
//! and registers `wai mcp` in the project-level MCP configuration files whose formats are
//! documented (`TARGETS`).
//!
//! ```text
//! wai setup-agent [--dir <project>] [--dry-run] [--yes] [--json] [--all] [--only <file>[,<file>...]]
//!                 [--toolset <set>[,<set>...]] [--command <program>]
//! ```
//!
//! - It writes nothing outside the project directory: every path is checked against the
//!   directory's canonical path, and a link among the files it would write is refused.
//! - A file that does not exist is created. A file that exists is changed only after its diff is
//!   shown and the change is accepted: asked for on a terminal, or given with `--yes` (also
//!   `--confirm`). Without a terminal and without `--yes`, nothing that exists is changed and the
//!   command exits 4, with every proposed change and the command that confirms them.
//! - `--dry-run` shows what would change and writes nothing.
//! - In Markdown it owns one block between two markers, which it adds or replaces; the rest of
//!   the file is kept. In JSON it sets one member, `wai`, under the file's server list; the
//!   rest is kept, in its order. A JSON file it cannot read as JSON (one with comments, say) is
//!   left alone, and the entry to add by hand is printed.
//! - `--toolset` names the toolsets the registered server offers (`wai mcp --toolset ...`);
//!   without it the server offers its default, `core`.
//! - With `--json`, the toolchain's JSON envelope (`{"wai": "envelope/1", "op":
//!   "agent.setup", "ok", "data" | "error", ...}`); text otherwise.
//! - `wai` reads `--dry-run`, `--yes` and `--json` wherever they are on the command line (the
//!   agent rules every command keeps, `agent.rs`), and hands them on here.
//!
//! Exit status, the toolchain's table: 0 done (or a dry run); 2 a usage error; 3 a file it may
//! not read or write; 4 changes to existing files wait for confirmation; 5 the project
//! directory is not there; 7 a file could not be read or written otherwise.

use crate::dx::envelope::OpError;
use serde_json::{json, Map, Value};
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};

/// The operation, as the catalogue names it.
const OP: &str = "agent.setup";

/// The exit statuses of the toolchain's table this command ends with.
const USAGE_ERROR: u8 = 2;
const PERMISSION: u8 = 3;
const CONFIRMATION_REQUIRED: u8 = 4;
const NOT_FOUND: u8 = 5;
const INTERNAL: u8 = 7;

/// The markers of the block this command owns in a Markdown file.
const BEGIN: &str = "<!-- wai agent instructions: begin -->";
const END: &str = "<!-- wai agent instructions: end -->";

/// How a file holds the MCP server registration.
#[derive(Clone, Copy)]
enum Form {
    /// `{"mcpServers": {"wai": {"command", "args"}}}`.
    McpServers,
    /// `{"servers": {"wai": {"type": "stdio", "command", "args"}}}`.
    Servers,
    /// `{"mcp": {"wai": {"type": "local", "command": [program, args...], "enabled": true}}}`.
    McpLocal,
    /// `.gemini/settings.json`'s `mcpServers`, and `context.fileName` naming AGENTS.md so that the
    /// agent reads it.
    McpServersAndContext,
}

/// A configuration file: its path in the project, what it is, its form, and when it is written
/// without `--all` (always, or when the directory or file it lives in already exists).
struct Target {
    path: &'static str,
    what: &'static str,
    form: Form,
    when_present: Option<&'static str>,
}

/// The MCP configuration files, by their documented formats.
const TARGETS: &[Target] = &[
    Target { path: ".mcp.json", what: "the project's MCP server list (mcpServers), read by several terminal agents", form: Form::McpServers, when_present: None },
    Target { path: ".cursor/mcp.json", what: "an editor's project MCP file (mcpServers)", form: Form::McpServers, when_present: Some(".cursor") },
    Target { path: ".vscode/mcp.json", what: "an editor workspace's MCP file (servers, type stdio)", form: Form::Servers, when_present: Some(".vscode") },
    Target { path: ".gemini/settings.json", what: "a terminal agent's project settings (mcpServers; context.fileName names AGENTS.md)", form: Form::McpServersAndContext, when_present: Some(".gemini") },
    Target { path: "opencode.json", what: "a terminal agent's project configuration (mcp, type local)", form: Form::McpLocal, when_present: Some("opencode.json") },
];

struct Opts {
    dir: PathBuf,
    dry_run: bool,
    confirm: bool,
    json: bool,
    all: bool,
    only: Option<Vec<String>>,
    toolsets: Option<Vec<String>>,
    command: String,
}

/// What would become of one file.
struct Plan {
    path: String,
    what: String,
    old: Option<String>,
    new: Option<String>,
    /// Set when the file is left alone, and why.
    skipped: Option<String>,
}

fn usage() -> &'static str {
    "usage: wai setup-agent [--dir <project>] [--dry-run] [--yes] [--json] [--all] [--only <file>[,<file>...]] [--toolset <set>[,<set>...]] [--command <program>]"
}

/// The toolsets this build's `wai mcp` serves, for `--toolset`.
const TOOLSETS: &[&str] = &[
    "core",
    "dev",
    #[cfg(feature = "app")]
    "app",
];

fn parse(args: &[String]) -> Result<Opts, String> {
    let mut o = Opts { dir: PathBuf::from("."), dry_run: false, confirm: false, json: false, all: false, only: None, toolsets: None, command: "wai".into() };
    let mut i = 0;
    while i < args.len() {
        let v = args.get(i + 1);
        match (args[i].as_str(), v) {
            ("--dry-run", _) => o.dry_run = true,
            ("--confirm", _) => o.confirm = true,
            ("--json", _) => o.json = true,
            ("--all", _) => o.all = true,
            ("--dir", Some(v)) => {
                o.dir = PathBuf::from(v);
                i += 1;
            }
            ("--only", Some(v)) => {
                o.only = Some(v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect());
                i += 1;
            }
            ("--toolset", Some(v)) => {
                let sets: Vec<String> = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
                if let Some(bad) = sets.iter().find(|s| !TOOLSETS.contains(&s.as_str())) {
                    return Err(format!("--toolset {bad}: no such toolset ({})", TOOLSETS.join(", ")));
                }
                o.toolsets = Some(sets);
                i += 1;
            }
            ("--command", Some(v)) => {
                o.command = v.clone();
                i += 1;
            }
            (a, _) => return Err(format!("{a}: not an option here. {}", usage())),
        }
        i += 1;
    }
    if let Some(only) = &o.only {
        let known: Vec<&str> = ["AGENTS.md", "CLAUDE.md"].into_iter().chain(TARGETS.iter().map(|t| t.path)).collect();
        if let Some(bad) = only.iter().find(|x| !known.contains(&x.as_str())) {
            return Err(format!("--only {bad}: not a file this command writes ({})", known.join(", ")));
        }
    }
    Ok(o)
}

/// The arguments the server is registered with: the verb, and the toolsets when named.
fn mcp_args(toolsets: Option<&[String]>) -> Vec<String> {
    let mut a = vec![super::MCP.to_string()];
    if let Some(t) = toolsets {
        a.push("--toolset".into());
        a.push(t.join(","));
    }
    a
}

/// AGENTS.md's block: what WAI is and how an agent uses it here.
fn agents_block(command: &str, args: &[String]) -> String {
    let server = std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
    format!(
        "{BEGIN}\n## WAI\n\n\
This project uses WAI, an open media standard. A `.wai` envelope carries media under a registered capability; \
receipts, claims and live-room records are signed so that a verifier can check them.\n\n\
- The `wai` MCP server (`{server}`, registered in this project's MCP configuration) gives you WAI's operations as \
tools, each named by its operation (`receipt.verify` is the tool `receipt_verify`). The `core` toolset inspects envelopes \
and packages, reads the capability registry, verifies receipts, claims, live-room sessions and audit bundles, packages \
media, lists and runs conformance corpora, and searches the specification. The `dev` toolset (`{command} {mcp} --toolset core,dev`) \
runs a development room on loopback{app}.\n\
- Verify with the tools. Do not judge a signature, a receipt or a session yourself: quote the verifier's verdict and its reason code.\n\
- The specification's text is the authority. Search it, and cite a rule by its section (`SPEC.md` §N, or the extension and its section).\n\
- The development tools are for development only. They run on 127.0.0.1 with keys held in memory and refuse any other \
service; never point a deployment at them.\n\
- No tool of the server sends anything off this machine.\n\
- The same operations are on the command line: `{command} inspect`, `{command} capabilities`, `{command} wrap`, \
`{command} verify`, `{command} dev`, `{command} registry export --check`.\n\
{END}\n",
        mcp = super::MCP,
        app = if TOOLSETS.contains(&"app") { ", and the `app` toolset writes, checks and exports apps made with Create" } else { "" },
    )
}

/// CLAUDE.md's block: it imports AGENTS.md, so the instructions live in one file.
fn claude_block() -> String {
    format!("{BEGIN}\nThis project's instructions for coding agents are in AGENTS.md:\n\n@AGENTS.md\n{END}\n")
}

/// `text` with the owned block set to `block`: replaced where the markers are, appended
/// otherwise.
fn with_block(text: Option<&str>, block: &str) -> String {
    let Some(t) = text else { return block.to_string() };
    if let (Some(b), Some(e)) = (t.find(BEGIN), t.find(END))
        && b < e
    {
        let end = e + END.len();
        let end = if t[end..].starts_with('\n') { end + 1 } else { end };
        return format!("{}{block}{}", &t[..b], &t[end..]);
    }
    let sep = if t.is_empty() || t.ends_with("\n\n") { "" } else if t.ends_with('\n') { "\n" } else { "\n\n" };
    format!("{t}{sep}{block}")
}

/// The server's entry in `form`.
fn entry(form: Form, command: &str, args: &[String]) -> Value {
    match form {
        Form::McpServers | Form::McpServersAndContext => json!({"command": command, "args": args}),
        Form::Servers => json!({"type": "stdio", "command": command, "args": args}),
        Form::McpLocal => json!({"type": "local", "command": std::iter::once(command.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>(), "enabled": true}),
    }
}

/// The file's JSON with the server registered, or why it cannot be.
fn with_server(text: Option<&str>, form: Form, command: &str, args: &[String]) -> Result<String, String> {
    let mut v: Value = match text {
        None => json!({}),
        Some(t) if t.trim().is_empty() => json!({}),
        Some(t) => serde_json::from_str(t).map_err(|e| format!("not plain JSON ({e}); add by hand: {}", json!({key(form): {"wai": entry(form, command, args)}})))?,
    };
    let root = v.as_object_mut().ok_or("its top level is not a JSON object")?;
    let list = root.entry(key(form)).or_insert_with(|| json!({}));
    let list = list.as_object_mut().ok_or_else(|| format!("its {} is not a JSON object", key(form)))?;
    list.insert("wai".into(), entry(form, command, args));
    if let Form::McpServersAndContext = form {
        let ctx = root.entry("context").or_insert_with(|| json!({}));
        let ctx = ctx.as_object_mut().ok_or("its context is not a JSON object")?;
        match ctx.get_mut("fileName") {
            None => {
                ctx.insert("fileName".into(), json!(["AGENTS.md", "GEMINI.md"]));
            }
            Some(Value::String(s)) if s != "AGENTS.md" => {
                let old = s.clone();
                ctx.insert("fileName".into(), json!(["AGENTS.md", old]));
            }
            Some(Value::Array(a)) if !a.iter().any(|x| x == "AGENTS.md") => a.insert(0, json!("AGENTS.md")),
            _ => {}
        }
    }
    let mut s = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
    s.push('\n');
    Ok(s)
}

fn key(form: Form) -> &'static str {
    match form {
        Form::McpServers | Form::McpServersAndContext => "mcpServers",
        Form::Servers => "servers",
        Form::McpLocal => "mcp",
    }
}

/// `rel` inside the project, checked: every existing ancestor resolves inside the project and
/// the file itself is no link.
fn checked(project: &Path, rel: &str) -> Result<PathBuf, String> {
    let p = project.join(rel);
    let mut existing = p.parent().unwrap_or(project).to_path_buf();
    while !existing.exists() {
        existing = existing.parent().map(Path::to_path_buf).ok_or("no ancestor exists")?;
    }
    let real = existing.canonicalize().map_err(|e| format!("{}: {e}", existing.display()))?;
    if !real.starts_with(project) {
        return Err(format!("{rel} resolves outside the project directory ({}): left alone", real.display()));
    }
    if std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
        return Err(format!("{rel} is a link: left alone"));
    }
    Ok(p)
}

/// Why the command stops: its exit status, the error's stable code, its message and a hint.
struct Fail {
    exit: u8,
    code: &'static str,
    message: String,
    hint: &'static str,
}

impl Fail {
    fn usage(message: String) -> Fail {
        Fail { exit: USAGE_ERROR, code: "usage", message, hint: "wai setup-agent --dry-run shows what it would write" }
    }

    /// A file that could not be read or written: permission, or anything else.
    fn io(p: &Path, e: &std::io::Error) -> Fail {
        if e.kind() == std::io::ErrorKind::PermissionDenied {
            Fail { exit: PERMISSION, code: "permission-denied", message: format!("{}: {e}", p.display()), hint: "check the files' permissions, or pass --only to leave a file out" }
        } else {
            Fail { exit: INTERNAL, code: "io-error", message: format!("{}: {e}", p.display()), hint: "check the file, or pass --only to leave it out" }
        }
    }
}

fn read(p: &Path) -> Result<Option<String>, Fail> {
    match std::fs::read_to_string(p) {
        Ok(t) => Ok(Some(t)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(Fail::io(p, &e)),
    }
}

fn plans(o: &Opts, project: &Path) -> Result<Vec<Plan>, Fail> {
    let selected = |path: &str| o.only.as_ref().is_none_or(|only| only.iter().any(|x| x == path));
    let mut out = Vec::new();
    let mut add = |path: &str, what: &str, make: &dyn Fn(Option<&str>) -> Result<String, String>| -> Result<(), Fail> {
        if !selected(path) {
            return Ok(());
        }
        let p = match checked(project, path) {
            Ok(p) => p,
            Err(why) => {
                out.push(Plan { path: path.into(), what: what.into(), old: None, new: None, skipped: Some(why) });
                return Ok(());
            }
        };
        let old = read(&p)?;
        match make(old.as_deref()) {
            Ok(new) => out.push(Plan { path: path.into(), what: what.into(), old, new: Some(new), skipped: None }),
            Err(why) => out.push(Plan { path: path.into(), what: what.into(), old, new: None, skipped: Some(why) }),
        }
        Ok(())
    };
    let args = mcp_args(o.toolsets.as_deref());
    add("AGENTS.md", "the instructions coding agents read", &|old| Ok(with_block(old, &agents_block(&o.command, &args))))?;
    // CLAUDE.md only where the project has one: an agent that reads it reads AGENTS.md when it
    // is absent, so it gains nothing from a new one.
    if std::fs::symlink_metadata(project.join("CLAUDE.md")).is_ok() {
        add("CLAUDE.md", "an instructions file the project has; it imports AGENTS.md", &|old| {
            // A CLAUDE.md that imports AGENTS.md already needs nothing.
            let imports = old.is_some_and(|t| t.lines().any(|l| l.trim() == "@AGENTS.md"));
            Ok(if imports { old.unwrap_or_default().to_string() } else { with_block(old, &claude_block()) })
        })?;
    }
    for t in TARGETS {
        let present = t.when_present.is_none_or(|p| project.join(p).exists());
        if !(present || o.all || o.only.as_ref().is_some_and(|only| only.iter().any(|x| x == t.path))) {
            continue;
        }
        let (form, command, args) = (t.form, o.command.clone(), args.clone());
        add(t.path, t.what, &move |old| with_server(old, form, &command, &args))?;
    }
    Ok(out)
}

/// A unified diff of one hunk: the lines between the common head and the common tail, with
/// three lines of context.
fn diff(path: &str, old: Option<&str>, new: &str) -> String {
    let a: Vec<&str> = old.map(|t| t.lines().collect()).unwrap_or_default();
    let b: Vec<&str> = new.lines().collect();
    let head = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
    let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
    let from = head.saturating_sub(3);
    let a_end = (a.len() - tail + 3).min(a.len());
    let b_end = (b.len() - tail + 3).min(b.len());
    let mut s = format!("--- {}\n+++ b/{path}\n", if old.is_some() { format!("a/{path}") } else { "/dev/null".into() });
    s.push_str(&format!("@@ -{},{} +{},{} @@\n", from + usize::from(!a.is_empty()), a_end - from, from + 1, b_end - from));
    for l in &a[from..head] {
        s.push_str(&format!(" {l}\n"));
    }
    for l in &a[head..a.len() - tail] {
        s.push_str(&format!("-{l}\n"));
    }
    for l in &b[head..b.len() - tail] {
        s.push_str(&format!("+{l}\n"));
    }
    for l in &b[b.len() - tail..b_end] {
        s.push_str(&format!(" {l}\n"));
    }
    s
}

fn ask(path: &str) -> bool {
    eprint!("change {path}? [y/N] ");
    let _ = std::io::stderr().flush();
    let mut line = String::new();
    std::io::stdin().lock().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes")
}

fn write(project: &Path, path: &str, text: &str) -> Result<(), Fail> {
    let refused = |why: String| Fail { exit: PERMISSION, code: "outside-project", message: why, hint: "the command writes inside the project directory alone" };
    let p = checked(project, path).map_err(refused)?;
    if let Some(dir) = p.parent()
        && !dir.exists()
    {
        std::fs::create_dir_all(dir).map_err(|e| Fail::io(dir, &e))?;
        // Created inside the project: check again that it resolves there.
        checked(project, path).map_err(refused)?;
    }
    std::fs::write(&p, text).map_err(|e| Fail::io(&p, &e))
}

/// The toolchain's JSON envelope for this command (`envelope/1`).
fn envelope(ok: bool, fail: Option<&Fail>) -> Map<String, Value> {
    let mut v = Map::new();
    v.insert("wai".into(), json!(wai::catalogue::ENVELOPE));
    v.insert("op".into(), json!(OP));
    v.insert("ok".into(), json!(ok));
    if let Some(f) = fail {
        v.insert("error".into(), json!({"code": f.code, "message": f.message, "hint": f.hint, "retryable": false}));
        v.insert("exit".into(), json!(f.exit));
    }
    v
}

/// `wai setup-agent` (`agent.setup`), through the catalogue's door: the agent flags `wai` read
/// are handed on, and the command reports its own outcome.
pub fn main(args: &[String]) -> Result<bool, OpError> {
    let c = crate::dx::envelope::ctx();
    let mut a = args.to_vec();
    for (on, flag) in [(c.dry_run, "--dry-run"), (c.yes, "--confirm"), (c.json, "--json")] {
        if on {
            a.push(flag.into());
        }
    }
    match run(&a) {
        0 => Ok(true),
        code => Err(OpError::reported(code)),
    }
}

/// `wai setup-agent`: its exit status, its output written.
fn run(args: &[String]) -> u8 {
    let json_out = args.iter().any(|a| a == "--json");
    let fail = |f: Fail| {
        if json_out {
            println!("{}", Value::Object(envelope(false, Some(&f))));
        } else {
            eprintln!("error: {}\nhint: {}", f.message, f.hint);
        }
        f.exit
    };
    let o = match parse(args) {
        Ok(o) => o,
        Err(e) => return fail(Fail::usage(e)),
    };
    let project = match o.dir.canonicalize() {
        Ok(p) if p.is_dir() => p,
        Ok(p) => return fail(Fail::usage(format!("{}: not a directory", p.display()))),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return fail(Fail { exit: NOT_FOUND, code: "not-found", message: format!("{}: {e}", o.dir.display()), hint: "give the project's directory with --dir" });
        }
        Err(e) => return fail(Fail::io(&o.dir, &e)),
    };
    let plans = match plans(&o, &project) {
        Ok(p) => p,
        Err(f) => return fail(f),
    };
    let interactive = !o.json && !o.confirm && !o.dry_run && std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
    let mut files = Vec::new();
    let mut proposed = Vec::new();
    let mut failed: Option<Fail> = None;
    for p in &plans {
        let mut f = Map::new();
        f.insert("path".into(), json!(p.path));
        f.insert("what".into(), json!(p.what));
        let action = match (&p.skipped, &p.new) {
            (Some(why), _) => {
                f.insert("reason".into(), json!(why));
                "skipped"
            }
            (None, Some(new)) if p.old.as_deref() == Some(new.as_str()) => "unchanged",
            (None, Some(new)) => {
                let d = diff(&p.path, p.old.as_deref(), new);
                f.insert("diff".into(), json!(d));
                let exists = p.old.is_some();
                if o.dry_run {
                    if exists { "would_update" } else { "would_create" }
                } else {
                    let go = !exists || o.confirm || (interactive && {
                        println!("{d}");
                        ask(&p.path)
                    });
                    if go {
                        match write(&project, &p.path, new) {
                            Ok(()) => if exists { "updated" } else { "created" },
                            Err(e) => {
                                f.insert("reason".into(), json!(e.message));
                                failed.get_or_insert(e);
                                "failed"
                            }
                        }
                    } else if interactive {
                        "declined"
                    } else {
                        proposed.push(json!({"action": "update", "path": p.path, "diff": d}));
                        "needs_confirmation"
                    }
                }
            }
            (None, None) => "skipped",
        };
        f.insert("action".into(), json!(action));
        files.push(Value::Object(f));
    }
    let confirm = format!("wai setup-agent --dir {} --yes{}{}{}", shell_word(&project.display().to_string()),
                          o.only.as_ref().map(|x| format!(" --only {}", x.join(","))).unwrap_or_default(),
                          o.toolsets.as_ref().map(|x| format!(" --toolset {}", x.join(","))).unwrap_or_default(),
                          if o.all { " --all" } else { "" });
    let waiting = !proposed.is_empty();
    let pending = Fail {
        exit: CONFIRMATION_REQUIRED,
        code: "confirmation-required",
        message: format!("{} change{} to existing files", proposed.len(), if proposed.len() == 1 { "" } else { "s" }),
        hint: "read each diff, then re-run with --yes",
    };
    let stop = failed.as_ref().or(if waiting { Some(&pending) } else { None });
    let code = stop.map_or(0, |f| f.exit);
    if json_out {
        let mut v = envelope(code == 0, stop);
        v.insert("data".into(), json!({"project": project.display().to_string(), "dry_run": o.dry_run, "files": files}));
        if o.dry_run {
            // The envelope's dry-run members, as every command gives them.
            let plan: Vec<Value> = files.iter().filter(|f| f["action"] == "would_create" || f["action"] == "would_update")
                .map(|f| json!({"write": f["path"], "replaces": f["action"] == "would_update"})).collect();
            v.insert("dry_run".into(), json!(true));
            v.insert("plan".into(), json!(plan));
        }
        if waiting {
            v.insert("proposed".into(), json!(proposed));
            v.insert("confirm".into(), json!(confirm));
        }
        println!("{}", Value::Object(v));
    } else {
        println!("wai setup-agent — {}", project.display());
        for f in &files {
            println!("  {:<22} {:<19} {}", f["path"].as_str().unwrap_or(""), f["action"].as_str().unwrap_or(""), f["what"].as_str().unwrap_or(""));
            if let Some(r) = f["reason"].as_str() {
                println!("  {:<22} {r}", "");
            }
            if (o.dry_run || f["action"] == "needs_confirmation") && let Some(d) = f["diff"].as_str() {
                println!("{d}");
            }
        }
        if waiting {
            println!("existing files would change and nothing confirmed it; to apply: {confirm}");
        }
        if o.dry_run {
            println!("dry run: nothing was written");
        }
        if let Some(f) = &failed {
            eprintln!("error: {}\nhint: {}", f.message, f.hint);
        }
    }
    code
}

/// A path as one shell word.
fn shell_word(s: &str) -> String {
    if s.chars().all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c)) { s.to_string() } else { format!("'{}'", s.replace('\'', "'\\''")) }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_block_is_added_once_and_replaced_in_place() {
        let b1 = format!("{BEGIN}\none\n{END}\n");
        let b2 = format!("{BEGIN}\ntwo\n{END}\n");
        let t = with_block(Some("# Mine\n\nkeep\n"), &b1);
        assert_eq!(t, format!("# Mine\n\nkeep\n\n{b1}"));
        assert_eq!(with_block(Some(&t), &b1), t, "the same block again changes nothing");
        let t2 = with_block(Some(&format!("{t}after\n")), &b2);
        assert_eq!(t2, format!("# Mine\n\nkeep\n\n{b2}after\n"), "replaced where it is, the rest kept");
    }

    #[test]
    fn a_server_list_keeps_its_other_members() {
        let t = r#"{"mcpServers": {"other": {"command": "x"}}, "z": 1}"#;
        let args = mcp_args(None);
        let v: Value = serde_json::from_str(&with_server(Some(t), Form::McpServers, "wai", &args).unwrap()).unwrap();
        assert_eq!(v["mcpServers"]["other"]["command"], "x");
        assert_eq!(v["mcpServers"]["wai"]["args"], json!([super::super::MCP]));
        assert_eq!(v["z"], 1);
        assert!(with_server(Some("{ // a comment\n}"), Form::Servers, "wai", &args).is_err());
        let dev = mcp_args(Some(&["core".to_string(), "dev".to_string()]));
        let l: Value = serde_json::from_str(&with_server(None, Form::McpLocal, "wai", &dev).unwrap()).unwrap();
        assert_eq!(l["mcp"]["wai"]["command"], json!(["wai", "mcp", "--toolset", "core,dev"]));
        let g: Value = serde_json::from_str(&with_server(Some(r#"{"context": {"fileName": "GEMINI.md"}}"#), Form::McpServersAndContext, "wai", &args).unwrap()).unwrap();
        assert_eq!(g["context"]["fileName"], json!(["AGENTS.md", "GEMINI.md"]));
    }

    #[test]
    fn a_diff_shows_the_changed_lines() {
        let d = diff("x", Some("a\nb\nc\n"), "a\nB\nc\n");
        assert!(d.contains("-b\n+B\n"), "{d}");
        let d = diff("x", None, "a\n");
        assert!(d.starts_with("--- /dev/null\n+++ b/x\n") && d.contains("+a\n"), "{d}");
    }
}
