code_ranker_plugin_api/list_override.rs
1//! The report list-override DSL: parse a `[report]` section into a
2//! [`ReportOverride`] of per-list [`ListPatch`]es, plus the generic op-table
3//! primitives [`is_list_op_table`] / [`patch_value_list`] reused by the TOML
4//! inheritance merge ([`crate::toml_merge::deep_merge`]).
5//!
6//! The report's table `columns`, card-featured metrics, and JSON `stats` keys are
7//! inherited from the global metric catalog. A config patches an inherited list
8//! rather than restating it: a plain array replaces it wholesale, while an
9//! **op-table** mutates it in place —
10//!
11//! ```toml
12//! [report]
13//! columns = { remove = ["volume", "effort"], add = ["unsafe"] }
14//! stats = { add = ["unsafe"] }
15//! card = { replace = { "sloc" = "unsafe" } }
16//! ```
17//!
18//! The op semantics (`clear` → `remove` → `replace` → `after`/`before` →
19//! `prepend` → `add`, then dedup) live in [`ListPatch::apply`]. The orchestrator
20//! applies the patch over the catalog list, then prunes to keys present.
21//!
22//! Lives in `code-ranker-plugin-api` (next to [`ReportOverride`]) so both the
23//! language plugins (`<lang>.toml` `[report]`) and the CLI (a project
24//! `code-ranker.toml` `[report]`, and its config inheritance merge) use it without
25//! reaching into a sibling crate.
26
27use crate::report::{ListPatch, ReportOverride};
28use toml::{Table, Value};
29
30/// The op-table keys that mark a `[table]` as a list patch rather than a value.
31const LIST_OP_KEYS: [&str; 7] = [
32 "add", "remove", "replace", "prepend", "clear", "after", "before",
33];
34
35/// True when `t` is a list-op table (carries at least one op key) — i.e. it
36/// patches an inherited list rather than replacing it with a value.
37pub fn is_list_op_table(t: &Table) -> bool {
38 LIST_OP_KEYS.iter().any(|k| t.contains_key(*k))
39}
40
41/// Apply an op-table `ov` to a string-list `base` (used by `deep_merge`). A
42/// non-string base can't be patched by value, so it is kept unchanged.
43pub fn patch_value_list(base: Vec<Value>, ov: &Value) -> Vec<Value> {
44 let strs: Option<Vec<String>> = base
45 .iter()
46 .map(|v| v.as_str().map(str::to_string))
47 .collect();
48 match strs {
49 Some(strs) => list_patch(ov)
50 .apply(&strs)
51 .into_iter()
52 .map(Value::String)
53 .collect(),
54 None => base,
55 }
56}
57
58/// Read the `[report]` section of a merged config table as a [`ReportOverride`]
59/// (used for a language's `<lang>.toml`, which nests it under `report`).
60pub fn report_override(cfg: &Table) -> ReportOverride {
61 cfg.get("report")
62 .and_then(Value::as_table)
63 .map(report_override_section)
64 .unwrap_or_default()
65}
66
67/// Read a bare `[report]` section table (its `columns` / `card` / `stats` keys)
68/// as a [`ReportOverride`]. Used for the project `code-ranker.toml`, where the
69/// section is parsed into a table directly.
70pub fn report_override_section(report: &Table) -> ReportOverride {
71 let patch = |key: &str| report.get(key).map(list_patch).unwrap_or_default();
72 ReportOverride {
73 columns: patch("columns"),
74 card: patch("card"),
75 stats: patch("stats"),
76 size: patch("size"),
77 filter: patch("filter"),
78 }
79}
80
81/// Extract a `Vec<String>` from a TOML array value (string elements only).
82fn value_strs(v: Option<&Value>) -> Vec<String> {
83 v.and_then(Value::as_array)
84 .map(|a| {
85 a.iter()
86 .filter_map(|x| x.as_str().map(str::to_string))
87 .collect()
88 })
89 .unwrap_or_default()
90}
91
92/// Parse a TOML value into a [`ListPatch`]: a plain array → `replace_all`; an
93/// op-table → the corresponding add/remove/replace/clear/prepend ops.
94fn list_patch(v: &Value) -> ListPatch {
95 match v {
96 Value::Array(a) => ListPatch {
97 replace_all: Some(
98 a.iter()
99 .filter_map(|x| x.as_str().map(str::to_string))
100 .collect(),
101 ),
102 ..Default::default()
103 },
104 Value::Table(t) => ListPatch {
105 replace_all: None,
106 clear: t.get("clear").and_then(Value::as_bool).unwrap_or(false),
107 remove: value_strs(t.get("remove")),
108 replace: t
109 .get("replace")
110 .and_then(Value::as_table)
111 .map(|rt| {
112 rt.iter()
113 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
114 .collect()
115 })
116 .unwrap_or_default(),
117 after: anchor_pairs(t.get("after")),
118 before: anchor_pairs(t.get("before")),
119 prepend: value_strs(t.get("prepend")),
120 add: value_strs(t.get("add")),
121 },
122 _ => ListPatch::default(),
123 }
124}
125
126/// Parse an anchor → items table (`{ hk = ["tsr", "tsr_big"] }`) for the
127/// `after` / `before` positional inserts.
128fn anchor_pairs(v: Option<&Value>) -> Vec<(String, Vec<String>)> {
129 v.and_then(Value::as_table)
130 .map(|t| {
131 t.iter()
132 .map(|(anchor, items)| (anchor.clone(), value_strs(Some(items))))
133 .collect()
134 })
135 .unwrap_or_default()
136}
137
138#[cfg(test)]
139#[path = "list_override_test.rs"]
140mod tests;