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 std::path::PathBuf;

use agileplus_sqlite::Storage;
use anyhow::{Context, Result};
use clap::Args;

/// Store an intent graph into a SQLite database.
#[derive(Debug, Args)]
#[command(name = "store")]
pub struct StoreCommand {
    /// Path to the JSON graph file.
    #[arg(short, long)]
    input: PathBuf,

    /// Path to the SQLite database.
    #[arg(short, long)]
    db: PathBuf,

    /// Optional caller-supplied graph id. If omitted, the id is derived
    /// from a stable hash of the graph JSON so re-storing the same graph
    /// is idempotent.
    #[arg(long)]
    id: Option<String>,
}

impl StoreCommand {
    pub fn run(&self) -> Result<()> {
        let json = std::fs::read_to_string(&self.input)
            .with_context(|| format!("Failed to read input: {}", self.input.display()))?;

        let graph: agileplus_domain::intent_graph::IntentGraph = serde_json::from_str(&json)
            .with_context(|| format!("Failed to parse JSON from: {}", self.input.display()))?;

        let id = match &self.id {
            Some(s) if !s.is_empty() => s.clone(),
            _ => graph_id_from_json(&json),
        };

        let storage = Storage::open(&self.db)
            .with_context(|| format!("Failed to open storage at: {}", self.db.display()))?;
        storage
            .save_graph(&id, &graph)
            .with_context(|| format!("Failed to persist graph {id}"))?;

        println!(
            "Stored graph {id} ({} nodes, {} edges) at {}",
            graph.nodes.len(),
            graph.edges.len(),
            self.db.display(),
        );

        Ok(())
    }
}

/// Derive a stable, content-addressed id from a graph JSON payload.
///
/// Uses FNV-1a 64-bit so the result is stable across processes (unlike
/// `std::collections::hash_map::DefaultHasher`, which is seeded at process
/// start). The id is intended for idempotent local CLI use; it is not
/// collision-resistant, so distinct large graphs that share the same first
/// 16 hex digits are vanishingly rare but theoretically possible.
fn graph_id_from_json(json: &str) -> String {
    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
    const FNV_PRIME: u64 = 0x00000100000001B3;

    let mut hash = FNV_OFFSET;
    for byte in json.as_bytes() {
        hash ^= *byte as u64;
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    // 16 hex chars is plenty for uniqueness in a local CLI workflow and
    // keeps the id human-readable in logs.
    format!("graph-{:016x}", hash)
}