1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4pub const MAX_ALERT_LINE_CHARS: usize = 240;
7pub const ALERT_ELLIPSIS: &str = "…";
8pub const MAX_RENDERED_ALERT_LINES: usize = 3;
9
10pub 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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AlertDiagnostic {
51 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#[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#[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#[derive(Debug, Default)]
157pub struct AlertEngine {
158 sessions: BTreeMap<String, SessionAlertState>,
159}
160
161#[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 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 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 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 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 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 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 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#[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 compose_common_nfc(&collapsed)
397}
398
399fn canonical_dispatch_root(root: &Path) -> PathBuf {
400 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
401}
402
403fn render_alert_line(identity: &AlertIdentity) -> String {
404 let line = format!(
405 "New error in {}:{}: {}",
406 identity.file, identity.line, identity.message
407 );
408 truncate_alert_line(&line)
409}
410
411fn truncate_alert_line(line: &str) -> String {
412 if line.chars().count() <= MAX_ALERT_LINE_CHARS {
413 return line.to_string();
414 }
415
416 let prefix_len = MAX_ALERT_LINE_CHARS.saturating_sub(ALERT_ELLIPSIS.chars().count());
417 let mut truncated = line.chars().take(prefix_len).collect::<String>();
418 truncated.push_str(ALERT_ELLIPSIS);
419 truncated
420}
421
422fn compose_common_nfc(input: &str) -> String {
426 let mut normalized = String::with_capacity(input.len());
427 let mut chars = input.chars().peekable();
428 while let Some(base) = chars.next() {
429 let Some(&mark) = chars.peek() else {
430 normalized.push(base);
431 continue;
432 };
433 if let Some(composed) = compose_pair(base, mark) {
434 normalized.push(composed);
435 chars.next();
436 } else {
437 normalized.push(base);
438 }
439 }
440 normalized
441}
442
443fn compose_pair(base: char, mark: char) -> Option<char> {
444 let composed = match (base, mark) {
445 ('A', '\u{0300}') => 'À',
446 ('A', '\u{0301}') => 'Á',
447 ('A', '\u{0302}') => 'Â',
448 ('A', '\u{0303}') => 'Ã',
449 ('A', '\u{0308}') => 'Ä',
450 ('A', '\u{030A}') => 'Å',
451 ('C', '\u{0327}') => 'Ç',
452 ('E', '\u{0300}') => 'È',
453 ('E', '\u{0301}') => 'É',
454 ('E', '\u{0302}') => 'Ê',
455 ('E', '\u{0308}') => 'Ë',
456 ('I', '\u{0300}') => 'Ì',
457 ('I', '\u{0301}') => 'Í',
458 ('I', '\u{0302}') => 'Î',
459 ('I', '\u{0308}') => 'Ï',
460 ('N', '\u{0303}') => 'Ñ',
461 ('O', '\u{0300}') => 'Ò',
462 ('O', '\u{0301}') => 'Ó',
463 ('O', '\u{0302}') => 'Ô',
464 ('O', '\u{0303}') => 'Õ',
465 ('O', '\u{0308}') => 'Ö',
466 ('U', '\u{0300}') => 'Ù',
467 ('U', '\u{0301}') => 'Ú',
468 ('U', '\u{0302}') => 'Û',
469 ('U', '\u{0308}') => 'Ü',
470 ('Y', '\u{0301}') => 'Ý',
471 ('a', '\u{0300}') => 'à',
472 ('a', '\u{0301}') => 'á',
473 ('a', '\u{0302}') => 'â',
474 ('a', '\u{0303}') => 'ã',
475 ('a', '\u{0308}') => 'ä',
476 ('a', '\u{030A}') => 'å',
477 ('c', '\u{0327}') => 'ç',
478 ('e', '\u{0300}') => 'è',
479 ('e', '\u{0301}') => 'é',
480 ('e', '\u{0302}') => 'ê',
481 ('e', '\u{0308}') => 'ë',
482 ('i', '\u{0300}') => 'ì',
483 ('i', '\u{0301}') => 'í',
484 ('i', '\u{0302}') => 'î',
485 ('i', '\u{0308}') => 'ï',
486 ('n', '\u{0303}') => 'ñ',
487 ('o', '\u{0300}') => 'ò',
488 ('o', '\u{0301}') => 'ó',
489 ('o', '\u{0302}') => 'ô',
490 ('o', '\u{0303}') => 'õ',
491 ('o', '\u{0308}') => 'ö',
492 ('u', '\u{0300}') => 'ù',
493 ('u', '\u{0301}') => 'ú',
494 ('u', '\u{0302}') => 'û',
495 ('u', '\u{0308}') => 'ü',
496 ('y', '\u{0301}') => 'ý',
497 ('y', '\u{0308}') => 'ÿ',
498 _ => return None,
499 };
500 Some(composed)
501}
502
503#[cfg(test)]
504mod tests {
505 use super::{
506 normalize_alert_message, AlertDiagnostic, AlertEngine, AlertSeverity,
507 EXCLUDED_FINALIZATION_COMMANDS, MAX_ALERT_LINE_CHARS,
508 };
509 use std::path::Path;
510
511 fn error(file: &str, line: u32, message: &str) -> AlertDiagnostic {
512 AlertDiagnostic::error(file, line, message)
513 }
514
515 #[test]
516 fn normalizer_uses_only_the_contractual_transformations() {
517 assert_eq!(
518 normalize_alert_message(" Cafe\u{301} failed\nsecond line"),
519 "Café failed"
520 );
521 assert_eq!(
522 normalize_alert_message("src/a.rs:42 \"quoted\""),
523 "src/a.rs:42 \"quoted\""
524 );
525 }
526
527 #[test]
528 fn first_observation_is_the_named_default_silent_baseline() {
529 let root = Path::new("/dispatch-root");
530 let mut engine = AlertEngine::default();
531 engine.observe_authoritative("session", root, "server-a", vec![error("a.rs", 3, "old")]);
532
533 assert!(engine.partition_is_baselined("session", root, "server-a"));
534 assert!(engine.finalize("session", root, "read").is_none());
535 assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
536
537 engine.observe_authoritative(
538 "session",
539 root,
540 "server-a",
541 vec![error("a.rs", 3, "old"), error("a.rs", 7, "new")],
542 );
543 let alert = engine.finalize("session", root, "read").expect("new alert");
544 assert!(alert.text.contains("a.rs:7: new"));
545 assert!(!alert.text.contains("your edit"));
546 assert_eq!(alert.agent_visible_response_ordinal, 2);
547 }
548
549 #[test]
550 fn cold_root_silence_still_advances_the_session_response_ordinal() {
551 let mut engine = AlertEngine::default();
552 assert!(engine
553 .finalize("session", Path::new("/cold-root"), "read")
554 .is_none());
555 assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
556 }
557
558 #[test]
559 fn dispatch_root_isolation_never_borrows_or_consumes_another_root() {
560 let dispatch_root = Path::new("/dispatch-root");
561 let other_root = Path::new("/other-root");
562 let mut engine = AlertEngine::default();
563 for root in [dispatch_root, other_root] {
564 engine.observe_authoritative("session", root, "server", Vec::new());
565 engine.observe_authoritative(
566 "session",
567 root,
568 "server",
569 vec![error("src/lib.rs", 4, root.to_string_lossy().as_ref())],
570 );
571 }
572
573 let alert = engine
574 .finalize("session", dispatch_root, "inspect")
575 .expect("dispatch-root alert");
576 assert!(alert.text.contains("/dispatch-root"));
577 assert!(!alert.text.contains("/other-root"));
578 assert!(engine
579 .finalize("session", other_root, "read")
580 .expect("other root remains pending")
581 .text
582 .contains("/other-root"));
583 }
584
585 #[test]
586 fn coalescing_orders_ties_and_marks_counted_identities_rendered() {
587 let root = Path::new("/root");
588 let mut engine = AlertEngine::default();
589 engine.observe_authoritative("session", root, "server", Vec::new());
590 engine.observe_authoritative(
591 "session",
592 root,
593 "server",
594 vec![
595 error("z.rs", 3, "z"),
596 error("a.rs", 2, "a"),
597 error("m.rs", 1, "m"),
598 ],
599 );
600
601 let alert = engine
602 .finalize("session", root, "read")
603 .expect("coalesced alert");
604 assert_eq!(alert.text.lines().count(), 5);
605 assert!(alert.text.find("a.rs:2").unwrap() < alert.text.find("m.rs:1").unwrap());
606 assert!(alert.text.contains("(+1 more)"));
607 assert_eq!(alert.represented_identities().count(), 3);
608 assert_eq!(
609 engine
610 .partition_rendered_identities("session", root, "server")
611 .len(),
612 3
613 );
614 assert!(engine.finalize("session", root, "read").is_none());
615 }
616
617 #[test]
618 fn excluded_commands_preserve_pending_alerts_and_do_not_advance_visible_ordinal() {
619 let root = Path::new("/root");
620 let mut engine = AlertEngine::default();
621 engine.observe_authoritative("session", root, "server", Vec::new());
622 engine.observe_authoritative("session", root, "server", vec![error("a.rs", 1, "boom")]);
623
624 for command in EXCLUDED_FINALIZATION_COMMANDS {
625 assert!(engine.finalize("session", root, command).is_none());
626 }
627 assert_eq!(engine.agent_visible_response_ordinal("session"), 0);
628 assert!(engine.finalize("session", root, "inspect").is_some());
629 assert_eq!(engine.agent_visible_response_ordinal("session"), 1);
630 }
631
632 #[test]
633 fn warning_information_and_hints_never_enter_the_alert_channel() {
634 let root = Path::new("/root");
635 let mut engine = AlertEngine::default();
636 for severity in [
637 AlertSeverity::Warning,
638 AlertSeverity::Information,
639 AlertSeverity::Hint,
640 ] {
641 let mut diagnostic = error("a.rs", 1, "not an error");
642 diagnostic.severity = severity;
643 engine.observe_authoritative(
644 "session",
645 root,
646 severity.canonical_name(),
647 vec![diagnostic],
648 );
649 }
650 assert!(engine.finalize("session", root, "read").is_none());
651 }
652
653 #[test]
654 fn truncation_never_wraps_the_rendered_line() {
655 let root = Path::new("/root");
656 let mut engine = AlertEngine::default();
657 engine.observe_authoritative("session", root, "server", Vec::new());
658 engine.observe_authoritative(
659 "session",
660 root,
661 "server",
662 vec![error("a.rs", 1, &"x".repeat(MAX_ALERT_LINE_CHARS * 2))],
663 );
664 let alert = engine
665 .finalize("session", root, "read")
666 .expect("long alert");
667 let line = alert.text.lines().nth(1).expect("alert line");
668 assert!(line.chars().count() <= MAX_ALERT_LINE_CHARS);
669 assert!(!line.contains('\n'));
670 }
671}