Skip to main content

aft/
alert_render.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4/// The rendered reminder is intentionally short enough to preserve the response it annotates.
5/// Gate 6 will replace this product constant with its ruled value.
6pub const MAX_ALERT_LINE_CHARS: usize = 240;
7pub const ALERT_ELLIPSIS: &str = "…";
8pub const MAX_RENDERED_ALERT_LINES: usize = 3;
9
10/// Commands in this closed list are transport or maintenance traffic, not agent-visible tool
11/// responses. Keep additions explicit so a new command cannot silently consume a pending alert.
12pub const EXCLUDED_FINALIZATION_COMMANDS: &[&str] = &[
13    "configure",
14    "ping",
15    "version",
16    "status",
17    "bash_abort_inflight",
18    "bash_status",
19    "bash_write",
20    "bash_promote",
21    "bash_wait_detach",
22    "bash_regex_match",
23    "bash_drain_completions",
24    "bash_notify",
25    "bash_unnotify",
26    "bash_ack_completions",
27];
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AlertSeverity {
31    Error,
32    Warning,
33    Information,
34    Hint,
35}
36
37impl AlertSeverity {
38    fn canonical_name(self) -> &'static str {
39        match self {
40            Self::Error => "error",
41            Self::Warning => "warning",
42            Self::Information => "information",
43            Self::Hint => "hint",
44        }
45    }
46}
47
48/// A diagnostic accepted from one document-version-verified producer snapshot.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AlertDiagnostic {
51    /// Canonical, dispatch-root-relative path supplied by the observation producer.
52    pub file: String,
53    pub line: u32,
54    pub column: u32,
55    pub end_line: u32,
56    pub end_column: u32,
57    pub severity: AlertSeverity,
58    pub source: Option<String>,
59    pub code: Option<String>,
60    pub message: String,
61}
62
63impl AlertDiagnostic {
64    #[must_use]
65    pub fn error(file: impl Into<String>, line: u32, message: impl Into<String>) -> Self {
66        Self {
67            file: file.into(),
68            line,
69            column: 0,
70            end_line: line,
71            end_column: 0,
72            severity: AlertSeverity::Error,
73            source: None,
74            code: None,
75            message: message.into(),
76        }
77    }
78
79    fn identity(&self) -> AlertIdentity {
80        AlertIdentity {
81            file: self.file.clone(),
82            line: self.line,
83            column: self.column,
84            end_line: self.end_line,
85            end_column: self.end_column,
86            severity: self.severity.canonical_name().to_string(),
87            source: self.source.clone().unwrap_or_default(),
88            code: self.code.clone().unwrap_or_default(),
89            message: normalize_alert_message(&self.message),
90        }
91    }
92}
93
94/// One complete, accepted snapshot from a single diagnostics producer.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct AlertObservation {
97    pub producer_key: String,
98    pub diagnostics: Vec<AlertDiagnostic>,
99}
100
101impl AlertObservation {
102    #[must_use]
103    pub fn new(producer_key: impl Into<String>, diagnostics: Vec<AlertDiagnostic>) -> Self {
104        Self {
105            producer_key: producer_key.into(),
106            diagnostics,
107        }
108    }
109}
110
111/// Canonical identity used for de-duplication, ordering ties, and rendered tracking.
112#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
113pub struct AlertIdentity {
114    pub file: String,
115    pub line: u32,
116    pub column: u32,
117    pub end_line: u32,
118    pub end_column: u32,
119    pub severity: String,
120    pub source: String,
121    pub code: String,
122    pub message: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126struct AlertCandidate {
127    identity: AlertIdentity,
128    entered_observation_ordinal: u64,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
132struct PartitionKey {
133    dispatch_root: PathBuf,
134    producer_key: String,
135}
136
137#[derive(Debug, Default)]
138struct ProducerPartition {
139    baseline_established: bool,
140    live: BTreeMap<AlertIdentity, AlertCandidate>,
141    rendered: BTreeSet<AlertIdentity>,
142}
143
144#[derive(Debug, Default)]
145struct SessionAlertState {
146    next_observation_ordinal: u64,
147    agent_visible_response_ordinal: u64,
148    partitions: BTreeMap<PartitionKey, ProducerPartition>,
149}
150
151/// Session-owned alert delta state. A host may retain one engine for its session registry.
152///
153/// The engine deliberately accepts a dispatch root at both observation and finalization. It
154/// never consults an `AppContext` or a session project root, because a response may be scoped to
155/// a root other than the context that happens to dispatch it.
156#[derive(Debug, Default)]
157pub struct AlertEngine {
158    sessions: BTreeMap<String, SessionAlertState>,
159}
160
161/// The server-rendered result of one finalized block.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct RenderedAlert {
164    pub text: String,
165    pub shown: Vec<AlertIdentity>,
166    pub counted_only: Vec<AlertIdentity>,
167    pub agent_visible_response_ordinal: u64,
168}
169
170impl RenderedAlert {
171    #[must_use]
172    pub fn represented_identities(&self) -> impl Iterator<Item = &AlertIdentity> {
173        self.shown.iter().chain(&self.counted_only)
174    }
175}
176
177impl AlertEngine {
178    /// Apply one atomic authoritative-observation batch. Empty snapshots are meaningful: they
179    /// prune only their producer partition and never affect another producer or root.
180    pub fn observe_authoritative_batch(
181        &mut self,
182        session_id: &str,
183        dispatch_root: &Path,
184        observations: impl IntoIterator<Item = AlertObservation>,
185    ) {
186        let root = canonical_dispatch_root(dispatch_root);
187        let state = self.sessions.entry(session_id.to_string()).or_default();
188        state.next_observation_ordinal = state.next_observation_ordinal.saturating_add(1);
189        let observation_ordinal = state.next_observation_ordinal;
190
191        for observation in observations {
192            let key = PartitionKey {
193                dispatch_root: root.clone(),
194                producer_key: observation.producer_key,
195            };
196            let current = observation
197                .diagnostics
198                .into_iter()
199                .filter(|diagnostic| diagnostic.severity == AlertSeverity::Error)
200                .map(|diagnostic| diagnostic.identity())
201                .collect::<BTreeSet<_>>();
202            let partition = state.partitions.entry(key).or_default();
203
204            if !partition.baseline_established {
205                // Gate 10 has not supplied the session-owned mutation store required to
206                // attribute a first observation, so each partition establishes a silent baseline.
207                partition.baseline_established = true;
208                partition.live = current
209                    .into_iter()
210                    .map(|identity| {
211                        let candidate = AlertCandidate {
212                            identity: identity.clone(),
213                            entered_observation_ordinal: observation_ordinal,
214                        };
215                        (identity, candidate)
216                    })
217                    .collect();
218                partition.rendered = partition.live.keys().cloned().collect();
219                continue;
220            }
221
222            // A disappearance closes its alert episode. Removing the rendered marker makes a
223            // later re-entry a new candidate rather than a permanently suppressed identity.
224            partition
225                .rendered
226                .retain(|identity| current.contains(identity));
227            let previous_live = std::mem::take(&mut partition.live);
228            partition.live = current
229                .into_iter()
230                .map(|identity| {
231                    let candidate =
232                        previous_live
233                            .get(&identity)
234                            .cloned()
235                            .unwrap_or_else(|| AlertCandidate {
236                                identity: identity.clone(),
237                                entered_observation_ordinal: observation_ordinal,
238                            });
239                    (identity, candidate)
240                })
241                .collect();
242        }
243    }
244
245    /// Apply one complete snapshot for one producer.
246    pub fn observe_authoritative(
247        &mut self,
248        session_id: &str,
249        dispatch_root: &Path,
250        producer_key: impl Into<String>,
251        diagnostics: Vec<AlertDiagnostic>,
252    ) {
253        self.observe_authoritative_batch(
254            session_id,
255            dispatch_root,
256            [AlertObservation::new(producer_key, diagnostics)],
257        );
258    }
259
260    /// Finalize one agent-visible response. Pending candidates are recomputed from the explicit
261    /// dispatch root's `live − rendered` partitions; there is no queued cross-root work list.
262    pub fn finalize(
263        &mut self,
264        session_id: &str,
265        dispatch_root: &Path,
266        command: &str,
267    ) -> Option<RenderedAlert> {
268        if is_excluded_finalization_command(command) {
269            return None;
270        }
271
272        let root = canonical_dispatch_root(dispatch_root);
273        let state = self.sessions.entry(session_id.to_string()).or_default();
274        state.agent_visible_response_ordinal =
275            state.agent_visible_response_ordinal.saturating_add(1);
276        let response_ordinal = state.agent_visible_response_ordinal;
277
278        let mut deliverable = Vec::new();
279        for (partition_key, partition) in &state.partitions {
280            if partition_key.dispatch_root != root {
281                continue;
282            }
283            for (identity, candidate) in &partition.live {
284                if !partition.rendered.contains(identity) {
285                    deliverable.push((partition_key.clone(), candidate.clone()));
286                }
287            }
288        }
289        if deliverable.is_empty() {
290            return None;
291        }
292
293        // Newer observations win. The identity's canonical tuple makes same-observation ordering
294        // deterministic across producers and transports.
295        deliverable.sort_by(|(_, left), (_, right)| {
296            right
297                .entered_observation_ordinal
298                .cmp(&left.entered_observation_ordinal)
299                .then_with(|| left.identity.cmp(&right.identity))
300        });
301
302        let shown_count = if deliverable.len() > MAX_RENDERED_ALERT_LINES - 1 {
303            MAX_RENDERED_ALERT_LINES - 1
304        } else {
305            deliverable.len()
306        };
307        let shown = deliverable
308            .iter()
309            .take(shown_count)
310            .map(|(_, candidate)| candidate.identity.clone())
311            .collect::<Vec<_>>();
312        let counted_only = deliverable
313            .iter()
314            .skip(shown_count)
315            .map(|(_, candidate)| candidate.identity.clone())
316            .collect::<Vec<_>>();
317
318        let mut lines = shown.iter().map(render_alert_line).collect::<Vec<_>>();
319        if !counted_only.is_empty() {
320            lines.push(format!("(+{} more)", counted_only.len()));
321        }
322        let text = format!(
323            "<system-reminder>\n{}\n</system-reminder>",
324            lines.join("\n")
325        );
326
327        // Every represented identity is consumed together, including identities represented only
328        // by the count suffix. This is what makes unchanged responses silent after coalescing.
329        for (partition_key, candidate) in deliverable {
330            if let Some(partition) = state.partitions.get_mut(&partition_key) {
331                partition.rendered.insert(candidate.identity);
332            }
333        }
334
335        Some(RenderedAlert {
336            text,
337            shown,
338            counted_only,
339            agent_visible_response_ordinal: response_ordinal,
340        })
341    }
342
343    #[must_use]
344    pub fn agent_visible_response_ordinal(&self, session_id: &str) -> u64 {
345        self.sessions
346            .get(session_id)
347            .map_or(0, |state| state.agent_visible_response_ordinal)
348    }
349
350    #[must_use]
351    pub fn partition_is_baselined(
352        &self,
353        session_id: &str,
354        dispatch_root: &Path,
355        producer_key: &str,
356    ) -> bool {
357        let key = PartitionKey {
358            dispatch_root: canonical_dispatch_root(dispatch_root),
359            producer_key: producer_key.to_string(),
360        };
361        self.sessions
362            .get(session_id)
363            .and_then(|state| state.partitions.get(&key))
364            .is_some_and(|partition| partition.baseline_established)
365    }
366
367    #[must_use]
368    pub fn partition_rendered_identities(
369        &self,
370        session_id: &str,
371        dispatch_root: &Path,
372        producer_key: &str,
373    ) -> BTreeSet<AlertIdentity> {
374        let key = PartitionKey {
375            dispatch_root: canonical_dispatch_root(dispatch_root),
376            producer_key: producer_key.to_string(),
377        };
378        self.sessions
379            .get(session_id)
380            .and_then(|state| state.partitions.get(&key))
381            .map_or_else(BTreeSet::new, |partition| partition.rendered.clone())
382    }
383}
384
385#[must_use]
386pub fn is_excluded_finalization_command(command: &str) -> bool {
387    EXCLUDED_FINALIZATION_COMMANDS.contains(&command)
388}
389
390/// The sole alert-message normalizer: first line, trim, whitespace-run collapse, and NFC-style
391/// composition. It deliberately does not rewrite paths, numbers, or quotation marks.
392#[must_use]
393pub fn normalize_alert_message(message: &str) -> String {
394    let first_line = message.lines().next().unwrap_or_default().trim();
395    let collapsed = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
396    if collapsed.is_ascii() {
397        collapsed
398    } else {
399        compose_common_nfc(&collapsed)
400    }
401}
402
403fn canonical_dispatch_root(root: &Path) -> PathBuf {
404    std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
405}
406
407fn render_alert_line(identity: &AlertIdentity) -> String {
408    let line = format!(
409        "New error in {}:{}: {}",
410        identity.file, identity.line, identity.message
411    );
412    truncate_alert_line(&line)
413}
414
415fn truncate_alert_line(line: &str) -> String {
416    if line.chars().count() <= MAX_ALERT_LINE_CHARS {
417        return line.to_string();
418    }
419
420    let prefix_len = MAX_ALERT_LINE_CHARS.saturating_sub(ALERT_ELLIPSIS.chars().count());
421    let mut truncated = line.chars().take(prefix_len).collect::<String>();
422    truncated.push_str(ALERT_ELLIPSIS);
423    truncated
424}
425
426/// Compose the canonical decompositions commonly emitted by diagnostics. Rust's standard library
427/// exposes no full Unicode normalization facility, so unfamiliar decompositions are preserved
428/// rather than applying an unsafe text rewrite outside the named normalization contract.
429fn compose_common_nfc(input: &str) -> String {
430    let mut normalized = String::with_capacity(input.len());
431    let mut chars = input.chars().peekable();
432    while let Some(base) = chars.next() {
433        let Some(&mark) = chars.peek() else {
434            normalized.push(base);
435            continue;
436        };
437        if let Some(composed) = compose_pair(base, mark) {
438            normalized.push(composed);
439            chars.next();
440        } else {
441            normalized.push(base);
442        }
443    }
444    normalized
445}
446
447fn compose_pair(base: char, mark: char) -> Option<char> {
448    let composed = match (base, mark) {
449        ('A', '\u{0300}') => 'À',
450        ('A', '\u{0301}') => 'Á',
451        ('A', '\u{0302}') => 'Â',
452        ('A', '\u{0303}') => 'Ã',
453        ('A', '\u{0308}') => 'Ä',
454        ('A', '\u{030A}') => 'Å',
455        ('C', '\u{0327}') => 'Ç',
456        ('E', '\u{0300}') => 'È',
457        ('E', '\u{0301}') => 'É',
458        ('E', '\u{0302}') => 'Ê',
459        ('E', '\u{0308}') => 'Ë',
460        ('I', '\u{0300}') => 'Ì',
461        ('I', '\u{0301}') => 'Í',
462        ('I', '\u{0302}') => 'Î',
463        ('I', '\u{0308}') => 'Ï',
464        ('N', '\u{0303}') => 'Ñ',
465        ('O', '\u{0300}') => 'Ò',
466        ('O', '\u{0301}') => 'Ó',
467        ('O', '\u{0302}') => 'Ô',
468        ('O', '\u{0303}') => 'Õ',
469        ('O', '\u{0308}') => 'Ö',
470        ('U', '\u{0300}') => 'Ù',
471        ('U', '\u{0301}') => 'Ú',
472        ('U', '\u{0302}') => 'Û',
473        ('U', '\u{0308}') => 'Ü',
474        ('Y', '\u{0301}') => 'Ý',
475        ('a', '\u{0300}') => 'à',
476        ('a', '\u{0301}') => 'á',
477        ('a', '\u{0302}') => 'â',
478        ('a', '\u{0303}') => 'ã',
479        ('a', '\u{0308}') => 'ä',
480        ('a', '\u{030A}') => 'å',
481        ('c', '\u{0327}') => 'ç',
482        ('e', '\u{0300}') => 'è',
483        ('e', '\u{0301}') => 'é',
484        ('e', '\u{0302}') => 'ê',
485        ('e', '\u{0308}') => 'ë',
486        ('i', '\u{0300}') => 'ì',
487        ('i', '\u{0301}') => 'í',
488        ('i', '\u{0302}') => 'î',
489        ('i', '\u{0308}') => 'ï',
490        ('n', '\u{0303}') => 'ñ',
491        ('o', '\u{0300}') => 'ò',
492        ('o', '\u{0301}') => 'ó',
493        ('o', '\u{0302}') => 'ô',
494        ('o', '\u{0303}') => 'õ',
495        ('o', '\u{0308}') => 'ö',
496        ('u', '\u{0300}') => 'ù',
497        ('u', '\u{0301}') => 'ú',
498        ('u', '\u{0302}') => 'û',
499        ('u', '\u{0308}') => 'ü',
500        ('y', '\u{0301}') => 'ý',
501        ('y', '\u{0308}') => 'ÿ',
502        _ => return None,
503    };
504    Some(composed)
505}
506
507#[cfg(test)]
508mod tests {
509    use super::{
510        compose_common_nfc, normalize_alert_message, AlertDiagnostic, AlertEngine, AlertSeverity,
511        EXCLUDED_FINALIZATION_COMMANDS, MAX_ALERT_LINE_CHARS,
512    };
513    use std::path::Path;
514
515    fn error(file: &str, line: u32, message: &str) -> AlertDiagnostic {
516        AlertDiagnostic::error(file, line, message)
517    }
518
519    #[test]
520    fn normalizer_uses_only_the_contractual_transformations() {
521        assert_eq!(
522            normalize_alert_message("  Cafe\u{301}   failed\nsecond line"),
523            "Café failed"
524        );
525        assert_eq!(
526            normalize_alert_message("src/a.rs:42 \"quoted\""),
527            "src/a.rs:42 \"quoted\""
528        );
529    }
530
531    fn normalize_alert_message_reference(message: &str) -> String {
532        let first_line = message.lines().next().unwrap_or_default().trim();
533        let collapsed = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
534        compose_common_nfc(&collapsed)
535    }
536
537    #[test]
538    fn ascii_fast_path_preserves_normalized_bytes() {
539        let mut messages = vec![
540            String::new(),
541            "  mismatched   types\tnear `request`  \nignored".to_string(),
542            "Cafe\u{301}   failed".to_string(),
543            "déjà vu".to_string(),
544            "a\u{0327}\u{0301} unfamiliar decomposition".to_string(),
545        ];
546        for character in '\0'..='\u{7f}' {
547            messages.push(format!(
548                "  prefix{character}{character}suffix\t detail  \nignored"
549            ));
550        }
551
552        for message in messages {
553            assert_eq!(
554                normalize_alert_message(&message).as_bytes(),
555                normalize_alert_message_reference(&message).as_bytes(),
556                "normalization changed for {message:?}",
557            );
558        }
559    }
560
561    #[test]
562    fn first_observation_is_the_named_default_silent_baseline() {
563        let root = Path::new("/dispatch-root");
564        let mut engine = AlertEngine::default();
565        engine.observe_authoritative("session", root, "server-a", vec![error("a.rs", 3, "old")]);
566
567        assert!(engine.partition_is_baselined("session", root, "server-a"));
568        assert!(engine.finalize("session", root, "read").is_none());
569        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
570
571        engine.observe_authoritative(
572            "session",
573            root,
574            "server-a",
575            vec![error("a.rs", 3, "old"), error("a.rs", 7, "new")],
576        );
577        let alert = engine.finalize("session", root, "read").expect("new alert");
578        assert!(alert.text.contains("a.rs:7: new"));
579        assert!(!alert.text.contains("your edit"));
580        assert_eq!(alert.agent_visible_response_ordinal, 2);
581    }
582
583    #[test]
584    fn cold_root_silence_still_advances_the_session_response_ordinal() {
585        let mut engine = AlertEngine::default();
586        assert!(engine
587            .finalize("session", Path::new("/cold-root"), "read")
588            .is_none());
589        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
590    }
591
592    #[test]
593    fn dispatch_root_isolation_never_borrows_or_consumes_another_root() {
594        let dispatch_root = Path::new("/dispatch-root");
595        let other_root = Path::new("/other-root");
596        let mut engine = AlertEngine::default();
597        for root in [dispatch_root, other_root] {
598            engine.observe_authoritative("session", root, "server", Vec::new());
599            engine.observe_authoritative(
600                "session",
601                root,
602                "server",
603                vec![error("src/lib.rs", 4, root.to_string_lossy().as_ref())],
604            );
605        }
606
607        let alert = engine
608            .finalize("session", dispatch_root, "inspect")
609            .expect("dispatch-root alert");
610        assert!(alert.text.contains("/dispatch-root"));
611        assert!(!alert.text.contains("/other-root"));
612        assert!(engine
613            .finalize("session", other_root, "read")
614            .expect("other root remains pending")
615            .text
616            .contains("/other-root"));
617    }
618
619    #[test]
620    fn coalescing_orders_ties_and_marks_counted_identities_rendered() {
621        let root = Path::new("/root");
622        let mut engine = AlertEngine::default();
623        engine.observe_authoritative("session", root, "server", Vec::new());
624        engine.observe_authoritative(
625            "session",
626            root,
627            "server",
628            vec![
629                error("z.rs", 3, "z"),
630                error("a.rs", 2, "a"),
631                error("m.rs", 1, "m"),
632            ],
633        );
634
635        let alert = engine
636            .finalize("session", root, "read")
637            .expect("coalesced alert");
638        assert_eq!(alert.text.lines().count(), 5);
639        assert!(alert.text.find("a.rs:2").unwrap() < alert.text.find("m.rs:1").unwrap());
640        assert!(alert.text.contains("(+1 more)"));
641        assert_eq!(alert.represented_identities().count(), 3);
642        assert_eq!(
643            engine
644                .partition_rendered_identities("session", root, "server")
645                .len(),
646            3
647        );
648        assert!(engine.finalize("session", root, "read").is_none());
649    }
650
651    #[test]
652    fn excluded_commands_preserve_pending_alerts_and_do_not_advance_visible_ordinal() {
653        let root = Path::new("/root");
654        let mut engine = AlertEngine::default();
655        engine.observe_authoritative("session", root, "server", Vec::new());
656        engine.observe_authoritative("session", root, "server", vec![error("a.rs", 1, "boom")]);
657
658        for command in EXCLUDED_FINALIZATION_COMMANDS {
659            assert!(engine.finalize("session", root, command).is_none());
660        }
661        assert_eq!(engine.agent_visible_response_ordinal("session"), 0);
662        assert!(engine.finalize("session", root, "inspect").is_some());
663        assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
664    }
665
666    #[test]
667    fn warning_information_and_hints_never_enter_the_alert_channel() {
668        let root = Path::new("/root");
669        let mut engine = AlertEngine::default();
670        for severity in [
671            AlertSeverity::Warning,
672            AlertSeverity::Information,
673            AlertSeverity::Hint,
674        ] {
675            let mut diagnostic = error("a.rs", 1, "not an error");
676            diagnostic.severity = severity;
677            engine.observe_authoritative(
678                "session",
679                root,
680                severity.canonical_name(),
681                vec![diagnostic],
682            );
683        }
684        assert!(engine.finalize("session", root, "read").is_none());
685    }
686
687    #[test]
688    fn truncation_never_wraps_the_rendered_line() {
689        let root = Path::new("/root");
690        let mut engine = AlertEngine::default();
691        engine.observe_authoritative("session", root, "server", Vec::new());
692        engine.observe_authoritative(
693            "session",
694            root,
695            "server",
696            vec![error("a.rs", 1, &"x".repeat(MAX_ALERT_LINE_CHARS * 2))],
697        );
698        let alert = engine
699            .finalize("session", root, "read")
700            .expect("long alert");
701        let line = alert.text.lines().nth(1).expect("alert line");
702        assert!(line.chars().count() <= MAX_ALERT_LINE_CHARS);
703        assert!(!line.contains('\n'));
704    }
705
706    #[test]
707    #[ignore = "manual performance probe"]
708    fn ascii_alert_normalization_perf_probe() {
709        const PASSES: usize = 100_000;
710        const SAMPLE_COUNT: usize = 11;
711        const MESSAGES: [&str; 8] = [
712            "error[E0308]:   mismatched types in crates/aft/src/commands/inspect.rs: expected `Result`, found `Option`",
713            "Cannot find name 'resolvedProjectRoot'.   Did you mean 'resolveProjectRoot'?",
714            "borrow of moved value: `request`   value borrowed here after move",
715            "the trait bound `PathBuf: Copy` is not satisfied   required by this call",
716            "Module not found: Can't resolve   '../../transport/runtime' in '/workspace/src'",
717            "Property 'agent_visible_response_ordinal' does not exist on type 'AlertState'",
718            "unused import: `std::collections::HashMap`   `#[warn(unused_imports)]` on by default",
719            "lifetime may not live long enough   returning this value requires that `'1` must outlive `'static'",
720        ];
721
722        for message in MESSAGES {
723            std::hint::black_box(normalize_alert_message(message));
724        }
725
726        let mut samples_ms = Vec::with_capacity(SAMPLE_COUNT);
727        let mut checksum = 0usize;
728        for _ in 0..SAMPLE_COUNT {
729            let started = std::time::Instant::now();
730            let mut sample_checksum = 0usize;
731            for _ in 0..PASSES {
732                for message in MESSAGES {
733                    sample_checksum = sample_checksum.wrapping_add(
734                        std::hint::black_box(normalize_alert_message(std::hint::black_box(
735                            message,
736                        )))
737                        .len(),
738                    );
739                }
740            }
741            samples_ms.push(started.elapsed().as_secs_f64() * 1_000.0);
742            checksum = checksum.wrapping_add(std::hint::black_box(sample_checksum));
743        }
744        samples_ms.sort_by(f64::total_cmp);
745
746        println!(
747            "ascii-alert-normalization: calls_per_sample={} samples_ms={samples_ms:?} min_ms={:.3} median_ms={:.3} max_ms={:.3} checksum={checksum}",
748            PASSES * MESSAGES.len(),
749            samples_ms[0],
750            samples_ms[SAMPLE_COUNT / 2],
751            samples_ms[SAMPLE_COUNT - 1],
752        );
753    }
754}