sdd-layer 0.24.0

Spec-Driven Development CLI and agent harness
//! Descoberta de orquestrações em `docs/` e parse do traceability-map.yaml (T-07).
//! Funções puras sobre o filesystem; sem render.

use std::path::{Path, PathBuf};

use crate::contract::{self, BoardLane, StagePhase};

use super::stage::{current_stage, Stage, StageState, StageStatus};

/// Uma orquestração encontrada no artifact store local.
#[derive(Clone, Debug)]
pub struct Orchestration {
    pub name: String,
    pub slug: String,
    pub path: PathBuf,
    pub state: String,
    pub stages: Vec<StageState>,
    pub pre_stages: Vec<RecordedStageState>,
    pub post_stages: Vec<RecordedStageState>,
}

#[derive(Clone, Debug)]
pub struct RecordedStageState {
    pub key: String,
    pub label: String,
    pub file: String,
    pub status: StageStatus,
    pub approved_at: Option<String>,
}

impl Orchestration {
    pub fn current_stage(&self) -> Option<Stage> {
        current_stage(&self.stages)
    }
}

/// Varre o Artifact Store compartilhado, incluindo stores em worktrees.
pub fn scan_orchestrations(root: &Path) -> Vec<Orchestration> {
    let docs = root.join("docs");
    let mut out = Vec::new();
    let mut dirs = crate::artifact_store::ArtifactLocator::new(root)
        .discover_paths()
        .unwrap_or_default();
    // Preserva diretórios legados sem mapa como estado `unknown`.
    if let Ok(entries) = std::fs::read_dir(&docs) {
        dirs.extend(
            entries
                .flatten()
                .map(|entry| entry.path())
                .filter(|path| path.is_dir()),
        );
    }
    dirs.sort();
    dirs.dedup();
    for dir in dirs {
        if let Some(o) = load_orchestration(&dir) {
            out.push(o);
        }
    }
    out
}

/// Carrega uma orquestração de um diretório; sem traceability-map → estado "unknown".
pub fn load_orchestration(dir: &Path) -> Option<Orchestration> {
    let slug = dir.file_name()?.to_string_lossy().into_owned();
    let index = dir.join("traceability-map.yaml");
    let text = std::fs::read_to_string(&index).ok();

    let (name, state, stages, pre_stages, post_stages) = match text {
        Some(content) => parse_index(&content),
        None => (
            slug.clone(),
            "unknown".to_string(),
            default_stages(),
            default_recorded_stages(StagePhase::Pre),
            default_recorded_stages(StagePhase::Post),
        ),
    };

    Some(Orchestration {
        name: if name.is_empty() { slug.clone() } else { name },
        slug,
        path: dir.to_path_buf(),
        state,
        stages,
        pre_stages,
        post_stages,
    })
}

fn default_stages() -> Vec<StageState> {
    guided_stage_sequence()
        .into_iter()
        .map(|stage| StageState {
            stage,
            file: String::new(),
            status: StageStatus::Unknown,
            approved_at: None,
        })
        .collect()
}

fn guided_stage_sequence() -> Vec<Stage> {
    contract::guided_stages()
        .into_iter()
        .filter_map(|stage| Stage::from_key(&stage.key))
        .collect()
}

fn default_recorded_stages(phase: StagePhase) -> Vec<RecordedStageState> {
    contract::board_stages(BoardLane::Recorded)
        .into_iter()
        .filter(|stage| stage.phase == phase)
        .map(|stage| RecordedStageState {
            key: stage.key.clone(),
            label: stage.label.clone(),
            file: stage.filename.clone(),
            status: StageStatus::Unknown,
            approved_at: None,
        })
        .collect()
}

fn recorded_stage_state(
    artifacts: Option<&serde_yaml::Value>,
    stage: &contract::StageContract,
) -> RecordedStageState {
    let node = artifacts.and_then(|a| a.get(stage.key.as_str()));
    let file = node
        .and_then(|n| n.get("file"))
        .and_then(|v| v.as_str())
        .unwrap_or(stage.filename.as_str())
        .to_string();
    let status = node
        .and_then(|n| n.get("state"))
        .and_then(|v| v.as_str())
        .map(StageStatus::from_yaml)
        .unwrap_or(StageStatus::Unknown);
    let approved_at = node
        .and_then(|n| n.get("approved_at"))
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string());
    RecordedStageState {
        key: stage.key.clone(),
        label: stage.label.clone(),
        file,
        status,
        approved_at,
    }
}

/// Faz o parse do traceability-map.yaml retornando
/// `(name, orchestration_state, guided_stages, pre_lanes, post_lanes)`.
pub fn parse_index(
    content: &str,
) -> (
    String,
    String,
    Vec<StageState>,
    Vec<RecordedStageState>,
    Vec<RecordedStageState>,
) {
    let doc: serde_yaml::Value = match serde_yaml::from_str(content) {
        Ok(v) => v,
        Err(_) => {
            return (
                String::new(),
                "unknown".to_string(),
                default_stages(),
                default_recorded_stages(StagePhase::Pre),
                default_recorded_stages(StagePhase::Post),
            );
        }
    };

    let name = doc
        .get("orchestration")
        .and_then(|o| o.get("name"))
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let state = doc
        .get("orchestration")
        .and_then(|o| o.get("state"))
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string();

    let artifacts = doc.get("artifacts");
    let stages = guided_stage_sequence()
        .into_iter()
        .map(|stage| {
            let node = artifacts.and_then(|a| a.get(stage.key()));
            let file = node
                .and_then(|n| n.get("file"))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let status = node
                .and_then(|n| n.get("state"))
                .and_then(|v| v.as_str())
                .map(StageStatus::from_yaml)
                .unwrap_or(StageStatus::Unknown);
            let approved_at = node
                .and_then(|n| n.get("approved_at"))
                .and_then(|v| v.as_str())
                .filter(|s| !s.is_empty())
                .map(|s| s.to_string());
            StageState {
                stage,
                file,
                status,
                approved_at,
            }
        })
        .collect();

    let pre_stages = contract::board_stages(BoardLane::Recorded)
        .into_iter()
        .filter(|stage| stage.phase == StagePhase::Pre)
        .map(|stage| recorded_stage_state(artifacts, stage))
        .collect();
    let post_stages = contract::board_stages(BoardLane::Recorded)
        .into_iter()
        .filter(|stage| stage.phase == StagePhase::Post)
        .map(|stage| recorded_stage_state(artifacts, stage))
        .collect();

    (name, state, stages, pre_stages, post_stages)
}

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

    const SAMPLE: &str = r#"
orchestration:
  name: "Minha Feature"
  slug: "minha-feature"
  state: draft
artifacts:
  idea:
    file: 01-idea.md
    state: approved
    approved_at: "2026-06-06T00:00:00Z"
  prd:
    file: 02-prd.md
    state: pending
    approved_at: ""
  refinement:
    file: 05-refinement.md
    state: optional
    approved_at: ""
    skipped_reason: ""
"#;

    #[test]
    fn parses_name_state_and_stages() {
        let (name, state, stages, pre_stages, post_stages) = parse_index(SAMPLE);
        assert_eq!(name, "Minha Feature");
        assert_eq!(state, "draft");
        let idea = stages.iter().find(|s| s.stage == Stage::Idea).unwrap();
        assert_eq!(idea.status, StageStatus::Approved);
        assert_eq!(idea.approved_at.as_deref(), Some("2026-06-06T00:00:00Z"));
        let prd = stages.iter().find(|s| s.stage == Stage::Prd).unwrap();
        assert_eq!(prd.status, StageStatus::Pending);
        let refinement = stages
            .iter()
            .find(|s| s.stage == Stage::Refinement)
            .unwrap();
        assert_eq!(refinement.status, StageStatus::Optional);
        assert_eq!(pre_stages.len(), 2);
        // ADR não é mais board_visible (virou utilitário sem posição fixa no pipeline).
        assert_eq!(post_stages.len(), 0);
    }

    #[test]
    fn missing_index_is_unknown() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("docs").join("orphan");
        fs::create_dir_all(&sub).unwrap();
        let o = load_orchestration(&sub).unwrap();
        assert_eq!(o.state, "unknown");
        assert!(o.stages.iter().all(|s| s.status == StageStatus::Unknown));
    }

    #[test]
    fn invalid_yaml_is_unknown() {
        let (_, state, _, _, _) = parse_index("::: not yaml :::\n  - broken");
        assert_eq!(state, "unknown");
    }

    #[test]
    fn scan_finds_orchestrations() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("docs").join("feat-a");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("traceability-map.yaml"), SAMPLE).unwrap();
        let found = scan_orchestrations(dir.path());
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "Minha Feature");
    }

    #[test]
    fn scan_finds_orchestration_stored_only_in_a_worktree() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join(".worktree/feat-a/docs/feat-a");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("traceability-map.yaml"), SAMPLE).unwrap();

        let found = scan_orchestrations(dir.path());

        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "Minha Feature");
        assert_eq!(found[0].path, sub);
    }

    #[test]
    fn scan_empty_when_no_docs() {
        let dir = tempfile::tempdir().unwrap();
        assert!(scan_orchestrations(dir.path()).is_empty());
    }
}