1use crate::cli::MigrateArgs;
23use crate::error::{CliError, CliResult};
24use serde_json::{Map, Value};
25use std::path::Path;
26
27pub async fn run(args: MigrateArgs) -> CliResult<()> {
29 let cwd = std::env::current_dir()?;
30 let path = match args.config.clone() {
31 Some(p) => p,
32 None => crate::env_loader::discover_config_path(&cwd)
33 .ok_or_else(|| CliError::Config("no config file found to migrate".into()))?,
34 };
35
36 let text = std::fs::read_to_string(&path)
37 .map_err(|e| CliError::Config(format!("cannot read '{}': {e}", path.display())))?;
38 let format = ConfigFormat::from_path(&path)?;
39 let mut value = format.parse(&text, &path)?;
40
41 let applied = migrate_value(&mut value);
42
43 if applied.is_empty() {
44 println!("{}: already current — no migration needed", path.display());
45 return Ok(());
46 }
47
48 let rendered = format.render(&value, &path)?;
49
50 if args.check {
51 eprintln!(
52 "{}: migration needed ({} rule{}):",
53 path.display(),
54 applied.len(),
55 if applied.len() == 1 { "" } else { "s" }
56 );
57 for rule in &applied {
58 eprintln!(" - {rule}");
59 }
60 return Err(CliError::Config(format!(
61 "{} is not up to date; run `faucet migrate` to upgrade it",
62 path.display()
63 )));
64 }
65
66 if args.stdout {
67 print!("{rendered}");
68 return Ok(());
69 }
70
71 std::fs::write(&path, rendered)
72 .map_err(|e| CliError::Config(format!("cannot write '{}': {e}", path.display())))?;
73 println!(
74 "{}: migrated ({} rule{} applied):",
75 path.display(),
76 applied.len(),
77 if applied.len() == 1 { "" } else { "s" }
78 );
79 for rule in &applied {
80 println!(" - {rule}");
81 }
82 Ok(())
83}
84
85#[derive(Clone, Copy)]
87enum ConfigFormat {
88 Yaml,
89 Json,
90}
91
92impl ConfigFormat {
93 fn from_path(path: &Path) -> CliResult<Self> {
94 match path.extension().and_then(|e| e.to_str()) {
95 Some("yaml") | Some("yml") => Ok(ConfigFormat::Yaml),
96 Some("json") => Ok(ConfigFormat::Json),
97 _ => Err(CliError::Config(format!(
98 "unsupported config extension for '{}' (expected .yaml/.yml/.json)",
99 path.display()
100 ))),
101 }
102 }
103
104 fn parse(self, text: &str, path: &Path) -> CliResult<Value> {
105 let err = |e: String| CliError::Config(format!("cannot parse '{}': {e}", path.display()));
106 match self {
107 ConfigFormat::Yaml => serde_yaml::from_str(text).map_err(|e| err(e.to_string())),
108 ConfigFormat::Json => serde_json::from_str(text).map_err(|e| err(e.to_string())),
109 }
110 }
111
112 fn render(self, value: &Value, path: &Path) -> CliResult<String> {
113 let err =
114 |e: String| CliError::Config(format!("cannot serialize '{}': {e}", path.display()));
115 match self {
116 ConfigFormat::Yaml => serde_yaml::to_string(value).map_err(|e| err(e.to_string())),
117 ConfigFormat::Json => {
118 let mut s = serde_json::to_string_pretty(value).map_err(|e| err(e.to_string()))?;
119 s.push('\n');
120 Ok(s)
121 }
122 }
123 }
124}
125
126pub(crate) fn migrate_value(value: &mut Value) -> Vec<String> {
130 let mut applied = Vec::new();
131 if migrate_toplevel_source_sink(value) {
132 applied.push(
133 "wrapped top-level `source:` / `sink:` in a `pipeline:` block (pre-#54 shape)".into(),
134 );
135 }
136 let n = migrate_legacy_auth(value);
137 if n > 0 {
138 applied.push(format!(
139 "folded {n} legacy `auth`/`credentials` block{} into `{{ type, config }}` (pre-#113 shape)",
140 if n == 1 { "" } else { "s" }
141 ));
142 }
143 applied
144}
145
146fn migrate_toplevel_source_sink(value: &mut Value) -> bool {
149 let Value::Object(root) = value else {
150 return false;
151 };
152 if root.contains_key("pipeline") {
153 return false;
154 }
155 let has_source = root.contains_key("source");
156 let has_sink = root.contains_key("sink");
157 if !has_source && !has_sink {
158 return false;
159 }
160 let mut pipeline = Map::new();
161 if let Some(s) = root.remove("source") {
162 pipeline.insert("source".into(), s);
163 }
164 if let Some(s) = root.remove("sink") {
165 pipeline.insert("sink".into(), s);
166 }
167 for key in ["transforms", "state"] {
170 if let Some(v) = root.remove(key) {
171 pipeline.insert(key.into(), v);
172 }
173 }
174 root.insert("pipeline".into(), Value::Object(pipeline));
175 true
176}
177
178fn migrate_legacy_auth(value: &mut Value) -> usize {
187 let mut count = 0;
188 walk_auth(value, false, &mut count);
189 count
190}
191
192fn walk_auth(value: &mut Value, under_auth_key: bool, count: &mut usize) {
193 match value {
194 Value::Object(map) => {
195 if under_auth_key && is_legacy_auth(map) {
196 fold_auth_config(map);
197 *count += 1;
198 }
199 for (k, v) in map.iter_mut() {
200 let child_is_auth = k == "auth" || k == "credentials";
201 walk_auth(v, child_is_auth, count);
202 }
203 }
204 Value::Array(items) => {
205 for v in items.iter_mut() {
206 walk_auth(v, false, count);
208 }
209 }
210 _ => {}
211 }
212}
213
214fn is_legacy_auth(map: &Map<String, Value>) -> bool {
217 map.get("type").is_some_and(Value::is_string)
218 && !map.contains_key("config")
219 && map.keys().any(|k| k != "type")
220}
221
222fn fold_auth_config(map: &mut Map<String, Value>) {
224 let type_val = map.remove("type");
225 let mut config = Map::new();
226 let keys: Vec<String> = map.keys().cloned().collect();
227 for k in keys {
228 if let Some(v) = map.remove(&k) {
229 config.insert(k, v);
230 }
231 }
232 map.clear();
233 if let Some(t) = type_val {
234 map.insert("type".into(), t);
235 }
236 map.insert("config".into(), Value::Object(config));
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use serde_json::json;
243
244 #[test]
245 fn wraps_toplevel_source_sink_into_pipeline() {
246 let mut v = json!({
247 "version": 1,
248 "source": { "type": "rest", "config": { "url": "x" } },
249 "sink": { "type": "jsonl", "config": { "path": "o.jsonl" } },
250 "transforms": [{ "flatten": {} }]
251 });
252 let applied = migrate_value(&mut v);
253 assert_eq!(applied.len(), 1);
254 assert!(v.get("source").is_none());
255 assert!(v.get("sink").is_none());
256 let p = v.get("pipeline").unwrap();
257 assert_eq!(p["source"]["type"], "rest");
258 assert_eq!(p["sink"]["type"], "jsonl");
259 assert!(p.get("transforms").is_some());
260 }
261
262 #[test]
263 fn toplevel_rule_is_noop_when_pipeline_present() {
264 let mut v = json!({
265 "version": 1,
266 "pipeline": { "source": { "type": "rest" }, "sink": { "type": "jsonl" } }
267 });
268 assert!(migrate_value(&mut v).is_empty());
269 }
270
271 #[test]
272 fn folds_legacy_auth_into_type_config() {
273 let mut v = json!({
274 "version": 1,
275 "pipeline": {
276 "source": {
277 "type": "rest",
278 "config": {
279 "url": "x",
280 "auth": { "type": "bearer", "token": "${env:TOK}" }
281 }
282 },
283 "sink": { "type": "jsonl", "config": { "path": "o.jsonl" } }
284 }
285 });
286 let applied = migrate_value(&mut v);
287 assert_eq!(applied.len(), 1);
288 let auth = &v["pipeline"]["source"]["config"]["auth"];
289 assert_eq!(auth["type"], "bearer");
290 assert_eq!(auth["config"]["token"], "${env:TOK}");
291 assert!(auth.get("token").is_none());
293 }
294
295 #[test]
296 fn auth_rule_is_idempotent_and_skips_current_shape() {
297 let v = json!({
298 "pipeline": { "source": { "type": "rest", "config": {
299 "auth": { "type": "bearer", "config": { "token": "t" } }
300 }}}
301 });
302 assert!(migrate_value(&mut v.clone()).is_empty());
304 let mut once = v.clone();
306 migrate_value(&mut once);
307 let mut twice = once.clone();
308 assert!(migrate_value(&mut twice).is_empty());
309 assert_eq!(once, twice);
310 }
311
312 #[test]
313 fn does_not_touch_non_auth_objects_with_a_type_field() {
314 let mut v = json!({
317 "pipeline": {
318 "source": { "type": "rest", "url": "x" },
319 "sink": { "type": "jsonl" }
320 }
321 });
322 let before = v.clone();
323 migrate_value(&mut v);
324 assert_eq!(v["pipeline"]["source"], before["pipeline"]["source"]);
326 }
327
328 #[test]
329 fn both_rules_compose() {
330 let mut v = json!({
331 "source": { "type": "rest", "config": {
332 "auth": { "type": "basic", "user": "u", "pass": "p" }
333 }},
334 "sink": { "type": "jsonl", "config": { "path": "o" } }
335 });
336 let applied = migrate_value(&mut v);
337 assert_eq!(
338 applied.len(),
339 2,
340 "both the source/sink wrap and the auth fold fire"
341 );
342 let auth = &v["pipeline"]["source"]["config"]["auth"];
343 assert_eq!(auth["type"], "basic");
344 assert_eq!(auth["config"]["user"], "u");
345 assert_eq!(auth["config"]["pass"], "p");
346 }
347
348 #[test]
349 fn fully_current_config_needs_no_migration() {
350 let mut v = json!({
351 "version": 1,
352 "pipeline": {
353 "source": { "type": "rest", "config": { "url": "x",
354 "auth": { "type": "bearer", "config": { "token": "t" } } } },
355 "sink": { "type": "jsonl", "config": { "path": "o.jsonl" } }
356 }
357 });
358 assert!(migrate_value(&mut v).is_empty());
359 }
360
361 #[test]
364 fn config_format_from_path() {
365 assert!(matches!(
366 ConfigFormat::from_path(Path::new("a.yaml")),
367 Ok(ConfigFormat::Yaml)
368 ));
369 assert!(matches!(
370 ConfigFormat::from_path(Path::new("a.yml")),
371 Ok(ConfigFormat::Yaml)
372 ));
373 assert!(matches!(
374 ConfigFormat::from_path(Path::new("a.json")),
375 Ok(ConfigFormat::Json)
376 ));
377 assert!(ConfigFormat::from_path(Path::new("a.toml")).is_err());
378 }
379
380 #[test]
381 fn config_format_parse_render_roundtrip() {
382 let p = Path::new("f.yaml");
383 let v = ConfigFormat::Yaml
384 .parse("version: 1\nname: demo\n", p)
385 .unwrap();
386 assert_eq!(v["name"], "demo");
387 let s = ConfigFormat::Yaml.render(&v, p).unwrap();
388 assert!(s.contains("name: demo"));
389
390 let pj = Path::new("f.json");
391 let vj = ConfigFormat::Json.parse(r#"{"version":1}"#, pj).unwrap();
392 let sj = ConfigFormat::Json.render(&vj, pj).unwrap();
393 assert!(sj.ends_with('\n') && sj.contains("\"version\""));
394 }
395
396 const LEGACY_YAML: &str = "version: 1\n\
397source:\n type: rest\n config:\n base_url: https://x\n auth: { type: bearer, token: t }\n\
398sink:\n type: jsonl\n config: { path: out.jsonl }\n";
399
400 fn write_tmp(name: &str, body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
401 let dir = tempfile::tempdir().expect("tempdir");
402 let path = dir.path().join(name);
403 std::fs::write(&path, body).expect("write");
404 (dir, path)
405 }
406
407 #[tokio::test]
408 async fn run_rewrites_legacy_file_in_place() {
409 let (_d, path) = write_tmp("old.yaml", LEGACY_YAML);
410 run(MigrateArgs {
411 config: Some(path.clone()),
412 check: false,
413 stdout: false,
414 })
415 .await
416 .unwrap();
417 let after = std::fs::read_to_string(&path).unwrap();
418 assert!(after.contains("pipeline:"), "{after}");
419 assert!(after.contains("config:"));
420 let before2 = std::fs::read_to_string(&path).unwrap();
422 run(MigrateArgs {
423 config: Some(path.clone()),
424 check: false,
425 stdout: false,
426 })
427 .await
428 .unwrap();
429 assert_eq!(std::fs::read_to_string(&path).unwrap(), before2);
430 }
431
432 #[tokio::test]
433 async fn run_check_errors_on_legacy_and_passes_on_current() {
434 let (_d, legacy) = write_tmp("old.yaml", LEGACY_YAML);
435 let err = run(MigrateArgs {
436 config: Some(legacy),
437 check: true,
438 stdout: false,
439 })
440 .await;
441 assert!(err.is_err(), "--check must fail on a legacy config");
442
443 let current = "version: 1\npipeline:\n source: { type: rest, config: { base_url: x } }\n sink: { type: jsonl, config: { path: o } }\n";
444 let (_d2, cur) = write_tmp("cur.yaml", current);
445 run(MigrateArgs {
446 config: Some(cur),
447 check: true,
448 stdout: false,
449 })
450 .await
451 .expect("--check passes on a current config");
452 }
453
454 #[tokio::test]
455 async fn run_stdout_does_not_write_the_file() {
456 let (_d, path) = write_tmp("old.yaml", LEGACY_YAML);
457 let original = std::fs::read_to_string(&path).unwrap();
458 run(MigrateArgs {
459 config: Some(path.clone()),
460 check: false,
461 stdout: true,
462 })
463 .await
464 .unwrap();
465 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
467 }
468}