1use 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#[derive(Debug, Default, PartialEq, Eq)]
23pub struct RecordDiff {
24 pub added: Vec<String>,
25 pub removed: Vec<String>,
26 pub kept: usize,
27}
28
29pub 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
41pub 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 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 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
90pub 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
99fn 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
114async 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
166pub 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 let (mut prev, err, dlq) = run_once(&args, &sample).await?;
181 render(None, &prev, &err, dlq);
182
183 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
192async fn watch_loop(args: DevArgs, sample: Vec<Value>, prev: &mut Vec<Value>) -> CliResult<()> {
194 use notify_fs::{RecursiveMode, Watcher};
195
196 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 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}