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            // hygiene-and-crash-fixes-v1 (BUG-AGG12-6): emit a valid empty
182            // JSON/SARIF document on stdout BEFORE the stderr message so JSON
183            // consumers don't choke on a 0-byte stdout. The advisory message
184            // remains on stderr (so humans see it), and we exit 0 because
185            // "no diagnostic tools installed" is an absence-of-tooling
186            // condition, not a runtime failure of the analysis itself.
187            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                    // GitHub Actions output is an annotation stream; an empty
195                    // stream is the correct representation of "no findings".
196                    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                        // Emit the empty DiagnosticsReport via the writer so
206                        // downstream JSON consumers get a well-formed object
207                        // that matches the schema of a real diagnostics run.
208                        writer.write(&empty_report)?;
209                    }
210                }
211            }
212
213            // Advisory on stderr (S6-R36 mitigation kept).
214            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            // Preserve exit code 60 so existing skip-on-no-tools test gates
226            // (e.g. high_bundle_progress_determinism_coverage_v1) continue to
227            // distinguish "no tools" from a real diagnostics run that found
228            // nothing. The new behavior — valid JSON on stdout — is purely
229            // additive: JSON consumers no longer choke on 0-byte stdout.
230            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        // 5. Run tools in parallel
239        let mut report = run_tools_parallel(&tools, &self.path, self.timeout)?;
240
241        // Check if all tools failed (exit code 61)
242        //
243        // hygiene-and-crash-fixes-v1 (BUG-AGG12-6, exhaustive iteration per
244        // no-synthetic-fixtures-v1 §5): the same 0-byte-stdout class affects
245        // the all-tools-failed path too. Emit the partial report (with the
246        // tools_run errors populated) on stdout so JSON consumers can still
247        // parse it, then surface the advisory on stderr. Exit 0 because the
248        // tool-execution failures are described in-band in the JSON.
249        if !report.tools_run.is_empty() && report.tools_run.iter().all(|t| !t.success) {
250            // Recompute summary so the empty-diagnostics array is internally
251            // consistent with summary.total == 0.
252            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            // Preserve exit code 61 (S6-R36) so callers can still discriminate
277            // tool-failure from clean/dirty runs. Stdout now carries the JSON.
278            std::process::exit(61);
279        }
280
281        // 6. Deduplicate diagnostics
282        report.diagnostics = dedupe_diagnostics(report.diagnostics);
283
284        // 7. Filter by severity
285        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        // 8. Filter by ignored codes
290        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        // 9. Apply baseline comparison (Phase 12)
301        if let Some(baseline_path) = &self.baseline {
302            report = apply_baseline(report, baseline_path)?;
303        }
304
305        // 10. Recompute summary after filtering (S6-R28 mitigation)
306        report.summary = compute_summary(&report.diagnostics);
307
308        // 11. Save baseline if requested
309        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        // 12. Calculate filtered count for display (S6-R47 mitigation)
315        let filtered_count = unfiltered_count - report.diagnostics.len();
316
317        // 13. Output based on format
318        match self.output {
319            Some(DiagnosticOutput::Sarif) => {
320                let sarif = to_sarif(&report);
321                // Warn if SARIF exceeds 10MB estimate (S6-R56 mitigation)
322                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        // 14. Compute exit code (S6-R36 mitigation: distinct codes)
345        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// =============================================================================
355// Phase 11: SARIF Output Format
356// =============================================================================
357
358/// SARIF 2.1.0 output structure
359#[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
432/// Convert DiagnosticsReport to SARIF 2.1.0 format
433fn to_sarif(report: &DiagnosticsReport) -> SarifReport {
434    let results: Vec<SarifResult> = report
435        .diagnostics
436        .iter()
437        .map(|d| {
438            // Map severity to SARIF level (S6-R35 mitigation)
439            let level = match d.severity {
440                Severity::Error => "error",
441                Severity::Warning => "warning",
442                Severity::Information => "note",
443                Severity::Hint => "note",
444            };
445
446            // Use relative path for URI (S6-R23 mitigation)
447            let uri = d.file.display().to_string();
448            let relative_uri = if uri.starts_with('/') {
449                // Strip absolute path prefix - try common prefixes
450                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
496// =============================================================================
497// Phase 11: GitHub Actions Output Format
498// =============================================================================
499
500/// Output diagnostics as GitHub Actions workflow commands
501fn output_github_actions(report: &DiagnosticsReport, max_annotations: usize) {
502    // Warn if exceeding annotation limit (S6-R55 mitigation)
503    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        // GitHub Actions format: ::severity file=path,line=N,col=M::message
522        // Escape message for GH Actions (newlines become %0A)
523        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    // Output summary as a group
540    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// =============================================================================
549// Phase 12: Baseline Comparison
550// =============================================================================
551
552/// Baseline file structure for JSON serialization
553#[derive(Debug, serde::Serialize, serde::Deserialize)]
554struct BaselineFile {
555    version: u32,
556    created_at: String,
557    diagnostics: Vec<BaselineDiagnostic>,
558}
559
560/// Simplified diagnostic for baseline storage
561#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
562struct BaselineDiagnostic {
563    /// Relative file path
564    file: String,
565    /// Start line
566    line: u32,
567    /// Start column
568    column: u32,
569    /// Hash of message for comparison
570    message_hash: u64,
571    /// Original message (for resolved diagnostics)
572    message: String,
573    /// Error code
574    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
597/// Apply baseline comparison to filter out known issues
598fn apply_baseline(
599    mut report: DiagnosticsReport,
600    baseline_path: &PathBuf,
601) -> Result<DiagnosticsReport> {
602    // Read baseline file
603    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    // Parse baseline (S6-R25 mitigation: validate on load)
612    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    // Check version compatibility
621    if baseline.version != 1 {
622        return Err(anyhow!(
623            "Unsupported baseline version: {}. Expected version 1.",
624            baseline.version
625        ));
626    }
627
628    // Convert current diagnostics to baseline format for comparison
629    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    // Find new diagnostics (in current but not in baseline)
636    let new_diagnostics: std::collections::HashSet<_> =
637        current_set.difference(&baseline_set).cloned().collect();
638
639    // Find resolved diagnostics (in baseline but not in current)
640    let resolved: Vec<_> = baseline_set.difference(&current_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    // Filter report to only new diagnostics
650    report.diagnostics.retain(|d| {
651        let bd: BaselineDiagnostic = d.into();
652        new_diagnostics.contains(&bd)
653    });
654
655    Ok(report)
656}
657
658/// Save current diagnostics as baseline file
659fn 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// =============================================================================
674// Tests
675// =============================================================================
676
677#[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(), // Same message
770            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}