code_ranker_plugin_api/report.rs
1//! Per-language overrides of the global report view/stat lists.
2//!
3//! The report's column order, card-featured metrics, and JSON `stats` keys come
4//! from the global metric catalog (`code-ranker-graph/metrics/builtin.toml`). A
5//! language may *patch* those inherited lists from its `<lang>.toml` `[report]`
6//! section — add a language-specific metric (e.g. Rust `unsafe`), drop some, swap
7//! one in place, or replace the list wholesale — without restating the whole
8//! catalog. [`ListPatch`] is the patch primitive; the parsing of the TOML
9//! `[report]` section into these types lives in `code-ranker-plugins`.
10
11/// A patch over an inherited ordered string list. Either a wholesale
12/// [`replace_all`](Self::replace_all) (a plain TOML array) or a set of in-place
13/// edits applied to the inherited base, in this order: `clear` → `remove` →
14/// `replace` → `after` / `before` → `prepend` → `add`. The result is
15/// de-duplicated, keeping the first occurrence (order-stable).
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct ListPatch {
18 /// Plain-array form: replace the inherited list outright (then dedup).
19 pub replace_all: Option<Vec<String>>,
20 /// Start from an empty list instead of the inherited base.
21 pub clear: bool,
22 /// Drop every element equal to one of these.
23 pub remove: Vec<String>,
24 /// Swap an element in place, preserving its position: `(old, new)`.
25 pub replace: Vec<(String, String)>,
26 /// Insert items immediately **after** an anchor element: `(anchor, items)`.
27 /// No-op if the anchor is absent.
28 pub after: Vec<(String, Vec<String>)>,
29 /// Insert items immediately **before** an anchor element: `(anchor, items)`.
30 pub before: Vec<(String, Vec<String>)>,
31 /// Insert at the front (before the inherited elements).
32 pub prepend: Vec<String>,
33 /// Append at the end.
34 pub add: Vec<String>,
35}
36
37impl ListPatch {
38 /// Apply the patch to `base`, returning the resulting order-stable, de-duped list.
39 pub fn apply(&self, base: &[String]) -> Vec<String> {
40 if let Some(all) = &self.replace_all {
41 return dedup(all.clone());
42 }
43 let mut out: Vec<String> = if self.clear {
44 Vec::new()
45 } else {
46 base.to_vec()
47 };
48 if !self.remove.is_empty() {
49 out.retain(|x| !self.remove.iter().any(|r| r == x));
50 }
51 for (old, new) in &self.replace {
52 if let Some(pos) = out.iter().position(|x| x == old) {
53 out[pos] = new.clone();
54 }
55 }
56 for (anchor, items) in &self.after {
57 if let Some(pos) = out.iter().position(|x| x == anchor) {
58 out.splice(pos + 1..pos + 1, items.iter().cloned());
59 }
60 }
61 for (anchor, items) in &self.before {
62 if let Some(pos) = out.iter().position(|x| x == anchor) {
63 out.splice(pos..pos, items.iter().cloned());
64 }
65 }
66 if !self.prepend.is_empty() {
67 let mut front = self.prepend.clone();
68 front.extend(out);
69 out = front;
70 }
71 out.extend(self.add.iter().cloned());
72 dedup(out)
73 }
74
75 /// True when the patch makes no change (no override declared).
76 pub fn is_noop(&self) -> bool {
77 self.replace_all.is_none()
78 && !self.clear
79 && self.remove.is_empty()
80 && self.replace.is_empty()
81 && self.after.is_empty()
82 && self.before.is_empty()
83 && self.prepend.is_empty()
84 && self.add.is_empty()
85 }
86}
87
88/// De-duplicate a list, keeping the first occurrence of each element (order-stable).
89fn dedup(list: Vec<String>) -> Vec<String> {
90 let mut seen = std::collections::HashSet::new();
91 list.into_iter()
92 .filter(|x| seen.insert(x.clone()))
93 .collect()
94}
95
96/// A language's overrides of the global report lists. Each field patches the
97/// inherited list from the metric catalog; an empty (no-op) patch leaves the
98/// global default untouched.
99#[derive(Debug, Clone, Default)]
100pub struct ReportOverride {
101 /// The node-table column order (`[report].columns`).
102 pub columns: ListPatch,
103 /// The card-featured metrics (`[report].card`).
104 pub card: ListPatch,
105 /// The JSON report's aggregate `stats` keys.
106 pub stats: ListPatch,
107 /// Metrics the SVG map offers as circle-size modes (`ui.size`), on top
108 /// of the built-in `loc` / `hk` modes.
109 pub size: ListPatch,
110 /// Metrics the SVG map offers as on/off node filters (`ui.filter`) —
111 /// keep only nodes where the metric has signal — alongside the built-in `cycle`.
112 pub filter: ListPatch,
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn v(xs: &[&str]) -> Vec<String> {
120 xs.iter().map(|s| s.to_string()).collect()
121 }
122
123 #[test]
124 fn apply_covers_every_op() {
125 let base = v(&["kind", "sloc", "hk", "volume", "effort"]);
126
127 // remove (one or many) + add (appended, de-duped against the base)
128 let p = ListPatch {
129 remove: v(&["volume", "effort"]),
130 add: v(&["unsafe", "hk"]), // hk already present → not duplicated
131 ..Default::default()
132 };
133 assert_eq!(p.apply(&base), v(&["kind", "sloc", "hk", "unsafe"]));
134
135 // replace in place (position preserved)
136 let p = ListPatch {
137 replace: vec![("sloc".into(), "lloc".into())],
138 ..Default::default()
139 };
140 assert_eq!(
141 p.apply(&base),
142 v(&["kind", "lloc", "hk", "volume", "effort"])
143 );
144
145 // clear + add = a fresh list
146 let p = ListPatch {
147 clear: true,
148 add: v(&["kind", "hk"]),
149 ..Default::default()
150 };
151 assert_eq!(p.apply(&base), v(&["kind", "hk"]));
152
153 // after / before insert relative to an anchor (position preserved)
154 let p = ListPatch {
155 after: vec![("hk".into(), v(&["tsr"]))],
156 ..Default::default()
157 };
158 assert_eq!(
159 p.apply(&base),
160 v(&["kind", "sloc", "hk", "tsr", "volume", "effort"])
161 );
162 let p = ListPatch {
163 before: vec![("hk".into(), v(&["tsr"]))],
164 ..Default::default()
165 };
166 assert_eq!(
167 p.apply(&base),
168 v(&["kind", "sloc", "tsr", "hk", "volume", "effort"])
169 );
170
171 // prepend goes to the front
172 let p = ListPatch {
173 prepend: v(&["unsafe"]),
174 ..Default::default()
175 };
176 assert_eq!(
177 p.apply(&base),
178 v(&["unsafe", "kind", "sloc", "hk", "volume", "effort"])
179 );
180
181 // replace_all wins outright (and de-dups)
182 let p = ListPatch {
183 replace_all: Some(v(&["a", "b", "a"])),
184 ..Default::default()
185 };
186 assert_eq!(p.apply(&base), v(&["a", "b"]));
187
188 // a no-op patch returns the base unchanged
189 assert!(ListPatch::default().is_noop());
190 assert_eq!(ListPatch::default().apply(&base), base);
191 }
192}