返回 CodeWhale
fragment.rs
根目录 / crates / tui / src / model_context / fragment.rs
1 //! One typed, capped, marker-stable ModelContext fragment.
2
3 use std::collections::hash_map::DefaultHasher;
4 use std::hash::{Hash, Hasher};
5
6 /// Default hard byte cap per volatile fragment. Keeps WorldState from
7 /// displacing the cache-stable constitution prefix under fanout noise.
8 pub const DEFAULT_FRAGMENT_MAX_BYTES: usize = 4 * 1024;
9
10 /// Stable identity for a WorldState concern. Markers are public contract —
11 /// do not rename without a migration note (prefix-cache + tests pin them).
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13 pub enum FragmentId {
14 Workspace,
15 Permissions,
16 Route,
17 AgentTopology,
18 SkillsTools,
19 TokenBudget,
20 }
21
22 impl FragmentId {
23 #[must_use]
24 #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::Workspace => "workspace",
28 Self::Permissions => "permissions",
29 Self::Route => "route",
30 Self::AgentTopology => "agent_topology",
31 Self::SkillsTools => "skills_tools",
32 Self::TokenBudget => "token_budget",
33 }
34 }
35
36 /// Stable HTML-comment marker wrapping the fragment body.
37 #[must_use]
38 pub fn marker(self) -> &'static str {
39 match self {
40 Self::Workspace => "<!-- cw:ctx:workspace -->",
41 Self::Permissions => "<!-- cw:ctx:permissions -->",
42 Self::Route => "<!-- cw:ctx:route -->",
43 Self::AgentTopology => "<!-- cw:ctx:agent_topology -->",
44 Self::SkillsTools => "<!-- cw:ctx:skills_tools -->",
45 Self::TokenBudget => "<!-- cw:ctx:token_budget -->",
46 }
47 }
48
49 #[must_use]
50 #[allow(dead_code)] // public identity API for WorldState host adapters (TUI-DOG-011)
51 pub fn role(self) -> FragmentRole {
52 match self {
53 Self::Workspace => FragmentRole::Workspace,
54 Self::Permissions => FragmentRole::Permissions,
55 Self::Route => FragmentRole::Route,
56 Self::AgentTopology => FragmentRole::AgentTopology,
57 Self::SkillsTools => FragmentRole::SkillsTools,
58 Self::TokenBudget => FragmentRole::TokenBudget,
59 }
60 }
61
62 #[must_use]
63 #[allow(dead_code)] // ordered enumeration for host rebuilds / inspectors (TUI-DOG-011)
64 pub fn all() -> &'static [FragmentId] {
65 &[
66 Self::Workspace,
67 Self::Permissions,
68 Self::Route,
69 Self::AgentTopology,
70 Self::SkillsTools,
71 Self::TokenBudget,
72 ]
73 }
74 }
75
76 /// Explicit role of a fragment relative to the cache-stable constitution.
77 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78 pub enum FragmentRole {
79 /// Workspace / repo working-set facts (volatile across sessions).
80 Workspace,
81 /// Approval / permission posture.
82 Permissions,
83 /// Active route, model, and app mode.
84 Route,
85 /// Sub-agent topology and recent completion notices.
86 AgentTopology,
87 /// Skills, plugins, and tool availability summary.
88 SkillsTools,
89 /// Token budget and compaction status.
90 TokenBudget,
91 }
92
93 impl FragmentRole {
94 #[must_use]
95 #[allow(dead_code)] // public role labels for inspectors / diffs (TUI-DOG-011)
96 pub fn as_str(self) -> &'static str {
97 match self {
98 Self::Workspace => "workspace",
99 Self::Permissions => "permissions",
100 Self::Route => "route",
101 Self::AgentTopology => "agent_topology",
102 Self::SkillsTools => "skills_tools",
103 Self::TokenBudget => "token_budget",
104 }
105 }
106 }
107
108 /// Result of comparing a fragment against its previous render.
109 #[derive(Debug, Clone, PartialEq, Eq)]
110 pub enum FragmentRender {
111 /// Content hash matches previous — retain bytes; do not reinject.
112 Unchanged { marker: String, content_hash: u64 },
113 /// New or changed content — inject the capped body.
114 Updated { fragment: ModelContextFragment },
115 /// Fragment was present before and is now absent.
116 #[allow(dead_code)] // produced by WorldState::clear; hosts wire clear next (TUI-DOG-011)
117 Cleared { marker: String },
118 }
119
120 /// One capped WorldState section with a stable marker and content hash.
121 #[derive(Debug, Clone, PartialEq, Eq)]
122 pub struct ModelContextFragment {
123 pub id: FragmentId,
124 pub role: FragmentRole,
125 pub marker: &'static str,
126 pub max_bytes: usize,
127 pub content: String,
128 pub content_hash: u64,
129 }
130
131 impl ModelContextFragment {
132 #[must_use]
133 pub fn new(id: FragmentId, role: FragmentRole, raw: impl Into<String>) -> Self {
134 Self::with_max_bytes(id, role, raw, DEFAULT_FRAGMENT_MAX_BYTES)
135 }
136
137 #[must_use]
138 pub fn with_max_bytes(
139 id: FragmentId,
140 role: FragmentRole,
141 raw: impl Into<String>,
142 max_bytes: usize,
143 ) -> Self {
144 let content = enforce_byte_cap(raw.into(), max_bytes);
145 let content_hash = hash_content(&content);
146 Self {
147 id,
148 role,
149 marker: id.marker(),
150 max_bytes,
151 content,
152 content_hash,
153 }
154 }
155
156 /// Compare against a previous fragment of the same id.
157 #[must_use]
158 pub fn render_diff(&self, previous: Option<&Self>) -> FragmentRender {
159 match previous {
160 Some(prev) if prev.content_hash == self.content_hash && prev.marker == self.marker => {
161 FragmentRender::Unchanged {
162 marker: self.marker.to_string(),
163 content_hash: self.content_hash,
164 }
165 }
166 _ => FragmentRender::Updated {
167 fragment: self.clone(),
168 },
169 }
170 }
171
172 /// Full render including the stable marker header.
173 #[must_use]
174 pub fn render_marked(&self) -> String {
175 format!("{}\n{}", self.marker, self.content.trim_end())
176 }
177 }
178
179 fn hash_content(content: &str) -> u64 {
180 let mut hasher = DefaultHasher::new();
181 content.hash(&mut hasher);
182 hasher.finish()
183 }
184
185 fn enforce_byte_cap(raw: String, max_bytes: usize) -> String {
186 if max_bytes == 0 {
187 return String::new();
188 }
189 if raw.len() <= max_bytes {
190 return raw;
191 }
192 let omitted = raw.len().saturating_sub(max_bytes);
193 let marker = format!("\n[…truncated: {omitted} bytes omitted]");
194 if marker.len() >= max_bytes {
195 return marker.chars().take(max_bytes).collect();
196 }
197 let keep = max_bytes.saturating_sub(marker.len());
198 // Truncate on a char boundary.
199 let mut end = keep;
200 while end > 0 && !raw.is_char_boundary(end) {
201 end -= 1;
202 }
203 let mut out = raw[..end].to_string();
204 out.push_str(&marker);
205 out
206 }
207
208 #[cfg(test)]
209 mod tests {
210 use super::*;
211
212 #[test]
213 fn render_diff_detects_change_and_retain() {
214 let a = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
215 let b = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=a");
216 let c = ModelContextFragment::new(FragmentId::Route, FragmentRole::Route, "m=b");
217 assert!(matches!(
218 b.render_diff(Some(&a)),
219 FragmentRender::Unchanged { .. }
220 ));
221 assert!(matches!(
222 c.render_diff(Some(&a)),
223 FragmentRender::Updated { .. }
224 ));
225 assert!(matches!(
226 a.render_diff(None),
227 FragmentRender::Updated { .. }
228 ));
229 }
230 }
231
231 lines RUST