1use crate::emitter::{ReadMode, emit_filtered};
7use crate::model::*;
8
9#[derive(Debug, Clone)]
13pub struct MetricScore {
14 pub name: &'static str,
16 pub label: &'static str,
18 pub score: u8,
20 pub suggestion: String,
22}
23
24#[derive(Debug, Clone)]
26pub struct ScoreReport {
27 pub total: u8,
29 pub metrics: Vec<MetricScore>,
31}
32
33#[must_use]
37pub fn compute_score(graph: &SceneGraph) -> ScoreReport {
38 let metrics = vec![
39 metric_semantic_naming(graph),
40 metric_doc_comment_density(graph),
41 metric_style_reuse(graph),
42 metric_edge_default_coverage(graph),
43 metric_token_efficiency(graph),
44 ];
45 let total: u8 = metrics.iter().map(|m| m.score).sum::<u8>().min(100);
46 ScoreReport { total, metrics }
47}
48
49fn metric_semantic_naming(graph: &SceneGraph) -> MetricScore {
53 let mut total = 0u32;
54 let mut semantic = 0u32;
55
56 for idx in graph.graph.node_indices() {
57 let node = &graph.graph[idx];
58 if matches!(node.kind, NodeKind::Root) {
59 continue;
60 }
61 total += 1;
62 if !is_anonymous_id(node.id.as_str()) {
63 semantic += 1;
64 }
65 }
66
67 let ratio = if total == 0 {
68 1.0
69 } else {
70 semantic as f32 / total as f32
71 };
72 let score = (ratio * 20.0).round() as u8;
73
74 let suggestion = if score < 20 {
75 let anon_count = total - semantic;
76 format!(
77 "Rename {anon_count} anonymous node(s) to semantic names (e.g. @login_btn instead of @_rect_0)"
78 )
79 } else {
80 String::new()
81 };
82
83 MetricScore {
84 name: "semantic_naming",
85 label: "Semantic Naming",
86 score,
87 suggestion,
88 }
89}
90
91fn is_anonymous_id(id: &str) -> bool {
93 let prefixes = [
94 "_rect_",
95 "_ellipse_",
96 "_text_",
97 "_group_",
98 "_path_",
99 "_frame_",
100 "_generic_",
101 "_edge_",
102 "_image_",
103 ];
104 prefixes.iter().any(|p| id.starts_with(p))
105}
106
107fn metric_doc_comment_density(graph: &SceneGraph) -> MetricScore {
111 let mut total = 0u32;
112 let mut commented = 0u32;
113
114 for idx in graph.graph.node_indices() {
115 let node = &graph.graph[idx];
116 if matches!(node.kind, NodeKind::Root) {
117 continue;
118 }
119 total += 1;
120 if !node.comments.is_empty() || node.spec.is_some() {
121 commented += 1;
122 }
123 }
124
125 let ratio = if total == 0 {
126 1.0
127 } else {
128 commented as f32 / total as f32
129 };
130 let score = (ratio * 20.0).round() as u8;
131
132 let suggestion = if score < 20 {
133 let uncommented = total - commented;
134 format!("Add comments or notes to {uncommented} undocumented node(s)")
135 } else {
136 String::new()
137 };
138
139 MetricScore {
140 name: "doc_comment_density",
141 label: "Documentation",
142 score,
143 suggestion,
144 }
145}
146
147fn metric_style_reuse(graph: &SceneGraph) -> MetricScore {
151 let mut styled_total = 0u32;
152 let mut using_refs = 0u32;
153
154 for idx in graph.graph.node_indices() {
155 let node = &graph.graph[idx];
156 if matches!(node.kind, NodeKind::Root) {
157 continue;
158 }
159 let has_inline = has_inline_styles(&node.props);
160 let has_refs = !node.use_styles.is_empty();
161
162 if has_inline || has_refs {
163 styled_total += 1;
164 if has_refs {
165 using_refs += 1;
166 }
167 }
168 }
169
170 let ratio = if styled_total == 0 {
171 1.0 } else {
173 using_refs as f32 / styled_total as f32
174 };
175 let score = (ratio * 20.0).round() as u8;
176
177 let suggestion = if score < 20 && styled_total > 0 {
178 let inline_only = styled_total - using_refs;
179 format!(
180 "Extract inline styles from {inline_only} node(s) into reusable `style {{ }}` blocks"
181 )
182 } else {
183 String::new()
184 };
185
186 MetricScore {
187 name: "style_reuse",
188 label: "Style Reuse",
189 score,
190 suggestion,
191 }
192}
193
194fn has_inline_styles(props: &Properties) -> bool {
196 props.fill.is_some()
197 || props.stroke.is_some()
198 || props.font.is_some()
199 || props.corner_radius.is_some()
200 || props.opacity.is_some()
201 || props.shadow.is_some()
202}
203
204fn metric_edge_default_coverage(graph: &SceneGraph) -> MetricScore {
208 let total_edges = graph.edges.len() as u32;
209
210 if total_edges == 0 {
211 return MetricScore {
212 name: "edge_default_coverage",
213 label: "Edge Defaults",
214 score: 20,
215 suggestion: String::new(),
216 };
217 }
218
219 let defaults = match &graph.edge_defaults {
221 Some(d) => d,
222 None => {
223 return MetricScore {
224 name: "edge_default_coverage",
225 label: "Edge Defaults",
226 score: 0,
227 suggestion: format!(
228 "Add an `edge_defaults {{ }}` block to reduce repetition across {total_edges} edge(s)"
229 ),
230 };
231 }
232 };
233
234 let mut matching = 0u32;
235 for edge in &graph.edges {
236 if edge_matches_defaults(edge, defaults) {
237 matching += 1;
238 }
239 }
240
241 let ratio = matching as f32 / total_edges as f32;
242 let score = (ratio * 20.0).round() as u8;
243
244 let suggestion = if score < 20 {
245 let non_matching = total_edges - matching;
246 format!(
247 "{non_matching} edge(s) override defaults — consider updating `edge_defaults` to cover common patterns"
248 )
249 } else {
250 String::new()
251 };
252
253 MetricScore {
254 name: "edge_default_coverage",
255 label: "Edge Defaults",
256 score,
257 suggestion,
258 }
259}
260
261fn edge_matches_defaults(edge: &Edge, defaults: &EdgeDefaults) -> bool {
267 let stroke_ok = match &edge.props.stroke {
269 Some(s) if is_parser_default_stroke(s) => true, Some(es) => match &defaults.props.stroke {
271 Some(ds) => stroke_approx_eq(es, ds),
272 None => false, },
274 None => true, };
276
277 let arrow_ok = match defaults.arrow {
279 Some(da) => edge.arrow == da || edge.arrow == ArrowKind::None,
280 None => true,
281 };
282
283 let curve_ok = match defaults.curve {
285 Some(dc) => edge.curve == dc || edge.curve == CurveKind::Straight,
286 None => true,
287 };
288
289 stroke_ok && arrow_ok && curve_ok
290}
291
292fn is_parser_default_stroke(stroke: &Stroke) -> bool {
295 if let Paint::Solid(c) = &stroke.paint {
296 (c.r - 0.42).abs() < 0.01
297 && (c.g - 0.44).abs() < 0.01
298 && (c.b - 0.5).abs() < 0.01
299 && (stroke.width - 1.5).abs() < 0.01
300 } else {
301 false
302 }
303}
304
305fn stroke_approx_eq(a: &Stroke, b: &Stroke) -> bool {
307 (a.width - b.width).abs() < 0.001 && a.paint == b.paint
308}
309
310fn metric_token_efficiency(graph: &SceneGraph) -> MetricScore {
315 let full = emit_filtered(graph, ReadMode::Full);
316 let structure = emit_filtered(graph, ReadMode::Structure);
317
318 let full_tokens = estimate_tokens(&full);
319 let structure_tokens = estimate_tokens(&structure);
320
321 if full_tokens == 0 {
322 return MetricScore {
323 name: "token_efficiency",
324 label: "Token Efficiency",
325 score: 20,
326 suggestion: String::new(),
327 };
328 }
329
330 let ratio = structure_tokens as f32 / full_tokens as f32;
334
335 let score = if ratio <= 0.5 {
338 20
339 } else {
340 let penalty = ((ratio - 0.5) / 0.5).min(1.0);
341 ((1.0 - penalty) * 20.0).round() as u8
342 };
343
344 let suggestion = if score < 20 {
345 "Add more styling, dimensions, and annotations to enrich the document beyond bare structure"
346 .to_string()
347 } else {
348 String::new()
349 };
350
351 MetricScore {
352 name: "token_efficiency",
353 label: "Token Efficiency",
354 score,
355 suggestion,
356 }
357}
358
359fn estimate_tokens(text: &str) -> usize {
361 let char_count = text.len();
363 char_count.div_ceil(4)
364}
365
366#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::parser::parse_document;
372
373 #[test]
374 fn score_empty_document() {
375 let graph = SceneGraph::new();
376 let report = compute_score(&graph);
377 assert_eq!(
378 report.total, 100,
379 "empty document should have perfect score"
380 );
381 }
382
383 #[test]
384 fn score_perfect_document() {
385 let input = r#"
386style card {
387 fill: #FFF
388 corner: 8
389}
390
391edge_defaults {
392 stroke: #333 2
393 arrow: end
394}
395
396# Main card component
397rect @hero_card {
398 w: 300 h: 200
399 use: card
400 note "Primary hero element"
401}
402
403# Secondary element
404text @subtitle "Welcome" {
405 use: card
406}
407
408edge @flow_1 {
409 from: @hero_card
410 to: @subtitle
411}
412"#;
413 let graph = parse_document(input).unwrap();
414 let report = compute_score(&graph);
415 assert!(
417 report.total >= 80,
418 "well-structured document should score >= 80, got {}",
419 report.total
420 );
421 }
422
423 #[test]
424 fn score_anonymous_ids() {
425 let input = "rect { w: 100 h: 50 }\nellipse { w: 80 h: 80 }\n";
427 let graph = parse_document(input).unwrap();
428 let report = compute_score(&graph);
429 let naming = report
430 .metrics
431 .iter()
432 .find(|m| m.name == "semantic_naming")
433 .unwrap();
434 assert_eq!(
435 naming.score, 0,
436 "anonymous-only IDs should score 0 on naming"
437 );
438 assert!(!naming.suggestion.is_empty());
439 }
440
441 #[test]
442 fn score_no_style_reuse() {
443 let input = r#"
444rect @btn {
445 w: 100 h: 50
446 fill: #FF0000
447 corner: 8
448}
449rect @card {
450 w: 200 h: 100
451 fill: #00FF00
452 corner: 12
453}
454"#;
455 let graph = parse_document(input).unwrap();
456 let report = compute_score(&graph);
457 let reuse = report
458 .metrics
459 .iter()
460 .find(|m| m.name == "style_reuse")
461 .unwrap();
462 assert_eq!(reuse.score, 0, "inline-only styles should score 0 on reuse");
463 }
464
465 #[test]
466 fn score_edge_defaults_coverage() {
467 let input = r#"
468edge_defaults {
469 stroke: #333 2
470 arrow: end
471}
472
473rect @a { w: 50 h: 50 }
474rect @b { w: 50 h: 50 }
475
476edge @e1 {
477 from: @a
478 to: @b
479}
480
481edge @e2 {
482 from: @a
483 to: @b
484 stroke: #FF0000 4
485}
486"#;
487 let graph = parse_document(input).unwrap();
488 let report = compute_score(&graph);
489 let coverage = report
490 .metrics
491 .iter()
492 .find(|m| m.name == "edge_default_coverage")
493 .unwrap();
494 assert_eq!(
496 coverage.score, 10,
497 "half matching edges should score 10, got {}",
498 coverage.score
499 );
500 }
501
502 #[test]
503 fn score_mixed_document() {
504 let input = r#"
505style primary {
506 fill: #007AFF
507}
508
509# Header section
510rect @header {
511 w: 800 h: 60
512 use: primary
513 note "Main navigation bar"
514}
515
516rect { w: 100 h: 50 fill: #FF0000 }
517"#;
518 let graph = parse_document(input).unwrap();
519 let report = compute_score(&graph);
520 assert!(
522 report.total > 20 && report.total < 90,
523 "mixed document should score moderately, got {}",
524 report.total
525 );
526 }
527}