ccd-cli 1.0.0-beta.1

Bootstrap and validate Continuous Context Development repositories
use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::db;
use crate::paths::state::StateLayout;

const WARNING_THRESHOLD: u32 = 2;
const CRITICAL_THRESHOLD: u32 = 3;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AttemptOutcome {
    Progress,
    NoProgress,
    Neutral,
}

impl AttemptOutcome {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Progress => "progress",
            Self::NoProgress => "no_progress",
            Self::Neutral => "neutral",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkStreamDecayState {
    pub(crate) session_id: String,
    pub(crate) consecutive_no_progress: u32,
    pub(crate) last_outcome: String,
    pub(crate) updated_at_epoch_s: u64,
    pub(crate) last_progress_at_epoch_s: Option<u64>,
}

#[derive(Debug, Clone, Serialize)]
pub(crate) struct WorkStreamDecayView {
    pub(crate) status: &'static str,
    pub(crate) consecutive_no_progress: u32,
    pub(crate) warning_threshold: u32,
    pub(crate) critical_threshold: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) last_outcome: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) updated_at_epoch_s: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) last_progress_at_epoch_s: Option<u64>,
    pub(crate) recommendation: &'static str,
}

impl WorkStreamDecayView {
    pub(crate) fn missing() -> Self {
        Self {
            status: "missing",
            consecutive_no_progress: 0,
            warning_threshold: WARNING_THRESHOLD,
            critical_threshold: CRITICAL_THRESHOLD,
            last_outcome: None,
            updated_at_epoch_s: None,
            last_progress_at_epoch_s: None,
            recommendation: "continue",
        }
    }
}

pub(crate) fn load_view_from_db(
    db: Option<&db::StateDb>,
    active_session_id: Option<&str>,
) -> Result<WorkStreamDecayView> {
    let Some(db) = db else {
        return Ok(WorkStreamDecayView::missing());
    };
    let Some(state) = db::work_stream_decay::read(db.conn())? else {
        return Ok(WorkStreamDecayView::missing());
    };
    if active_session_id.is_none() {
        return Ok(view_for_state("stale", &state));
    }
    if Some(state.session_id.as_str()) != active_session_id {
        return Ok(view_for_state("stale", &state));
    }
    Ok(view_for_state(
        classify_status(state.consecutive_no_progress),
        &state,
    ))
}

pub(crate) fn record_attempt_outcome(
    layout: &StateLayout,
    session_id: &str,
    outcome: AttemptOutcome,
    observed_at_epoch_s: u64,
) -> Result<WorkStreamDecayView> {
    let db = db::StateDb::open(&layout.state_db_path())?;
    let existing = db::work_stream_decay::read(db.conn())?;
    let mut next = match existing {
        Some(state) if state.session_id == session_id => state,
        _ => WorkStreamDecayState {
            session_id: session_id.to_owned(),
            consecutive_no_progress: 0,
            last_outcome: AttemptOutcome::Neutral.as_str().to_owned(),
            updated_at_epoch_s: observed_at_epoch_s,
            last_progress_at_epoch_s: None,
        },
    };

    match outcome {
        AttemptOutcome::Progress => {
            next.consecutive_no_progress = 0;
            next.last_progress_at_epoch_s = Some(observed_at_epoch_s);
        }
        AttemptOutcome::NoProgress => {
            next.consecutive_no_progress = next.consecutive_no_progress.saturating_add(1);
        }
        AttemptOutcome::Neutral => {}
    }
    next.last_outcome = outcome.as_str().to_owned();
    next.updated_at_epoch_s = observed_at_epoch_s;
    db::work_stream_decay::write(db.conn(), &next)?;

    Ok(view_for_state(
        classify_status(next.consecutive_no_progress),
        &next,
    ))
}

fn classify_status(consecutive_no_progress: u32) -> &'static str {
    if consecutive_no_progress >= CRITICAL_THRESHOLD {
        "critical"
    } else if consecutive_no_progress >= WARNING_THRESHOLD {
        "warning"
    } else {
        "clear"
    }
}

fn view_for_state(status: &'static str, state: &WorkStreamDecayState) -> WorkStreamDecayView {
    let recommendation = match status {
        "critical" => "wrap_up_and_clear",
        "warning" => "wrap_up_soon",
        _ => "continue",
    };
    WorkStreamDecayView {
        status,
        consecutive_no_progress: state.consecutive_no_progress,
        warning_threshold: WARNING_THRESHOLD,
        critical_threshold: CRITICAL_THRESHOLD,
        last_outcome: Some(state.last_outcome.clone()),
        updated_at_epoch_s: Some(state.updated_at_epoch_s),
        last_progress_at_epoch_s: state.last_progress_at_epoch_s,
        recommendation,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::profile::ProfileName;
    use tempfile::tempdir;

    fn test_layout() -> StateLayout {
        let temp = tempdir().unwrap();
        let root = temp.keep();
        StateLayout::new(
            root.join(".ccd"),
            root.join("repo/.git/ccd"),
            ProfileName::new("main").unwrap(),
        )
    }

    #[test]
    fn no_progress_warns_after_two_attempts() {
        let layout = test_layout();
        let first =
            record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 10).unwrap();
        assert_eq!(first.status, "clear");
        let second =
            record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 20).unwrap();
        assert_eq!(second.status, "warning");
        assert_eq!(second.recommendation, "wrap_up_soon");
    }

    #[test]
    fn progress_resets_counter() {
        let layout = test_layout();
        record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 10).unwrap();
        record_attempt_outcome(&layout, "ses_1", AttemptOutcome::NoProgress, 20).unwrap();
        let reset = record_attempt_outcome(&layout, "ses_1", AttemptOutcome::Progress, 30).unwrap();
        assert_eq!(reset.status, "clear");
        assert_eq!(reset.consecutive_no_progress, 0);
    }
}