lean_ctx/core/config/
renamed.rs1pub struct RenamedKey {
13 pub from: &'static str,
14 pub to: &'static str,
15}
16
17pub const RENAMED_KEYS: &[RenamedKey] = &[
19 RenamedKey {
20 from: "hive.project_labels",
22 to: "hive.vault_labels",
23 },
24 RenamedKey {
25 from: "hive.auto_draft",
27 to: "hive.auto_capture",
28 },
29];
30
31#[must_use]
33pub fn renamed_key(path: &str) -> Option<&'static RenamedKey> {
34 RENAMED_KEYS.iter().find(|key| key.from == path)
35}
36
37#[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#[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
63pub(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
76fn 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
102pub(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 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 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 #[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}