Skip to main content

cfgd_core/
exit.rs

1//! Exit-code taxonomy for the cfgd CLI.
2//!
3//! Scripted consumers rely on distinct exit codes to choose follow-up
4//! actions without parsing stderr. This module defines every code cfgd
5//! itself emits and provides the error-to-code mapping used by the CLI
6//! entry point.
7//!
8//! # Codes
9//!
10//! | Code | Variant           | Meaning                                                  |
11//! |------|-------------------|----------------------------------------------------------|
12//! | 0    | [`Success`]       | Operation completed without error.                       |
13//! | 1    | [`Error`]         | Generic failure (network, IO, unclassified internal).    |
14//! | 2    | [`UpdateAvailable`] | `upgrade --check`: a newer release exists.             |
15//! | 3    | [`NoConfig`]      | No cfgd config file at the resolved path.                |
16//! | 4    | [`ConfigInvalid`] | Config file exists but failed parse or validation.       |
17//! | 5    | [`DriftDetected`] | `diff`/`status` with `--exit-code`: drift present.       |
18//!
19//! External-process passthrough (e.g. `kubectl exec` forwarded by the
20//! `kubectl cfgd` plugin) is out of scope for this enum — those codes
21//! belong to the invoked tool, not to cfgd.
22//!
23//! [`Success`]: ExitCode::Success
24//! [`Error`]: ExitCode::Error
25//! [`UpdateAvailable`]: ExitCode::UpdateAvailable
26//! [`NoConfig`]: ExitCode::NoConfig
27//! [`ConfigInvalid`]: ExitCode::ConfigInvalid
28//! [`DriftDetected`]: ExitCode::DriftDetected
29
30use crate::errors::{CfgdError, ConfigError};
31
32#[repr(i32)]
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ExitCode {
35    Success = 0,
36    Error = 1,
37    UpdateAvailable = 2,
38    NoConfig = 3,
39    ConfigInvalid = 4,
40    DriftDetected = 5,
41    NotFound = 6,
42}
43
44impl ExitCode {
45    pub const fn as_i32(self) -> i32 {
46        self as i32
47    }
48
49    /// Terminate the current process with this exit code.
50    pub fn exit(self) -> ! {
51        std::process::exit(self.as_i32())
52    }
53}
54
55impl From<ExitCode> for i32 {
56    fn from(code: ExitCode) -> i32 {
57        code as i32
58    }
59}
60
61/// Map a [`CfgdError`] to the most specific exit code available.
62///
63/// Only config-setup errors are differentiated today — runtime errors
64/// (network, filesystem, provider) all collapse to [`ExitCode::Error`]
65/// because scripted consumers generally can't act on those without
66/// reading the message anyway. Extend this function when a new variant
67/// warrants a distinct code.
68pub fn exit_code_for_error(err: &CfgdError) -> ExitCode {
69    match err {
70        CfgdError::Config(ConfigError::NotFound { .. }) => ExitCode::NoConfig,
71        CfgdError::Config(_) => ExitCode::ConfigInvalid,
72        _ => ExitCode::Error,
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use std::path::PathBuf;
80
81    #[test]
82    fn distinct_integer_codes() {
83        let codes = [
84            ExitCode::Success.as_i32(),
85            ExitCode::Error.as_i32(),
86            ExitCode::UpdateAvailable.as_i32(),
87            ExitCode::NoConfig.as_i32(),
88            ExitCode::ConfigInvalid.as_i32(),
89            ExitCode::DriftDetected.as_i32(),
90            ExitCode::NotFound.as_i32(),
91        ];
92        let mut seen = std::collections::HashSet::new();
93        for c in codes {
94            assert!(seen.insert(c), "duplicate exit code {}", c);
95        }
96    }
97
98    #[test]
99    fn stable_wire_values() {
100        // These are consumed by downstream shell scripts. Changing any of
101        // these numbers is a breaking change — update this test and
102        // document the change in a release note.
103        assert_eq!(ExitCode::Success.as_i32(), 0);
104        assert_eq!(ExitCode::Error.as_i32(), 1);
105        assert_eq!(ExitCode::UpdateAvailable.as_i32(), 2);
106        assert_eq!(ExitCode::NoConfig.as_i32(), 3);
107        assert_eq!(ExitCode::ConfigInvalid.as_i32(), 4);
108        assert_eq!(ExitCode::DriftDetected.as_i32(), 5);
109        assert_eq!(ExitCode::NotFound.as_i32(), 6);
110    }
111
112    #[test]
113    fn config_not_found_maps_to_no_config() {
114        let err = CfgdError::Config(ConfigError::NotFound {
115            path: PathBuf::from("/nonexistent/cfgd.yaml"),
116        });
117        assert_eq!(exit_code_for_error(&err), ExitCode::NoConfig);
118    }
119
120    #[test]
121    fn config_invalid_maps_to_config_invalid() {
122        let err = CfgdError::Config(ConfigError::Invalid {
123            message: "missing apiVersion".into(),
124        });
125        assert_eq!(exit_code_for_error(&err), ExitCode::ConfigInvalid);
126        let err = CfgdError::Config(ConfigError::ProfileNotFound { name: "dev".into() });
127        assert_eq!(exit_code_for_error(&err), ExitCode::ConfigInvalid);
128    }
129
130    #[test]
131    fn non_config_error_maps_to_generic() {
132        let err = CfgdError::Io(std::io::Error::other("boom"));
133        assert_eq!(exit_code_for_error(&err), ExitCode::Error);
134    }
135
136    #[test]
137    fn i32_conversion_matches_as_i32() {
138        let code: i32 = ExitCode::DriftDetected.into();
139        assert_eq!(code, ExitCode::DriftDetected.as_i32());
140    }
141}