mur_common/
config_migrate.rs1use serde_yaml_ng::{Mapping, Value};
13
14use crate::config::{DEFAULT_LOCAL_LLM_MODEL, DEFAULT_OLLAMA_ENDPOINT};
15
16struct StageField {
18 legacy_model: &'static str,
19 backend_key: &'static str,
20}
21
22const 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
60pub 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
91fn 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 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 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 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 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}