Skip to main content

routa_scanner/
lib.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ScanReport {
10    pub generated_at: DateTime<Utc>,
11    pub project_dir: String,
12    pub strict: bool,
13    pub scans: Vec<ScanResult>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ScanResult {
18    pub id: String,
19    pub category: ScanCategory,
20    pub status: ScanStatus,
21    pub command: String,
22    pub duration_ms: u128,
23    pub stdout: String,
24    pub stderr: String,
25    pub exit_code: Option<i32>,
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ScanCategory {
31    Typescript,
32    Rust,
33    Docker,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum ScanStatus {
39    Passed,
40    Failed,
41    Skipped,
42}
43
44#[derive(Debug, Clone)]
45pub struct ScanConfig {
46    pub project_dir: PathBuf,
47    pub strict: bool,
48}
49
50impl Default for ScanConfig {
51    fn default() -> Self {
52        Self {
53            project_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
54            strict: false,
55        }
56    }
57}
58
59#[derive(Debug, Clone)]
60struct ToolSpec {
61    id: &'static str,
62    category: ScanCategory,
63    program: &'static str,
64    args: &'static [&'static str],
65}
66
67pub fn run_scans(config: &ScanConfig) -> ScanReport {
68    let specs = [
69        ToolSpec {
70            id: "typescript-eslint",
71            category: ScanCategory::Typescript,
72            program: "npm",
73            args: &["run", "lint"],
74        },
75        ToolSpec {
76            id: "typescript-typecheck",
77            category: ScanCategory::Typescript,
78            program: "npx",
79            args: &["tsc", "--noEmit"],
80        },
81        ToolSpec {
82            id: "rust-clippy",
83            category: ScanCategory::Rust,
84            program: "cargo",
85            args: &[
86                "clippy",
87                "--workspace",
88                "--all-targets",
89                "--",
90                "-D",
91                "warnings",
92            ],
93        },
94        ToolSpec {
95            id: "rust-audit",
96            category: ScanCategory::Rust,
97            program: "cargo",
98            args: &["audit"],
99        },
100        ToolSpec {
101            id: "docker-trivy-config",
102            category: ScanCategory::Docker,
103            program: "trivy",
104            args: &["config", ".", "--severity", "HIGH,CRITICAL"],
105        },
106    ];
107
108    let scans = specs
109        .iter()
110        .map(|spec| run_tool(spec, config.project_dir.as_path()))
111        .collect();
112
113    ScanReport {
114        generated_at: Utc::now(),
115        project_dir: config.project_dir.to_string_lossy().to_string(),
116        strict: config.strict,
117        scans,
118    }
119}
120
121fn run_tool(spec: &ToolSpec, project_dir: &Path) -> ScanResult {
122    if which::which(spec.program).is_err() {
123        return ScanResult {
124            id: spec.id.to_string(),
125            category: spec.category,
126            status: ScanStatus::Skipped,
127            command: format!("{} {}", spec.program, spec.args.join(" ")),
128            duration_ms: 0,
129            stdout: String::new(),
130            stderr: format!("Command not found: {}", spec.program),
131            exit_code: None,
132        };
133    }
134
135    let start = std::time::Instant::now();
136    let output = Command::new(spec.program)
137        .args(spec.args)
138        .current_dir(project_dir)
139        .output();
140
141    match output {
142        Ok(output) => {
143            let status = if output.status.success() {
144                ScanStatus::Passed
145            } else {
146                ScanStatus::Failed
147            };
148
149            ScanResult {
150                id: spec.id.to_string(),
151                category: spec.category,
152                status,
153                command: format!("{} {}", spec.program, spec.args.join(" ")),
154                duration_ms: start.elapsed().as_millis(),
155                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
156                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
157                exit_code: output.status.code(),
158            }
159        }
160        Err(err) => ScanResult {
161            id: spec.id.to_string(),
162            category: spec.category,
163            status: ScanStatus::Failed,
164            command: format!("{} {}", spec.program, spec.args.join(" ")),
165            duration_ms: start.elapsed().as_millis(),
166            stdout: String::new(),
167            stderr: err.to_string(),
168            exit_code: None,
169        },
170    }
171}
172
173pub fn write_report(report: &ScanReport, output_dir: &Path) -> io::Result<(PathBuf, PathBuf)> {
174    fs::create_dir_all(output_dir)?;
175
176    let json_path = output_dir.join("scan-report.json");
177    let json = serde_json::to_vec_pretty(report)
178        .map_err(|err| io::Error::other(format!("serialize report failed: {err}")))?;
179    fs::write(&json_path, json)?;
180
181    let md_path = output_dir.join("scan-report.md");
182    fs::write(&md_path, render_markdown(report))?;
183
184    Ok((json_path, md_path))
185}
186
187pub fn has_failures(report: &ScanReport) -> bool {
188    report
189        .scans
190        .iter()
191        .any(|result| result.status == ScanStatus::Failed)
192}
193
194pub fn has_strict_failures(report: &ScanReport) -> bool {
195    report
196        .scans
197        .iter()
198        .any(|result| result.status != ScanStatus::Passed)
199}
200
201fn render_markdown(report: &ScanReport) -> String {
202    let mut out = String::new();
203    out.push_str("# Routa Scan Report\n\n");
204    out.push_str(&format!(
205        "- Generated at: {}\n",
206        report.generated_at.to_rfc3339()
207    ));
208    out.push_str(&format!("- Project dir: `{}`\n\n", report.project_dir));
209    out.push_str("| Tool | Category | Status | Duration (ms) | Exit Code |\n");
210    out.push_str("| --- | --- | --- | ---: | ---: |\n");
211
212    for scan in &report.scans {
213        let exit = scan
214            .exit_code
215            .map(|code| code.to_string())
216            .unwrap_or_else(|| "-".to_string());
217
218        out.push_str(&format!(
219            "| `{}` | `{:?}` | `{:?}` | {} | {} |\n",
220            scan.id, scan.category, scan.status, scan.duration_ms, exit
221        ));
222    }
223
224    out.push_str("\n## Details\n\n");
225    for scan in &report.scans {
226        out.push_str(&format!("### {}\n\n", scan.id));
227        out.push_str(&format!("- Command: `{}`\n", scan.command));
228        out.push_str(&format!("- Status: `{:?}`\n\n", scan.status));
229
230        if !scan.stdout.trim().is_empty() {
231            out.push_str("<details><summary>stdout</summary>\n\n```text\n");
232            out.push_str(&scan.stdout);
233            out.push_str("\n```\n\n</details>\n\n");
234        }
235
236        if !scan.stderr.trim().is_empty() {
237            out.push_str("<details><summary>stderr</summary>\n\n```text\n");
238            out.push_str(&scan.stderr);
239            out.push_str("\n```\n\n</details>\n\n");
240        }
241    }
242
243    out
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn detects_failed_status() {
252        let report = ScanReport {
253            generated_at: Utc::now(),
254            project_dir: ".".to_string(),
255            strict: false,
256            scans: vec![ScanResult {
257                id: "test".to_string(),
258                category: ScanCategory::Rust,
259                status: ScanStatus::Failed,
260                command: "cargo clippy".to_string(),
261                duration_ms: 1,
262                stdout: String::new(),
263                stderr: String::new(),
264                exit_code: Some(1),
265            }],
266        };
267
268        assert!(has_failures(&report));
269    }
270
271    #[test]
272    fn strict_mode_treats_skipped_as_failure() {
273        let report = ScanReport {
274            generated_at: Utc::now(),
275            project_dir: ".".to_string(),
276            strict: true,
277            scans: vec![ScanResult {
278                id: "test".to_string(),
279                category: ScanCategory::Docker,
280                status: ScanStatus::Skipped,
281                command: "trivy config .".to_string(),
282                duration_ms: 0,
283                stdout: String::new(),
284                stderr: "Command not found: trivy".to_string(),
285                exit_code: None,
286            }],
287        };
288
289        assert!(has_strict_failures(&report));
290    }
291}