whycodes_config/
dotted.rs1use toml::Value;
13use toml_edit::{DocumentMut, Item, TableLike};
14use whycodes_core::{Error, Result};
15
16pub const REDACTED: &str = "<redacted>";
18
19const SECRET_WORDS: [&str; 8] = [
22 "api_key",
23 "apikey",
24 "token",
25 "secret",
26 "password",
27 "webhook",
28 "webhook_url",
29 "authorization",
30];
31
32pub 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
40pub 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
49pub 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
69pub 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
105pub 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
127fn 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
135pub 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
166pub 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
176pub(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 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;