1use serde::{Deserialize, Serialize};
4
5use crate::{CommandId, CommandStatus, TerminalLine, TerminalState};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct TerminalDiagnosticId(
21 pub u64,
23);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34pub enum TerminalDiagnosticSeverity {
35 Error,
37 Warning,
39 Info,
41 Hint,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum TerminalDiagnosticKind {
55 RustcError,
57 RustPanic,
59 FileLocation,
61 CargoTestFailure,
63 NpmError,
65 GitConflict,
67 SshPrompt,
69 SudoPrompt,
71 Url,
73 CommandExitFailure,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct TerminalSourceRange {
90 pub start_line: u64,
92 pub end_line: u64,
94}
95
96impl TerminalSourceRange {
97 pub const fn single(line: u64) -> Self {
107 Self {
108 start_line: line,
109 end_line: line,
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
128pub struct TerminalFileLocation {
129 pub path: String,
131 pub uri: Option<String>,
133 pub line: Option<usize>,
135 pub column: Option<usize>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct TerminalDiagnosticLink {
152 pub label: String,
154 pub target: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct TerminalDiagnostic {
172 pub id: TerminalDiagnosticId,
174 pub severity: TerminalDiagnosticSeverity,
176 pub kind: TerminalDiagnosticKind,
178 pub message: String,
180 pub source_range: TerminalSourceRange,
182 pub file_location: Option<TerminalFileLocation>,
184 pub links: Vec<TerminalDiagnosticLink>,
186 pub command_id: Option<CommandId>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub enum TerminalDiagnosticEvent {
203 Added {
205 diagnostic: TerminalDiagnostic,
207 },
208 Cleared,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
226pub struct TerminalOutputClassification {
227 pub diagnostics: Vec<TerminalDiagnostic>,
229 pub events: Vec<TerminalDiagnosticEvent>,
231}
232
233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
243pub struct TerminalOutputClassifier;
244
245impl TerminalOutputClassifier {
246 pub fn new() -> Self {
255 Self
256 }
257
258 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
460pub 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
499fn 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
527fn 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
549fn 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
575fn 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
598fn is_rustc_error(text: &str) -> bool {
600 text.starts_with("error[") || text.starts_with("error:")
601}
602
603fn rustc_message(text: &str) -> String {
605 text.strip_prefix("error: ")
606 .unwrap_or(text)
607 .trim()
608 .to_string()
609}
610
611fn 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
621fn 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
634fn 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
641fn 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
654fn 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
684fn 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
698fn 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
714fn 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
729fn 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)]
740fn _assert_terminal_line_is_used(_: &TerminalLine) {}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
747 use crate::{
748 TerminalConfig, TerminalParser, TerminalSecurityPolicy, TerminalSize, VteTerminalParser,
749 };
750
751 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}