govctl 0.9.1

Project governance CLI for RFC, ADR, and Work Item management
use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use crate::loop_state::{
    LoopItemState, LoopLifecycleState, LoopState, load_loop_state, loop_state_path,
    loop_state_root, validate_loop_id,
};
use std::collections::BTreeSet;

pub(super) fn find_reusable_loop(
    config: &Config,
    loop_id: Option<&str>,
    work: &[String],
) -> DiagnosticResult<Option<LoopState>> {
    if let Some(loop_id) = loop_id {
        match load_loop_state(config, loop_id) {
            Ok(state) => {
                ensure_same_work_set(&state, work)?;
                return Ok(Some(state));
            }
            Err(err) if err.code == DiagnosticCode::E1202LoopStateNotFound => return Ok(None),
            Err(err) => return Err(err),
        }
    }

    find_matching_non_terminal_loop(config, work)
}

fn find_matching_non_terminal_loop(
    config: &Config,
    work: &[String],
) -> DiagnosticResult<Option<LoopState>> {
    let mut matches = Vec::new();
    for loop_id in canonical_loop_ids(config)? {
        let state_path = loop_state_path(config, &loop_id)?;
        if !state_path.exists() {
            continue;
        }
        let state = load_loop_state(config, &loop_id)?;
        if is_non_terminal(state.loop_meta.state) && same_work_set(&state.loop_meta.work, work) {
            matches.push(state);
        }
    }

    match matches.len() {
        0 => Ok(None),
        1 => Ok(matches.into_iter().next()),
        _ => Err(Diagnostic::new(
            DiagnosticCode::E1208LoopResumeAmbiguous,
            format!(
                "Multiple matching non-terminal loops found: {}",
                matches
                    .iter()
                    .map(|state| state.loop_meta.id.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            work.join(", "),
        )),
    }
}

pub(super) fn canonical_loop_ids(config: &Config) -> DiagnosticResult<Vec<String>> {
    let root = loop_state_root(config);
    if !root.exists() {
        return Ok(vec![]);
    }

    let mut loop_ids = Vec::new();
    for entry in std::fs::read_dir(&root).map_err(|e| {
        Diagnostic::io_error("read loop state directory", e, root.display().to_string())
    })? {
        let entry = entry.map_err(|e| {
            Diagnostic::io_error("read loop state entry", e, root.display().to_string())
        })?;
        if !entry.path().is_dir() {
            continue;
        }
        let Some(loop_id) = entry.file_name().to_str().map(str::to_string) else {
            continue;
        };
        if validate_loop_id(&loop_id).is_ok() {
            loop_ids.push(loop_id);
        }
    }
    loop_ids.sort();
    Ok(loop_ids)
}

pub(super) fn ensure_work_values(work: &[String]) -> DiagnosticResult<()> {
    if work.is_empty() {
        return Err(Diagnostic::new(
            DiagnosticCode::E0801MissingRequiredArg,
            "At least one loop work item ID is required",
            "loop",
        ));
    }
    ensure_unique_work_item_ids(work, "Loop work field value", "loop work item", "loop")
}

pub(super) fn ensure_unique_work_item_ids(
    work: &[String],
    invalid_subject: &str,
    duplicate_subject: &str,
    scope: &str,
) -> DiagnosticResult<()> {
    let mut seen = BTreeSet::new();
    for work_id in work {
        if !crate::validate::is_work_item_id(work_id) {
            return Err(Diagnostic::new(
                DiagnosticCode::E0409WorkDependencyInvalid,
                format!("{invalid_subject} '{work_id}' must be a work item ID"),
                scope,
            ));
        }
        if !seen.insert(work_id.as_str()) {
            return Err(Diagnostic::new(
                DiagnosticCode::E1201LoopStateInvalid,
                format!("duplicate {duplicate_subject}: {work_id}"),
                scope,
            ));
        }
    }
    Ok(())
}

fn ensure_same_work_set(state: &LoopState, work: &[String]) -> DiagnosticResult<()> {
    if same_work_set(&state.loop_meta.work, work) {
        Ok(())
    } else {
        Err(Diagnostic::new(
            DiagnosticCode::E1209LoopWorkMismatch,
            format!(
                "Loop work field does not match existing loop state: stored [{}], requested [{}]",
                state.loop_meta.work.join(", "),
                work.join(", ")
            ),
            state.loop_meta.id.clone(),
        ))
    }
}

pub(super) fn loop_dependencies<'a>(
    state: &'a LoopState,
    work_id: &str,
    subject: &str,
) -> DiagnosticResult<&'a [String]> {
    state
        .dependencies
        .get(work_id)
        .map(Vec::as_slice)
        .ok_or_else(|| {
            Diagnostic::new(
                DiagnosticCode::E1201LoopStateInvalid,
                format!("missing dependency entry for {subject}: {work_id}"),
                state.loop_meta.id.clone(),
            )
        })
}

pub(super) fn loop_item_state<'a>(
    state: &'a LoopState,
    work_id: &str,
) -> DiagnosticResult<&'a LoopItemState> {
    state.items.get(work_id).ok_or_else(|| {
        Diagnostic::new(
            DiagnosticCode::E1201LoopStateInvalid,
            format!("missing item state for work item: {work_id}"),
            state.loop_meta.id.clone(),
        )
    })
}

pub(super) fn ensure_loop_not_terminal(state: &LoopState, action: &str) -> DiagnosticResult<()> {
    if matches!(
        state.loop_meta.state,
        LoopLifecycleState::Completed | LoopLifecycleState::Failed
    ) {
        return Err(Diagnostic::new(
            DiagnosticCode::E1210LoopExecutionFailed,
            format!(
                "Cannot {action} terminal loop '{}' in {} state",
                state.loop_meta.id,
                state.loop_meta.state.as_str()
            ),
            state.loop_meta.id.clone(),
        ));
    }
    Ok(())
}

pub(super) fn generated_loop_id(config: &Config) -> DiagnosticResult<String> {
    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
    generated_loop_id_for_date(config, &date)
}

fn same_work_set(left: &[String], right: &[String]) -> bool {
    left.iter().collect::<BTreeSet<_>>() == right.iter().collect::<BTreeSet<_>>()
}

fn is_non_terminal(state: LoopLifecycleState) -> bool {
    matches!(
        state,
        LoopLifecycleState::Pending | LoopLifecycleState::Active | LoopLifecycleState::Paused
    )
}

fn generated_loop_id_for_date(config: &Config, date: &str) -> DiagnosticResult<String> {
    for sequence in 1..=999 {
        let loop_id = format!("LOOP-{date}-{sequence:03}");
        validate_loop_id(&loop_id)?;
        if !loop_state_root(config).join(&loop_id).exists() {
            return Ok(loop_id);
        }
    }
    Err(Diagnostic::new(
        DiagnosticCode::E1204LoopInvalidId,
        format!("No available loop ID sequence for date {date}"),
        date,
    ))
}