nils-agent-docs 0.3.5

CLI crate for nils-agent-docs in the nils-cli workspace.
Documentation
#![allow(dead_code)]

use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use agent_docs::env::ResolvedRoots;
use agent_docs::model::{Context, OutputFormat};

static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);

pub struct FixtureWorkspace {
    _temp: TestTempDir,
    pub root: PathBuf,
    pub codex_home: PathBuf,
    pub project_path: PathBuf,
}

impl FixtureWorkspace {
    pub fn from_fixtures() -> Self {
        let temp = TestTempDir::new("agent-docs-resolve-builtin");
        let root = temp.path().to_path_buf();
        let codex_home = root.join("codex-home");
        let project_path = root.join("project");

        copy_fixture_tree(&fixture_path("home"), &codex_home);
        copy_fixture_tree(&fixture_path("project"), &project_path);
        ensure_agents_fixture_docs(&codex_home, &project_path);

        Self {
            _temp: temp,
            root,
            codex_home,
            project_path,
        }
    }

    pub fn roots(&self) -> ResolvedRoots {
        ResolvedRoots {
            codex_home: self.codex_home.clone(),
            project_path: self.project_path.clone(),
            is_linked_worktree: false,
            git_common_dir: None,
            primary_worktree_path: None,
        }
    }
}

impl Default for FixtureWorkspace {
    fn default() -> Self {
        Self::from_fixtures()
    }
}

pub fn fixture_path(relative: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join(relative)
}

pub fn remove_file_if_exists(path: &Path) {
    if path.exists() {
        fs::remove_file(path).expect("remove file");
    }
}

pub fn run_resolve_exit_code(
    workspace: &FixtureWorkspace,
    context: Context,
    format: OutputFormat,
    strict: bool,
) -> i32 {
    let mut args: Vec<OsString> = vec![
        OsString::from("agent-docs"),
        OsString::from("resolve"),
        OsString::from("--context"),
        OsString::from(context.as_str()),
        OsString::from("--format"),
        OsString::from(format.as_str()),
        OsString::from("--codex-home"),
        workspace.codex_home.as_os_str().to_owned(),
        OsString::from("--project-path"),
        workspace.project_path.as_os_str().to_owned(),
    ];

    if strict {
        args.push(OsString::from("--strict"));
    }

    agent_docs::run_with_args(args)
}

pub fn required_lines(text: &str) -> Vec<&str> {
    text.lines()
        .filter(|line| line.starts_with("[required]"))
        .collect()
}

#[derive(Debug)]
pub struct ChecklistBegin<'a> {
    pub context: &'a str,
    pub mode: &'a str,
}

#[derive(Debug)]
pub struct ChecklistDoc<'a> {
    pub file_name: &'a str,
    pub status: &'a str,
    pub path: &'a str,
}

#[derive(Debug)]
pub struct ChecklistEnd<'a> {
    pub required: usize,
    pub present: usize,
    pub missing: usize,
    pub mode: &'a str,
    pub context: &'a str,
}

#[derive(Debug)]
pub struct ParsedChecklist<'a> {
    pub begin: ChecklistBegin<'a>,
    pub docs: Vec<ChecklistDoc<'a>>,
    pub end: ChecklistEnd<'a>,
}

pub fn parse_checklist(output: &str) -> ParsedChecklist<'_> {
    let lines: Vec<&str> = output.lines().collect();
    assert!(
        lines.len() >= 2,
        "checklist output requires at least begin/end markers:\n{output}"
    );

    let begin = parse_begin_line(lines[0]);
    let end = parse_end_line(lines.last().expect("last line"));
    let docs = lines[1..lines.len() - 1]
        .iter()
        .map(|line| parse_doc_line(line))
        .collect();

    ParsedChecklist { begin, docs, end }
}

#[derive(Debug)]
pub struct CliOutput {
    pub exit_code: i32,
    pub stdout: String,
    pub stderr: String,
}

impl CliOutput {
    pub fn success(&self) -> bool {
        self.exit_code == 0
    }
}

pub fn run_agent_docs_command(workspace: &FixtureWorkspace, args: &[&str]) -> CliOutput {
    let mut command = Command::new(agent_docs_bin_path());
    command
        .arg("--codex-home")
        .arg(&workspace.codex_home)
        .arg("--project-path")
        .arg(&workspace.project_path)
        .args(args);

    let output = command.output().expect("run agent-docs command");
    CliOutput {
        exit_code: output.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
    }
}

pub fn write_text(path: &Path, body: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create parent directory");
    }
    fs::write(path, body).expect("write file");
}

fn parse_begin_line(line: &str) -> ChecklistBegin<'_> {
    let payload = line
        .strip_prefix("REQUIRED_DOCS_BEGIN ")
        .expect("begin marker should start with REQUIRED_DOCS_BEGIN");
    let context = parse_kv(payload, "context").expect("begin marker should include context");
    let mode = parse_kv(payload, "mode").expect("begin marker should include mode");

    ChecklistBegin { context, mode }
}

fn parse_doc_line(line: &str) -> ChecklistDoc<'_> {
    let (file_name, remainder) = line
        .split_once(" status=")
        .expect("doc line should include status");
    let (status, path_payload) = remainder
        .split_once(" path=")
        .expect("doc line should include path");

    ChecklistDoc {
        file_name,
        status,
        path: path_payload,
    }
}

fn parse_end_line(line: &str) -> ChecklistEnd<'_> {
    let payload = line
        .strip_prefix("REQUIRED_DOCS_END ")
        .expect("end marker should start with REQUIRED_DOCS_END");

    let required = parse_kv(payload, "required")
        .expect("end marker should include required")
        .parse::<usize>()
        .expect("required should be usize");
    let present = parse_kv(payload, "present")
        .expect("end marker should include present")
        .parse::<usize>()
        .expect("present should be usize");
    let missing = parse_kv(payload, "missing")
        .expect("end marker should include missing")
        .parse::<usize>()
        .expect("missing should be usize");
    let mode = parse_kv(payload, "mode").expect("end marker should include mode");
    let context = parse_kv(payload, "context").expect("end marker should include context");

    ChecklistEnd {
        required,
        present,
        missing,
        mode,
        context,
    }
}

fn parse_kv<'a>(payload: &'a str, key: &str) -> Option<&'a str> {
    payload
        .split_whitespace()
        .find_map(|part| part.strip_prefix(&format!("{key}=")))
}

fn copy_fixture_tree(source: &Path, destination: &Path) {
    assert!(
        source.is_dir(),
        "fixture source missing: {}",
        source.display()
    );
    fs::create_dir_all(destination).expect("create destination fixture directory");

    let mut entries: Vec<_> = fs::read_dir(source)
        .expect("read fixture directory")
        .map(|entry| entry.expect("fixture entry"))
        .collect();
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        let source_path = entry.path();
        let destination_path = destination.join(entry.file_name());
        let file_type = entry.file_type().expect("fixture file type");
        if file_type.is_dir() {
            copy_fixture_tree(&source_path, &destination_path);
        } else if file_type.is_file() {
            fs::copy(&source_path, &destination_path).expect("copy fixture file");
        }
    }
}

fn ensure_agents_fixture_docs(codex_home: &Path, project_path: &Path) {
    ensure_text_file(
        &codex_home.join("AGENTS.md"),
        "# Fixture: home AGENTS default\n\nid: fixture-home-agents-default\n",
    );
    ensure_text_file(
        &codex_home.join("AGENTS.override.md"),
        "# Fixture: home AGENTS override\n\nid: fixture-home-agents-override\n",
    );
    ensure_text_file(
        &project_path.join("AGENTS.md"),
        "# Fixture: project AGENTS default\n\nid: fixture-project-agents-default\n",
    );
    ensure_text_file(
        &project_path.join("AGENTS.override.md"),
        "# Fixture: project AGENTS override\n\nid: fixture-project-agents-override\n",
    );
}

fn ensure_text_file(path: &Path, body: &str) {
    if path.exists() {
        return;
    }
    write_text(path, body);
}

struct TestTempDir {
    path: PathBuf,
}

impl TestTempDir {
    fn new(prefix: &str) -> Self {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time")
            .as_nanos();
        let sequence = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
        let dirname = format!("{prefix}-{}-{timestamp}-{sequence}", std::process::id());
        let path = std::env::temp_dir().join(dirname);
        fs::create_dir_all(&path).expect("create temp dir");
        Self { path }
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TestTempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

fn agent_docs_bin_path() -> PathBuf {
    for env_name in ["CARGO_BIN_EXE_agent-docs", "CARGO_BIN_EXE_agent_docs"] {
        if let Some(path) = std::env::var_os(env_name) {
            return PathBuf::from(path);
        }
    }

    let current = std::env::current_exe().expect("current test executable");
    let Some(target_profile_dir) = current.parent().and_then(|path| path.parent()) else {
        panic!("failed to resolve target profile directory from current executable");
    };

    let candidate = target_profile_dir.join(format!("agent-docs{}", std::env::consts::EXE_SUFFIX));
    if candidate.exists() {
        return candidate;
    }

    panic!(
        "agent-docs binary path not found via env vars or fallback candidate {}",
        candidate.display()
    );
}