ailloli_ui_terminal_core/
diagnostics.rs

1//! Deterministic heuristic classification of terminal output into IDE diagnostics.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{CommandId, CommandStatus, TerminalLine, TerminalState};
6
7/// Classification-local diagnostic identity.
8///
9/// [`TerminalOutputClassifier::classify`] assigns IDs from one in output order
10/// on every call; IDs are not stable across changed classifications. Any `u64`
11/// can also be constructed or deserialized.
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_terminal_core::TerminalDiagnosticId;
17/// assert_eq!(TerminalDiagnosticId(1).0, 1);
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct TerminalDiagnosticId(
21    /// Raw classification-local numeric identity.
22    pub u64,
23);
24
25/// Presentation priority assigned to a terminal diagnostic.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_terminal_core::TerminalDiagnosticSeverity;
31/// assert_ne!(TerminalDiagnosticSeverity::Error, TerminalDiagnosticSeverity::Hint);
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34pub enum TerminalDiagnosticSeverity {
35    /// Failure requiring attention.
36    Error,
37    /// Risk, conflict, or interactive credential prompt.
38    Warning,
39    /// Informational location or URL.
40    Info,
41    /// Low-priority suggestion; the built-in classifier currently emits none.
42    Hint,
43}
44
45/// Heuristic pattern that produced a diagnostic.
46///
47/// # Examples
48///
49/// ```
50/// use ailloli_ui_terminal_core::TerminalDiagnosticKind;
51/// assert_ne!(TerminalDiagnosticKind::RustcError, TerminalDiagnosticKind::NpmError);
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum TerminalDiagnosticKind {
55    /// A line beginning with `error[` or `error:`.
56    RustcError,
57    /// A line containing `panicked at`.
58    RustPanic,
59    /// A token resembling `path:line[:column]`.
60    FileLocation,
61    /// Cargo test summary/failure marker.
62    CargoTestFailure,
63    /// A line containing `npm ERR!`.
64    NpmError,
65    /// Git conflict or automatic-merge-failure marker.
66    GitConflict,
67    /// SSH trust, permission, or key-passphrase prompt.
68    SshPrompt,
69    /// `sudo` password/error prompt.
70    SudoPrompt,
71    /// Whitespace-delimited HTTP(S) URL.
72    Url,
73    /// Finished shell command marked failed or interrupted.
74    CommandExitFailure,
75}
76
77/// Inclusive range of global terminal line indices.
78///
79/// Values are zero-based logical history/screen indices. No invariant enforces
80/// `start_line <= end_line` for directly constructed/deserialized values.
81///
82/// # Examples
83///
84/// ```
85/// use ailloli_ui_terminal_core::TerminalSourceRange;
86/// assert_eq!(TerminalSourceRange::single(7), TerminalSourceRange { start_line: 7, end_line: 7 });
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct TerminalSourceRange {
90    /// Inclusive first global terminal line.
91    pub start_line: u64,
92    /// Inclusive last global terminal line.
93    pub end_line: u64,
94}
95
96impl TerminalSourceRange {
97    /// Creates a one-line inclusive range.
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use ailloli_ui_terminal_core::TerminalSourceRange;
103    /// let range = TerminalSourceRange::single(u64::MAX);
104    /// assert_eq!((range.start_line, range.end_line), (u64::MAX, u64::MAX));
105    /// ```
106    pub const fn single(line: u64) -> Self {
107        Self {
108            start_line: line,
109            end_line: line,
110        }
111    }
112}
113
114/// Parsed source location found in terminal text.
115///
116/// Paths/URIs are lexical strings and are not canonicalized or existence-checked.
117/// Parsed line/column values are conventionally one-based, but zero is accepted.
118/// `uri == None` means a relative path could not be resolved without a CWD.
119///
120/// # Examples
121///
122/// ```
123/// use ailloli_ui_terminal_core::TerminalFileLocation;
124/// let location = TerminalFileLocation { path: "src/main.rs".into(), uri: None, line: Some(3), column: Some(4) };
125/// assert_eq!(location.line, Some(3));
126/// ```
127#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
128pub struct TerminalFileLocation {
129    /// Path token exactly as classified.
130    pub path: String,
131    /// Absolute/resolved URI, or `None` when unresolved.
132    pub uri: Option<String>,
133    /// Parsed line number, or `None` when unavailable.
134    pub line: Option<usize>,
135    /// Parsed column number, or `None` when unavailable.
136    pub column: Option<usize>,
137}
138
139/// Actionable link extracted from one diagnostic.
140///
141/// Label and target are stored verbatim; no URL validation is performed.
142///
143/// # Examples
144///
145/// ```
146/// use ailloli_ui_terminal_core::TerminalDiagnosticLink;
147/// let link = TerminalDiagnosticLink { label: "docs".into(), target: "https://example.com".into() };
148/// assert_eq!(link.label, "docs");
149/// ```
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct TerminalDiagnosticLink {
152    /// User-facing link label.
153    pub label: String,
154    /// Consumer-facing target string.
155    pub target: String,
156}
157
158/// One classified terminal-output issue or navigation hint.
159///
160/// Fields are intentionally serializable and publicly constructible; their
161/// cross-field consistency is not validated.
162///
163/// # Examples
164///
165/// ```
166/// use ailloli_ui_terminal_core::{TerminalDiagnostic, TerminalDiagnosticId, TerminalDiagnosticKind, TerminalDiagnosticSeverity, TerminalSourceRange};
167/// let diagnostic = TerminalDiagnostic { id: TerminalDiagnosticId(1), severity: TerminalDiagnosticSeverity::Info, kind: TerminalDiagnosticKind::Url, message: "URL".into(), source_range: TerminalSourceRange::single(0), file_location: None, links: vec![], command_id: None };
168/// assert_eq!(diagnostic.id.0, 1);
169/// ```
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct TerminalDiagnostic {
172    /// Identity assigned within the latest classification.
173    pub id: TerminalDiagnosticId,
174    /// Presentation priority.
175    pub severity: TerminalDiagnosticSeverity,
176    /// Pattern that produced the diagnostic.
177    pub kind: TerminalDiagnosticKind,
178    /// Human-readable summary; may be empty for manually built values.
179    pub message: String,
180    /// Inclusive global terminal line range.
181    pub source_range: TerminalSourceRange,
182    /// Optional parsed file location.
183    pub file_location: Option<TerminalFileLocation>,
184    /// Zero or more actionable links.
185    pub links: Vec<TerminalDiagnosticLink>,
186    /// Shell command correlated by output range, when found.
187    pub command_id: Option<CommandId>,
188}
189
190/// Incremental diagnostic-list event.
191///
192/// The built-in classifier returns only `Added`; `Cleared` is available for
193/// stateful consumers clearing a previous set.
194///
195/// # Examples
196///
197/// ```
198/// use ailloli_ui_terminal_core::TerminalDiagnosticEvent;
199/// assert!(matches!(TerminalDiagnosticEvent::Cleared, TerminalDiagnosticEvent::Cleared));
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub enum TerminalDiagnosticEvent {
203    /// A diagnostic was added to the current set.
204    Added {
205        /// Complete added diagnostic snapshot.
206        diagnostic: TerminalDiagnostic,
207    },
208    /// The previous diagnostic set was cleared.
209    Cleared,
210}
211
212/// Complete deterministic classifier result and corresponding events.
213///
214/// A default value contains two empty vectors. Normally `events.len()` equals
215/// `diagnostics.len()` and each event is `Added`, but public construction does
216/// not enforce that relationship.
217///
218/// # Examples
219///
220/// ```
221/// use ailloli_ui_terminal_core::TerminalOutputClassification;
222/// let output = TerminalOutputClassification::default();
223/// assert!(output.diagnostics.is_empty() && output.events.is_empty());
224/// ```
225#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
226pub struct TerminalOutputClassification {
227    /// Sorted, deduplicated complete diagnostics.
228    pub diagnostics: Vec<TerminalDiagnostic>,
229    /// One `Added` event per classified diagnostic under normal classification.
230    pub events: Vec<TerminalDiagnosticEvent>,
231}
232
233/// Stateless, case-sensitive heuristic terminal-output classifier.
234///
235/// # Examples
236///
237/// ```
238/// use ailloli_ui_terminal_core::TerminalOutputClassifier;
239/// let classifier = TerminalOutputClassifier::new();
240/// assert_eq!(classifier, TerminalOutputClassifier);
241/// ```
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
243pub struct TerminalOutputClassifier;
244
245impl TerminalOutputClassifier {
246    /// Creates the zero-sized stateless classifier.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use ailloli_ui_terminal_core::TerminalOutputClassifier;
252    /// assert_eq!(TerminalOutputClassifier::new(), TerminalOutputClassifier::default());
253    /// ```
254    pub fn new() -> Self {
255        Self
256    }
257
258    /// Classifies the normal screen, retained scrollback, and failed command history.
259    ///
260    /// Patterns are case-sensitive and may emit several diagnostics for one
261    /// line. Relative file locations use `state.cwd_uri`, falling back to the
262    /// shell CWD; no filesystem access occurs. Results sort by global line then
263    /// severity (`Error`, `Warning`, `Info`, `Hint`), deduplicate equal semantic
264    /// keys, assign fresh IDs starting at one, and emit matching `Added` events.
265    /// The alternate screen is not classified even when active. Complexity is
266    /// linear in text plus command history except duplicate removal, which is
267    /// quadratic in the number of candidate diagnostics.
268    ///
269    /// # Examples
270    ///
271    /// ```
272    /// use ailloli_ui_terminal_core::{TerminalDiagnosticKind, TerminalOutputClassifier, TerminalState};
273    /// let mut state = TerminalState::new();
274    /// state.write_str("error: build failed");
275    /// let output = TerminalOutputClassifier::new().classify(&state);
276    /// assert!(output.diagnostics.iter().any(|item| item.kind == TerminalDiagnosticKind::RustcError));
277    /// ```
278    pub fn classify(&self, state: &TerminalState) -> TerminalOutputClassification {
279        let lines = terminal_global_text_lines(state);
280        let default_cwd = state.cwd_uri.as_deref().or(state.shell.cwd_uri.as_deref());
281        let mut pending = Vec::new();
282
283        for (idx, (global_line, text)) in lines.iter().enumerate() {
284            let trimmed = text.trim();
285            let command_id = command_id_for_line(state, *global_line);
286
287            if is_rustc_error(trimmed) {
288                let location = find_file_location(&lines, idx, default_cwd);
289                let end_line = location
290                    .as_ref()
291                    .and_then(|_| lines.get(idx + 1).map(|(line, _)| *line))
292                    .unwrap_or(*global_line);
293                pending.push(diagnostic(
294                    TerminalDiagnosticSeverity::Error,
295                    TerminalDiagnosticKind::RustcError,
296                    rustc_message(trimmed),
297                    TerminalSourceRange {
298                        start_line: *global_line,
299                        end_line,
300                    },
301                    location,
302                    Vec::new(),
303                    command_id,
304                ));
305            }
306
307            if trimmed.contains("panicked at") {
308                pending.push(diagnostic(
309                    TerminalDiagnosticSeverity::Error,
310                    TerminalDiagnosticKind::RustPanic,
311                    trimmed.to_string(),
312                    TerminalSourceRange::single(*global_line),
313                    parse_file_location(trimmed, default_cwd),
314                    Vec::new(),
315                    command_id,
316                ));
317            }
318
319            if trimmed.contains("test result: FAILED") || trimmed.starts_with("error: test failed")
320            {
321                pending.push(diagnostic(
322                    TerminalDiagnosticSeverity::Error,
323                    TerminalDiagnosticKind::CargoTestFailure,
324                    trimmed.to_string(),
325                    TerminalSourceRange::single(*global_line),
326                    None,
327                    Vec::new(),
328                    command_id,
329                ));
330            }
331
332            if trimmed.contains("npm ERR!") {
333                pending.push(diagnostic(
334                    TerminalDiagnosticSeverity::Error,
335                    TerminalDiagnosticKind::NpmError,
336                    trimmed.to_string(),
337                    TerminalSourceRange::single(*global_line),
338                    parse_file_location(trimmed, default_cwd),
339                    Vec::new(),
340                    command_id,
341                ));
342            }
343
344            if trimmed.contains("CONFLICT (") || trimmed.contains("Automatic merge failed") {
345                pending.push(diagnostic(
346                    TerminalDiagnosticSeverity::Warning,
347                    TerminalDiagnosticKind::GitConflict,
348                    trimmed.to_string(),
349                    TerminalSourceRange::single(*global_line),
350                    parse_file_location(trimmed, default_cwd),
351                    Vec::new(),
352                    command_id,
353                ));
354            }
355
356            if trimmed.contains("The authenticity of host")
357                || trimmed.contains("Permission denied")
358                || trimmed.contains("Enter passphrase for key")
359            {
360                pending.push(diagnostic(
361                    TerminalDiagnosticSeverity::Warning,
362                    TerminalDiagnosticKind::SshPrompt,
363                    trimmed.to_string(),
364                    TerminalSourceRange::single(*global_line),
365                    None,
366                    Vec::new(),
367                    command_id,
368                ));
369            }
370
371            if trimmed.contains("[sudo] password for") || trimmed.starts_with("sudo:") {
372                pending.push(diagnostic(
373                    TerminalDiagnosticSeverity::Warning,
374                    TerminalDiagnosticKind::SudoPrompt,
375                    trimmed.to_string(),
376                    TerminalSourceRange::single(*global_line),
377                    None,
378                    Vec::new(),
379                    command_id,
380                ));
381            }
382
383            let links = url_links(trimmed);
384            if !links.is_empty() {
385                pending.push(diagnostic(
386                    TerminalDiagnosticSeverity::Info,
387                    TerminalDiagnosticKind::Url,
388                    "URL detected in terminal output",
389                    TerminalSourceRange::single(*global_line),
390                    None,
391                    links,
392                    command_id,
393                ));
394            }
395
396            if let Some(location) = parse_file_location(trimmed, default_cwd) {
397                pending.push(diagnostic(
398                    TerminalDiagnosticSeverity::Info,
399                    TerminalDiagnosticKind::FileLocation,
400                    format!("file location: {}", location.path),
401                    TerminalSourceRange::single(*global_line),
402                    Some(location),
403                    Vec::new(),
404                    command_id,
405                ));
406            }
407        }
408
409        for command in &state.shell.history {
410            if !matches!(
411                command.status,
412                CommandStatus::Failed | CommandStatus::Interrupted
413            ) {
414                continue;
415            }
416            pending.push(diagnostic(
417                TerminalDiagnosticSeverity::Error,
418                TerminalDiagnosticKind::CommandExitFailure,
419                command_failure_message(command.command_line.as_str(), command.exit_code),
420                TerminalSourceRange {
421                    start_line: command.output_range.start_line,
422                    end_line: command
423                        .output_range
424                        .end_line
425                        .unwrap_or(command.output_range.start_line),
426                },
427                None,
428                Vec::new(),
429                Some(command.id),
430            ));
431        }
432
433        pending.sort_by_key(|diagnostic| {
434            (
435                diagnostic.source_range.start_line,
436                severity_rank(diagnostic.severity),
437            )
438        });
439        let diagnostics = deduplicate(pending);
440        let diagnostics = diagnostics
441            .into_iter()
442            .enumerate()
443            .map(|(idx, mut diagnostic)| {
444                diagnostic.id = TerminalDiagnosticId((idx + 1) as u64);
445                diagnostic
446            })
447            .collect::<Vec<_>>();
448        let events = diagnostics
449            .iter()
450            .cloned()
451            .map(|diagnostic| TerminalDiagnosticEvent::Added { diagnostic })
452            .collect();
453        TerminalOutputClassification {
454            diagnostics,
455            events,
456        }
457    }
458}
459
460/// Maps currently displayed physical rows to global normal-history indices.
461///
462/// On the normal screen, output contains one `Some(index)` per retained
463/// scrollback and visible line, oldest first. Saturating arithmetic prevents
464/// wrap if counters were deserialized inconsistently. On the alternate screen,
465/// it contains one `None` per alternate row because alternate content has no
466/// stable history index.
467///
468/// # Examples
469///
470/// ```
471/// use ailloli_ui_terminal_core::{terminal_visual_line_global_indices, TerminalState};
472/// let mut state = TerminalState::new();
473/// assert!(terminal_visual_line_global_indices(&state).iter().all(Option::is_some));
474/// state.switch_to_alternate_screen();
475/// assert!(terminal_visual_line_global_indices(&state).iter().all(Option::is_none));
476/// ```
477pub fn terminal_visual_line_global_indices(state: &TerminalState) -> Vec<Option<u64>> {
478    match state.active_screen {
479        crate::ActiveScreen::Normal => {
480            let scrollback_start = state
481                .scrollback
482                .total_pushed()
483                .saturating_sub(state.scrollback.len() as u64);
484            (0..state.scrollback.len())
485                .map(|idx| Some(scrollback_start.saturating_add(idx as u64)))
486                .chain(
487                    (0..state.screen.lines.len()).map(|idx| {
488                        Some(state.scrollback.total_pushed().saturating_add(idx as u64))
489                    }),
490                )
491                .collect()
492        }
493        crate::ActiveScreen::Alternate => {
494            state.alternate_screen.lines.iter().map(|_| None).collect()
495        }
496    }
497}
498
499/// Materializes normal scrollback/screen text with saturating global indices.
500///
501/// This intentionally ignores the active-screen selector because diagnostics
502/// are derived only from persistent normal output.
503fn terminal_global_text_lines(state: &TerminalState) -> Vec<(u64, String)> {
504    let scrollback_start = state
505        .scrollback
506        .total_pushed()
507        .saturating_sub(state.scrollback.len() as u64);
508    state
509        .scrollback
510        .iter()
511        .enumerate()
512        .map(|(idx, line)| {
513            (
514                scrollback_start.saturating_add(idx as u64),
515                line.plain_text(),
516            )
517        })
518        .chain(state.screen.lines.iter().enumerate().map(|(idx, line)| {
519            (
520                state.scrollback.total_pushed().saturating_add(idx as u64),
521                line.plain_text(),
522            )
523        }))
524        .collect()
525}
526
527/// Constructs one candidate with placeholder ID zero before final ordering.
528fn diagnostic(
529    severity: TerminalDiagnosticSeverity,
530    kind: TerminalDiagnosticKind,
531    message: impl Into<String>,
532    source_range: TerminalSourceRange,
533    file_location: Option<TerminalFileLocation>,
534    links: Vec<TerminalDiagnosticLink>,
535    command_id: Option<CommandId>,
536) -> TerminalDiagnostic {
537    TerminalDiagnostic {
538        id: TerminalDiagnosticId(0),
539        severity,
540        kind,
541        message: message.into(),
542        source_range,
543        file_location,
544        links,
545        command_id,
546    }
547}
548
549/// Keeps the first candidate for each formatted semantic key.
550///
551/// Link and command-ID differences are not part of the key. The vector scan is
552/// intentionally stable but quadratic in candidate count.
553fn deduplicate(diagnostics: Vec<TerminalDiagnostic>) -> Vec<TerminalDiagnostic> {
554    let mut unique = Vec::new();
555    let mut keys = Vec::new();
556    for diagnostic in diagnostics {
557        let key = format!(
558            "{:?}|{:?}|{}|{}|{:?}|{}",
559            diagnostic.kind,
560            diagnostic.severity,
561            diagnostic.source_range.start_line,
562            diagnostic.source_range.end_line,
563            diagnostic.file_location,
564            diagnostic.message
565        );
566        if keys.iter().any(|existing| existing == &key) {
567            continue;
568        }
569        keys.push(key);
570        unique.push(diagnostic);
571    }
572    unique
573}
574
575/// Correlates a global line with the first finished range or current command.
576fn command_id_for_line(state: &TerminalState, line: u64) -> Option<CommandId> {
577    state
578        .shell
579        .history
580        .iter()
581        .find(|command| {
582            let end = command
583                .output_range
584                .end_line
585                .unwrap_or(command.output_range.start_line);
586            command.output_range.start_line <= line && line <= end
587        })
588        .map(|command| command.id)
589        .or_else(|| {
590            state
591                .shell
592                .current_command
593                .as_ref()
594                .and_then(|command| (command.output_range.start_line <= line).then_some(command.id))
595        })
596}
597
598/// Recognizes the two supported case-sensitive rustc error prefixes.
599fn is_rustc_error(text: &str) -> bool {
600    text.starts_with("error[") || text.starts_with("error:")
601}
602
603/// Removes exact `error: ` and surrounding whitespace when present.
604fn rustc_message(text: &str) -> String {
605    text.strip_prefix("error: ")
606        .unwrap_or(text)
607        .trim()
608        .to_string()
609}
610
611/// Formats the four command/exit-code presence combinations.
612fn command_failure_message(command: &str, exit_code: Option<i32>) -> String {
613    match (command.is_empty(), exit_code) {
614        (true, Some(code)) => format!("command exited with code {code}"),
615        (true, None) => "command failed".to_string(),
616        (false, Some(code)) => format!("command failed with code {code}: {command}"),
617        (false, None) => format!("command failed: {command}"),
618    }
619}
620
621/// Searches the triggering line and next three lines for a location token.
622fn find_file_location(
623    lines: &[(u64, String)],
624    start: usize,
625    cwd_uri: Option<&str>,
626) -> Option<TerminalFileLocation> {
627    lines
628        .iter()
629        .skip(start)
630        .take(4)
631        .find_map(|(_, text)| parse_file_location(text, cwd_uri))
632}
633
634/// Returns the first whitespace token containing a supported path location.
635fn parse_file_location(text: &str, cwd_uri: Option<&str>) -> Option<TerminalFileLocation> {
636    text.split_whitespace()
637        .flat_map(split_location_candidates)
638        .find_map(|candidate| parse_location_candidate(candidate, cwd_uri))
639}
640
641/// Trims common punctuation and splits Rust path separators into candidates.
642fn split_location_candidates(token: &str) -> Vec<&str> {
643    token
644        .trim_matches(|ch: char| {
645            matches!(
646                ch,
647                ',' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '\'' | '"' | '`'
648            )
649        })
650        .split("::")
651        .collect()
652}
653
654/// Parses `path:line[:column]`, rejecting HTTP(S) and unsupported path shapes.
655///
656/// A two-component `path:line` token is not recognized because the rightmost
657/// component is first treated as optional column and a line remains required.
658fn parse_location_candidate(
659    candidate: &str,
660    cwd_uri: Option<&str>,
661) -> Option<TerminalFileLocation> {
662    if candidate.starts_with("http://") || candidate.starts_with("https://") {
663        return None;
664    }
665    let cleaned = candidate
666        .trim_start_matches("-->")
667        .trim_start_matches("at")
668        .trim_matches(|ch: char| matches!(ch, ',' | ';' | ':' | ')' | '('));
669    let mut parts = cleaned.rsplitn(3, ':');
670    let column = parts.next()?.parse::<usize>().ok();
671    let line = parts.next()?.parse::<usize>().ok()?;
672    let path = parts.next()?.trim();
673    if path.is_empty() || !looks_like_path(path) {
674        return None;
675    }
676    Some(TerminalFileLocation {
677        path: path.to_string(),
678        uri: resolve_uri(path, cwd_uri),
679        line: Some(line),
680        column,
681    })
682}
683
684/// Recognizes separators, `file://`, or a small source/config extension set.
685fn looks_like_path(path: &str) -> bool {
686    path.contains('/')
687        || path.contains('\\')
688        || path.starts_with("file://")
689        || path.ends_with(".rs")
690        || path.ends_with(".js")
691        || path.ends_with(".ts")
692        || path.ends_with(".tsx")
693        || path.ends_with(".jsx")
694        || path.ends_with(".json")
695        || path.ends_with(".toml")
696}
697
698/// Lexically resolves a path without normalization or filesystem access.
699///
700/// Any `://` string is retained; absolute paths gain `file://`; relative paths
701/// append to a trailing-slash-trimmed CWD and remain unresolved without one.
702fn resolve_uri(path: &str, cwd_uri: Option<&str>) -> Option<String> {
703    if path.contains("://") {
704        return Some(path.to_string());
705    }
706    if path.starts_with('/') {
707        return Some(format!("file://{path}"));
708    }
709    let cwd = cwd_uri?;
710    let cwd = cwd.trim_end_matches('/');
711    Some(format!("{cwd}/{path}"))
712}
713
714/// Extracts whitespace-delimited HTTP(S) tokens after limited punctuation trim.
715fn url_links(text: &str) -> Vec<TerminalDiagnosticLink> {
716    text.split_whitespace()
717        .filter_map(|token| {
718            let target = token.trim_matches(|ch: char| matches!(ch, ',' | ';' | ')' | '('));
719            (target.starts_with("http://") || target.starts_with("https://")).then(|| {
720                TerminalDiagnosticLink {
721                    label: target.to_string(),
722                    target: target.to_string(),
723                }
724            })
725        })
726        .collect()
727}
728
729/// Maps severities to deterministic ascending presentation order.
730fn severity_rank(severity: TerminalDiagnosticSeverity) -> u8 {
731    match severity {
732        TerminalDiagnosticSeverity::Error => 0,
733        TerminalDiagnosticSeverity::Warning => 1,
734        TerminalDiagnosticSeverity::Info => 2,
735        TerminalDiagnosticSeverity::Hint => 3,
736    }
737}
738
739#[allow(dead_code)]
740/// Compile-time import-use sentinel retained for the public line type.
741fn _assert_terminal_line_is_used(_: &TerminalLine) {}
742
743#[cfg(test)]
744mod tests {
745    //! Covers classifier patterns, CWD resolution, deduplication, and global indices.
746    use super::*;
747    use crate::{
748        TerminalConfig, TerminalParser, TerminalSecurityPolicy, TerminalSize, VteTerminalParser,
749    };
750
751    /// Builds a parsed multi-tool output fixture and runs classification.
752    fn classified_fixture() -> TerminalState {
753        let mut state = TerminalState::with_config(TerminalConfig {
754            size: TerminalSize::new(8, 96),
755            scrollback_limit: 80,
756            security: TerminalSecurityPolicy::default(),
757        });
758        state.set_cwd_uri("file:///tmp/ailloli_ui");
759        state.start_shell_command("cargo test -p demo", None, Some(1));
760        let mut parser = VteTerminalParser::new();
761        let fixture = concat!(
762            "error[E0502]: cannot borrow `state` as mutable\r\n",
763            "  --> src/main.rs:42:13\r\n",
764            "thread 'main' panicked at src/lib.rs:7:5: boom\r\n",
765            "test result: FAILED. 1 passed; 1 failed\r\n",
766            "npm ERR! missing script: build\r\n",
767            "CONFLICT (content): Merge conflict in src/app.rs\r\n",
768            "The authenticity of host 'github.com' can't be established.\r\n",
769            "[sudo] password for chaos:\r\n",
770            "docs: https://example.test/report\r\n"
771        );
772        parser.advance(&mut state, fixture.as_bytes());
773        state.finish_shell_command(Some(101), None, Some(2), None);
774        state.classify_terminal_output();
775        state
776    }
777
778    #[test]
779    fn diagnostics_classify_rustc_panic_prompts_urls_and_command_failure() {
780        let state = classified_fixture();
781
782        assert!(state.diagnostics.iter().any(|diagnostic| {
783            diagnostic.kind == TerminalDiagnosticKind::RustcError
784                && diagnostic.severity == TerminalDiagnosticSeverity::Error
785                && diagnostic
786                    .file_location
787                    .as_ref()
788                    .is_some_and(|location| location.line == Some(42))
789        }));
790        assert!(state
791            .diagnostics
792            .iter()
793            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::RustPanic));
794        assert!(state
795            .diagnostics
796            .iter()
797            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::CargoTestFailure));
798        assert!(state
799            .diagnostics
800            .iter()
801            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::NpmError));
802        assert!(state
803            .diagnostics
804            .iter()
805            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::GitConflict));
806        assert!(state
807            .diagnostics
808            .iter()
809            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::SshPrompt));
810        assert!(state
811            .diagnostics
812            .iter()
813            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::SudoPrompt));
814        assert!(state
815            .diagnostics
816            .iter()
817            .any(|diagnostic| diagnostic.kind == TerminalDiagnosticKind::Url));
818        assert!(state.diagnostics.iter().any(|diagnostic| {
819            diagnostic.kind == TerminalDiagnosticKind::CommandExitFailure
820                && diagnostic.command_id.is_some()
821        }));
822    }
823
824    #[test]
825    fn diagnostics_resolve_relative_paths_from_cwd_and_leave_unresolved_without_cwd() {
826        let location = parse_file_location("--> src/main.rs:3:4", Some("file:///tmp/ailloli_ui"))
827            .expect("loc");
828        assert_eq!(
829            location.uri.as_deref(),
830            Some("file:///tmp/ailloli_ui/src/main.rs")
831        );
832
833        let unresolved = parse_file_location("--> src/main.rs:3:4", None).expect("loc");
834        assert_eq!(unresolved.uri, None);
835    }
836
837    #[test]
838    fn diagnostics_deduplicate_without_reordering_remaining_items() {
839        let first = diagnostic(
840            TerminalDiagnosticSeverity::Error,
841            TerminalDiagnosticKind::NpmError,
842            "npm ERR! missing script",
843            TerminalSourceRange::single(2),
844            None,
845            Vec::new(),
846            None,
847        );
848        let second = diagnostic(
849            TerminalDiagnosticSeverity::Warning,
850            TerminalDiagnosticKind::GitConflict,
851            "CONFLICT (content): Merge conflict in src/lib.rs",
852            TerminalSourceRange::single(3),
853            None,
854            Vec::new(),
855            None,
856        );
857
858        let deduped = deduplicate(vec![first.clone(), first, second]);
859
860        assert_eq!(deduped.len(), 2);
861        assert_eq!(deduped[0].kind, TerminalDiagnosticKind::NpmError);
862        assert_eq!(deduped[1].kind, TerminalDiagnosticKind::GitConflict);
863    }
864
865    #[test]
866    fn diagnostics_global_line_mapping_survives_scrollback_trim() {
867        let mut state = TerminalState::with_config(TerminalConfig {
868            size: TerminalSize::new(2, 8),
869            scrollback_limit: 1,
870            security: TerminalSecurityPolicy::default(),
871        });
872        state.write_str("one\ntwo\nthree\n");
873
874        let indices = terminal_visual_line_global_indices(&state);
875        assert_eq!(indices.len(), 3);
876        assert!(indices[0].expect("global") > 0);
877        assert!(indices[2].expect("global") >= indices[0].expect("global"));
878    }
879}