Skip to main content

faucet_cli/
error.rs

1//! CLI-level error type. Wraps every failure mode the binary surfaces so
2//! `main()` can render a single, user-readable line per failure.
3
4use std::path::PathBuf;
5use thiserror::Error;
6
7/// Convenience alias used by every CLI module.
8pub type CliResult<T> = Result<T, CliError>;
9
10/// Top-level error variants for the `faucet` binary.
11#[derive(Debug, Error)]
12pub enum CliError {
13    /// Failed to read a config file from disk.
14    #[error("failed to read config file '{path}': {source}")]
15    ReadConfig {
16        path: PathBuf,
17        #[source]
18        source: std::io::Error,
19    },
20
21    /// The config file extension is neither `.yaml`/`.yml` nor `.json`.
22    #[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    /// Failed to parse the raw config text after interpolation.
28    #[error("failed to parse config '{path}': {message}")]
29    ParseConfig { path: PathBuf, message: String },
30
31    /// An `${env:VAR}` reference could not be resolved.
32    #[error("missing environment variable '{var}' referenced in config at '{location}'")]
33    MissingEnvVar { var: String, location: String },
34
35    /// A `${file:PATH}` reference could not be read.
36    #[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    /// A `${row_id.path}` token referenced an unknown matrix row id at
51    /// expand-time (or a typo'd load-time prefix that survived to record-time).
52    #[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    /// A `${row_id.path}` resolved at record-time, but the dotted path doesn't
58    /// match any field in the parent record.
59    #[error("matrix row '{id}' has no field at path '{path}' in this parent record")]
60    MissingRecordField { id: String, path: String },
61
62    /// The named connector is unknown (or its feature flag is disabled in this build).
63    #[error("unknown {kind} '{name}'. Available: {available}")]
64    UnknownConnector {
65        kind: &'static str,
66        name: String,
67        available: String,
68    },
69
70    /// The state-store type referenced in the config is unknown or not compiled in.
71    #[error("unknown state store '{name}'. Available: {available}")]
72    UnknownStateStore { name: String, available: String },
73
74    /// A transform type referenced in the config is not recognised.
75    #[error("unknown transform '{name}'. Available: {available}")]
76    UnknownTransform { name: String, available: String },
77
78    /// The transform config block could not be deserialized into the expected shape.
79    #[error("invalid transform '{name}': {message}")]
80    InvalidTransform { name: String, message: String },
81
82    /// A connector config object failed to deserialize.
83    #[error("invalid config for {kind} '{name}': {message}")]
84    InvalidConnectorConfig {
85        kind: &'static str,
86        name: String,
87        message: String,
88    },
89
90    /// A scaffold target already exists.
91    #[error("refusing to overwrite existing file '{path}' — pass --force to overwrite")]
92    ScaffoldExists { path: PathBuf },
93
94    /// The CLI was invoked with `--from-env` but the required selector env var
95    /// (`FAUCET_SOURCE` or `FAUCET_SINK`) is unset.
96    #[error(
97        "missing required environment variable '{var}' — set it before invoking `faucet run --from-env`"
98    )]
99    MissingEnvSelector { var: String },
100
101    /// An explicit `--env-file` path does not exist on disk.
102    #[error("--env-file path '{}' does not exist", path.display())]
103    EnvFileNotFound { path: PathBuf },
104
105    /// `faucet run` invoked with neither a config path nor `--from-env`, and
106    /// auto-discovery found no `faucet.{yaml,yml,json}` in cwd.
107    #[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    /// Both a scalar env var and its `_JSON` counterpart were set for the same field.
113    #[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    /// A `*_JSON` env var did not parse as JSON.
123    #[error("environment variable '{var}' is not valid JSON: {message}")]
124    InvalidEnvJson { var: String, message: String },
125
126    /// `FAUCET_TRANSFORM_<N>` indices are not contiguous starting at 1.
127    #[error(
128        "transform env vars must be contiguous starting at FAUCET_TRANSFORM_1; index {missing} is missing"
129    )]
130    TransformIndexGap { missing: u32 },
131
132    /// A matrix row id collides with a load-time interpolation prefix.
133    #[error("matrix row id '{id}' is reserved (env, file, secret, matrix, pipeline)")]
134    ReservedRowId { id: String },
135
136    /// Two matrix rows declared the same id.
137    #[error("duplicate matrix row id '{id}'")]
138    DuplicateRowId { id: String },
139
140    /// A row's `parent:` field names a row that doesn't exist.
141    #[error("matrix row '{id}' references unknown parent '{parent}'")]
142    UnknownParent { id: String, parent: String },
143
144    /// The parent chain contains a cycle.
145    #[error("matrix has a parent cycle through: {}", ids.join(" -> "))]
146    ParentCycle { ids: Vec<String> },
147
148    /// A row's `depends_on:` list names a row that doesn't exist.
149    #[error("matrix row '{id}' depends on unknown row '{depends_on}'")]
150    UnknownDependency { id: String, depends_on: String },
151
152    /// The combined `parent:` + `depends_on:` graph contains a cycle.
153    #[error("matrix has a dependency cycle involving: {}", ids.join(", "))]
154    DependencyCycle { ids: Vec<String> },
155
156    /// Two parent records of the same matrix row resolved to the same
157    /// `parent_key` value, producing a colliding state-key suffix.
158    #[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    /// The state key derived from the pipeline name + row id (+ resolved
164    /// parent-key value) is not a valid state-store key. Caught up front at
165    /// unit construction rather than mid-run.
166    #[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    /// One or more matrix invocations failed under `on_error: continue`.
174    #[error("{count} pipeline invocation(s) failed (see logs above for details)")]
175    PipelineHadFailures { count: usize },
176
177    /// Both `pipeline.nodes` (topology mode) and `matrix:` are non-empty.
178    /// They are mutually exclusive: topology mode replaces the matrix.
179    #[error(
180        "`pipeline.nodes` (topology mode) and `matrix:` are mutually exclusive — set one or the other, not both"
181    )]
182    MatrixAndNodesBothPresent,
183
184    /// A topology edge references a node id that doesn't exist in `nodes:`.
185    #[error("topology edge references unknown node '{name}' (known nodes: {})", known.join(", "))]
186    EdgeEndpointMissing { name: String, known: Vec<String> },
187
188    /// A topology graph-structure violation (arity, fan-out, join edges,
189    /// cycle, reachability) reported by the core validator.
190    #[error("invalid topology: {message}")]
191    InvalidTopology { message: String },
192
193    /// One or more topology sink nodes failed under `on_error: continue`.
194    #[error("{count} topology node(s) failed (see logs above for details)")]
195    TopologyHadFailures { count: usize },
196
197    /// DLQ sink kind is not registered (not compiled in or feature disabled).
198    #[error("DLQ sink kind `{kind}` is not registered (in {context})")]
199    UnknownDlqSinkKind { kind: String, context: String },
200
201    /// DLQ budget field is set to zero (which is invalid; omit to mean 'unlimited').
202    #[error("DLQ {field} must be > 0 (got 0); omit the field to mean 'unlimited'")]
203    InvalidDlqBudget { field: &'static str },
204
205    /// A matrix row referenced a named template that doesn't exist in
206    /// `pipeline.sources` / `pipeline.sinks` (or the legacy `default`).
207    #[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    /// A matrix row supplied no `ref:` and the legacy `default` template
219    /// doesn't exist either.
220    #[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    /// Both the legacy `pipeline.source` and `pipeline.sources.default` were
226    /// declared (same for sinks). The `default` slot can only be defined once.
227    #[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    /// A sink template carries a `transforms:` field, which only sources support.
233    #[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    /// A sink template carries `inherit_transforms:`, which only sources support.
240    #[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    /// A cycle was detected resolving `${vars.X}` / `${sources.X.PATH}` /
247    /// `${sinks.X.PATH}` references at load time.
248    #[error("interpolation cycle: {}", chain.join(" -> "))]
249    InterpolationCycle { chain: Vec<String> },
250
251    /// A config-composition include/extends chain contains a cycle.
252    #[error("config composition cycle: {}", chain.join(" -> "))]
253    CompositionCycle { chain: Vec<String> },
254
255    /// An `extends`/`!include` target file does not exist.
256    #[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    /// Composition nesting exceeded the safety cap (extends/!include loop guard).
267    #[error(
268        "config composition nested deeper than {max} levels — check for an extends/!include loop"
269    )]
270    CompositionDepthExceeded { max: usize },
271
272    /// An `!include` tag had a non-string payload, an unsupported tag, or its
273    /// target failed structural checks.
274    #[error("invalid `!include` in '{}': {reason}", path.display())]
275    BadInclude { path: PathBuf, reason: String },
276
277    /// `--profile NAME` (or FAUCET_PROFILE) named a profile not declared under `profiles:`.
278    #[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    /// A `${vars.X}` token referenced an undefined var.
285    #[error(
286        "interpolation '{token}' references unknown var '{name}' (define it under top-level `vars:`)"
287    )]
288    UnknownVarsRef { name: String, token: String },
289
290    /// A `${sources.X.PATH}` or `${sinks.X.PATH}` token referenced an
291    /// undefined template, or a dotted path that doesn't resolve inside it.
292    #[error("interpolation '{token}' could not be resolved: {reason}")]
293    UnknownTemplateRef { token: String, reason: String },
294
295    /// A connector's `auth: { ref }` named a provider not declared in the
296    /// top-level `auth:` catalog.
297    #[error(
298        "auth references unknown provider '{name}'. Declared providers: {}",
299        if known.is_empty() { String::from("(none)") } else { known.join(", ") }
300    )]
301    UnknownAuthProvider { name: String, known: Vec<String> },
302
303    /// A top-level `auth:` provider spec failed to build.
304    #[error("failed to build auth provider '{name}': {message}")]
305    AuthProviderBuild { name: String, message: String },
306
307    /// A `--select`/`--only`/`--skip` token matched no matrix row id (#370).
308    /// Guards against typos silently producing a partial or empty run.
309    #[error(
310        "{flag} '{token}' matched no matrix row. Available rows: {}",
311        if available.is_empty() { String::from("(none)") } else { available.join(", ") }
312    )]
313    NoMatchForSelector {
314        flag: &'static str,
315        token: String,
316        available: Vec<String>,
317    },
318
319    /// A `--status <tier>` value is not one of the readiness-ladder tiers (#371).
320    #[error("unknown status '{value}'. Valid tiers: {}", available.join(", "))]
321    UnknownStatus {
322        value: String,
323        available: Vec<String>,
324    },
325
326    /// A `--tag <t>` value matches no row's tags (#376). Typo protection.
327    #[error(
328        "unknown tag '{tag}'. Tags present in this config: {}",
329        if available.is_empty() { String::from("(none — no row declares tags)") } else { available.join(", ") }
330    )]
331    UnknownTag { tag: String, available: Vec<String> },
332
333    /// A `--include-parents <policy>` value is not `off`/`eligible`/`all` (#377).
334    #[error("unknown include_parents policy '{value}' (expected off, eligible, or all)")]
335    UnknownIncludeParents { value: String },
336
337    /// Matrix-only selectors were passed for a config with no `matrix:`
338    /// (single anonymous invocation) — nothing to select among (#370/#376).
339    #[error(
340        "selector(s) {flags} require a `matrix:` — this config has a single anonymous invocation (nothing to select)"
341    )]
342    SelectorsWithoutMatrix { flags: String },
343
344    /// The resolved run set is empty after status gating / tag narrowing / skip
345    /// (#371). Not a silent no-op — names each row's status and how to include.
346    #[error(
347        "no matrix rows selected to run. Rows and their status: {}. \
348         Widen the run set with --status <tier>, --select <id>, or --tag <t>",
349        rows.join(", ")
350    )]
351    EmptyRunSet { rows: Vec<String> },
352
353    /// A run-set row structurally depends on an ancestor that is not in the run
354    /// set, under the active `include_parents` policy (#377). Lists every
355    /// offending `dependent → ancestor (edge)` pair.
356    #[error(
357        "run-set dependency violation (include_parents={policy}): {}. \
358         Select the ancestor by id (--select <id>), or loosen the policy \
359         (--include-parents eligible|all)",
360        pairs.join("; ")
361    )]
362    RunSetMissingAncestors {
363        pairs: Vec<String>,
364        policy: &'static str,
365    },
366
367    /// A config-level validation failure that isn't covered by a more specific
368    /// variant (e.g. an invalid `quality:` block, or a quality check that
369    /// requires a DLQ when none is configured).
370    #[error("config error: {0}")]
371    Config(String),
372
373    /// Pass-through for failures bubbling up from `faucet-core` or a connector.
374    #[error(transparent)]
375    Faucet(#[from] faucet_core::FaucetError),
376
377    /// Pass-through I/O error.
378    #[error("io error: {0}")]
379    Io(#[from] std::io::Error),
380
381    /// Observability stack (Prometheus / tracing) failed to install.
382    #[error("observability install failed: {0}")]
383    Observability(String),
384
385    /// An internal invariant was violated (a bug). Surfaced instead of
386    /// silently producing a partial result.
387    #[error("internal error: {0}")]
388    Internal(String),
389
390    /// A secret-manager directive used a scheme whose backend feature was not
391    /// compiled into this binary.
392    #[error(
393        "secret directive uses scheme '{scheme}' but this binary was built without \
394         the `secrets-{scheme}` feature — rebuild with `--features secrets-{scheme}` (or `secrets`)"
395    )]
396    SecretBackendDisabled { scheme: String },
397
398    /// The secrets manager has no secret at the given reference.
399    #[error("secret '{reference}' not found in {scheme}")]
400    SecretNotFound { scheme: String, reference: String },
401
402    /// The secret fetch failed (network / API error).
403    #[error("failed to fetch secret '{reference}' from {scheme}: {source}")]
404    SecretFetchFailed {
405        scheme: String,
406        reference: String,
407        #[source]
408        source: Box<dyn std::error::Error + Send + Sync>,
409    },
410
411    /// No ambient credentials were available for the backend.
412    #[error("could not authenticate to {scheme}: {hint}")]
413    SecretAuthFailed { scheme: String, hint: String },
414
415    /// A `#field` selector was used on a secret that is not JSON.
416    #[error("secret '{reference}' from {scheme} is not JSON, but a '#field' selector was used")]
417    SecretNotJson { scheme: String, reference: String },
418
419    /// A `#field` selector named a key absent from the secret JSON.
420    #[error(
421        "secret '{reference}' from {scheme} has no field '{field}' (available: {})",
422        if available.is_empty() { String::from("(none — secret is an empty object)") } else { available.join(", ") }
423    )]
424    SecretFieldMissing {
425        scheme: String,
426        reference: String,
427        field: String,
428        available: Vec<String>,
429    },
430
431    /// A secret directive was found while loading via the synchronous path.
432    #[error(
433        "config references a secrets manager (${{vault:…}} / ${{aws-sm:…}} / …) which requires \
434         the async load path — load via `faucet run`/`validate`/`preview` rather than the sync API"
435    )]
436    SecretsRequireAsyncLoad,
437
438    /// One or more `faucet doctor` preflight probes failed. The checklist is
439    /// printed by the command; `main` maps this to an exit code equal to the
440    /// failed-probe count (clamped to 255).
441    #[error("{failed} preflight probe(s) failed")]
442    DoctorFailed { failed: usize },
443
444    /// One or more `faucet test` cases failed. The report is printed by the
445    /// command; `main` maps this to an exit code equal to the failed-case
446    /// count (clamped to 255).
447    #[error("{failed} test case(s) failed")]
448    TestsFailed { failed: usize },
449
450    /// One or more `faucet backfill` units failed. The per-unit report is
451    /// printed by the command (progress is already durably recorded, so
452    /// `--resume` retries only the failures); `main` maps this to an exit
453    /// code equal to the failed-unit count (clamped to 255).
454    #[error("{failed} backfill unit(s) failed")]
455    BackfillFailed { failed: usize },
456
457    /// A `faucet serve` startup or runtime failure (bind, auth gate, etc.).
458    #[error("serve error: {0}")]
459    Serve(String),
460
461    /// `overlap_policy: forbid` saw a tick fire while a run was still in flight.
462    #[error("scheduled run overlap with overlap_policy: forbid — previous run still in progress")]
463    ScheduleOverlapForbidden,
464}
465
466impl From<faucet_core::InstallError> for CliError {
467    fn from(e: faucet_core::InstallError) -> Self {
468        CliError::Observability(e.to_string())
469    }
470}
471
472#[cfg(test)]
473mod secrets_error_tests {
474    use super::*;
475
476    #[test]
477    fn secret_errors_render_reference_not_value() {
478        let e = CliError::SecretNotFound {
479            scheme: "vault".into(),
480            reference: "secret/data/app#token".into(),
481        };
482        let msg = e.to_string();
483        assert!(msg.contains("vault"));
484        assert!(msg.contains("secret/data/app#token"));
485
486        let e = CliError::SecretFieldMissing {
487            scheme: "aws-sm".into(),
488            reference: "prod/db".into(),
489            field: "password".into(),
490            available: vec!["username".into(), "host".into()],
491        };
492        let msg = e.to_string();
493        assert!(msg.contains("password"));
494        assert!(msg.contains("username") && msg.contains("host"));
495
496        let e = CliError::SecretBackendDisabled {
497            scheme: "azure-kv".into(),
498        };
499        assert!(e.to_string().contains("secrets-azure-kv"));
500
501        assert!(
502            CliError::SecretsRequireAsyncLoad
503                .to_string()
504                .contains("async")
505        );
506    }
507
508    #[test]
509    fn fetch_auth_notjson_errors_render_safely() {
510        let e = CliError::SecretFetchFailed {
511            scheme: "vault".into(),
512            reference: "secret/data/app#token".into(),
513            source: "connection refused".into(),
514        };
515        let msg = e.to_string();
516        assert!(msg.contains("vault") && msg.contains("secret/data/app#token"));
517
518        let e = CliError::SecretAuthFailed {
519            scheme: "aws-sm".into(),
520            hint: "set AWS_PROFILE".into(),
521        };
522        assert!(e.to_string().contains("aws-sm") && e.to_string().contains("set AWS_PROFILE"));
523
524        let e = CliError::SecretNotJson {
525            scheme: "vault".into(),
526            reference: "secret/raw".into(),
527        };
528        assert!(e.to_string().contains("not JSON"));
529    }
530
531    #[test]
532    fn field_missing_with_empty_available_has_no_dangling_list() {
533        let e = CliError::SecretFieldMissing {
534            scheme: "vault".into(),
535            reference: "secret/data/app".into(),
536            field: "token".into(),
537            available: vec![],
538        };
539        let msg = e.to_string();
540        assert!(!msg.ends_with("(available: )"));
541        assert!(msg.contains("token"));
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn missing_env_selector_renders() {
551        let e = CliError::MissingEnvSelector {
552            var: "FAUCET_SOURCE".to_owned(),
553        };
554        let msg = e.to_string();
555        assert!(msg.contains("FAUCET_SOURCE"));
556        assert!(msg.contains("--from-env"));
557    }
558
559    #[test]
560    fn env_conflict_names_both_vars() {
561        let e = CliError::EnvConflict {
562            field: "auth".to_owned(),
563            scalar_var: "FAUCET_SOURCE_REST_AUTH".to_owned(),
564            json_var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
565        };
566        let msg = e.to_string();
567        assert!(msg.contains("FAUCET_SOURCE_REST_AUTH"));
568        assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
569    }
570
571    #[test]
572    fn invalid_env_json_names_var_and_parse_error() {
573        let e = CliError::InvalidEnvJson {
574            var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
575            message: "expected value at line 1 column 1".to_owned(),
576        };
577        let msg = e.to_string();
578        assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
579        assert!(msg.contains("expected value"));
580    }
581
582    #[test]
583    fn transform_index_gap_reports_missing_index() {
584        let e = CliError::TransformIndexGap { missing: 2 };
585        let msg = e.to_string();
586        assert!(msg.contains('2'));
587        assert!(msg.to_ascii_lowercase().contains("transform"));
588    }
589
590    #[test]
591    fn unknown_template_lists_known_names() {
592        let e = CliError::UnknownTemplate {
593            kind: "source",
594            name: "users_api".into(),
595            row_id: "load_users".into(),
596            known: vec!["customers_api".into(), "orders_api".into()],
597        };
598        let msg = e.to_string();
599        assert!(msg.contains("users_api"));
600        assert!(msg.contains("load_users"));
601        assert!(msg.contains("customers_api"));
602    }
603
604    #[test]
605    fn duplicate_template_names_kind() {
606        let e = CliError::DuplicateTemplate {
607            kind: "sink",
608            name: "default".into(),
609        };
610        let msg = e.to_string();
611        assert!(msg.contains("sink"));
612        assert!(msg.contains("default"));
613    }
614
615    #[test]
616    fn interpolation_cycle_renders_chain() {
617        let e = CliError::InterpolationCycle {
618            chain: vec!["vars.a".into(), "vars.b".into(), "vars.a".into()],
619        };
620        let msg = e.to_string();
621        assert!(msg.contains("vars.a"));
622        assert!(msg.contains("vars.b"));
623    }
624
625    #[test]
626    fn composition_cycle_renders_chain() {
627        let e = CliError::CompositionCycle {
628            chain: vec!["a.yaml".into(), "b.yaml".into(), "a.yaml".into()],
629        };
630        let msg = e.to_string();
631        assert!(msg.contains("a.yaml") && msg.contains("b.yaml"));
632        assert!(msg.contains(" -> "));
633    }
634
635    #[test]
636    fn unknown_profile_lists_known() {
637        let e = CliError::UnknownProfile {
638            name: "staging".into(),
639            known: vec!["dev".into(), "prod".into()],
640        };
641        let msg = e.to_string();
642        assert!(msg.contains("staging") && msg.contains("dev") && msg.contains("prod"));
643
644        let none = CliError::UnknownProfile {
645            name: "x".into(),
646            known: vec![],
647        };
648        assert!(none.to_string().contains("no `profiles:` block"));
649    }
650
651    #[test]
652    fn include_not_found_names_both_paths() {
653        let e = CliError::IncludeNotFound {
654            path: std::path::PathBuf::from("base.yaml"),
655            referenced_by: std::path::PathBuf::from("app.yaml"),
656        };
657        let msg = e.to_string();
658        assert!(msg.contains("base.yaml") && msg.contains("app.yaml"));
659    }
660
661    #[test]
662    fn composition_depth_exceeds_renders_max() {
663        assert!(
664            CliError::CompositionDepthExceeded { max: 32 }
665                .to_string()
666                .contains("32")
667        );
668    }
669
670    #[test]
671    fn bad_include_names_path_and_reason() {
672        let e = CliError::BadInclude {
673            path: std::path::PathBuf::from("f.yaml"),
674            reason: "!include payload must be a string path".into(),
675        };
676        assert!(e.to_string().contains("f.yaml") && e.to_string().contains("string path"));
677    }
678}