1use 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 #[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}