返回 CodeWhale
legacy_root.rs
根目录 / crates / config / src / legacy_root.rs
1 //! Canonicalize the legacy top-level `base_url` / `api_key` into
2 //! `[providers.<name>]` tables (#6394).
3 //!
4 //! Older releases kept DeepSeek's endpoint and key at the root of
5 //! `config.toml`, and every reader then decided for itself which routes
6 //! inherited them. Those readers disagreed. This module is now the only place
7 //! that interprets the root keys: every parse runs [`apply_to_table`] before
8 //! the typed structs see the document (which no longer have root fields), and
9 //! every user-started write runs [`apply_to_document`], so memory and disk
10 //! follow one rule.
11 //!
12 //! Where the top-level `base_url` goes, per scope (the file itself and each
13 //! `[profiles.<name>]`):
14 //!
15 //! 1. `provider = "custom"` with no `[providers.custom]` table: the endpoint,
16 //! key and a copy of the model become `[providers.custom]`.
17 //! 2. An endpoint on another vendor's official host moves to that vendor's
18 //! table, so a DeepSeek route never dispatches to it.
19 //! 3. Anything else belongs to `[providers.deepseek]`, its historical owner.
20 //!
21 //! The key goes with DeepSeek (or the literal custom route). It follows the
22 //! endpoint to another vendor only when the same table explicitly selects that
23 //! vendor, the host is that vendor's own, and the vendor's table has no key.
24 //!
25 //! When the root and the table disagree, memory resolves it (the table's
26 //! `base_url` wins; the root `api_key` wins, because that is the key the TUI
27 //! sent) but disk never does: both keys stay where they are until the user
28 //! runs `codewhale config migrate --prefer ...` or writes that value.
29
30 use std::fmt;
31
32 use crate::ProviderKind;
33
34 /// The root key names older releases accepted, canonical name first.
35 const BASE_URL_KEYS: [&str; 2] = ["base_url", "baseUrl"];
36 const API_KEY_KEYS: [&str; 2] = ["api_key", "apiKey"];
37
38 /// Hosts whose root `base_url` belongs to a non-DeepSeek vendor even when the
39 /// path is not one of that vendor's exact official endpoints. This is the list
40 /// the DeepSeek route used to refuse at dispatch time.
41 const FOREIGN_HOST_NEEDLES: &[(&str, ProviderKind)] = &[
42 ("integrate.api.nvidia.com", ProviderKind::NvidiaNim),
43 ("api.openai.com", ProviderKind::Openai),
44 ("api.atlascloud.ai", ProviderKind::Atlascloud),
45 ("maas-openapi.wanjiedata.com", ProviderKind::WanjieArk),
46 ("volces.com", ProviderKind::Volcengine),
47 ("openrouter.ai", ProviderKind::Openrouter),
48 ("xiaomimimo.com", ProviderKind::XiaomiMimo),
49 ("novita.ai", ProviderKind::Novita),
50 ("fireworks.ai", ProviderKind::Fireworks),
51 ("siliconflow", ProviderKind::Siliconflow),
52 ("arcee.ai", ProviderKind::Arcee),
53 ("moonshot.ai", ProviderKind::Moonshot),
54 ("api.kimi.com", ProviderKind::Moonshot),
55 ];
56
57 /// Which field a note is about.
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 pub enum LegacyRootField {
60 BaseUrl,
61 ApiKey,
62 }
63
64 impl LegacyRootField {
65 #[must_use]
66 pub fn key(self) -> &'static str {
67 match self {
68 Self::BaseUrl => "base_url",
69 Self::ApiKey => "api_key",
70 }
71 }
72 }
73
74 /// How a conflicting pair is resolved on disk by `codewhale config migrate`.
75 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
76 pub enum LegacyRootPrefer {
77 /// Keep the top-level value; it replaces the table value.
78 TopLevel,
79 /// Keep the table value; the top-level value is removed.
80 Table,
81 }
82
83 /// One thing the canonicalizer did or found. Never carries a value.
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub enum LegacyRootNote {
86 /// A top-level key moved into a table.
87 Moved {
88 scope: Option<String>,
89 field: LegacyRootField,
90 to: String,
91 },
92 /// The table already held the same value; the top-level copy was removed.
93 Merged {
94 scope: Option<String>,
95 field: LegacyRootField,
96 to: String,
97 },
98 /// An empty top-level value was removed.
99 DroppedEmpty {
100 scope: Option<String>,
101 field: LegacyRootField,
102 },
103 /// The top-level key was copied into `[vision_model]`, which inherited it.
104 CopiedToVision { scope: Option<String> },
105 /// The literal custom route's model was copied into `[providers.custom]`.
106 CopiedModel { scope: Option<String> },
107 /// `provider` was written because older releases guessed it from the URL.
108 GuessedProvider {
109 scope: Option<String>,
110 provider: &'static str,
111 },
112 /// The top-level value and the table value differ.
113 Conflict {
114 scope: Option<String>,
115 field: LegacyRootField,
116 table: String,
117 /// Whether the conflict was resolved (in memory, or by `--prefer`).
118 resolved: bool,
119 },
120 }
121
122 /// Receipt for one canonicalization pass.
123 #[derive(Debug, Clone, Default, PartialEq, Eq)]
124 pub struct LegacyRootMigration {
125 pub notes: Vec<LegacyRootNote>,
126 }
127
128 impl LegacyRootMigration {
129 #[must_use]
130 pub fn is_empty(&self) -> bool {
131 self.notes.is_empty()
132 }
133
134 /// Whether anything in the file changed (or would change).
135 #[must_use]
136 pub fn changes_file(&self) -> bool {
137 self.notes.iter().any(|note| match note {
138 LegacyRootNote::Conflict { resolved, .. } => *resolved,
139 _ => true,
140 })
141 }
142
143 /// Conflicts that are still present in the file.
144 pub fn unresolved_conflicts(&self) -> impl Iterator<Item = &LegacyRootNote> {
145 self.notes.iter().filter(|note| {
146 matches!(
147 note,
148 LegacyRootNote::Conflict {
149 resolved: false,
150 ..
151 }
152 )
153 })
154 }
155
156 /// Whether the file still has legacy top-level keys to move.
157 #[must_use]
158 pub fn has_pending_moves(&self) -> bool {
159 self.notes.iter().any(|note| {
160 matches!(
161 note,
162 LegacyRootNote::Moved { .. }
163 | LegacyRootNote::Merged { .. }
164 | LegacyRootNote::DroppedEmpty { .. }
165 )
166 })
167 }
168
169 /// Whether the top-level (not per-profile) `api_key` moved into
170 /// `[providers.<table>]`, where that table had no key of its own.
171 #[must_use]
172 pub fn moved_root_api_key_to(&self, table: &str) -> bool {
173 self.notes.iter().any(|note| {
174 matches!(
175 note,
176 LegacyRootNote::Moved {
177 scope: None,
178 field: LegacyRootField::ApiKey,
179 to,
180 } if to.strip_prefix("providers.") == Some(table)
181 )
182 })
183 }
184
185 /// One line per note, suitable for CLI output and doctor.
186 #[must_use]
187 pub fn lines(&self) -> Vec<String> {
188 self.notes.iter().map(ToString::to_string).collect()
189 }
190
191 /// Short one-line summary of what moved.
192 #[must_use]
193 pub fn summary(&self) -> Option<String> {
194 let moved: Vec<String> = self
195 .notes
196 .iter()
197 .filter_map(|note| match note {
198 LegacyRootNote::Moved { scope, field, to }
199 | LegacyRootNote::Merged { scope, field, to } => {
200 Some(format!("{}{} to [{to}]", scope_prefix(scope), field.key()))
201 }
202 _ => None,
203 })
204 .collect();
205 (!moved.is_empty()).then(|| format!("moved top-level {}", moved.join(", ")))
206 }
207 }
208
209 fn scope_prefix(scope: &Option<String>) -> String {
210 scope
211 .as_deref()
212 .map(|name| format!("profiles.{name}."))
213 .unwrap_or_default()
214 }
215
216 impl fmt::Display for LegacyRootNote {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 match self {
219 Self::Moved { scope, field, to } => write!(
220 f,
221 "moved top-level {}{} to [{to}]",
222 scope_prefix(scope),
223 field.key()
224 ),
225 Self::Merged { scope, field, to } => write!(
226 f,
227 "removed top-level {}{}; [{to}] already has the same value",
228 scope_prefix(scope),
229 field.key()
230 ),
231 Self::DroppedEmpty { scope, field } => write!(
232 f,
233 "removed empty top-level {}{}",
234 scope_prefix(scope),
235 field.key()
236 ),
237 Self::CopiedToVision { scope } => write!(
238 f,
239 "copied top-level {}api_key into [{}vision_model], which used it",
240 scope_prefix(scope),
241 scope_prefix(scope)
242 ),
243 Self::CopiedModel { scope } => write!(
244 f,
245 "copied the {}custom route's model into [{}providers.custom]",
246 scope_prefix(scope),
247 scope_prefix(scope)
248 ),
249 Self::GuessedProvider { scope, provider } => write!(
250 f,
251 "set {}provider = \"{provider}\" (older releases inferred it from base_url)",
252 scope_prefix(scope)
253 ),
254 Self::Conflict {
255 scope,
256 field,
257 table,
258 resolved,
259 } => {
260 let in_use = match field {
261 LegacyRootField::BaseUrl => format!("[{table}] {}", field.key()),
262 LegacyRootField::ApiKey => format!("top-level {}", field.key()),
263 };
264 if *resolved {
265 write!(
266 f,
267 "resolved conflicting top-level {}{} and [{table}] {}",
268 scope_prefix(scope),
269 field.key(),
270 field.key()
271 )
272 } else {
273 write!(
274 f,
275 "top-level {}{} differs from [{table}] {}; {in_use} is in use \
276 (run `codewhale config migrate --prefer top-level|table`)",
277 scope_prefix(scope),
278 field.key(),
279 field.key()
280 )
281 }
282 }
283 }
284 }
285 }
286
287 /// A primitive edit, shared by the in-memory and on-disk appliers.
288 #[derive(Debug, Clone, PartialEq)]
289 enum Op {
290 Set(Vec<String>, toml::Value),
291 /// Set `to`, then remove `from` only if the set landed.
292 Move {
293 from: Vec<String>,
294 to: Vec<String>,
295 value: toml::Value,
296 },
297 /// A conflicting pair left in place on disk (no-op for both appliers).
298 KeepConflict {
299 root: Vec<String>,
300 table: Vec<String>,
301 },
302 /// Set only when the destination is absent.
303 SetIfAbsent(Vec<String>, toml::Value),
304 Remove(Vec<String>),
305 }
306
307 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
308 enum Mode {
309 Memory,
310 Document(Option<LegacyRootPrefer>),
311 }
312
313 /// The vendor whose official host a root `base_url` names, if it is not
314 /// DeepSeek's. Loopback hosts and Codewhale's own env-dependent endpoint never
315 /// count: a local server is not another vendor.
316 #[must_use]
317 pub fn legacy_root_owner(base_url: &str) -> Option<ProviderKind> {
318 let trimmed = base_url.trim();
319 if trimmed.is_empty() || crate::base_url_uses_local_host(trimmed) {
320 return None;
321 }
322 let lower = trimmed.to_ascii_lowercase();
323 if crate::device_code::url_scheme_and_host(trimmed).is_ok_and(|(scheme, host, credentials)| {
324 scheme == "https" && host == "chatgpt.com" && !credentials
325 }) {
326 return Some(ProviderKind::OpenaiCodex);
327 }
328 if lower.contains("integrate.api.nvidia.com") {
329 return Some(ProviderKind::NvidiaNim);
330 }
331 ProviderKind::ALL
332 .iter()
333 .copied()
334 .filter(|kind| {
335 !matches!(
336 kind,
337 ProviderKind::Deepseek | ProviderKind::Custom | ProviderKind::Codewhale
338 )
339 })
340 .find(|kind| crate::provider_base_url_is_official(*kind, trimmed))
341 .or_else(|| {
342 FOREIGN_HOST_NEEDLES
343 .iter()
344 .find(|(needle, _)| lower.contains(needle))
345 .map(|(_, kind)| *kind)
346 })
347 }
348
349 /// Whether `base_url` is on `provider`'s own host family.
350 fn host_belongs_to(provider: ProviderKind, base_url: &str) -> bool {
351 if crate::base_url_uses_local_host(base_url) {
352 return false;
353 }
354 let lower = base_url.trim().to_ascii_lowercase();
355 crate::provider_base_url_is_official(provider, base_url)
356 || FOREIGN_HOST_NEEDLES
357 .iter()
358 .any(|(needle, kind)| *kind == provider && lower.contains(needle))
359 }
360
361 fn string_at<'a>(table: &'a toml::Table, key: &str) -> Option<&'a str> {
362 table.get(key).and_then(toml::Value::as_str)
363 }
364
365 fn table_at<'a>(table: &'a toml::Table, key: &str) -> Option<&'a toml::Table> {
366 table.get(key).and_then(toml::Value::as_table)
367 }
368
369 fn nonempty(value: Option<&str>) -> Option<&str> {
370 value.map(str::trim).filter(|value| !value.is_empty())
371 }
372
373 /// First present key of an alias pair: `(key name, value)`.
374 fn root_entry<'a>(
375 table: &'a toml::Table,
376 keys: &[&'static str],
377 ) -> Option<(&'static str, &'a toml::Value)> {
378 keys.iter()
379 .find_map(|key| table.get(*key).map(|value| (*key, value)))
380 }
381
382 fn is_blank(value: &toml::Value) -> bool {
383 value.as_str().is_some_and(|value| value.trim().is_empty())
384 }
385
386 struct Scope<'a> {
387 name: Option<String>,
388 /// Path prefix of this scope's table (empty for the file itself).
389 prefix: Vec<String>,
390 table: &'a toml::Table,
391 /// The base file's table when this scope is a profile.
392 base: Option<&'a toml::Table>,
393 }
394
395 impl Scope<'_> {
396 fn path(&self, parts: &[&str]) -> Vec<String> {
397 let mut path = self.prefix.clone();
398 path.extend(parts.iter().map(|part| (*part).to_string()));
399 path
400 }
401
402 fn label(&self, parts: &[&str]) -> String {
403 self.path(parts).join(".")
404 }
405
406 fn provider(&self) -> Option<&str> {
407 nonempty(string_at(self.table, "provider")).or_else(|| {
408 self.base
409 .and_then(|base| nonempty(string_at(base, "provider")))
410 })
411 }
412
413 fn own_provider_kind(&self) -> Option<ProviderKind> {
414 nonempty(string_at(self.table, "provider")).and_then(ProviderKind::parse_config_identity)
415 }
416
417 fn provider_table(&self, key: &str) -> Option<&toml::Table> {
418 table_at(self.table, "providers").and_then(|providers| table_at(providers, key))
419 }
420
421 fn has_literal_custom_table(&self) -> bool {
422 let has = |table: &toml::Table| {
423 table_at(table, "providers").is_some_and(|providers| {
424 providers
425 .keys()
426 .any(|key| key.trim().eq_ignore_ascii_case("custom"))
427 })
428 };
429 has(self.table) || self.base.is_some_and(has)
430 }
431
432 fn selects_literal_custom(&self) -> bool {
433 self.provider()
434 .is_some_and(|provider| provider.eq_ignore_ascii_case("custom"))
435 }
436 }
437
438 fn plan(root: &toml::Table, mode: Mode) -> (Vec<Op>, LegacyRootMigration) {
439 let mut ops = Vec::new();
440 let mut receipt = LegacyRootMigration::default();
441 plan_scope(
442 &Scope {
443 name: None,
444 prefix: Vec::new(),
445 table: root,
446 base: None,
447 },
448 mode,
449 &mut ops,
450 &mut receipt,
451 );
452 if let Some(profiles) = table_at(root, "profiles") {
453 for (name, profile) in profiles {
454 let Some(profile) = profile.as_table() else {
455 continue;
456 };
457 plan_scope(
458 &Scope {
459 name: Some(name.clone()),
460 prefix: vec!["profiles".to_string(), name.clone()],
461 table: profile,
462 base: Some(root),
463 },
464 mode,
465 &mut ops,
466 &mut receipt,
467 );
468 }
469 }
470 (ops, receipt)
471 }
472
473 fn plan_scope(scope: &Scope<'_>, mode: Mode, ops: &mut Vec<Op>, receipt: &mut LegacyRootMigration) {
474 let base_url = root_entry(scope.table, &BASE_URL_KEYS);
475 let api_key = root_entry(scope.table, &API_KEY_KEYS);
476 if base_url.is_none() && api_key.is_none() {
477 return;
478 }
479 let name = scope.name.clone();
480
481 let base_url_str = base_url.and_then(|(_, value)| nonempty(value.as_str()));
482 let literal_custom = scope.selects_literal_custom() && !scope.has_literal_custom_table();
483
484 // Provider guesses that `api_provider()` used to make from the URL.
485 if scope.provider().is_none()
486 && let Some(url) = base_url_str
487 {
488 let lower = url.to_ascii_lowercase();
489 let guess = if lower.contains("integrate.api.nvidia.com") {
490 Some("nvidia-nim")
491 } else if lower.contains("api.deepseeki.com") {
492 Some("deepseek-cn")
493 } else {
494 None
495 };
496 if let Some(provider) = guess {
497 ops.push(Op::Set(
498 scope.path(&["provider"]),
499 toml::Value::String(provider.to_string()),
500 ));
501 receipt.notes.push(LegacyRootNote::GuessedProvider {
502 scope: name.clone(),
503 provider,
504 });
505 }
506 }
507
508 let url_owner = base_url_str.and_then(legacy_root_owner);
509 let base_url_dest = if literal_custom {
510 "custom".to_string()
511 } else if let Some(explicit) = scope
512 .own_provider_kind()
513 .filter(|kind| base_url_str.is_some_and(|url| host_belongs_to(*kind, url)))
514 .filter(|kind| *kind != ProviderKind::Deepseek && *kind != ProviderKind::Custom)
515 {
516 explicit.provider().provider_config_key().to_string()
517 } else if let Some(owner) = url_owner {
518 owner.provider().provider_config_key().to_string()
519 } else {
520 "deepseek".to_string()
521 };
522
523 if let Some((key, value)) = base_url {
524 plan_move(
525 scope,
526 mode,
527 key,
528 value,
529 LegacyRootField::BaseUrl,
530 &base_url_dest,
531 ops,
532 receipt,
533 );
534 }
535
536 if let Some((key, value)) = api_key {
537 let key_dest = if literal_custom {
538 "custom".to_string()
539 } else if let Some(explicit) = scope.own_provider_kind().filter(|kind| {
540 *kind != ProviderKind::Deepseek
541 && *kind != ProviderKind::Custom
542 && base_url_str.is_some_and(|url| host_belongs_to(*kind, url))
543 && nonempty(
544 scope
545 .provider_table(kind.provider().provider_config_key())
546 .and_then(|table| string_at(table, "api_key")),
547 )
548 .is_none()
549 }) {
550 explicit.provider().provider_config_key().to_string()
551 } else {
552 "deepseek".to_string()
553 };
554
555 // `[vision_model]` without a key of its own used the top-level key.
556 if !is_blank(value)
557 && let Some(vision) = table_at(scope.table, "vision_model")
558 && vision.get("api_key").is_none()
559 {
560 ops.push(Op::Set(
561 scope.path(&["vision_model", "api_key"]),
562 value.clone(),
563 ));
564 receipt.notes.push(LegacyRootNote::CopiedToVision {
565 scope: name.clone(),
566 });
567 }
568
569 plan_move(
570 scope,
571 mode,
572 key,
573 value,
574 LegacyRootField::ApiKey,
575 &key_dest,
576 ops,
577 receipt,
578 );
579 }
580
581 if literal_custom
582 && base_url_str.is_some()
583 && let Some(model) = ["default_text_model", "model"]
584 .iter()
585 .find_map(|key| nonempty(string_at(scope.table, key)))
586 .or_else(|| {
587 scope.base.and_then(|base| {
588 ["default_text_model", "model"]
589 .iter()
590 .find_map(|key| nonempty(string_at(base, key)))
591 })
592 })
593 && scope
594 .provider_table("custom")
595 .and_then(|table| table.get("model"))
596 .is_none()
597 {
598 ops.push(Op::SetIfAbsent(
599 scope.path(&["providers", "custom", "model"]),
600 toml::Value::String(model.to_string()),
601 ));
602 receipt.notes.push(LegacyRootNote::CopiedModel {
603 scope: name.clone(),
604 });
605 }
606
607 // A `[providers.custom]` table is an OpenAI-compatible custom route; the
608 // top-level shape it replaces always was one.
609 let moved_key = api_key.is_some_and(|(_, value)| !is_blank(value));
610 if literal_custom && (base_url_str.is_some() || moved_key) {
611 ops.push(Op::SetIfAbsent(
612 scope.path(&["providers", "custom", "kind"]),
613 toml::Value::String("openai-compatible".to_string()),
614 ));
615 }
616 }
617
618 #[allow(clippy::too_many_arguments)]
619 fn plan_move(
620 scope: &Scope<'_>,
621 mode: Mode,
622 key: &str,
623 value: &toml::Value,
624 field: LegacyRootField,
625 dest: &str,
626 ops: &mut Vec<Op>,
627 receipt: &mut LegacyRootMigration,
628 ) {
629 let name = scope.name.clone();
630 let root_path = scope.path(&[key]);
631 let dest_path = scope.path(&["providers", dest, field.key()]);
632 let dest_label = scope.label(&["providers", dest]);
633 if is_blank(value) {
634 ops.push(Op::Remove(root_path));
635 receipt
636 .notes
637 .push(LegacyRootNote::DroppedEmpty { scope: name, field });
638 return;
639 }
640 let existing = scope
641 .provider_table(dest)
642 .and_then(|table| table.get(field.key()))
643 .filter(|existing| !is_blank(existing));
644 let Some(existing) = existing else {
645 ops.push(Op::Move {
646 from: root_path,
647 to: dest_path,
648 value: value.clone(),
649 });
650 receipt.notes.push(LegacyRootNote::Moved {
651 scope: name,
652 field,
653 to: dest_label,
654 });
655 return;
656 };
657 let same = match (existing.as_str(), value.as_str()) {
658 (Some(a), Some(b)) => a.trim() == b.trim(),
659 _ => existing == value,
660 };
661 if same {
662 ops.push(Op::Remove(root_path));
663 receipt.notes.push(LegacyRootNote::Merged {
664 scope: name,
665 field,
666 to: dest_label,
667 });
668 return;
669 }
670 // A real conflict. Memory applies the precedence the runtime always had;
671 // disk changes only on an explicit `--prefer`.
672 let top_level_wins = match mode {
673 Mode::Memory => Some(field == LegacyRootField::ApiKey),
674 Mode::Document(Some(LegacyRootPrefer::TopLevel)) => Some(true),
675 Mode::Document(Some(LegacyRootPrefer::Table)) => Some(false),
676 Mode::Document(None) => None,
677 };
678 match top_level_wins {
679 Some(true) => ops.push(Op::Move {
680 from: root_path,
681 to: dest_path,
682 value: value.clone(),
683 }),
684 Some(false) => ops.push(Op::Remove(root_path)),
685 None => ops.push(Op::KeepConflict {
686 root: root_path,
687 table: dest_path,
688 }),
689 }
690 receipt.notes.push(LegacyRootNote::Conflict {
691 scope: name,
692 field,
693 table: dest_label,
694 resolved: matches!(mode, Mode::Document(Some(_))),
695 });
696 }
697
698 /// Canonicalize a parsed document in memory. Every parse of `config.toml`
699 /// (and of profile, managed and imported configs) runs this before
700 /// deserializing, so no reader ever sees a top-level `base_url` or `api_key`.
701 pub fn apply_to_table(root: &mut toml::Table) -> LegacyRootMigration {
702 let (ops, receipt) = plan(root, Mode::Memory);
703 for op in ops {
704 apply_op_to_table(root, op);
705 }
706 receipt
707 }
708
709 /// Canonicalize `contents` as text, for callers that then deserialize it.
710 ///
711 /// Going through `toml::Value` would turn datetimes in unknown keys into
712 /// strings, so the edit is made on the document and re-rendered instead.
713 /// Returns `contents` unchanged (borrowed) when there is nothing to move.
714 pub fn canonicalize_text(
715 contents: &str,
716 ) -> Result<(std::borrow::Cow<'_, str>, LegacyRootMigration), toml::de::Error> {
717 let might_have_keys = BASE_URL_KEYS
718 .iter()
719 .chain(API_KEY_KEYS.iter())
720 .any(|key| contents.contains(key));
721 if !might_have_keys {
722 return Ok((
723 std::borrow::Cow::Borrowed(contents),
724 LegacyRootMigration::default(),
725 ));
726 }
727 let table = toml::from_str::<toml::Table>(contents)?;
728 if !has_legacy_root_keys(&table) {
729 return Ok((
730 std::borrow::Cow::Borrowed(contents),
731 LegacyRootMigration::default(),
732 ));
733 }
734 let (ops, receipt) = plan(&table, Mode::Memory);
735 match contents.parse::<toml_edit::DocumentMut>() {
736 Ok(mut doc) => {
737 for op in ops {
738 apply_op_to_document(&mut doc, op);
739 }
740 Ok((std::borrow::Cow::Owned(doc.to_string()), receipt))
741 }
742 Err(_) => {
743 let mut table = table;
744 for op in ops {
745 apply_op_to_table(&mut table, op);
746 }
747 let text = toml::to_string(&table).unwrap_or_else(|_| contents.to_string());
748 Ok((std::borrow::Cow::Owned(text), receipt))
749 }
750 }
751 }
752
753 /// What [`apply_to_document`] would do, without doing it.
754 #[must_use]
755 pub fn preview_document(
756 doc: &toml_edit::DocumentMut,
757 prefer: Option<LegacyRootPrefer>,
758 ) -> LegacyRootMigration {
759 document_table(doc)
760 .map(|table| plan(&table, Mode::Document(prefer)).1)
761 .unwrap_or_default()
762 }
763
764 /// Canonicalize a document on disk, keeping comments and layout. Conflicting
765 /// pairs are left untouched unless `prefer` says which one to keep.
766 pub fn apply_to_document(
767 doc: &mut toml_edit::DocumentMut,
768 prefer: Option<LegacyRootPrefer>,
769 ) -> LegacyRootMigration {
770 let Some(table) = document_table(doc) else {
771 return LegacyRootMigration::default();
772 };
773 let (ops, receipt) = plan(&table, Mode::Document(prefer));
774 for op in ops {
775 apply_op_to_document(doc, op);
776 }
777 receipt
778 }
779
780 fn document_table(doc: &toml_edit::DocumentMut) -> Option<toml::Table> {
781 toml::from_str::<toml::Table>(&doc.to_string()).ok()
782 }
783
784 /// `raw` with its legacy top-level keys moved (conflicts left in place), or
785 /// `None` when there is nothing to move or `raw` does not parse.
786 #[must_use]
787 pub fn migrated_document_text(raw: &str) -> Option<String> {
788 let mut doc = raw.parse::<toml_edit::DocumentMut>().ok()?;
789 apply_to_document(&mut doc, None)
790 .changes_file()
791 .then(|| doc.to_string())
792 }
793
794 /// Whether a raw document still holds legacy top-level keys anywhere.
795 #[must_use]
796 pub fn has_legacy_root_keys(root: &toml::Table) -> bool {
797 let scope_has = |table: &toml::Table| {
798 BASE_URL_KEYS
799 .iter()
800 .chain(API_KEY_KEYS.iter())
801 .any(|key| table.contains_key(*key))
802 };
803 scope_has(root)
804 || table_at(root, "profiles").is_some_and(|profiles| {
805 profiles
806 .values()
807 .filter_map(toml::Value::as_table)
808 .any(scope_has)
809 })
810 }
811
812 fn apply_op_to_table(root: &mut toml::Table, op: Op) {
813 match op {
814 Op::Set(path, value) => {
815 set_in_table(root, &path, value, true);
816 }
817 Op::SetIfAbsent(path, value) => {
818 set_in_table(root, &path, value, false);
819 }
820 Op::KeepConflict { .. } => {}
821 Op::Move { from, to, value } => {
822 if set_in_table(root, &to, value, true) {
823 apply_op_to_table(root, Op::Remove(from));
824 }
825 }
826 Op::Remove(path) => {
827 let Some((last, parents)) = path.split_last() else {
828 return;
829 };
830 let mut current = root;
831 for part in parents {
832 match current.get_mut(part).and_then(toml::Value::as_table_mut) {
833 Some(next) => current = next,
834 None => return,
835 }
836 }
837 current.remove(last);
838 }
839 }
840 }
841
842 fn set_in_table(
843 root: &mut toml::Table,
844 path: &[String],
845 value: toml::Value,
846 overwrite: bool,
847 ) -> bool {
848 let Some((last, parents)) = path.split_last() else {
849 return false;
850 };
851 let mut current = root;
852 for part in parents {
853 let entry = current
854 .entry(part.clone())
855 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
856 match entry.as_table_mut() {
857 Some(next) => current = next,
858 // A non-table where a table belongs: leave the document alone and
859 // let the typed parse report it.
860 None => return false,
861 }
862 }
863 if overwrite || !current.contains_key(last) {
864 current.insert(last.clone(), value);
865 }
866 true
867 }
868
869 fn apply_op_to_document(doc: &mut toml_edit::DocumentMut, op: Op) {
870 let (path, value) = match op {
871 Op::Move { from, to, value } => {
872 let carried = comment_travelling_with(doc, &from);
873 // Move the value as written, trailing comment included.
874 let written = document_value(doc, &from).or_else(|| toml_value_to_edit(&value));
875 let segments: Vec<&str> = to.iter().map(String::as_str).collect();
876 let landed = written.is_some_and(|value| {
877 crate::set_config_document_value(doc, &segments, value).is_ok()
878 });
879 if landed {
880 apply_op_to_document(doc, Op::Remove(from));
881 if let Some(comment) = carried {
882 set_key_comment(doc, &to, &comment);
883 }
884 }
885 return;
886 }
887 Op::Set(path, value) => (path, value),
888 Op::SetIfAbsent(path, value) if !document_has(doc, &path) => (path, value),
889 Op::SetIfAbsent(..) | Op::KeepConflict { .. } => return,
890 Op::Remove(path) => {
891 let segments: Vec<&str> = path.iter().map(String::as_str).collect();
892 let _ = crate::unset_config_document_value(doc, &segments);
893 return;
894 }
895 };
896 let segments: Vec<&str> = path.iter().map(String::as_str).collect();
897 if let Some(value) = toml_value_to_edit(&value) {
898 // An error means a non-table sits where a table belongs; the typed
899 // parse reports that shape, so the document is left as it is.
900 let _ = crate::set_config_document_value(doc, &segments, value);
901 }
902 }
903
904 /// Conflicting pairs `(root path, table path)` a document still holds.
905 fn conflict_sites(root: &toml::Table) -> Vec<(Vec<String>, Vec<String>)> {
906 plan(root, Mode::Document(None))
907 .0
908 .into_iter()
909 .filter_map(|op| match op {
910 Op::KeepConflict { root, table } => Some((root, table)),
911 _ => None,
912 })
913 .collect()
914 }
915
916 fn value_at<'a>(root: &'a toml::Table, path: &[String]) -> Option<&'a toml::Value> {
917 let (last, parents) = path.split_last()?;
918 let mut current = root;
919 for part in parents {
920 current = current.get(part)?.as_table()?;
921 }
922 current.get(last)
923 }
924
925 fn set_document_value(doc: &mut toml_edit::DocumentMut, path: &[String], value: &toml::Value) {
926 let segments: Vec<&str> = path.iter().map(String::as_str).collect();
927 if let Some(value) = toml_value_to_edit(value) {
928 let _ = crate::set_config_document_value(doc, &segments, value);
929 }
930 }
931
932 /// Put back conflicting pairs that a typed save dropped.
933 ///
934 /// A typed save writes the in-memory view, where the conflict was already
935 /// resolved and the top-level key is gone. Unless the user changed that exact
936 /// value during the session, both keys go back exactly as they were in
937 /// `original_raw`. Returns whether the document changed.
938 pub fn restore_conflicts(doc: &mut toml_edit::DocumentMut, original_raw: &str) -> bool {
939 let Ok(original) = toml::from_str::<toml::Table>(original_raw) else {
940 return false;
941 };
942 let sites = conflict_sites(&original);
943 if sites.is_empty() {
944 return false;
945 }
946 let mut canonical = original.clone();
947 apply_to_table(&mut canonical);
948 let Some(current) = document_table(doc) else {
949 return false;
950 };
951 let mut changed = false;
952 for (root_path, table_path) in sites {
953 if value_at(&current, &table_path) != value_at(&canonical, &table_path) {
954 // The user wrote this value during the session: their write wins
955 // and the top-level key stays gone.
956 continue;
957 }
958 if let (Some(table_value), Some(root_value)) = (
959 value_at(&original, &table_path),
960 value_at(&original, &root_path),
961 ) {
962 set_document_value(doc, &table_path, table_value);
963 set_document_value(doc, &root_path, root_value);
964 changed = true;
965 }
966 }
967 changed
968 }
969
970 /// Table values of the conflicting pairs in `doc`, taken before a targeted
971 /// write so [`settle_conflicts_after_write`] can tell what the user changed.
972 pub(crate) type ConflictSnapshot = Vec<(Vec<String>, Vec<String>, Option<toml::Value>)>;
973
974 pub(crate) fn conflict_snapshot(doc: &toml_edit::DocumentMut) -> ConflictSnapshot {
975 let Some(table) = document_table(doc) else {
976 return Vec::new();
977 };
978 conflict_sites(&table)
979 .into_iter()
980 .map(|(root, path)| {
981 let value = value_at(&table, &path).cloned();
982 (root, path, value)
983 })
984 .collect()
985 }
986
987 /// After a targeted write: when the write changed (or removed) the table side
988 /// of a conflicting pair, that explicit choice ends the conflict and the
989 /// top-level key is removed.
990 pub(crate) fn settle_conflicts_after_write(
991 doc: &mut toml_edit::DocumentMut,
992 snapshot: ConflictSnapshot,
993 ) {
994 if snapshot.is_empty() {
995 return;
996 }
997 let Some(current) = document_table(doc) else {
998 return;
999 };
1000 for (root_path, table_path, before) in snapshot {
1001 if value_at(&current, &table_path) != before.as_ref() {
1002 apply_op_to_document(doc, Op::Remove(root_path));
1003 }
1004 }
1005 }
1006
1007 static PENDING_NOTICES: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
1008
1009 /// Queue the one-line notice for a write that moved legacy top-level keys.
1010 pub(crate) fn queue_notice(receipt: &LegacyRootMigration, backup: &std::path::Path) {
1011 let Some(summary) = receipt.summary() else {
1012 return;
1013 };
1014 let line = format!("{summary}; backup at {}", backup.display());
1015 if let Ok(mut pending) = PENDING_NOTICES.lock()
1016 && !pending.contains(&line)
1017 {
1018 pending.push(line);
1019 }
1020 }
1021
1022 /// Drain the notices queued by writes that moved legacy top-level keys. Each
1023 /// notice is returned exactly once.
1024 #[must_use]
1025 pub fn take_notices() -> Vec<String> {
1026 PENDING_NOTICES
1027 .lock()
1028 .map(|mut pending| std::mem::take(&mut *pending))
1029 .unwrap_or_default()
1030 }
1031
1032 fn table_like_at<'a>(
1033 doc: &'a toml_edit::DocumentMut,
1034 parents: &[String],
1035 ) -> Option<&'a dyn toml_edit::TableLike> {
1036 let mut current: &dyn toml_edit::TableLike = doc.as_table();
1037 for part in parents {
1038 current = current.get(part)?.as_table_like()?;
1039 }
1040 Some(current)
1041 }
1042
1043 /// The comment written directly above the key at `path`, when removing the
1044 /// key would otherwise drop it: the next key already has a comment of its
1045 /// own, or there is no next key. (When the next key has none, removal hands
1046 /// the comment to it, which keeps a file header at the top.)
1047 fn comment_travelling_with(doc: &toml_edit::DocumentMut, path: &[String]) -> Option<String> {
1048 let (key, parents) = path.split_last()?;
1049 let table = table_like_at(doc, parents)?;
1050 let prefix = table.key(key)?.leaf_decor().prefix()?.as_str()?.to_string();
1051 if !prefix.contains('#') {
1052 return None;
1053 }
1054 let mut found = false;
1055 let next_prefix_empty = table
1056 .iter()
1057 .find_map(|(candidate, _)| {
1058 if found {
1059 Some(candidate.to_owned())
1060 } else {
1061 found = candidate == key.as_str();
1062 None
1063 }
1064 })
1065 .and_then(|next| {
1066 table.key(&next).map(|next| {
1067 next.leaf_decor()
1068 .prefix()
1069 .and_then(|prefix| prefix.as_str())
1070 .is_none_or(str::is_empty)
1071 })
1072 })
1073 .unwrap_or(false);
1074 (!next_prefix_empty).then(|| prefix.trim_start_matches(['\n', '\r']).to_string())
1075 }
1076
1077 fn set_key_comment(doc: &mut toml_edit::DocumentMut, path: &[String], comment: &str) {
1078 let Some((key, parents)) = path.split_last() else {
1079 return;
1080 };
1081 let mut current: &mut dyn toml_edit::TableLike = doc.as_table_mut();
1082 for part in parents {
1083 match current
1084 .get_mut(part)
1085 .and_then(toml_edit::Item::as_table_like_mut)
1086 {
1087 Some(next) => current = next,
1088 None => return,
1089 }
1090 }
1091 if let Some(mut key) = current.key_mut(key) {
1092 key.leaf_decor_mut().set_prefix(comment);
1093 }
1094 }
1095
1096 fn document_value(doc: &toml_edit::DocumentMut, path: &[String]) -> Option<toml_edit::Value> {
1097 let (key, parents) = path.split_last()?;
1098 let mut value = table_like_at(doc, parents)?.get(key)?.as_value()?.clone();
1099 // The destination key supplies its own leading spacing.
1100 value.decor_mut().set_prefix(" ");
1101 Some(value)
1102 }
1103
1104 fn document_has(doc: &toml_edit::DocumentMut, path: &[String]) -> bool {
1105 let mut item: &toml_edit::Item = doc.as_item();
1106 for part in path {
1107 match item.get(part) {
1108 Some(next) => item = next,
1109 None => return false,
1110 }
1111 }
1112 true
1113 }
1114
1115 fn toml_value_to_edit(value: &toml::Value) -> Option<toml_edit::Value> {
1116 match value {
1117 toml::Value::String(text) => Some(toml_edit::Value::from(text.as_str())),
1118 other => other.to_string().parse::<toml_edit::Value>().ok(),
1119 }
1120 }
1121
1122 #[cfg(test)]
1123 mod tests;
1124
1124 lines RUST