codewhale-tui 0.9.8

Terminal UI for open-source and open-weight coding models
Documentation
use std::collections::BTreeSet;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextSourceKind {
    Constitution,
    RepositoryLaw,
    ScopedRepositoryLaw,
    Instruction,
    Skill,
    Hook,
    Mcp,
    Memory,
    ModelProfile,
    CapabilityProfile,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextPriority {
    Required,
    High,
    Normal,
    Optional,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextSourceReceipt {
    pub id: String,
    pub kind: ContextSourceKind,
    pub priority: ContextPriority,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    pub bytes: u64,
    pub estimated_tokens: u64,
    pub content_hash: String,
    pub included: bool,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourcesLoadedEvent {
    pub event_id: String,
    pub sources: Vec<ContextSourceReceipt>,
    pub assembly_ms: u64,
    pub prompt_tokens: u64,
    pub schema_tokens: u64,
}

impl ResourcesLoadedEvent {
    #[must_use]
    pub fn included_tokens(&self) -> u64 {
        self.sources
            .iter()
            .filter(|source| source.included)
            .map(|source| source.estimated_tokens)
            .sum()
    }

    pub fn validate_required_sources(&self) -> Result<(), Vec<String>> {
        let missing = self
            .sources
            .iter()
            .filter(|source| source.priority == ContextPriority::Required && !source.included)
            .map(|source| source.id.clone())
            .collect::<Vec<_>>();
        if missing.is_empty() {
            Ok(())
        } else {
            Err(missing)
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContinuitySet {
    #[serde(default)]
    pub constraints: BTreeSet<String>,
    #[serde(default)]
    pub approvals: BTreeSet<String>,
    #[serde(default)]
    pub failed_checks: BTreeSet<String>,
    #[serde(default)]
    pub edited_paths: BTreeSet<PathBuf>,
    #[serde(default)]
    pub pending_work: BTreeSet<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CondensationEvent {
    pub event_id: String,
    pub source_start_event_id: String,
    pub source_end_event_id: String,
    pub summary_hash: String,
    pub provider: String,
    pub model: String,
    pub reason: String,
    pub continuity: ContinuitySet,
    pub input_tokens: u64,
    pub output_tokens: u64,
}

impl CondensationEvent {
    pub fn validate(&self) -> Result<(), String> {
        for (label, value) in [
            ("event_id", self.event_id.as_str()),
            ("source_start_event_id", self.source_start_event_id.as_str()),
            ("source_end_event_id", self.source_end_event_id.as_str()),
            ("summary_hash", self.summary_hash.as_str()),
            ("provider", self.provider.as_str()),
            ("model", self.model.as_str()),
            ("reason", self.reason.as_str()),
        ] {
            if value.trim().is_empty() {
                return Err(format!("condensation {label} cannot be empty"));
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn required_law_cannot_be_silently_dropped() {
        let event = ResourcesLoadedEvent {
            event_id: "resources-1".to_string(),
            sources: vec![ContextSourceReceipt {
                id: "AGENTS.md".to_string(),
                kind: ContextSourceKind::RepositoryLaw,
                priority: ContextPriority::Required,
                path: Some(PathBuf::from("AGENTS.md")),
                bytes: 100,
                estimated_tokens: 25,
                content_hash: "hash".to_string(),
                included: false,
                reason: "budget".to_string(),
            }],
            assembly_ms: 1,
            prompt_tokens: 0,
            schema_tokens: 0,
        };
        assert_eq!(
            event.validate_required_sources().unwrap_err(),
            vec!["AGENTS.md"]
        );
    }

    #[test]
    fn condensation_preserves_distinct_continuity_domains() {
        let continuity = ContinuitySet {
            constraints: BTreeSet::from(["do not deploy".to_string()]),
            approvals: BTreeSet::from(["edit src only".to_string()]),
            failed_checks: BTreeSet::from(["cargo test".to_string()]),
            edited_paths: BTreeSet::from([PathBuf::from("src/lib.rs")]),
            pending_work: BTreeSet::from(["rerun test".to_string()]),
        };
        let json = serde_json::to_value(&continuity).unwrap();
        assert_eq!(json["constraints"][0], "do not deploy");
        assert_eq!(json["failed_checks"][0], "cargo test");
        assert_eq!(json["pending_work"][0], "rerun test");
    }
}