Skip to main content

faucet_cli/commands/
migrate.rs

1//! `faucet migrate` — upgrade a config written against an older `faucet`
2//! grammar to the current shape (#388).
3//!
4//! Each migration is a **pure** `serde_json::Value → serde_json::Value`
5//! transform with a before/after unit test. The command reads a config, applies
6//! every rule, and (unless `--check`) rewrites it in place, printing which rules
7//! fired. Migrations are **idempotent**: running `migrate` on an already-current
8//! config changes nothing and exits 0.
9//!
10//! Two rules ship today:
11//!
12//! 1. **Top-level `source:` / `sink:` → `pipeline.source` / `pipeline.sink`**
13//!    (the pre-#54 shape). If the document has top-level `source`/`sink` keys
14//!    and no `pipeline`, they move under a new `pipeline:` map.
15//! 2. **Legacy auth → adjacently-tagged `{ type, config }`** (the pre-#113
16//!    shape). Any `auth:` / `credentials:` object carrying a `type` string plus
17//!    sibling fields but no `config` has those siblings folded into `config`.
18//!
19//! Comments are not preserved (the config is parsed and re-serialized) — the
20//! same limitation `faucet fmt` documents.
21
22use crate::cli::MigrateArgs;
23use crate::error::{CliError, CliResult};
24use serde_json::{Map, Value};
25use std::path::Path;
26
27/// Execute the `migrate` subcommand.
28pub 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/// The on-disk serialization of a config file.
86#[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
126/// Apply every migration rule in order, returning a human description of each
127/// rule that actually changed the document. An empty result means the config is
128/// already current. Pure and idempotent.
129pub(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
146/// Rule 1: move top-level `source:` / `sink:` under a new `pipeline:` map.
147/// No-op if there is already a `pipeline:` key or neither top-level key exists.
148fn 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    // Also relocate the sibling `transforms:` / `state:` blocks, which lived at
168    // the top level alongside the old `source:`/`sink:`.
169    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
178/// Rule 2: fold a legacy `auth`/`credentials` object of the shape
179/// `{ type: X, <field>: … }` into `{ type: X, config: { <field>: … } }`.
180/// Recurses through the whole document. Returns the number of blocks migrated.
181///
182/// Only objects reached under a key literally named `auth` or `credentials` are
183/// considered, and only when they carry a `type` string, at least one other
184/// field, and no existing `config` — so it never touches an already-migrated
185/// block or an unrelated object that happens to have a `type` field.
186fn 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                // Array elements are not themselves "under" the auth key name.
207                walk_auth(v, false, count);
208            }
209        }
210        _ => {}
211    }
212}
213
214/// A map is the legacy auth shape if it has a string `type`, no `config`, and at
215/// least one field besides `type`.
216fn 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
222/// Move every field except `type` into a nested `config` object.
223fn 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        // No stray sibling left behind.
292        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        // Already `{type, config}` → untouched.
303        assert!(migrate_value(&mut v.clone()).is_empty());
304        // And running twice is a no-op on the second pass.
305        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        // A `source: { type, config }` has a `type` but is NOT under an
315        // auth/credentials key, so it must be left alone.
316        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        // The source's `url` sibling is NOT folded into a config (it's not auth).
325        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    // ── ConfigFormat + the `run` command flow ────────────────────────────────
362
363    #[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        // Idempotent: a second migrate reports no change and leaves the file.
421        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        // --stdout prints the migrated config but leaves the file untouched.
466        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
467    }
468}