codeswarm_core/
details.rs1use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13pub enum DetailKind {
14 Tool,
15 Thought,
16 Terminal,
17 Diff,
18}
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct DetailRecord {
23 pub id: String,
24 pub kind: DetailKind,
25 pub summary: String,
26 pub content: String,
27 expanded: bool,
28}
29
30impl DetailRecord {
31 pub fn collapsed(
33 id: impl Into<String>,
34 kind: DetailKind,
35 summary: impl Into<String>,
36 content: impl Into<String>,
37 ) -> Self {
38 Self {
39 id: id.into(),
40 kind,
41 summary: summary.into(),
42 content: content.into(),
43 expanded: false,
44 }
45 }
46
47 pub fn is_expanded(&self) -> bool {
48 self.expanded
49 }
50
51 pub fn projected_text(&self) -> &str {
54 if self.expanded {
55 &self.content
56 } else {
57 &self.summary
58 }
59 }
60
61 pub fn source(&self) -> &str {
63 &self.content
64 }
65}
66
67#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70pub enum DetailEvent {
71 Upsert { record: DetailRecord },
72 SetExpanded { id: String, expanded: bool },
73}
74
75#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
79pub struct DetailModel {
80 records: Vec<DetailRecord>,
81}
82
83impl DetailModel {
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 pub fn len(&self) -> usize {
89 self.records.len()
90 }
91
92 pub fn is_empty(&self) -> bool {
93 self.records.is_empty()
94 }
95
96 pub fn get(&self, id: &str) -> Option<&DetailRecord> {
97 self.records.iter().find(|record| record.id == id)
98 }
99
100 pub fn iter(&self) -> impl Iterator<Item = &DetailRecord> {
101 self.records.iter()
102 }
103
104 pub fn apply(&mut self, event: DetailEvent) -> bool {
106 match event {
107 DetailEvent::Upsert { mut record } => {
108 if let Some(existing) = self.records.iter().find(|item| item.id == record.id) {
109 record.expanded = existing.expanded;
110 }
111 if let Some(existing) = self.records.iter_mut().find(|item| item.id == record.id) {
112 *existing = record;
113 } else {
114 self.records.push(record);
115 }
116 true
117 }
118 DetailEvent::SetExpanded { id, expanded } => {
119 let Some(record) = self.records.iter_mut().find(|record| record.id == id) else {
120 return false;
121 };
122 record.expanded = expanded;
123 true
124 }
125 }
126 }
127
128 pub fn set_expanded(&mut self, id: impl Into<String>, expanded: bool) -> bool {
129 self.apply(DetailEvent::SetExpanded {
130 id: id.into(),
131 expanded,
132 })
133 }
134
135 pub fn toggle(&mut self, id: &str) -> bool {
136 let Some(expanded) = self.get(id).map(|record| !record.expanded) else {
137 return false;
138 };
139 self.set_expanded(id, expanded)
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::{DetailEvent, DetailKind, DetailModel, DetailRecord};
146
147 fn terminal() -> DetailRecord {
148 DetailRecord::collapsed(
149 "terminal-1",
150 DetailKind::Terminal,
151 "$ cargo test (output hidden)",
152 "full terminal output\n".repeat(100),
153 )
154 }
155
156 fn diff() -> DetailRecord {
157 DetailRecord::collapsed(
158 "diff-1",
159 DetailKind::Diff,
160 "3 files changed",
161 "--- a/src/lib.rs\n+++ b/src/lib.rs\n",
162 )
163 }
164
165 #[test]
166 fn expensive_terminal_and_diff_details_start_collapsed() {
167 let mut model = DetailModel::new();
168 model.apply(DetailEvent::Upsert { record: terminal() });
169 model.apply(DetailEvent::Upsert { record: diff() });
170 assert_eq!(model.len(), 2);
171 assert!(!model.get("terminal-1").expect("terminal").is_expanded());
172 assert_eq!(
173 model.get("terminal-1").expect("terminal").projected_text(),
174 "$ cargo test (output hidden)"
175 );
176 assert_eq!(
177 model.get("diff-1").expect("diff").projected_text(),
178 "3 files changed"
179 );
180 }
181
182 #[test]
183 fn explicit_toggle_is_the_only_expansion_path() {
184 let mut model = DetailModel::new();
185 model.apply(DetailEvent::Upsert { record: terminal() });
186 assert!(model.toggle("terminal-1"));
187 assert!(model.get("terminal-1").expect("terminal").is_expanded());
188 assert!(
189 model
190 .get("terminal-1")
191 .expect("terminal")
192 .projected_text()
193 .contains("full terminal output")
194 );
195 assert!(model.toggle("terminal-1"));
196 assert!(!model.get("terminal-1").expect("terminal").is_expanded());
197 assert!(!model.toggle("missing"));
198 }
199
200 #[test]
201 fn replacing_streaming_detail_preserves_position_and_expansion() {
202 let mut model = DetailModel::new();
203 model.apply(DetailEvent::Upsert { record: terminal() });
204 model.apply(DetailEvent::Upsert { record: diff() });
205 assert!(model.set_expanded("terminal-1", true));
206 let mut update = terminal();
207 update.content = "new output".into();
208 update.summary = "updated terminal".into();
209 model.apply(DetailEvent::Upsert { record: update });
210 let ids = model
211 .iter()
212 .map(|record| record.id.as_str())
213 .collect::<Vec<_>>();
214 assert_eq!(ids, ["terminal-1", "diff-1"]);
215 assert!(model.get("terminal-1").expect("terminal").is_expanded());
216 assert_eq!(
217 model.get("terminal-1").expect("terminal").source(),
218 "new output"
219 );
220 }
221
222 #[test]
223 fn detail_events_replay_to_identical_state() {
224 let events = vec![
225 DetailEvent::Upsert { record: terminal() },
226 DetailEvent::Upsert { record: diff() },
227 DetailEvent::SetExpanded {
228 id: "diff-1".into(),
229 expanded: true,
230 },
231 ];
232 let encoded = serde_json::to_string(&events).expect("serialize");
233 let decoded: Vec<DetailEvent> = serde_json::from_str(&encoded).expect("deserialize");
234 let mut first = DetailModel::new();
235 let mut second = DetailModel::new();
236 for event in events {
237 first.apply(event);
238 }
239 for event in decoded {
240 second.apply(event);
241 }
242 assert_eq!(first, second);
243 }
244
245 #[test]
246 fn absent_expansion_target_is_a_safe_replay_noop() {
247 let mut model = DetailModel::new();
248 assert!(!model.apply(DetailEvent::SetExpanded {
249 id: "late-detail".into(),
250 expanded: true,
251 }));
252 assert!(model.is_empty());
253 }
254}