govctl 0.9.4

Project governance CLI for RFC, ADR, and Work Item management
mod graph;

use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use crate::loop_state::{LoopState, LoopWorkItemStatus};
use crate::model::{WorkItemEntry, WorkItemStatus};
use graph::{dependency_table, deterministic_execution_order, resolve_dependency_closure};
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoopPlan {
    pub state: LoopState,
    pub topological_order: Vec<String>,
}

pub fn build_loop_plan_from_config(
    config: &Config,
    loop_id: &str,
    work: &[String],
) -> DiagnosticResult<LoopPlan> {
    let all_work_items = crate::parse::load_work_items(config)?;
    build_loop_plan(loop_id, work, &all_work_items)
}

pub fn replan_loop_state_from_config(
    config: &Config,
    existing: &LoopState,
    work: &[String],
) -> DiagnosticResult<LoopPlan> {
    let all_work_items = crate::parse::load_work_items(config)?;
    replan_loop_state(existing, work, &all_work_items)
}

pub fn build_loop_plan(
    loop_id: &str,
    work: &[String],
    all_work_items: &[WorkItemEntry],
) -> DiagnosticResult<LoopPlan> {
    if work.is_empty() {
        return Err(Diagnostic::new(
            DiagnosticCode::E1201LoopStateInvalid,
            "Loop work item set must not be empty",
            loop_id,
        ));
    }

    let by_id = all_work_items
        .iter()
        .map(|entry| (entry.meta().id.as_str(), entry))
        .collect::<HashMap<_, _>>();
    let resolved_work_ids = resolve_dependency_closure(loop_id, work, &by_id)?;
    let dependencies = dependency_table(&resolved_work_ids, &by_id);
    let topological_order = deterministic_execution_order(loop_id, &dependencies)?;

    let mut state = LoopState::new(
        loop_id,
        work.to_vec(),
        resolved_work_ids.clone(),
        dependencies,
    )?;
    for work_id in &resolved_work_ids {
        let entry = by_id.get(work_id.as_str()).ok_or_else(|| {
            Diagnostic::new(
                DiagnosticCode::E1205LoopDependencyNotFound,
                format!("Loop work item disappeared while planning: {work_id}"),
                loop_id,
            )
        })?;
        match entry.meta().status {
            WorkItemStatus::Done => state.set_item_status(work_id, LoopWorkItemStatus::Done)?,
            WorkItemStatus::Cancelled => {
                state.set_item_status(work_id, LoopWorkItemStatus::Cancelled)?;
            }
            WorkItemStatus::Queue | WorkItemStatus::Active => {}
        }
    }

    propagate_blocked_outcomes(&mut state)?;

    Ok(LoopPlan {
        state,
        topological_order,
    })
}

pub fn propagate_blocked_outcomes(state: &mut LoopState) -> DiagnosticResult<Vec<String>> {
    propagate_blocked_outcomes_inner(state, false)
}

pub fn recompute_scope_mutation_blocked_outcomes(
    state: &mut LoopState,
) -> DiagnosticResult<Vec<String>> {
    for item in state.items.values_mut() {
        if item.status == LoopWorkItemStatus::Blocked {
            item.status = LoopWorkItemStatus::Pending;
        }
    }
    propagate_blocked_outcomes_inner(state, true)
}

fn propagate_blocked_outcomes_inner(
    state: &mut LoopState,
    preserve_done: bool,
) -> DiagnosticResult<Vec<String>> {
    state.validate(Some(&state.loop_meta.id))?;
    let mut blocked = Vec::new();

    loop {
        let mut changed = false;
        let resolved_work_ids = state.loop_meta.resolved.clone();
        for work_id in resolved_work_ids {
            if matches!(
                state.items[work_id.as_str()].status,
                LoopWorkItemStatus::Failed
                    | LoopWorkItemStatus::Blocked
                    | LoopWorkItemStatus::Cancelled
            ) {
                continue;
            }
            if preserve_done && state.items[work_id.as_str()].status == LoopWorkItemStatus::Done {
                continue;
            }

            let dependencies = state.dependencies.get(&work_id).ok_or_else(|| {
                Diagnostic::new(
                    DiagnosticCode::E1201LoopStateInvalid,
                    format!("missing dependency entry for work item: {work_id}"),
                    state.loop_meta.id.clone(),
                )
            })?;

            if dependencies.iter().any(|dependency| {
                matches!(
                    state.items[dependency.as_str()].status,
                    LoopWorkItemStatus::Failed
                        | LoopWorkItemStatus::Blocked
                        | LoopWorkItemStatus::Cancelled
                )
            }) {
                state.set_item_status(&work_id, LoopWorkItemStatus::Blocked)?;
                blocked.push(work_id);
                changed = true;
            }
        }

        if !changed {
            break;
        }
    }

    Ok(blocked)
}

pub fn replan_loop_state(
    existing: &LoopState,
    work: &[String],
    all_work_items: &[WorkItemEntry],
) -> DiagnosticResult<LoopPlan> {
    let mut plan = build_loop_plan(&existing.loop_meta.id, work, all_work_items)?;
    plan.state.loop_meta.state = existing.loop_meta.state;
    plan.state.loop_meta.current_round = existing.loop_meta.current_round;
    plan.state.loop_meta.next_action = existing.loop_meta.next_action;

    for (work_id, item) in &mut plan.state.items {
        let Some(previous) = existing.items.get(work_id) else {
            continue;
        };
        item.round_count = previous.round_count;
        item.last_round = previous.last_round;
        item.status = preserved_replan_status(previous.status, item.status);
    }

    recompute_scope_mutation_blocked_outcomes(&mut plan.state)?;
    Ok(plan)
}

fn preserved_replan_status(
    previous: LoopWorkItemStatus,
    current: LoopWorkItemStatus,
) -> LoopWorkItemStatus {
    match (previous, current) {
        (LoopWorkItemStatus::Done, _) => LoopWorkItemStatus::Done,
        (LoopWorkItemStatus::Failed, _) => LoopWorkItemStatus::Failed,
        (LoopWorkItemStatus::Cancelled, _) => LoopWorkItemStatus::Cancelled,
        (_, LoopWorkItemStatus::Cancelled) => LoopWorkItemStatus::Cancelled,
        (_, LoopWorkItemStatus::Done) => LoopWorkItemStatus::Done,
        (LoopWorkItemStatus::Blocked, _) => LoopWorkItemStatus::Pending,
        (LoopWorkItemStatus::Active, LoopWorkItemStatus::Pending) => LoopWorkItemStatus::Active,
        (LoopWorkItemStatus::Pending, LoopWorkItemStatus::Pending) => LoopWorkItemStatus::Pending,
        (_, LoopWorkItemStatus::Blocked) => LoopWorkItemStatus::Blocked,
        (_, LoopWorkItemStatus::Active) => current,
        (_, LoopWorkItemStatus::Failed) => current,
    }
}

pub fn topological_order_for_state(state: &LoopState) -> DiagnosticResult<Vec<String>> {
    deterministic_execution_order(&state.loop_meta.id, &state.dependencies)
}

#[cfg(test)]
mod tests;