1use 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#[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#[derive(Debug, Serialize)]
56pub struct InvocationOut {
57 pub id: String,
58 pub probes: Vec<ProbeOut>,
59 #[serde(skip_serializing_if = "Option::is_none")]
63 pub delivery: Option<String>,
64 #[serde(skip)]
66 pub source_kind: String,
67 #[serde(skip)]
68 pub sink_kind: String,
69}
70
71pub 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 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 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 #[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
155pub 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#[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
251pub 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
294pub 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
302async 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
323fn 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
334pub 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
351fn 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
366fn 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
381fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
437#[serde(rename_all = "lowercase")]
438pub enum LintSeverity {
439 Error,
441 Warning,
443}
444
445#[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
474const NO_OP_BATCH_SINKS: [&str; 3] = ["jsonl", "csv", "stdout"];
477
478pub(crate) fn lint_config(
482 cfg: &PipelineConfig,
483 nodes: &[ExpandedNode],
484 raw: &str,
485) -> Vec<LintFinding> {
486 let mut out = Vec::new();
487
488 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 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 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 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 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
558fn 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 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 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 assert_eq!(render_lints(Path::new("faucet.yaml"), &findings, true), 1);
814 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}