1use std::path::Path;
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12use crate::graph::store::GraphStore;
13use crate::graph::GraphQuery;
14use crate::security;
15
16#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default)]
23pub struct CheckConfig {
24 pub security: SecurityConfig,
25 pub complexity: ComplexityConfig,
26 pub dead_code: DeadCodeConfig,
27 pub vulnerabilities: VulnCheckConfig,
28}
29
30#[derive(Debug, Clone, Default, Deserialize)]
31#[serde(default)]
32pub struct VulnCheckConfig {
33 pub enabled: bool,
34 pub max_critical: usize,
35 pub max_high: usize,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39#[serde(default)]
40pub struct SecurityConfig {
41 pub enabled: bool,
42 pub max_critical: usize,
43 pub max_high: usize,
44}
45
46impl Default for SecurityConfig {
47 fn default() -> Self {
48 Self {
49 enabled: true,
50 max_critical: 0,
51 max_high: 0,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Deserialize)]
57#[serde(default)]
58pub struct ComplexityConfig {
59 pub enabled: bool,
60 pub threshold: u32,
61 pub max_violations: usize,
62}
63
64impl Default for ComplexityConfig {
65 fn default() -> Self {
66 Self {
67 enabled: true,
68 threshold: 15,
69 max_violations: 0,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Deserialize)]
75#[serde(default)]
76pub struct DeadCodeConfig {
77 pub enabled: bool,
78 pub max_dead: usize,
79 pub ignore_patterns: Vec<String>,
80}
81
82impl Default for DeadCodeConfig {
83 fn default() -> Self {
84 Self {
85 enabled: true,
86 max_dead: 50,
87 ignore_patterns: vec![
88 "main".into(),
89 "__init__".into(),
90 "setUp".into(),
91 "tearDown".into(),
92 "Java_*".into(),
93 "test_*".into(),
94 "Test*".into(),
95 ],
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105pub enum CheckStatus {
106 Pass,
107 Fail,
108}
109
110impl std::fmt::Display for CheckStatus {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 CheckStatus::Pass => write!(f, "PASS"),
114 CheckStatus::Fail => write!(f, "FAIL"),
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize)]
121pub struct CheckResult {
122 pub name: String,
123 pub status: CheckStatus,
124 pub summary: String,
125 #[serde(skip_serializing_if = "Vec::is_empty")]
127 pub details: Vec<String>,
128}
129
130pub fn load_config(config_path: &Path) -> Result<CheckConfig> {
137 if config_path.exists() {
138 let text = std::fs::read_to_string(config_path)?;
139 let cfg: CheckConfig = toml::from_str(&text)?;
140 Ok(cfg)
141 } else {
142 Ok(CheckConfig::default())
143 }
144}
145
146#[derive(Debug, Clone)]
152pub struct CheckSelection {
153 pub security: bool,
154 pub complexity: bool,
155 pub dead_code: bool,
156 pub vulnerabilities: bool,
157}
158
159impl CheckSelection {
160 pub fn all() -> Self {
162 Self {
163 security: true,
164 complexity: true,
165 dead_code: true,
166 vulnerabilities: true,
167 }
168 }
169
170 pub fn from_csv(s: &str) -> Self {
172 let mut sel = Self {
173 security: false,
174 complexity: false,
175 dead_code: false,
176 vulnerabilities: false,
177 };
178 for part in s.split(',') {
179 match part.trim().to_lowercase().as_str() {
180 "security" | "sec" => sel.security = true,
181 "complexity" | "cx" => sel.complexity = true,
182 "dead-code" | "dead_code" | "deadcode" => sel.dead_code = true,
183 "vulnerabilities" | "vulns" | "vuln" => sel.vulnerabilities = true,
184 _ => {}
185 }
186 }
187 sel
188 }
189}
190
191pub fn run_checks(
197 root: &Path,
198 config: &CheckConfig,
199 store: &GraphStore,
200 selection: &CheckSelection,
201) -> Vec<CheckResult> {
202 let mut results = Vec::new();
203
204 if selection.security && config.security.enabled {
205 results.push(run_security_check(root, &config.security));
206 }
207
208 let conn_result = store.connection();
210 let conn = match conn_result {
211 Ok(ref c) => Some(c),
212 Err(ref e) => {
213 if selection.complexity && config.complexity.enabled {
214 results.push(CheckResult {
215 name: "complexity".into(),
216 status: CheckStatus::Fail,
217 summary: format!("Graph connection failed: {e}"),
218 details: vec![],
219 });
220 }
221 if selection.dead_code && config.dead_code.enabled {
222 results.push(CheckResult {
223 name: "dead-code".into(),
224 status: CheckStatus::Fail,
225 summary: format!("Graph connection failed: {e}"),
226 details: vec![],
227 });
228 }
229 None
230 }
231 };
232
233 if let Some(conn) = conn {
234 let gq = GraphQuery::new(conn);
235
236 if selection.complexity && config.complexity.enabled {
237 results.push(run_complexity_check(&gq, &config.complexity));
238 }
239 if selection.dead_code && config.dead_code.enabled {
240 results.push(run_dead_code_check(&gq, &config.dead_code));
241 }
242
243 if selection.vulnerabilities && config.vulnerabilities.enabled {
244 results.push(run_vuln_check(store, &config.vulnerabilities));
245 }
246 }
247
248 results
249}
250
251fn run_security_check(root: &Path, cfg: &SecurityConfig) -> CheckResult {
252 let canonical = match root.canonicalize() {
253 Ok(p) => p,
254 Err(e) => {
255 return CheckResult {
256 name: "security".into(),
257 status: CheckStatus::Fail,
258 summary: format!("Failed to resolve project root: {e}"),
259 details: vec![],
260 };
261 }
262 };
263
264 let scan = match security::scan_project(&canonical) {
265 Ok(s) => s,
266 Err(e) => {
267 return CheckResult {
268 name: "security".into(),
269 status: CheckStatus::Fail,
270 summary: format!("Security scan failed: {e}"),
271 details: vec![],
272 };
273 }
274 };
275
276 let critical = scan.critical_count();
277 let high = scan.high_count();
278 let medium = scan.medium_count();
279 let low = scan.low_count();
280
281 let failed = critical > cfg.max_critical || high > cfg.max_high;
282
283 let mut details = Vec::new();
284 if failed {
285 for f in scan.findings.iter().take(20) {
286 if f.severity == security::Severity::Critical || f.severity == security::Severity::High
287 {
288 details.push(format!(
289 " [{sev}] {file}:{line} -- {msg}",
290 sev = f.severity,
291 file = f.file,
292 line = f.line,
293 msg = f.message,
294 ));
295 }
296 }
297 }
298
299 CheckResult {
300 name: "security".into(),
301 status: if failed {
302 CheckStatus::Fail
303 } else {
304 CheckStatus::Pass
305 },
306 summary: format!(
307 "{critical} critical, {high} high, {medium} medium, {low} low \
308 (max_critical={}, max_high={})",
309 cfg.max_critical, cfg.max_high,
310 ),
311 details,
312 }
313}
314
315fn run_complexity_check(gq: &GraphQuery, cfg: &ComplexityConfig) -> CheckResult {
316 let query = format!(
317 "MATCH (s:Symbol) WHERE s.complexity >= {} \
318 AND (s.kind = 'Function' OR s.kind = 'Method') \
319 RETURN s.name, s.file, s.complexity ORDER BY s.complexity DESC",
320 cfg.threshold,
321 );
322
323 let rows = match gq.raw_query(&query) {
324 Ok(r) => r,
325 Err(e) => {
326 return CheckResult {
327 name: "complexity".into(),
328 status: CheckStatus::Fail,
329 summary: format!("Query failed: {e}"),
330 details: vec![],
331 };
332 }
333 };
334
335 let count = rows.len();
336 let failed = count > cfg.max_violations;
337
338 let details: Vec<String> = if failed {
339 rows.iter()
340 .take(20)
341 .filter_map(|row| {
342 let name = row.first()?;
343 let file = row.get(1)?;
344 let cplx = row.get(2)?;
345 Some(format!(" [{cplx:>3}] {name} ({file})"))
346 })
347 .collect()
348 } else {
349 vec![]
350 };
351
352 CheckResult {
353 name: "complexity".into(),
354 status: if failed {
355 CheckStatus::Fail
356 } else {
357 CheckStatus::Pass
358 },
359 summary: format!(
360 "{count} symbols >= threshold {threshold} (max_violations={max})",
361 threshold = cfg.threshold,
362 max = cfg.max_violations,
363 ),
364 details,
365 }
366}
367
368fn run_dead_code_check(gq: &GraphQuery, cfg: &DeadCodeConfig) -> CheckResult {
369 let query = "MATCH (s:Symbol) WHERE s.kind IN ['Function', 'Method'] \
370 AND NOT EXISTS { MATCH ()-[:CALLS]->(s) } \
371 AND NOT EXISTS { MATCH (p:Symbol)<-[:INHERITS]-() WHERE p.file = s.file AND p.kind IN ['Class', 'Interface', 'Trait'] } \
372 RETURN s.name, s.kind, s.file ORDER BY s.file, s.name";
373
374 let rows = match gq.raw_query(query) {
375 Ok(r) => r,
376 Err(e) => {
377 return CheckResult {
378 name: "dead-code".into(),
379 status: CheckStatus::Fail,
380 summary: format!("Query failed: {e}"),
381 details: vec![],
382 };
383 }
384 };
385
386 let dead: Vec<&Vec<String>> = rows
388 .iter()
389 .filter(|row| {
390 let name = row.first().map(|s| s.as_str()).unwrap_or("");
391 !cfg.ignore_patterns.iter().any(|pat| {
392 if let Some(prefix) = pat.strip_suffix('*') {
393 name.starts_with(prefix)
394 } else {
395 name == pat
396 }
397 })
398 })
399 .collect();
400
401 let count = dead.len();
402 let failed = count > cfg.max_dead;
403
404 let details: Vec<String> = if failed {
405 dead.iter()
406 .take(20)
407 .filter_map(|row| {
408 let name = row.first()?;
409 let kind = row.get(1)?;
410 let file = row.get(2)?;
411 Some(format!(" {kind:>8} {name} ({file})"))
412 })
413 .collect()
414 } else {
415 vec![]
416 };
417
418 CheckResult {
419 name: "dead-code".into(),
420 status: if failed {
421 CheckStatus::Fail
422 } else {
423 CheckStatus::Pass
424 },
425 summary: format!("{count} dead symbols (max_dead={max})", max = cfg.max_dead),
426 details,
427 }
428}
429
430fn run_vuln_check(store: &GraphStore, cfg: &VulnCheckConfig) -> CheckResult {
431 let deps = match crate::manifest::query_deps(store) {
432 Ok(d) => d,
433 Err(e) => {
434 return CheckResult {
435 name: "vulns".into(),
436 status: CheckStatus::Pass,
437 summary: format!("failed to query deps: {e}"),
438 details: vec![],
439 };
440 }
441 };
442 if deps.is_empty() {
443 return CheckResult {
444 name: "vulns".into(),
445 status: CheckStatus::Pass,
446 summary: "no dependencies indexed (run infigraph index-manifests first)".into(),
447 details: vec![],
448 };
449 }
450
451 let report = match crate::vuln::scan_deps(&deps) {
452 Ok(r) => r,
453 Err(e) => {
454 return CheckResult {
455 name: "vulns".into(),
456 status: CheckStatus::Pass,
457 summary: format!("scan skipped: {e}"),
458 details: vec![],
459 };
460 }
461 };
462
463 let critical = report
464 .findings
465 .iter()
466 .filter(|f| f.severity == "CRITICAL")
467 .count();
468 let high = report
469 .findings
470 .iter()
471 .filter(|f| f.severity == "HIGH")
472 .count();
473 let medium = report
474 .findings
475 .iter()
476 .filter(|f| f.severity == "MEDIUM")
477 .count();
478 let low = report
479 .findings
480 .iter()
481 .filter(|f| f.severity == "LOW")
482 .count();
483
484 let failed = critical > cfg.max_critical || high > cfg.max_high;
485
486 let details: Vec<String> = if failed {
487 report
488 .findings
489 .iter()
490 .filter(|f| f.severity == "CRITICAL" || f.severity == "HIGH")
491 .take(20)
492 .map(|f| {
493 format!(
494 " [{}] {} {} -- {}",
495 f.severity, f.dep_name, f.dep_version, f.summary
496 )
497 })
498 .collect()
499 } else {
500 vec![]
501 };
502
503 CheckResult {
504 name: "vulns".into(),
505 status: if failed { CheckStatus::Fail } else { CheckStatus::Pass },
506 summary: format!(
507 "{critical} critical, {high} high, {medium} medium, {low} low (max_critical={}, max_high={})",
508 cfg.max_critical, cfg.max_high,
509 ),
510 details,
511 }
512}
513
514pub fn format_table(results: &[CheckResult]) -> String {
520 let mut out = String::new();
521
522 out.push_str("\n Check Status Summary\n");
523 out.push_str(" ------------- ------ -------\n");
524
525 for r in results {
526 let status_str = match r.status {
527 CheckStatus::Pass => "PASS",
528 CheckStatus::Fail => "FAIL",
529 };
530 out.push_str(&format!(
531 " {:<13} {:<8} {}\n",
532 r.name, status_str, r.summary
533 ));
534 }
535
536 let failures: Vec<_> = results
538 .iter()
539 .filter(|r| r.status == CheckStatus::Fail)
540 .collect();
541 if !failures.is_empty() {
542 out.push('\n');
543 for r in &failures {
544 if !r.details.is_empty() {
545 out.push_str(&format!(" {} details:\n", r.name));
546 for d in &r.details {
547 out.push_str(&format!("{d}\n"));
548 }
549 out.push('\n');
550 }
551 }
552 }
553
554 let total = results.len();
555 let passed = results
556 .iter()
557 .filter(|r| r.status == CheckStatus::Pass)
558 .count();
559 let failed_count = total - passed;
560
561 out.push_str(&format!("\n {passed}/{total} checks passed"));
562 if failed_count > 0 {
563 out.push_str(&format!(", {failed_count} failed"));
564 }
565 out.push('\n');
566
567 out
568}
569
570pub fn format_json(results: &[CheckResult]) -> String {
572 serde_json::to_string_pretty(results).unwrap_or_else(|_| "[]".to_string())
573}