use anyhow::{anyhow, Result};
use clap::Args;
use std::path::PathBuf;
use tldr_core::diagnostics::{
compute_exit_code, compute_summary, dedupe_diagnostics, detect_available_tools,
filter_diagnostics_by_severity, run_tools_parallel, tools_for_language, DiagnosticsReport,
Severity, ToolConfig,
};
use tldr_core::Language;
use crate::output::{format_diagnostics_text, OutputFormat, OutputWriter};
#[derive(Debug, Args)]
pub struct DiagnosticsArgs {
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(long, short = 'l')]
pub lang: Option<Language>,
#[arg(long, value_delimiter = ',')]
pub tools: Vec<String>,
#[arg(long)]
pub no_typecheck: bool,
#[arg(long)]
pub no_lint: bool,
#[arg(long, short = 's', value_enum, default_value = "hint")]
pub severity: SeverityFilter,
#[arg(long, value_delimiter = ',')]
pub ignore: Vec<String>,
#[arg(long, value_enum)]
pub output: Option<DiagnosticOutput>,
#[arg(long)]
pub project: bool,
#[arg(long, default_value = "50")]
pub max_annotations: usize,
#[arg(long, default_value = "60")]
pub timeout: u64,
#[arg(long)]
pub strict: bool,
#[arg(long)]
pub baseline: Option<PathBuf>,
#[arg(long)]
pub save_baseline: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
pub enum SeverityFilter {
Error,
Warning,
Info,
#[default]
Hint,
}
impl From<SeverityFilter> for Severity {
fn from(filter: SeverityFilter) -> Self {
match filter {
SeverityFilter::Error => Severity::Error,
SeverityFilter::Warning => Severity::Warning,
SeverityFilter::Info => Severity::Information,
SeverityFilter::Hint => Severity::Hint,
}
}
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum DiagnosticOutput {
Sarif,
GithubActions,
}
impl DiagnosticsArgs {
pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
let writer = OutputWriter::new(format, quiet);
let language = self.lang.unwrap_or_else(|| {
if self.path.is_file() {
Language::from_path(&self.path).unwrap_or(Language::Python)
} else {
Language::from_directory(&self.path).unwrap_or(Language::Python)
}
});
writer.progress(&format!("Detecting tools for {:?}...", language));
let mut tools: Vec<ToolConfig> = if self.tools.is_empty() {
detect_available_tools(language)
} else {
tools_for_language(language)
.into_iter()
.filter(|t| {
self.tools
.iter()
.any(|name| t.name.eq_ignore_ascii_case(name))
})
.collect()
};
if self.no_typecheck {
tools.retain(|t| !t.is_type_checker);
}
if self.no_lint {
tools.retain(|t| !t.is_linter);
}
if tools.is_empty() {
eprintln!(
"Error: No diagnostic tools available for {:?}. Install one of:",
language
);
for tool in tools_for_language(language) {
eprintln!(
" - {} ({})",
tool.name,
tldr_core::diagnostics::get_install_suggestion(tool.name)
);
}
std::process::exit(60);
}
writer.progress(&format!(
"Running diagnostics: {}",
tools.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
));
let mut report = run_tools_parallel(&tools, &self.path, self.timeout)?;
if report.tools_run.iter().all(|t| !t.success) {
eprintln!("Error: All diagnostic tools failed to run.");
for result in &report.tools_run {
if let Some(err) = &result.error {
eprintln!(" - {}: {}", result.name, err);
}
}
std::process::exit(61);
}
report.diagnostics = dedupe_diagnostics(report.diagnostics);
let min_severity: Severity = self.severity.into();
let unfiltered_count = report.diagnostics.len();
report.diagnostics = filter_diagnostics_by_severity(&report.diagnostics, min_severity);
if !self.ignore.is_empty() {
report.diagnostics.retain(|d| {
if let Some(code) = &d.code {
!self.ignore.iter().any(|ignored| code == ignored)
} else {
true
}
});
}
if let Some(baseline_path) = &self.baseline {
report = apply_baseline(report, baseline_path)?;
}
report.summary = compute_summary(&report.diagnostics);
if let Some(save_path) = &self.save_baseline {
save_baseline(&report, save_path)?;
writer.progress(&format!("Baseline saved to: {}", save_path.display()));
}
let filtered_count = unfiltered_count - report.diagnostics.len();
match self.output {
Some(DiagnosticOutput::Sarif) => {
let sarif = to_sarif(&report);
let estimated_size = serde_json::to_string(&sarif).map(|s| s.len()).unwrap_or(0);
if estimated_size > 10 * 1024 * 1024 {
eprintln!(
"Warning: SARIF output is large (~{}MB). GitHub may reject files over 10MB.",
estimated_size / (1024 * 1024)
);
}
println!("{}", serde_json::to_string_pretty(&sarif)?);
}
Some(DiagnosticOutput::GithubActions) => {
output_github_actions(&report, self.max_annotations);
}
None => {
if writer.is_text() {
let text = format_diagnostics_text(&report, filtered_count);
writer.write_text(&text)?;
} else {
writer.write(&report)?;
}
}
}
let exit_code = compute_exit_code(&report.summary, self.strict);
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
}
#[derive(Debug, serde::Serialize)]
struct SarifReport {
#[serde(rename = "$schema")]
schema: &'static str,
version: &'static str,
runs: Vec<SarifRun>,
}
#[derive(Debug, serde::Serialize)]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifDriver {
name: String,
version: String,
information_uri: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult {
rule_id: String,
level: String,
message: SarifMessage,
locations: Vec<SarifLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
help_uri: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct SarifMessage {
text: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifLocation {
physical_location: SarifPhysicalLocation,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifPhysicalLocation {
artifact_location: SarifArtifactLocation,
region: SarifRegion,
}
#[derive(Debug, serde::Serialize)]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifRegion {
start_line: u32,
start_column: u32,
#[serde(skip_serializing_if = "Option::is_none")]
end_line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
end_column: Option<u32>,
}
fn to_sarif(report: &DiagnosticsReport) -> SarifReport {
let results: Vec<SarifResult> = report
.diagnostics
.iter()
.map(|d| {
let level = match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Information => "note",
Severity::Hint => "note",
};
let uri = d.file.display().to_string();
let relative_uri = if uri.starts_with('/') {
uri.trim_start_matches('/')
.split_once('/')
.map(|(_, rest)| rest.to_string())
.unwrap_or(uri)
} else {
uri
};
SarifResult {
rule_id: d.code.clone().unwrap_or_else(|| d.source.clone()),
level: level.to_string(),
message: SarifMessage {
text: d.message.clone(),
},
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation { uri: relative_uri },
region: SarifRegion {
start_line: d.line,
start_column: d.column,
end_line: d.end_line,
end_column: d.end_column,
},
},
}],
help_uri: d.url.clone(),
}
})
.collect();
SarifReport {
schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
version: "2.1.0",
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: "tldr-diagnostics".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
information_uri: "https://github.com/user/tldr".to_string(),
},
},
results,
}],
}
}
fn output_github_actions(report: &DiagnosticsReport, max_annotations: usize) {
if report.diagnostics.len() > max_annotations {
eprintln!(
"Warning: {} diagnostics found, but GitHub Actions limits annotations to {}. \
Only first {} will be shown. Use --max-annotations to adjust.",
report.diagnostics.len(),
max_annotations,
max_annotations
);
}
for diag in report.diagnostics.iter().take(max_annotations) {
let severity = match diag.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Information => "notice",
Severity::Hint => "notice",
};
let escaped_message = diag
.message
.replace('\n', "%0A")
.replace('\r', "%0D")
.replace('%', "%25");
println!(
"::{} file={},line={},col={}::{}",
severity,
diag.file.display(),
diag.line,
diag.column,
escaped_message
);
}
println!("::group::Diagnostics Summary");
println!(
"Errors: {}, Warnings: {}, Info: {}, Hints: {}",
report.summary.errors, report.summary.warnings, report.summary.info, report.summary.hints
);
println!("::endgroup::");
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct BaselineFile {
version: u32,
created_at: String,
diagnostics: Vec<BaselineDiagnostic>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash)]
struct BaselineDiagnostic {
file: String,
line: u32,
column: u32,
message_hash: u64,
message: String,
code: Option<String>,
}
impl From<&tldr_core::diagnostics::Diagnostic> for BaselineDiagnostic {
fn from(d: &tldr_core::diagnostics::Diagnostic) -> Self {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
d.message.hash(&mut hasher);
let message_hash = hasher.finish();
BaselineDiagnostic {
file: d.file.display().to_string(),
line: d.line,
column: d.column,
message_hash,
message: d.message.clone(),
code: d.code.clone(),
}
}
}
fn apply_baseline(
mut report: DiagnosticsReport,
baseline_path: &PathBuf,
) -> Result<DiagnosticsReport> {
let baseline_content = std::fs::read_to_string(baseline_path).map_err(|e| {
anyhow!(
"Failed to read baseline file '{}': {}",
baseline_path.display(),
e
)
})?;
let baseline: BaselineFile = serde_json::from_str(&baseline_content).map_err(|e| {
anyhow!(
"Invalid baseline JSON in '{}': {}",
baseline_path.display(),
e
)
})?;
if baseline.version != 1 {
return Err(anyhow!(
"Unsupported baseline version: {}. Expected version 1.",
baseline.version
));
}
let current_set: std::collections::HashSet<BaselineDiagnostic> =
report.diagnostics.iter().map(|d| d.into()).collect();
let baseline_set: std::collections::HashSet<BaselineDiagnostic> =
baseline.diagnostics.into_iter().collect();
let new_diagnostics: std::collections::HashSet<_> =
current_set.difference(&baseline_set).cloned().collect();
let resolved: Vec<_> = baseline_set.difference(¤t_set).collect();
if !resolved.is_empty() {
eprintln!(
"Info: {} issues from baseline have been resolved.",
resolved.len()
);
}
report.diagnostics.retain(|d| {
let bd: BaselineDiagnostic = d.into();
new_diagnostics.contains(&bd)
});
Ok(report)
}
fn save_baseline(report: &DiagnosticsReport, path: &PathBuf) -> Result<()> {
let baseline = BaselineFile {
version: 1,
created_at: chrono::Utc::now().to_rfc3339(),
diagnostics: report.diagnostics.iter().map(|d| d.into()).collect(),
};
let json = serde_json::to_string_pretty(&baseline)?;
std::fs::write(path, json)
.map_err(|e| anyhow!("Failed to write baseline file '{}': {}", path.display(), e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_filter_conversion() {
assert_eq!(Severity::from(SeverityFilter::Error), Severity::Error);
assert_eq!(Severity::from(SeverityFilter::Warning), Severity::Warning);
assert_eq!(Severity::from(SeverityFilter::Info), Severity::Information);
assert_eq!(Severity::from(SeverityFilter::Hint), Severity::Hint);
}
#[test]
fn test_args_default_values() {
use clap::Parser;
#[derive(Debug, Parser)]
struct TestCli {
#[command(flatten)]
args: DiagnosticsArgs,
}
let cli = TestCli::try_parse_from(["test"]).unwrap();
assert_eq!(cli.args.path, PathBuf::from("."));
assert!(!cli.args.no_typecheck);
assert!(!cli.args.no_lint);
assert!(!cli.args.strict);
assert_eq!(cli.args.timeout, 60);
assert!(matches!(cli.args.severity, SeverityFilter::Hint));
}
#[test]
fn test_sarif_severity_mapping() {
use tldr_core::diagnostics::Diagnostic;
let diag = Diagnostic {
file: PathBuf::from("test.py"),
line: 1,
column: 1,
end_line: None,
end_column: None,
severity: Severity::Error,
message: "test error".to_string(),
code: Some("E001".to_string()),
source: "test".to_string(),
url: None,
};
let report = DiagnosticsReport {
diagnostics: vec![diag],
summary: tldr_core::diagnostics::DiagnosticsSummary {
errors: 1,
warnings: 0,
info: 0,
hints: 0,
total: 1,
},
tools_run: vec![],
files_analyzed: 1,
};
let sarif = to_sarif(&report);
assert_eq!(sarif.version, "2.1.0");
assert_eq!(sarif.runs.len(), 1);
assert_eq!(sarif.runs[0].results.len(), 1);
assert_eq!(sarif.runs[0].results[0].level, "error");
}
#[test]
fn test_baseline_diagnostic_hash() {
use tldr_core::diagnostics::Diagnostic;
let diag1 = Diagnostic {
file: PathBuf::from("test.py"),
line: 10,
column: 5,
end_line: None,
end_column: None,
severity: Severity::Warning,
message: "test warning".to_string(),
code: Some("W001".to_string()),
source: "test".to_string(),
url: None,
};
let diag2 = Diagnostic {
file: PathBuf::from("test.py"),
line: 10,
column: 5,
end_line: None,
end_column: None,
severity: Severity::Warning,
message: "test warning".to_string(), code: Some("W001".to_string()),
source: "test".to_string(),
url: None,
};
let bd1: BaselineDiagnostic = (&diag1).into();
let bd2: BaselineDiagnostic = (&diag2).into();
assert_eq!(bd1, bd2);
assert_eq!(bd1.message_hash, bd2.message_hash);
}
}