1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[non_exhaustive]
20pub struct AnalyzeResult {
21 pub bindings: Vec<Binding>,
23 pub entries: Vec<EvalEntry>,
25 #[serde(default)]
27 pub groups: Vec<Group>,
28 pub total: u32,
30 #[serde(default)]
32 pub excluded: u32,
33}
34
35impl AnalyzeResult {
36 pub fn new(bindings: Vec<Binding>, entries: Vec<EvalEntry>, total: u32) -> Self {
38 Self {
39 bindings,
40 entries,
41 groups: Vec::new(),
42 total,
43 excluded: 0,
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[non_exhaustive]
55pub struct Binding {
56 pub index: String,
58 pub percept: String,
60}
61
62impl Binding {
63 pub fn new(index: String, percept: String) -> Self {
65 Self { index, percept }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[non_exhaustive]
76pub struct EvalEntry {
77 pub kind: String,
80 pub name: String,
82 pub full_name: String,
84 pub file: String,
86 pub start_line: u32,
88 pub end_line: u32,
90 pub lines: u32,
92 #[serde(default)]
94 pub exported: bool,
95 #[serde(default)]
97 pub visibility: String,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub params: Option<u32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub cyclomatic: Option<u32>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub depth: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub field_count: Option<u32>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub git_churn_30d: Option<u32>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub coverage: Option<f64>,
118
119 pub normalized: PerceptValues,
122 pub percept: PerceptValues,
124}
125
126impl EvalEntry {
127 #[allow(clippy::too_many_arguments)]
131 pub fn new(
132 kind: String,
133 name: String,
134 full_name: String,
135 file: String,
136 start_line: u32,
137 end_line: u32,
138 lines: u32,
139 normalized: PerceptValues,
140 percept: PerceptValues,
141 ) -> Self {
142 Self {
143 kind,
144 name,
145 full_name,
146 file,
147 start_line,
148 end_line,
149 lines,
150 exported: false,
151 visibility: String::new(),
152 params: None,
153 cyclomatic: None,
154 depth: None,
155 field_count: None,
156 git_churn_30d: None,
157 coverage: None,
158 normalized,
159 percept,
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[non_exhaustive]
172pub struct PerceptValues {
173 #[serde(default)]
175 pub hue: f64,
176 #[serde(default)]
178 pub size: f64,
179 #[serde(default)]
181 pub border: f64,
182 #[serde(default)]
184 pub opacity: f64,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub clarity: Option<f64>,
188}
189
190impl PerceptValues {
191 pub fn new(hue: f64, size: f64, border: f64, opacity: f64) -> Self {
193 Self {
194 hue,
195 size,
196 border,
197 opacity,
198 clarity: None,
199 }
200 }
201
202 pub fn with_clarity(hue: f64, size: f64, border: f64, opacity: f64, clarity: f64) -> Self {
204 Self {
205 hue,
206 size,
207 border,
208 opacity,
209 clarity: Some(clarity),
210 }
211 }
212}
213
214impl Default for PerceptValues {
215 fn default() -> Self {
216 Self::new(0.0, 0.0, 0.0, 0.0)
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225#[non_exhaustive]
226pub struct Group {
227 pub name: String,
229 pub count: u32,
231 pub pct: f64,
233}
234
235impl Group {
236 pub fn new(name: String, count: u32, pct: f64) -> Self {
238 Self { name, count, pct }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn analyze_result_roundtrip() {
248 let result = AnalyzeResult {
249 bindings: vec![
250 Binding::new("cyclomatic".into(), "hue".into()),
251 Binding::new("lines".into(), "size".into()),
252 ],
253 entries: vec![EvalEntry {
254 kind: "function".into(),
255 name: "main".into(),
256 full_name: "src/main::main".into(),
257 file: "src/main".into(),
258 start_line: 1,
259 end_line: 10,
260 lines: 10,
261 exported: false,
262 visibility: "private".into(),
263 params: Some(0),
264 cyclomatic: Some(3),
265 depth: Some(2),
266 field_count: None,
267 git_churn_30d: Some(5),
268 coverage: None,
269 normalized: PerceptValues::new(0.5, 0.3, 0.1, 0.2),
270 percept: PerceptValues::new(60.0, 0.8, 0.5, 0.7),
271 }],
272 groups: vec![Group::new("src".into(), 10, 50.0)],
273 total: 20,
274 excluded: 0,
275 };
276
277 let json = serde_json::to_string(&result).unwrap();
278 let parsed: AnalyzeResult = serde_json::from_str(&json).unwrap();
279
280 assert_eq!(parsed.bindings.len(), 2);
281 assert_eq!(parsed.bindings[0].index, "cyclomatic");
282 assert_eq!(parsed.entries.len(), 1);
283 assert_eq!(parsed.entries[0].name, "main");
284 assert_eq!(parsed.entries[0].cyclomatic, Some(3));
285 assert_eq!(parsed.groups.len(), 1);
286 assert_eq!(parsed.total, 20);
287 }
288
289 #[test]
290 fn deserialize_actual_codedash_output_entry() {
291 let json = r#"{
293 "bindings": [
294 {"index": "cyclomatic", "percept": "hue"},
295 {"index": "lines", "percept": "size"},
296 {"index": "params", "percept": "border"},
297 {"index": "depth", "percept": "opacity"},
298 {"index": "coverage", "percept": "clarity"}
299 ],
300 "entries": [{
301 "cyclomatic": 1,
302 "depth": 1,
303 "end_line": 11,
304 "exported": false,
305 "field_count": 0,
306 "file": "codedash-schemas/examples/generate_schema",
307 "full_name": "codedash-schemas/examples/generate_schema::main",
308 "git_churn_30d": 2,
309 "kind": "function",
310 "lines": 5,
311 "name": "main",
312 "normalized": {"border": 0.346, "hue": 0.0, "opacity": 0.232, "size": 0.071},
313 "params": 0,
314 "percept": {"border": 1.038, "hue": 120.0, "opacity": 0.790, "size": 0.542},
315 "start_line": 7,
316 "visibility": "private"
317 }],
318 "groups": [],
319 "total": 437,
320 "excluded": 0
321 }"#;
322
323 let parsed: AnalyzeResult = serde_json::from_str(json).unwrap();
324
325 assert_eq!(parsed.bindings.len(), 5);
326 assert_eq!(parsed.total, 437);
327 assert_eq!(parsed.entries.len(), 1);
328
329 let entry = &parsed.entries[0];
330 assert_eq!(entry.kind, "function");
331 assert_eq!(entry.name, "main");
332 assert_eq!(entry.cyclomatic, Some(1));
333 assert_eq!(entry.params, Some(0));
334 assert_eq!(entry.field_count, Some(0));
335 assert_eq!(entry.normalized.hue, 0.0);
336 assert!((entry.percept.hue - 120.0).abs() < f64::EPSILON);
337 assert!(entry.normalized.clarity.is_none());
338 }
339
340 #[test]
341 fn deserialize_with_missing_optional_fields() {
342 let json = r#"{
343 "bindings": [],
344 "entries": [{
345 "kind": "struct",
346 "name": "Foo",
347 "full_name": "src/lib::Foo",
348 "file": "src/lib",
349 "start_line": 1,
350 "end_line": 5,
351 "lines": 5,
352 "normalized": {"hue": 0.0, "size": 0.0, "border": 0.0, "opacity": 0.0},
353 "percept": {"hue": 0.0, "size": 0.0, "border": 0.0, "opacity": 0.0}
354 }],
355 "total": 1
356 }"#;
357
358 let parsed: AnalyzeResult = serde_json::from_str(json).unwrap();
359 let entry = &parsed.entries[0];
360
361 assert!(!entry.exported);
362 assert!(entry.visibility.is_empty());
363 assert!(entry.params.is_none());
364 assert!(entry.cyclomatic.is_none());
365 assert!(entry.coverage.is_none());
366 assert_eq!(parsed.excluded, 0);
367 assert!(parsed.groups.is_empty());
368 }
369
370 #[test]
371 fn percept_values_with_clarity() {
372 let pv = PerceptValues::with_clarity(120.0, 0.5, 1.0, 0.8, 0.9);
373 let json = serde_json::to_string(&pv).unwrap();
374 let parsed: PerceptValues = serde_json::from_str(&json).unwrap();
375
376 assert!((parsed.hue - 120.0).abs() < f64::EPSILON);
377 assert_eq!(parsed.clarity, Some(0.9));
378 }
379
380 #[test]
381 fn percept_values_without_clarity_omits_field() {
382 let pv = PerceptValues::new(0.5, 0.3, 0.1, 0.2);
383 let json = serde_json::to_value(&pv).unwrap();
384
385 assert!(json.get("clarity").is_none());
386 assert!(json.get("hue").is_some());
387 }
388
389 #[test]
390 fn group_serialization() {
391 let group = Group::new("domain".into(), 42, 33.5);
392 let json = serde_json::to_value(&group).unwrap();
393
394 assert_eq!(json["name"], "domain");
395 assert_eq!(json["count"], 42);
396 assert!((json["pct"].as_f64().unwrap() - 33.5).abs() < f64::EPSILON);
397 }
398
399 #[test]
400 fn binding_eq() {
401 let a = Binding::new("cyclomatic".into(), "hue".into());
402 let b = Binding::new("cyclomatic".into(), "hue".into());
403 let c = Binding::new("lines".into(), "size".into());
404 assert_eq!(a, b);
405 assert_ne!(a, c);
406 }
407
408 #[test]
409 fn constructors_produce_correct_defaults() {
410 let result = AnalyzeResult::new(vec![], vec![], 0);
411 assert!(result.groups.is_empty());
412 assert_eq!(result.excluded, 0);
413
414 let entry = EvalEntry::new(
415 "function".into(),
416 "f".into(),
417 "mod::f".into(),
418 "mod".into(),
419 1,
420 5,
421 5,
422 PerceptValues::default(),
423 PerceptValues::default(),
424 );
425 assert!(!entry.exported);
426 assert!(entry.visibility.is_empty());
427 assert!(entry.params.is_none());
428 assert!(entry.coverage.is_none());
429 }
430}
431
432#[cfg(all(test, feature = "schema"))]
433mod schema_snapshot {
434 use super::*;
435
436 #[test]
437 fn analyze_result_json_schema() {
438 let schema = schemars::schema_for!(AnalyzeResult);
439 insta::assert_json_snapshot!("analyze-result-schema", schema);
440 }
441}
442
443#[cfg(test)]
444mod proptests {
445 use super::*;
446 use proptest::prelude::*;
447
448 fn arb_binding() -> impl Strategy<Value = Binding> {
449 (
450 prop_oneof!["cyclomatic", "lines", "params", "depth", "coverage"],
451 prop_oneof!["hue", "size", "border", "opacity", "clarity"],
452 )
453 .prop_map(|(index, percept)| Binding { index, percept })
454 }
455
456 fn arb_percept_values() -> impl Strategy<Value = PerceptValues> {
459 (0i32..360, 0i32..100, 0i32..100, 0i32..100).prop_map(|(hue, size, border, opacity)| {
460 PerceptValues {
461 hue: f64::from(hue),
462 size: f64::from(size) / 100.0,
463 border: f64::from(border) / 100.0,
464 opacity: f64::from(opacity) / 100.0,
465 clarity: None,
466 }
467 })
468 }
469
470 fn arb_eval_entry() -> impl Strategy<Value = EvalEntry> {
471 (
472 "[a-z]{1,8}",
473 "[a-z]{1,8}",
474 "[a-z/]{1,15}::[a-z]{1,8}",
475 "[a-z/]{1,15}",
476 1u32..10000,
477 1u32..500,
478 any::<bool>(),
479 arb_percept_values(),
480 arb_percept_values(),
481 )
482 .prop_map(
483 |(kind, name, full_name, file, start, delta, exported, normalized, percept)| {
484 let end = start + delta;
485 let lines = delta + 1;
486 EvalEntry {
487 kind,
488 name,
489 full_name,
490 file,
491 start_line: start,
492 end_line: end,
493 lines,
494 exported,
495 visibility: "private".into(),
496 params: None,
497 cyclomatic: None,
498 depth: None,
499 field_count: None,
500 git_churn_30d: None,
501 coverage: None,
502 normalized,
503 percept,
504 }
505 },
506 )
507 }
508
509 fn arb_group() -> impl Strategy<Value = Group> {
510 ("[a-z]{1,8}", 0u32..1000, 0u32..1000).prop_map(|(name, count, pct_raw)| Group {
511 name,
512 count,
513 pct: f64::from(pct_raw) / 10.0,
514 })
515 }
516
517 fn arb_analyze_result() -> impl Strategy<Value = AnalyzeResult> {
518 (
519 proptest::collection::vec(arb_binding(), 0..6),
520 proptest::collection::vec(arb_eval_entry(), 0..4),
521 proptest::collection::vec(arb_group(), 0..4),
522 0u32..1000,
523 0u32..100,
524 )
525 .prop_map(
526 |(bindings, entries, groups, total, excluded)| AnalyzeResult {
527 bindings,
528 entries,
529 groups,
530 total,
531 excluded,
532 },
533 )
534 }
535
536 proptest! {
537 #[test]
538 fn analyze_result_serde_roundtrip(data in arb_analyze_result()) {
539 let json = serde_json::to_string(&data).unwrap();
540 let parsed: AnalyzeResult = serde_json::from_str(&json).unwrap();
541 prop_assert_eq!(data, parsed);
542 }
543
544 #[test]
545 fn eval_entry_serde_roundtrip(entry in arb_eval_entry()) {
546 let json = serde_json::to_string(&entry).unwrap();
547 let parsed: EvalEntry = serde_json::from_str(&json).unwrap();
548 prop_assert_eq!(entry, parsed);
549 }
550
551 #[test]
552 fn percept_values_serde_roundtrip(pv in arb_percept_values()) {
553 let json = serde_json::to_string(&pv).unwrap();
554 let parsed: PerceptValues = serde_json::from_str(&json).unwrap();
555 prop_assert_eq!(pv, parsed);
556 }
557 }
558}