Skip to main content

faucet_cli/commands/
doctor.rs

1//! `faucet doctor` — preflight probes for every connector in a config (#126).
2//!
3//! Expands the config, builds each **root** invocation's source / sink / state
4//! store, and runs their non-mutating `check()` probes concurrently (bounded by
5//! a semaphore, each wrapped in a per-probe timeout). Prints a green/red
6//! checklist (or `--json`) and exits with the number of failed probes
7//! (clamped to 255).
8//!
9//! Child invocations are listed but not probed: their configs depend on parent
10//! records that only exist at run time (same limitation as `faucet preview`).
11
12use crate::auth_catalog::{AuthCatalog, build_auth_catalog};
13use crate::cli::DoctorArgs;
14use crate::config::{ConnectorSpec, PipelineConfig, StateStoreSpec};
15use crate::error::{CliError, CliResult};
16use crate::expand::{ExpandedNode, NodeRole, expand};
17use crate::registry::{build_sink, build_source};
18use crate::secrets::registry::redact;
19use crate::state::build_state_store;
20use faucet_core::check::{CheckContext, CheckReport, Probe, ProbeStatus};
21use serde::Serialize;
22use std::path::Path;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25use tokio::sync::Semaphore;
26
27/// A single probe enriched with the role + connector it came from. This is the
28/// `--json` shape for each probe.
29#[derive(Debug, Serialize)]
30pub struct ProbeOut {
31    pub role: &'static str,
32    pub connector: String,
33    pub name: &'static str,
34    #[serde(flatten)]
35    pub status: ProbeStatus,
36    pub elapsed_ms: u64,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub hint: Option<String>,
39}
40
41impl ProbeOut {
42    fn from_probe(role: &'static str, connector: String, p: Probe) -> Self {
43        Self {
44            role,
45            connector,
46            name: p.name,
47            status: p.status,
48            elapsed_ms: p.elapsed_ms,
49            hint: p.hint,
50        }
51    }
52}
53
54/// One expanded invocation and its probes (the `--json` per-invocation shape).
55#[derive(Debug, Serialize)]
56pub struct InvocationOut {
57    pub id: String,
58    pub probes: Vec<ProbeOut>,
59    /// The derived end-to-end delivery guarantee for this invocation (#292),
60    /// e.g. `"effectively-once (atomic watermark)"`. `None` for synthetic
61    /// entries (lineage transport).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub delivery: Option<String>,
64    // Connector kinds for the human header; not part of the JSON contract.
65    #[serde(skip)]
66    pub source_kind: String,
67    #[serde(skip)]
68    pub sink_kind: String,
69}
70
71/// Execute the `doctor` subcommand.
72pub async fn run(args: DoctorArgs) -> CliResult<()> {
73    let overall = Instant::now();
74    let cwd = std::env::current_dir()?;
75    let env_path =
76        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
77    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
78    let path = match args.config {
79        Some(p) => p,
80        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
81    };
82
83    let t_cfg = Instant::now();
84    let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
85    let cfg_ms = t_cfg.elapsed().as_millis();
86
87    let nodes = expand(&cfg)?;
88
89    // `--offline`: run only the static config lints — no connectors built, no
90    // network, no credentials. Fast, CI-friendly, and credential-free.
91    if args.offline {
92        let raw = std::fs::read_to_string(&path).unwrap_or_default();
93        let findings = lint_config(&cfg, &nodes, &raw);
94        let errors = render_lints(&path, &findings, args.json);
95        if errors > 0 {
96            return Err(CliError::DoctorFailed { failed: errors });
97        }
98        return Ok(());
99    }
100
101    let auth = build_auth_catalog(cfg.auth.as_ref())?;
102    let ctx = CheckContext {
103        timeout: Duration::from_secs(args.timeout_secs),
104    };
105    // Same derivation as `faucet run`, so the SLA probes read the same
106    // state keys the executor writes.
107    let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
108        path.file_stem()
109            .and_then(|s| s.to_str())
110            .unwrap_or("pipeline")
111            .to_owned()
112    });
113
114    let roots: Vec<&ExpandedNode> = nodes
115        .iter()
116        .filter(|n| matches!(n.role, NodeRole::Root))
117        .collect();
118    let n_children = nodes.len() - roots.len();
119
120    let mut invocations = probe_roots(&nodes, &auth, &ctx, cfg.sla.as_ref(), &pipeline_name).await;
121
122    // Lineage transport reachability — one pipeline-wide probe (the `lineage:`
123    // block is top-level, not per-row), rendered as its own invocation entry so
124    // it isn't duplicated across roots.
125    #[cfg(feature = "lineage")]
126    if let Some(inv) = probe_lineage(cfg.lineage.as_ref()).await {
127        invocations.push(inv);
128    }
129
130    redact_invocations(&mut invocations);
131    let (_passed, failed, _skipped) = tally(&invocations);
132
133    if args.json {
134        let v = build_json(&path, overall.elapsed().as_millis(), &invocations);
135        println!(
136            "{}",
137            serde_json::to_string_pretty(&v).expect("doctor json serializes")
138        );
139    } else {
140        render_human(
141            cfg_ms,
142            roots.len(),
143            n_children,
144            overall.elapsed(),
145            &invocations,
146        );
147    }
148
149    if failed > 0 {
150        return Err(CliError::DoctorFailed { failed });
151    }
152    Ok(())
153}
154
155/// Build the three connectors for one invocation and run their probes. When an
156/// `sla:` block is configured, `sla` carries the spec plus the invocation's
157/// base state key so the persisted SLA history can be probed read-only
158/// (staleness vs `max_staleness_secs`, volume-baseline warm-up).
159pub async fn probe_invocation(
160    id: String,
161    source: ConnectorSpec,
162    sink: ConnectorSpec,
163    state: Option<StateStoreSpec>,
164    auth: &AuthCatalog,
165    ctx: &CheckContext,
166    sla: Option<(crate::sla::SlaSpec, String)>,
167) -> InvocationOut {
168    let mut probes = Vec::new();
169
170    match build_source(&source.kind, source.config.clone(), auth, None).await {
171        Ok(src) => {
172            probes.extend(collect_probes("source", src.connector_name(), ctx, src.check(ctx)).await)
173        }
174        Err(e) => probes.push(construct_fail("source", &source.kind, &e)),
175    }
176
177    match build_sink(&sink.kind, sink.config.clone(), auth).await {
178        Ok(snk) => {
179            probes.extend(collect_probes("sink", snk.connector_name(), ctx, snk.check(ctx)).await)
180        }
181        Err(e) => probes.push(construct_fail("sink", &sink.kind, &e)),
182    }
183
184    let mut store = None;
185    if let Some(spec) = state {
186        match build_state_store(&spec).await {
187            Ok(st) => {
188                probes.extend(collect_probes("state", &spec.kind, ctx, st.check(ctx)).await);
189                store = Some(st);
190            }
191            Err(e) => probes.push(construct_fail("state", &spec.kind, &e)),
192        }
193    }
194
195    if let Some((spec, base_key)) = sla {
196        let now = chrono::Utc::now().timestamp();
197        let sla_probes = tokio::time::timeout(
198            ctx.timeout,
199            crate::sla::doctor_probes(&spec, store.as_ref(), &base_key, now),
200        )
201        .await
202        .unwrap_or_else(|_| {
203            vec![Probe::fail(
204                "history",
205                ctx.timeout,
206                "SLA state read timed out",
207            )]
208        });
209        probes.extend(
210            sla_probes
211                .into_iter()
212                .map(|p| ProbeOut::from_probe("sla", "sla".to_string(), p)),
213        );
214    }
215
216    InvocationOut {
217        id,
218        probes,
219        delivery: None,
220        source_kind: source.kind,
221        sink_kind: sink.kind,
222    }
223}
224
225/// Probe the pipeline-wide `lineage:` transport reachability, returning a
226/// single-probe invocation (or `None` when no `lineage:` block is configured).
227/// A failed probe is diagnostic only — lineage emission never blocks a run.
228#[cfg(feature = "lineage")]
229pub async fn probe_lineage(
230    lineage: Option<&faucet_lineage::LineageConfig>,
231) -> Option<InvocationOut> {
232    let lc = lineage?;
233    let start = Instant::now();
234    let probe = match crate::lineage_glue::check_transport(lc).await {
235        Ok(_) => Probe::pass("reachable", start.elapsed()),
236        Err(reason) => Probe::fail("reachable", start.elapsed(), reason),
237    };
238    Some(InvocationOut {
239        id: "lineage".to_string(),
240        delivery: None,
241        probes: vec![ProbeOut::from_probe(
242            "lineage",
243            "openlineage".to_string(),
244            probe,
245        )],
246        source_kind: "—".to_string(),
247        sink_kind: "—".to_string(),
248    })
249}
250
251/// Probe every *root* invocation's source/sink/state concurrently (bounded).
252/// Child invocations are skipped — their configs need parent records. Reused by
253/// `faucet doctor` and serve's `doctor_first` preflight. `sla` /
254/// `pipeline_name` come from the top-level config; when set, each root also
255/// gets read-only SLA staleness/baseline probes against its state store.
256pub async fn probe_roots(
257    nodes: &[ExpandedNode],
258    auth: &AuthCatalog,
259    ctx: &CheckContext,
260    sla: Option<&crate::sla::SlaSpec>,
261    pipeline_name: &str,
262) -> Vec<InvocationOut> {
263    let sem = Arc::new(Semaphore::new(8));
264    let mut handles = Vec::new();
265    for node in nodes.iter().filter(|n| matches!(n.role, NodeRole::Root)) {
266        let id = node.id.clone();
267        let source = node.source.clone();
268        let sink = node.sink.clone();
269        let state = node.state.clone();
270        let auth = auth.clone();
271        let ctx = ctx.clone();
272        let sem = sem.clone();
273        let sla = sla.map(|s| {
274            (
275                s.clone(),
276                crate::executor::build_state_key(pipeline_name, &node.id, None),
277            )
278        });
279        let guarantee = node.delivery_guarantee.to_string();
280        handles.push(tokio::spawn(async move {
281            let _permit = sem.acquire_owned().await.expect("semaphore not closed");
282            let mut inv = probe_invocation(id, source, sink, state, &auth, &ctx, sla).await;
283            inv.delivery = Some(guarantee);
284            inv
285        }));
286    }
287    let mut out = Vec::with_capacity(handles.len());
288    for h in handles {
289        out.push(h.await.expect("doctor probe task panicked"));
290    }
291    out
292}
293
294/// Total number of failed probes across all invocations.
295pub fn count_failures(invs: &[InvocationOut]) -> usize {
296    invs.iter()
297        .flat_map(|i| &i.probes)
298        .filter(|p| matches!(p.status, ProbeStatus::Fail { .. }))
299        .count()
300}
301
302/// Run one connector's `check()` future under the timeout, mapping the report
303/// (or an outer error / timeout) into role-tagged [`ProbeOut`]s.
304async fn collect_probes(
305    role: &'static str,
306    connector: &str,
307    ctx: &CheckContext,
308    fut: impl std::future::Future<Output = Result<CheckReport, faucet_core::FaucetError>>,
309) -> Vec<ProbeOut> {
310    let start = Instant::now();
311    let report = match tokio::time::timeout(ctx.timeout, fut).await {
312        Err(_) => CheckReport::single(Probe::fail("timeout", start.elapsed(), "check timed out")),
313        Ok(Ok(r)) => r,
314        Ok(Err(e)) => CheckReport::single(Probe::fail("check", start.elapsed(), e.to_string())),
315    };
316    report
317        .probes
318        .into_iter()
319        .map(|p| ProbeOut::from_probe(role, connector.to_string(), p))
320        .collect()
321}
322
323/// A `construct` failure: the connector could not even be built from its config.
324/// Accepts any `Display` error (build errors are `CliError`; the unit test uses
325/// `FaucetError`).
326fn construct_fail(role: &'static str, kind: &str, e: impl std::fmt::Display) -> ProbeOut {
327    ProbeOut::from_probe(
328        role,
329        kind.to_string(),
330        Probe::fail("construct", Duration::ZERO, e.to_string()),
331    )
332}
333
334/// Scrub resolved secrets out of every probe `reason` / `hint`.
335pub fn redact_invocations(invs: &mut [InvocationOut]) {
336    for inv in invs.iter_mut() {
337        for p in inv.probes.iter_mut() {
338            match &mut p.status {
339                ProbeStatus::Fail { reason } | ProbeStatus::Skip { reason } => {
340                    *reason = redact(reason).into_owned();
341                }
342                ProbeStatus::Pass => {}
343            }
344            if let Some(h) = &mut p.hint {
345                *h = redact(h).into_owned();
346            }
347        }
348    }
349}
350
351/// Count (passed, failed, skipped) probes across all invocations.
352fn tally(invs: &[InvocationOut]) -> (usize, usize, usize) {
353    let (mut p, mut f, mut s) = (0usize, 0usize, 0usize);
354    for inv in invs {
355        for pr in &inv.probes {
356            match pr.status {
357                ProbeStatus::Pass => p += 1,
358                ProbeStatus::Fail { .. } => f += 1,
359                ProbeStatus::Skip { .. } => s += 1,
360            }
361        }
362    }
363    (p, f, s)
364}
365
366/// Build the `--json` envelope.
367fn build_json(config: &Path, total_ms: u128, invs: &[InvocationOut]) -> serde_json::Value {
368    let (passed, failed, skipped) = tally(invs);
369    serde_json::json!({
370        "config": config.display().to_string(),
371        "invocations": invs,
372        "summary": {
373            "passed": passed,
374            "failed": failed,
375            "skipped": skipped,
376            "elapsed_ms": total_ms,
377        }
378    })
379}
380
381/// Render the human checklist to stdout.
382fn render_human(
383    cfg_ms: u128,
384    n_roots: usize,
385    n_children: usize,
386    total: Duration,
387    invs: &[InvocationOut],
388) {
389    println!("✓ Config parses and interpolates{:>34} ms", cfg_ms);
390    println!(
391        "✓ Matrix expands to {} invocation{}{:>22} skipped (children)",
392        n_roots,
393        if n_roots == 1 { "" } else { "s" },
394        n_children
395    );
396
397    for inv in invs {
398        println!();
399        println!(
400            "▸ Invocation {}  (source={}, sink={}{})",
401            inv.id,
402            inv.source_kind,
403            inv.sink_kind,
404            inv.delivery
405                .as_deref()
406                .map(|d| format!(", delivery={d}"))
407                .unwrap_or_default()
408        );
409        for p in &inv.probes {
410            let (sym, extra) = match &p.status {
411                ProbeStatus::Pass => ("✓", String::new()),
412                ProbeStatus::Fail { reason } => ("✗", format!(" ({reason})")),
413                ProbeStatus::Skip { reason } => ("•", format!(" (skip: {reason})")),
414            };
415            println!(
416                "  {} {:6} [{}] {}{}{:>8} ms",
417                sym, p.role, p.connector, p.name, extra, p.elapsed_ms
418            );
419            if let Some(hint) = &p.hint {
420                println!("        hint: {hint}");
421            }
422        }
423    }
424
425    let (passed, failed, skipped) = tally(invs);
426    println!();
427    println!(
428        "Summary: {passed} passed, {failed} failed, {skipped} skipped       total elapsed {:.1}s",
429        total.as_secs_f64()
430    );
431}
432
433// ── Offline config linter (#392) ─────────────────────────────────────────────
434
435/// Severity of a static config-lint finding.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
437#[serde(rename_all = "lowercase")]
438pub enum LintSeverity {
439    /// A likely-broken config; counts toward the non-zero exit code.
440    Error,
441    /// A smell worth flagging; does not fail the lint.
442    Warning,
443}
444
445/// One static-lint finding: a severity, a short stable `code`, a message, and a
446/// fix hint.
447#[derive(Debug, Clone, Serialize)]
448pub struct LintFinding {
449    pub severity: LintSeverity,
450    pub code: &'static str,
451    pub message: String,
452    pub hint: String,
453}
454
455impl LintFinding {
456    fn error(code: &'static str, message: String, hint: impl Into<String>) -> Self {
457        Self {
458            severity: LintSeverity::Error,
459            code,
460            message,
461            hint: hint.into(),
462        }
463    }
464    fn warning(code: &'static str, message: String, hint: impl Into<String>) -> Self {
465        Self {
466            severity: LintSeverity::Warning,
467            code,
468            message,
469            hint: hint.into(),
470        }
471    }
472}
473
474/// File/append sinks where `batch_size` is a documented no-op (they write
475/// per-record regardless).
476const NO_OP_BATCH_SINKS: [&str; 3] = ["jsonl", "csv", "stdout"];
477
478/// Run the offline static lints over a resolved config + its expanded nodes.
479/// Pure: no I/O beyond the `raw` config text passed in for `${vars.*}` usage
480/// scanning. Reused by `faucet doctor --offline`.
481pub(crate) fn lint_config(
482    cfg: &PipelineConfig,
483    nodes: &[ExpandedNode],
484    raw: &str,
485) -> Vec<LintFinding> {
486    let mut out = Vec::new();
487
488    // Every `auth: { ref: NAME }` referenced by any node's source or sink.
489    let mut referenced: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
490    for n in nodes {
491        for spec in [&n.source, &n.sink] {
492            if let Some(name) = crate::auth_catalog::auth_ref(&spec.config) {
493                referenced.insert(name);
494            }
495        }
496    }
497    let catalog: std::collections::BTreeSet<String> = cfg
498        .auth
499        .as_ref()
500        .map(|m| m.keys().cloned().collect())
501        .unwrap_or_default();
502
503    // (1) Dangling auth ref — a connector references a provider not in `auth:`.
504    for name in &referenced {
505        if !catalog.contains(name) {
506            out.push(LintFinding::error(
507                "dangling-auth-ref",
508                format!("a connector references auth provider '{name}', which is not defined in the top-level `auth:` catalog"),
509                format!("add an `auth:` entry named '{name}', or fix the `auth: {{ ref: … }}` to match an existing provider"),
510            ));
511        }
512    }
513
514    // (2) Unreferenced auth provider — a catalog entry no connector uses.
515    for name in &catalog {
516        if !referenced.contains(name) {
517            out.push(LintFinding::warning(
518                "unreferenced-auth-provider",
519                format!("auth provider '{name}' is defined in `auth:` but never referenced by any connector"),
520                format!("reference it with `auth: {{ ref: {name} }}` on a connector, or remove the unused entry"),
521            ));
522        }
523    }
524
525    // (3) Unused vars — a `vars:` key never interpolated as `${vars.KEY}`.
526    if let Some(vars) = &cfg.vars {
527        for key in vars.keys() {
528            let token = format!("${{vars.{key}}}");
529            if !raw.contains(&token) {
530                out.push(LintFinding::warning(
531                    "unused-var",
532                    format!("`vars.{key}` is defined but never used (no `${{vars.{key}}}` reference found)"),
533                    "remove the unused variable, or reference it where intended",
534                ));
535            }
536        }
537    }
538
539    // (4) No-op sink `batch_size: 0` on file/append sinks.
540    for n in nodes {
541        if NO_OP_BATCH_SINKS.contains(&n.sink.kind.as_str())
542            && n.sink.config.get("batch_size").and_then(|v| v.as_u64()) == Some(0)
543        {
544            out.push(LintFinding::warning(
545                "noop-batch-size",
546                format!(
547                    "row '{}': sink `{}` sets `batch_size: 0`, which has no effect (this sink writes per-record)",
548                    n.id, n.sink.kind
549                ),
550                "drop `batch_size` from this sink — it only matters for batching sinks (databases, object stores, bulk APIs)",
551            ));
552        }
553    }
554
555    out
556}
557
558/// Render lint findings (human or `--json`) and return the number of *errors*
559/// (warnings don't count toward the exit code).
560fn render_lints(path: &Path, findings: &[LintFinding], json: bool) -> usize {
561    let errors = findings
562        .iter()
563        .filter(|f| f.severity == LintSeverity::Error)
564        .count();
565    let warnings = findings.len() - errors;
566
567    if json {
568        let v = serde_json::json!({
569            "config": path.display().to_string(),
570            "errors": errors,
571            "warnings": warnings,
572            "findings": findings,
573        });
574        println!(
575            "{}",
576            serde_json::to_string_pretty(&v).expect("lint json serializes")
577        );
578        return errors;
579    }
580
581    println!("Config lint: {}", path.display());
582    if findings.is_empty() {
583        println!("  ✓ no issues found");
584        return 0;
585    }
586    for f in findings {
587        let tag = match f.severity {
588            LintSeverity::Error => "error",
589            LintSeverity::Warning => "warn",
590        };
591        println!("  [{tag}] {} — {}", f.code, f.message);
592        println!("         hint: {}", f.hint);
593    }
594    println!();
595    println!("Lint: {errors} error(s), {warnings} warning(s)");
596    errors
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn probe_out(role: &'static str, name: &'static str, status: ProbeStatus) -> ProbeOut {
604        ProbeOut {
605            role,
606            connector: "x".into(),
607            name,
608            status,
609            elapsed_ms: 1,
610            hint: None,
611        }
612    }
613
614    fn inv(probes: Vec<ProbeOut>) -> InvocationOut {
615        InvocationOut {
616            id: "default".into(),
617            probes,
618            delivery: None,
619            source_kind: "rest".into(),
620            sink_kind: "stdout".into(),
621        }
622    }
623
624    #[test]
625    fn tally_counts_each_status() {
626        let invs = vec![inv(vec![
627            probe_out("source", "read", ProbeStatus::Pass),
628            probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
629            probe_out(
630                "state",
631                "sentinel",
632                ProbeStatus::Skip {
633                    reason: "n/a".into(),
634                },
635            ),
636            probe_out("sink", "schema", ProbeStatus::Fail { reason: "y".into() }),
637        ])];
638        assert_eq!(tally(&invs), (1, 2, 1));
639    }
640
641    #[test]
642    fn json_has_summary_and_invocations() {
643        let invs = vec![inv(vec![probe_out("source", "read", ProbeStatus::Pass)])];
644        let v = build_json(Path::new("pipeline.yaml"), 100, &invs);
645        assert_eq!(v["config"], "pipeline.yaml");
646        assert_eq!(v["invocations"][0]["id"], "default");
647        assert_eq!(v["invocations"][0]["probes"][0]["role"], "source");
648        assert_eq!(v["invocations"][0]["probes"][0]["status"], "pass");
649        assert_eq!(v["summary"]["passed"], 1);
650        assert_eq!(v["summary"]["failed"], 0);
651        assert_eq!(v["summary"]["elapsed_ms"], 100);
652    }
653
654    #[test]
655    fn redaction_scrubs_secret_in_reason_and_hint() {
656        crate::secrets::registry::register("supersecretvalue");
657        let mut invs = vec![inv(vec![ProbeOut {
658            role: "sink",
659            connector: "postgres".into(),
660            name: "auth",
661            status: ProbeStatus::Fail {
662                reason: "login failed for supersecretvalue".into(),
663            },
664            elapsed_ms: 5,
665            hint: Some("token supersecretvalue rejected".into()),
666        }])];
667        redact_invocations(&mut invs);
668        if let ProbeStatus::Fail { reason } = &invs[0].probes[0].status {
669            assert!(
670                !reason.contains("supersecretvalue"),
671                "reason not redacted: {reason}"
672            );
673        } else {
674            panic!("expected fail");
675        }
676        assert!(
677            !invs[0].probes[0]
678                .hint
679                .as_ref()
680                .unwrap()
681                .contains("supersecretvalue")
682        );
683    }
684
685    #[test]
686    fn construct_fail_is_a_fail_probe() {
687        let e = faucet_core::FaucetError::Config("bad".into());
688        let p = construct_fail("sink", "postgres", &e);
689        assert_eq!(p.name, "construct");
690        assert!(matches!(p.status, ProbeStatus::Fail { .. }));
691        assert_eq!(p.connector, "postgres");
692    }
693
694    #[test]
695    fn count_failures_sums_across_invocations() {
696        let invs = vec![
697            inv(vec![
698                probe_out("source", "read", ProbeStatus::Pass),
699                probe_out("sink", "auth", ProbeStatus::Fail { reason: "x".into() }),
700            ]),
701            inv(vec![probe_out(
702                "sink",
703                "auth",
704                ProbeStatus::Fail { reason: "y".into() },
705            )]),
706        ];
707        assert_eq!(count_failures(&invs), 2);
708    }
709
710    // ── Offline linter (#392) ────────────────────────────────────────────────
711
712    /// Parse a YAML config, expand it, and run the offline lints.
713    fn lint_yaml(text: &str) -> Vec<LintFinding> {
714        let cfg = crate::config::parse_with_extension(text, "yaml").expect("config parses");
715        let nodes = expand(&cfg).expect("config expands");
716        lint_config(&cfg, &nodes, text)
717    }
718
719    fn has(findings: &[LintFinding], code: &str) -> bool {
720        findings.iter().any(|f| f.code == code)
721    }
722
723    #[test]
724    fn clean_config_has_no_findings() {
725        let cfg = r#"
726version: 1
727pipeline:
728  source: { type: rest, config: { base_url: "https://x", auth: { ref: idp } } }
729  sink: { type: jsonl, config: { path: out.jsonl } }
730auth:
731  idp: { type: static, config: { token: "${env:T}" } }
732"#;
733        assert!(lint_yaml(cfg).is_empty(), "{:?}", lint_yaml(cfg));
734    }
735
736    #[test]
737    fn flags_dangling_auth_ref_as_error() {
738        let cfg = r#"
739version: 1
740pipeline:
741  source: { type: rest, config: { base_url: "https://x", auth: { ref: missing } } }
742  sink: { type: jsonl, config: { path: out.jsonl } }
743"#;
744        let f = lint_yaml(cfg);
745        assert!(has(&f, "dangling-auth-ref"));
746        assert!(
747            f.iter()
748                .any(|x| x.code == "dangling-auth-ref" && x.severity == LintSeverity::Error)
749        );
750    }
751
752    #[test]
753    fn flags_unreferenced_auth_provider_as_warning() {
754        let cfg = r#"
755version: 1
756pipeline:
757  source: { type: rest, config: { base_url: "https://x" } }
758  sink: { type: jsonl, config: { path: out.jsonl } }
759auth:
760  unused_idp: { type: static, config: { token: "t" } }
761"#;
762        let f = lint_yaml(cfg);
763        let p = f.iter().find(|x| x.code == "unreferenced-auth-provider");
764        assert!(p.is_some());
765        assert_eq!(p.unwrap().severity, LintSeverity::Warning);
766    }
767
768    #[test]
769    fn flags_unused_var() {
770        let cfg = r#"
771version: 1
772vars:
773  used: "https://x"
774  never: 5
775pipeline:
776  source: { type: rest, config: { base_url: "${vars.used}" } }
777  sink: { type: jsonl, config: { path: out.jsonl } }
778"#;
779        let f = lint_yaml(cfg);
780        assert!(has(&f, "unused-var"));
781        // Only `never` is flagged — `used` is referenced.
782        assert!(
783            f.iter()
784                .any(|x| x.code == "unused-var" && x.message.contains("never"))
785        );
786        assert!(
787            !f.iter()
788                .any(|x| x.code == "unused-var" && x.message.contains("vars.used"))
789        );
790    }
791
792    #[test]
793    fn flags_noop_batch_size_on_file_sink() {
794        let cfg = r#"
795version: 1
796pipeline:
797  source: { type: rest, config: { base_url: "https://x" } }
798  sink: { type: jsonl, config: { path: out.jsonl, batch_size: 0 } }
799"#;
800        let f = lint_yaml(cfg);
801        let p = f.iter().find(|x| x.code == "noop-batch-size");
802        assert!(p.is_some());
803        assert_eq!(p.unwrap().severity, LintSeverity::Warning);
804    }
805
806    #[test]
807    fn render_lints_counts_errors_only() {
808        let findings = vec![
809            LintFinding::error("dangling-auth-ref", "x".into(), "h"),
810            LintFinding::warning("unused-var", "y".into(), "h"),
811        ];
812        // JSON branch.
813        assert_eq!(render_lints(Path::new("faucet.yaml"), &findings, true), 1);
814        // Human branch (also exercises the empty-findings path).
815        assert_eq!(render_lints(Path::new("faucet.yaml"), &findings, false), 1);
816        assert_eq!(render_lints(Path::new("faucet.yaml"), &[], false), 0);
817    }
818
819    fn write_cfg(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
820        let dir = tempfile::tempdir().expect("tempdir");
821        let path = dir.path().join("faucet.yaml");
822        std::fs::write(&path, body).expect("write");
823        (dir, path)
824    }
825
826    fn offline_args(path: std::path::PathBuf) -> DoctorArgs {
827        DoctorArgs {
828            config: Some(path),
829            env_file: None,
830            no_env_file: true,
831            timeout_secs: 5,
832            json: false,
833            offline: true,
834            profile: None,
835        }
836    }
837
838    #[tokio::test]
839    async fn offline_run_ok_on_clean_config() {
840        let (_d, path) = write_cfg(
841            "version: 1\npipeline:\n  source: { type: rest, config: { base_url: x } }\n  sink: { type: jsonl, config: { path: o } }\n",
842        );
843        super::run(offline_args(path)).await.expect("clean lint ok");
844    }
845
846    #[tokio::test]
847    async fn offline_run_errors_on_dangling_auth_ref() {
848        let (_d, path) = write_cfg(
849            "version: 1\npipeline:\n  source: { type: rest, config: { base_url: x, auth: { ref: nope } } }\n  sink: { type: jsonl, config: { path: o } }\n",
850        );
851        let err = super::run(offline_args(path)).await;
852        assert!(matches!(err, Err(CliError::DoctorFailed { failed }) if failed >= 1));
853    }
854}