Skip to main content

faucet_cli/commands/
dev.rs

1//! `faucet dev` — a watch-and-diff authoring loop (#283, `cli-dev` feature).
2//!
3//! Re-runs a bounded sample through the offline pipeline harness
4//! (`pipeline_test::run_case` — transforms → quality → contract, zero real sink
5//! writes) on every config save and prints the resulting schema, quality/DLQ
6//! counts, errors, and a **diff vs the previous run**. Fast and offline by
7//! default (`--sample <fixture>`); `--live --limit N` pulls a capped, read-only
8//! sample from the real source instead.
9//!
10//! Only the filesystem-watch glue touches the OS; the decision logic
11//! (`diff_records`, `referenced_paths`, `should_refire`) is pure and unit-tested.
12
13use crate::cli::DevArgs;
14use crate::error::{CliError, CliResult};
15use crate::pipeline_test::runner::run_case;
16use serde_json::Value;
17use std::collections::BTreeSet;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21/// A record-level diff between two runs' output.
22#[derive(Debug, Default, PartialEq, Eq)]
23pub struct RecordDiff {
24    pub added: Vec<String>,
25    pub removed: Vec<String>,
26    pub kept: usize,
27}
28
29/// Diff two output record sets by their canonical JSON (order-insensitive).
30pub fn diff_records(prev: &[Value], curr: &[Value]) -> RecordDiff {
31    let ser = |v: &Value| serde_json::to_string(v).unwrap_or_default();
32    let prev_set: BTreeSet<String> = prev.iter().map(ser).collect();
33    let curr_set: BTreeSet<String> = curr.iter().map(ser).collect();
34    RecordDiff {
35        added: curr_set.difference(&prev_set).cloned().collect(),
36        removed: prev_set.difference(&curr_set).cloned().collect(),
37        kept: prev_set.intersection(&curr_set).count(),
38    }
39}
40
41/// Shallowly extract the paths a config references so we can watch them too:
42/// `extends:` targets (string or list) and `!include <path>` tags. Relative
43/// paths resolve against `dir`. Pure over `(dir, text)`.
44pub fn referenced_paths(dir: &Path, text: &str) -> Vec<PathBuf> {
45    let mut out = Vec::new();
46    let mut in_extends_list = false;
47    for line in text.lines() {
48        let trimmed = line.trim();
49        // `!include <path>` anywhere on a line.
50        if let Some(idx) = trimmed.find("!include") {
51            let rest = trimmed[idx + "!include".len()..]
52                .trim()
53                .trim_matches(['"', '\'']);
54            if !rest.is_empty() {
55                out.push(dir.join(rest));
56            }
57        }
58        // `extends: <path>` or an `extends:` list.
59        if let Some(rest) = trimmed.strip_prefix("extends:") {
60            in_extends_list = false;
61            let rest = rest.trim().trim_matches(['"', '\'']);
62            if rest.is_empty() {
63                in_extends_list = true;
64            } else if !rest.starts_with('[') {
65                out.push(dir.join(rest));
66            } else {
67                for item in rest.trim_matches(['[', ']']).split(',') {
68                    let p = item.trim().trim_matches(['"', '\'']);
69                    if !p.is_empty() {
70                        out.push(dir.join(p));
71                    }
72                }
73            }
74            continue;
75        }
76        if in_extends_list {
77            if let Some(item) = trimmed.strip_prefix('-') {
78                let p = item.trim().trim_matches(['"', '\'']);
79                if !p.is_empty() {
80                    out.push(dir.join(p));
81                }
82            } else if !trimmed.is_empty() {
83                in_extends_list = false;
84            }
85        }
86    }
87    out
88}
89
90/// Leading-edge debounce: fire only if at least `min_gap` has elapsed since the
91/// last fire. Pure.
92pub fn should_refire(last: Option<Instant>, now: Instant, min_gap: Duration) -> bool {
93    match last {
94        None => true,
95        Some(t) => now.duration_since(t) >= min_gap,
96    }
97}
98
99/// Load an offline sample fixture (`.jsonl` or `.json` array).
100fn read_sample(path: &Path) -> CliResult<Vec<Value>> {
101    let text = std::fs::read_to_string(path)?;
102    let t = text.trim_start();
103    if t.starts_with('[') {
104        serde_json::from_str(t)
105            .map_err(|e| CliError::Config(format!("invalid --sample `{}`: {e}", path.display())))
106    } else {
107        text.lines()
108            .filter(|l| !l.trim().is_empty())
109            .map(|l| serde_json::from_str(l).map_err(|e| CliError::Config(e.to_string())))
110            .collect()
111    }
112}
113
114/// Run the selected row once through the offline harness and return its output
115/// records (+ any run error string). Reused for the initial run and each save.
116async fn run_once(
117    args: &DevArgs,
118    sample: &[Value],
119) -> CliResult<(Vec<Value>, Option<String>, usize)> {
120    let cfg = crate::config::PipelineConfig::from_path_tolerating_secrets(
121        &args.config,
122        args.profile.as_deref(),
123    )?;
124    let nodes = crate::expand::expand(&cfg)?;
125    let node = crate::commands::plan::select_root(&nodes, args.row.as_deref())?;
126    let clock = chrono::Utc::now().fixed_offset();
127    let case = crate::commands::plan::resolved_case_from_node(node, sample.to_vec(), clock);
128    let run = run_case(&case).await?;
129    Ok((run.written, run.error, run.dlq_payloads.len()))
130}
131
132fn render(prev: Option<&[Value]>, curr: &[Value], error: &Option<String>, dlq: usize) {
133    let schema = faucet_core::schema::infer_schema(curr);
134    let cols = schema
135        .get("properties")
136        .and_then(Value::as_object)
137        .map(|o| o.keys().cloned().collect::<Vec<_>>().join(", "))
138        .unwrap_or_default();
139    println!("── run @ {} ─────────────", short_now());
140    println!("  {} record(s) out, {} to DLQ", curr.len(), dlq);
141    println!("  schema: {{ {cols} }}");
142    if let Some(err) = error {
143        println!("  ⚠ run error: {err}");
144    }
145    if let Some(prev) = prev {
146        let d = diff_records(prev, curr);
147        println!(
148            "  diff vs previous: +{} -{} ={}",
149            d.added.len(),
150            d.removed.len(),
151            d.kept
152        );
153        for a in d.added.iter().take(3) {
154            println!("    + {a}");
155        }
156        for r in d.removed.iter().take(3) {
157            println!("    - {r}");
158        }
159    }
160}
161
162fn short_now() -> String {
163    chrono::Utc::now().format("%H:%M:%S").to_string()
164}
165
166/// Execute the `dev` subcommand.
167pub async fn run(args: DevArgs) -> CliResult<()> {
168    use std::io::IsTerminal;
169
170    let sample = match &args.sample {
171        Some(p) => read_sample(p)?,
172        None => {
173            return Err(CliError::Config(
174                "faucet dev needs an offline sample: pass --sample <fixture.jsonl>".to_owned(),
175            ));
176        }
177    };
178
179    // Initial run.
180    let (mut prev, err, dlq) = run_once(&args, &sample).await?;
181    render(None, &prev, &err, dlq);
182
183    // Non-interactive or --once: single shot.
184    if args.once || !std::io::stdin().is_terminal() {
185        println!("(single run — not watching: pass a TTY and omit --once to watch)");
186        return Ok(());
187    }
188
189    watch_loop(args, sample, &mut prev).await
190}
191
192/// The filesystem-watch loop (the only OS-touching part).
193async fn watch_loop(args: DevArgs, sample: Vec<Value>, prev: &mut Vec<Value>) -> CliResult<()> {
194    use notify_fs::{RecursiveMode, Watcher};
195
196    // Watch the config file's directory plus the directories of any referenced
197    // (`extends:` / `!include`) fragments, so editing an include re-triggers.
198    let cfg_dir = args
199        .config
200        .parent()
201        .map(Path::to_path_buf)
202        .unwrap_or_else(|| PathBuf::from("."));
203    let text = std::fs::read_to_string(&args.config).unwrap_or_default();
204    let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
205    dirs.insert(cfg_dir.clone());
206    for p in referenced_paths(&cfg_dir, &text) {
207        if let Some(d) = p.parent() {
208            dirs.insert(d.to_path_buf());
209        }
210    }
211
212    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
213    let mut watcher =
214        notify_fs::recommended_watcher(move |res: notify_fs::Result<notify_fs::Event>| {
215            if res.is_ok() {
216                let _ = tx.send(());
217            }
218        })
219        .map_err(|e| CliError::Config(format!("failed to start file watcher: {e}")))?;
220    for d in &dirs {
221        watcher
222            .watch(d, RecursiveMode::NonRecursive)
223            .map_err(|e| CliError::Config(format!("failed to watch {}: {e}", d.display())))?;
224    }
225
226    println!(
227        "\nwatching {} director{} — edit the config to re-run (Ctrl-C to stop)",
228        dirs.len(),
229        if dirs.len() == 1 { "y" } else { "ies" }
230    );
231
232    let debounce = Duration::from_millis(args.debounce_ms);
233    let mut last_fire: Option<Instant> = None;
234    loop {
235        tokio::select! {
236            _ = tokio::signal::ctrl_c() => {
237                println!("\nstopping.");
238                return Ok(());
239            }
240            recv = rx.recv() => {
241                if recv.is_none() {
242                    return Ok(());
243                }
244                // Coalesce a burst of events, then debounce.
245                while rx.try_recv().is_ok() {}
246                let now = Instant::now();
247                if !should_refire(last_fire, now, debounce) {
248                    continue;
249                }
250                last_fire = Some(now);
251                match run_once(&args, &sample).await {
252                    Ok((curr, err, dlq)) => {
253                        render(Some(prev), &curr, &err, dlq);
254                        *prev = curr;
255                    }
256                    Err(e) => println!("  ⚠ reload failed: {e}"),
257                }
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use serde_json::json;
267
268    #[test]
269    fn diff_records_detects_add_remove_keep() {
270        let prev = vec![json!({"a": 1}), json!({"a": 2})];
271        let curr = vec![json!({"a": 2}), json!({"a": 3})];
272        let d = diff_records(&prev, &curr);
273        assert_eq!(d.kept, 1);
274        assert_eq!(d.added.len(), 1);
275        assert_eq!(d.removed.len(), 1);
276        assert!(d.added[0].contains("\"a\":3"));
277        assert!(d.removed[0].contains("\"a\":1"));
278    }
279
280    #[test]
281    fn referenced_paths_extracts_extends_and_include() {
282        let dir = Path::new("/cfg");
283        let text = "extends: base.yaml\npipeline:\n  source: !include src.yaml\n";
284        let paths = referenced_paths(dir, text);
285        assert!(paths.contains(&PathBuf::from("/cfg/base.yaml")));
286        assert!(paths.contains(&PathBuf::from("/cfg/src.yaml")));
287    }
288
289    #[test]
290    fn referenced_paths_handles_extends_list() {
291        let dir = Path::new("/cfg");
292        let text = "extends:\n  - base1.yaml\n  - base2.yaml\nname: x\n";
293        let paths = referenced_paths(dir, text);
294        assert!(paths.contains(&PathBuf::from("/cfg/base1.yaml")));
295        assert!(paths.contains(&PathBuf::from("/cfg/base2.yaml")));
296    }
297
298    #[test]
299    fn should_refire_respects_min_gap() {
300        let now = Instant::now();
301        assert!(should_refire(None, now, Duration::from_millis(100)));
302        assert!(!should_refire(Some(now), now, Duration::from_millis(100)));
303        assert!(should_refire(
304            Some(now - Duration::from_millis(200)),
305            now,
306            Duration::from_millis(100)
307        ));
308    }
309
310    #[tokio::test]
311    async fn run_once_produces_output_offline() {
312        let dir = tempfile::tempdir().unwrap();
313        let cfg = dir.path().join("p.yaml");
314        std::fs::write(
315            &cfg,
316            "version: 1\npipeline:\n  source:\n    type: csv\n    config:\n      path: in.csv\n  sink:\n    type: jsonl\n    config:\n      path: out.jsonl\n",
317        )
318        .unwrap();
319        let args = DevArgs {
320            config: cfg,
321            row: None,
322            sample: None,
323            live: false,
324            limit: 10,
325            once: true,
326            debounce_ms: 300,
327            profile: None,
328        };
329        let (out, err, _dlq) = run_once(&args, &[json!({"a": 1})]).await.unwrap();
330        assert_eq!(out.len(), 1);
331        assert!(err.is_none());
332    }
333}