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}