opencrabs 0.3.52

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
use crate::brain::plans;
use crate::brain::plans::files;
use crate::tui::plan::{PlanDocument, PlanStatus, TaskDep, TaskStatus};

fn embedded_content(rel_path: &str) -> &'static str {
    match rel_path {
        "plan-json-spec.md" => include_str!("../docs/reference/plans/plan-json-spec.md"),
        "coding-plans/python-fast.json" => {
            include_str!("../docs/reference/plans/coding-plans/python-fast.json")
        }
        "coding-plans/python-medium.json" => {
            include_str!("../docs/reference/plans/coding-plans/python-medium.json")
        }
        "coding-plans/python-full.json" => {
            include_str!("../docs/reference/plans/coding-plans/python-full.json")
        }
        "coding-plans/rust-fast.json" => {
            include_str!("../docs/reference/plans/coding-plans/rust-fast.json")
        }
        "coding-plans/rust-medium.json" => {
            include_str!("../docs/reference/plans/coding-plans/rust-medium.json")
        }
        "coding-plans/rust-full.json" => {
            include_str!("../docs/reference/plans/coding-plans/rust-full.json")
        }
        "coding-plans/sample-minimal-plan.json" => {
            include_str!("../docs/reference/plans/coding-plans/sample-minimal-plan.json")
        }
        _ => panic!("unknown bundled file: {}", rel_path),
    }
}

#[test]
fn test_embedded_content_all_files() {
    let file_list = vec![
        "plan-json-spec.md",
        "coding-plans/python-fast.json",
        "coding-plans/python-medium.json",
        "coding-plans/python-full.json",
        "coding-plans/rust-fast.json",
        "coding-plans/rust-medium.json",
        "coding-plans/rust-full.json",
        "coding-plans/sample-minimal-plan.json",
    ];
    for f in file_list {
        let content = embedded_content(f);
        assert!(!content.is_empty(), "embedded file {f} should not be empty");
    }
}

#[test]
#[should_panic(expected = "unknown bundled file")]
fn test_embedded_content_unknown_file() {
    embedded_content("nonexistent.json");
}

#[test]
fn test_minimal_plan_deserializes() {
    let content = embedded_content("coding-plans/sample-minimal-plan.json");
    let plan: PlanDocument =
        serde_json::from_str(content).expect("sample-minimal-plan.json must deserialize");
    assert_eq!(plan.title, "Sample: Minimal Plan (Import-Ready Format)");
    assert_eq!(plan.tasks.len(), 4);
    assert!(!plan.id.is_nil(), "id should be auto-generated UUID");
    assert!(
        !plan.session_id.is_nil(),
        "session_id should be auto-generated"
    );
    assert!(
        plan.tasks.iter().all(|t| !t.id.is_nil()),
        "task ids should be auto-generated"
    );
}

#[test]
fn test_numeric_dependencies_deserialize() {
    let content = embedded_content("coding-plans/python-fast.json");
    let plan: PlanDocument =
        serde_json::from_str(content).expect("python-fast.json must deserialize");
    assert_eq!(plan.title, "Python Fast Path");
    assert_eq!(plan.tasks.len(), 3);
    assert!(plan.tasks[0].dependencies.is_empty());
    assert_eq!(plan.tasks[1].dependencies.len(), 1);
    assert!(
        !plan.tasks[1].dependencies[0].is_uuid(),
        "should be Index, not UUID"
    );
    assert_eq!(plan.tasks[2].dependencies.len(), 1);
    assert!(
        !plan.tasks[2].dependencies[0].is_uuid(),
        "should be Index, not UUID"
    );
}

#[test]
fn test_resolve_index_deps_on_bundled_plan() {
    let content = embedded_content("coding-plans/python-fast.json");
    let mut plan: PlanDocument = serde_json::from_str(content).unwrap();
    plan.resolve_index_deps();
    for task in &plan.tasks {
        for dep in &task.dependencies {
            assert!(dep.is_uuid(), "dep should be resolved to UUID, got {dep:?}");
        }
    }
}

#[test]
fn test_all_bundled_plans_deserialize() {
    let paths = vec![
        "python-fast",
        "python-medium",
        "python-full",
        "rust-fast",
        "rust-medium",
        "rust-full",
        "sample-minimal-plan",
    ];
    for name in paths {
        let content = embedded_content(&format!("coding-plans/{name}.json"));
        let plan: PlanDocument = serde_json::from_str(content)
            .unwrap_or_else(|e| panic!("{name}.json failed to deserialize: {e}"));
        assert!(!plan.title.is_empty(), "{name}: title must not be empty");
        assert!(
            !plan.tasks.is_empty(),
            "{name}: must have at least one task"
        );
    }
}

#[test]
fn test_minimal_format_with_no_optional_fields() {
    let json = r#"{
        "title": "Quick Fix",
        "description": "Fix the bug",
        "tasks": [
            {"title": "Fix it", "description": "Do the thing", "task_type": "edit"}
        ]
    }"#;
    let plan: PlanDocument = serde_json::from_str(json).expect("minimal format must deserialize");
    assert_eq!(plan.title, "Quick Fix");
    assert_eq!(plan.tasks.len(), 1);
    assert_eq!(plan.tasks[0].order, 0, "order defaults to 0 when omitted");
    assert!(plan.tasks[0].dependencies.is_empty());
    assert!(plan.tasks[0].acceptance_criteria.is_empty());
    assert!(plan.risks.is_empty());
    assert!(plan.context.is_empty());
}

#[test]
fn test_plan_status_default() {
    assert_eq!(PlanStatus::default(), PlanStatus::Draft);
}

#[test]
fn test_task_status_default() {
    assert_eq!(TaskStatus::default(), TaskStatus::Pending);
}

#[test]
fn test_task_dep_id_variant() {
    let uuid = uuid::Uuid::new_v4();
    let dep = TaskDep::Id(uuid);

    assert!(dep.is_uuid());
    assert_eq!(dep.as_uuid(), Some(uuid));
}

#[test]
fn test_task_dep_index_variant() {
    let dep = TaskDep::Index(1);

    assert!(!dep.is_uuid(), "Index is not a UUID");
    assert_eq!(dep.as_uuid(), None);
}

#[test]
fn test_task_dep_deserialize_numeric() {
    let json = r#"[1, 2, 3]"#;
    let deps: Vec<TaskDep> = serde_json::from_str(json).unwrap();
    assert_eq!(deps.len(), 3);
    assert!(
        deps.iter().all(|d| !d.is_uuid()),
        "numeric deps should deserialize as Index"
    );
}

#[test]
fn test_task_dep_deserialize_uuid() {
    let uuid1 = uuid::Uuid::new_v4();
    let uuid2 = uuid::Uuid::new_v4();
    let json = format!(r#"["{}", "{}"]"#, uuid1, uuid2);
    let deps: Vec<TaskDep> = serde_json::from_str(&json).unwrap();
    assert_eq!(deps.len(), 2);
    assert!(deps.iter().all(|d| d.is_uuid()));
    assert_eq!(deps[0].as_uuid(), Some(uuid1));
    assert_eq!(deps[1].as_uuid(), Some(uuid2));
}

#[test]
fn test_task_dep_deserialize_mixed() {
    let uuid = uuid::Uuid::new_v4();
    let json = format!(r#"[1, "{}"]"#, uuid);
    let deps: Vec<TaskDep> = serde_json::from_str(&json).unwrap();
    assert_eq!(deps.len(), 2);
    assert!(!deps[0].is_uuid(), "numeric should be Index");
    assert!(deps[1].is_uuid(), "string UUID should be Id");
}

#[test]
fn test_plan_document_default_values() {
    let json = r#"{"title": "Test", "description": "Test plan", "tasks": []}"#;
    let plan: PlanDocument = serde_json::from_str(json).unwrap();
    assert!(!plan.id.is_nil(), "id should be auto-generated");
    assert!(
        !plan.session_id.is_nil(),
        "session_id should be auto-generated"
    );
    assert_eq!(plan.status, PlanStatus::Draft);
    assert_eq!(plan.context, "");
    assert!(plan.risks.is_empty());
    assert!(plan.technical_stack.is_empty());
}

#[test]
fn test_resolve_index_deps_mixed_format() {
    let uuid = uuid::Uuid::new_v4();
    let json = format!(
        r#"{{
        "title": "Mixed Deps",
        "description": "test",
        "tasks": [
            {{"title": "A", "description": "", "task_type": "research", "order": 1}},
            {{"title": "B", "description": "", "task_type": "build", "order": 2, "dependencies": [1]}},
            {{"title": "C", "description": "", "task_type": "test", "dependencies": ["{}"]}}
        ]
    }}"#,
        uuid
    );
    let mut plan: PlanDocument = serde_json::from_str(&json).unwrap();
    plan.resolve_index_deps();

    assert!(
        plan.tasks[1].dependencies[0].is_uuid(),
        "numeric dep 1 should resolve to task A's UUID"
    );
    assert_eq!(
        plan.tasks[1].dependencies[0].as_uuid(),
        Some(plan.tasks[0].id)
    );
    assert!(
        plan.tasks[2].dependencies[0].is_uuid(),
        "UUID dep should remain UUID"
    );
    assert_eq!(plan.tasks[2].dependencies[0].as_uuid(), Some(uuid));
}

#[test]
fn test_task_dep_to_uuid() {
    use std::collections::HashMap;

    let uuid = uuid::Uuid::new_v4();
    let id_dep = TaskDep::Id(uuid);
    let idx_dep = TaskDep::Index(1);

    let mut order_map = HashMap::new();
    order_map.insert(1, uuid);

    assert_eq!(id_dep.to_uuid(&order_map), Some(uuid));
    assert_eq!(idx_dep.to_uuid(&order_map), Some(uuid));

    let missing_idx = TaskDep::Index(99);
    assert_eq!(
        missing_idx.to_uuid(&order_map),
        None,
        "missing index should return None"
    );
}

#[test]
fn test_plans_dir_path() {
    let dir = plans::plans_dir();
    assert!(
        dir.to_string_lossy().contains("plans"),
        "plans_dir should point to a 'plans' subdirectory"
    );
}

#[test]
fn test_coding_plan_path_returns_json() {
    let path = files::coding_plan_path("python-fast");
    assert!(
        path.to_string_lossy().ends_with(".json"),
        "coding_plan_path should return .json file"
    );
}

#[test]
fn test_all_coding_plan_paths_count() {
    let paths = files::all_coding_plan_paths();
    assert_eq!(paths.len(), 7, "should have 7 bundled coding plans");
}