Skip to main content

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;