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(
151 "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"
152 )]
153 DuplicateStateKey { id: String, state_key: String },
154
155 #[error("invalid state key '{state_key}' for row '{id}': {reason}")]
159 InvalidStateKey {
160 id: String,
161 state_key: String,
162 reason: String,
163 },
164
165 #[error("{count} pipeline invocation(s) failed (see logs above for details)")]
167 PipelineHadFailures { count: usize },
168
169 #[error("DLQ sink kind `{kind}` is not registered (in {context})")]
171 UnknownDlqSinkKind { kind: String, context: String },
172
173 #[error("DLQ {field} must be > 0 (got 0); omit the field to mean 'unlimited'")]
175 InvalidDlqBudget { field: &'static str },
176
177 #[error(
180 "matrix row '{row_id}' references unknown {kind} template '{name}'. Known {kind} templates: {known}",
181 known = if known.is_empty() { String::from("(none defined)") } else { known.join(", ") }
182 )]
183 UnknownTemplate {
184 kind: &'static str,
185 name: String,
186 row_id: String,
187 known: Vec<String>,
188 },
189
190 #[error(
193 "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"
194 )]
195 MissingTemplate { kind: &'static str, row_id: String },
196
197 #[error(
200 "{kind} template '{name}' is defined twice — declare it either via the singular `pipeline.{kind}` block or in `pipeline.{kind}s`, not both"
201 )]
202 DuplicateTemplate { kind: &'static str, name: String },
203
204 #[error(
206 "sink template '{name}' has `transforms:` — sinks cannot carry transforms; \
207 declare transforms on the source template, pipeline, or matrix row instead"
208 )]
209 TransformsOnSink { name: String },
210
211 #[error(
213 "sink template '{name}' has `inherit_transforms:` — sinks cannot carry the \
214 transform-inheritance flag; remove it"
215 )]
216 InheritTransformsOnSink { name: String },
217
218 #[error("interpolation cycle: {}", chain.join(" -> "))]
221 InterpolationCycle { chain: Vec<String> },
222
223 #[error("config composition cycle: {}", chain.join(" -> "))]
225 CompositionCycle { chain: Vec<String> },
226
227 #[error(
229 "config composition: file '{}' referenced by '{}' not found",
230 path.display(),
231 referenced_by.display()
232 )]
233 IncludeNotFound {
234 path: PathBuf,
235 referenced_by: PathBuf,
236 },
237
238 #[error(
240 "config composition nested deeper than {max} levels — check for an extends/!include loop"
241 )]
242 CompositionDepthExceeded { max: usize },
243
244 #[error("invalid `!include` in '{}': {reason}", path.display())]
247 BadInclude { path: PathBuf, reason: String },
248
249 #[error(
251 "unknown profile '{name}'. Declared profiles: {}",
252 if known.is_empty() { String::from("(none — no `profiles:` block)") } else { known.join(", ") }
253 )]
254 UnknownProfile { name: String, known: Vec<String> },
255
256 #[error(
258 "interpolation '{token}' references unknown var '{name}' (define it under top-level `vars:`)"
259 )]
260 UnknownVarsRef { name: String, token: String },
261
262 #[error("interpolation '{token}' could not be resolved: {reason}")]
265 UnknownTemplateRef { token: String, reason: String },
266
267 #[error(
270 "auth references unknown provider '{name}'. Declared providers: {}",
271 if known.is_empty() { String::from("(none)") } else { known.join(", ") }
272 )]
273 UnknownAuthProvider { name: String, known: Vec<String> },
274
275 #[error("failed to build auth provider '{name}': {message}")]
277 AuthProviderBuild { name: String, message: String },
278
279 #[error("config error: {0}")]
283 Config(String),
284
285 #[error(transparent)]
287 Faucet(#[from] faucet_core::FaucetError),
288
289 #[error("io error: {0}")]
291 Io(#[from] std::io::Error),
292
293 #[error("observability install failed: {0}")]
295 Observability(String),
296
297 #[error("internal error: {0}")]
300 Internal(String),
301
302 #[error(
305 "secret directive uses scheme '{scheme}' but this binary was built without \
306 the `secrets-{scheme}` feature — rebuild with `--features secrets-{scheme}` (or `secrets`)"
307 )]
308 SecretBackendDisabled { scheme: String },
309
310 #[error("secret '{reference}' not found in {scheme}")]
312 SecretNotFound { scheme: String, reference: String },
313
314 #[error("failed to fetch secret '{reference}' from {scheme}: {source}")]
316 SecretFetchFailed {
317 scheme: String,
318 reference: String,
319 #[source]
320 source: Box<dyn std::error::Error + Send + Sync>,
321 },
322
323 #[error("could not authenticate to {scheme}: {hint}")]
325 SecretAuthFailed { scheme: String, hint: String },
326
327 #[error("secret '{reference}' from {scheme} is not JSON, but a '#field' selector was used")]
329 SecretNotJson { scheme: String, reference: String },
330
331 #[error(
333 "secret '{reference}' from {scheme} has no field '{field}' (available: {})",
334 if available.is_empty() { String::from("(none — secret is an empty object)") } else { available.join(", ") }
335 )]
336 SecretFieldMissing {
337 scheme: String,
338 reference: String,
339 field: String,
340 available: Vec<String>,
341 },
342
343 #[error(
345 "config references a secrets manager (${{vault:…}} / ${{aws-sm:…}} / …) which requires \
346 the async load path — load via `faucet run`/`validate`/`preview` rather than the sync API"
347 )]
348 SecretsRequireAsyncLoad,
349
350 #[error("{failed} preflight probe(s) failed")]
354 DoctorFailed { failed: usize },
355
356 #[error("serve error: {0}")]
358 Serve(String),
359
360 #[error("scheduled run overlap with overlap_policy: forbid — previous run still in progress")]
362 ScheduleOverlapForbidden,
363}
364
365impl From<faucet_core::InstallError> for CliError {
366 fn from(e: faucet_core::InstallError) -> Self {
367 CliError::Observability(e.to_string())
368 }
369}
370
371#[cfg(test)]
372mod secrets_error_tests {
373 use super::*;
374
375 #[test]
376 fn secret_errors_render_reference_not_value() {
377 let e = CliError::SecretNotFound {
378 scheme: "vault".into(),
379 reference: "secret/data/app#token".into(),
380 };
381 let msg = e.to_string();
382 assert!(msg.contains("vault"));
383 assert!(msg.contains("secret/data/app#token"));
384
385 let e = CliError::SecretFieldMissing {
386 scheme: "aws-sm".into(),
387 reference: "prod/db".into(),
388 field: "password".into(),
389 available: vec!["username".into(), "host".into()],
390 };
391 let msg = e.to_string();
392 assert!(msg.contains("password"));
393 assert!(msg.contains("username") && msg.contains("host"));
394
395 let e = CliError::SecretBackendDisabled {
396 scheme: "azure-kv".into(),
397 };
398 assert!(e.to_string().contains("secrets-azure-kv"));
399
400 assert!(
401 CliError::SecretsRequireAsyncLoad
402 .to_string()
403 .contains("async")
404 );
405 }
406
407 #[test]
408 fn fetch_auth_notjson_errors_render_safely() {
409 let e = CliError::SecretFetchFailed {
410 scheme: "vault".into(),
411 reference: "secret/data/app#token".into(),
412 source: "connection refused".into(),
413 };
414 let msg = e.to_string();
415 assert!(msg.contains("vault") && msg.contains("secret/data/app#token"));
416
417 let e = CliError::SecretAuthFailed {
418 scheme: "aws-sm".into(),
419 hint: "set AWS_PROFILE".into(),
420 };
421 assert!(e.to_string().contains("aws-sm") && e.to_string().contains("set AWS_PROFILE"));
422
423 let e = CliError::SecretNotJson {
424 scheme: "vault".into(),
425 reference: "secret/raw".into(),
426 };
427 assert!(e.to_string().contains("not JSON"));
428 }
429
430 #[test]
431 fn field_missing_with_empty_available_has_no_dangling_list() {
432 let e = CliError::SecretFieldMissing {
433 scheme: "vault".into(),
434 reference: "secret/data/app".into(),
435 field: "token".into(),
436 available: vec![],
437 };
438 let msg = e.to_string();
439 assert!(!msg.ends_with("(available: )"));
440 assert!(msg.contains("token"));
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn missing_env_selector_renders() {
450 let e = CliError::MissingEnvSelector {
451 var: "FAUCET_SOURCE".to_owned(),
452 };
453 let msg = e.to_string();
454 assert!(msg.contains("FAUCET_SOURCE"));
455 assert!(msg.contains("--from-env"));
456 }
457
458 #[test]
459 fn env_conflict_names_both_vars() {
460 let e = CliError::EnvConflict {
461 field: "auth".to_owned(),
462 scalar_var: "FAUCET_SOURCE_REST_AUTH".to_owned(),
463 json_var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
464 };
465 let msg = e.to_string();
466 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH"));
467 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
468 }
469
470 #[test]
471 fn invalid_env_json_names_var_and_parse_error() {
472 let e = CliError::InvalidEnvJson {
473 var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
474 message: "expected value at line 1 column 1".to_owned(),
475 };
476 let msg = e.to_string();
477 assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
478 assert!(msg.contains("expected value"));
479 }
480
481 #[test]
482 fn transform_index_gap_reports_missing_index() {
483 let e = CliError::TransformIndexGap { missing: 2 };
484 let msg = e.to_string();
485 assert!(msg.contains('2'));
486 assert!(msg.to_ascii_lowercase().contains("transform"));
487 }
488
489 #[test]
490 fn unknown_template_lists_known_names() {
491 let e = CliError::UnknownTemplate {
492 kind: "source",
493 name: "users_api".into(),
494 row_id: "load_users".into(),
495 known: vec!["customers_api".into(), "orders_api".into()],
496 };
497 let msg = e.to_string();
498 assert!(msg.contains("users_api"));
499 assert!(msg.contains("load_users"));
500 assert!(msg.contains("customers_api"));
501 }
502
503 #[test]
504 fn duplicate_template_names_kind() {
505 let e = CliError::DuplicateTemplate {
506 kind: "sink",
507 name: "default".into(),
508 };
509 let msg = e.to_string();
510 assert!(msg.contains("sink"));
511 assert!(msg.contains("default"));
512 }
513
514 #[test]
515 fn interpolation_cycle_renders_chain() {
516 let e = CliError::InterpolationCycle {
517 chain: vec!["vars.a".into(), "vars.b".into(), "vars.a".into()],
518 };
519 let msg = e.to_string();
520 assert!(msg.contains("vars.a"));
521 assert!(msg.contains("vars.b"));
522 }
523
524 #[test]
525 fn composition_cycle_renders_chain() {
526 let e = CliError::CompositionCycle {
527 chain: vec!["a.yaml".into(), "b.yaml".into(), "a.yaml".into()],
528 };
529 let msg = e.to_string();
530 assert!(msg.contains("a.yaml") && msg.contains("b.yaml"));
531 assert!(msg.contains(" -> "));
532 }
533
534 #[test]
535 fn unknown_profile_lists_known() {
536 let e = CliError::UnknownProfile {
537 name: "staging".into(),
538 known: vec!["dev".into(), "prod".into()],
539 };
540 let msg = e.to_string();
541 assert!(msg.contains("staging") && msg.contains("dev") && msg.contains("prod"));
542
543 let none = CliError::UnknownProfile {
544 name: "x".into(),
545 known: vec![],
546 };
547 assert!(none.to_string().contains("no `profiles:` block"));
548 }
549
550 #[test]
551 fn include_not_found_names_both_paths() {
552 let e = CliError::IncludeNotFound {
553 path: std::path::PathBuf::from("base.yaml"),
554 referenced_by: std::path::PathBuf::from("app.yaml"),
555 };
556 let msg = e.to_string();
557 assert!(msg.contains("base.yaml") && msg.contains("app.yaml"));
558 }
559
560 #[test]
561 fn composition_depth_exceeds_renders_max() {
562 assert!(
563 CliError::CompositionDepthExceeded { max: 32 }
564 .to_string()
565 .contains("32")
566 );
567 }
568
569 #[test]
570 fn bad_include_names_path_and_reason() {
571 let e = CliError::BadInclude {
572 path: std::path::PathBuf::from("f.yaml"),
573 reason: "!include payload must be a string path".into(),
574 };
575 assert!(e.to_string().contains("f.yaml") && e.to_string().contains("string path"));
576 }
577}