quinjet 0.0.48

A fast, live, keyboard-first Git source-control interface for the terminal
use super::*;

fn capability_document() -> Result<serde_json::Value> {
    run_in(None, &["capabilities", "--json"])?.success()?.json()
}

#[test]
fn capabilities_describe_the_installed_command_tree() -> Result<()> {
    let document = capability_document()?;
    ensure!(document["schemaVersion"] == 1);
    ensure!(document["version"] == env!("CARGO_PKG_VERSION"));
    let commands = document["commands"]
        .as_array()
        .context("capabilities commands were not an array")?;
    let completion = commands
        .iter()
        .find(|command| command["path"] == "quinjet completions")
        .context("capabilities omitted completions")?;
    ensure!(
        completion["arguments"][0]["possibleValues"]
            == serde_json::json!(["bash", "elvish", "fish", "powershell", "zsh"])
    );
    let tui = commands
        .iter()
        .find(|command| command["path"] == "quinjet tui")
        .context("capabilities omitted tui")?;
    let theme = tui["arguments"]
        .as_array()
        .and_then(|arguments| arguments.iter().find(|argument| argument["id"] == "theme"))
        .context("tui capabilities omitted --theme")?;
    ensure!(theme["defaultValues"] == serde_json::json!(["quinjet"]));
    ensure!(
        theme["possibleValues"]
            == serde_json::json!([
                "quinjet",
                "catppuccin",
                "dracula",
                "everforest",
                "gruvbox",
                "nord",
                "one",
                "rose-pine",
                "solarized",
                "tokyo-night",
                "ayu",
                "monokai",
                "github"
            ])
    );
    let appearance = tui["arguments"]
        .as_array()
        .and_then(|arguments| {
            arguments
                .iter()
                .find(|argument| argument["id"] == "appearance")
        })
        .context("tui capabilities omitted --appearance")?;
    ensure!(appearance["defaultValues"] == serde_json::json!(["system"]));
    ensure!(appearance["possibleValues"] == serde_json::json!(["system", "light", "dark"]));
    let stage = commands
        .iter()
        .find(|command| command["path"] == "quinjet stage")
        .context("capabilities omitted stage")?;
    ensure!(
        stage["usage"]
            .as_str()
            .is_some_and(|usage| usage.contains("<PATH|--all>"))
    );
    let all = stage["arguments"]
        .as_array()
        .and_then(|arguments| arguments.iter().find(|argument| argument["id"] == "all"))
        .context("stage capabilities omitted --all")?;
    ensure!(all["action"] == "set_true");
    ensure!(all["minValues"] == 0 && all["maxValues"] == 0);
    ensure!(all["possibleValues"] == serde_json::json!([]));
    let selection = stage["groups"]
        .as_array()
        .and_then(|groups| {
            groups
                .iter()
                .find(|group| group["arguments"] == serde_json::json!(["paths", "all"]))
        })
        .context("stage capabilities omitted its required selection group")?;
    ensure!(selection["required"] == true);
    ensure!(selection["multiple"] == false);

    let status = commands
        .iter()
        .find(|command| command["path"] == "quinjet status")
        .context("capabilities omitted status")?;
    let interval = status["arguments"]
        .as_array()
        .and_then(|arguments| {
            arguments
                .iter()
                .find(|argument| argument["id"] == "interval")
        })
        .context("status capabilities omitted --interval")?;
    ensure!(interval["action"] == "set");
    ensure!(interval["defaultValues"] == serde_json::json!(["2"]));
    Ok(())
}

#[test]
fn capability_paths_are_complete_unique_and_parent_first() -> Result<()> {
    let document = capability_document()?;
    let commands = document["commands"]
        .as_array()
        .context("capabilities commands were not an array")?;
    ensure!(
        commands.len() == 85,
        "unexpected command count: {}",
        commands.len()
    );
    let root = commands.first().context("capabilities omitted the root")?;
    ensure!(root["path"] == "quinjet");
    ensure!(
        root["subcommands"]
            == serde_json::json!([
                "tui",
                "status",
                "diff",
                "stage",
                "unstage",
                "discard",
                "remove",
                "commit",
                "fetch",
                "pull",
                "push",
                "sync",
                "log",
                "show",
                "branch",
                "stash",
                "worktree",
                "project",
                "cherry-pick",
                "revert",
                "resolve",
                "repos",
                "remote",
                "pr",
                "completions",
                "man",
                "capabilities",
                "update"
            ])
    );
    let paths: Vec<&str> = commands
        .iter()
        .map(|command| {
            command["path"]
                .as_str()
                .context("a capability command omitted its path")
        })
        .collect::<Result<_>>()?;
    let mut unique = std::collections::HashSet::new();
    for (index, path) in paths.iter().enumerate() {
        ensure!(unique.insert(*path), "duplicate capability path: {path}");
        let Some((parent, name)) = path.rsplit_once(' ') else {
            continue;
        };
        let parent_index = paths
            .iter()
            .position(|candidate| *candidate == parent)
            .with_context(|| format!("{path} has no parent capability"))?;
        ensure!(parent_index < index, "{parent} did not precede {path}");
        ensure!(
            commands[parent_index]["subcommands"]
                .as_array()
                .is_some_and(|children| children.iter().any(|child| child == name))
        );
    }
    ensure!(!unique.contains("quinjet rm"));
    ensure!(!unique.contains("quinjet completion"));
    Ok(())
}

#[test]
fn every_capability_carries_the_global_arguments() -> Result<()> {
    let document = capability_document()?;
    let commands = document["commands"]
        .as_array()
        .context("capabilities commands were not an array")?;
    for command in commands {
        let path = command["path"]
            .as_str()
            .context("command path was absent")?;
        let arguments = command["arguments"]
            .as_array()
            .with_context(|| format!("{path} arguments were not an array"))?;
        let repository = arguments
            .iter()
            .find(|argument| argument["id"] == "repository")
            .with_context(|| format!("{path} omitted -C/--path"))?;
        ensure!(repository["short"] == "C" && repository["long"] == "path");
        ensure!(repository["action"] == "set");
        ensure!(repository["defaultValues"] == serde_json::json!(["."]));
        let json = arguments
            .iter()
            .find(|argument| argument["id"] == "json")
            .with_context(|| format!("{path} omitted --json"))?;
        ensure!(json["short"].is_null() && json["long"] == "json");
        ensure!(json["action"] == "set_true");
        ensure!(json["defaultValues"] == serde_json::json!(["false"]));
    }
    Ok(())
}

#[test]
fn plain_capabilities_preserve_the_json_path_order() -> Result<()> {
    let document = capability_document()?;
    let commands = document["commands"]
        .as_array()
        .context("capabilities commands were not an array")?;
    let plain = run_in(None, &["capabilities"])?.success()?;
    let listed: Vec<&str> = plain
        .stdout
        .lines()
        .filter(|line| line.starts_with("quinjet"))
        .collect();
    ensure!(listed.len() == commands.len());
    for (line, command) in listed.iter().zip(commands) {
        let path = command["path"]
            .as_str()
            .context("command path was absent")?;
        ensure!(
            line.starts_with(&format!("{path}  ")),
            "unexpected line: {line}"
        );
    }
    ensure!(plain.stderr.is_empty());
    Ok(())
}

#[test]
fn completion_json_contains_the_exact_plain_script() -> Result<()> {
    let plain = run_in(None, &["completions", "bash"])?.success()?;
    let structured = run_in(None, &["completions", "bash", "--json"])?
        .success()?
        .json()?;
    let object = structured
        .as_object()
        .context("completion JSON was not an object")?;
    ensure!(object.len() == 2);
    ensure!(structured["shell"] == "bash");
    ensure!(structured["script"] == plain.stdout);
    Ok(())
}

#[cfg(not(windows))]
#[test]
fn bash_accepts_the_generated_completion_script() -> Result<()> {
    let scratch = Scratch::directory()?;
    let run = scratch.quinjet(&["completions", "bash"])?.success()?;
    scratch.write("quinjet.bash", &run.stdout)?;
    let mut command = ProcessCommand::new("bash");
    command.arg("-n").arg(scratch.path.join("quinjet.bash"));
    isolate_git(&mut command);
    let output = command
        .output()
        .context("failed to validate bash completions")?;
    ensure!(
        output.status.success(),
        "bash rejected completions: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    Ok(())
}

#[test]
fn man_prints_one_page_and_writes_one_per_command() -> Result<()> {
    let capabilities = capability_document()?;
    let commands = capabilities["commands"]
        .as_array()
        .context("capabilities commands were not an array")?;
    let expected: std::collections::BTreeSet<String> = commands
        .iter()
        .map(|command| {
            command["path"]
                .as_str()
                .context("command path was absent")
                .map(|path| format!("{}.1", path.replace(' ', "-")))
        })
        .collect::<Result<_>>()?;
    let scratch = Scratch::directory()?;
    let page = scratch.quinjet(&["man"])?.success()?;
    ensure!(
        page.stdout.contains(".TH QUINJET"),
        "man page misses its title header"
    );
    let target = scratch.path.join("man");
    let target_argument = target.display().to_string();
    let written = scratch
        .quinjet(&["man", "--dir", &target_argument, "--json"])?
        .success()?
        .json()?;
    let object = written.as_object().context("man JSON was not an object")?;
    ensure!(object.len() == 1);
    let reported: std::collections::BTreeSet<String> = written["pages"]
        .as_array()
        .context("man JSON pages were not an array")?
        .iter()
        .map(|page| {
            Path::new(page.as_str().context("man page path was not text")?)
                .file_name()
                .and_then(|name| name.to_str())
                .map(str::to_owned)
                .context("man page path had no UTF-8 file name")
        })
        .collect::<Result<_>>()?;
    let actual: std::collections::BTreeSet<String> = fs::read_dir(&target)?
        .map(|entry| {
            entry?
                .file_name()
                .into_string()
                .map_err(|_| anyhow::anyhow!("man page file name was not UTF-8"))
        })
        .collect::<Result<_>>()?;
    ensure!(
        reported == expected,
        "reported pages differ from capabilities"
    );
    ensure!(actual == expected, "written pages differ from capabilities");
    let nested = fs::read_to_string(target.join("quinjet-branch-create.1"))?;
    ensure!(
        nested.contains("quinjet branch create"),
        "nested synopsis lost its command path: {nested}"
    );
    ensure!(
        nested.contains("\\-\\-json") && nested.contains("\\-C"),
        "nested page lost global options: {nested}"
    );
    Ok(())
}