req-cli 0.1.0

Managed requirements CLI for LLM agents and humans
// Implements REQ-0015 (interactive terminal menu for humans) and the TUI
// half of REQ-0083 (cross-surface parity): the menu exposes every
// agent-relevant CLI operation so a human at the terminal can achieve
// the same things an agent reaches for via MCP or the flag CLI.
use anyhow::Result;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use std::path::PathBuf;

use crate::cli::{
    AddArgs, CoverageArgs, DeleteArgs, DiffArgs, DoctorArgs, ExportArgs, ExportFormat, ListArgs,
    NextArgs, ShowArgs, StaleArgs, StatusArgs, UpdateArgs, ValidateArgs, VersionArgs,
};
use crate::commands;
use crate::storage::load_resolved;

/// The full TUI menu. Each entry maps to a top-level CLI command so the
/// surfaces stay one-to-one. See REQ-0083 for the parity contract.
pub const MENU: &[&str] = &[
    "Browse / view (list + show)",
    "Status",
    "Next requirement to work on",
    "Add",
    "Update",
    "Link",
    "Delete (mark obsolete)",
    "Validate project",
    "Coverage report",
    "Stale report",
    "Doctor (setup audit)",
    "Diff between git refs",
    "Audit (git signature trail)",
    "Export to markdown (stdout)",
    "Version",
    "Quit",
];

pub fn run(file: &Option<PathBuf>) -> Result<()> {
    let theme = ColorfulTheme::default();
    loop {
        let (_, project) = load_resolved(file)?;
        let header = format!(
            "req — {} ({} requirements)",
            project.name,
            project.requirements.len()
        );
        println!("\n{}", header);
        println!("{}", "=".repeat(header.len()));

        let idx = Select::with_theme(&theme)
            .with_prompt("Action")
            .items(MENU)
            .default(0)
            .interact()?;

        let action = match dispatch(idx, file, &project, &theme) {
            Ok(()) => continue,
            Err(e) if e.to_string() == "__quit__" => return Ok(()),
            Err(e) => Err(e),
        };
        action?;
    }
}

fn dispatch(
    idx: usize,
    file: &Option<PathBuf>,
    project: &crate::model::Project,
    theme: &ColorfulTheme,
) -> Result<()> {
    match MENU[idx] {
        "Browse / view (list + show)" => browse(file, project),
        "Status" => commands::status::run(StatusArgs { json: false }, file),
        "Next requirement to work on" => commands::next::run(default_next(), file),
        "Add" => commands::add::run(default_add(), file),
        "Update" => {
            if let Some(id) = pick_id(theme, project)? {
                commands::update::run(default_update(id), file)?;
            }
            Ok(())
        }
        "Link" => link_flow(file, project, theme),
        "Delete (mark obsolete)" => {
            if let Some(id) = pick_id(theme, project)? {
                commands::delete::run(
                    DeleteArgs {
                        id,
                        hard: false,
                        reason: None,
                        json: false,
                    },
                    file,
                )?;
            }
            Ok(())
        }
        "Validate project" => commands::validate_cmd::run(ValidateArgs { json: false }, file),
        "Coverage report" => commands::coverage::run(default_coverage(), file),
        "Stale report" => commands::stale::run(default_stale(), file),
        "Doctor (setup audit)" => commands::doctor::run(DoctorArgs { json: false }),
        "Diff between git refs" => diff_flow(file, theme),
        "Audit (git signature trail)" => audit_flow(file),
        "Export to markdown (stdout)" => commands::export::run(
            ExportArgs {
                format: ExportFormat::Markdown,
                output: "-".into(),
            },
            file,
        ),
        "Version" => commands::version::run(VersionArgs { json: false }),
        "Quit" => Err(anyhow::anyhow!("__quit__")),
        _ => Ok(()),
    }
}

fn link_flow(
    file: &Option<PathBuf>,
    project: &crate::model::Project,
    theme: &ColorfulTheme,
) -> Result<()> {
    let from = match pick_id(theme, project)? {
        Some(id) => id,
        None => return Ok(()),
    };
    let to = match pick_id(theme, project)? {
        Some(id) => id,
        None => return Ok(()),
    };
    let kinds = ["parent", "depends-on", "refines", "conflicts", "verifies"];
    let idx = Select::with_theme(theme)
        .with_prompt("Link kind")
        .items(&kinds)
        .default(0)
        .interact()?;
    let kind = match kinds[idx] {
        "parent" => crate::cli::LinkKindArg::Parent,
        "depends-on" => crate::cli::LinkKindArg::DependsOn,
        "refines" => crate::cli::LinkKindArg::Refines,
        "conflicts" => crate::cli::LinkKindArg::Conflicts,
        _ => crate::cli::LinkKindArg::Verifies,
    };
    commands::link::run(
        crate::cli::LinkArgs {
            from,
            to,
            kind,
            remove: false,
            json: false,
        },
        file,
    )
}

fn diff_flow(file: &Option<PathBuf>, theme: &ColorfulTheme) -> Result<()> {
    let spec: String = Input::with_theme(theme)
        .with_prompt("Git diff spec (e.g. origin/main..HEAD)")
        .default("HEAD~1..HEAD".to_string())
        .interact_text()?;
    commands::diff::run(DiffArgs { spec, json: false }, file)
}

fn audit_flow(file: &Option<PathBuf>) -> Result<()> {
    use crate::cli::AuditArgs;
    commands::audit::run(
        AuditArgs {
            limit: 20,
            gate: false,
            require_good_signature: false,
            required_signers: Vec::new(),
            json: false,
        },
        file,
    )
}

fn browse(file: &Option<PathBuf>, project: &crate::model::Project) -> Result<()> {
    if project.requirements.is_empty() {
        println!("(no requirements yet)");
        return Ok(());
    }
    let theme = ColorfulTheme::default();
    let ids: Vec<String> = project.requirements.keys().cloned().collect();
    let labels: Vec<String> = ids
        .iter()
        .map(|id| {
            let r = &project.requirements[id];
            format!("{:<10} [{}] {}", id, r.status.as_str(), r.title)
        })
        .collect();
    let pick = Select::with_theme(&theme)
        .with_prompt("Pick a requirement")
        .items(&labels)
        .default(0)
        .interact_opt()?;
    if let Some(i) = pick {
        commands::show::run(
            ShowArgs {
                id: ids[i].clone(),
                json: false,
            },
            file,
        )?;
    }
    Ok(())
}

fn pick_id(theme: &ColorfulTheme, project: &crate::model::Project) -> Result<Option<String>> {
    if project.requirements.is_empty() {
        println!("(no requirements yet)");
        return Ok(None);
    }
    let ids: Vec<String> = project.requirements.keys().cloned().collect();
    let labels: Vec<String> = ids
        .iter()
        .map(|id| format!("{}{}", id, project.requirements[id].title))
        .collect();
    let pick = Select::with_theme(theme)
        .items(&labels)
        .default(0)
        .interact_opt()?;
    Ok(pick.map(|i| ids[i].clone()))
}

fn default_add() -> AddArgs {
    AddArgs {
        title: None,
        statement: None,
        rationale: None,
        acceptance: vec![],
        kind: None,
        priority: None,
        tag: vec![],
        parent: None,
        interactive: true,
        json: false,
        from_json: None,
    }
}

fn default_update(id: String) -> UpdateArgs {
    UpdateArgs {
        id,
        title: None,
        statement: None,
        rationale: None,
        acceptance: None,
        add_acceptance: vec![],
        remove_acceptance: vec![],
        kind: None,
        priority: None,
        status: None,
        add_tag: vec![],
        remove_tag: vec![],
        reason: None,
        json: false,
    }
}

// Unused for now; kept for completeness so list-style filtering compiles if reused.
#[allow(dead_code)]
fn default_list() -> ListArgs {
    ListArgs {
        status: None,
        include_obsolete: false,
        kind: None,
        priority: None,
        tag: vec![],
        query: None,
        json: false,
    }
}

fn default_next() -> NextArgs {
    NextArgs {
        status: None,
        kind: None,
        priority: None,
        tag: vec![],
        json: false,
    }
}

fn default_coverage() -> CoverageArgs {
    CoverageArgs {
        path: PathBuf::from("."),
        extensions: vec![],
        unlinked_files: false,
        by_file: false,
        remap: vec![],
        apply: false,
        strict: false,
        allow_orphans: vec![],
        json: false,
    }
}

fn default_stale() -> StaleArgs {
    StaleArgs {
        path: PathBuf::from("."),
        only_stale: false,
        json: false,
    }
}