Skip to main content

mur_common/
config_migrate.rs

1//! One-shot migration of the legacy conversations model fields.
2//!
3//! `conversations.{ask,compact,rollup}` used to carry a bare model-name
4//! string plus an `ollama_endpoint`, and resolution fabricated an Ollama
5//! backend from them. This converts those into explicit `BackendConfig`
6//! overrides so the fields can be deleted.
7//!
8//! Operates on raw YAML text, NOT on the typed `Config`: by the time this
9//! runs the struct no longer has the legacy fields, so a typed load would
10//! silently drop exactly the values we need to read.
11
12use serde_yaml_ng::{Mapping, Value};
13
14use crate::config::{DEFAULT_LOCAL_LLM_MODEL, DEFAULT_OLLAMA_ENDPOINT};
15
16/// One legacy model field and the override field it becomes.
17struct StageField {
18    legacy_model: &'static str,
19    backend_key: &'static str,
20}
21
22/// Every (stage, legacy model field → override field) pair. `compact` and
23/// `rollup` each own two models sharing one `ollama_endpoint`.
24const STAGES: &[(&str, &[StageField])] = &[
25    (
26        "ask",
27        &[StageField {
28            legacy_model: "model",
29            backend_key: "backend",
30        }],
31    ),
32    (
33        "compact",
34        &[
35            StageField {
36                legacy_model: "extractive_model",
37                backend_key: "extractive_backend",
38            },
39            StageField {
40                legacy_model: "abstractive_model",
41                backend_key: "abstractive_backend",
42            },
43        ],
44    ),
45    (
46        "rollup",
47        &[
48            StageField {
49                legacy_model: "extractive_model",
50                backend_key: "extractive_backend",
51            },
52            StageField {
53                legacy_model: "abstractive_model",
54                backend_key: "abstractive_backend",
55            },
56        ],
57    ),
58];
59
60/// Convert legacy `conversations.*` model fields into explicit backend
61/// overrides. Returns `None` when there was nothing to migrate — including
62/// when the input does not parse, so a syntactically broken config is left
63/// untouched rather than replaced.
64///
65/// A stage counts as *untouched* only when its model name AND its stage's
66/// `ollama_endpoint` both still hold the shipped defaults. A default model
67/// pointed at a custom endpoint (a remote Ollama box) is a deliberate choice
68/// and gets pinned, not inherited away.
69pub fn migrate_conversations_yaml(text: &str) -> Option<String> {
70    let mut root: Value = serde_yaml_ng::from_str(text).ok()?;
71    let conversations = root.get_mut("conversations")?.as_mapping_mut()?;
72
73    let mut changed = false;
74    for (stage_name, fields) in STAGES {
75        let Some(stage) = conversations
76            .get_mut(Value::from(*stage_name))
77            .and_then(Value::as_mapping_mut)
78        else {
79            continue;
80        };
81        if migrate_stage(stage, fields) {
82            changed = true;
83        }
84    }
85
86    changed
87        .then(|| serde_yaml_ng::to_string(&root).ok())
88        .flatten()
89}
90
91/// Returns true when this stage's mapping was modified.
92fn migrate_stage(stage: &mut Mapping, fields: &[StageField]) -> bool {
93    let endpoint = stage
94        .get(Value::from("ollama_endpoint"))
95        .and_then(Value::as_str)
96        .map(str::to_owned);
97
98    // Nothing legacy present at all → nothing to do.
99    let has_legacy = endpoint.is_some()
100        || fields
101            .iter()
102            .any(|f| stage.contains_key(Value::from(f.legacy_model)));
103    if !has_legacy {
104        return false;
105    }
106
107    let endpoint_is_default =
108        endpoint.as_deref().unwrap_or(DEFAULT_OLLAMA_ENDPOINT) == DEFAULT_OLLAMA_ENDPOINT;
109
110    for f in fields {
111        let model = stage
112            .remove(Value::from(f.legacy_model))
113            .as_ref()
114            .and_then(Value::as_str)
115            .map(str::to_owned);
116
117        // An override the user already wrote always wins — never overwrite it.
118        if stage.contains_key(Value::from(f.backend_key))
119            && !stage[Value::from(f.backend_key)].is_null()
120        {
121            continue;
122        }
123
124        let Some(model) = model else { continue };
125        if model == DEFAULT_LOCAL_LLM_MODEL && endpoint_is_default {
126            // Untouched: leave the override absent so the stage inherits smart.
127            continue;
128        }
129
130        let mut backend = Mapping::new();
131        backend.insert(Value::from("provider"), Value::from("ollama"));
132        backend.insert(Value::from("model"), Value::from(model));
133        backend.insert(
134            Value::from("endpoint"),
135            Value::from(
136                endpoint
137                    .clone()
138                    .unwrap_or_else(|| DEFAULT_OLLAMA_ENDPOINT.to_string()),
139            ),
140        );
141        stage.insert(Value::from(f.backend_key), Value::Mapping(backend));
142    }
143
144    stage.remove(Value::from("ollama_endpoint"));
145    true
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn untouched_stage_migrates_to_inherit() {
154        let yaml = "\
155conversations:
156  ask:
157    model: qwen3.5:4b
158    ollama_endpoint: http://localhost:11434
159    timeout_secs: 120
160";
161        let out = migrate_conversations_yaml(yaml).expect("migrates");
162        assert!(
163            !out.contains("ollama_endpoint"),
164            "legacy endpoint removed: {out}"
165        );
166        assert!(
167            !out.contains("model: qwen3.5:4b"),
168            "legacy model removed: {out}"
169        );
170        assert!(!out.contains("backend:"), "no pin written: {out}");
171        assert!(
172            out.contains("timeout_secs: 120"),
173            "unrelated keys kept: {out}"
174        );
175    }
176
177    #[test]
178    fn customized_model_migrates_to_a_pinned_ollama_backend() {
179        let yaml = "\
180conversations:
181  ask:
182    model: llama3:70b
183    ollama_endpoint: http://localhost:11434
184";
185        let out = migrate_conversations_yaml(yaml).expect("migrates");
186        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
187        let b = &v["conversations"]["ask"]["backend"];
188        assert_eq!(b["provider"].as_str(), Some("ollama"));
189        assert_eq!(b["model"].as_str(), Some("llama3:70b"));
190        assert_eq!(b["endpoint"].as_str(), Some("http://localhost:11434"));
191    }
192
193    #[test]
194    fn default_model_at_a_custom_endpoint_is_pinned_not_inherited() {
195        let yaml = "\
196conversations:
197  ask:
198    model: qwen3.5:4b
199    ollama_endpoint: http://box.local:11434
200";
201        let out = migrate_conversations_yaml(yaml).expect("migrates");
202        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
203        let b = &v["conversations"]["ask"]["backend"];
204        assert_eq!(b["endpoint"].as_str(), Some("http://box.local:11434"));
205        assert_eq!(b["model"].as_str(), Some("qwen3.5:4b"));
206    }
207
208    #[test]
209    fn an_existing_override_is_left_alone_and_its_legacy_siblings_dropped() {
210        let yaml = "\
211conversations:
212  ask:
213    model: llama3:70b
214    ollama_endpoint: http://localhost:11434
215    backend:
216      provider: openai
217      model: Qwen3.5-4B-MLX-4bit
218      endpoint: http://127.0.0.1:8000/v1
219";
220        let out = migrate_conversations_yaml(yaml).expect("migrates");
221        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
222        assert_eq!(
223            v["conversations"]["ask"]["backend"]["provider"].as_str(),
224            Some("openai")
225        );
226        assert!(v["conversations"]["ask"]["model"].is_null());
227        assert!(v["conversations"]["ask"]["ollama_endpoint"].is_null());
228    }
229
230    #[test]
231    fn compacts_two_models_share_one_endpoint() {
232        let yaml = "\
233conversations:
234  compact:
235    extractive_model: llama3:70b
236    abstractive_model: qwen3.5:4b
237    ollama_endpoint: http://box.local:11434
238";
239        let out = migrate_conversations_yaml(yaml).expect("migrates");
240        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
241        let c = &v["conversations"]["compact"];
242        assert_eq!(
243            c["extractive_backend"]["model"].as_str(),
244            Some("llama3:70b")
245        );
246        assert_eq!(
247            c["extractive_backend"]["endpoint"].as_str(),
248            Some("http://box.local:11434")
249        );
250        // default model name, but the endpoint was customized → still pinned
251        assert_eq!(
252            c["abstractive_backend"]["endpoint"].as_str(),
253            Some("http://box.local:11434")
254        );
255    }
256
257    #[test]
258    fn rollup_migrates_too() {
259        let yaml = "\
260conversations:
261  rollup:
262    extractive_model: llama3:70b
263    abstractive_model: llama3:70b
264    ollama_endpoint: http://localhost:11434
265";
266        let out = migrate_conversations_yaml(yaml).expect("migrates");
267        let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
268        assert_eq!(
269            v["conversations"]["rollup"]["extractive_backend"]["model"].as_str(),
270            Some("llama3:70b")
271        );
272        assert_eq!(
273            v["conversations"]["rollup"]["abstractive_backend"]["model"].as_str(),
274            Some("llama3:70b")
275        );
276    }
277
278    #[test]
279    fn is_idempotent() {
280        let yaml = "\
281conversations:
282  ask:
283    model: llama3:70b
284    ollama_endpoint: http://localhost:11434
285";
286        let once = migrate_conversations_yaml(yaml).expect("migrates");
287        assert!(
288            migrate_conversations_yaml(&once).is_none(),
289            "second pass must be a no-op"
290        );
291    }
292
293    #[test]
294    fn a_config_without_legacy_keys_is_untouched() {
295        assert!(migrate_conversations_yaml("skills:\n  max_skills_in_prompt: 5\n").is_none());
296        assert!(migrate_conversations_yaml("").is_none());
297    }
298
299    #[test]
300    fn unparseable_yaml_is_left_alone_rather_than_destroyed() {
301        assert!(migrate_conversations_yaml("conversations: [unclosed\n").is_none());
302    }
303}