Skip to main content

tldr_cli/commands/
diagnostics.rs

1//! Diagnostics command - Unified type checking and linting across languages
2//!
3//! Session 6 Phase 10: CLI command for running diagnostic tools.
4//!
5//! # Features
6//! - Auto-detect available tools (pyright, ruff, tsc, eslint, etc.)
7//! - Run type checkers and linters in parallel
8//! - Unified diagnostic output format
9//! - Severity filtering (error, warning, info, hint)
10//! - Multiple output formats (JSON, text, SARIF, GitHub Actions)
11//!
12//! # Exit Codes (documented in --help, S6-R52 mitigation)
13//! - 0: Success (no errors, or only warnings without --strict)
14//! - 1: Errors found (or warnings with --strict)
15//! - 60: No diagnostic tools available
16//! - 61: All tools failed to run
17
18use 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/// Run type checking and linting
32///
33/// Runs diagnostic tools (type checkers and linters) and produces unified output.
34/// Tools are detected automatically based on language and availability.
35///
36/// # Exit Codes
37///
38/// - 0: Success (no errors, or only warnings without --strict)
39/// - 1: Errors found (or warnings with --strict)
40/// - 60: No diagnostic tools available for language
41/// - 61: All tools failed to run
42#[derive(Debug, Args)]
43pub struct DiagnosticsArgs {
44    /// File or directory to analyze
45    #[arg(default_value = ".")]
46    pub path: PathBuf,
47
48    /// Programming language (auto-detect if not specified)
49    #[arg(long, short = 'l')]
50    pub lang: Option<Language>,
51
52    // === Tool Selection ===
53    /// Specific tools to run (comma-separated, e.g., "pyright,ruff")
54    #[arg(long, value_delimiter = ',')]
55    pub tools: Vec<String>,
56
57    /// Skip type checking (linters only)
58    #[arg(long)]
59    pub no_typecheck: bool,
60
61    /// Skip linting (type checkers only)
62    #[arg(long)]
63    pub no_lint: bool,
64
65    // === Filtering ===
66    /// Minimum severity to report (error, warning, info, hint)
67    #[arg(long, short = 's', value_enum, default_value = "hint")]
68    pub severity: SeverityFilter,
69
70    /// Ignore specific error codes (comma-separated)
71    #[arg(long, value_delimiter = ',')]
72    pub ignore: Vec<String>,
73
74    // === Output Options ===
75    /// Additional output format (sarif, github-actions)
76    #[arg(long, value_enum)]
77    pub output: Option<DiagnosticOutput>,
78
79    /// Analyze entire project (not just specified path)
80    #[arg(long)]
81    pub project: bool,
82
83    /// Maximum number of annotations for GitHub Actions output
84    #[arg(long, default_value = "50")]
85    pub max_annotations: usize,
86
87    // === Execution ===
88    /// Timeout per tool in seconds
89    #[arg(long, default_value = "60")]
90    pub timeout: u64,
91
92    /// Fail on warnings (not just errors)
93    #[arg(long)]
94    pub strict: bool,
95
96    // === Baseline Comparison (Phase 12) ===
97    /// Compare against baseline file (show only new issues)
98    #[arg(long)]
99    pub baseline: Option<PathBuf>,
100
101    /// Save current results as baseline
102    #[arg(long)]
103    pub save_baseline: Option<PathBuf>,
104}
105
106/// Severity filter for CLI (maps to core Severity)
107#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
108pub enum SeverityFilter {
109    /// Show only errors
110    Error,
111    /// Show errors and warnings
112    Warning,
113    /// Show errors, warnings, and info
114    Info,
115    /// Show all diagnostics including hints
116    #[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/// Additional output formats for diagnostics
132#[derive(Debug, Clone, Copy, clap::ValueEnum)]
133pub enum DiagnosticOutput {
134    /// SARIF 2.1.0 format for GitHub/GitLab Code Scanning
135    Sarif,
136    /// GitHub Actions workflow commands (::error::, ::warning::)
137    GithubActions,
138}
139
140impl DiagnosticsArgs {
141    /// Run the diagnostics command
142    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
143        let writer = OutputWriter::new(format, quiet);
144
145        // 1. Detect language (default to Python if not specified and can't detect)
146        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        // 2. Get available tools
157        let mut tools: Vec<ToolConfig> = if self.tools.is_empty() {
158            detect_available_tools(language)
159        } else {
160            // Filter to requested tools
161            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        // 3. Apply type/lint filtering
172        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        // 4. Check if we have tools to run
180        if tools.is_empty() {
181            // Exit code 60: No diagnostic tools available (S6-R36 mitigation)
182            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        // 5. Run tools in parallel
202        let mut report = run_tools_parallel(&tools, &self.path, self.timeout)?;
203
204        // Check if all tools failed (exit code 61)
205        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        // 6. Deduplicate diagnostics
216        report.diagnostics = dedupe_diagnostics(report.diagnostics);
217
218        // 7. Filter by severity
219        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        // 8. Filter by ignored codes
224        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        // 9. Apply baseline comparison (Phase 12)
235        if let Some(baseline_path) = &self.baseline {
236            report = apply_baseline(report, baseline_path)?;
237        }
238
239        // 10. Recompute summary after filtering (S6-R28 mitigation)
240        report.summary = compute_summary(&report.diagnostics);
241
242        // 11. Save baseline if requested
243        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        // 12. Calculate filtered count for display (S6-R47 mitigation)
249        let filtered_count = unfiltered_count - report.diagnostics.len();
250
251        // 13. Output based on format
252        match self.output {
253            Some(DiagnosticOutput::Sarif) => {
254                let sarif = to_sarif(&report);
255                // Warn if SARIF exceeds 10MB estimate (S6-R56 mitigation)
256                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        // 14. Compute exit code (S6-R36 mitigation: distinct codes)
279        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// =============================================================================
289// Phase 11: SARIF Output Format
290// =============================================================================
291
292/// SARIF 2.1.0 output structure
293#[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
366/// Convert DiagnosticsReport to SARIF 2.1.0 format
367fn to_sarif(report: &DiagnosticsReport) -> SarifReport {
368    let results: Vec<SarifResult> = report
369        .diagnostics
370        .iter()
371        .map(|d| {
372            // Map severity to SARIF level (S6-R35 mitigation)
373            let level = match d.severity {
374                Severity::Error => "error",
375                Severity::Warning => "warning",
376                Severity::Information => "note",
377                Severity::Hint => "note",
378            };
379
380            // Use relative path for URI (S6-R23 mitigation)
381            let uri = d.file.display().to_string();
382            let relative_uri = if uri.starts_with('/') {
383                // Strip absolute path prefix - try common prefixes
384                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
430// =============================================================================
431// Phase 11: GitHub Actions Output Format
432// =============================================================================
433
434/// Output diagnostics as GitHub Actions workflow commands
435fn output_github_actions(report: &DiagnosticsReport, max_annotations: usize) {
436    // Warn if exceeding annotation limit (S6-R55 mitigation)
437    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        // GitHub Actions format: ::severity file=path,line=N,col=M::message
456        // Escape message for GH Actions (newlines become %0A)
457        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    // Output summary as a group
474    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// =============================================================================
483// Phase 12: Baseline Comparison
484// =============================================================================
485
486/// Baseline file structure for JSON serialization
487#[derive(Debug, serde::Serialize, serde::Deserialize)]
488struct BaselineFile {
489    version: u32,
490    created_at: String,
491    diagnostics: Vec<BaselineDiagnostic>,
492}
493
494/// Simplified diagnostic for baseline storage
495#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
496struct BaselineDiagnostic {
497    /// Relative file path
498    file: String,
499    /// Start line
500    line: u32,
501    /// Start column
502    column: u32,
503    /// Hash of message for comparison
504    message_hash: u64,
505    /// Original message (for resolved diagnostics)
506    message: String,
507    /// Error code
508    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
531/// Apply baseline comparison to filter out known issues
532fn apply_baseline(
533    mut report: DiagnosticsReport,
534    baseline_path: &PathBuf,
535) -> Result<DiagnosticsReport> {
536    // Read baseline file
537    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    // Parse baseline (S6-R25 mitigation: validate on load)
546    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    // Check version compatibility
555    if baseline.version != 1 {
556        return Err(anyhow!(
557            "Unsupported baseline version: {}. Expected version 1.",
558            baseline.version
559        ));
560    }
561
562    // Convert current diagnostics to baseline format for comparison
563    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    // Find new diagnostics (in current but not in baseline)
570    let new_diagnostics: std::collections::HashSet<_> =
571        current_set.difference(&baseline_set).cloned().collect();
572
573    // Find resolved diagnostics (in baseline but not in current)
574    let resolved: Vec<_> = baseline_set.difference(&current_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    // Filter report to only new diagnostics
584    report.diagnostics.retain(|d| {
585        let bd: BaselineDiagnostic = d.into();
586        new_diagnostics.contains(&bd)
587    });
588
589    Ok(report)
590}
591
592/// Save current diagnostics as baseline file
593fn 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// =============================================================================
608// Tests
609// =============================================================================
610
611#[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(), // Same message
704            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}