use std::path::PathBuf;
use thiserror::Error;
pub type CliResult<T> = Result<T, CliError>;
#[derive(Debug, Error)]
pub enum CliError {
#[error("failed to read config file '{path}': {source}")]
ReadConfig {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"unsupported config extension for '{path}' — use .yaml, .yml, or .json (mixed JSON/YAML in a single file is not allowed)"
)]
UnknownExtension { path: PathBuf },
#[error("failed to parse config '{path}': {message}")]
ParseConfig { path: PathBuf, message: String },
#[error("missing environment variable '{var}' referenced in config at '{location}'")]
MissingEnvVar { var: String, location: String },
#[error("failed to read interpolated file '{}' referenced in config: {source}", path.display())]
ReadInterpolatedFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"interpolated file '{}' exceeds the {max_bytes}-byte limit for `${{file:...}}` — \
this directive is for small token/secret files, not bulk data",
path.display()
)]
InterpolatedFileTooLarge { path: PathBuf, max_bytes: u64 },
#[error(
"interpolation '{token}' references unknown id '{id}' (must be a matrix row id, or one of env/file/secret)"
)]
UnknownInterpolationId { id: String, token: String },
#[error("matrix row '{id}' has no field at path '{path}' in this parent record")]
MissingRecordField { id: String, path: String },
#[error("unknown {kind} '{name}'. Available: {available}")]
UnknownConnector {
kind: &'static str,
name: String,
available: String,
},
#[error("unknown state store '{name}'. Available: {available}")]
UnknownStateStore { name: String, available: String },
#[error("unknown transform '{name}'. Available: {available}")]
UnknownTransform { name: String, available: String },
#[error("invalid transform '{name}': {message}")]
InvalidTransform { name: String, message: String },
#[error("invalid config for {kind} '{name}': {message}")]
InvalidConnectorConfig {
kind: &'static str,
name: String,
message: String,
},
#[error("refusing to overwrite existing file '{path}' — pass --force to overwrite")]
ScaffoldExists { path: PathBuf },
#[error(
"missing required environment variable '{var}' — set it before invoking `faucet run --from-env`"
)]
MissingEnvSelector { var: String },
#[error("--env-file path '{}' does not exist", path.display())]
EnvFileNotFound { path: PathBuf },
#[error(
"no pipeline config: pass a path, --from-env, or create faucet.yaml (or .yml/.json) in the current directory"
)]
NoConfigOrFromEnv,
#[error(
"conflicting environment variables for field '{field}': both '{scalar_var}' and '{json_var}' are set — pick one"
)]
EnvConflict {
field: String,
scalar_var: String,
json_var: String,
},
#[error("environment variable '{var}' is not valid JSON: {message}")]
InvalidEnvJson { var: String, message: String },
#[error(
"transform env vars must be contiguous starting at FAUCET_TRANSFORM_1; index {missing} is missing"
)]
TransformIndexGap { missing: u32 },
#[error("matrix row id '{id}' is reserved (env, file, secret, matrix, pipeline)")]
ReservedRowId { id: String },
#[error("duplicate matrix row id '{id}'")]
DuplicateRowId { id: String },
#[error("matrix row '{id}' references unknown parent '{parent}'")]
UnknownParent { id: String, parent: String },
#[error("matrix has a parent cycle through: {}", ids.join(" -> "))]
ParentCycle { ids: Vec<String> },
#[error(
"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"
)]
DuplicateStateKey { id: String, state_key: String },
#[error("invalid state key '{state_key}' for row '{id}': {reason}")]
InvalidStateKey {
id: String,
state_key: String,
reason: String,
},
#[error("{count} pipeline invocation(s) failed (see logs above for details)")]
PipelineHadFailures { count: usize },
#[error("DLQ sink kind `{kind}` is not registered (in {context})")]
UnknownDlqSinkKind { kind: String, context: String },
#[error("DLQ {field} must be > 0 (got 0); omit the field to mean 'unlimited'")]
InvalidDlqBudget { field: &'static str },
#[error(
"matrix row '{row_id}' references unknown {kind} template '{name}'. Known {kind} templates: {known}",
known = if known.is_empty() { String::from("(none defined)") } else { known.join(", ") }
)]
UnknownTemplate {
kind: &'static str,
name: String,
row_id: String,
known: Vec<String>,
},
#[error(
"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"
)]
MissingTemplate { kind: &'static str, row_id: String },
#[error(
"{kind} template '{name}' is defined twice — declare it either via the singular `pipeline.{kind}` block or in `pipeline.{kind}s`, not both"
)]
DuplicateTemplate { kind: &'static str, name: String },
#[error(
"sink template '{name}' has `transforms:` — sinks cannot carry transforms; \
declare transforms on the source template, pipeline, or matrix row instead"
)]
TransformsOnSink { name: String },
#[error(
"sink template '{name}' has `inherit_transforms:` — sinks cannot carry the \
transform-inheritance flag; remove it"
)]
InheritTransformsOnSink { name: String },
#[error("interpolation cycle: {}", chain.join(" -> "))]
InterpolationCycle { chain: Vec<String> },
#[error(
"interpolation '{token}' references unknown var '{name}' (define it under top-level `vars:`)"
)]
UnknownVarsRef { name: String, token: String },
#[error("interpolation '{token}' could not be resolved: {reason}")]
UnknownTemplateRef { token: String, reason: String },
#[error(
"auth references unknown provider '{name}'. Declared providers: {}",
if known.is_empty() { String::from("(none)") } else { known.join(", ") }
)]
UnknownAuthProvider { name: String, known: Vec<String> },
#[error("failed to build auth provider '{name}': {message}")]
AuthProviderBuild { name: String, message: String },
#[error("config error: {0}")]
Config(String),
#[error(transparent)]
Faucet(#[from] faucet_core::FaucetError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("observability install failed: {0}")]
Observability(String),
#[error("internal error: {0}")]
Internal(String),
#[error(
"secret directive uses scheme '{scheme}' but this binary was built without \
the `secrets-{scheme}` feature — rebuild with `--features secrets-{scheme}` (or `secrets`)"
)]
SecretBackendDisabled { scheme: String },
#[error("secret '{reference}' not found in {scheme}")]
SecretNotFound { scheme: String, reference: String },
#[error("failed to fetch secret '{reference}' from {scheme}: {source}")]
SecretFetchFailed {
scheme: String,
reference: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("could not authenticate to {scheme}: {hint}")]
SecretAuthFailed { scheme: String, hint: String },
#[error("secret '{reference}' from {scheme} is not JSON, but a '#field' selector was used")]
SecretNotJson { scheme: String, reference: String },
#[error(
"secret '{reference}' from {scheme} has no field '{field}' (available: {})",
if available.is_empty() { String::from("(none — secret is an empty object)") } else { available.join(", ") }
)]
SecretFieldMissing {
scheme: String,
reference: String,
field: String,
available: Vec<String>,
},
#[error(
"config references a secrets manager (${{vault:…}} / ${{aws-sm:…}} / …) which requires \
the async load path — load via `faucet run`/`validate`/`preview` rather than the sync API"
)]
SecretsRequireAsyncLoad,
#[error("{failed} preflight probe(s) failed")]
DoctorFailed { failed: usize },
#[error("serve error: {0}")]
Serve(String),
#[error("scheduled run overlap with overlap_policy: forbid — previous run still in progress")]
ScheduleOverlapForbidden,
}
impl From<faucet_core::InstallError> for CliError {
fn from(e: faucet_core::InstallError) -> Self {
CliError::Observability(e.to_string())
}
}
#[cfg(test)]
mod secrets_error_tests {
use super::*;
#[test]
fn secret_errors_render_reference_not_value() {
let e = CliError::SecretNotFound {
scheme: "vault".into(),
reference: "secret/data/app#token".into(),
};
let msg = e.to_string();
assert!(msg.contains("vault"));
assert!(msg.contains("secret/data/app#token"));
let e = CliError::SecretFieldMissing {
scheme: "aws-sm".into(),
reference: "prod/db".into(),
field: "password".into(),
available: vec!["username".into(), "host".into()],
};
let msg = e.to_string();
assert!(msg.contains("password"));
assert!(msg.contains("username") && msg.contains("host"));
let e = CliError::SecretBackendDisabled {
scheme: "azure-kv".into(),
};
assert!(e.to_string().contains("secrets-azure-kv"));
assert!(
CliError::SecretsRequireAsyncLoad
.to_string()
.contains("async")
);
}
#[test]
fn fetch_auth_notjson_errors_render_safely() {
let e = CliError::SecretFetchFailed {
scheme: "vault".into(),
reference: "secret/data/app#token".into(),
source: "connection refused".into(),
};
let msg = e.to_string();
assert!(msg.contains("vault") && msg.contains("secret/data/app#token"));
let e = CliError::SecretAuthFailed {
scheme: "aws-sm".into(),
hint: "set AWS_PROFILE".into(),
};
assert!(e.to_string().contains("aws-sm") && e.to_string().contains("set AWS_PROFILE"));
let e = CliError::SecretNotJson {
scheme: "vault".into(),
reference: "secret/raw".into(),
};
assert!(e.to_string().contains("not JSON"));
}
#[test]
fn field_missing_with_empty_available_has_no_dangling_list() {
let e = CliError::SecretFieldMissing {
scheme: "vault".into(),
reference: "secret/data/app".into(),
field: "token".into(),
available: vec![],
};
let msg = e.to_string();
assert!(!msg.ends_with("(available: )"));
assert!(msg.contains("token"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_env_selector_renders() {
let e = CliError::MissingEnvSelector {
var: "FAUCET_SOURCE".to_owned(),
};
let msg = e.to_string();
assert!(msg.contains("FAUCET_SOURCE"));
assert!(msg.contains("--from-env"));
}
#[test]
fn env_conflict_names_both_vars() {
let e = CliError::EnvConflict {
field: "auth".to_owned(),
scalar_var: "FAUCET_SOURCE_REST_AUTH".to_owned(),
json_var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
};
let msg = e.to_string();
assert!(msg.contains("FAUCET_SOURCE_REST_AUTH"));
assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
}
#[test]
fn invalid_env_json_names_var_and_parse_error() {
let e = CliError::InvalidEnvJson {
var: "FAUCET_SOURCE_REST_AUTH_JSON".to_owned(),
message: "expected value at line 1 column 1".to_owned(),
};
let msg = e.to_string();
assert!(msg.contains("FAUCET_SOURCE_REST_AUTH_JSON"));
assert!(msg.contains("expected value"));
}
#[test]
fn transform_index_gap_reports_missing_index() {
let e = CliError::TransformIndexGap { missing: 2 };
let msg = e.to_string();
assert!(msg.contains('2'));
assert!(msg.to_ascii_lowercase().contains("transform"));
}
#[test]
fn unknown_template_lists_known_names() {
let e = CliError::UnknownTemplate {
kind: "source",
name: "users_api".into(),
row_id: "load_users".into(),
known: vec!["customers_api".into(), "orders_api".into()],
};
let msg = e.to_string();
assert!(msg.contains("users_api"));
assert!(msg.contains("load_users"));
assert!(msg.contains("customers_api"));
}
#[test]
fn duplicate_template_names_kind() {
let e = CliError::DuplicateTemplate {
kind: "sink",
name: "default".into(),
};
let msg = e.to_string();
assert!(msg.contains("sink"));
assert!(msg.contains("default"));
}
#[test]
fn interpolation_cycle_renders_chain() {
let e = CliError::InterpolationCycle {
chain: vec!["vars.a".into(), "vars.b".into(), "vars.a".into()],
};
let msg = e.to_string();
assert!(msg.contains("vars.a"));
assert!(msg.contains("vars.b"));
}
}