1use std::path::PathBuf;
5use thiserror::Error;
6
7pub type CliResult<T> = Result<T, CliError>;
9
10#[derive(Debug, Error)]
12pub enum CliError {
13 #[error("failed to read config file '{path}': {source}")]
15 ReadConfig {
16 path: PathBuf,
17 #[source]
18 source: std::io::Error,
19 },
20
21 #[error(
23 "unsupported config extension for '{path}' — use .yaml, .yml, or .json (mixed JSON/YAML in a single file is not allowed)"
24 )]
25 UnknownExtension { path: PathBuf },
26
27 #[error("failed to parse config '{path}': {message}")]
29 ParseConfig { path: PathBuf, message: String },
30
31 #[error("missing environment variable '{var}' referenced in config at '{location}'")]
33 MissingEnvVar { var: String, location: String },
34
35 #[error("failed to read interpolated file '{}' referenced in config: {source}", path.display())]
37 ReadInterpolatedFile {
38 path: PathBuf,
39 #[source]
40 source: std::io::Error,
41 },
42
43 #[error(
44 "interpolated file '{}' exceeds the {max_bytes}-byte limit for `${{file:...}}` — \
45 this directive is for small token/secret files, not bulk data",
46 path.display()
47 )]
48 InterpolatedFileTooLarge { path: PathBuf, max_bytes: u64 },
49
50 #[error(
53 "interpolation '{token}' references unknown id '{id}' (must be a matrix row id, or one of env/file/secret)"
54 )]
55 UnknownInterpolationId { id: String, token: String },
56
57 #[error("matrix row '{id}' has no field at path '{path}' in this parent record")]
60 MissingRecordField { id: String, path: String },
61
62 #[error("unknown {kind} '{name}'. Available: {available}")]
64 UnknownConnector {
65 kind: &'static str,
66 name: String,
67 available: String,
68 },
69
70 #[error("unknown state store '{name}'. Available: {available}")]
72 UnknownStateStore { name: String, available: String },
73
74 #[error("unknown transform '{name}'. Available: {available}")]
76 UnknownTransform { name: String, available: String },
77
78 #[error("invalid transform '{name}': {message}")]
80 InvalidTransform { name: String, message: String },
81
82 #[error("invalid config for {kind} '{name}': {message}")]
84 InvalidConnectorConfig {
85 kind: &'static str,
86 name: String,
87 message: String,
88 },
89
90 #[error("refusing to overwrite existing file '{path}' — pass --force to overwrite")]
92 ScaffoldExists { path: PathBuf },
93
94 #[error(
97 "missing required environment variable '{var}' — set it before invoking `faucet run --from-env`"
98 )]
99 MissingEnvSelector { var: String },
100
101 #[error("--env-file path '{}' does not exist", path.display())]
103 EnvFileNotFound { path: PathBuf },
104
105 #[error(
108 "no pipeline config: pass a path, --from-env, or create faucet.yaml (or .yml/.json) in the current directory"
109 )]
110 NoConfigOrFromEnv,
111
112 #[error(
114 "conflicting environment variables for field '{field}': both '{scalar_var}' and '{json_var}' are set — pick one"
115 )]
116 EnvConflict {
117 field: String,
118 scalar_var: String,
119 json_var: String,
120 },
121
122 #[error("environment variable '{var}' is not valid JSON: {message}")]
124 InvalidEnvJson { var: String, message: String },
125
126 #[error(
128 "transform env vars must be contiguous starting at FAUCET_TRANSFORM_1; index {missing} is missing"
129 )]
130 TransformIndexGap { missing: u32 },
131
132 #[error("matrix row id '{id}' is reserved (env, file, secret, matrix, pipeline)")]
134 ReservedRowId { id: String },
135
136 #[error("duplicate matrix row id '{id}'")]
138 DuplicateRowId { id: String },
139
140 #[error("matrix row '{id}' references unknown parent '{parent}'")]
142 UnknownParent { id: String, parent: String },
143
144 #[error("matrix has a parent cycle through: {}", ids.join(" -> "))]
146 ParentCycle { ids: Vec<String> },
147
148 #[error("matrix row '{id}' depends on unknown row '{depends_on}'")]
150 UnknownDependency { id: String, depends_on: String },
151
152 #[error("matrix has a dependency cycle involving: {}", ids.join(", "))]
154 DependencyCycle { ids: Vec<String> },
155
156 #[error(
159 "duplicate state key '{state_key}' for matrix row '{id}': two parent records resolve to the same `parent_key` value — choose a `parent_key` that is unique per record"
160 )]
161 DuplicateStateKey { id: String, state_key: String },
162
163 #[error("invalid state key '{state_key}' for row '{id}': {reason}")]
167 InvalidStateKey {
168 id: String,
169 state_key: String,
170 reason: String,
171 },
172
173 #[error("{count} pipeline invocation(s) failed (see logs above for details)")]
175 PipelineHadFailures { count: usize },
176
177 #[error(
180 "`pipeline.nodes` (topology mode) and `matrix:` are mutually exclusive — set one or the other, not both"
181 )]
182 MatrixAndNodesBothPresent,
183
184 #[error("topology edge references unknown node '{name}' (known nodes: {})", known.join(", "))]
186 EdgeEndpointMissing { name: String, known: Vec<String> },
187
188 #[error("invalid topology: {message}")]
191 InvalidTopology { message: String },
192
193 #[error("{count} topology node(s) failed (see logs above for details)")]
195 TopologyHadFailures { count: usize },
196
197 #[error("DLQ sink kind `{kind}` is not registered (in {context})")]
199 UnknownDlqSinkKind { kind: String, context: String },
200
201 #[error("DLQ {field} must be > 0 (got 0); omit the field to mean 'unlimited'")]
203 InvalidDlqBudget { field: &'static str },
204
205 #[error(
208 "matrix row '{row_id}' references unknown {kind} template '{name}'. Known {kind} templates: {known}",
209 known = if known.is_empty() { String::from("(none defined)") } else { known.join(", ") }
210 )]
211 UnknownTemplate {
212 kind: &'static str,
213 name: String,
214 row_id: String,
215 known: Vec<String>,
216 },
217
218 #[error(
221 "matrix row '{row_id}' has no {kind}: either set `{kind}: {{ ref: <name> }}` pointing at a `pipeline.{kind}s` template, or declare a legacy `pipeline.{kind}` block"
222 )]
223 MissingTemplate { kind: &'static str, row_id: String },
224
225 #[error(
228 "{kind} template '{name}' is defined twice — declare it either via the singular `pipeline.{kind}` block or in `pipeline.{kind}s`, not both"
229 )]
230 DuplicateTemplate { kind: &'static str, name: String },
231
232 #[error(
234 "sink template '{name}' has `transforms:` — sinks cannot carry transforms; \
235 declare transforms on the source template, pipeline, or matrix row instead"
236 )]
237 TransformsOnSink { name: String },
238
239 #[error(
241 "sink template '{name}' has `inherit_transforms:` — sinks cannot carry the \
242 transform-inheritance flag; remove it"
243 )]
244 InheritTransformsOnSink { name: String },
245
246 #[error("interpolation cycle: {}", chain.join(" -> "))]
249 InterpolationCycle { chain: Vec<String> },
250
251 #[error("config composition cycle: {}", chain.join(" -> "))]
253 CompositionCycle { chain: Vec<String> },
254
255 #[error(
257 "config composition: file '{}' referenced by '{}' not found",
258 path.display(),
259 referenced_by.display()
260 )]
261 IncludeNotFound {
262 path: PathBuf,
263 referenced_by: PathBuf,
264 },
265
266 #[error(
268 "config composition nested deeper than {max} levels — check for an extends/!include loop"
269 )]
270 CompositionDepthExceeded { max: usize },
271
272 #[error("invalid `!include` in '{}': {reason}", path.display())]
275 BadInclude { path: PathBuf, reason: String },
276
277 #[error(
279 "unknown profile '{name}'. Declared profiles: {}",
280 if known.is_empty() { String::from("(none — no `profiles:` block)") } else { known.join(", ") }
281 )]
282 UnknownProfile { name: String, known: Vec<String> },
283
284 #[error(
286 "interpolation '{token}' references unknown var '{name}' (define it under top-level `vars:`)"
287 )]
288 UnknownVarsRef { name: String, token: String },
289
290 #[error("interpolation '{token}' could not be resolved: {reason}")]
293 UnknownTemplateRef { token: String, reason: String },
294
295 #[error(
298 "missing required param '{name}'{} — supply it with `--param {name}=<value>` (or a \
299 `\"params\"` entry over HTTP)",
300 match description { Some(d) => format!(" ({d})"), None => String::new() }
301 )]
302 MissingParam {
303 name: String,
304 description: Option<String>,
305 },
306
307 #[error(
310 "unknown param '{name}'. Declared params: {}",
311 if known.is_empty() { String::from("(none — this config has no `params:` block)") } else { known.join(", ") }
312 )]
313 UnknownParam { name: String, known: Vec<String> },
314
315 #[error(
317 "interpolation '{token}' references undeclared param '{name}' (declare it under top-level \
318 `params:`)"
319 )]
320 UnknownParamRef { name: String, token: String },
321
322 #[error(
326 "no pipeline template '{id}'{} in the registry — list them with `faucet template list`",
327 match version { Some(v) => format!(" at version {v}"), None => String::new() }
328 )]
329 UnknownPipelineTemplate { id: String, version: Option<u32> },
330
331 #[error(
334 "auth references unknown provider '{name}'. Declared providers: {}",
335 if known.is_empty() { String::from("(none)") } else { known.join(", ") }
336 )]
337 UnknownAuthProvider { name: String, known: Vec<String> },
338
339 #[error("failed to build auth provider '{name}': {message}")]
341 AuthProviderBuild { name: String, message: String },
342
343 #[error(
346 "{flag} '{token}' matched no matrix row. Available rows: {}",
347 if available.is_empty() { String::from("(none)") } else { available.join(", ") }
348 )]
349 NoMatchForSelector {
350 flag: &'static str,
351 token: String,
352 available: Vec<String>,
353 },
354
355 #[error("unknown status '{value}'. Valid tiers: {}", available.join(", "))]
357 UnknownStatus {
358 value: String,
359 available: Vec<String>,
360 },
361
362 #[error(
364 "unknown tag '{tag}'. Tags present in this config: {}",
365 if available.is_empty() { String::from("(none — no row declares tags)") } else { available.join(", ") }
366 )]
367 UnknownTag { tag: String, available: Vec<String> },
368
369 #[error("unknown include_parents policy '{value}' (expected off, eligible, or all)")]
371 UnknownIncludeParents { value: String },
372
373 #[error(
376 "selector(s) {flags} require a `matrix:` — this config has a single anonymous invocation (nothing to select)"
377 )]
378 SelectorsWithoutMatrix { flags: String },
379
380 #[error(
383 "no matrix rows selected to run. Rows and their status: {}. \
384 Widen the run set with --status <tier>, --select <id>, or --tag <t>",
385 rows.join(", ")
386 )]
387 EmptyRunSet { rows: Vec<String> },
388
389 #[error(
393 "run-set dependency violation (include_parents={policy}): {}. \
394 Select the ancestor by id (--select <id>), or loosen the policy \
395 (--include-parents eligible|all)",
396 pairs.join("; ")
397 )]
398 RunSetMissingAncestors {
399 pairs: Vec<String>,
400 policy: &'static str,
401 },
402
403 #[error("config error: {0}")]
407 Config(String),
408
409 #[error(transparent)]
411 Faucet(#[from] faucet_core::FaucetError),
412
413 #[error("io error: {0}")]
415 Io(#[from] std::io::Error),
416
417 #[error("observability install failed: {0}")]
419 Observability(String),
420
421 #[error("internal error: {0}")]
424 Internal(String),
425
426 #[error(
429 "secret directive uses scheme '{scheme}' but this binary was built without \
430 the `secrets-{scheme}` feature — rebuild with `--features secrets-{scheme}` (or `secrets`)"
431 )]
432 SecretBackendDisabled { scheme: String },
433
434 #[error("secret '{reference}' not found in {scheme}")]
436 SecretNotFound { scheme: String, reference: String },
437
438 #[error("failed to fetch secret '{reference}' from {scheme}: {source}")]
440 SecretFetchFailed {
441 scheme: String,
442 reference: String,
443 #[source]
444 source: Box<dyn std::error::Error + Send + Sync>,
445 },
446
447 #[error("could not authenticate to {scheme}: {hint}")]
449 SecretAuthFailed { scheme: String, hint: String },
450
451 #[error("secret '{reference}' from {scheme} is not JSON, but a '#field' selector was used")]
453 SecretNotJson { scheme: String, reference: String },
454
455 #[error(
457 "secret '{reference}' from {scheme} has no field '{field}' (available: {})",
458 if available.is_empty() { String::from("(none — secret is an empty object)") } else { available.join(", ") }
459 )]
460 SecretFieldMissing {
461 scheme: String,
462 reference: String,
463 field: String,
464 available: Vec<String>,
465 },
466
467 #[error(
469 "config references a secrets manager (${{vault:…}} / ${{aws-sm:…}} / …) which requires \
470 the async load path — load via `faucet run`/`validate`/`preview` rather than the sync API"
471 )]
472 SecretsRequireAsyncLoad,
473
474 #[error("{failed} preflight probe(s) failed")]
478 DoctorFailed { failed: usize },
479
480 #[error("{failed} test case(s) failed")]
484 TestsFailed { failed: usize },
485
486 #[error("{failed} backfill unit(s) failed")]
491 BackfillFailed { failed: usize },
492
493 #[error("serve error: {0}")]
495 Serve(String),
496
497 #[error("scheduled run overlap with overlap_policy: forbid — previous run still in progress")]
499 ScheduleOverlapForbidden,
500}
501
502impl From<faucet_core::InstallError> for CliError {
503 fn from(e: faucet_core::InstallError) -> Self {
504 CliError::Observability(e.to_string())
505 }
506}
507
508#[cfg(test)]
509mod secrets_error_tests {
510 use super::*;
511
512 #[test]
513 fn secret_errors_render_reference_not_value() {
514 let e = CliError::SecretNotFound {
515 scheme: "vault".into(),
516 reference: "secret/data/app#token".into(),
517 };
518 let msg = e.to_string();
519 assert!(msg.contains("vault"));
520 assert!(msg.contains("secret/data/app#token"));
521
522 let e = CliError::SecretFieldMissing {
523 scheme: "aws-sm".into(),
524 reference: "prod/db".into(),
525 field: "password".into(),
526 available: vec!["username".into(), "host".into()],
527 };
528 let msg = e.to_string();
529 assert!(msg.contains("password"));
530 assert!(msg.contains("username") && msg.contains("host"));
531
532 let e = CliError::SecretBackendDisabled {
533 scheme: "azure-kv".into(),
534 };
535 assert!(e.to_string().contains("secrets-azure-kv"));
536
537 assert!(
538 CliError::SecretsRequireAsyncLoad
539 .to_string()
540 .contains("async")
541 );
542 }
543
544 #[test]
545 fn fetch_auth_notjson_errors_render_safely() {
546 let e = CliError::SecretFetchFailed {
547 scheme: "vault".into(),
548 reference: "secret/data/app#token".into(),
549 source: "connection refused".into(),
550 };
551 let msg = e.to_string();
552 assert!(msg.contains("vault") && msg.contains("secret/data/app#token"));
553
554 let e = CliError::SecretAuthFailed {
555 scheme: "aws-sm".into(),
556 hint: "set AWS_PROFILE".into(),
557 };
558 assert!(e.to_string().contains("aws-sm") && e.to_string().contains("set AWS_PROFILE"));
559
560 let e = CliError::SecretNotJson {
561 scheme: "vault".into(),
562 reference: "secret/raw".into(),
563 };
564 assert!(e.to_string().contains("not JSON"));
565 }
566
567 #[test]
568 fn field_missing_with_empty_available_has_no_dangling_list() {
569 let e = CliError::SecretFieldMissing {
570 scheme: "vault".into(),
571 reference: "secret/data/app".into(),
572 field: "token".into(),
573 available: vec![],
574 };
575 let msg = e.to_string();
576 assert!(!msg.ends_with("(available: )"));
577 assert!(msg.contains("token"));
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn missing_env_selector_renders() {
587 let e = CliError::MissingEnvSelector {
588 var: "FAUCET_SOURCE".to_owned(),
589 };
590 let msg = e.to_string();
591 assert!(msg.contains("FAUCET_SOURCE"));
592 assert!(msg.contains("--from-env"));
593 }
594
595 #[test]
596 fn env_conflict_names_both_vars() {
597 let e = CliError::EnvConflict {
598 field: "auth".to_owned(),
599 scalar_var: "FAUCET_SOURCE_REST_AUTH".to_owned(),
600 json_var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
601 };
602 let msg = e.to_string();
603 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH"));
604 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
605 }
606
607 #[test]
608 fn invalid_env_json_names_var_and_parse_error() {
609 let e = CliError::InvalidEnvJson {
610 var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
611 message: "expected value at line 1 column 1".to_owned(),
612 };
613 let msg = e.to_string();
614 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
615 assert!(msg.contains("expected value"));
616 }
617
618 #[test]
619 fn transform_index_gap_reports_missing_index() {
620 let e = CliError::TransformIndexGap { missing: 2 };
621 let msg = e.to_string();
622 assert!(msg.contains('2'));
623 assert!(msg.to_ascii_lowercase().contains("transform"));
624 }
625
626 #[test]
627 fn unknown_template_lists_known_names() {
628 let e = CliError::UnknownTemplate {
629 kind: "source",
630 name: "users_api".into(),
631 row_id: "load_users".into(),
632 known: vec!["customers_api".into(), "orders_api".into()],
633 };
634 let msg = e.to_string();
635 assert!(msg.contains("users_api"));
636 assert!(msg.contains("load_users"));
637 assert!(msg.contains("customers_api"));
638 }
639
640 #[test]
641 fn duplicate_template_names_kind() {
642 let e = CliError::DuplicateTemplate {
643 kind: "sink",
644 name: "default".into(),
645 };
646 let msg = e.to_string();
647 assert!(msg.contains("sink"));
648 assert!(msg.contains("default"));
649 }
650
651 #[test]
652 fn interpolation_cycle_renders_chain() {
653 let e = CliError::InterpolationCycle {
654 chain: vec!["vars.a".into(), "vars.b".into(), "vars.a".into()],
655 };
656 let msg = e.to_string();
657 assert!(msg.contains("vars.a"));
658 assert!(msg.contains("vars.b"));
659 }
660
661 #[test]
662 fn composition_cycle_renders_chain() {
663 let e = CliError::CompositionCycle {
664 chain: vec!["a.yaml".into(), "b.yaml".into(), "a.yaml".into()],
665 };
666 let msg = e.to_string();
667 assert!(msg.contains("a.yaml") && msg.contains("b.yaml"));
668 assert!(msg.contains(" -> "));
669 }
670
671 #[test]
672 fn unknown_profile_lists_known() {
673 let e = CliError::UnknownProfile {
674 name: "staging".into(),
675 known: vec!["dev".into(), "prod".into()],
676 };
677 let msg = e.to_string();
678 assert!(msg.contains("staging") && msg.contains("dev") && msg.contains("prod"));
679
680 let none = CliError::UnknownProfile {
681 name: "x".into(),
682 known: vec![],
683 };
684 assert!(none.to_string().contains("no `profiles:` block"));
685 }
686
687 #[test]
688 fn include_not_found_names_both_paths() {
689 let e = CliError::IncludeNotFound {
690 path: std::path::PathBuf::from("base.yaml"),
691 referenced_by: std::path::PathBuf::from("app.yaml"),
692 };
693 let msg = e.to_string();
694 assert!(msg.contains("base.yaml") && msg.contains("app.yaml"));
695 }
696
697 #[test]
698 fn composition_depth_exceeds_renders_max() {
699 assert!(
700 CliError::CompositionDepthExceeded { max: 32 }
701 .to_string()
702 .contains("32")
703 );
704 }
705
706 #[test]
707 fn bad_include_names_path_and_reason() {
708 let e = CliError::BadInclude {
709 path: std::path::PathBuf::from("f.yaml"),
710 reason: "!include payload must be a string path".into(),
711 };
712 assert!(e.to_string().contains("f.yaml") && e.to_string().contains("string path"));
713 }
714}