#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Stage {
Idea,
Prd,
Techspec,
Tasks,
Refinement,
Execution,
Adr,
Review,
Memory,
}
impl Stage {
pub const ALL: [Stage; 9] = [
Stage::Idea,
Stage::Prd,
Stage::Techspec,
Stage::Tasks,
Stage::Refinement,
Stage::Execution,
Stage::Adr,
Stage::Review,
Stage::Memory,
];
pub const GUIDED: [Stage; 8] = [
Stage::Idea,
Stage::Prd,
Stage::Techspec,
Stage::Tasks,
Stage::Refinement,
Stage::Execution,
Stage::Review,
Stage::Memory,
];
pub fn key(self) -> &'static str {
match self {
Stage::Idea => "idea",
Stage::Prd => "prd",
Stage::Techspec => "techspec",
Stage::Tasks => "tasks",
Stage::Refinement => "refinement",
Stage::Execution => "execution",
Stage::Adr => "adr",
Stage::Review => "review",
Stage::Memory => "memory",
}
}
pub fn filename(self) -> &'static str {
match self {
Stage::Idea => "01-idea.md",
Stage::Prd => "02-prd.md",
Stage::Techspec => "03-techspec.md",
Stage::Tasks => "04-tasks.md",
Stage::Refinement => "05-refinement.md",
Stage::Execution => "06-execution.md",
Stage::Adr => "06-adr.md",
Stage::Review => "07-review.md",
Stage::Memory => "08-memory.md",
}
}
pub fn label(self) -> &'static str {
match self {
Stage::Idea => "Idea",
Stage::Prd => "PRD",
Stage::Techspec => "Tech Spec",
Stage::Tasks => "Tasks",
Stage::Refinement => "Refinement",
Stage::Execution => "Execution",
Stage::Adr => "ADR",
Stage::Review => "Review",
Stage::Memory => "Memory",
}
}
pub fn is_optional(self) -> bool {
matches!(self, Stage::Refinement)
}
pub fn from_key(key: &str) -> Option<Stage> {
Stage::ALL.into_iter().find(|s| s.key() == key)
}
fn order(self) -> usize {
Stage::ALL.iter().position(|s| *s == self).unwrap_or(0)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StageStatus {
Pending,
Optional,
Recorded,
InProgress,
Approved,
Skipped,
Unknown,
}
impl StageStatus {
pub fn from_yaml(value: &str) -> StageStatus {
match value.trim() {
"approved" => StageStatus::Approved,
"skipped" => StageStatus::Skipped,
"optional" => StageStatus::Optional,
"recorded" => StageStatus::Recorded,
"in_progress" | "in-progress" => StageStatus::InProgress,
"pending" | "draft" => StageStatus::Pending,
"" => StageStatus::Unknown,
_ => StageStatus::Unknown,
}
}
pub fn is_cleared(self) -> bool {
matches!(self, StageStatus::Approved | StageStatus::Skipped)
}
pub fn icon(self) -> &'static str {
match self {
StageStatus::Pending => "○",
StageStatus::Optional => "◌",
StageStatus::Recorded => "◍",
StageStatus::InProgress => "●",
StageStatus::Approved => "✓",
StageStatus::Skipped => "⊘",
StageStatus::Unknown => "?",
}
}
}
#[derive(Clone, Debug)]
pub struct StageState {
pub stage: Stage,
pub file: String,
pub status: StageStatus,
pub approved_at: Option<String>,
}
pub fn can_navigate(stages: &[StageState], target: Stage) -> Result<(), String> {
let target_order = target.order();
if let Some(st) = stages.iter().find(|s| s.stage == target) {
if st.status.is_cleared() {
return Ok(());
}
}
for st in stages.iter() {
if st.stage.order() >= target_order {
continue;
}
if st.stage.is_optional() {
continue;
}
if !st.status.is_cleared() {
return Err(format!(
"Aprovação do {} obrigatória antes de avançar",
st.stage.label()
));
}
}
Ok(())
}
pub fn current_stage(stages: &[StageState]) -> Option<Stage> {
for st in stages {
if st.stage.is_optional() {
if matches!(st.status, StageStatus::Pending | StageStatus::InProgress) {
return Some(st.stage);
}
continue;
}
if !st.status.is_cleared() {
return Some(st.stage);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn state(stage: Stage, status: StageStatus) -> StageState {
StageState {
stage,
file: format!("{}.md", stage.key()),
status,
approved_at: None,
}
}
fn all_with(overrides: &[(Stage, StageStatus)]) -> Vec<StageState> {
Stage::GUIDED
.into_iter()
.map(|s| {
let status = overrides
.iter()
.find(|(st, _)| *st == s)
.map(|(_, status)| *status)
.unwrap_or(if s.is_optional() {
StageStatus::Optional
} else {
StageStatus::Pending
});
state(s, status)
})
.collect()
}
#[test]
fn blocks_when_required_prev_pending() {
let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
let err = can_navigate(&stages, Stage::Techspec).unwrap_err();
assert!(
err.contains("PRD"),
"esperava bloqueio citando PRD, veio: {err}"
);
}
#[test]
fn allows_when_required_prev_approved() {
let stages = all_with(&[
(Stage::Idea, StageStatus::Approved),
(Stage::Prd, StageStatus::Approved),
]);
assert!(can_navigate(&stages, Stage::Techspec).is_ok());
}
#[test]
fn refinement_skipped_allows_execution() {
let stages = all_with(&[
(Stage::Idea, StageStatus::Approved),
(Stage::Prd, StageStatus::Approved),
(Stage::Techspec, StageStatus::Approved),
(Stage::Tasks, StageStatus::Approved),
(Stage::Refinement, StageStatus::Skipped),
]);
assert!(can_navigate(&stages, Stage::Execution).is_ok());
}
#[test]
fn refinement_optional_does_not_block_execution() {
let stages = all_with(&[
(Stage::Idea, StageStatus::Approved),
(Stage::Prd, StageStatus::Approved),
(Stage::Techspec, StageStatus::Approved),
(Stage::Tasks, StageStatus::Approved),
]);
assert!(can_navigate(&stages, Stage::Execution).is_ok());
}
#[test]
fn review_no_longer_depends_on_recorded_adr_lane() {
let stages = all_with(&[
(Stage::Idea, StageStatus::Approved),
(Stage::Prd, StageStatus::Approved),
(Stage::Techspec, StageStatus::Approved),
(Stage::Tasks, StageStatus::Approved),
(Stage::Refinement, StageStatus::Skipped),
(Stage::Execution, StageStatus::Approved),
]);
assert!(can_navigate(&stages, Stage::Review).is_ok());
}
#[test]
fn navigate_to_approved_stage_is_free() {
let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
assert!(can_navigate(&stages, Stage::Idea).is_ok());
}
#[test]
fn current_stage_is_first_uncleared() {
let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
assert_eq!(current_stage(&stages), Some(Stage::Prd));
}
#[test]
fn from_key_roundtrip() {
for s in Stage::ALL {
assert_eq!(Stage::from_key(s.key()), Some(s));
}
}
#[test]
fn recorded_status_has_distinct_icon() {
assert_eq!(StageStatus::from_yaml("recorded"), StageStatus::Recorded);
assert_eq!(StageStatus::Recorded.icon(), "◍");
assert!(!StageStatus::Recorded.is_cleared());
}
}