Skip to main content

cfgd_core/errors/
mod.rs

1// Domain error types — thiserror for library errors, anyhow only at CLI boundary
2
3use std::path::PathBuf;
4
5pub type Result<T> = std::result::Result<T, CfgdError>;
6
7#[derive(Debug, thiserror::Error)]
8pub enum CfgdError {
9    #[error("config error: {0}")]
10    Config(#[from] ConfigError),
11
12    #[error("file error: {0}")]
13    File(#[from] FileError),
14
15    #[error("package error: {0}")]
16    Package(#[from] PackageError),
17
18    #[error("secret error: {0}")]
19    Secret(#[from] SecretError),
20
21    #[error("state error: {0}")]
22    State(#[from] StateError),
23
24    #[error("daemon error: {0}")]
25    Daemon(#[from] DaemonError),
26
27    #[error("source error: {0}")]
28    Source(#[from] SourceError),
29
30    #[error("composition error: {0}")]
31    Composition(Box<CompositionError>),
32
33    #[error("upgrade error: {0}")]
34    Upgrade(#[from] UpgradeError),
35
36    #[error("module error: {0}")]
37    Module(#[from] ModuleError),
38
39    #[error("generate error: {0}")]
40    Generate(#[from] GenerateError),
41
42    #[error("oci error: {0}")]
43    Oci(#[from] OciError),
44
45    #[error("io error: {0}")]
46    Io(#[from] std::io::Error),
47}
48
49#[derive(Debug, thiserror::Error)]
50pub enum ConfigError {
51    #[error("config file not found: {path}")]
52    NotFound { path: PathBuf },
53
54    #[error("invalid config: {message}")]
55    Invalid { message: String },
56
57    #[error("circular profile inheritance: {chain:?}")]
58    CircularInheritance { chain: Vec<String> },
59
60    #[error("profile not found: {name}")]
61    ProfileNotFound { name: String },
62
63    #[error("yaml parse error: {0}")]
64    Yaml(#[from] serde_yaml::Error),
65
66    #[error("toml parse error: {0}")]
67    Toml(#[from] toml::de::Error),
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum FileError {
72    #[error("source file not found: {path}")]
73    SourceNotFound { path: PathBuf },
74
75    #[error("target path not writable: {path}")]
76    TargetNotWritable { path: PathBuf },
77
78    #[error("template rendering failed for {path}: {message}")]
79    TemplateError { path: PathBuf, message: String },
80
81    #[error("permission denied setting mode {mode:#o} on {path}")]
82    PermissionDenied { path: PathBuf, mode: u32 },
83
84    #[error("io error on {path}: {source}")]
85    Io {
86        path: PathBuf,
87        source: std::io::Error,
88    },
89
90    #[error(
91        "file conflict: {target} is targeted by both '{source_a}' and '{source_b}' with different content"
92    )]
93    Conflict {
94        target: PathBuf,
95        source_a: String,
96        source_b: String,
97    },
98
99    #[error("source file changed between plan and apply: {path}")]
100    SourceChanged { path: PathBuf },
101
102    #[error("path {path} escapes root directory {root}")]
103    PathTraversal { path: PathBuf, root: PathBuf },
104
105    #[error(
106        "source file '{path}' must be encrypted with '{backend}' but appears to be unencrypted"
107    )]
108    NotEncrypted { path: PathBuf, backend: String },
109
110    #[error("unknown encryption backend '{backend}' — supported: sops, age")]
111    UnknownEncryptionBackend { backend: String },
112
113    #[error(
114        "encryption mode 'Always' is incompatible with strategy '{strategy}' for '{path}' — use Copy or Template instead"
115    )]
116    EncryptionStrategyIncompatible { path: PathBuf, strategy: String },
117}
118
119#[derive(Debug, thiserror::Error)]
120pub enum PackageError {
121    #[error("package manager '{manager}' not available")]
122    ManagerNotAvailable { manager: String },
123
124    #[error("{manager} install failed: {message}")]
125    InstallFailed { manager: String, message: String },
126
127    #[error("{manager} uninstall failed: {message}")]
128    UninstallFailed { manager: String, message: String },
129
130    #[error("{manager} failed to list installed packages: {message}")]
131    ListFailed { manager: String, message: String },
132
133    #[error("{manager} command failed: {source}")]
134    CommandFailed {
135        manager: String,
136        source: std::io::Error,
137    },
138
139    #[error("{manager} bootstrap failed: {message}")]
140    BootstrapFailed { manager: String, message: String },
141
142    #[error("package manager '{manager}' not found in registry")]
143    ManagerNotFound { manager: String },
144}
145
146#[derive(Debug, thiserror::Error)]
147pub enum SecretError {
148    #[error("sops not found — install: https://github.com/getsops/sops#install")]
149    SopsNotFound,
150
151    #[error("sops encryption failed for {path}: {message}")]
152    EncryptionFailed { path: PathBuf, message: String },
153
154    #[error("sops decryption failed for {path}: {message}")]
155    DecryptionFailed { path: PathBuf, message: String },
156
157    #[error("secret provider '{provider}' not available — {hint}")]
158    ProviderNotAvailable { provider: String, hint: String },
159
160    #[error("secret reference unresolvable: {reference}")]
161    UnresolvableRef { reference: String },
162
163    #[error("age key not found at {path}")]
164    AgeKeyNotFound { path: PathBuf },
165}
166
167#[derive(Debug, thiserror::Error)]
168pub enum StateError {
169    #[error("state database error: {0}")]
170    Database(String),
171
172    #[error("migration failed: {message}")]
173    MigrationFailed { message: String },
174
175    #[error("state directory not writable: {path}")]
176    DirectoryNotWritable { path: PathBuf },
177
178    #[error("apply lock held by another process: {holder}")]
179    ApplyLockHeld { holder: String },
180}
181
182impl From<rusqlite::Error> for StateError {
183    fn from(e: rusqlite::Error) -> Self {
184        StateError::Database(e.to_string())
185    }
186}
187
188impl From<CompositionError> for CfgdError {
189    fn from(e: CompositionError) -> Self {
190        CfgdError::Composition(Box::new(e))
191    }
192}
193
194impl From<rusqlite::Error> for CfgdError {
195    fn from(e: rusqlite::Error) -> Self {
196        CfgdError::State(StateError::Database(e.to_string()))
197    }
198}
199
200#[derive(Debug, thiserror::Error)]
201pub enum SourceError {
202    #[error("source '{name}' not found")]
203    NotFound { name: String },
204
205    #[error("failed to fetch source '{name}': {message}")]
206    FetchFailed { name: String, message: String },
207
208    #[error("invalid ConfigSource manifest in '{name}': {message}")]
209    InvalidManifest { name: String, message: String },
210
211    #[error("source version {version} does not match pin {pin} for '{name}'")]
212    VersionMismatch {
213        name: String,
214        version: String,
215        pin: String,
216    },
217
218    #[error("source '{name}' contains no profiles")]
219    NoProfiles { name: String },
220
221    #[error("profile '{profile}' not found in source '{name}'")]
222    ProfileNotFound { name: String, profile: String },
223
224    #[error("source cache error: {message}")]
225    CacheError { message: String },
226
227    #[error("git error for source '{name}': {message}")]
228    GitError { name: String, message: String },
229
230    #[error("signature verification failed for source '{name}': {message}")]
231    SignatureVerificationFailed { name: String, message: String },
232}
233
234#[derive(Debug, thiserror::Error)]
235pub enum CompositionError {
236    #[error("cannot override locked resource '{resource}' from source '{source_name}'")]
237    LockedResource {
238        source_name: String,
239        resource: String,
240    },
241
242    #[error("cannot remove required resource '{resource}' from source '{source_name}'")]
243    RequiredResource {
244        source_name: String,
245        resource: String,
246    },
247
248    #[error("path '{path}' not in allowed paths for source '{source_name}'")]
249    PathNotAllowed { source_name: String, path: String },
250
251    #[error("source '{source_name}' is not allowed to run scripts")]
252    ScriptsNotAllowed { source_name: String },
253
254    #[error("source '{source_name}' template attempted to access local variable '{variable}'")]
255    TemplateSandboxViolation {
256        source_name: String,
257        variable: String,
258    },
259
260    #[error(
261        "source '{source_name}' attempted to modify system setting '{setting}' without permission"
262    )]
263    SystemChangeNotAllowed {
264        source_name: String,
265        setting: String,
266    },
267
268    #[error("conflict on '{resource}' between sources: {source_names:?}")]
269    UnresolvableConflict {
270        resource: String,
271        source_names: Vec<String>,
272    },
273
274    #[error(
275        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but has no encryption block"
276    )]
277    EncryptionRequired {
278        source_name: String,
279        path: String,
280        pattern: String,
281    },
282
283    #[error(
284        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but uses backend '{actual_backend}' instead of required '{required_backend}'"
285    )]
286    EncryptionBackendMismatch {
287        source_name: String,
288        path: String,
289        pattern: String,
290        actual_backend: String,
291        required_backend: String,
292    },
293
294    #[error(
295        "file '{path}' matches required-encryption target '{pattern}' in source '{source_name}' but uses mode '{actual_mode}' instead of required '{required_mode}'"
296    )]
297    EncryptionModeMismatch {
298        source_name: String,
299        path: String,
300        pattern: String,
301        actual_mode: String,
302        required_mode: String,
303    },
304}
305
306#[derive(Debug, thiserror::Error)]
307pub enum UpgradeError {
308    #[error("failed to query GitHub releases: {message}")]
309    ApiError { message: String },
310
311    #[error("no release found for {os}/{arch}")]
312    NoAsset { os: String, arch: String },
313
314    #[error("download failed: {message}")]
315    DownloadFailed { message: String },
316
317    #[error("checksum verification failed for {file}")]
318    ChecksumMismatch { file: String },
319
320    #[error("failed to install binary: {message}")]
321    InstallFailed { message: String },
322
323    #[error("version parse error: {message}")]
324    VersionParse { message: String },
325}
326
327#[derive(Debug, thiserror::Error)]
328pub enum ModuleError {
329    #[error("module not found: {name}")]
330    NotFound { name: String },
331
332    #[error("module dependency cycle: {chain:?}")]
333    DependencyCycle { chain: Vec<String> },
334
335    #[error("module '{module}' depends on '{dependency}' which is not available")]
336    MissingDependency { module: String, dependency: String },
337
338    #[error(
339        "package '{package}' in module '{module}' cannot be resolved: no available manager satisfies the requirements (minVersion: {min_version})"
340    )]
341    UnresolvablePackage {
342        module: String,
343        package: String,
344        min_version: String,
345    },
346
347    #[error("failed to fetch git source for module '{module}': {url}: {message}")]
348    GitFetchFailed {
349        module: String,
350        url: String,
351        message: String,
352    },
353
354    #[error("module '{name}' has invalid spec: {message}")]
355    InvalidSpec { name: String, message: String },
356
357    #[error(
358        "lockfile integrity check failed for module '{name}': expected {expected}, got {actual}"
359    )]
360    IntegrityMismatch {
361        name: String,
362        expected: String,
363        actual: String,
364    },
365
366    #[error(
367        "remote module '{name}' requires a pinned ref (tag or commit) — branch tracking is not allowed for security"
368    )]
369    UnpinnedRemoteModule { name: String },
370
371    #[error("module source fetch failed for '{url}': {message}")]
372    SourceFetchFailed { url: String, message: String },
373}
374
375#[derive(Debug, thiserror::Error)]
376pub enum GenerateError {
377    #[error("validation failed: {message}")]
378    ValidationFailed { message: String },
379
380    #[error("file access denied: {path} — {reason}")]
381    FileAccessDenied { path: PathBuf, reason: String },
382
383    #[error("AI provider error: {message}")]
384    ProviderError { message: String },
385
386    #[error("API key not found in environment variable '{env_var}'")]
387    ApiKeyNotFound { env_var: String },
388}
389
390#[derive(Debug, thiserror::Error)]
391pub enum DaemonError {
392    #[error("daemon already running (pid {pid})")]
393    AlreadyRunning { pid: u32 },
394
395    #[error("health socket unavailable: {message}")]
396    HealthSocketError { message: String },
397
398    #[error("service install failed: {message}")]
399    ServiceInstallFailed { message: String },
400
401    #[error("service error: {message}")]
402    ServiceError { message: String },
403
404    #[error("watch error: {message}")]
405    WatchError { message: String },
406}
407
408#[derive(Debug, thiserror::Error)]
409pub enum OciError {
410    #[error("invalid OCI reference: {reference}")]
411    InvalidReference { reference: String },
412
413    #[error("registry authentication failed for {registry}: {message}")]
414    AuthFailed { registry: String, message: String },
415
416    #[error("registry request failed: {message}")]
417    RequestFailed { message: String },
418
419    #[error("blob upload failed for {digest}: {message}")]
420    BlobUploadFailed { digest: String, message: String },
421
422    #[error("manifest push failed: {message}")]
423    ManifestPushFailed { message: String },
424
425    #[error("manifest not found: {reference}")]
426    ManifestNotFound { reference: String },
427
428    #[error("blob not found: {digest}")]
429    BlobNotFound { digest: String },
430
431    #[error("module.yaml not found in {dir}")]
432    ModuleYamlNotFound { dir: PathBuf },
433
434    #[error("signature required but not found for {reference}")]
435    SignatureRequired { reference: String },
436
437    #[error("archive error: {message}")]
438    ArchiveError { message: String },
439
440    #[error("build error: {message}")]
441    BuildError { message: String },
442
443    #[error("signing error: {message}")]
444    SigningError { message: String },
445
446    #[error("signature verification failed for {reference}: {message}")]
447    VerificationFailed { reference: String, message: String },
448
449    #[error("attestation error: {message}")]
450    AttestationError { message: String },
451
452    #[error("{tool} not found — install it or add it to PATH")]
453    ToolNotFound { tool: String },
454
455    #[error("io error: {0}")]
456    Io(#[from] std::io::Error),
457
458    #[error("json error: {0}")]
459    Json(#[from] serde_json::Error),
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    /// Table-driven: every `From<SubError> for CfgdError` variant in one test.
467    #[test]
468    fn all_sub_errors_convert_to_cfgd_error() {
469        let cases: Vec<(CfgdError, &str)> = vec![
470            (
471                ConfigError::ProfileNotFound {
472                    name: "test".into(),
473                }
474                .into(),
475                "test",
476            ),
477            (
478                SourceError::NotFound {
479                    name: "acme".into(),
480                }
481                .into(),
482                "acme",
483            ),
484            (
485                CompositionError::LockedResource {
486                    source_name: "acme".into(),
487                    resource: "~/.config/security.yaml".into(),
488                }
489                .into(),
490                "locked",
491            ),
492            (
493                UpgradeError::ChecksumMismatch {
494                    file: "cfgd-0.2.0-linux-x86_64.tar.gz".into(),
495                }
496                .into(),
497                "checksum",
498            ),
499            (
500                ModuleError::NotFound {
501                    name: "nvim".into(),
502                }
503                .into(),
504                "nvim",
505            ),
506            (
507                GenerateError::ValidationFailed {
508                    message: "missing apiVersion".into(),
509                }
510                .into(),
511                "missing apiVersion",
512            ),
513            (
514                std::io::Error::new(std::io::ErrorKind::NotFound, "file missing").into(),
515                "file missing",
516            ),
517        ];
518        for (cfgd_err, needle) in &cases {
519            assert!(
520                cfgd_err.to_string().contains(needle),
521                "expected '{}' in: {}",
522                needle,
523                cfgd_err,
524            );
525        }
526    }
527}