ailloli_ui_terminal_core/snapshot.rs
1//! Bounded, serializable terminal snapshots and replayable event logs.
2//!
3//! Snapshot creation is read-only. It copies selected terminal state into owned
4//! values, applies the configured text policy to selected fields, and limits
5//! collection sizes. A snapshot is an observation format, not a lossless state
6//! serialization: cell limits, line limits, event sanitization, and omitted
7//! parser state prevent reconstructing the original terminal from one.
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 terminal_visual_line_global_indices, ActiveScreen, CellWidth, CommandExecution, CommandId,
13 CommandStatus, TerminalCell, TerminalConfig, TerminalCursor, TerminalDiagnostic,
14 TerminalDiagnosticSeverity, TerminalLine, TerminalModes, TerminalParser, TerminalShellSnapshot,
15 TerminalSize, TerminalState, TerminalStyle, TerminalWarning, VteTerminalParser,
16};
17
18/// Default maximum number of retained visual lines.
19const DEFAULT_MAX_LINES: usize = 200;
20/// Default maximum number of retained non-trailing cells per line.
21const DEFAULT_MAX_CELLS_PER_LINE: usize = 160;
22/// Default maximum number of retained command summaries.
23const DEFAULT_MAX_COMMANDS: usize = 64;
24/// Default maximum number of retained diagnostics.
25const DEFAULT_MAX_DIAGNOSTICS: usize = 128;
26/// Default maximum number of retained warnings.
27const DEFAULT_MAX_WARNINGS: usize = 128;
28/// Default event-log capacity and snapshot event limit.
29const DEFAULT_MAX_EVENTS: usize = 2_000;
30/// Default maximum number of input or output bytes sanitized per event.
31const DEFAULT_MAX_EVENT_BYTES: usize = 4_096;
32/// Default maximum number of latest-output text lines.
33const DEFAULT_MAX_LATEST_OUTPUT_LINES: usize = 12;
34/// Default maximum source-text byte count before the truncation suffix.
35const DEFAULT_MAX_TEXT_BYTES: usize = 8_192;
36
37/// Collection limits and redaction policy used to build terminal snapshots.
38///
39/// Every limit accepts `0`, which produces an empty corresponding collection.
40/// Text fields are independently limited by [`TerminalRedactionPolicy::max_text_bytes`].
41/// The public fields and derived deserialization allow any `usize` values; no
42/// additional validation or clamping is performed.
43///
44/// # Examples
45///
46/// ```
47/// use ailloli_ui_terminal_core::TerminalSnapshotConfig;
48///
49/// let config = TerminalSnapshotConfig {
50/// max_lines: 25,
51/// max_events: 0,
52/// ..TerminalSnapshotConfig::default()
53/// };
54/// assert_eq!(config.max_lines, 25);
55/// assert_eq!(config.max_events, 0);
56/// ```
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct TerminalSnapshotConfig {
59 /// Maximum number of newest visual lines retained in [`TerminalSnapshot::lines`].
60 pub max_lines: usize,
61 /// Maximum number of non-wide-trailing cells retained for each snapshot line.
62 pub max_cells_per_line: usize,
63 /// Maximum number of newest completed and current commands retained.
64 pub max_commands: usize,
65 /// Maximum number of oldest diagnostics retained from the state.
66 pub max_diagnostics: usize,
67 /// Maximum number of oldest warnings retained from the state.
68 pub max_warnings: usize,
69 /// Maximum number of newest records copied by [`TerminalEventLog::snapshot`].
70 pub max_events: usize,
71 /// Maximum number of raw bytes considered for an input or output event.
72 pub max_event_bytes: usize,
73 /// Maximum number of newest visual lines represented as plain text.
74 pub max_latest_output_lines: usize,
75 /// Text truncation and secret-replacement policy.
76 pub redaction: TerminalRedactionPolicy,
77}
78
79impl TerminalSnapshotConfig {
80 /// Returns the default limits with all redaction disabled.
81 ///
82 /// Text is still limited to 8,192 source bytes and byte events are still
83 /// limited by [`Self::max_event_bytes`]. This helper is intended only for
84 /// test fixtures containing no secrets; using it for production snapshots
85 /// can expose command lines, terminal output, and working-directory URIs.
86 ///
87 /// # Examples
88 ///
89 /// ```
90 /// use ailloli_ui_terminal_core::TerminalSnapshotConfig;
91 ///
92 /// let config = TerminalSnapshotConfig::unredacted_for_tests();
93 /// assert!(!config.redaction.enabled);
94 /// assert_eq!(config.max_lines, 200);
95 /// ```
96 pub fn unredacted_for_tests() -> Self {
97 Self {
98 redaction: TerminalRedactionPolicy::disabled(),
99 ..Self::default()
100 }
101 }
102}
103
104impl Default for TerminalSnapshotConfig {
105 /// Returns bounded production-oriented defaults with redaction enabled.
106 ///
107 /// # Examples
108 ///
109 /// ```
110 /// use ailloli_ui_terminal_core::TerminalSnapshotConfig;
111 ///
112 /// let config = TerminalSnapshotConfig::default();
113 /// assert_eq!(config.max_lines, 200);
114 /// assert_eq!(config.max_cells_per_line, 160);
115 /// assert_eq!(config.max_commands, 64);
116 /// assert_eq!(config.max_diagnostics, 128);
117 /// assert_eq!(config.max_warnings, 128);
118 /// assert_eq!(config.max_events, 2_000);
119 /// assert_eq!(config.max_event_bytes, 4_096);
120 /// assert_eq!(config.max_latest_output_lines, 12);
121 /// assert!(config.redaction.enabled);
122 /// ```
123 fn default() -> Self {
124 Self {
125 max_lines: DEFAULT_MAX_LINES,
126 max_cells_per_line: DEFAULT_MAX_CELLS_PER_LINE,
127 max_commands: DEFAULT_MAX_COMMANDS,
128 max_diagnostics: DEFAULT_MAX_DIAGNOSTICS,
129 max_warnings: DEFAULT_MAX_WARNINGS,
130 max_events: DEFAULT_MAX_EVENTS,
131 max_event_bytes: DEFAULT_MAX_EVENT_BYTES,
132 max_latest_output_lines: DEFAULT_MAX_LATEST_OUTPUT_LINES,
133 redaction: TerminalRedactionPolicy::default(),
134 }
135 }
136}
137
138/// Policy for byte limiting, literal rules, and secret-like key redaction.
139///
140/// [`Self::redact_text`] first truncates the input at a UTF-8 boundary, then,
141/// when enabled, applies explicit rules in order followed by case-insensitive
142/// heuristics for `password`, `passwd`, `token`, `secret`, `api_key`, `apikey`,
143/// `authorization`, and `bearer`. Heuristic values end at whitespace or one of
144/// `;&,)]`. This is a best-effort scrubber, not a complete secret detector.
145///
146/// # Examples
147///
148/// ```
149/// use ailloli_ui_terminal_core::TerminalRedactionPolicy;
150///
151/// let policy = TerminalRedactionPolicy::default();
152/// assert_eq!(policy.redact_text("token=abc; ok"), "token=[redacted]; ok");
153/// ```
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct TerminalRedactionPolicy {
156 /// Whether literal and heuristic replacement is active.
157 pub enabled: bool,
158 /// Replacement used by heuristic rules; empty strings remove matched values.
159 pub replacement: String,
160 /// Maximum source byte count retained by [`Self::redact_text`].
161 ///
162 /// A `"...[truncated]"` suffix is appended after limiting, so the returned
163 /// string can exceed this value. `0` returns only that suffix for non-empty input.
164 pub max_text_bytes: usize,
165 /// Ordered literal replacement rules applied before heuristics.
166 pub rules: Vec<TerminalRedactionRule>,
167}
168
169impl TerminalRedactionPolicy {
170 /// Returns a policy that limits text but performs no secret replacement.
171 ///
172 /// # Examples
173 ///
174 /// ```
175 /// use ailloli_ui_terminal_core::TerminalRedactionPolicy;
176 ///
177 /// let policy = TerminalRedactionPolicy::disabled();
178 /// assert_eq!(policy.redact_text("token=visible"), "token=visible");
179 /// assert_eq!(policy.max_text_bytes, 8_192);
180 /// ```
181 pub fn disabled() -> Self {
182 Self {
183 enabled: false,
184 replacement: "[redacted]".into(),
185 max_text_bytes: DEFAULT_MAX_TEXT_BYTES,
186 rules: Vec::new(),
187 }
188 }
189
190 /// Limits and, when enabled, redacts a UTF-8 string.
191 ///
192 /// Empty literal patterns are ignored. Non-empty explicit rules are applied
193 /// sequentially, so a replacement produced by one rule can be matched by a
194 /// later rule. Truncation occurs before replacement and preserves a UTF-8
195 /// boundary, but the truncation suffix is additional to the byte limit.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use ailloli_ui_terminal_core::{TerminalRedactionPolicy, TerminalRedactionRule};
201 ///
202 /// let mut policy = TerminalRedactionPolicy::default();
203 /// policy.rules.push(TerminalRedactionRule::exact("host", "internal.example"));
204 /// assert_eq!(
205 /// policy.redact_text("host=internal.example password=hunter2"),
206 /// "host=[redacted] password=[redacted]",
207 /// );
208 /// ```
209 pub fn redact_text(&self, text: &str) -> String {
210 let mut out = limit_string_bytes(text, self.max_text_bytes);
211 if !self.enabled {
212 return out;
213 }
214
215 for rule in &self.rules {
216 if !rule.pattern.is_empty() {
217 out = out.replace(&rule.pattern, &rule.replacement);
218 }
219 }
220
221 for key in [
222 "password",
223 "passwd",
224 "token",
225 "secret",
226 "api_key",
227 "apikey",
228 "authorization",
229 "bearer",
230 ] {
231 out = redact_key_value(&out, key, &self.replacement);
232 }
233 out
234 }
235
236 /// Limits and optionally redacts an arbitrary byte payload.
237 ///
238 /// The returned tuple is `(bytes, changed_by_redaction, truncated_by_max_bytes)`.
239 /// With redaction disabled, retained bytes are returned exactly, including
240 /// invalid UTF-8. With redaction enabled, retained bytes are decoded lossily,
241 /// then passed through [`Self::redact_text`]; consequently invalid UTF-8 or
242 /// the policy's independent text limit can make the second flag `true`.
243 /// `max_bytes == 0` retains no input bytes.
244 ///
245 /// # Examples
246 ///
247 /// ```
248 /// use ailloli_ui_terminal_core::TerminalRedactionPolicy;
249 ///
250 /// let policy = TerminalRedactionPolicy::default();
251 /// let (bytes, changed, truncated) = policy.redact_bytes(b"token=secret", 64);
252 /// assert_eq!(bytes, b"token=[redacted]");
253 /// assert!(changed);
254 /// assert!(!truncated);
255 /// ```
256 pub fn redact_bytes(&self, bytes: &[u8], max_bytes: usize) -> (Vec<u8>, bool, bool) {
257 let truncated = bytes.len() > max_bytes;
258 let input = &bytes[..bytes.len().min(max_bytes)];
259 if !self.enabled {
260 return (input.to_vec(), false, truncated);
261 }
262 let text = String::from_utf8_lossy(input);
263 let redacted = self.redact_text(&text);
264 let redacted_flag = redacted.as_bytes() != input;
265 (redacted.into_bytes(), redacted_flag, truncated)
266 }
267}
268
269impl Default for TerminalRedactionPolicy {
270 /// Enables heuristic redaction with an 8,192-byte text limit.
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use ailloli_ui_terminal_core::TerminalRedactionPolicy;
276 ///
277 /// let policy = TerminalRedactionPolicy::default();
278 /// assert!(policy.enabled);
279 /// assert_eq!(policy.replacement, "[redacted]");
280 /// assert!(policy.rules.is_empty());
281 /// ```
282 fn default() -> Self {
283 Self {
284 enabled: true,
285 replacement: "[redacted]".into(),
286 max_text_bytes: DEFAULT_MAX_TEXT_BYTES,
287 rules: Vec::new(),
288 }
289 }
290}
291
292/// One named, literal text replacement rule.
293///
294/// The name is metadata only. Matching uses [`str::replace`], is
295/// case-sensitive, and ignores a rule whose pattern is empty.
296///
297/// # Examples
298///
299/// ```
300/// use ailloli_ui_terminal_core::TerminalRedactionRule;
301///
302/// let rule = TerminalRedactionRule::exact("account", "alice@example.com");
303/// assert_eq!(rule.name, "account");
304/// assert_eq!(rule.pattern, "alice@example.com");
305/// assert_eq!(rule.replacement, "[redacted]");
306/// ```
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct TerminalRedactionRule {
309 /// Informational rule name; it does not affect matching.
310 pub name: String,
311 /// Case-sensitive literal pattern; an empty pattern is ignored.
312 pub pattern: String,
313 /// Text inserted for every match; it may be empty.
314 pub replacement: String,
315}
316
317impl TerminalRedactionRule {
318 /// Creates a literal rule whose replacement is `"[redacted]"`.
319 ///
320 /// `name` and `pattern` are stored without validation. In particular, an
321 /// empty pattern is accepted but later ignored by the policy.
322 ///
323 /// # Examples
324 ///
325 /// ```
326 /// use ailloli_ui_terminal_core::{TerminalRedactionPolicy, TerminalRedactionRule};
327 ///
328 /// let mut policy = TerminalRedactionPolicy::default();
329 /// policy.rules.push(TerminalRedactionRule::exact("user", "alice"));
330 /// assert_eq!(policy.redact_text("alice and Alice"), "[redacted] and Alice");
331 /// ```
332 pub fn exact(name: impl Into<String>, pattern: impl Into<String>) -> Self {
333 Self {
334 name: name.into(),
335 pattern: pattern.into(),
336 replacement: "[redacted]".into(),
337 }
338 }
339}
340
341/// Owned, bounded observation of a [`TerminalState`].
342///
343/// The selected line and command collections retain their newest entries,
344/// whereas diagnostics and warnings retain their oldest entries. Title, CWD,
345/// line text/cells, latest output, and shell command/CWD text are passed through
346/// the configured redaction policy. Diagnostics and warnings are copied
347/// verbatim, so their messages, paths, and other fields can still contain
348/// sensitive data. Dirty-line indices are also copied without a size limit.
349///
350/// Public fields and deserialization can construct internally inconsistent
351/// observations; this type deliberately performs no validation on deserialize.
352///
353/// # Examples
354///
355/// ```
356/// use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalSnapshotConfig, TerminalState};
357///
358/// let mut state = TerminalState::new();
359/// state.write_str("hello");
360/// let snapshot = TerminalSnapshot::from_state(&state, TerminalSnapshotConfig::default());
361/// assert_eq!(snapshot.active_screen, state.active_screen);
362/// assert!(snapshot.lines.iter().any(|line| line.text.contains("hello")));
363/// ```
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct TerminalSnapshot {
366 /// Active screen size in rows and columns.
367 pub size: TerminalSize,
368 /// Screen whose lines were captured.
369 pub active_screen: ActiveScreen,
370 /// Redacted terminal title, or `None` when the state has none.
371 pub title: Option<String>,
372 /// Redacted current-working-directory URI, or `None` when unknown.
373 pub cwd_uri: Option<String>,
374 /// Cursor observation, including a normal-screen global line when available.
375 pub cursor: TerminalSnapshotCursor,
376 /// Copy of current terminal modes.
377 pub modes: TerminalModes,
378 /// Total number of lines currently retained in normal-screen scrollback.
379 pub scrollback_len: usize,
380 /// Configured scrollback retention limit; `0` retains no history.
381 pub scrollback_limit: usize,
382 /// Saturating count of all lines ever pushed to scrollback.
383 pub scrollback_total_pushed: u64,
384 /// Whether the state's damage tracker requests a full repaint.
385 pub damage_full: bool,
386 /// Unbounded copy of the state's dirty row indices.
387 pub dirty_lines: Vec<usize>,
388 /// Newest captured visual lines in original visual order.
389 pub lines: Vec<TerminalSnapshotLine>,
390 /// Newest captured full-line texts in original visual order.
391 ///
392 /// This limit is independent of [`Self::lines`] and its cell limit.
393 pub latest_output_lines: Vec<String>,
394 /// Redacted shell/process/command snapshot.
395 pub shell: TerminalShellSnapshot,
396 /// Newest bounded command summaries, including the current command last.
397 pub commands: Vec<CommandSummary>,
398 /// Oldest bounded diagnostics, copied without redaction.
399 pub diagnostics: Vec<TerminalDiagnostic>,
400 /// Oldest bounded warnings, copied without redaction.
401 pub warnings: Vec<TerminalWarning>,
402 /// Newest bounded event records, or empty when no log was supplied.
403 pub event_log: Vec<TerminalEventRecord>,
404 /// Number of registered hyperlinks; targets are not included.
405 pub hyperlinks: usize,
406 /// Whether selected top-level state collections were omitted.
407 ///
408 /// This is set for omitted visual lines, diagnostics, warnings, or command
409 /// history according to the implementation's history-versus-summary count.
410 /// It does **not** report cell truncation, latest-output omission, event-log
411 /// omission, byte/text truncation, redaction, or every possible command
412 /// omission. Inspect nested flags and configured limits as well.
413 pub truncated: bool,
414}
415
416impl TerminalSnapshot {
417 /// Captures state without attaching an event log.
418 ///
419 /// This does not mutate `state`. [`Self::event_log`] is empty even if events
420 /// were recorded elsewhere, because no log is accepted by this method.
421 ///
422 /// # Examples
423 ///
424 /// ```
425 /// use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalSnapshotConfig, TerminalState};
426 ///
427 /// let snapshot = TerminalSnapshot::from_state(
428 /// &TerminalState::new(),
429 /// TerminalSnapshotConfig { max_lines: 1, ..Default::default() },
430 /// );
431 /// assert!(snapshot.lines.len() <= 1);
432 /// assert!(snapshot.event_log.is_empty());
433 /// ```
434 pub fn from_state(state: &TerminalState, config: TerminalSnapshotConfig) -> Self {
435 Self::from_state_with_event_log(state, config, None)
436 }
437
438 /// Captures state and the newest configured records from an optional log.
439 ///
440 /// `None` and an empty log both produce an empty event collection. Event
441 /// records are copied as already sanitized at record time; changing the
442 /// snapshot's redaction policy does not re-sanitize existing records.
443 ///
444 /// Line `visual_index` values refer to the full pre-limit visual sequence,
445 /// not the returned vector. On the normal screen that sequence is scrollback
446 /// followed by visible screen rows; on the alternate screen it contains only
447 /// alternate-screen rows.
448 ///
449 /// # Examples
450 ///
451 /// ```
452 /// use ailloli_ui_terminal_core::{
453 /// TerminalEventLog, TerminalSnapshot, TerminalSnapshotConfig, TerminalState,
454 /// };
455 ///
456 /// let config = TerminalSnapshotConfig::unredacted_for_tests();
457 /// let mut log = TerminalEventLog::new(2);
458 /// assert_eq!(log.record_output(b"ok", &config), 1);
459 /// let snapshot = TerminalSnapshot::from_state_with_event_log(
460 /// &TerminalState::new(), config, Some(&log),
461 /// );
462 /// assert_eq!(snapshot.event_log.len(), 1);
463 /// ```
464 pub fn from_state_with_event_log(
465 state: &TerminalState,
466 config: TerminalSnapshotConfig,
467 event_log: Option<&TerminalEventLog>,
468 ) -> Self {
469 let all_lines = snapshot_line_refs(state);
470 let skipped = all_lines.len().saturating_sub(config.max_lines);
471 let lines = all_lines
472 .iter()
473 .skip(skipped)
474 .enumerate()
475 .map(|(visible_idx, line)| {
476 snapshot_line(
477 visible_idx + skipped,
478 line.global_index,
479 line.scrollback,
480 line.line,
481 &config,
482 )
483 })
484 .collect::<Vec<_>>();
485 let latest_output_lines = all_lines
486 .iter()
487 .rev()
488 .take(config.max_latest_output_lines)
489 .map(|line| config.redaction.redact_text(&line.line.plain_text()))
490 .collect::<Vec<_>>()
491 .into_iter()
492 .rev()
493 .collect::<Vec<_>>();
494 let diagnostics = state
495 .diagnostics
496 .iter()
497 .take(config.max_diagnostics)
498 .cloned()
499 .collect::<Vec<_>>();
500 let commands = command_summaries(state, &diagnostics, &config);
501 let warnings = state
502 .warnings
503 .iter()
504 .take(config.max_warnings)
505 .cloned()
506 .collect::<Vec<_>>();
507 let event_log = event_log
508 .map(|log| log.snapshot(&config))
509 .unwrap_or_default();
510 let truncated = skipped > 0
511 || state.diagnostics.len() > diagnostics.len()
512 || state.warnings.len() > warnings.len()
513 || state.shell.history.len() > commands.len();
514
515 Self {
516 size: state.active_screen().size(),
517 active_screen: state.active_screen,
518 title: state
519 .title
520 .as_ref()
521 .map(|t| config.redaction.redact_text(t)),
522 cwd_uri: state
523 .cwd_uri
524 .as_ref()
525 .map(|u| config.redaction.redact_text(u)),
526 cursor: TerminalSnapshotCursor::from_cursor(state.cursor, cursor_global_line(state)),
527 modes: state.modes,
528 scrollback_len: state.scrollback.len(),
529 scrollback_limit: state.scrollback.limit(),
530 scrollback_total_pushed: state.scrollback.total_pushed(),
531 damage_full: state.damage.full,
532 dirty_lines: state.damage.dirty_lines.clone(),
533 lines,
534 latest_output_lines,
535 shell: redacted_shell_snapshot(state.shell_snapshot(), &config.redaction),
536 commands,
537 diagnostics,
538 warnings,
539 event_log,
540 hyperlinks: state.hyperlinks.len(),
541 truncated,
542 }
543 }
544}
545
546/// Snapshot cursor position and presentation.
547///
548/// # Examples
549///
550/// ```
551/// use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalSnapshotConfig, TerminalState};
552///
553/// let snapshot = TerminalSnapshot::from_state(&TerminalState::new(), Default::default());
554/// assert_eq!((snapshot.cursor.row, snapshot.cursor.col), (0, 0));
555/// assert_eq!(snapshot.cursor.global_line, Some(0));
556/// ```
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub struct TerminalSnapshotCursor {
559 /// Zero-based row within the active screen.
560 pub row: usize,
561 /// Zero-based column within the active screen.
562 pub col: usize,
563 /// Whether renderers should display the cursor.
564 pub visible: bool,
565 /// Requested cursor shape.
566 pub shape: crate::TerminalCursorShape,
567 /// Saturating normal-screen global line, or `None` on the alternate screen.
568 pub global_line: Option<u64>,
569}
570
571impl TerminalSnapshotCursor {
572 /// Copies a live cursor and its separately computed global line.
573 fn from_cursor(cursor: TerminalCursor, global_line: Option<u64>) -> Self {
574 Self {
575 row: cursor.row,
576 col: cursor.col,
577 visible: cursor.visible,
578 shape: cursor.shape,
579 global_line,
580 }
581 }
582}
583
584/// One captured terminal line.
585///
586/// `text` represents the full plain line subject only to the text policy, while
587/// `cells` is independently bounded. Wide-character trailing placeholders are
588/// excluded from `cells`; each retained cell's `col` remains its original
589/// physical column.
590///
591/// # Examples
592///
593/// ```
594/// use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalSnapshotConfig, TerminalState};
595///
596/// let snapshot = TerminalSnapshot::from_state(&TerminalState::new(), Default::default());
597/// let first = &snapshot.lines[0];
598/// assert_eq!(first.visual_index, 0);
599/// assert!(!first.from_scrollback);
600/// ```
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct TerminalSnapshotLine {
603 /// Zero-based position in the full pre-limit visual line sequence.
604 pub visual_index: usize,
605 /// Normal-screen global line, or `None` for alternate-screen lines.
606 pub global_index: Option<u64>,
607 /// Whether this line came from normal-screen scrollback.
608 pub from_scrollback: bool,
609 /// Redacted full plain text, independent of the cell limit.
610 pub text: String,
611 /// Bounded non-wide-trailing cells in physical-column order.
612 pub cells: Vec<TerminalSnapshotCell>,
613 /// Whether the raw line cell count exceeds `max_cells_per_line`.
614 ///
615 /// This comparison includes filtered wide-trailing placeholders, so `true`
616 /// does not necessarily mean a visible cell was omitted.
617 pub truncated: bool,
618}
619
620/// One retained, renderable cell in a snapshot line.
621///
622/// # Examples
623///
624/// ```
625/// use ailloli_ui_terminal_core::{TerminalSnapshot, TerminalState};
626///
627/// let mut state = TerminalState::new();
628/// state.write_str("A");
629/// let snapshot = TerminalSnapshot::from_state(&state, Default::default());
630/// let cell = &snapshot.lines[0].cells[0];
631/// assert_eq!((cell.col, cell.text.as_str()), (0, "A"));
632/// ```
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634pub struct TerminalSnapshotCell {
635 /// Original zero-based physical column before placeholder filtering.
636 pub col: usize,
637 /// Redacted grapheme text stored by the source cell.
638 pub text: String,
639 /// Copy of the cell's visual style.
640 pub style: TerminalStyle,
641 /// Single, wide-leading, or wide-trailing classification.
642 ///
643 /// Snapshot construction filters wide-trailing cells, but public
644 /// construction/deserialization can still supply that value.
645 pub width: CellWidth,
646 /// Hyperlink identity, or `None`; hyperlink target data is not embedded.
647 pub hyperlink: Option<crate::TerminalHyperlinkId>,
648}
649
650impl TerminalSnapshotCell {
651 /// Copies and redacts one cell while preserving its physical column.
652 fn from_cell(col: usize, cell: &TerminalCell, config: &TerminalSnapshotConfig) -> Self {
653 Self {
654 col,
655 text: config.redaction.redact_text(&cell.text),
656 style: cell.style,
657 width: cell.width,
658 hyperlink: cell.hyperlink,
659 }
660 }
661}
662
663/// Redacted command lifecycle and diagnostic counts for a snapshot.
664///
665/// Counts are computed only from the diagnostic slice supplied to
666/// [`Self::from_command`]. `diagnostic_count` includes all severities;
667/// `error_count` and `warning_count` are severity-specific subsets.
668///
669/// # Examples
670///
671/// ```
672/// use ailloli_ui_terminal_core::{
673/// CommandExecution, CommandId, CommandStatus, CommandSummary, TerminalRedactionPolicy,
674/// };
675///
676/// let command = CommandExecution::running(CommandId(7), "token=abc", None, 3, Some(10));
677/// let summary = CommandSummary::from_command(
678/// &command,
679/// &[],
680/// &TerminalRedactionPolicy::default(),
681/// );
682/// assert_eq!(summary.status, CommandStatus::Running);
683/// assert_eq!(summary.command_line, "token=[redacted]");
684/// ```
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686pub struct CommandSummary {
687 /// Shell-state-local command identity.
688 pub id: CommandId,
689 /// Redacted command line.
690 pub command_line: String,
691 /// Redacted working-directory URI, or `None` when unknown.
692 pub cwd_uri: Option<String>,
693 /// Lifecycle outcome at capture time.
694 pub status: CommandStatus,
695 /// Signed exit code, or `None` when absent.
696 pub exit_code: Option<i32>,
697 /// Signed signal number, or `None` when absent.
698 pub signal: Option<i32>,
699 /// Duration in milliseconds, or `None` when unavailable.
700 pub duration_ms: Option<u64>,
701 /// Inclusive first global output line.
702 pub output_start_line: u64,
703 /// Inclusive last global output line, or `None` while open/unknown.
704 pub output_end_line: Option<u64>,
705 /// Number of supplied diagnostics whose `command_id` exactly matches.
706 pub diagnostic_count: usize,
707 /// Number of matching error diagnostics.
708 pub error_count: usize,
709 /// Number of matching warning diagnostics.
710 pub warning_count: usize,
711}
712
713impl CommandSummary {
714 /// Builds a redacted summary and counts matching diagnostics.
715 ///
716 /// Diagnostics with `command_id == None` or another identity are ignored.
717 /// The diagnostics themselves are not returned or redacted here. Count
718 /// increments use ordinary `usize` arithmetic; exceeding `usize::MAX` would
719 /// panic with overflow checks and wrap otherwise, although such a slice
720 /// cannot be represented in practice.
721 ///
722 /// # Examples
723 ///
724 /// ```
725 /// use ailloli_ui_terminal_core::{
726 /// CommandExecution, CommandId, CommandSummary, TerminalRedactionPolicy,
727 /// };
728 ///
729 /// let command = CommandExecution::running(CommandId(2), "echo ok", None, 9, None);
730 /// let summary = CommandSummary::from_command(
731 /// &command,
732 /// &[],
733 /// &TerminalRedactionPolicy::disabled(),
734 /// );
735 /// assert_eq!(summary.id, CommandId(2));
736 /// assert_eq!(summary.output_start_line, 9);
737 /// assert_eq!(summary.diagnostic_count, 0);
738 /// ```
739 pub fn from_command(
740 command: &CommandExecution,
741 diagnostics: &[TerminalDiagnostic],
742 redaction: &TerminalRedactionPolicy,
743 ) -> Self {
744 let mut diagnostic_count = 0;
745 let mut error_count = 0;
746 let mut warning_count = 0;
747 for diagnostic in diagnostics
748 .iter()
749 .filter(|diagnostic| diagnostic.command_id == Some(command.id))
750 {
751 diagnostic_count += 1;
752 match diagnostic.severity {
753 TerminalDiagnosticSeverity::Error => error_count += 1,
754 TerminalDiagnosticSeverity::Warning => warning_count += 1,
755 TerminalDiagnosticSeverity::Info | TerminalDiagnosticSeverity::Hint => {}
756 }
757 }
758 Self {
759 id: command.id,
760 command_line: redaction.redact_text(&command.command_line),
761 cwd_uri: command
762 .cwd_uri
763 .as_ref()
764 .map(|cwd| redaction.redact_text(cwd)),
765 status: command.status,
766 exit_code: command.exit_code,
767 signal: command.signal,
768 duration_ms: command.duration_ms,
769 output_start_line: command.output_range.start_line,
770 output_end_line: command.output_range.end_line,
771 diagnostic_count,
772 error_count,
773 warning_count,
774 }
775 }
776}
777
778/// Bounded, insertion-ordered log of sanitized terminal events.
779///
780/// Recording sanitizes each event immediately using the supplied snapshot
781/// configuration. Retention keeps the newest `limit` records; a zero limit
782/// stores none but still allocates and returns sequence numbers. Public fields
783/// and deserialization can violate capacity and sequence assumptions.
784///
785/// # Examples
786///
787/// ```
788/// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
789///
790/// let mut log = TerminalEventLog::new(1);
791/// let config = TerminalSnapshotConfig::unredacted_for_tests();
792/// assert_eq!(log.record_output(b"first", &config), 1);
793/// assert_eq!(log.record_output(b"second", &config), 2);
794/// assert_eq!(log.records.len(), 1);
795/// ```
796#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
797pub struct TerminalEventLog {
798 /// Maximum records retained by [`Self::record`]; `0` retains none.
799 pub limit: usize,
800 /// Sequence returned by the next record operation.
801 ///
802 /// Allocation saturates at `u64::MAX`, after which that value repeats.
803 pub next_sequence: u64,
804 /// Sanitized records in insertion order, normally oldest to newest.
805 pub records: Vec<TerminalEventRecord>,
806}
807
808impl TerminalEventLog {
809 /// Creates an empty log whose first sequence is one.
810 ///
811 /// `limit` is exact and may be zero.
812 ///
813 /// # Examples
814 ///
815 /// ```
816 /// use ailloli_ui_terminal_core::TerminalEventLog;
817 ///
818 /// let log = TerminalEventLog::new(0);
819 /// assert_eq!(log.limit, 0);
820 /// assert_eq!(log.next_sequence, 1);
821 /// assert!(log.records.is_empty());
822 /// ```
823 pub fn new(limit: usize) -> Self {
824 Self {
825 limit,
826 next_sequence: 1,
827 records: Vec::new(),
828 }
829 }
830
831 /// Sanitizes and conditionally retains one event, returning its sequence.
832 ///
833 /// The sequence counter uses saturating addition, so recordings at and after
834 /// `u64::MAX` reuse that sequence. When over capacity, removing the oldest
835 /// vector element shifts the remaining records and is linear in log length.
836 /// See [`TerminalEventRecord`] for the scope of sanitization flags.
837 ///
838 /// # Examples
839 ///
840 /// ```
841 /// use ailloli_ui_terminal_core::{
842 /// TerminalEventKind, TerminalEventLog, TerminalSize, TerminalSnapshotConfig,
843 /// };
844 ///
845 /// let mut log = TerminalEventLog::new(4);
846 /// let sequence = log.record(
847 /// TerminalEventKind::Resize { size: TerminalSize::new(30, 100) },
848 /// &TerminalSnapshotConfig::default(),
849 /// );
850 /// assert_eq!(sequence, 1);
851 /// assert_eq!(log.records.len(), 1);
852 /// ```
853 pub fn record(&mut self, kind: TerminalEventKind, config: &TerminalSnapshotConfig) -> u64 {
854 let sequence = self.next_sequence;
855 self.next_sequence = self.next_sequence.saturating_add(1);
856 let record = TerminalEventRecord::new(sequence, kind, config);
857 if self.limit > 0 {
858 self.records.push(record);
859 while self.records.len() > self.limit {
860 self.records.remove(0);
861 }
862 }
863 sequence
864 }
865
866 /// Records sanitized terminal-output bytes and returns their sequence.
867 ///
868 /// The stored payload may be truncated, lossily decoded, or redacted. The
869 /// input slice is copied and is not retained by reference.
870 ///
871 /// # Examples
872 ///
873 /// ```
874 /// use ailloli_ui_terminal_core::{TerminalEventKind, TerminalEventLog, TerminalSnapshotConfig};
875 ///
876 /// let mut log = TerminalEventLog::new(1);
877 /// let sequence = log.record_output(b"token=abc", &TerminalSnapshotConfig::default());
878 /// assert_eq!(sequence, 1);
879 /// assert!(matches!(log.records[0].kind, TerminalEventKind::OutputBytes { .. }));
880 /// assert!(log.records[0].redacted);
881 /// ```
882 pub fn record_output(&mut self, bytes: &[u8], config: &TerminalSnapshotConfig) -> u64 {
883 self.record(
884 TerminalEventKind::OutputBytes {
885 bytes: bytes.to_vec(),
886 },
887 config,
888 )
889 }
890
891 /// Records sanitized terminal-input bytes and returns their sequence.
892 ///
893 /// Input events are retained for inspection but skipped by [`Self::replay`].
894 ///
895 /// # Examples
896 ///
897 /// ```
898 /// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
899 ///
900 /// let mut log = TerminalEventLog::new(1);
901 /// log.record_input(b"yes\n", &TerminalSnapshotConfig::unredacted_for_tests());
902 /// let replay = log.replay(Default::default());
903 /// assert_eq!((replay.replayed_events, replay.skipped_events), (0, 1));
904 /// ```
905 pub fn record_input(&mut self, bytes: &[u8], config: &TerminalSnapshotConfig) -> u64 {
906 self.record(
907 TerminalEventKind::InputBytes {
908 bytes: bytes.to_vec(),
909 },
910 config,
911 )
912 }
913
914 /// Copies the newest `config.max_events` records in stored order.
915 ///
916 /// Existing records are not re-sanitized with `config.redaction` or
917 /// `config.max_event_bytes`. A zero event limit returns an empty vector.
918 ///
919 /// # Examples
920 ///
921 /// ```
922 /// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
923 ///
924 /// let recorded_with = TerminalSnapshotConfig::unredacted_for_tests();
925 /// let mut log = TerminalEventLog::new(4);
926 /// log.record_output(b"one", &recorded_with);
927 /// log.record_output(b"two", &recorded_with);
928 /// let copied = log.snapshot(&TerminalSnapshotConfig { max_events: 1, ..Default::default() });
929 /// assert_eq!(copied[0].sequence, 2);
930 /// ```
931 pub fn snapshot(&self, config: &TerminalSnapshotConfig) -> Vec<TerminalEventRecord> {
932 let skipped = self.records.len().saturating_sub(config.max_events);
933 self.records.iter().skip(skipped).cloned().collect()
934 }
935
936 /// Replays supported stored events into a new terminal state.
937 ///
938 /// Output is parsed, resize uses the state's default resize policy, and
939 /// warnings are appended. Input, diagnostic, and command events are skipped.
940 /// Records are processed in vector order without validating sequence values.
941 /// Because output was sanitized and may be truncated at record time, replay
942 /// can intentionally differ from the original session.
943 ///
944 /// Event counters use ordinary `usize` addition; theoretical overflow would
945 /// panic with checks and wrap otherwise, though a vector that large cannot
946 /// normally be represented.
947 ///
948 /// # Examples
949 ///
950 /// ```
951 /// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
952 ///
953 /// let mut log = TerminalEventLog::new(2);
954 /// log.record_output(b"hello", &TerminalSnapshotConfig::unredacted_for_tests());
955 /// let replay = log.replay(Default::default());
956 /// assert_eq!((replay.replayed_events, replay.skipped_events), (1, 0));
957 /// assert!(replay.state.screen.lines[0].plain_text().contains("hello"));
958 /// ```
959 pub fn replay(&self, config: TerminalConfig) -> TerminalReplayResult {
960 let mut state = TerminalState::with_config(config);
961 let mut parser = VteTerminalParser::new();
962 let mut replayed_events = 0;
963 let mut skipped_events = 0;
964 for record in &self.records {
965 match &record.kind {
966 TerminalEventKind::OutputBytes { bytes } => {
967 TerminalParser::advance(&mut parser, &mut state, bytes);
968 replayed_events += 1;
969 }
970 TerminalEventKind::Resize { size } => {
971 state.resize(*size);
972 replayed_events += 1;
973 }
974 TerminalEventKind::Warning { warning } => {
975 state.push_warning(warning.clone());
976 replayed_events += 1;
977 }
978 TerminalEventKind::InputBytes { .. }
979 | TerminalEventKind::Diagnostic { .. }
980 | TerminalEventKind::CommandStarted { .. }
981 | TerminalEventKind::CommandFinished { .. } => skipped_events += 1,
982 }
983 }
984 TerminalReplayResult {
985 state,
986 replayed_events,
987 skipped_events,
988 }
989 }
990}
991
992impl Default for TerminalEventLog {
993 /// Creates an empty log retaining at most 2,000 records.
994 ///
995 /// # Examples
996 ///
997 /// ```
998 /// use ailloli_ui_terminal_core::TerminalEventLog;
999 /// let log = TerminalEventLog::default();
1000 /// assert_eq!(log.limit, 2_000);
1001 /// ```
1002 fn default() -> Self {
1003 Self::new(DEFAULT_MAX_EVENTS)
1004 }
1005}
1006
1007/// One sanitized event-log entry.
1008///
1009/// For input/output byte events, `payload_preview`, `redacted`, and `truncated`
1010/// describe the stored bytes. Diagnostic and command events redact only the
1011/// diagnostic message or command line and expose that as the preview, but their
1012/// two flags remain `false` even when the text policy changed or truncated the
1013/// string. Warning and resize events are stored without redaction or a preview.
1014/// Consumers must therefore not treat `redacted == false` as proof that no
1015/// sanitization occurred, nor assume every nested string was scrubbed.
1016///
1017/// # Examples
1018///
1019/// ```
1020/// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
1021///
1022/// let mut log = TerminalEventLog::new(1);
1023/// log.record_output(b"password=abc", &TerminalSnapshotConfig::default());
1024/// let record = &log.records[0];
1025/// assert_eq!(record.sequence, 1);
1026/// assert_eq!(record.payload_preview.as_deref(), Some("password=[redacted]"));
1027/// assert!(record.redacted);
1028/// ```
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030pub struct TerminalEventRecord {
1031 /// Recording sequence; normally starts at one and saturates at `u64::MAX`.
1032 pub sequence: u64,
1033 /// Sanitized event payload.
1034 pub kind: TerminalEventKind,
1035 /// Lossy byte preview or redacted message/command, depending on event kind.
1036 pub payload_preview: Option<String>,
1037 /// Whether byte-event sanitization changed retained bytes.
1038 ///
1039 /// This remains `false` for non-byte events even if text was redacted.
1040 pub redacted: bool,
1041 /// Whether a byte event exceeded `max_event_bytes`.
1042 ///
1043 /// This remains `false` for non-byte events even if `max_text_bytes`
1044 /// truncated a diagnostic message or command line.
1045 pub truncated: bool,
1046}
1047
1048impl TerminalEventRecord {
1049 /// Sanitizes an event and builds its stored record.
1050 fn new(sequence: u64, kind: TerminalEventKind, config: &TerminalSnapshotConfig) -> Self {
1051 let (kind, payload_preview, redacted, truncated) = sanitize_event(kind, config);
1052 Self {
1053 sequence,
1054 kind,
1055 payload_preview,
1056 redacted,
1057 truncated,
1058 }
1059 }
1060}
1061
1062/// Event payload accepted by [`TerminalEventLog`].
1063///
1064/// # Examples
1065///
1066/// ```
1067/// use ailloli_ui_terminal_core::{TerminalEventKind, TerminalSize};
1068///
1069/// let event = TerminalEventKind::Resize { size: TerminalSize::new(24, 80) };
1070/// assert!(matches!(event, TerminalEventKind::Resize { .. }));
1071/// ```
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1073pub enum TerminalEventKind {
1074 /// Bytes emitted by the terminal process; replay parses the sanitized copy.
1075 OutputBytes {
1076 /// Owned, bounded bytes, potentially lossy-decoded and redacted.
1077 bytes: Vec<u8>,
1078 },
1079 /// Bytes sent toward the terminal process; replay skips this event.
1080 InputBytes {
1081 /// Owned, bounded bytes, potentially lossy-decoded and redacted.
1082 bytes: Vec<u8>,
1083 },
1084 /// Active-screen resize; replay applies the state's default resize policy.
1085 Resize {
1086 /// Target rows and columns.
1087 size: TerminalSize,
1088 },
1089 /// Terminal warning; stored verbatim and replayed by appending a clone.
1090 Warning {
1091 /// Warning payload, which can contain unsanitized text.
1092 warning: TerminalWarning,
1093 },
1094 /// Classified diagnostic; replay skips this event.
1095 Diagnostic {
1096 /// Diagnostic whose message alone is passed through text redaction.
1097 diagnostic: TerminalDiagnostic,
1098 },
1099 /// Command-start observation; replay skips this event.
1100 CommandStarted {
1101 /// Command whose command line alone is redacted; CWD remains verbatim.
1102 command: CommandExecution,
1103 },
1104 /// Command-finish observation; replay skips this event.
1105 CommandFinished {
1106 /// Command whose command line alone is redacted; CWD remains verbatim.
1107 command: CommandExecution,
1108 },
1109}
1110
1111/// Result of replaying supported event-log records.
1112///
1113/// # Examples
1114///
1115/// ```
1116/// use ailloli_ui_terminal_core::{TerminalEventLog, TerminalSnapshotConfig};
1117///
1118/// let mut log = TerminalEventLog::new(1);
1119/// log.record_input(b"not replayed", &TerminalSnapshotConfig::default());
1120/// let result = log.replay(Default::default());
1121/// assert_eq!(result.replayed_events, 0);
1122/// assert_eq!(result.skipped_events, 1);
1123/// ```
1124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1125pub struct TerminalReplayResult {
1126 /// New terminal state after parsing/applying supported records.
1127 pub state: TerminalState,
1128 /// Number of output, resize, and warning records applied.
1129 pub replayed_events: usize,
1130 /// Number of input, diagnostic, and command records ignored.
1131 pub skipped_events: usize,
1132}
1133
1134/// Borrowed line plus its origin and stable global identity, when available.
1135struct SnapshotLineRef<'a> {
1136 /// Normal-screen global identity, or `None` on the alternate screen.
1137 global_index: Option<u64>,
1138 /// Whether the line is retained normal-screen scrollback.
1139 scrollback: bool,
1140 /// Source line.
1141 line: &'a TerminalLine,
1142}
1143
1144/// Collects active visual lines and their global indices before limiting.
1145fn snapshot_line_refs(state: &TerminalState) -> Vec<SnapshotLineRef<'_>> {
1146 let global_indices = terminal_visual_line_global_indices(state);
1147 let mut out = Vec::new();
1148 match state.active_screen {
1149 ActiveScreen::Normal => {
1150 for (idx, line) in state.scrollback.iter().enumerate() {
1151 out.push(SnapshotLineRef {
1152 global_index: global_indices.get(idx).copied().flatten(),
1153 scrollback: true,
1154 line,
1155 });
1156 }
1157 let base = state.scrollback.len();
1158 for (idx, line) in state.screen.lines.iter().enumerate() {
1159 out.push(SnapshotLineRef {
1160 global_index: global_indices.get(base + idx).copied().flatten(),
1161 scrollback: false,
1162 line,
1163 });
1164 }
1165 }
1166 ActiveScreen::Alternate => {
1167 for (idx, line) in state.alternate_screen.lines.iter().enumerate() {
1168 out.push(SnapshotLineRef {
1169 global_index: global_indices.get(idx).copied().flatten(),
1170 scrollback: false,
1171 line,
1172 });
1173 }
1174 }
1175 }
1176 out
1177}
1178
1179/// Redacts and bounds a single referenced line.
1180fn snapshot_line(
1181 visual_index: usize,
1182 global_index: Option<u64>,
1183 from_scrollback: bool,
1184 line: &TerminalLine,
1185 config: &TerminalSnapshotConfig,
1186) -> TerminalSnapshotLine {
1187 let cells = line
1188 .cells
1189 .iter()
1190 .enumerate()
1191 .filter(|(_, cell)| cell.width != CellWidth::WideTrailing)
1192 .take(config.max_cells_per_line)
1193 .map(|(col, cell)| TerminalSnapshotCell::from_cell(col, cell, config))
1194 .collect::<Vec<_>>();
1195 let text = config.redaction.redact_text(&line.plain_text());
1196 TerminalSnapshotLine {
1197 visual_index,
1198 global_index,
1199 from_scrollback,
1200 text,
1201 cells,
1202 truncated: line.cells.len() > config.max_cells_per_line,
1203 }
1204}
1205
1206/// Returns newest bounded history plus the current command, in lifecycle order.
1207fn command_summaries(
1208 state: &TerminalState,
1209 diagnostics: &[TerminalDiagnostic],
1210 config: &TerminalSnapshotConfig,
1211) -> Vec<CommandSummary> {
1212 let mut commands = state.shell.history.clone();
1213 if let Some(command) = &state.shell.current_command {
1214 commands.push(command.clone());
1215 }
1216 let skipped = commands.len().saturating_sub(config.max_commands);
1217 commands
1218 .iter()
1219 .skip(skipped)
1220 .map(|command| CommandSummary::from_command(command, diagnostics, &config.redaction))
1221 .collect()
1222}
1223
1224/// Redacts CWD and command/CWD text throughout an owned shell snapshot.
1225fn redacted_shell_snapshot(
1226 mut shell: TerminalShellSnapshot,
1227 redaction: &TerminalRedactionPolicy,
1228) -> TerminalShellSnapshot {
1229 shell.cwd_uri = shell.cwd_uri.map(|cwd| redaction.redact_text(&cwd));
1230 shell.current_command = shell
1231 .current_command
1232 .map(|command| redacted_command(command, redaction));
1233 shell.last_command = shell
1234 .last_command
1235 .map(|command| redacted_command(command, redaction));
1236 shell.command_history = shell
1237 .command_history
1238 .into_iter()
1239 .map(|command| redacted_command(command, redaction))
1240 .collect();
1241 shell
1242}
1243
1244/// Redacts the command line and optional CWD of one owned command.
1245fn redacted_command(
1246 mut command: CommandExecution,
1247 redaction: &TerminalRedactionPolicy,
1248) -> CommandExecution {
1249 command.command_line = redaction.redact_text(&command.command_line);
1250 command.cwd_uri = command.cwd_uri.map(|cwd| redaction.redact_text(&cwd));
1251 command
1252}
1253
1254/// Computes the normal-screen global cursor line with saturating addition.
1255fn cursor_global_line(state: &TerminalState) -> Option<u64> {
1256 match state.active_screen {
1257 ActiveScreen::Normal => Some(
1258 state
1259 .scrollback
1260 .total_pushed()
1261 .saturating_add(state.cursor.row as u64),
1262 ),
1263 ActiveScreen::Alternate => None,
1264 }
1265}
1266
1267/// Sanitizes one event and derives its preview and byte-event flags.
1268fn sanitize_event(
1269 kind: TerminalEventKind,
1270 config: &TerminalSnapshotConfig,
1271) -> (TerminalEventKind, Option<String>, bool, bool) {
1272 match kind {
1273 TerminalEventKind::OutputBytes { bytes } => {
1274 let (bytes, redacted, truncated) = config
1275 .redaction
1276 .redact_bytes(&bytes, config.max_event_bytes);
1277 let preview = Some(String::from_utf8_lossy(&bytes).into_owned());
1278 (
1279 TerminalEventKind::OutputBytes { bytes },
1280 preview,
1281 redacted,
1282 truncated,
1283 )
1284 }
1285 TerminalEventKind::InputBytes { bytes } => {
1286 let (bytes, redacted, truncated) = config
1287 .redaction
1288 .redact_bytes(&bytes, config.max_event_bytes);
1289 let preview = Some(String::from_utf8_lossy(&bytes).into_owned());
1290 (
1291 TerminalEventKind::InputBytes { bytes },
1292 preview,
1293 redacted,
1294 truncated,
1295 )
1296 }
1297 TerminalEventKind::Diagnostic { mut diagnostic } => {
1298 diagnostic.message = config.redaction.redact_text(&diagnostic.message);
1299 let preview = Some(diagnostic.message.clone());
1300 (
1301 TerminalEventKind::Diagnostic { diagnostic },
1302 preview,
1303 false,
1304 false,
1305 )
1306 }
1307 TerminalEventKind::CommandStarted { mut command } => {
1308 command.command_line = config.redaction.redact_text(&command.command_line);
1309 let preview = Some(command.command_line.clone());
1310 (
1311 TerminalEventKind::CommandStarted { command },
1312 preview,
1313 false,
1314 false,
1315 )
1316 }
1317 TerminalEventKind::CommandFinished { mut command } => {
1318 command.command_line = config.redaction.redact_text(&command.command_line);
1319 let preview = Some(command.command_line.clone());
1320 (
1321 TerminalEventKind::CommandFinished { command },
1322 preview,
1323 false,
1324 false,
1325 )
1326 }
1327 other @ TerminalEventKind::Resize { .. } | other @ TerminalEventKind::Warning { .. } => {
1328 (other, None, false, false)
1329 }
1330 }
1331}
1332
1333/// Truncates at a UTF-8 boundary and appends a suffix outside the byte limit.
1334fn limit_string_bytes(text: &str, limit: usize) -> String {
1335 if text.len() <= limit {
1336 return text.to_string();
1337 }
1338 let mut end = limit.min(text.len());
1339 while !text.is_char_boundary(end) {
1340 end = end.saturating_sub(1);
1341 }
1342 let mut out = text[..end].to_string();
1343 out.push_str("...[truncated]");
1344 out
1345}
1346
1347/// Replaces delimiter-separated values following an ASCII-case-insensitive key.
1348fn redact_key_value(input: &str, key: &str, replacement: &str) -> String {
1349 let lower = input.to_ascii_lowercase();
1350 let mut out = String::new();
1351 let mut cursor = 0;
1352 while let Some(pos) = lower[cursor..].find(key) {
1353 let start = cursor + pos;
1354 out.push_str(&input[cursor..start]);
1355 let key_end = start + key.len();
1356 out.push_str(&input[start..key_end]);
1357 let mut value_start = key_end;
1358 while let Some(ch) = input[value_start..].chars().next() {
1359 if matches!(ch, '=' | ':' | ' ' | '\t') {
1360 out.push(ch);
1361 value_start += ch.len_utf8();
1362 } else {
1363 break;
1364 }
1365 }
1366 let mut value_end = value_start;
1367 while let Some(ch) = input[value_end..].chars().next() {
1368 if ch.is_whitespace() || matches!(ch, ';' | '&' | ',' | ')' | ']') {
1369 break;
1370 }
1371 value_end += ch.len_utf8();
1372 }
1373 if value_end > value_start {
1374 out.push_str(replacement);
1375 }
1376 cursor = value_end;
1377 }
1378 out.push_str(&input[cursor..]);
1379 out
1380}
1381
1382#[cfg(test)]
1383/// Unit tests for snapshot bounds, sanitization, serialization, and replay.
1384mod tests {
1385 use super::*;
1386 use crate::TerminalSecurityPolicy;
1387
1388 /// Builds a bounded state containing secret-like terminal and command text.
1389 fn small_state() -> TerminalState {
1390 let mut state = TerminalState::with_config(TerminalConfig {
1391 size: TerminalSize::new(3, 20),
1392 scrollback_limit: 4,
1393 security: TerminalSecurityPolicy::default(),
1394 });
1395 state.write_str("token=super-secret\nline two\nline three\nline four");
1396 state.start_shell_command("cargo test token=super-secret", None, Some(10));
1397 state.finish_shell_command(Some(0), None, Some(20), None);
1398 state
1399 }
1400
1401 #[test]
1402 fn snapshot_is_bounded_and_redacts_secret_like_values() {
1403 let state = small_state();
1404 let snapshot = TerminalSnapshot::from_state(
1405 &state,
1406 TerminalSnapshotConfig {
1407 max_lines: 2,
1408 max_cells_per_line: 8,
1409 ..TerminalSnapshotConfig::default()
1410 },
1411 );
1412
1413 assert!(snapshot.truncated);
1414 assert_eq!(snapshot.lines.len(), 2);
1415 assert!(snapshot.lines.iter().all(|line| line.cells.len() <= 8));
1416 assert!(!format!("{snapshot:?}").contains("super-secret"));
1417 assert!(format!("{snapshot:?}").contains("[redacted]"));
1418 }
1419
1420 #[test]
1421 fn snapshot_serializes_for_agent_consumers() {
1422 let state = small_state();
1423 let snapshot = TerminalSnapshot::from_state(&state, TerminalSnapshotConfig::default());
1424 let json = serde_json::to_string(&snapshot).expect("json");
1425 assert!(json.contains("latest_output_lines"));
1426 assert!(json.contains("commands"));
1427 }
1428
1429 #[test]
1430 fn snapshot_command_summary_counts_command_diagnostics() {
1431 let mut state = small_state();
1432 state.classify_terminal_output();
1433 let snapshot = TerminalSnapshot::from_state(
1434 &state,
1435 TerminalSnapshotConfig {
1436 redaction: TerminalRedactionPolicy::disabled(),
1437 ..TerminalSnapshotConfig::default()
1438 },
1439 );
1440
1441 assert_eq!(snapshot.commands.len(), 1);
1442 assert_eq!(snapshot.commands[0].status, CommandStatus::Succeeded);
1443 assert_eq!(
1444 snapshot.commands[0].command_line,
1445 "cargo test token=super-secret"
1446 );
1447 }
1448
1449 #[test]
1450 fn snapshot_event_log_replays_output_deterministically() {
1451 let mut log = TerminalEventLog::new(10);
1452 let config = TerminalSnapshotConfig::unredacted_for_tests();
1453 log.record_output(b"hello\r\nworld", &config);
1454 log.record_input(b"ignored input", &config);
1455 log.record(
1456 TerminalEventKind::Resize {
1457 size: TerminalSize::new(4, 20),
1458 },
1459 &config,
1460 );
1461
1462 let replay = log.replay(TerminalConfig {
1463 size: TerminalSize::new(3, 20),
1464 scrollback_limit: 10,
1465 security: TerminalSecurityPolicy::default(),
1466 });
1467
1468 assert_eq!(replay.replayed_events, 2);
1469 assert_eq!(replay.skipped_events, 1);
1470 assert!(replay
1471 .state
1472 .screen
1473 .line(0)
1474 .expect("line")
1475 .plain_text()
1476 .contains("hello"));
1477 assert_eq!(replay.state.screen.rows, 4);
1478 }
1479
1480 #[test]
1481 fn snapshot_event_log_trims_and_redacts_payloads() {
1482 let mut log = TerminalEventLog::new(2);
1483 let config = TerminalSnapshotConfig {
1484 max_event_bytes: 12,
1485 ..TerminalSnapshotConfig::default()
1486 };
1487
1488 log.record_output(b"one", &config);
1489 log.record_output(b"two", &config);
1490 log.record_output(b"password=super-secret-value", &config);
1491
1492 assert_eq!(log.records.len(), 2);
1493 assert_eq!(log.records[0].sequence, 2);
1494 assert!(log.records[1].redacted);
1495 assert!(log.records[1].truncated);
1496 assert!(!format!("{:?}", log.records).contains("super-secret-value"));
1497 }
1498}