agileplus-cli 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
use agileplus_trace_validator::validate_intent_graph;
use anyhow::{Context, Result};
use clap::Args;
use serde_json;
use std::io::{self, Read, Write};

pub mod generate;
pub mod slug;

pub use generate::generate_intent_graph;

/// Convert a natural language prompt into an intent graph.
#[derive(Debug, Args)]
#[command(name = "intent")]
pub struct IntentCommand {
    /// The prompt to convert. If not provided, reads from stdin.
    #[arg(short, long)]
    prompt: Option<String>,

    /// Output file path. If not provided, prints to stdout.
    #[arg(short, long)]
    output: Option<String>,

    /// Source system identifier.
    #[arg(long, default_value = "agileplus-cli")]
    source_system: String,

    /// Agent identifier.
    #[arg(long, default_value = "agileplus-cli")]
    agent_id: String,

    /// Pretty-print the JSON output.
    #[arg(long, default_value = "true")]
    pretty: bool,

    /// Validate the generated graph before output.
    #[arg(long, default_value = "true")]
    validate: bool,
}

impl IntentCommand {
    pub fn run(&self) -> Result<()> {
        let prompt = match &self.prompt {
            Some(p) => p.clone(),
            None => {
                let mut buffer = String::new();
                io::stdin().read_to_string(&mut buffer)?;
                buffer.trim().to_string()
            }
        };

        if prompt.is_empty() {
            return Err(anyhow::anyhow!(
                "Prompt is empty. Provide --prompt or pipe text to stdin."
            ));
        }

        let graph = generate_intent_graph(&prompt, &self.source_system, &self.agent_id)?;

        if self.validate {
            if let Err(errs) = validate_intent_graph(&graph) {
                let messages: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
                return Err(anyhow::anyhow!(
                    "Generated graph failed validation: {}",
                    messages.join("; ")
                ));
            }
        }

        let json = if self.pretty {
            serde_json::to_string_pretty(&graph)?
        } else {
            serde_json::to_string(&graph)?
        };

        match &self.output {
            Some(path) => {
                std::fs::write(path, &json)
                    .with_context(|| format!("Failed to write to {}", path))?;
                eprintln!("Intent graph written to {}", path);
            }
            None => {
                io::stdout().write_all(json.as_bytes())?;
                io::stdout().write_all(b"\n")?;
            }
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::generate::generate_intent_graph;
    use super::slug::{decompose_features, extract_title, slugify};
    use agileplus_domain::intent_graph::NodeType;

    #[test]
    fn test_generate_intent_graph() {
        let graph = generate_intent_graph(
            "Build an OAuth2 authentication system with login and session management.",
            "test",
            "test-agent",
        )
        .unwrap();

        assert!(!graph.nodes.is_empty());
        assert!(!graph.edges.is_empty());
        assert!(graph.metadata.version == "1.0.0");

        let intent_count = graph
            .nodes
            .iter()
            .filter(|n| n.node_type == NodeType::Intent)
            .count();
        let plan_count = graph
            .nodes
            .iter()
            .filter(|n| n.node_type == NodeType::Plan)
            .count();
        let feature_count = graph
            .nodes
            .iter()
            .filter(|n| n.node_type == NodeType::Feature)
            .count();

        assert_eq!(intent_count, 1);
        assert_eq!(plan_count, 1);
        assert!(feature_count >= 1);
    }

    #[test]
    fn test_extract_title() {
        assert_eq!(extract_title("Hello world"), "Hello world");
    }

    #[test]
    fn test_decompose_features() {
        let feats = decompose_features("Build an OAuth2 system with database and cache");
        assert!(feats.iter().any(|f| f.contains("oauth2") || f.contains("OAuth2")));
        assert!(feats.iter().any(|f| f.contains("database") || f.contains("Database")));
        assert!(feats.iter().any(|f| f.contains("cache") || f.contains("Cache")));
    }

    #[test]
    fn test_slugify() {
        assert_eq!(slugify("Hello World"), "hello-world");
    }
}