Skip to main content

lean_ctx/core/config/
renamed.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Renamed configuration keys.
3//!
4//! A renamed key keeps its value under the new name. Loading reads the old
5//! name as the new one (`apply`); the automatic config migration rewrites
6//! it in a writable global config (`migrate_table`); `config validate` and
7//! `lean-ctx doctor` name it as renamed instead of calling it unknown. When a
8//! config sets both names, the new one wins and the old one is left for the
9//! user to remove.
10
11/// One renamed key: the dotted path it had and the one it has now.
12pub struct RenamedKey {
13    pub from: &'static str,
14    pub to: &'static str,
15}
16
17/// Keys whose name changed; their values apply unchanged.
18pub const RENAMED_KEYS: &[RenamedKey] = &[
19    RenamedKey {
20        // Hive projects and repos became vaults (3.12).
21        from: "hive.project_labels",
22        to: "hive.vault_labels",
23    },
24    RenamedKey {
25        // Session drafts became the capture gate (3.12, SPEC-ENGINE §3.16).
26        from: "hive.auto_draft",
27        to: "hive.auto_capture",
28    },
29];
30
31/// The renamed key `path` (dotted) is, if any.
32#[must_use]
33pub fn renamed_key(path: &str) -> Option<&'static RenamedKey> {
34    RENAMED_KEYS.iter().find(|key| key.from == path)
35}
36
37/// What `config validate` and `doctor` say about a renamed key `path` the
38/// config sets; `both` when it also sets the new name.
39#[must_use]
40pub fn hint(key: &RenamedKey, both: bool) -> String {
41    if both {
42        format!(
43            "`{}` was renamed to `{}`, which is set too and wins; remove `{}`",
44            key.from, key.to, key.from
45        )
46    } else {
47        format!(
48            "`{}` was renamed to `{}`; the value still applies",
49            key.from, key.to
50        )
51    }
52}
53
54/// The hints for the renamed keys among `used` (dotted paths a config sets).
55#[must_use]
56pub fn hints(used: &[String]) -> Vec<String> {
57    used.iter()
58        .filter_map(|path| renamed_key(path))
59        .map(|key| hint(key, used.iter().any(|path| path == key.to)))
60        .collect()
61}
62
63/// Reads every renamed key of a parsed config (root or profile overlay) as
64/// its new name. A value under the new name wins.
65pub(super) fn apply(table: &mut toml::Table) {
66    for key in RENAMED_KEYS {
67        let from: Vec<&str> = key.from.split('.').collect();
68        let Some(value) = take_toml(table, &from) else {
69            continue;
70        };
71        let to: Vec<&str> = key.to.split('.').collect();
72        keep_or_insert_toml(table, &to, value);
73    }
74}
75
76/// Inserts `value` at `segments` unless a value is there already.
77fn keep_or_insert_toml(table: &mut toml::Table, segments: &[&str], value: toml::Value) {
78    match segments {
79        [] => {}
80        [leaf] => {
81            table.entry(*leaf).or_insert(value);
82        }
83        [head, rest @ ..] => {
84            let child = table
85                .entry(*head)
86                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
87            if let Some(child) = child.as_table_mut() {
88                keep_or_insert_toml(child, rest, value);
89            }
90        }
91    }
92}
93
94fn take_toml(table: &mut toml::Table, segments: &[&str]) -> Option<toml::Value> {
95    match segments {
96        [] => None,
97        [leaf] => table.remove(*leaf),
98        [head, rest @ ..] => take_toml(table.get_mut(*head)?.as_table_mut()?, rest),
99    }
100}
101
102/// Moves every renamed key of a config document table (root or profile
103/// overlay) to its new name, keeping its value and comment. A key whose new
104/// name is set too stays untouched. Returns whether anything moved.
105pub(super) fn migrate_table(root: &mut toml_edit::Table) -> bool {
106    let mut changed = false;
107    for key in RENAMED_KEYS {
108        let from: Vec<&str> = key.from.split('.').collect();
109        let to: Vec<&str> = key.to.split('.').collect();
110        if get_edit(root, &from).is_none() || get_edit(root, &to).is_some() {
111            continue;
112        }
113        let (from_leaf, from_parents) = from.split_last().expect("a renamed key has a name");
114        let (to_leaf, to_parents) = to.split_last().expect("a renamed key has a name");
115        let Some((comment, item)) = table_at(root, from_parents, false).and_then(|table| {
116            let comment = table
117                .key(from_leaf)
118                .and_then(|key| key.leaf_decor().prefix().cloned());
119            Some((comment, table.remove(from_leaf)?))
120        }) else {
121            continue;
122        };
123        let Some(target) = table_at(root, to_parents, true) else {
124            continue;
125        };
126        target.insert(to_leaf, item);
127        if let (Some(comment), Some(mut key)) = (comment, target.key_mut(to_leaf)) {
128            key.leaf_decor_mut().set_prefix(comment);
129        }
130        changed = true;
131    }
132    changed
133}
134
135fn get_edit<'a>(table: &'a toml_edit::Table, segments: &[&str]) -> Option<&'a toml_edit::Item> {
136    let (leaf, parents) = segments.split_last()?;
137    let mut current: &dyn toml_edit::TableLike = table;
138    for part in parents {
139        current = current.get(part)?.as_table_like()?;
140    }
141    current.get(leaf)
142}
143
144fn table_at<'a>(
145    root: &'a mut toml_edit::Table,
146    segments: &[&str],
147    create: bool,
148) -> Option<&'a mut dyn toml_edit::TableLike> {
149    let mut current: &mut dyn toml_edit::TableLike = root;
150    for part in segments {
151        if create && current.get(part).is_none() {
152            current.insert(part, toml_edit::table());
153        }
154        current = current.get_mut(part)?.as_table_like_mut()?;
155    }
156    Some(current)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn loaded(raw: &str) -> crate::core::config::Config {
164        super::super::loader::parse_config_with_profile(raw, None).expect("config loads")
165    }
166
167    #[test]
168    fn an_old_name_applies_as_the_new_one_on_load() {
169        assert!(!loaded("[hive]\nproject_labels = false\n").hive.vault_labels);
170        assert!(loaded("[hive]\nauto_draft = true\n").hive.vault_labels);
171        let both = loaded("[hive]\nproject_labels = true\nvault_labels = false\n");
172        assert!(!both.hive.vault_labels, "the new name wins");
173        let profile = "config_profile = \"ci\"\n[profiles.ci.hive]\nproject_labels = false\n";
174        assert!(!loaded(profile).hive.vault_labels);
175        // 3.11.5 `auto_draft` is read as `auto_capture` (SPEC-ENGINE §3.16).
176        assert!(!loaded("[hive]\nauto_draft = false\n").hive.auto_capture);
177        assert!(
178            loaded("[hive]\nauto_draft = false\nauto_capture = true\n")
179                .hive
180                .auto_capture,
181            "the new name wins"
182        );
183    }
184
185    #[test]
186    fn hive_is_the_only_section_name() {
187        assert_eq!(
188            loaded("[hive]\nenabled = false\n").hive.enabled,
189            Some(false)
190        );
191        // The pre-release name is not read any more.
192        assert_eq!(loaded("[atlas]\nenabled = false\n").hive.enabled, None);
193        let profile = "config_profile = \"ci\"\n[profiles.ci.hive]\nenabled = false\n";
194        assert_eq!(loaded(profile).hive.enabled, Some(false));
195    }
196
197    #[test]
198    fn migration_moves_the_value_and_keeps_the_comment() {
199        let raw = "# mine\n[hive]\nauto_draft = false\n# no folder names\nproject_labels = false\n\n[profiles.ci.hive]\nproject_labels = true\n";
200        let migrated = super::super::retired::migrate_retired_document(raw)
201            .expect("parses")
202            .expect("changes");
203        assert!(!migrated.contains("project_labels"), "{migrated}");
204        assert!(
205            migrated.contains("# no folder names\nvault_labels = false"),
206            "{migrated}"
207        );
208        assert!(migrated.contains("[profiles.ci.hive]\nvault_labels = true"));
209        assert!(migrated.starts_with("# mine"));
210        assert!(!migrated.contains("auto_draft"), "{migrated}");
211        assert!(migrated.contains("auto_capture = false"), "{migrated}");
212        assert!(!loaded(&migrated).hive.vault_labels);
213        assert!(!loaded(&migrated).hive.auto_capture);
214        assert_eq!(
215            super::super::retired::migrate_retired_document(&migrated).expect("parses"),
216            None
217        );
218    }
219
220    #[test]
221    fn migration_leaves_both_names_for_the_user() {
222        let raw = "[hive]\nproject_labels = true\nvault_labels = false\n";
223        assert_eq!(
224            super::super::retired::migrate_retired_document(raw).expect("parses"),
225            None
226        );
227    }
228
229    /// The `[hive]` section of an engine before the VA rename: like the
230    /// current `HiveConfig` it has `#[serde(default)]` and no
231    /// `deny_unknown_fields`, so a key it does not know is skipped.
232    #[derive(serde::Deserialize)]
233    #[serde(default)]
234    struct PreVaHiveConfig {
235        project_labels: bool,
236        auto_draft: bool,
237    }
238
239    impl Default for PreVaHiveConfig {
240        fn default() -> Self {
241            Self {
242                project_labels: true,
243                auto_draft: true,
244            }
245        }
246    }
247
248    #[test]
249    fn an_engine_before_the_rename_ignores_vault_labels() {
250        let older: PreVaHiveConfig =
251            toml::from_str("vault_labels = false\nauto_draft = false\n").expect("still loads");
252        assert!(older.project_labels, "the older engine keeps its default");
253        assert!(!older.auto_draft, "and every key it knows");
254    }
255
256    #[test]
257    fn hints_name_the_new_key() {
258        let key = renamed_key("hive.project_labels").expect("renamed");
259        assert_eq!(
260            hint(key, false),
261            "`hive.project_labels` was renamed to `hive.vault_labels`; the value still applies"
262        );
263        assert!(hint(key, true).contains("wins; remove `hive.project_labels`"));
264        assert!(renamed_key("hive.vault_labels").is_none());
265    }
266
267    #[test]
268    fn an_old_config_gets_exactly_the_rename_hint() {
269        let used = |paths: &[&str]| paths.iter().map(|p| (*p).to_string()).collect::<Vec<_>>();
270        assert_eq!(
271            hints(&used(&["hive.auto_draft", "hive.project_labels"])),
272            [
273                "`hive.auto_draft` was renamed to `hive.auto_capture`; the value still applies",
274                "`hive.project_labels` was renamed to `hive.vault_labels`; the value still applies"
275            ]
276        );
277        assert_eq!(
278            hints(&used(&["hive.vault_labels", "hive.project_labels"])),
279            [hint(&RENAMED_KEYS[0], true)]
280        );
281        assert!(hints(&used(&["hive.vault_labels"])).is_empty());
282    }
283}