use crate::agent::{self, AgentOptions};
use crate::checkpoint::OrchestratorState;
use crate::config::Config;
use crate::deadlock::{self, DeadlockResolution};
use crate::dependency_graph::DependencyGraph;
use crate::prompts::PromptContext;
use crate::providers;
use crate::state::Status;
use crate::story::Story;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct RunOptions {
pub once: bool,
pub story_filter: Option<String>,
pub epic_filter: Option<String>,
pub epics_range: Option<(String, String)>,
pub dry_run: bool,
pub quiet: bool,
}
fn filter_stories(stories: Vec<Story>, options: &RunOptions) -> Vec<Story> {
let mut stories = stories;
if let Some(ref story_id) = options.story_filter {
stories.retain(|s| s.id == *story_id);
}
if let Some(ref epic_id) = options.epic_filter {
stories.retain(|s| s.epic.as_ref().is_some_and(|e| e == epic_id));
}
if let Some((ref start, ref end)) = options.epics_range {
let start_num = extract_numeric(start);
let end_num = extract_numeric(end);
stories.retain(|s| {
s.epic.as_ref().is_some_and(|e| {
let num = extract_numeric(e);
num >= start_num && num <= end_num
})
});
}
stories
}
pub fn run(
project_root: &Path,
cfg: &Config,
options: &RunOptions,
resume_state: Option<OrchestratorState>,
) -> anyhow::Result<RunReport> {
if options.dry_run {
return run_dry(project_root, cfg, options);
}
run_real(project_root, cfg, options, resume_state)
}
fn run_real(
project_root: &Path,
cfg: &Config,
options: &RunOptions,
resume_state: Option<OrchestratorState>,
) -> anyhow::Result<RunReport> {
let start = Instant::now();
let max_wall = std::time::Duration::from_secs(cfg.limits.max_wall_time_seconds);
let (mut reject_cycles, mut story_iterations, mut story_errors, start_iteration) =
if let Some(state) = resume_state {
tracing::info!(
"π Reanudando desde checkpoint: iteraciΓ³n {}",
state.iteration
);
(
state.reject_cycles,
state.story_iterations,
state.story_errors,
state.iteration,
)
} else {
(HashMap::new(), HashMap::new(), HashMap::new(), 0u32)
};
let mut iteration: u32 = start_iteration;
let mut stop_reason: Option<String> = None;
let initial_stories = load_all_stories(project_root, cfg)?;
let effective_max = effective_max_iterations(cfg.limits.max_iterations, initial_stories.len());
if effective_max != cfg.limits.max_iterations {
tracing::info!(
"max_iterations auto: {} ({} historias Γ 6)",
effective_max,
initial_stories.len()
);
}
loop {
iteration += 1;
if iteration > effective_max {
stop_reason = Some(format!("max_iterations ({})", effective_max));
tracing::warn!("Alcanzado el mΓ‘ximo de {} iteraciones", effective_max);
break;
}
if start.elapsed() >= max_wall {
stop_reason = Some(format!("max_wall_time ({}s)", max_wall.as_secs()));
tracing::warn!("LΓmite de tiempo total alcanzado ({}s)", max_wall.as_secs());
break;
}
if !options.quiet {
tracing::info!("ββββββ IteraciΓ³n {iteration} ββββββ");
}
let stories = load_all_stories(project_root, cfg)?;
let full_graph = DependencyGraph::from_stories(&stories);
let stories =
apply_automatic_transitions(stories, &full_graph, &mut reject_cycles, cfg, false)?;
let stories = filter_stories(stories, options);
if stories.is_empty() {
tracing::info!("Sin historias que procesar con los filtros actuales.");
break;
}
let graph = DependencyGraph::from_stories(&stories);
let resolution = deadlock::analyze(&stories, &graph);
if !handle_deadlock(&resolution, project_root, cfg)? {
break;
}
match &resolution {
DeadlockResolution::InvokePoFor {
story_id, reason, ..
} => {
if !options.quiet {
tracing::info!("π Deadlock detectado: {reason}");
}
let story = stories.iter().find(|s| s.id == *story_id).unwrap();
let iter = story_iterations.entry(story.id.clone()).or_insert(0);
*iter += 1;
let agent_opts = build_agent_opts(story, cfg);
if let Err(e) =
process_story(story, project_root, cfg, &mut reject_cycles, &agent_opts)
{
story_errors
.entry(story.id.clone())
.or_insert_with(|| e.to_string());
}
save_checkpoint(
project_root,
iteration,
&reject_cycles,
&story_iterations,
&story_errors,
);
}
DeadlockResolution::NoDeadlock => {
if let Some(story) = pick_next_actionable(&stories, &graph) {
let id = story.id.clone();
let iter = story_iterations.entry(id.clone()).or_insert(0);
*iter += 1;
let agent_opts = build_agent_opts(story, cfg);
if let Err(e) =
process_story(story, project_root, cfg, &mut reject_cycles, &agent_opts)
{
story_errors
.entry(id.clone())
.or_insert_with(|| e.to_string());
}
save_checkpoint(
project_root,
iteration,
&reject_cycles,
&story_iterations,
&story_errors,
);
}
}
DeadlockResolution::PipelineComplete => {
if !options.quiet {
tracing::info!("β
Pipeline completo: todas las historias en estado terminal.");
}
OrchestratorState::remove(project_root);
break;
}
}
if options.once {
if !options.quiet {
tracing::info!("π Modo --once: completado tras una iteraciΓ³n.");
}
break;
}
}
let stories = filter_stories(load_all_stories(project_root, cfg)?, options);
build_report(
&stories,
iteration,
start.elapsed(),
&story_iterations,
&reject_cycles,
&story_errors,
stop_reason,
)
}
fn run_dry(project_root: &Path, cfg: &Config, options: &RunOptions) -> anyhow::Result<RunReport> {
let start = Instant::now();
tracing::info!("π§ͺ DRY-RUN β No se ejecutarΓ‘n agentes ni se modificarΓ‘n archivos.");
tracing::info!("");
let mut stories = filter_stories(load_all_stories(project_root, cfg)?, options);
if stories.is_empty() {
tracing::info!("Sin historias que procesar.");
return build_report(
&stories,
0,
start.elapsed(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
None,
);
}
let reject_cycles: HashMap<String, u32> = HashMap::new();
let mut story_iterations: HashMap<String, u32> = HashMap::new();
let story_errors: HashMap<String, String> = HashMap::new();
let mut iteration: u32 = 0;
let effective_max = effective_max_iterations(cfg.limits.max_iterations, stories.len());
if effective_max != cfg.limits.max_iterations {
tracing::info!(
"max_iterations auto: {} ({} historias Γ 6)",
effective_max,
stories.len()
);
}
loop {
iteration += 1;
if iteration > effective_max {
break;
}
tracing::info!("βββ IteraciΓ³n {iteration} βββ");
let status_snapshot: Vec<(String, Status, Vec<String>)> = stories
.iter()
.map(|s| (s.id.clone(), s.status, s.blockers.clone()))
.collect();
for (id, status, blockers) in &status_snapshot {
if *status == Status::Blocked {
let all_done = blockers.iter().all(|b| {
stories
.iter()
.any(|s| s.id == *b && s.status == Status::Done)
});
if all_done {
tracing::info!(" β {id} (Blocked) desbloqueada automΓ‘ticamente β Ready");
if let Some(story) = stories.iter_mut().find(|s| s.id == *id) {
story.advance_status_in_memory(Status::Ready);
}
}
}
}
let graph = DependencyGraph::from_stories(&stories);
let resolution = deadlock::analyze(&stories, &graph);
match &resolution {
DeadlockResolution::PipelineComplete => {
tracing::info!("β
Pipeline completo.");
break;
}
DeadlockResolution::InvokePoFor {
story_id,
reason,
unblocks,
..
} => {
tracing::info!(" β {story_id} (Draft) serΓa procesada por PO (groom) β Ready");
tracing::info!(" RazΓ³n: {reason}");
if *unblocks > 0 {
tracing::info!(" DesbloquearΓa: {unblocks} historias");
}
if let Some(story) = stories.iter_mut().find(|s| s.id == *story_id) {
let iter = story_iterations.entry(story.id.clone()).or_insert(0);
*iter += 1;
story.advance_status_in_memory(Status::Ready);
}
}
DeadlockResolution::NoDeadlock => {
if let Some(id) = {
let graph = DependencyGraph::from_stories(&stories);
pick_next_actionable(&stories, &graph).map(|s| s.id.clone())
} {
if let Some(story) = stories.iter_mut().find(|s| s.id == id) {
let next = next_status(story.status);
let label = match story.status {
Status::Draft => "PO (groom)",
Status::Ready => "QA (tests)",
Status::TestsReady => "Dev (implement)",
Status::InProgress => "Dev (fix)",
Status::InReview => "Reviewer",
Status::BusinessReview => "PO (validate)",
_ => "?",
};
let iter = story_iterations.entry(story.id.clone()).or_insert(0);
*iter += 1;
tracing::info!(
" β {} ({}) serΓa procesada por {} β {}",
story.id,
story.status,
label,
next
);
let unblocks = graph.blocks_count(&story.id);
if unblocks > 0 {
tracing::info!(" DesbloquearΓa: {unblocks} historias");
}
story.advance_status_in_memory(next);
}
}
}
}
if options.once {
tracing::info!("π Modo --once: simulada una iteraciΓ³n.");
break;
}
}
tracing::info!("");
tracing::info!("βββ Resumen Dry-Run βββ");
tracing::info!(" Total historias: {}", stories.len());
let done = stories.iter().filter(|s| s.status == Status::Done).count();
let failed = stories
.iter()
.filter(|s| s.status == Status::Failed)
.count();
let blocked = stories
.iter()
.filter(|s| s.status == Status::Blocked)
.count();
let draft = stories.iter().filter(|s| s.status == Status::Draft).count();
tracing::info!(" Done: {done}");
tracing::info!(" Failed: {failed}");
tracing::info!(" Blocked: {blocked}");
tracing::info!(" Draft: {draft}");
tracing::info!(" Iteraciones estimadas: {iteration}");
let est_minutes = iteration as u64 * 5;
tracing::info!(
" Tiempo estimado: ~{}-{} min",
est_minutes,
est_minutes * 2
);
build_report(
&stories,
iteration,
start.elapsed(),
&story_iterations,
&reject_cycles,
&story_errors,
None, )
}
fn build_report(
stories: &[Story],
iterations: u32,
elapsed: std::time::Duration,
story_iterations: &HashMap<String, u32>,
reject_cycles: &HashMap<String, u32>,
story_errors: &HashMap<String, String>,
stop_reason: Option<String>,
) -> anyhow::Result<RunReport> {
let done = stories.iter().filter(|s| s.status == Status::Done).count();
let failed = stories
.iter()
.filter(|s| s.status == Status::Failed)
.count();
let blocked = stories
.iter()
.filter(|s| s.status == Status::Blocked)
.count();
let draft = stories.iter().filter(|s| s.status == Status::Draft).count();
let total = stories.len();
let story_records: Vec<StoryRecord> = stories
.iter()
.map(|s| {
let iter_count = story_iterations.get(&s.id).copied().unwrap_or(0);
let rej_count = reject_cycles.get(&s.id).copied().unwrap_or(0);
let error = story_errors.get(&s.id).cloned();
StoryRecord {
id: s.id.clone(),
status: s.status.to_string(),
epic: s.epic.clone(),
iterations: iter_count,
reject_cycles: rej_count,
error,
}
})
.collect();
Ok(RunReport {
total,
done,
failed,
blocked,
draft,
iterations,
elapsed,
elapsed_seconds: elapsed.as_secs(),
stories: story_records,
stop_reason,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct RunReport {
pub total: usize,
pub done: usize,
pub failed: usize,
pub blocked: usize,
pub draft: usize,
pub iterations: u32,
#[serde(skip)]
pub elapsed: std::time::Duration,
pub elapsed_seconds: u64,
pub stories: Vec<StoryRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StoryRecord {
pub id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub epic: Option<String>,
pub iterations: u32,
pub reject_cycles: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn load_all_stories(project_root: &Path, cfg: &Config) -> anyhow::Result<Vec<Story>> {
let stories_dir = project_root.join(&cfg.project.stories_dir);
let pattern = stories_dir.join(&cfg.project.story_pattern);
let mut stories = vec![];
for entry in glob::glob(pattern.to_str().unwrap())? {
let path = entry?;
match Story::load(&path) {
Ok(story) => stories.push(story),
Err(e) => tracing::warn!("Error cargando {}: {e}", path.display()),
}
}
Ok(stories)
}
fn apply_automatic_transitions(
stories: Vec<Story>,
_graph: &DependencyGraph,
reject_cycles: &mut HashMap<String, u32>,
cfg: &Config,
simulate: bool,
) -> anyhow::Result<Vec<Story>> {
let mut stories = stories;
for story in stories.iter_mut() {
if story.status.is_terminal() {
continue;
}
let cycles = reject_cycles.get(&story.id).copied().unwrap_or(0);
if cycles >= cfg.limits.max_reject_cycles {
tracing::warn!(
"β {}: {} ciclos de rechazo agotados β Failed",
story.id,
cycles
);
if simulate {
story.advance_status_in_memory(Status::Failed);
} else {
story.set_status(Status::Failed)?;
}
continue;
}
if cycles > 0 && cycles >= cfg.limits.max_reject_cycles {
if simulate {
story.advance_status_in_memory(Status::Failed);
} else {
story.set_status(Status::Failed)?;
}
}
}
let status_map: HashMap<String, Status> =
stories.iter().map(|s| (s.id.clone(), s.status)).collect();
for story in stories.iter_mut() {
if story.status != Status::Blocked {
continue;
}
let all_blockers_done = story
.blockers
.iter()
.all(|b| status_map.get(b).is_some_and(|s| *s == Status::Done));
if all_blockers_done {
tracing::info!("π {}: dependencias resueltas β Ready", story.id);
if simulate {
story.advance_status_in_memory(Status::Ready);
} else {
story.set_status(Status::Ready)?;
}
}
}
let status_map_after: HashMap<String, Status> =
stories.iter().map(|s| (s.id.clone(), s.status)).collect();
for story in stories.iter_mut() {
if story.status.is_terminal() || story.status == Status::Blocked {
continue;
}
if story.blockers.is_empty() {
continue;
}
let any_blocker_not_done = story
.blockers
.iter()
.any(|b| !status_map_after.get(b).is_some_and(|s| *s == Status::Done));
if any_blocker_not_done {
tracing::info!("β {}: dependencias no resueltas β Blocked", story.id);
if simulate {
story.advance_status_in_memory(Status::Blocked);
} else {
story.set_status(Status::Blocked)?;
}
}
}
Ok(stories)
}
fn handle_deadlock(
resolution: &DeadlockResolution,
_project_root: &Path,
_cfg: &Config,
) -> anyhow::Result<bool> {
match resolution {
DeadlockResolution::PipelineComplete => {
tracing::info!("β
Pipeline completo.");
Ok(false)
}
DeadlockResolution::InvokePoFor {
story_id, reason, ..
} => {
tracing::info!("π Deadlock β PO debe refinar {story_id}: {reason}");
Ok(true)
}
DeadlockResolution::NoDeadlock => Ok(true),
}
}
fn pick_next_actionable<'a>(stories: &'a [Story], graph: &DependencyGraph) -> Option<&'a Story> {
stories
.iter()
.filter(|s| s.status.is_actionable())
.max_by_key(|s| {
(
status_priority(s.status),
graph.blocks_count(&s.id),
-(extract_numeric(&s.id) as i32),
)
})
}
fn status_priority(status: Status) -> u32 {
match status {
Status::BusinessReview => 6,
Status::InReview => 5,
Status::InProgress => 4,
Status::TestsReady => 3,
Status::Ready => 2,
_ => 0,
}
}
fn process_story(
story: &Story,
project_root: &Path,
cfg: &Config,
reject_cycles: &mut HashMap<String, u32>,
agent_opts: &AgentOptions,
) -> anyhow::Result<()> {
let ctx = PromptContext {
story_id: story.id.clone(),
stories_dir: cfg.project.stories_dir.clone(),
decisions_dir: cfg.project.decisions_dir.clone(),
last_rejection: story.last_rejection.clone(),
from: story.status,
to: next_status(story.status),
};
let role = map_status_to_role(story.status);
let provider_name = cfg.agents.provider_for_role(role);
let provider = providers::from_name(&provider_name);
let skill_path_str = cfg.agents.skill_for_role(role);
let instruction_path = project_root.join(&skill_path_str);
let (prompt, label) = match story.status {
Status::Draft => (ctx.po_groom(), "PO (groom)"),
Status::Ready => (ctx.qa_tests(), "QA (tests)"),
Status::TestsReady => {
if story.last_actor().as_deref() == Some("Dev") {
let qa_ctx = PromptContext {
to: Status::TestsReady,
story_id: ctx.story_id.clone(),
stories_dir: ctx.stories_dir.clone(),
decisions_dir: ctx.decisions_dir.clone(),
last_rejection: ctx.last_rejection.clone(),
from: ctx.from,
};
(qa_ctx.qa_fix_tests(), "QA (fix tests)")
} else {
(ctx.dev_implement(), "Dev (implement)")
}
}
Status::InProgress => (ctx.dev_fix(), "Dev (fix)"),
Status::InReview => (ctx.reviewer(), "Reviewer"),
Status::BusinessReview => (ctx.po_validate(), "PO (validate)"),
_ => {
tracing::warn!("{}: estado {} no procesable", story.id, story.status);
return Ok(());
}
};
tracing::info!(
" π― {label} ({}) | {} ({} β {})",
provider.display_name(),
story.id,
story.status,
ctx.to
);
let prev_hash = if cfg.git.enabled {
crate::git::snapshot(project_root, &format!("{label}-{}", story.id))
} else {
None
};
let result = agent::invoke_with_retry(
provider.as_ref(),
&instruction_path,
&prompt,
&cfg.limits,
agent_opts,
);
match result {
Ok(_) => {
let updated = Story::load(&story.path)?;
if updated.status == story.status {
tracing::warn!(
" β {}: el agente no cambiΓ³ el estado (sigue en {})",
story.id,
story.status
);
} else if (updated.status == Status::InProgress || updated.status == Status::InReview)
&& (story.status == Status::InReview || story.status == Status::BusinessReview)
{
let cycles = reject_cycles.entry(story.id.clone()).or_insert(0);
*cycles += 1;
tracing::info!(
" π {}: ciclo de rechazo {}/{}",
story.id,
cycles,
cfg.limits.max_reject_cycles
);
}
let hook_result = match story.status {
Status::Ready => crate::hooks::run_hook(cfg.hooks.post_qa.as_deref(), "post_qa"),
Status::TestsReady | Status::InProgress => {
crate::hooks::run_hook(cfg.hooks.post_dev.as_deref(), "post_dev")
}
Status::InReview => {
crate::hooks::run_hook(cfg.hooks.post_reviewer.as_deref(), "post_reviewer")
}
_ => Ok(()),
};
if let Err(e) = hook_result {
tracing::warn!(" β hook fallΓ³: {e}");
if let Some(ref hash) = prev_hash {
crate::git::rollback(project_root, hash, label);
}
}
}
Err(e) => {
tracing::error!(" β {}: fallΓ³ la invocaciΓ³n del agente: {e}", story.id);
if let Some(ref hash) = prev_hash {
crate::git::rollback(project_root, hash, label);
}
}
}
Ok(())
}
fn next_status(current: Status) -> Status {
match current {
Status::Draft => Status::Ready,
Status::Ready => Status::TestsReady,
Status::TestsReady => Status::InReview,
Status::InProgress => Status::InReview,
Status::InReview => Status::BusinessReview,
Status::BusinessReview => Status::Done,
_ => current,
}
}
fn map_status_to_role(status: Status) -> &'static str {
match status {
Status::Draft | Status::BusinessReview => "product_owner",
Status::Ready => "qa_engineer",
Status::TestsReady | Status::InProgress => "developer",
Status::InReview => "reviewer",
_ => "product_owner", }
}
fn extract_numeric(id: &str) -> u32 {
id.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0)
}
fn effective_max_iterations(cfg_max: u32, story_count: usize) -> u32 {
if cfg_max > 0 {
cfg_max
} else {
let computed = story_count as u32 * 6;
computed.max(10)
}
}
fn build_agent_opts(story: &Story, cfg: &Config) -> AgentOptions {
AgentOptions {
story_id: Some(story.id.clone()),
decisions_dir: Some(Path::new(&cfg.project.decisions_dir).to_path_buf()),
inject_feedback: cfg.limits.inject_feedback_on_retry,
}
}
fn save_checkpoint(
project_root: &Path,
iteration: u32,
reject_cycles: &HashMap<String, u32>,
story_iterations: &HashMap<String, u32>,
story_errors: &HashMap<String, String>,
) {
let state = OrchestratorState {
iteration,
reject_cycles: reject_cycles.clone(),
story_iterations: story_iterations.clone(),
story_errors: story_errors.clone(),
};
if let Err(e) = state.save(project_root) {
tracing::warn!("β οΈ no se pudo guardar el checkpoint: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_priority_order() {
assert!(status_priority(Status::BusinessReview) > status_priority(Status::InReview));
assert!(status_priority(Status::InReview) > status_priority(Status::TestsReady));
assert!(status_priority(Status::TestsReady) > status_priority(Status::Ready));
assert!(status_priority(Status::Ready) > status_priority(Status::Draft));
}
#[test]
fn next_status_follows_happy_path() {
assert_eq!(next_status(Status::Draft), Status::Ready);
assert_eq!(next_status(Status::Ready), Status::TestsReady);
assert_eq!(next_status(Status::TestsReady), Status::InReview);
assert_eq!(next_status(Status::InReview), Status::BusinessReview);
assert_eq!(next_status(Status::BusinessReview), Status::Done);
}
#[test]
fn next_status_fix_path() {
assert_eq!(next_status(Status::InProgress), Status::InReview);
}
fn story_fixture(id: &str, status: Status, epic: Option<&str>) -> Story {
Story {
id: id.to_string(),
path: format!("stories/{id}.md").into(),
status,
epic: epic.map(|s| s.to_string()),
blockers: vec![],
last_rejection: None,
raw_content: String::new(),
}
}
#[test]
fn filter_no_options_keeps_all() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, Some("EPIC-001")),
story_fixture("STORY-002", Status::Draft, Some("EPIC-002")),
story_fixture("STORY-003", Status::Done, None),
];
let options = RunOptions::default();
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 3);
}
#[test]
fn filter_by_story_id_includes_only_match() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, None),
story_fixture("STORY-002", Status::Draft, None),
story_fixture("STORY-003", Status::Done, None),
];
let options = RunOptions {
story_filter: Some("STORY-002".into()),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "STORY-002");
}
#[test]
fn filter_by_story_id_empty_when_no_match() {
let stories = vec![story_fixture("STORY-001", Status::Ready, None)];
let options = RunOptions {
story_filter: Some("STORY-999".into()),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert!(filtered.is_empty());
}
#[test]
fn filter_by_epic_includes_only_matching_epic() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, Some("EPIC-001")),
story_fixture("STORY-002", Status::Draft, Some("EPIC-001")),
story_fixture("STORY-003", Status::Ready, Some("EPIC-002")),
story_fixture("STORY-004", Status::Draft, None),
];
let options = RunOptions {
epic_filter: Some("EPIC-001".into()),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 2);
assert!(filtered
.iter()
.all(|s| s.epic.as_deref() == Some("EPIC-001")));
}
#[test]
fn filter_by_epic_excludes_stories_without_epic() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, None),
story_fixture("STORY-002", Status::Ready, Some("EPIC-001")),
];
let options = RunOptions {
epic_filter: Some("EPIC-001".into()),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "STORY-002");
}
#[test]
fn filter_by_epics_range_inclusive() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, Some("EPIC-001")),
story_fixture("STORY-002", Status::Draft, Some("EPIC-002")),
story_fixture("STORY-003", Status::Ready, Some("EPIC-003")),
story_fixture("STORY-004", Status::Draft, Some("EPIC-004")),
story_fixture("STORY-005", Status::Ready, Some("EPIC-005")),
];
let options = RunOptions {
epics_range: Some(("EPIC-002".into(), "EPIC-004".into())),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 3);
let ids: Vec<&str> = filtered.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"STORY-002"));
assert!(ids.contains(&"STORY-003"));
assert!(ids.contains(&"STORY-004"));
}
#[test]
fn filter_by_epics_range_single_epic() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, Some("EPIC-001")),
story_fixture("STORY-002", Status::Draft, Some("EPIC-001")),
story_fixture("STORY-003", Status::Ready, Some("EPIC-002")),
];
let options = RunOptions {
epics_range: Some(("EPIC-001".into(), "EPIC-001".into())),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 2);
}
#[test]
fn filter_combined_story_and_epic_both_must_match() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, Some("EPIC-001")),
story_fixture("STORY-002", Status::Draft, Some("EPIC-002")),
];
let options = RunOptions {
story_filter: Some("STORY-001".into()),
epic_filter: Some("EPIC-001".into()),
..Default::default()
};
let filtered = filter_stories(stories, &options);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "STORY-001");
}
#[test]
fn run_options_default_has_no_filters() {
let opts = RunOptions::default();
assert!(!opts.once);
assert!(opts.story_filter.is_none());
assert!(opts.epic_filter.is_none());
assert!(opts.epics_range.is_none());
}
#[test]
fn extract_numeric_from_story_id() {
assert_eq!(extract_numeric("STORY-001"), 1);
assert_eq!(extract_numeric("STORY-042"), 42);
assert_eq!(extract_numeric("story-007"), 7);
}
#[test]
fn extract_numeric_from_epic_id() {
assert_eq!(extract_numeric("EPIC-001"), 1);
assert_eq!(extract_numeric("EPIC-010"), 10);
assert_eq!(extract_numeric("EPIC-123"), 123);
}
#[test]
fn extract_numeric_fallback_zero() {
assert_eq!(extract_numeric("ABC"), 0);
assert_eq!(extract_numeric(""), 0);
}
#[test]
fn pick_next_actionable_returns_highest_priority() {
let stories = vec![
story_fixture("STORY-001", Status::Ready, None),
story_fixture("STORY-002", Status::BusinessReview, None),
story_fixture("STORY-003", Status::TestsReady, None),
];
let graph = DependencyGraph::from_stories(&stories);
let picked = pick_next_actionable(&stories, &graph);
assert!(picked.is_some());
assert_eq!(picked.unwrap().id, "STORY-002");
}
#[test]
fn pick_next_actionable_breaks_tie_by_lower_id() {
let stories = vec![
story_fixture("STORY-005", Status::Ready, None),
story_fixture("STORY-002", Status::Ready, None),
];
let graph = DependencyGraph::from_stories(&stories);
let picked = pick_next_actionable(&stories, &graph);
assert!(picked.is_some());
assert_eq!(picked.unwrap().id, "STORY-002");
}
#[test]
fn pick_next_actionable_returns_none_when_no_actionable() {
let stories = vec![
story_fixture("STORY-001", Status::Draft, None),
story_fixture("STORY-002", Status::Done, None),
story_fixture("STORY-003", Status::Blocked, None),
story_fixture("STORY-004", Status::Failed, None),
];
let graph = DependencyGraph::from_stories(&stories);
let picked = pick_next_actionable(&stories, &graph);
assert!(picked.is_none());
}
}