use std::path::{Path, PathBuf};
use crate::contract::{self, BoardLane, StagePhase};
use super::stage::{current_stage, Stage, StageState, StageStatus};
#[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)
}
}
pub fn scan_orchestrations(root: &Path) -> Vec<Orchestration> {
let docs = root.join("docs");
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(&docs) else {
return out;
};
let mut dirs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
for dir in dirs {
if let Some(o) = load_orchestration(&dir) {
out.push(o);
}
}
out
}
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,
}
}
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);
assert_eq!(post_stages.len(), 1);
assert_eq!(post_stages[0].key, "adr");
}
#[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_empty_when_no_docs() {
let dir = tempfile::tempdir().unwrap();
assert!(scan_orchestrations(dir.path()).is_empty());
}
}