req-cli 0.3.1

Managed requirements CLI for LLM agents and humans
// Discharges REQ-0001 (add sub-surface), REQ-0008 (acceptance gate at write),
// REQ-0038 (--json output on creates).
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
use std::path::PathBuf;

use crate::cli::AddArgs;
use crate::model::{Kind, Link, LinkKind, Priority, Requirement, Status};
use crate::storage::{self, load_for_mutation};
use crate::validate;

pub fn run(args: AddArgs, file: &Option<PathBuf>) -> Result<()> {
    // REQ-0072: --from-json bypasses shell quoting for multi-line content.
    let args = if args.from_json.is_some() {
        let src = args.from_json.clone().unwrap();
        merge_from_json(args, &src)?
    } else {
        args
    };

    let (path, mut project, _lock) = load_for_mutation(file)?;

    let interactive = args.interactive
        || (args.title.is_none()
            && args.statement.is_none()
            && args.from_json.is_none()
            && atty_stdin());

    let theme = ColorfulTheme::default();

    let title = match args.title {
        Some(t) => t,
        None if interactive => Input::with_theme(&theme)
            .with_prompt("Title (imperative, ≤120 chars)")
            .interact_text()?,
        None => return Err(anyhow!("--title is required in non-interactive mode")),
    };

    let statement = match args.statement {
        Some(s) => s,
        None if interactive => Input::with_theme(&theme)
            .with_prompt("Statement (use shall/must/should/will)")
            .interact_text()?,
        None => return Err(anyhow!("--statement is required in non-interactive mode")),
    };

    let rationale = match args.rationale {
        Some(r) => r,
        None if interactive => Input::with_theme(&theme)
            .with_prompt("Rationale (why does this exist?)")
            .interact_text()?,
        None => return Err(anyhow!("--rationale is required in non-interactive mode")),
    };

    let kind: Kind = match args.kind {
        Some(k) => k.into(),
        None if interactive => {
            let opts = [
                "Functional",
                "NonFunctional",
                "Constraint",
                "Interface",
                "Business",
            ];
            let idx = Select::with_theme(&theme)
                .with_prompt("Kind")
                .items(&opts)
                .default(0)
                .interact()?;
            match idx {
                0 => Kind::Functional,
                1 => Kind::NonFunctional,
                2 => Kind::Constraint,
                3 => Kind::Interface,
                _ => Kind::Business,
            }
        }
        None => Kind::Functional,
    };

    let priority: Priority = match args.priority {
        Some(p) => p.into(),
        None if interactive => {
            let opts = ["Must", "Should", "Could", "Wont"];
            let idx = Select::with_theme(&theme)
                .with_prompt("Priority (MoSCoW)")
                .items(&opts)
                .default(1)
                .interact()?;
            match idx {
                0 => Priority::Must,
                1 => Priority::Should,
                2 => Priority::Could,
                _ => Priority::Wont,
            }
        }
        None => Priority::Should,
    };

    let mut acceptance = args.acceptance;
    if interactive && matches!(kind, Kind::Functional) && acceptance.is_empty() {
        println!("Acceptance criteria (blank line to finish):");
        loop {
            let line: String = Input::with_theme(&theme)
                .with_prompt(format!("  AC #{}", acceptance.len() + 1))
                .allow_empty(true)
                .interact_text()?;
            if line.trim().is_empty() {
                break;
            }
            acceptance.push(line);
        }
    }

    let mut tags = args.tag;
    if interactive && tags.is_empty() {
        let raw: String = Input::with_theme(&theme)
            .with_prompt("Tags (comma-separated, blank to skip)")
            .allow_empty(true)
            .interact_text()?;
        tags = raw
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
    }

    let mut links = Vec::new();
    if let Some(parent) = &args.parent {
        if !project.requirements.contains_key(parent) {
            return Err(anyhow!("parent {} does not exist", parent));
        }
        links.push(Link {
            kind: LinkKind::Parent,
            target: parent.clone(),
        });
    } else if interactive && !project.requirements.is_empty() {
        let ids: Vec<&String> = project.requirements.keys().collect();
        let display: Vec<String> = ids
            .iter()
            .map(|id| format!("{}{}", id, project.requirements[*id].title))
            .collect();
        let picks = MultiSelect::with_theme(&theme)
            .with_prompt("Link to parents (space to toggle, enter to confirm)")
            .items(&display)
            .interact()?;
        for i in picks {
            links.push(Link {
                kind: LinkKind::Parent,
                target: ids[i].clone(),
            });
        }
    }

    let now = Utc::now();
    // Build with placeholder id; validate BEFORE allocating so failed adds
    // do not consume IDs (REQ-0010: stable sequential allocation).
    let mut req = Requirement {
        id: String::new(),
        title,
        statement,
        rationale,
        acceptance,
        kind,
        priority,
        status: Status::Draft,
        tags,
        links,
        created: now,
        updated: now,
        history: vec![super::history("created", None)],
        tests: Vec::new(),
    };

    let findings = validate::validate_requirement(&req);
    let errors = validate::errors_only(&findings);

    // REQ-0095: warn when the new title is very similar to a
    // requirement retired to Obsolete in the last 60 days. The common
    // mistake is to `req delete` then immediately re-add the same idea
    // under a fresh ID, losing the history thread. Surface the prior
    // ID so the author can choose to update or split-keep instead.
    let recent_obsolete_match = find_recent_obsolete_similar(&project, &req.title, &req.statement);
    if let Some((prior_id, similarity)) = recent_obsolete_match {
        eprintln!(
            "Note: title looks similar ({:.0}%) to recently-obsolete {}\
             consider `req update {} ...` or `req split {} --keep-original ...` \
             instead of creating a fresh ID.",
            similarity * 100.0,
            prior_id,
            prior_id,
            prior_id
        );
    }

    if !findings.is_empty() {
        eprintln!("Validation:");
        for f in &findings {
            eprintln!(
                "  {} [{}] {}",
                if f.error { "ERR " } else { "WARN" },
                f.field,
                f.message
            );
        }
        // Force the validation block out *before* the stdout "Added"
        // line so users don't see the success first and miss the
        // warnings underneath when the two streams interleave on a
        // terminal.
        use std::io::Write;
        let _ = std::io::stderr().flush();
    }
    if !errors.is_empty() {
        if interactive {
            let proceed = Confirm::with_theme(&theme)
                .with_prompt("Errors above. Save anyway as Draft?")
                .default(false)
                .interact()?;
            if !proceed {
                return Err(anyhow!("aborted"));
            }
        } else {
            return Err(anyhow!(
                "{} validation errors — fix and retry",
                errors.len()
            ));
        }
    }

    let id = project.allocate_id();
    req.id = id.clone();
    project.requirements.insert(id.clone(), req.clone());
    project.updated = now;
    storage::save(&path, &project)?;
    if args.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&project.requirements[&id])?
        );
    } else {
        println!("Added {}", id);
        // Discoverability nudge: the gate fires on changed source
        // files with no `// REQ-NNNN:` reference. Closing the loop
        // from "REQ created" to "REQ referenced from code" is the
        // discipline that prevents 0.1.x-era drift.
        println!(
            "Next: add `// {}:` to the source file that implements this, \
             or run `req coverage --path src` to find unlinked files.",
            id
        );
    }
    Ok(())
}

fn atty_stdin() -> bool {
    use std::io::IsTerminal;
    std::io::stdin().is_terminal()
}

/// REQ-0072: load `--from-json` (file path or `-` for stdin) and overlay its
/// fields onto the existing AddArgs. CLI flags take precedence when both are
/// supplied (so `--from-json doc.json --priority must` lets you override).
fn merge_from_json(mut args: AddArgs, src: &str) -> Result<AddArgs> {
    use std::io::Read;
    let raw = if src == "-" {
        let mut buf = String::new();
        std::io::stdin().read_to_string(&mut buf)?;
        buf
    } else {
        std::fs::read_to_string(src).with_context(|| format!("read --from-json source {}", src))?
    };
    #[derive(serde::Deserialize, Default)]
    struct AddDoc {
        title: Option<String>,
        statement: Option<String>,
        rationale: Option<String>,
        acceptance: Option<Vec<String>>,
        kind: Option<String>,
        priority: Option<String>,
        tags: Option<Vec<String>>,
        parent: Option<String>,
    }
    let doc: AddDoc = serde_json::from_str(&raw).context("parse --from-json document")?;
    if args.title.is_none() {
        args.title = doc.title;
    }
    if args.statement.is_none() {
        args.statement = doc.statement;
    }
    if args.rationale.is_none() {
        args.rationale = doc.rationale;
    }
    if args.acceptance.is_empty() {
        if let Some(a) = doc.acceptance {
            args.acceptance = a;
        }
    }
    if args.kind.is_none() {
        if let Some(k) = doc.kind {
            args.kind = Some(match k.as_str() {
                "functional" => crate::cli::KindArg::Functional,
                "non-functional" | "nonfunctional" => crate::cli::KindArg::NonFunctional,
                "constraint" => crate::cli::KindArg::Constraint,
                "interface" => crate::cli::KindArg::Interface,
                "business" => crate::cli::KindArg::Business,
                other => return Err(anyhow!("--from-json: unknown kind '{}'", other)),
            });
        }
    }
    if args.priority.is_none() {
        if let Some(p) = doc.priority {
            args.priority = Some(match p.as_str() {
                "must" => crate::cli::PriorityArg::Must,
                "should" => crate::cli::PriorityArg::Should,
                "could" => crate::cli::PriorityArg::Could,
                "wont" => crate::cli::PriorityArg::Wont,
                other => return Err(anyhow!("--from-json: unknown priority '{}'", other)),
            });
        }
    }
    if args.tag.is_empty() {
        if let Some(t) = doc.tags {
            args.tag = t;
        }
    }
    if args.parent.is_none() {
        args.parent = doc.parent;
    }
    Ok(args)
}

// REQ-0095: dedup-warn on recently-obsolete reqs. Reuses validate's
// Jaccard token-set heuristic with the same 0.65 threshold so the
// warning fires on the same conceptual overlap REQ-V-0020 catches.
// Window is 60 days from now: longer than that and "re-adding the
// same idea" is no longer the typical mistake — it's reinvention.
fn find_recent_obsolete_similar(
    project: &crate::model::Project,
    new_title: &str,
    new_statement: &str,
) -> Option<(String, f64)> {
    use std::collections::HashSet;
    let cutoff = chrono::Utc::now() - chrono::Duration::days(60);
    let new_text = format!("{} {}", new_title, new_statement);
    let new_tokens = jaccard_tokens(&new_text);
    let mut best: Option<(String, f64)> = None;
    for (id, r) in &project.requirements {
        if !matches!(r.status, Status::Obsolete) {
            continue;
        }
        if r.updated < cutoff {
            continue;
        }
        let old_text = format!("{} {}", r.title, r.statement);
        let old_tokens: HashSet<String> = jaccard_tokens(&old_text);
        let inter = new_tokens.intersection(&old_tokens).count() as f64;
        let union = new_tokens.union(&old_tokens).count() as f64;
        if union == 0.0 {
            continue;
        }
        let sim = inter / union;
        if sim < 0.65 {
            continue;
        }
        if best.as_ref().map(|(_, s)| sim > *s).unwrap_or(true) {
            best = Some((id.clone(), sim));
        }
    }
    best
}

fn jaccard_tokens(s: &str) -> std::collections::HashSet<String> {
    use once_cell::sync::Lazy;
    use regex::Regex;
    static STOP: Lazy<std::collections::HashSet<&'static str>> = Lazy::new(|| {
        [
            "the", "a", "an", "and", "or", "of", "to", "for", "on", "in", "is", "be", "by", "with",
            "as", "that", "this", "shall", "must", "should", "will", "system", "cli",
        ]
        .iter()
        .copied()
        .collect()
    });
    static WORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[a-z0-9]+").unwrap());
    let lower = s.to_lowercase();
    WORD_RE
        .find_iter(&lower)
        .map(|m| m.as_str().to_string())
        .filter(|w| w.len() > 2 && !STOP.contains(w.as_str()))
        .collect()
}