1use std::collections::VecDeque;
44use std::time::{SystemTime, UNIX_EPOCH};
45
46pub use tear_types::Block;
49
50#[derive(Copy, Clone, Debug, PartialEq, Eq)]
52enum Phase {
53 Idle,
56 Prompt,
58 Command,
62 Output,
64}
65
66pub struct BlockExtractor {
67 blocks: VecDeque<Block>,
68 cap: usize,
69 current: Option<Block>,
70 phase: Phase,
71 next_index: u64,
72 current_cwd: Option<String>,
76 yurai: tear_types::Yurai,
79}
80
81impl Default for BlockExtractor {
82 fn default() -> Self {
83 Self::new(10_000)
84 }
85}
86
87impl BlockExtractor {
88 #[must_use]
89 pub fn new(cap: usize) -> Self {
90 Self {
91 blocks: VecDeque::new(),
92 cap,
93 current: None,
94 phase: Phase::Idle,
95 next_index: 0,
96 current_cwd: None,
97 yurai: tear_types::Yurai::Unknown,
98 }
99 }
100
101 pub fn stamp_yurai(&mut self, y: tear_types::Yurai) -> bool {
129 if self.yurai == tear_types::Yurai::Unknown {
130 self.yurai = y;
131 true
132 } else {
133 false
134 }
135 }
136
137 #[must_use]
139 pub fn yurai(&self) -> &tear_types::Yurai {
140 &self.yurai
141 }
142
143 pub fn set_cwd_from_osc7(&mut self, raw: &str) {
149 if let Some(rest) = raw.strip_prefix("file://") {
150 if let Some(slash) = rest.find('/') {
153 self.current_cwd = Some(rest[slash..].to_owned());
154 return;
155 }
156 }
157 self.current_cwd = Some(raw.to_owned());
160 }
161
162 #[must_use]
166 pub fn current_cwd(&self) -> Option<&str> {
167 self.current_cwd.as_deref()
168 }
169
170 #[must_use]
172 pub fn len(&self) -> usize {
173 self.blocks.len()
174 }
175
176 #[must_use]
177 pub fn is_empty(&self) -> bool {
178 self.blocks.is_empty()
179 }
180
181 pub fn iter(&self) -> impl Iterator<Item = &Block> {
183 self.blocks.iter()
184 }
185
186 #[must_use]
190 pub fn get(&self, index: u64) -> Option<&Block> {
191 self.blocks.iter().find(|b| b.index == index)
192 }
193
194 #[must_use]
197 pub fn current(&self) -> Option<&Block> {
198 self.current.as_ref()
199 }
200
201 pub fn on_print(&mut self, c: char) {
204 let Some(block) = self.current.as_mut() else {
205 return;
206 };
207 match self.phase {
208 Phase::Prompt => block.prompt.push(c),
209 Phase::Command => block.command.push(c),
210 Phase::Output => block.output.push(c),
211 Phase::Idle => {}
212 }
213 }
214
215 pub fn on_raw_byte(&mut self, b: u8) {
221 let Some(block) = self.current.as_mut() else {
222 return;
223 };
224 if matches!(self.phase, Phase::Output) {
225 block.output.push(b as char);
228 }
229 }
230
231 pub fn on_osc_133(&mut self, marker: &str) {
234 let kind = marker.chars().next().unwrap_or(' ');
235 match kind {
236 'A' => self.start_prompt(),
237 'B' => self.start_command(),
238 'C' => self.start_output(),
239 'D' => self.end_output(parse_exit_code(marker)),
240 _ => {}
241 }
242 }
243
244 fn start_prompt(&mut self) {
245 if self.current.is_some() {
248 self.finalize_current(None);
249 }
250 let now = now_ms();
251 self.current = Some(Block {
252 index: self.next_index,
253 prompt: String::new(),
254 command: String::new(),
255 output: String::new(),
256 exit_code: None,
257 started_at_unix_ms: now,
258 ended_at_unix_ms: None,
259 cwd: self.current_cwd.clone(),
260 yurai: self.yurai.clone(),
265 });
266 self.next_index += 1;
267 self.phase = Phase::Prompt;
268 }
269
270 fn start_command(&mut self) {
271 if self.current.is_some() {
272 self.phase = Phase::Command;
273 }
274 }
275
276 fn start_output(&mut self) {
277 if self.current.is_some() {
278 self.phase = Phase::Output;
279 }
280 }
281
282 fn end_output(&mut self, exit_code: Option<i32>) {
283 self.finalize_current(exit_code);
284 }
285
286 fn finalize_current(&mut self, exit_code: Option<i32>) {
287 let Some(mut block) = self.current.take() else {
288 return;
289 };
290 block.exit_code = exit_code;
291 block.ended_at_unix_ms = Some(now_ms());
292 if self.blocks.len() == self.cap {
293 self.blocks.pop_front();
294 }
295 self.blocks.push_back(block);
296 self.phase = Phase::Idle;
297 }
298}
299
300fn parse_exit_code(marker: &str) -> Option<i32> {
301 marker.split(';').nth(1).and_then(|s| s.trim().parse().ok())
303}
304
305fn now_ms() -> u64 {
306 SystemTime::now()
307 .duration_since(UNIX_EPOCH)
308 .map(|d| d.as_millis() as u64)
309 .unwrap_or(0)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn idle_extractor_drops_prints() {
318 let mut bx = BlockExtractor::default();
319 bx.on_print('x');
320 assert!(bx.is_empty());
321 assert!(bx.current().is_none());
322 }
323
324 #[test]
325 fn full_block_lifecycle_captures_all_phases() {
326 let mut bx = BlockExtractor::default();
327 bx.on_osc_133("A");
329 for c in "$ ".chars() {
330 bx.on_print(c);
331 }
332 bx.on_osc_133("B");
334 for c in "ls".chars() {
335 bx.on_print(c);
336 }
337 bx.on_osc_133("C");
339 for c in "a b c".chars() {
340 bx.on_print(c);
341 }
342 bx.on_osc_133("D;0");
344
345 assert_eq!(bx.len(), 1);
346 let b = bx.iter().next().unwrap();
347 assert_eq!(b.prompt, "$ ");
348 assert_eq!(b.command, "ls");
349 assert_eq!(b.output, "a b c");
350 assert_eq!(b.exit_code, Some(0));
351 assert!(b.ended_at_unix_ms.is_some());
352 assert_eq!(b.index, 0);
353 assert!(bx.current().is_none());
354 }
355
356 #[test]
357 fn exit_code_optional_when_d_marker_omits_it() {
358 let mut bx = BlockExtractor::default();
359 bx.on_osc_133("A");
360 bx.on_osc_133("B");
361 bx.on_osc_133("C");
362 bx.on_osc_133("D");
363 let b = bx.iter().next().unwrap();
364 assert_eq!(b.exit_code, None);
365 }
366
367 #[test]
368 fn unfinished_block_is_orphaned_when_next_prompt_starts() {
369 let mut bx = BlockExtractor::default();
370 bx.on_osc_133("A");
371 for c in "p1".chars() {
372 bx.on_print(c);
373 }
374 bx.on_osc_133("A");
376 for c in "p2".chars() {
377 bx.on_print(c);
378 }
379 bx.on_osc_133("B");
380 bx.on_osc_133("C");
381 bx.on_osc_133("D;0");
382
383 assert_eq!(bx.len(), 2);
384 let mut iter = bx.iter();
385 let first = iter.next().unwrap();
386 let second = iter.next().unwrap();
387 assert_eq!(first.prompt, "p1");
388 assert_eq!(first.exit_code, None);
389 assert_eq!(second.prompt, "p2");
390 assert_eq!(second.exit_code, Some(0));
391 }
392
393 #[test]
394 fn ring_buffer_caps_at_max() {
395 let mut bx = BlockExtractor::new(3);
396 for i in 0..5 {
397 bx.on_osc_133("A");
398 for c in format!("p{i}").chars() {
399 bx.on_print(c);
400 }
401 bx.on_osc_133("D;0");
402 }
403 assert_eq!(bx.len(), 3);
404 let indices: Vec<u64> = bx.iter().map(|b| b.index).collect();
406 assert_eq!(indices, vec![2, 3, 4]);
407 }
408
409 #[test]
410 fn get_by_index_returns_block_or_none() {
411 let mut bx = BlockExtractor::default();
412 bx.on_osc_133("A");
413 bx.on_osc_133("D;0");
414 bx.on_osc_133("A");
415 bx.on_osc_133("D;1");
416
417 assert_eq!(bx.get(0).map(|b| b.exit_code), Some(Some(0)));
418 assert_eq!(bx.get(1).map(|b| b.exit_code), Some(Some(1)));
419 assert!(bx.get(99).is_none());
420 }
421
422 #[test]
423 fn osc7_cwd_stamped_onto_next_block() {
424 let mut bx = BlockExtractor::default();
425 bx.set_cwd_from_osc7("file://localhost/Users/me/code");
426 bx.on_osc_133("A");
427 bx.on_osc_133("D;0");
428 let b = bx.iter().next().unwrap();
429 assert_eq!(b.cwd.as_deref(), Some("/Users/me/code"));
430 }
431
432 #[test]
433 fn osc7_without_file_scheme_passes_through_verbatim() {
434 let mut bx = BlockExtractor::default();
435 bx.set_cwd_from_osc7("/tmp/raw-path");
436 assert_eq!(bx.current_cwd(), Some("/tmp/raw-path"));
437 }
438
439 #[test]
440 fn block_duration_ms_computes_on_finalize() {
441 let mut bx = BlockExtractor::default();
442 bx.on_osc_133("A");
443 std::thread::sleep(std::time::Duration::from_millis(5));
444 bx.on_osc_133("D;0");
445 let b = bx.iter().next().unwrap();
446 let d = b.duration_ms().expect("finalized block has duration");
447 assert!(d < 5_000, "absurd duration: {d}ms");
448 }
449
450 #[test]
451 fn parse_exit_code_handles_typical_shapes() {
452 assert_eq!(parse_exit_code("D"), None);
453 assert_eq!(parse_exit_code("D;"), None);
454 assert_eq!(parse_exit_code("D;0"), Some(0));
455 assert_eq!(parse_exit_code("D;127"), Some(127));
456 assert_eq!(parse_exit_code("D ; 130"), Some(130));
457 }
458
459 fn one_block(ex: &mut BlockExtractor) {
468 ex.on_osc_133("A");
469 ex.on_osc_133("B");
470 for c in "echo hi".chars() {
471 ex.on_print(c);
472 }
473 ex.on_osc_133("C");
474 ex.on_osc_133("D;0");
475 }
476
477 #[test]
478 fn an_unstamped_extractor_mints_unknown_never_human() {
479 let mut ex = BlockExtractor::new(8);
480 one_block(&mut ex);
481 assert_eq!(
482 ex.get(0).unwrap().yurai,
483 tear_types::Yurai::Unknown,
484 "an unattributed block must stay Unknown — defaulting to Human \
485 would launder every agent-run command in the history"
486 );
487 }
488
489 #[test]
490 fn a_stamped_extractor_marks_every_block_it_mints() {
491 let mut ex = BlockExtractor::new(8);
492 assert!(ex.stamp_yurai(tear_types::Yurai::Automation {
493 label: Some("claude-code".into())
494 }));
495 one_block(&mut ex);
496 one_block(&mut ex);
497 for i in 0..2 {
498 assert!(
499 ex.get(i).unwrap().yurai.is_automation(),
500 "block {i} lost its attribution"
501 );
502 }
503 }
504
505 #[test]
510 fn re_stamping_is_refused_so_provenance_cannot_drift() {
511 let mut ex = BlockExtractor::new(8);
512 assert!(ex.stamp_yurai(tear_types::Yurai::Automation { label: None }));
513 assert!(
514 !ex.stamp_yurai(tear_types::Yurai::Human),
515 "the second stamp must be REFUSED, not applied"
516 );
517 one_block(&mut ex);
518 assert!(
519 ex.get(0).unwrap().yurai.is_automation(),
520 "an agent pane must not be able to relabel itself human"
521 );
522 }
523
524 #[test]
528 fn a_pre_attribution_block_decodes_as_unknown() {
529 let legacy = r#"{
530 "index": 0, "prompt": "$ ", "command": "ls", "output": "a\n",
531 "exit_code": 0, "started_at_unix_ms": 1, "ended_at_unix_ms": 2
532 }"#;
533 let b: Block = serde_json::from_str(legacy).expect("legacy block must still decode");
534 assert_eq!(b.yurai, tear_types::Yurai::Unknown);
535 assert_eq!(b.cwd, None);
536 }
537}