govctl 0.9.4

Project governance CLI for RFC, ADR, and Work Item management
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use crate::model::WorkItemEntry;
use std::collections::{BTreeMap, BTreeSet, HashMap};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VisitState {
    Visiting,
    Visited,
}

pub(super) fn resolve_dependency_closure(
    loop_id: &str,
    work: &[String],
    by_id: &HashMap<&str, &WorkItemEntry>,
) -> DiagnosticResult<Vec<String>> {
    let mut visit = HashMap::new();
    let mut stack = Vec::new();
    let mut closure = BTreeSet::new();

    for work_id in work {
        visit_dependency_closure(
            work_id,
            loop_id,
            by_id,
            &mut visit,
            &mut stack,
            &mut closure,
        )?;
    }

    Ok(closure.iter().cloned().collect())
}

fn visit_dependency_closure(
    work_id: &str,
    loop_id: &str,
    by_id: &HashMap<&str, &WorkItemEntry>,
    visit: &mut HashMap<String, VisitState>,
    stack: &mut Vec<String>,
    closure: &mut BTreeSet<String>,
) -> DiagnosticResult<()> {
    if visit.get(work_id) == Some(&VisitState::Visiting) {
        return Err(cycle_error(loop_id, work_id, stack));
    }
    if visit.get(work_id) == Some(&VisitState::Visited) {
        return Ok(());
    }

    let entry = by_id.get(work_id).ok_or_else(|| {
        Diagnostic::new(
            DiagnosticCode::E1205LoopDependencyNotFound,
            format!("Loop dependency work item not found: {work_id}"),
            loop_id,
        )
    })?;

    visit.insert(work_id.to_string(), VisitState::Visiting);
    stack.push(work_id.to_string());

    let mut dependencies = entry.meta().depends_on.clone();
    dependencies.sort();
    for dependency in dependencies {
        visit_dependency_closure(&dependency, loop_id, by_id, visit, stack, closure)?;
    }

    stack.pop();
    visit.insert(work_id.to_string(), VisitState::Visited);
    closure.insert(work_id.to_string());
    Ok(())
}

fn cycle_error(loop_id: &str, work_id: &str, stack: &[String]) -> Diagnostic {
    let start = stack.iter().position(|id| id == work_id).unwrap_or(0);
    let mut cycle = stack[start..].to_vec();
    cycle.push(work_id.to_string());
    Diagnostic::new(
        DiagnosticCode::E1206LoopDependencyCycle,
        format!("cyclic loop dependency detected: {}", cycle.join(" -> ")),
        loop_id,
    )
}

pub(super) fn dependency_table(
    resolved_work_ids: &[String],
    by_id: &HashMap<&str, &WorkItemEntry>,
) -> BTreeMap<String, Vec<String>> {
    resolved_work_ids
        .iter()
        .map(|work_id| {
            let mut dependencies = by_id
                .get(work_id.as_str())
                .map(|entry| entry.meta().depends_on.clone())
                .unwrap_or_default();
            dependencies.sort();
            (work_id.clone(), dependencies)
        })
        .collect()
}

pub(super) fn deterministic_execution_order(
    loop_id: &str,
    dependencies: &BTreeMap<String, Vec<String>>,
) -> DiagnosticResult<Vec<String>> {
    let mut remaining = dependencies
        .iter()
        .map(|(work_id, deps)| {
            (
                work_id.clone(),
                deps.iter().cloned().collect::<BTreeSet<_>>(),
            )
        })
        .collect::<BTreeMap<_, _>>();
    let mut dependents = BTreeMap::<String, BTreeSet<String>>::new();
    for (work_id, deps) in dependencies {
        for dependency in deps {
            dependents
                .entry(dependency.clone())
                .or_default()
                .insert(work_id.clone());
        }
    }

    let mut ready = remaining
        .iter()
        .filter_map(|(work_id, deps)| deps.is_empty().then_some(work_id.clone()))
        .collect::<BTreeSet<_>>();
    let mut order = Vec::new();

    while let Some(work_id) = ready.iter().next().cloned() {
        ready.remove(&work_id);
        order.push(work_id.clone());

        if let Some(next_items) = dependents.get(&work_id) {
            for dependent in next_items {
                if let Some(deps) = remaining.get_mut(dependent) {
                    deps.remove(&work_id);
                    if deps.is_empty() {
                        ready.insert(dependent.clone());
                    }
                }
            }
        }
    }

    if order.len() != dependencies.len() {
        return Err(Diagnostic::new(
            DiagnosticCode::E1206LoopDependencyCycle,
            "cyclic loop dependency detected while ordering work items",
            loop_id,
        ));
    }

    Ok(order)
}