tftio-asana-cli 3.1.0

An interface to the Asana API
Documentation
#![allow(clippy::disallowed_methods)] // test harness reads env for fixtures/tokens
//! Agent-mode surface smoke tests for `asana-cli`.

use std::path::PathBuf;
use std::process::{Command, Output};

const AGENT_TOKEN_ENV: &str = "TFTIO_AGENT_TOKEN";
const AGENT_TOKEN_EXPECTED_ENV: &str = "TFTIO_AGENT_TOKEN_EXPECTED";

fn bin_path() -> String {
    if let Some(value) = std::env::vars().find_map(|(key, value)| {
        if key.starts_with("CARGO_BIN_EXE_asana") {
            Some(value)
        } else {
            None
        }
    }) {
        return value;
    }

    // Workspace fallback: walk upward from CARGO_MANIFEST_DIR
    // looking for target/{debug,release}/<exe>. Necessary because
    // cargo places binaries in the WORKSPACE target dir, not the
    // per-crate one.
    let exe_name = if cfg!(windows) {
        "asana-cli.exe"
    } else {
        "asana-cli"
    };
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let mut dir: &std::path::Path = &manifest_dir;
    loop {
        for profile in ["debug", "release"] {
            let candidate = dir.join("target").join(profile).join(exe_name);
            if candidate.exists() {
                return candidate.to_string_lossy().into_owned();
            }
        }
        match dir.parent() {
            Some(parent) => dir = parent,
            None => break,
        }
    }

    panic!(
        "asana-cli binary not found: neither CARGO_BIN_EXE_asana* was set \
         nor was a target/debug or target/release build present above {}",
        manifest_dir.display()
    );
}

fn run_agent_command(args: &[&str]) -> Output {
    Command::new(bin_path())
        .args(args)
        .env(AGENT_TOKEN_ENV, "shared-test-token")
        .env(AGENT_TOKEN_EXPECTED_ENV, "shared-test-token")
        .output()
        .expect("failed to execute asana-cli binary")
}

fn run_plain(args: &[&str]) -> Output {
    Command::new(bin_path())
        .args(args)
        .env_remove(AGENT_TOKEN_ENV)
        .env_remove(AGENT_TOKEN_EXPECTED_ENV)
        .output()
        .expect("failed to execute asana-cli binary")
}

#[test]
fn agent_surface_help_lists_only_declared_capabilities() {
    let output = run_agent_command(&["--agent-help"]);
    assert!(
        output.status.success(),
        "agent help failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    for capability in [
        "manage-config",
        "manage-tasks",
        "manage-projects",
        "manage-sections",
        "manage-tags",
        "manage-custom-fields",
        "manage-workspaces",
        "manage-users",
    ] {
        assert!(
            stdout.contains(capability),
            "missing capability {capability} from output: {stdout}"
        );
    }

    for hidden in [
        "- doctor",
        "- update",
        "- manpage",
        "- completions",
        "- version",
        "- license",
        "command doctor",
        "command update",
        "command manpage",
        "command completions",
        "command version",
        "command license",
    ] {
        assert!(
            !stdout.contains(hidden),
            "unexpected hidden entry {hidden} in output: {stdout}"
        );
    }
}

#[test]
fn agent_surface_skill_manage_tasks_is_capability_scoped() {
    let output = run_agent_command(&["--agent-skill", "manage-tasks"]);
    assert!(
        output.status.success(),
        "agent skill failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("manage-tasks"));
    assert!(stdout.contains("- task"));
    assert!(
        !stdout.contains("manage-projects"),
        "unexpected extra capability: {stdout}"
    );
    assert!(
        !stdout.contains("- project"),
        "unexpected extra command path: {stdout}"
    );
}

#[test]
fn agent_surface_examples_parse_as_valid_invocations() {
    // Every example string declared on every capability must parse cleanly
    // through clap (we add `--help` so the runtime never hits the API).
    // This is a regression guard: examples that drift from the real surface
    // mislead agents that follow them.
    let placeholders: &[(&str, &str)] = &[
        ("<TASK2>", "T2"),
        ("<TASK>", "T1"),
        ("<TAG>", "TAG1"),
        ("<USER>", "U1"),
        ("<PROJECT>", "P1"),
        ("<SECTION>", "S1"),
        ("<FIELD>", "F1"),
        ("<WORKSPACE>", "W1"),
        ("<COMMENT>", "C1"),
        ("<ATTACHMENT>", "A1"),
        ("<PATH>", "/tmp/x"),
        ("<NAME>", "Name"),
        ("<TEXT>", "text"),
        ("<BODY>", "body"),
        ("<PAT>", "pat"),
        ("<GID>", "G1"),
    ];

    for capability in [
        "manage-config",
        "manage-tasks",
        "manage-projects",
        "manage-sections",
        "manage-tags",
        "manage-custom-fields",
        "manage-workspaces",
        "manage-users",
    ] {
        let described = run_plain(&["meta", "agent", "describe", capability]);
        assert!(
            described.status.success(),
            "describe {capability} failed: {}",
            String::from_utf8_lossy(&described.stderr)
        );
        let body = String::from_utf8_lossy(&described.stdout);
        let mut in_examples = false;
        let mut tested = 0usize;
        for raw in body.lines() {
            if raw.starts_with("examples:") {
                in_examples = true;
                continue;
            }
            if in_examples
                && let Some(prefix) = raw.chars().next()
                && prefix.is_ascii_alphabetic()
                && raw.contains(':')
            {
                in_examples = false;
            }
            if !in_examples {
                continue;
            }
            let Some(stripped) = raw.strip_prefix("- asana-cli ") else {
                continue;
            };

            let mut cooked = stripped.to_string();
            for (placeholder, value) in placeholders {
                cooked = cooked.replace(placeholder, value);
            }
            let mut tokens: Vec<String> = cooked.split_whitespace().map(str::to_string).collect();
            tokens.push("--help".to_string());
            let token_refs: Vec<&str> = tokens.iter().map(String::as_str).collect();
            let probe = run_plain(&token_refs);
            assert!(
                probe.status.success(),
                "example for {capability} failed to parse: `asana-cli {cooked}`\nstderr: {}",
                String::from_utf8_lossy(&probe.stderr)
            );
            tested += 1;
        }
        assert!(
            tested > 0,
            "{capability} declared no examples (or parser failed to find any)"
        );
    }
}

#[test]
fn agent_surface_hidden_top_level_commands_parse_as_nonexistent_in_agent_mode() {
    for args in [&["doctor"][..], &["update"][..]] {
        let output = run_agent_command(args);
        assert!(
            !output.status.success(),
            "command should be hidden but succeeded: {}",
            args.join(" ")
        );

        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("unrecognized subcommand"),
            "expected clap unknown-subcommand error for {}: {stderr}",
            args.join(" ")
        );
        assert!(
            !stderr.contains("Did you mean"),
            "hidden command error leaked suggestion for {}: {stderr}",
            args.join(" ")
        );
    }
}