Skip to main content

whycodes_config/
dotted.rs

1//! Dotted keys over the config tree (`session.tool_profile`,
2//! `agents.build.model`, `mcp_servers."my.server".command`).
3//!
4//! Read side: [`lookup`], [`leaves`], [`render`] work on the serialized
5//! [`crate::Config`] (`toml::Value`), and [`redact`] hides secret values.
6//! Write side: [`set_in_document`] changes one key of a TOML document and
7//! keeps every other key, comment and blank line as it was.
8//!
9//! A segment that meets an array picks an element: a number is an index,
10//! anything else the table whose `name` equals it (`agents.build`).
11
12use toml::Value;
13use toml_edit::{DocumentMut, Item, TableLike};
14use whycodes_core::{Error, Result};
15
16/// Printed in place of a secret value.
17pub const REDACTED: &str = "<redacted>";
18
19/// Last segments that hold a secret, matched whole or as a `_<word>` suffix
20/// (`telegram_bot_token`, `discord_webhook`).
21const SECRET_WORDS: [&str; 8] = [
22    "api_key",
23    "apikey",
24    "token",
25    "secret",
26    "password",
27    "webhook",
28    "webhook_url",
29    "authorization",
30];
31
32/// Split a dotted key into segments (TOML dotted-key syntax, so a segment
33/// with a dot in it is quoted: `models."gpt-4.1"`).
34pub fn parse_key(key: &str) -> Result<Vec<String>> {
35    let keys = toml_edit::Key::parse(key)
36        .map_err(|e| Error::Config(format!("invalid config key '{key}': {e}")))?;
37    Ok(keys.iter().map(|k| k.get().to_string()).collect())
38}
39
40/// The dotted form of `path`, quoting segments that are not bare keys.
41pub fn format_key(path: &[String]) -> String {
42    let segs: Vec<String> = path
43        .iter()
44        .map(|s| toml_edit::Key::new(s.as_str()).display_repr().into_owned())
45        .collect();
46    segs.join(".")
47}
48
49/// The value at `path`, if any.
50pub fn lookup<'a>(root: &'a Value, path: &[String]) -> Option<&'a Value> {
51    path.iter().try_fold(root, |node, seg| child(node, seg))
52}
53
54fn child<'a>(node: &'a Value, seg: &str) -> Option<&'a Value> {
55    match node {
56        Value::Table(table) => table.get(seg),
57        Value::Array(items) => match seg.parse::<usize>().ok() {
58            Some(index) => items.get(index),
59            None => items.iter().find(|item| element_name(item) == Some(seg)),
60        },
61        _ => None,
62    }
63}
64
65fn element_name(item: &Value) -> Option<&str> {
66    item.get("name").and_then(Value::as_str)
67}
68
69/// Every leaf under `root` with its path, sorted by path. Tables recurse,
70/// and so do arrays of tables (each element by its `name`, else its
71/// index); scalars, other arrays and empty tables are leaves.
72pub fn leaves(root: &Value) -> Vec<(Vec<String>, Value)> {
73    let mut out = Vec::new();
74    collect_leaves(root, &mut Vec::new(), &mut out);
75    out.sort_by(|a, b| a.0.cmp(&b.0));
76    out
77}
78
79fn collect_leaves(node: &Value, path: &mut Vec<String>, out: &mut Vec<(Vec<String>, Value)>) {
80    let children: Vec<(String, &Value)> = match node {
81        Value::Table(table) => table.iter().map(|(k, v)| (k.clone(), v)).collect(),
82        Value::Array(items) if items.iter().all(Value::is_table) => items
83            .iter()
84            .enumerate()
85            .map(|(i, item)| {
86                (
87                    element_name(item).map_or(i.to_string(), str::to_string),
88                    item,
89                )
90            })
91            .collect(),
92        _ => Vec::new(),
93    };
94    if children.is_empty() {
95        out.push((path.clone(), node.clone()));
96        return;
97    }
98    for (seg, item) in children {
99        path.push(seg);
100        collect_leaves(item, path, out);
101        path.pop();
102    }
103}
104
105/// True when the key at `path` holds a secret: the last segment is (or ends
106/// in `_` plus) one of api_key, apikey, token, secret, password, webhook,
107/// webhook_url, authorization; or it is `key` under `providers`; or it sits
108/// directly in a `headers` table.
109pub fn is_secret_key(path: &[String]) -> bool {
110    let Some(last) = path.last() else {
111        return false;
112    };
113    let last = last.to_ascii_lowercase();
114    let in_headers = path
115        .iter()
116        .rev()
117        .nth(1)
118        .is_some_and(|parent| parent.eq_ignore_ascii_case("headers"));
119    let provider_key = last == "key" && path.first().is_some_and(|f| f == "providers");
120    in_headers
121        || provider_key
122        || SECRET_WORDS
123            .iter()
124            .any(|word| last == *word || last.ends_with(&format!("_{word}")))
125}
126
127/// A whole-string `${NAME}` reference: printed as-is, it names a variable
128/// and holds no secret.
129fn is_env_ref(s: &str) -> bool {
130    s.strip_prefix("${")
131        .and_then(|rest| rest.strip_suffix('}'))
132        .is_some_and(|name| !name.is_empty() && !name.contains(['$', '{', '}']))
133}
134
135/// `value`, found at `path`, with every secret string under it replaced by
136/// [`REDACTED`]. `${NAME}` references are kept.
137pub fn redact(value: &Value, path: &[String]) -> Value {
138    redact_under(value, &mut path.to_vec(), false)
139}
140
141fn redact_under(value: &Value, path: &mut Vec<String>, inherited: bool) -> Value {
142    let secret = inherited || is_secret_key(path);
143    match value {
144        Value::String(s) if secret && !is_env_ref(s) => Value::String(REDACTED.to_string()),
145        Value::Table(table) => Value::Table(
146            table
147                .iter()
148                .map(|(k, v)| {
149                    path.push(k.clone());
150                    let out = redact_under(v, path, secret);
151                    path.pop();
152                    (k.clone(), out)
153                })
154                .collect(),
155        ),
156        Value::Array(items) => Value::Array(
157            items
158                .iter()
159                .map(|item| redact_under(item, path, secret))
160                .collect(),
161        ),
162        other => other.clone(),
163    }
164}
165
166/// A value for people: strings bare, tables as a TOML document, the rest
167/// as a TOML literal.
168pub fn render(value: &Value) -> String {
169    match value {
170        Value::String(s) => s.clone(),
171        Value::Table(table) => table.to_string(),
172        other => other.to_string(),
173    }
174}
175
176/// Set `path` in `doc` to `value`. Missing tables are created; a segment
177/// after an array of tables picks the element by index or `name`. The
178/// replaced value keeps its old spacing and trailing comment.
179pub(crate) fn set_in_document(
180    doc: &mut DocumentMut,
181    path: &[String],
182    mut value: toml_edit::Value,
183) -> Result<()> {
184    let (last, parents) = path
185        .split_last()
186        .ok_or_else(|| Error::Config("empty config key".to_string()))?;
187    let mut table: &mut dyn TableLike = doc.as_table_mut();
188    let mut inline = false;
189    let mut segs = parents.iter();
190    while let Some(seg) = segs.next() {
191        let item = table.entry(seg).or_insert(new_table(inline));
192        inline = item.is_inline_table();
193        table = match item {
194            Item::ArrayOfTables(array) => {
195                let pick = segs.next().ok_or_else(|| not_a_table(seg))?;
196                let found = match pick.parse::<usize>().ok() {
197                    Some(index) => array.get_mut(index),
198                    None => array
199                        .iter_mut()
200                        .find(|t| t.get("name").and_then(Item::as_str) == Some(pick.as_str())),
201                };
202                found.ok_or_else(|| {
203                    Error::Config(format!("no '{seg}' entry named '{pick}' in this file"))
204                })?
205            }
206            other => other.as_table_like_mut().ok_or_else(|| not_a_table(seg))?,
207        };
208    }
209    let old_decor = table
210        .get(last)
211        .and_then(Item::as_value)
212        .map(|v| v.decor().clone());
213    *value.decor_mut() = old_decor.unwrap_or_default();
214    // Replace in place so the key keeps its position and leading comments.
215    match table.get_mut(last) {
216        Some(item) => *item = Item::Value(value),
217        None => {
218            table.insert(last, Item::Value(value));
219        }
220    }
221    Ok(())
222}
223
224fn new_table(inline: bool) -> Item {
225    if inline {
226        return Item::Value(toml_edit::Value::InlineTable(Default::default()));
227    }
228    let mut table = toml_edit::Table::new();
229    table.set_implicit(true);
230    Item::Table(table)
231}
232
233fn not_a_table(seg: &str) -> Error {
234    Error::Config(format!("'{seg}' is not a table"))
235}
236
237#[cfg(test)]
238#[path = "dotted_tests.rs"]
239mod tests;