1use std::path::Path;
2
3use anyhow::Result;
4use serde::{Deserialize, Serialize};
5
6use crate::graph::store::GraphStore;
7use crate::graph::GraphQuery;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct QualityMetrics {
12 pub timestamp: u64,
13 pub symbols: usize,
14 pub modules: usize,
15 pub calls_edges: usize,
16 pub inherits_edges: usize,
17 pub dead_code_count: usize,
18 pub security_critical: usize,
19 pub security_high: usize,
20 pub security_medium: usize,
21 pub security_low: usize,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct QualityBaseline {
27 pub metrics: QualityMetrics,
28 pub project_path: String,
29}
30
31impl QualityMetrics {
32 pub fn capture(root: &Path, store: &GraphStore) -> Result<Self> {
34 let conn = store.connection()?;
35 let gq = GraphQuery::new(&conn);
36
37 let symbols = count_query(&gq, "MATCH (s:Symbol) RETURN count(s)");
38 let modules = count_query(&gq, "MATCH (m:Module) RETURN count(m)");
39 let calls_edges = count_query(&gq, "MATCH ()-[r:CALLS]->() RETURN count(r)");
40 let inherits_edges = count_query(&gq, "MATCH ()-[r:INHERITS]->() RETURN count(r)");
41
42 let dead_rows = gq
43 .raw_query(
44 "MATCH (s:Symbol) WHERE s.kind IN ['Function', 'Method'] \
45 AND NOT EXISTS { MATCH ()-[:CALLS]->(s) } RETURN count(s)",
46 )
47 .unwrap_or_default();
48 let dead_code_count = dead_rows
49 .first()
50 .and_then(|r| r.first())
51 .and_then(|v| v.parse::<usize>().ok())
52 .unwrap_or(0);
53
54 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
55 let (critical, high, medium, low) = match crate::security::scan_project(&canonical) {
56 Ok(scan) => (
57 scan.critical_count(),
58 scan.high_count(),
59 scan.medium_count(),
60 scan.low_count(),
61 ),
62 Err(_) => (0, 0, 0, 0),
63 };
64
65 let timestamp = std::time::SystemTime::now()
66 .duration_since(std::time::UNIX_EPOCH)
67 .unwrap_or_default()
68 .as_secs();
69
70 Ok(Self {
71 timestamp,
72 symbols,
73 modules,
74 calls_edges,
75 inherits_edges,
76 dead_code_count,
77 security_critical: critical,
78 security_high: high,
79 security_medium: medium,
80 security_low: low,
81 })
82 }
83
84 pub fn format(&self) -> String {
85 let mut out = String::new();
86 out.push_str("\n Metric Value\n");
87 out.push_str(" ------------------ --------\n");
88 out.push_str(&format!(" symbols {}\n", self.symbols));
89 out.push_str(&format!(" modules {}\n", self.modules));
90 out.push_str(&format!(" calls_edges {}\n", self.calls_edges));
91 out.push_str(&format!(" inherits_edges {}\n", self.inherits_edges));
92 out.push_str(&format!(" dead_code {}\n", self.dead_code_count));
93 out.push_str(&format!(
94 " security_critical {}\n",
95 self.security_critical
96 ));
97 out.push_str(&format!(" security_high {}\n", self.security_high));
98 out.push_str(&format!(" security_medium {}\n", self.security_medium));
99 out.push_str(&format!(" security_low {}\n", self.security_low));
100 out
101 }
102}
103
104fn count_query(gq: &GraphQuery, cypher: &str) -> usize {
105 gq.raw_query(cypher)
106 .ok()
107 .and_then(|rows| rows.first().cloned())
108 .and_then(|row| row.first().cloned())
109 .and_then(|v| v.parse::<usize>().ok())
110 .unwrap_or(0)
111}
112
113pub fn load_baseline(root: &Path) -> Option<QualityBaseline> {
114 let path = root.join(".infigraph").join("quality_baseline.json");
115 let content = std::fs::read_to_string(&path).ok()?;
116 serde_json::from_str(&content).ok()
117}
118
119pub fn save_baseline(root: &Path, metrics: &QualityMetrics) -> Result<()> {
120 let baseline = QualityBaseline {
121 metrics: metrics.clone(),
122 project_path: root.to_string_lossy().to_string(),
123 };
124 let path = root.join(".infigraph").join("quality_baseline.json");
125 std::fs::create_dir_all(path.parent().unwrap())?;
126 let json = serde_json::to_string_pretty(&baseline)?;
127 std::fs::write(&path, json)?;
128 Ok(())
129}
130
131pub struct ComparisonResult {
132 pub metric: String,
133 pub baseline: String,
134 pub current: String,
135 pub change: String,
136 pub regression: bool,
137}
138
139pub fn compare(baseline: &QualityMetrics, current: &QualityMetrics) -> Vec<ComparisonResult> {
140 vec![
141 compare_metric("symbols", baseline.symbols, current.symbols, false),
142 compare_metric("modules", baseline.modules, current.modules, false),
143 compare_metric(
144 "calls_edges",
145 baseline.calls_edges,
146 current.calls_edges,
147 false,
148 ),
149 compare_metric(
150 "inherits_edges",
151 baseline.inherits_edges,
152 current.inherits_edges,
153 false,
154 ),
155 compare_metric(
156 "dead_code",
157 baseline.dead_code_count,
158 current.dead_code_count,
159 true,
160 ),
161 compare_metric(
162 "security_critical",
163 baseline.security_critical,
164 current.security_critical,
165 true,
166 ),
167 compare_metric(
168 "security_high",
169 baseline.security_high,
170 current.security_high,
171 true,
172 ),
173 compare_metric(
174 "security_medium",
175 baseline.security_medium,
176 current.security_medium,
177 true,
178 ),
179 compare_metric(
180 "security_low",
181 baseline.security_low,
182 current.security_low,
183 true,
184 ),
185 ]
186}
187
188fn compare_metric(
189 name: &str,
190 baseline: usize,
191 current: usize,
192 higher_is_worse: bool,
193) -> ComparisonResult {
194 let change = if baseline == 0 {
195 if current == 0 {
196 "same".to_string()
197 } else {
198 format!("+{current}")
199 }
200 } else {
201 let pct = ((current as f64 - baseline as f64) / baseline as f64 * 100.0) as i64;
202 if pct == 0 {
203 "same".to_string()
204 } else if pct > 0 {
205 format!("+{pct}%")
206 } else {
207 format!("{pct}%")
208 }
209 };
210
211 let regression = if higher_is_worse {
212 current > baseline && (current as f64 > baseline as f64 * 1.02)
213 } else {
214 current < baseline && ((current as f64) < baseline as f64 * 0.98)
215 };
216
217 ComparisonResult {
218 metric: name.to_string(),
219 baseline: baseline.to_string(),
220 current: current.to_string(),
221 change,
222 regression,
223 }
224}
225
226pub fn format_comparison(results: &[ComparisonResult]) -> String {
227 let mut out = String::new();
228 out.push_str("\n Metric Baseline Current Change\n");
229 out.push_str(" ------------------ -------- ------- ------\n");
230 for r in results {
231 let flag = if r.regression { " REGRESSION" } else { "" };
232 out.push_str(&format!(
233 " {:<18} {:>8} {:>7} {}{}\n",
234 r.metric, r.baseline, r.current, r.change, flag
235 ));
236 }
237 out
238}