govctl 0.9.4

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

pub(super) use round::validate_loop_round_record;
pub(super) use state::validate_loop_state;

use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult};
use crate::loop_state::LoopLifecycleState;
use chrono::NaiveDate;
use std::collections::BTreeSet;

const LOOP_ID_FORMAT: &str = "LOOP-YYYY-MM-DD-NNN";

pub fn validate_loop_id(loop_id: &str) -> DiagnosticResult<()> {
    if !is_canonical_loop_id(loop_id) {
        return Err(Diagnostic::new(
            DiagnosticCode::E1204LoopInvalidId,
            format!("Invalid loop ID '{loop_id}': must use canonical format {LOOP_ID_FORMAT}"),
            loop_id,
        ));
    }
    Ok(())
}

pub(in crate::loop_state) fn ensure_work_item_id(
    work_id: &str,
    loop_id: &str,
) -> DiagnosticResult<()> {
    if crate::validate::is_work_item_id(work_id) {
        Ok(())
    } else {
        Err(invalid_state(
            loop_id,
            format!("invalid work item ID in loop state: {work_id}"),
        ))
    }
}

pub(in crate::loop_state) fn invalid_state(
    loop_id: &str,
    message: impl Into<String>,
) -> Diagnostic {
    Diagnostic::new(DiagnosticCode::E1201LoopStateInvalid, message, loop_id)
}

pub(super) fn ensure_no_duplicates(
    values: &[String],
    field: &str,
    loop_id: &str,
) -> DiagnosticResult<()> {
    let mut seen = BTreeSet::new();
    for value in values {
        if !seen.insert(value.as_str()) {
            return Err(invalid_state(
                loop_id,
                format!("duplicate value '{value}' in {field}"),
            ));
        }
    }
    Ok(())
}

pub(in crate::loop_state) fn validate_loop_transition(
    loop_id: &str,
    from: LoopLifecycleState,
    to: LoopLifecycleState,
) -> DiagnosticResult<()> {
    if is_valid_loop_transition(from, to) {
        Ok(())
    } else {
        Err(Diagnostic::new(
            DiagnosticCode::E1203LoopInvalidTransition,
            format!("Invalid loop transition: {from:?} -> {to:?}"),
            loop_id,
        ))
    }
}

fn is_canonical_loop_id(loop_id: &str) -> bool {
    if loop_id.len() != "LOOP-YYYY-MM-DD-NNN".len() {
        return false;
    }
    if !loop_id.starts_with("LOOP-") {
        return false;
    }
    let bytes = loop_id.as_bytes();
    if bytes[9] != b'-' || bytes[12] != b'-' || bytes[15] != b'-' {
        return false;
    }
    if !bytes[5..9].iter().all(|byte| byte.is_ascii_digit())
        || !bytes[10..12].iter().all(|byte| byte.is_ascii_digit())
        || !bytes[13..15].iter().all(|byte| byte.is_ascii_digit())
        || !bytes[16..19].iter().all(|byte| byte.is_ascii_digit())
    {
        return false;
    }
    let date = &loop_id[5..15];
    if NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() {
        return false;
    }
    &loop_id[16..19] != "000"
}

fn is_valid_loop_transition(from: LoopLifecycleState, to: LoopLifecycleState) -> bool {
    matches!(
        (from, to),
        (LoopLifecycleState::Pending, LoopLifecycleState::Active)
            | (LoopLifecycleState::Active, LoopLifecycleState::Paused)
            | (LoopLifecycleState::Paused, LoopLifecycleState::Active)
            | (LoopLifecycleState::Active, LoopLifecycleState::Completed)
            | (LoopLifecycleState::Active, LoopLifecycleState::Failed)
            | (LoopLifecycleState::Paused, LoopLifecycleState::Failed)
    )
}