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_domain::builder::*;
use agileplus_domain::intent_graph::*;
use anyhow::Result;
use chrono::Utc;

use super::slug::{decompose_features, extract_title, slugify};

/// Generate an intent graph from a prompt.
///
/// The graph includes: Intent node, Feature nodes, Plan node, Test nodes,
/// edges with meta blocks, and a metadata block.
pub fn generate_intent_graph(
    prompt: &str,
    source_system: &str,
    agent_id: &str,
) -> Result<IntentGraph> {
    let now = Utc::now().to_rfc3339();
    let intent_title = extract_title(prompt);
    let intent_slug = slugify(&intent_title);
    let intent_id = format!("Intent#{}", intent_slug);

    let builder = IntentGraphBuilder::new().with_default_metadata();

    let (builder, plan_id) = add_plan_node(
        builder,
        &intent_id,
        &intent_slug,
        &intent_title,
        source_system,
        agent_id,
        &now,
    );
    let (builder, feature_nodes) = add_feature_nodes(
        builder,
        &intent_id,
        &intent_slug,
        prompt,
        source_system,
        agent_id,
        &now,
    );
    let (builder, test_nodes) = add_test_nodes(
        builder,
        &feature_nodes,
        &intent_slug,
        source_system,
        agent_id,
        &now,
    );
    let builder = add_intent_node(
        builder,
        &intent_id,
        &intent_title,
        prompt,
        source_system,
        agent_id,
        &now,
    );
    let builder = wire_edges(
        builder,
        &intent_id,
        &plan_id,
        &feature_nodes,
        &test_nodes,
        source_system,
        agent_id,
        &now,
    );

    let mut graph = builder.build().map_err(anyhow::Error::msg)?;
    graph.update_counts();
    Ok(graph)
}

#[allow(clippy::too_many_arguments)]
fn add_intent_node(
    builder: IntentGraphBuilder,
    intent_id: &str,
    intent_title: &str,
    prompt: &str,
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> IntentGraphBuilder {
    let intent_meta = MetaBuilder::new()
        .source(source_system)
        .timestamp(now)
        .agent_id(agent_id)
        .confidence(0.95)
        .build()
        .expect("valid intent meta");
    let node = NodeBuilder::new()
        .id(intent_id)
        .node_type(NodeType::Intent)
        .dag_stage(DagStage::Intent)
        .title(intent_title)
        .description(prompt)
        .status(Status::Active)
        .meta(intent_meta)
        .build()
        .expect("valid intent node");
    builder.node(node)
}

#[allow(clippy::too_many_arguments)]
fn add_plan_node(
    builder: IntentGraphBuilder,
    _intent_id: &str,
    intent_slug: &str,
    intent_title: &str,
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> (IntentGraphBuilder, String) {
    let plan_id = format!("Plan#{}", intent_slug);
    let plan_meta = MetaBuilder::new()
        .source(source_system)
        .timestamp(now)
        .agent_id(agent_id)
        .confidence(0.90)
        .build()
        .expect("valid plan meta");
    let node = NodeBuilder::new()
        .id(&plan_id)
        .node_type(NodeType::Plan)
        .dag_stage(DagStage::Plan)
        .title(format!("Plan: {}", intent_title))
        .status(Status::Draft)
        .meta(plan_meta)
        .build()
        .expect("valid plan node");
    (builder.node(node), plan_id)
}

#[allow(clippy::too_many_arguments)]
fn add_feature_nodes(
    builder: IntentGraphBuilder,
    _intent_id: &str,
    intent_slug: &str,
    prompt: &str,
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> (IntentGraphBuilder, Vec<Node>) {
    let mut builder = builder;
    let features = decompose_features(prompt);
    let mut nodes = Vec::new();
    for (idx, feat_title) in features.iter().enumerate() {
        let feat_id = format!("Feature#{}-sub{}", intent_slug, idx + 1);
        let meta = MetaBuilder::new()
            .source(source_system)
            .timestamp(now)
            .agent_id(agent_id)
            .confidence(0.85)
            .build()
            .expect("valid feature meta");
        let node = NodeBuilder::new()
            .id(&feat_id)
            .node_type(NodeType::Feature)
            .dag_stage(DagStage::Feature)
            .title(feat_title)
            .status(Status::Draft)
            .meta(meta)
            .build()
            .expect("valid feature node");
        nodes.push(node.clone());
        builder = builder.node(node);
    }
    (builder, nodes)
}

#[allow(clippy::too_many_arguments)]
fn add_test_nodes(
    builder: IntentGraphBuilder,
    feature_nodes: &[Node],
    intent_slug: &str,
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> (IntentGraphBuilder, Vec<Node>) {
    let mut builder = builder;
    let mut nodes = Vec::new();
    for (idx, feat) in feature_nodes.iter().enumerate() {
        let test_id = format!("Test#{}-sub{}-test", intent_slug, idx + 1);
        let meta = MetaBuilder::new()
            .source(source_system)
            .timestamp(now)
            .agent_id(agent_id)
            .confidence(0.80)
            .build()
            .expect("valid test meta");
        let node = NodeBuilder::new()
            .id(&test_id)
            .node_type(NodeType::Test)
            .dag_stage(DagStage::Test)
            .title(format!("Tests for {}", feat.title))
            .status(Status::Draft)
            .meta(meta)
            .build()
            .expect("valid test node");
        nodes.push(node.clone());
        builder = builder.node(node);
    }
    (builder, nodes)
}

#[allow(clippy::too_many_arguments)]
fn wire_edges(
    builder: IntentGraphBuilder,
    intent_id: &str,
    plan_id: &str,
    feature_nodes: &[Node],
    test_nodes: &[Node],
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> IntentGraphBuilder {
    let mut builder = builder;

    // Intent -> Plan (traces-to)
    builder = add_edge(
        builder,
        intent_id,
        plan_id,
        RelationshipType::TracesTo,
        CanonicalLinkType::ParentOf,
        Direction::Forward,
        0.90,
        source_system,
        agent_id,
        now,
    );

    // Intent implements Feature
    for feat in feature_nodes {
        builder = add_edge(
            builder,
            intent_id,
            &feat.id.0,
            RelationshipType::Implements,
            CanonicalLinkType::ParentOf,
            Direction::Forward,
            0.80,
            source_system,
            agent_id,
            now,
        );
    }

    // Plan -> Feature (traces-to)
    for feat in feature_nodes {
        builder = add_edge(
            builder,
            plan_id,
            &feat.id.0,
            RelationshipType::TracesTo,
            CanonicalLinkType::ParentOf,
            Direction::Forward,
            0.75,
            source_system,
            agent_id,
            now,
        );
    }

    // Feature -> Test (tests)
    for (idx, feat) in feature_nodes.iter().enumerate() {
        let test_node = &test_nodes[idx];
        builder = add_edge(
            builder,
            &feat.id.0,
            &test_node.id.0,
            RelationshipType::Tests,
            CanonicalLinkType::Verifies,
            Direction::Forward,
            0.70,
            source_system,
            agent_id,
            now,
        );
    }

    builder
}

#[allow(clippy::too_many_arguments)]
fn add_edge(
    builder: IntentGraphBuilder,
    source: &str,
    target: &str,
    rel: RelationshipType,
    link: CanonicalLinkType,
    dir: Direction,
    confidence: f64,
    source_system: &str,
    agent_id: &str,
    now: &str,
) -> IntentGraphBuilder {
    let meta = MetaBuilder::new()
        .source(source_system)
        .timestamp(now)
        .agent_id(agent_id)
        .confidence(confidence)
        .build()
        .expect("valid edge meta");
    let edge = EdgeBuilder::new()
        .auto_id()
        .source(source)
        .target(target)
        .relationship_type(rel)
        .canonical_map(
            CanonicalMapBuilder::new()
                .link_type(link)
                .direction(dir)
                .build(),
        )
        .meta(meta)
        .build()
        .expect("valid edge");
    builder.edge(edge)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_generate_intent_graph_basic() {
        let prompt = "Build a CLI for the intent ontology. It should validate the graph and store it.";
        let graph = generate_intent_graph(prompt, "test", "test-agent").unwrap();
        assert!(!graph.nodes.is_empty(), "graph should have nodes");
        let has_intent = graph
            .nodes
            .iter()
            .any(|n| matches!(n.node_type, NodeType::Intent));
        let has_plan = graph
            .nodes
            .iter()
            .any(|n| matches!(n.node_type, NodeType::Plan));
        assert!(has_intent, "graph should have an intent node");
        assert!(has_plan, "graph should have a plan node");
    }
}