codewhale-tui 0.9.4

Terminal UI for open-source and open-weight coding models
//! One typed, capped, marker-stable ModelContext fragment.

use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};

/// Default hard byte cap per volatile fragment. Keeps WorldState from
/// displacing the cache-stable constitution prefix under fanout noise.
pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;

/// Stable identity for a WorldState concern. Markers are public contract —
/// do not rename without a migration note (prefix-cache + tests pin them).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum FragmentId {
    Workspace,
    Permissions,
    Route,
    AgentTopology,
    SkillsTools,
    TokenBudget,
}

impl FragmentId {
    #[must_use]
    #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Workspace => "workspace",
            Self::Permissions => "permissions",
            Self::Route => "route",
            Self::AgentTopology => "agent_topology",
            Self::SkillsTools => "skills_tools",
            Self::TokenBudget => "token_budget",
        }
    }

    /// Stable HTML-comment marker wrapping the fragment body.
    #[must_use]
    pub fn marker(self) -> &'static str {
        match self {
            Self::Workspace => "<!-- cw:ctx:workspace -->",
            Self::Permissions => "<!-- cw:ctx:permissions -->",
            Self::Route => "<!-- cw:ctx:route -->",
            Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
            Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
            Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
        }
    }

    #[must_use]
    #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
    pub fn role(self) -> FragmentRole {
        match self {
            Self::Workspace => FragmentRole::Workspace,
            Self::Permissions => FragmentRole::Permissions,
            Self::Route => FragmentRole::Route,
            Self::AgentTopology => FragmentRole::AgentTopology,
            Self::SkillsTools => FragmentRole::SkillsTools,
            Self::TokenBudget => FragmentRole::TokenBudget,
        }
    }

    #[must_use]
    #[allow(dead_code)] // ordered enumeration for host rebuilds / inspectors (TUI-DOG-011)
    pub fn all() -> &'static [FragmentId] {
        &[
            Self::Workspace,
            Self::Permissions,
            Self::Route,
            Self::AgentTopology,
            Self::SkillsTools,
            Self::TokenBudget,
        ]
    }
}

/// Explicit role of a fragment relative to the cache-stable constitution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FragmentRole {
    /// Workspace / repo working-set facts (volatile across sessions).
    Workspace,
    /// Approval / permission posture.
    Permissions,
    /// Active route, model, and app mode.
    Route,
    /// Sub-agent topology and recent completion notices.
    AgentTopology,
    /// Skills, plugins, and tool availability summary.
    SkillsTools,
    /// Token budget and compaction status.
    TokenBudget,
}

impl FragmentRole {
    #[must_use]
    #[allow(dead_code)] // public role labels for inspectors / diffs (TUI-DOG-011)
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Workspace => "workspace",
            Self::Permissions => "permissions",
            Self::Route => "route",
            Self::AgentTopology => "agent_topology",
            Self::SkillsTools => "skills_tools",
            Self::TokenBudget => "token_budget",
        }
    }
}

/// Result of comparing a fragment against its previous render.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FragmentRender {
    /// Content hash matches previous — retain bytes; do not reinject.
    Unchanged { marker: String, content_hash: u64 },
    /// New or changed content — inject the capped body.
    Updated { fragment: ModelContextFragment },
    /// Fragment was present before and is now absent.
    #[allow(dead_code)] // produced by WorldState::clear; hosts wire clear next (TUI-DOG-011)
    Cleared { marker: String },
}

/// One capped WorldState section with a stable marker and content hash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelContextFragment {
    pub id: FragmentId,
    pub role: FragmentRole,
    pub marker: &'static str,
    pub max_bytes: usize,
    pub content: String,
    pub content_hash: u64,
}

impl ModelContextFragment {
    #[must_use]
    pub fn new(id: FragmentId, role: FragmentRole, raw: impl Into<String>) -> Self {
        Self::with_max_bytes(id, role, raw, DEFAULT_FRAGMENT_MAX_BYTES)
    }

    #[must_use]
    pub fn with_max_bytes(
        id: FragmentId,
        role: FragmentRole,
        raw: impl Into<String>,
        max_bytes: usize,
    ) -> Self {
        let content = enforce_byte_cap(raw.into(), max_bytes);
        let content_hash = hash_content(&content);
        Self {
            id,
            role,
            marker: id.marker(),
            max_bytes,
            content,
            content_hash,
        }
    }

    /// Compare against a previous fragment of the same id.
    #[must_use]
    pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender {
        match previous {
            Some(prev) if prev.content_hash == self.content_hash && prev.marker == self.marker => {
                FragmentRender::Unchanged {
                    marker: self.marker.to_string(),
                    content_hash: self.content_hash,
                }
            }
            _ => FragmentRender::Updated {
                fragment: self.clone(),
            },
        }
    }

    /// Full render including the stable marker header.
    #[must_use]
    pub fn render_marked(&self) -> String {
        format!("{}\n{}", self.marker, self.content.trim_end())
    }
}

fn hash_content(content: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    content.hash(&mut hasher);
    hasher.finish()
}

fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
    if max_bytes == 0 {
        return String::new();
    }
    if raw.len() <= max_bytes {
        return raw;
    }
    let omitted = raw.len().saturating_sub(max_bytes);
    let marker = format!("\n[…truncated: {omitted} bytes omitted]");
    if marker.len() >= max_bytes {
        return marker.chars().take(max_bytes).collect();
    }
    let keep = max_bytes.saturating_sub(marker.len());
    // Truncate on a char boundary.
    let mut end = keep;
    while end > 0 && !raw.is_char_boundary(end) {
        end -= 1;
    }
    let mut out = raw[..end].to_string();
    out.push_str(&marker);
    out
}

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

    #[test]
    fn render_diff_detects_change_and_retain() {
        let a = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
        let b = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
        let c = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=b");
        assert!(matches!(
            b.render_diff(Some(&a)),
            FragmentRender::Unchanged { .. }
        ));
        assert!(matches!(
            c.render_diff(Some(&a)),
            FragmentRender::Updated { .. }
        ));
        assert!(matches!(
            a.render_diff(None),
            FragmentRender::Updated { .. }
        ));
    }
}