1use anyhow::{anyhow, Result};
19use clap::Args;
20use std::path::PathBuf;
21
22use tldr_core::diagnostics::{
23 compute_exit_code, compute_summary, dedupe_diagnostics, detect_available_tools,
24 filter_diagnostics_by_severity, run_tools_parallel, tools_for_language, DiagnosticsReport,
25 Severity, ToolConfig,
26};
27use tldr_core::Language;
28
29use crate::output::{format_diagnostics_text, OutputFormat, OutputWriter};
30
31#[derive(Debug, Args)]
43pub struct DiagnosticsArgs {
44 #[arg(default_value = ".")]
46 pub path: PathBuf,
47
48 #[arg(long, short = 'l')]
50 pub lang: Option<Language>,
51
52 #[arg(long, value_delimiter = ',')]
55 pub tools: Vec<String>,
56
57 #[arg(long)]
59 pub no_typecheck: bool,
60
61 #[arg(long)]
63 pub no_lint: bool,
64
65 #[arg(long, short = 's', value_enum, default_value = "hint")]
68 pub severity: SeverityFilter,
69
70 #[arg(long, value_delimiter = ',')]
72 pub ignore: Vec<String>,
73
74 #[arg(long, value_enum)]
77 pub output: Option<DiagnosticOutput>,
78
79 #[arg(long)]
81 pub project: bool,
82
83 #[arg(long, default_value = "50")]
85 pub max_annotations: usize,
86
87 #[arg(long, default_value = "60")]
90 pub timeout: u64,
91
92 #[arg(long)]
94 pub strict: bool,
95
96 #[arg(long)]
99 pub baseline: Option<PathBuf>,
100
101 #[arg(long)]
103 pub save_baseline: Option<PathBuf>,
104}
105
106#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
108pub enum SeverityFilter {
109 Error,
111 Warning,
113 Info,
115 #[default]
117 Hint,
118}
119
120impl From<SeverityFilter> for Severity {
121 fn from(filter: SeverityFilter) -> Self {
122 match filter {
123 SeverityFilter::Error => Severity::Error,
124 SeverityFilter::Warning => Severity::Warning,
125 SeverityFilter::Info => Severity::Information,
126 SeverityFilter::Hint => Severity::Hint,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, clap::ValueEnum)]
133pub enum DiagnosticOutput {
134 Sarif,
136 GithubActions,
138}
139
140impl DiagnosticsArgs {
141 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
143 let writer = OutputWriter::new(format, quiet);
144
145 let language = self.lang.unwrap_or_else(|| {
147 if self.path.is_file() {
148 Language::from_path(&self.path).unwrap_or(Language::Python)
149 } else {
150 Language::from_directory(&self.path).unwrap_or(Language::Python)
151 }
152 });
153
154 writer.progress(&format!("Detecting tools for {:?}...", language));
155
156 let mut tools: Vec<ToolConfig> = if self.tools.is_empty() {
158 detect_available_tools(language)
159 } else {
160 tools_for_language(language)
162 .into_iter()
163 .filter(|t| {
164 self.tools
165 .iter()
166 .any(|name| t.name.eq_ignore_ascii_case(name))
167 })
168 .collect()
169 };
170
171 if self.no_typecheck {
173 tools.retain(|t| !t.is_type_checker);
174 }
175 if self.no_lint {
176 tools.retain(|t| !t.is_linter);
177 }
178
179 if tools.is_empty() {
181 let empty_report = DiagnosticsReport::default();
188 match self.output {
189 Some(DiagnosticOutput::Sarif) => {
190 let sarif = to_sarif(&empty_report);
191 println!("{}", serde_json::to_string_pretty(&sarif)?);
192 }
193 Some(DiagnosticOutput::GithubActions) => {
194 output_github_actions(&empty_report, self.max_annotations);
197 }
198 None => {
199 if writer.is_text() {
200 writer.write_text(&format!(
201 "No diagnostic tools available for {:?}.\n",
202 language
203 ))?;
204 } else {
205 writer.write(&empty_report)?;
209 }
210 }
211 }
212
213 eprintln!(
215 "Note: No diagnostic tools available for {:?}. Install one of:",
216 language
217 );
218 for tool in tools_for_language(language) {
219 eprintln!(
220 " - {} ({})",
221 tool.name,
222 tldr_core::diagnostics::get_install_suggestion(tool.name)
223 );
224 }
225 std::process::exit(60);
231 }
232
233 writer.progress(&format!(
234 "Running diagnostics: {}",
235 tools.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
236 ));
237
238 let mut report = run_tools_parallel(&tools, &self.path, self.timeout)?;
240
241 if !report.tools_run.is_empty() && report.tools_run.iter().all(|t| !t.success) {
250 report.summary = compute_summary(&report.diagnostics);
253 match self.output {
254 Some(DiagnosticOutput::Sarif) => {
255 let sarif = to_sarif(&report);
256 println!("{}", serde_json::to_string_pretty(&sarif)?);
257 }
258 Some(DiagnosticOutput::GithubActions) => {
259 output_github_actions(&report, self.max_annotations);
260 }
261 None => {
262 if writer.is_text() {
263 let text = format_diagnostics_text(&report, 0);
264 writer.write_text(&text)?;
265 } else {
266 writer.write(&report)?;
267 }
268 }
269 }
270 eprintln!("Note: All diagnostic tools failed to run.");
271 for result in &report.tools_run {
272 if let Some(err) = &result.error {
273 eprintln!(" - {}: {}", result.name, err);
274 }
275 }
276 std::process::exit(61);
279 }
280
281 report.diagnostics = dedupe_diagnostics(report.diagnostics);
283
284 let min_severity: Severity = self.severity.into();
286 let unfiltered_count = report.diagnostics.len();
287 report.diagnostics = filter_diagnostics_by_severity(&report.diagnostics, min_severity);
288
289 if !self.ignore.is_empty() {
291 report.diagnostics.retain(|d| {
292 if let Some(code) = &d.code {
293 !self.ignore.iter().any(|ignored| code == ignored)
294 } else {
295 true
296 }
297 });
298 }
299
300 if let Some(baseline_path) = &self.baseline {
302 report = apply_baseline(report, baseline_path)?;
303 }
304
305 report.summary = compute_summary(&report.diagnostics);
307
308 if let Some(save_path) = &self.save_baseline {
310 save_baseline(&report, save_path)?;
311 writer.progress(&format!("Baseline saved to: {}", save_path.display()));
312 }
313
314 let filtered_count = unfiltered_count - report.diagnostics.len();
316
317 match self.output {
319 Some(DiagnosticOutput::Sarif) => {
320 let sarif = to_sarif(&report);
321 let estimated_size = serde_json::to_string(&sarif).map(|s| s.len()).unwrap_or(0);
323 if estimated_size > 10 * 1024 * 1024 {
324 eprintln!(
325 "Warning: SARIF output is large (~{}MB). GitHub may reject files over 10MB.",
326 estimated_size / (1024 * 1024)
327 );
328 }
329 println!("{}", serde_json::to_string_pretty(&sarif)?);
330 }
331 Some(DiagnosticOutput::GithubActions) => {
332 output_github_actions(&report, self.max_annotations);
333 }
334 None => {
335 if writer.is_text() {
336 let text = format_diagnostics_text(&report, filtered_count);
337 writer.write_text(&text)?;
338 } else {
339 writer.write(&report)?;
340 }
341 }
342 }
343
344 let exit_code = compute_exit_code(&report.summary, self.strict);
346 if exit_code != 0 {
347 std::process::exit(exit_code);
348 }
349
350 Ok(())
351 }
352}
353
354#[derive(Debug, serde::Serialize)]
360struct SarifReport {
361 #[serde(rename = "$schema")]
362 schema: &'static str,
363 version: &'static str,
364 runs: Vec<SarifRun>,
365}
366
367#[derive(Debug, serde::Serialize)]
368struct SarifRun {
369 tool: SarifTool,
370 results: Vec<SarifResult>,
371}
372
373#[derive(Debug, serde::Serialize)]
374#[serde(rename_all = "camelCase")]
375struct SarifTool {
376 driver: SarifDriver,
377}
378
379#[derive(Debug, serde::Serialize)]
380#[serde(rename_all = "camelCase")]
381struct SarifDriver {
382 name: String,
383 version: String,
384 information_uri: String,
385}
386
387#[derive(Debug, serde::Serialize)]
388#[serde(rename_all = "camelCase")]
389struct SarifResult {
390 rule_id: String,
391 level: String,
392 message: SarifMessage,
393 locations: Vec<SarifLocation>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 help_uri: Option<String>,
396}
397
398#[derive(Debug, serde::Serialize)]
399struct SarifMessage {
400 text: String,
401}
402
403#[derive(Debug, serde::Serialize)]
404#[serde(rename_all = "camelCase")]
405struct SarifLocation {
406 physical_location: SarifPhysicalLocation,
407}
408
409#[derive(Debug, serde::Serialize)]
410#[serde(rename_all = "camelCase")]
411struct SarifPhysicalLocation {
412 artifact_location: SarifArtifactLocation,
413 region: SarifRegion,
414}
415
416#[derive(Debug, serde::Serialize)]
417struct SarifArtifactLocation {
418 uri: String,
419}
420
421#[derive(Debug, serde::Serialize)]
422#[serde(rename_all = "camelCase")]
423struct SarifRegion {
424 start_line: u32,
425 start_column: u32,
426 #[serde(skip_serializing_if = "Option::is_none")]
427 end_line: Option<u32>,
428 #[serde(skip_serializing_if = "Option::is_none")]
429 end_column: Option<u32>,
430}
431
432fn to_sarif(report: &DiagnosticsReport) -> SarifReport {
434 let results: Vec<SarifResult> = report
435 .diagnostics
436 .iter()
437 .map(|d| {
438 let level = match d.severity {
440 Severity::Error => "error",
441 Severity::Warning => "warning",
442 Severity::Information => "note",
443 Severity::Hint => "note",
444 };
445
446 let uri = d.file.display().to_string();
448 let relative_uri = if uri.starts_with('/') {
449 uri.trim_start_matches('/')
451 .split_once('/')
452 .map(|(_, rest)| rest.to_string())
453 .unwrap_or(uri)
454 } else {
455 uri
456 };
457
458 SarifResult {
459 rule_id: d.code.clone().unwrap_or_else(|| d.source.clone()),
460 level: level.to_string(),
461 message: SarifMessage {
462 text: d.message.clone(),
463 },
464 locations: vec![SarifLocation {
465 physical_location: SarifPhysicalLocation {
466 artifact_location: SarifArtifactLocation { uri: relative_uri },
467 region: SarifRegion {
468 start_line: d.line,
469 start_column: d.column,
470 end_line: d.end_line,
471 end_column: d.end_column,
472 },
473 },
474 }],
475 help_uri: d.url.clone(),
476 }
477 })
478 .collect();
479
480 SarifReport {
481 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
482 version: "2.1.0",
483 runs: vec![SarifRun {
484 tool: SarifTool {
485 driver: SarifDriver {
486 name: "tldr-diagnostics".to_string(),
487 version: env!("CARGO_PKG_VERSION").to_string(),
488 information_uri: "https://github.com/user/tldr".to_string(),
489 },
490 },
491 results,
492 }],
493 }
494}
495
496fn output_github_actions(report: &DiagnosticsReport, max_annotations: usize) {
502 if report.diagnostics.len() > max_annotations {
504 eprintln!(
505 "Warning: {} diagnostics found, but GitHub Actions limits annotations to {}. \
506 Only first {} will be shown. Use --max-annotations to adjust.",
507 report.diagnostics.len(),
508 max_annotations,
509 max_annotations
510 );
511 }
512
513 for diag in report.diagnostics.iter().take(max_annotations) {
514 let severity = match diag.severity {
515 Severity::Error => "error",
516 Severity::Warning => "warning",
517 Severity::Information => "notice",
518 Severity::Hint => "notice",
519 };
520
521 let escaped_message = diag
524 .message
525 .replace('\n', "%0A")
526 .replace('\r', "%0D")
527 .replace('%', "%25");
528
529 println!(
530 "::{} file={},line={},col={}::{}",
531 severity,
532 diag.file.display(),
533 diag.line,
534 diag.column,
535 escaped_message
536 );
537 }
538
539 println!("::group::Diagnostics Summary");
541 println!(
542 "Errors: {}, Warnings: {}, Info: {}, Hints: {}",
543 report.summary.errors, report.summary.warnings, report.summary.info, report.summary.hints
544 );
545 println!("::endgroup::");
546}
547
548#[derive(Debug, serde::Serialize, serde::Deserialize)]
554struct BaselineFile {
555 version: u32,
556 created_at: String,
557 diagnostics: Vec<BaselineDiagnostic>,
558}
559
560#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
562struct BaselineDiagnostic {
563 file: String,
565 line: u32,
567 column: u32,
569 message_hash: u64,
571 message: String,
573 code: Option<String>,
575}
576
577impl From<&tldr_core::diagnostics::Diagnostic> for BaselineDiagnostic {
578 fn from(d: &tldr_core::diagnostics::Diagnostic) -> Self {
579 use std::collections::hash_map::DefaultHasher;
580 use std::hash::{Hash, Hasher};
581
582 let mut hasher = DefaultHasher::new();
583 d.message.hash(&mut hasher);
584 let message_hash = hasher.finish();
585
586 BaselineDiagnostic {
587 file: d.file.display().to_string(),
588 line: d.line,
589 column: d.column,
590 message_hash,
591 message: d.message.clone(),
592 code: d.code.clone(),
593 }
594 }
595}
596
597fn apply_baseline(
599 mut report: DiagnosticsReport,
600 baseline_path: &PathBuf,
601) -> Result<DiagnosticsReport> {
602 let baseline_content = std::fs::read_to_string(baseline_path).map_err(|e| {
604 anyhow!(
605 "Failed to read baseline file '{}': {}",
606 baseline_path.display(),
607 e
608 )
609 })?;
610
611 let baseline: BaselineFile = serde_json::from_str(&baseline_content).map_err(|e| {
613 anyhow!(
614 "Invalid baseline JSON in '{}': {}",
615 baseline_path.display(),
616 e
617 )
618 })?;
619
620 if baseline.version != 1 {
622 return Err(anyhow!(
623 "Unsupported baseline version: {}. Expected version 1.",
624 baseline.version
625 ));
626 }
627
628 let current_set: std::collections::HashSet<BaselineDiagnostic> =
630 report.diagnostics.iter().map(|d| d.into()).collect();
631
632 let baseline_set: std::collections::HashSet<BaselineDiagnostic> =
633 baseline.diagnostics.into_iter().collect();
634
635 let new_diagnostics: std::collections::HashSet<_> =
637 current_set.difference(&baseline_set).cloned().collect();
638
639 let resolved: Vec<_> = baseline_set.difference(¤t_set).collect();
641
642 if !resolved.is_empty() {
643 eprintln!(
644 "Info: {} issues from baseline have been resolved.",
645 resolved.len()
646 );
647 }
648
649 report.diagnostics.retain(|d| {
651 let bd: BaselineDiagnostic = d.into();
652 new_diagnostics.contains(&bd)
653 });
654
655 Ok(report)
656}
657
658fn save_baseline(report: &DiagnosticsReport, path: &PathBuf) -> Result<()> {
660 let baseline = BaselineFile {
661 version: 1,
662 created_at: chrono::Utc::now().to_rfc3339(),
663 diagnostics: report.diagnostics.iter().map(|d| d.into()).collect(),
664 };
665
666 let json = serde_json::to_string_pretty(&baseline)?;
667 std::fs::write(path, json)
668 .map_err(|e| anyhow!("Failed to write baseline file '{}': {}", path.display(), e))?;
669
670 Ok(())
671}
672
673#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn test_severity_filter_conversion() {
683 assert_eq!(Severity::from(SeverityFilter::Error), Severity::Error);
684 assert_eq!(Severity::from(SeverityFilter::Warning), Severity::Warning);
685 assert_eq!(Severity::from(SeverityFilter::Info), Severity::Information);
686 assert_eq!(Severity::from(SeverityFilter::Hint), Severity::Hint);
687 }
688
689 #[test]
690 fn test_args_default_values() {
691 use clap::Parser;
692
693 #[derive(Debug, Parser)]
694 struct TestCli {
695 #[command(flatten)]
696 args: DiagnosticsArgs,
697 }
698
699 let cli = TestCli::try_parse_from(["test"]).unwrap();
700 assert_eq!(cli.args.path, PathBuf::from("."));
701 assert!(!cli.args.no_typecheck);
702 assert!(!cli.args.no_lint);
703 assert!(!cli.args.strict);
704 assert_eq!(cli.args.timeout, 60);
705 assert!(matches!(cli.args.severity, SeverityFilter::Hint));
706 }
707
708 #[test]
709 fn test_sarif_severity_mapping() {
710 use tldr_core::diagnostics::Diagnostic;
711
712 let diag = Diagnostic {
713 file: PathBuf::from("test.py"),
714 line: 1,
715 column: 1,
716 end_line: None,
717 end_column: None,
718 severity: Severity::Error,
719 message: "test error".to_string(),
720 code: Some("E001".to_string()),
721 source: "test".to_string(),
722 url: None,
723 };
724
725 let report = DiagnosticsReport {
726 diagnostics: vec![diag],
727 summary: tldr_core::diagnostics::DiagnosticsSummary {
728 errors: 1,
729 warnings: 0,
730 info: 0,
731 hints: 0,
732 total: 1,
733 },
734 tools_run: vec![],
735 files_analyzed: 1,
736 };
737
738 let sarif = to_sarif(&report);
739 assert_eq!(sarif.version, "2.1.0");
740 assert_eq!(sarif.runs.len(), 1);
741 assert_eq!(sarif.runs[0].results.len(), 1);
742 assert_eq!(sarif.runs[0].results[0].level, "error");
743 }
744
745 #[test]
746 fn test_baseline_diagnostic_hash() {
747 use tldr_core::diagnostics::Diagnostic;
748
749 let diag1 = Diagnostic {
750 file: PathBuf::from("test.py"),
751 line: 10,
752 column: 5,
753 end_line: None,
754 end_column: None,
755 severity: Severity::Warning,
756 message: "test warning".to_string(),
757 code: Some("W001".to_string()),
758 source: "test".to_string(),
759 url: None,
760 };
761
762 let diag2 = Diagnostic {
763 file: PathBuf::from("test.py"),
764 line: 10,
765 column: 5,
766 end_line: None,
767 end_column: None,
768 severity: Severity::Warning,
769 message: "test warning".to_string(), code: Some("W001".to_string()),
771 source: "test".to_string(),
772 url: None,
773 };
774
775 let bd1: BaselineDiagnostic = (&diag1).into();
776 let bd2: BaselineDiagnostic = (&diag2).into();
777
778 assert_eq!(bd1, bd2);
779 assert_eq!(bd1.message_hash, bd2.message_hash);
780 }
781}