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 eprintln!(
183 "Error: No diagnostic tools available for {:?}. Install one of:",
184 language
185 );
186 for tool in tools_for_language(language) {
187 eprintln!(
188 " - {} ({})",
189 tool.name,
190 tldr_core::diagnostics::get_install_suggestion(tool.name)
191 );
192 }
193 std::process::exit(60);
194 }
195
196 writer.progress(&format!(
197 "Running diagnostics: {}",
198 tools.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
199 ));
200
201 let mut report = run_tools_parallel(&tools, &self.path, self.timeout)?;
203
204 if report.tools_run.iter().all(|t| !t.success) {
206 eprintln!("Error: All diagnostic tools failed to run.");
207 for result in &report.tools_run {
208 if let Some(err) = &result.error {
209 eprintln!(" - {}: {}", result.name, err);
210 }
211 }
212 std::process::exit(61);
213 }
214
215 report.diagnostics = dedupe_diagnostics(report.diagnostics);
217
218 let min_severity: Severity = self.severity.into();
220 let unfiltered_count = report.diagnostics.len();
221 report.diagnostics = filter_diagnostics_by_severity(&report.diagnostics, min_severity);
222
223 if !self.ignore.is_empty() {
225 report.diagnostics.retain(|d| {
226 if let Some(code) = &d.code {
227 !self.ignore.iter().any(|ignored| code == ignored)
228 } else {
229 true
230 }
231 });
232 }
233
234 if let Some(baseline_path) = &self.baseline {
236 report = apply_baseline(report, baseline_path)?;
237 }
238
239 report.summary = compute_summary(&report.diagnostics);
241
242 if let Some(save_path) = &self.save_baseline {
244 save_baseline(&report, save_path)?;
245 writer.progress(&format!("Baseline saved to: {}", save_path.display()));
246 }
247
248 let filtered_count = unfiltered_count - report.diagnostics.len();
250
251 match self.output {
253 Some(DiagnosticOutput::Sarif) => {
254 let sarif = to_sarif(&report);
255 let estimated_size = serde_json::to_string(&sarif).map(|s| s.len()).unwrap_or(0);
257 if estimated_size > 10 * 1024 * 1024 {
258 eprintln!(
259 "Warning: SARIF output is large (~{}MB). GitHub may reject files over 10MB.",
260 estimated_size / (1024 * 1024)
261 );
262 }
263 println!("{}", serde_json::to_string_pretty(&sarif)?);
264 }
265 Some(DiagnosticOutput::GithubActions) => {
266 output_github_actions(&report, self.max_annotations);
267 }
268 None => {
269 if writer.is_text() {
270 let text = format_diagnostics_text(&report, filtered_count);
271 writer.write_text(&text)?;
272 } else {
273 writer.write(&report)?;
274 }
275 }
276 }
277
278 let exit_code = compute_exit_code(&report.summary, self.strict);
280 if exit_code != 0 {
281 std::process::exit(exit_code);
282 }
283
284 Ok(())
285 }
286}
287
288#[derive(Debug, serde::Serialize)]
294struct SarifReport {
295 #[serde(rename = "$schema")]
296 schema: &'static str,
297 version: &'static str,
298 runs: Vec<SarifRun>,
299}
300
301#[derive(Debug, serde::Serialize)]
302struct SarifRun {
303 tool: SarifTool,
304 results: Vec<SarifResult>,
305}
306
307#[derive(Debug, serde::Serialize)]
308#[serde(rename_all = "camelCase")]
309struct SarifTool {
310 driver: SarifDriver,
311}
312
313#[derive(Debug, serde::Serialize)]
314#[serde(rename_all = "camelCase")]
315struct SarifDriver {
316 name: String,
317 version: String,
318 information_uri: String,
319}
320
321#[derive(Debug, serde::Serialize)]
322#[serde(rename_all = "camelCase")]
323struct SarifResult {
324 rule_id: String,
325 level: String,
326 message: SarifMessage,
327 locations: Vec<SarifLocation>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 help_uri: Option<String>,
330}
331
332#[derive(Debug, serde::Serialize)]
333struct SarifMessage {
334 text: String,
335}
336
337#[derive(Debug, serde::Serialize)]
338#[serde(rename_all = "camelCase")]
339struct SarifLocation {
340 physical_location: SarifPhysicalLocation,
341}
342
343#[derive(Debug, serde::Serialize)]
344#[serde(rename_all = "camelCase")]
345struct SarifPhysicalLocation {
346 artifact_location: SarifArtifactLocation,
347 region: SarifRegion,
348}
349
350#[derive(Debug, serde::Serialize)]
351struct SarifArtifactLocation {
352 uri: String,
353}
354
355#[derive(Debug, serde::Serialize)]
356#[serde(rename_all = "camelCase")]
357struct SarifRegion {
358 start_line: u32,
359 start_column: u32,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 end_line: Option<u32>,
362 #[serde(skip_serializing_if = "Option::is_none")]
363 end_column: Option<u32>,
364}
365
366fn to_sarif(report: &DiagnosticsReport) -> SarifReport {
368 let results: Vec<SarifResult> = report
369 .diagnostics
370 .iter()
371 .map(|d| {
372 let level = match d.severity {
374 Severity::Error => "error",
375 Severity::Warning => "warning",
376 Severity::Information => "note",
377 Severity::Hint => "note",
378 };
379
380 let uri = d.file.display().to_string();
382 let relative_uri = if uri.starts_with('/') {
383 uri.trim_start_matches('/')
385 .split_once('/')
386 .map(|(_, rest)| rest.to_string())
387 .unwrap_or(uri)
388 } else {
389 uri
390 };
391
392 SarifResult {
393 rule_id: d.code.clone().unwrap_or_else(|| d.source.clone()),
394 level: level.to_string(),
395 message: SarifMessage {
396 text: d.message.clone(),
397 },
398 locations: vec![SarifLocation {
399 physical_location: SarifPhysicalLocation {
400 artifact_location: SarifArtifactLocation { uri: relative_uri },
401 region: SarifRegion {
402 start_line: d.line,
403 start_column: d.column,
404 end_line: d.end_line,
405 end_column: d.end_column,
406 },
407 },
408 }],
409 help_uri: d.url.clone(),
410 }
411 })
412 .collect();
413
414 SarifReport {
415 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
416 version: "2.1.0",
417 runs: vec![SarifRun {
418 tool: SarifTool {
419 driver: SarifDriver {
420 name: "tldr-diagnostics".to_string(),
421 version: env!("CARGO_PKG_VERSION").to_string(),
422 information_uri: "https://github.com/user/tldr".to_string(),
423 },
424 },
425 results,
426 }],
427 }
428}
429
430fn output_github_actions(report: &DiagnosticsReport, max_annotations: usize) {
436 if report.diagnostics.len() > max_annotations {
438 eprintln!(
439 "Warning: {} diagnostics found, but GitHub Actions limits annotations to {}. \
440 Only first {} will be shown. Use --max-annotations to adjust.",
441 report.diagnostics.len(),
442 max_annotations,
443 max_annotations
444 );
445 }
446
447 for diag in report.diagnostics.iter().take(max_annotations) {
448 let severity = match diag.severity {
449 Severity::Error => "error",
450 Severity::Warning => "warning",
451 Severity::Information => "notice",
452 Severity::Hint => "notice",
453 };
454
455 let escaped_message = diag
458 .message
459 .replace('\n', "%0A")
460 .replace('\r', "%0D")
461 .replace('%', "%25");
462
463 println!(
464 "::{} file={},line={},col={}::{}",
465 severity,
466 diag.file.display(),
467 diag.line,
468 diag.column,
469 escaped_message
470 );
471 }
472
473 println!("::group::Diagnostics Summary");
475 println!(
476 "Errors: {}, Warnings: {}, Info: {}, Hints: {}",
477 report.summary.errors, report.summary.warnings, report.summary.info, report.summary.hints
478 );
479 println!("::endgroup::");
480}
481
482#[derive(Debug, serde::Serialize, serde::Deserialize)]
488struct BaselineFile {
489 version: u32,
490 created_at: String,
491 diagnostics: Vec<BaselineDiagnostic>,
492}
493
494#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
496struct BaselineDiagnostic {
497 file: String,
499 line: u32,
501 column: u32,
503 message_hash: u64,
505 message: String,
507 code: Option<String>,
509}
510
511impl From<&tldr_core::diagnostics::Diagnostic> for BaselineDiagnostic {
512 fn from(d: &tldr_core::diagnostics::Diagnostic) -> Self {
513 use std::collections::hash_map::DefaultHasher;
514 use std::hash::{Hash, Hasher};
515
516 let mut hasher = DefaultHasher::new();
517 d.message.hash(&mut hasher);
518 let message_hash = hasher.finish();
519
520 BaselineDiagnostic {
521 file: d.file.display().to_string(),
522 line: d.line,
523 column: d.column,
524 message_hash,
525 message: d.message.clone(),
526 code: d.code.clone(),
527 }
528 }
529}
530
531fn apply_baseline(
533 mut report: DiagnosticsReport,
534 baseline_path: &PathBuf,
535) -> Result<DiagnosticsReport> {
536 let baseline_content = std::fs::read_to_string(baseline_path).map_err(|e| {
538 anyhow!(
539 "Failed to read baseline file '{}': {}",
540 baseline_path.display(),
541 e
542 )
543 })?;
544
545 let baseline: BaselineFile = serde_json::from_str(&baseline_content).map_err(|e| {
547 anyhow!(
548 "Invalid baseline JSON in '{}': {}",
549 baseline_path.display(),
550 e
551 )
552 })?;
553
554 if baseline.version != 1 {
556 return Err(anyhow!(
557 "Unsupported baseline version: {}. Expected version 1.",
558 baseline.version
559 ));
560 }
561
562 let current_set: std::collections::HashSet<BaselineDiagnostic> =
564 report.diagnostics.iter().map(|d| d.into()).collect();
565
566 let baseline_set: std::collections::HashSet<BaselineDiagnostic> =
567 baseline.diagnostics.into_iter().collect();
568
569 let new_diagnostics: std::collections::HashSet<_> =
571 current_set.difference(&baseline_set).cloned().collect();
572
573 let resolved: Vec<_> = baseline_set.difference(¤t_set).collect();
575
576 if !resolved.is_empty() {
577 eprintln!(
578 "Info: {} issues from baseline have been resolved.",
579 resolved.len()
580 );
581 }
582
583 report.diagnostics.retain(|d| {
585 let bd: BaselineDiagnostic = d.into();
586 new_diagnostics.contains(&bd)
587 });
588
589 Ok(report)
590}
591
592fn save_baseline(report: &DiagnosticsReport, path: &PathBuf) -> Result<()> {
594 let baseline = BaselineFile {
595 version: 1,
596 created_at: chrono::Utc::now().to_rfc3339(),
597 diagnostics: report.diagnostics.iter().map(|d| d.into()).collect(),
598 };
599
600 let json = serde_json::to_string_pretty(&baseline)?;
601 std::fs::write(path, json)
602 .map_err(|e| anyhow!("Failed to write baseline file '{}': {}", path.display(), e))?;
603
604 Ok(())
605}
606
607#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn test_severity_filter_conversion() {
617 assert_eq!(Severity::from(SeverityFilter::Error), Severity::Error);
618 assert_eq!(Severity::from(SeverityFilter::Warning), Severity::Warning);
619 assert_eq!(Severity::from(SeverityFilter::Info), Severity::Information);
620 assert_eq!(Severity::from(SeverityFilter::Hint), Severity::Hint);
621 }
622
623 #[test]
624 fn test_args_default_values() {
625 use clap::Parser;
626
627 #[derive(Debug, Parser)]
628 struct TestCli {
629 #[command(flatten)]
630 args: DiagnosticsArgs,
631 }
632
633 let cli = TestCli::try_parse_from(["test"]).unwrap();
634 assert_eq!(cli.args.path, PathBuf::from("."));
635 assert!(!cli.args.no_typecheck);
636 assert!(!cli.args.no_lint);
637 assert!(!cli.args.strict);
638 assert_eq!(cli.args.timeout, 60);
639 assert!(matches!(cli.args.severity, SeverityFilter::Hint));
640 }
641
642 #[test]
643 fn test_sarif_severity_mapping() {
644 use tldr_core::diagnostics::Diagnostic;
645
646 let diag = Diagnostic {
647 file: PathBuf::from("test.py"),
648 line: 1,
649 column: 1,
650 end_line: None,
651 end_column: None,
652 severity: Severity::Error,
653 message: "test error".to_string(),
654 code: Some("E001".to_string()),
655 source: "test".to_string(),
656 url: None,
657 };
658
659 let report = DiagnosticsReport {
660 diagnostics: vec![diag],
661 summary: tldr_core::diagnostics::DiagnosticsSummary {
662 errors: 1,
663 warnings: 0,
664 info: 0,
665 hints: 0,
666 total: 1,
667 },
668 tools_run: vec![],
669 files_analyzed: 1,
670 };
671
672 let sarif = to_sarif(&report);
673 assert_eq!(sarif.version, "2.1.0");
674 assert_eq!(sarif.runs.len(), 1);
675 assert_eq!(sarif.runs[0].results.len(), 1);
676 assert_eq!(sarif.runs[0].results[0].level, "error");
677 }
678
679 #[test]
680 fn test_baseline_diagnostic_hash() {
681 use tldr_core::diagnostics::Diagnostic;
682
683 let diag1 = Diagnostic {
684 file: PathBuf::from("test.py"),
685 line: 10,
686 column: 5,
687 end_line: None,
688 end_column: None,
689 severity: Severity::Warning,
690 message: "test warning".to_string(),
691 code: Some("W001".to_string()),
692 source: "test".to_string(),
693 url: None,
694 };
695
696 let diag2 = Diagnostic {
697 file: PathBuf::from("test.py"),
698 line: 10,
699 column: 5,
700 end_line: None,
701 end_column: None,
702 severity: Severity::Warning,
703 message: "test warning".to_string(), code: Some("W001".to_string()),
705 source: "test".to_string(),
706 url: None,
707 };
708
709 let bd1: BaselineDiagnostic = (&diag1).into();
710 let bd2: BaselineDiagnostic = (&diag2).into();
711
712 assert_eq!(bd1, bd2);
713 assert_eq!(bd1.message_hash, bd2.message_hash);
714 }
715}