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::fs;
use std::path::Path;

use agileplus_domain::intent_graph::IntentGraph;
use agileplus_sqlite::Storage;
use tempfile::TempDir;

/// A minimal valid intent graph JSON payload for testing.
fn sample_graph_json(name: &str) -> String {
    format!(
        r#"{{
  "nodes": [
    {{
      "id": "Feature#{name}",
      "node_type": "Feature",
      "dag_stage": "feature",
      "title": "Test {name}",
      "status": "draft",
      "meta": {{
        "source": "test",
        "timestamp": "2024-01-01T00:00:00Z"
      }}
    }}
  ],
  "edges": [],
  "metadata": {{
    "version": "1.0",
    "schema_uri": "https://agileplus.dev/schema/intent-graph.json",
    "created_at": "2024-01-01T00:00:00Z"
  }}
}}"#
    )
}

/// Write sample graph JSON files into `dir` and return the expected graph IDs
/// (file stems).
fn write_sample_graphs(dir: &Path) -> Vec<String> {
    let names = ["auth", "dashboard", "api"];
    for name in &names {
        let json = sample_graph_json(name);
        let path = dir.join(format!("{name}.json"));
        fs::write(&path, &json).expect("failed to write sample graph");
    }
    names.iter().map(|s| s.to_string()).collect()
}

#[test]
fn bulk_import_and_export_roundtrip() {
    // ─── Arrange: create input directory with sample graph files ───
    let input_dir = TempDir::new().expect("failed to create input temp dir");
    let expected_ids = write_sample_graphs(input_dir.path());

    // ─── Act: import each graph file into Storage ───
    let storage = Storage::in_memory().expect("failed to create in-memory storage");

    let mut imported_ids: Vec<String> = Vec::new();
    for entry in fs::read_dir(input_dir.path()).expect("failed to read input dir") {
        let entry = entry.expect("failed to read entry");
        let path = entry.path();
        if path.extension().map_or(true, |e| e != "json") {
            continue;
        }
        let id = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap()
            .to_string();
        let content = fs::read_to_string(&path).expect("failed to read sample graph");
        let graph: IntentGraph =
            serde_json::from_str(&content).expect("failed to parse sample graph");
        storage
            .save_graph(&id, &graph)
            .expect("failed to save graph to storage");
        imported_ids.push(id);
    }

    // ─── Assert: all expected IDs were imported ───
    imported_ids.sort();
    let mut sorted_expected = expected_ids.clone();
    sorted_expected.sort();
    assert_eq!(imported_ids, sorted_expected, "imported IDs should match expected");

    // ─── Verify storage lists all imported graphs ───
    let stored_ids = storage.list_graphs().expect("failed to list graphs");
    let mut stored_ids_sorted = stored_ids.clone();
    stored_ids_sorted.sort();
    assert_eq!(stored_ids_sorted, sorted_expected, "stored IDs should match expected");

    // ─── Export: load each graph and write to output directory ───
    let output_dir = TempDir::new().expect("failed to create output temp dir");
    for id in &stored_ids {
        let graph = storage
            .load_graph(id)
            .expect("failed to load graph")
            .unwrap_or_else(|| panic!("graph {id} not found in storage"));
        let json = serde_json::to_string_pretty(&graph)
            .unwrap_or_else(|_| panic!("failed to serialize graph {id}"));
        let out_path = output_dir.path().join(format!("{id}.json"));
        fs::write(&out_path, &json).unwrap_or_else(|_| panic!("failed to write {id}.json"));
    }

    // ─── Verify: output dir has the same number of files ───
    let output_entries: Vec<_> = fs::read_dir(output_dir.path())
        .expect("failed to read output dir")
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map_or(false, |e| e == "json"))
        .collect();
    assert_eq!(
        output_entries.len(),
        expected_ids.len(),
        "output dir should have the same number of JSON files"
    );

    // ─── Verify: each exported file round-trips correctly ───
    // Compare deserialized graphs, not raw JSON, because serde may reorder
    // fields or apply formatting differences.
    for id in &expected_ids {
        let original_path = input_dir.path().join(format!("{id}.json"));
        let exported_path = output_dir.path().join(format!("{id}.json"));

        let original_content = fs::read_to_string(&original_path)
            .unwrap_or_else(|_| panic!("failed to read original {id}.json"));
        let exported_content = fs::read_to_string(&exported_path)
            .unwrap_or_else(|_| panic!("failed to read exported {id}.json"));

        let original_graph: IntentGraph =
            serde_json::from_str(&original_content).expect("failed to parse original graph");
        let exported_graph: IntentGraph =
            serde_json::from_str(&exported_content).expect("failed to parse exported graph");

        assert_eq!(
            original_graph, exported_graph,
            "round-tripped graph {id} should match original"
        );
    }
}

#[test]
fn bulk_import_handles_empty_dir() {
    let input_dir = TempDir::new().expect("failed to create temp dir");
    let storage = Storage::in_memory().expect("failed to create in-memory storage");

    // Import from empty dir — should do nothing, no error
    for entry in fs::read_dir(input_dir.path()).expect("failed to read empty dir") {
        let entry = entry.expect("failed to read entry");
        let path = entry.path();
        if path.extension().map_or(true, |e| e != "json") {
            continue;
        }
        let id = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap()
            .to_string();
        let content = fs::read_to_string(&path).expect("failed to read");
        let graph: IntentGraph =
            serde_json::from_str(&content).expect("failed to parse graph");
        storage
            .save_graph(&id, &graph)
            .expect("failed to save graph");
    }

    let ids = storage.list_graphs().expect("failed to list graphs");
    assert!(ids.is_empty(), "should have no graphs after importing empty dir");
}

#[test]
fn bulk_export_empty_db() {
    let storage = Storage::in_memory().expect("failed to create in-memory storage");
    let output_dir = TempDir::new().expect("failed to create output temp dir");

    let ids = storage.list_graphs().expect("failed to list graphs");
    assert!(ids.is_empty(), "fresh storage should be empty");

    // Export from empty db — no files should be written
    for id in &ids {
        let graph = storage.load_graph(id).expect("failed to load graph");
        if let Some(g) = graph {
            let json = serde_json::to_string_pretty(&g).expect("failed to serialize");
            let path = output_dir.path().join(format!("{id}.json"));
            fs::write(&path, &json).expect("failed to write");
        }
    }

    let output_count = fs::read_dir(output_dir.path())
        .expect("failed to read output dir")
        .count();
    assert_eq!(output_count, 0, "export from empty db should produce no files");
}

#[test]
fn bulk_import_skips_non_json_files() {
    let input_dir = TempDir::new().expect("failed to create temp dir");
    let storage = Storage::in_memory().expect("failed to create in-memory storage");

    // Write a .json file and a .txt file
    let json_content = sample_graph_json("only-json");
    fs::write(input_dir.path().join("valid.json"), &json_content)
        .expect("failed to write valid.json");
    fs::write(input_dir.path().join("readme.txt"), "not a graph")
        .expect("failed to write readme.txt");
    fs::write(input_dir.path().join("data.md"), "# Markdown")
        .expect("failed to write data.md");

    let mut imported = 0;
    for entry in fs::read_dir(input_dir.path()).expect("failed to read dir") {
        let entry = entry.expect("failed to read entry");
        let path = entry.path();
        if path.extension().map_or(true, |e| e != "json") {
            continue;
        }
        let id = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap()
            .to_string();
        let content = fs::read_to_string(&path).expect("failed to read");
        let graph: IntentGraph =
            serde_json::from_str(&content).expect("failed to parse graph");
        storage
            .save_graph(&id, &graph)
            .expect("failed to save graph");
        imported += 1;
    }

    assert_eq!(imported, 1, "only the .json file should be imported");
    let ids = storage.list_graphs().expect("failed to list graphs");
    assert_eq!(ids.len(), 1, "storage should contain exactly one graph");
    assert_eq!(ids[0], "valid", "imported graph should have correct id");
}