agent-spec 0.5.0

Intent compiler for AI agent coding: human intent compiles through requirement IR into verifiable task contracts, mechanically verified against the code
use crate::spec_knowledge::{RequirementPlan, RequirementPlanEdgeKind, RequirementPlanStatus};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorktreeManifest {
    pub version: u32,
    pub entries: Vec<WorktreeEntry>,
    pub diagnostics: Vec<WorktreeDiagnostic>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorktreeEntry {
    pub work_unit_id: String,
    pub requirement_id: String,
    pub batch: usize,
    pub base_branch: String,
    pub branch: String,
    pub path: PathBuf,
    pub spec_path: PathBuf,
    pub depends_on: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorktreeDiagnostic {
    pub code: String,
    pub severity: String,
    pub requirement_id: Option<String>,
    pub message: String,
}

pub fn build_worktree_manifest(
    plan: &RequirementPlan,
    base_branch: &str,
    path_prefix: &Path,
) -> WorktreeManifest {
    let mut entries = Vec::new();
    let mut diagnostics = plan
        .diagnostics
        .iter()
        .map(|diagnostic| WorktreeDiagnostic {
            code: diagnostic.code.clone(),
            severity: diagnostic.severity.clone(),
            requirement_id: diagnostic.requirement_id.clone(),
            message: diagnostic.message.clone(),
        })
        .collect::<Vec<_>>();
    diagnostics.extend(plan.parse_errors.iter().map(|error| WorktreeDiagnostic {
        code: "knowledge-parse-error".into(),
        severity: "error".into(),
        requirement_id: None,
        message: format!("{}: {}", error.path.display(), error.message),
    }));
    if diagnostics
        .iter()
        .any(|diagnostic| diagnostic.severity == "error")
    {
        return WorktreeManifest {
            version: 1,
            entries,
            diagnostics,
        };
    }

    for node in &plan.requirements {
        if node.status != RequirementPlanStatus::Ready {
            continue;
        }
        let Some(coverage) = plan
            .coverage
            .iter()
            .find(|entry| entry.requirement_id == node.id)
        else {
            diagnostics.push(missing_spec_diag(&node.id));
            continue;
        };
        let Some(spec_path) = coverage.spec_paths.first() else {
            diagnostics.push(missing_spec_diag(&node.id));
            continue;
        };
        let slug = requirement_slug(&node.id);
        let Some(batch) = batch_for(plan, &node.id) else {
            diagnostics.push(WorktreeDiagnostic {
                code: "worktree-batch-missing".into(),
                severity: "error".into(),
                requirement_id: Some(node.id.clone()),
                message: format!("{} has no executable plan batch", node.id),
            });
            continue;
        };
        entries.push(WorktreeEntry {
            work_unit_id: format!("WU-{}", node.id),
            requirement_id: node.id.clone(),
            batch,
            base_branch: base_branch.to_string(),
            branch: format!("feat/wu-{slug}"),
            path: path_prefix.join(format!("wu-{slug}")),
            spec_path: spec_path.clone(),
            depends_on: dependency_ids(plan, &node.id),
        });
    }

    entries.sort_by(|a, b| {
        a.batch
            .cmp(&b.batch)
            .then_with(|| a.requirement_id.cmp(&b.requirement_id))
    });
    diagnostics.sort_by(|a, b| {
        a.requirement_id
            .cmp(&b.requirement_id)
            .then_with(|| a.code.cmp(&b.code))
    });

    WorktreeManifest {
        version: 1,
        entries,
        diagnostics,
    }
}

fn requirement_slug(id: &str) -> String {
    id.to_ascii_lowercase().replace('_', "-")
}

fn batch_for(plan: &RequirementPlan, requirement_id: &str) -> Option<usize> {
    plan.batches
        .iter()
        .find(|batch| batch.requirement_ids.iter().any(|id| id == requirement_id))
        .map(|batch| batch.order)
}

fn dependency_ids(plan: &RequirementPlan, requirement_id: &str) -> Vec<String> {
    let mut deps = plan
        .edges
        .iter()
        .filter(|edge| {
            edge.to == requirement_id && edge.kind == RequirementPlanEdgeKind::Dependency
        })
        .map(|edge| edge.from.clone())
        .collect::<Vec<_>>();
    deps.sort();
    deps.dedup();
    deps
}

fn missing_spec_diag(requirement_id: &str) -> WorktreeDiagnostic {
    WorktreeDiagnostic {
        code: "worktree-missing-spec".into(),
        severity: "error".into(),
        requirement_id: Some(requirement_id.to_string()),
        message: format!("{requirement_id} is ready but has no spec path for worktree execution"),
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::spec_knowledge::{
        RequirementPlan, RequirementPlanBatch, RequirementPlanNode, RequirementPlanStatus,
        RequirementSpecCoverage,
    };
    use std::path::PathBuf;

    #[test]
    fn test_worktree_manifest_maps_ready_units_only() {
        let plan = RequirementPlan {
            version: 1,
            requirements: vec![
                RequirementPlanNode {
                    id: "REQ-NOTE-CREATE".into(),
                    title: "Create Note".into(),
                    source_path: PathBuf::from("knowledge/requirements/req-note-create.md"),
                    status: RequirementPlanStatus::Ready,
                    mode: "leaf_full".into(),
                    scenario_count: 1,
                    blocked_by: Vec::new(),
                },
                RequirementPlanNode {
                    id: "REQ-NOTE-EXPORT".into(),
                    title: "Export Note".into(),
                    source_path: PathBuf::from("knowledge/requirements/req-note-export.md"),
                    status: RequirementPlanStatus::Blocked,
                    mode: "blocked_questions".into(),
                    scenario_count: 1,
                    blocked_by: vec!["Should CSV be supported?".into()],
                },
            ],
            work_units: Vec::new(),
            specs: Vec::new(),
            edges: Vec::new(),
            batches: vec![RequirementPlanBatch {
                order: 1,
                requirement_ids: vec!["REQ-NOTE-CREATE".into(), "REQ-NOTE-EXPORT".into()],
            }],
            coverage: vec![RequirementSpecCoverage {
                requirement_id: "REQ-NOTE-CREATE".into(),
                spec_paths: vec![PathBuf::from("specs/task-req-note-create.spec.md")],
                spec_depends: Vec::new(),
            }],
            diagnostics: Vec::new(),
            parse_errors: Vec::new(),
        };

        let manifest =
            build_worktree_manifest(&plan, "main", PathBuf::from("../worktrees").as_path());

        assert_eq!(manifest.entries.len(), 1);
        let entry = &manifest.entries[0];
        assert_eq!(entry.work_unit_id, "WU-REQ-NOTE-CREATE");
        assert_eq!(entry.requirement_id, "REQ-NOTE-CREATE");
        assert_eq!(entry.batch, 1);
        assert_eq!(entry.base_branch, "main");
        assert_eq!(entry.branch, "feat/wu-req-note-create");
        assert_eq!(entry.path, PathBuf::from("../worktrees/wu-req-note-create"));
        assert_eq!(
            entry.spec_path,
            PathBuf::from("specs/task-req-note-create.spec.md")
        );
    }

    #[test]
    fn test_worktree_manifest_propagates_plan_errors_and_emits_no_entries() {
        let mut plan = RequirementPlan {
            version: 1,
            requirements: vec![RequirementPlanNode {
                id: "REQ-A".into(),
                title: "A".into(),
                source_path: PathBuf::from("knowledge/requirements/req-a.md"),
                status: RequirementPlanStatus::Ready,
                mode: "leaf_full".into(),
                scenario_count: 1,
                blocked_by: Vec::new(),
            }],
            work_units: Vec::new(),
            specs: Vec::new(),
            edges: Vec::new(),
            batches: Vec::new(),
            coverage: vec![RequirementSpecCoverage {
                requirement_id: "REQ-A".into(),
                spec_paths: vec![PathBuf::from("specs/task-a.spec.md")],
                spec_depends: Vec::new(),
            }],
            diagnostics: Vec::new(),
            parse_errors: Vec::new(),
        };
        plan.diagnostics
            .push(crate::spec_knowledge::RequirementPlanDiagnostic {
                code: "dependency-cycle".into(),
                severity: "error".into(),
                requirement_id: Some("REQ-A".into()),
                message: "cycle".into(),
            });

        let manifest = build_worktree_manifest(&plan, "main", Path::new("../worktrees"));
        assert!(manifest.entries.is_empty());
        assert!(
            manifest
                .diagnostics
                .iter()
                .any(|diag| diag.code == "dependency-cycle")
        );
    }
}