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}