Skip to main content

ftts_cli/
error.rs

1//! Stable CLI error and exit-code contract.
2
3use std::fmt;
4use std::process::ExitCode;
5
6/// The process exit codes promised by the `ftts` robot and human interfaces.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8#[repr(u8)]
9pub enum FttsExitCode {
10    Success = 0,
11    Generic = 1,
12    Usage = 2,
13    ModelNotFound = 3,
14    Input = 4,
15    BudgetTimeout = 5,
16    Cancelled = 6,
17    ArtifactFormat = 7,
18    EnrollmentQualityRefusal = 8,
19}
20
21impl FttsExitCode {
22    /// Stable, machine-readable explanation of this process status.
23    pub const fn description(self) -> &'static str {
24        match self {
25            Self::Success => "success",
26            Self::Generic => "generic error",
27            Self::Usage => "usage or CLI error",
28            Self::ModelNotFound => "model not found or not resolvable",
29            Self::Input => "input error",
30            Self::BudgetTimeout => "budget or timeout exceeded",
31            Self::Cancelled => "cancelled",
32            Self::ArtifactFormat => "artifact format or version mismatch",
33            Self::EnrollmentQualityRefusal => "enrollment-quality refusal",
34        }
35    }
36
37    pub const fn as_u8(self) -> u8 {
38        self as u8
39    }
40
41    pub fn as_exit_code(self) -> ExitCode {
42        ExitCode::from(self.as_u8())
43    }
44}
45
46/// An error that can be presented by the Phase-0 CLI without fabricating engine work.
47#[derive(Debug)]
48pub enum FttsError {
49    Generic(String),
50    Usage(String),
51    ModelNotFound(String),
52    Input(String),
53    BudgetTimeout(String),
54    ArtifactFormat(String),
55    EnrollmentQualityRefusal(String),
56}
57
58impl FttsError {
59    /// A stable, actionable next step for this failure class.
60    ///
61    /// Kept separate from the message so an agent can branch on remediation without parsing prose,
62    /// and so the message stays free to name the specific offending path or value.
63    pub const fn remediation(&self) -> &'static str {
64        match self {
65            Self::Generic(_) => {
66                "see the message; if it names an unimplemented phase, the capability is not built yet"
67            }
68            Self::Usage(_) => "re-run with --help to see the accepted argument shapes",
69            Self::ModelNotFound(_) => {
70                "run `ftts pull` to fetch the model (~2.0 GB), or pass --model PATH or set FTTS_MODEL_DIR; `ftts robot health` lists every directory searched"
71            }
72            Self::Input(_) => {
73                "check the input text or file encoding; input must be non-empty UTF-8"
74            }
75            Self::BudgetTimeout(_) => {
76                "raise the budget, shorten the text, or choose a faster profile"
77            }
78            Self::ArtifactFormat(_) => {
79                "regenerate the artifact with a matching ftts version; `ftts robot health` reports the expected format"
80            }
81            Self::EnrollmentQualityRefusal(_) => {
82                "supply a cleaner reference, or pass --force to accept the warned-about quality"
83            }
84        }
85    }
86
87    pub const fn exit_code(&self) -> FttsExitCode {
88        match self {
89            Self::Generic(_) => FttsExitCode::Generic,
90            Self::Usage(_) => FttsExitCode::Usage,
91            Self::ModelNotFound(_) => FttsExitCode::ModelNotFound,
92            Self::Input(_) => FttsExitCode::Input,
93            Self::BudgetTimeout(_) => FttsExitCode::BudgetTimeout,
94            Self::ArtifactFormat(_) => FttsExitCode::ArtifactFormat,
95            Self::EnrollmentQualityRefusal(_) => FttsExitCode::EnrollmentQualityRefusal,
96        }
97    }
98}
99
100impl fmt::Display for FttsError {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::Generic(message)
104            | Self::Usage(message)
105            | Self::ModelNotFound(message)
106            | Self::Input(message)
107            | Self::BudgetTimeout(message)
108            | Self::ArtifactFormat(message)
109            | Self::EnrollmentQualityRefusal(message) => formatter.write_str(message),
110        }
111    }
112}
113
114impl std::error::Error for FttsError {}
115
116#[cfg(test)]
117mod tests {
118    use super::FttsExitCode;
119
120    #[test]
121    fn exit_codes_are_stable() {
122        let cases = [
123            (FttsExitCode::Success, 0),
124            (FttsExitCode::Generic, 1),
125            (FttsExitCode::Usage, 2),
126            (FttsExitCode::ModelNotFound, 3),
127            (FttsExitCode::Input, 4),
128            (FttsExitCode::BudgetTimeout, 5),
129            (FttsExitCode::Cancelled, 6),
130            (FttsExitCode::ArtifactFormat, 7),
131            (FttsExitCode::EnrollmentQualityRefusal, 8),
132        ];
133
134        for (code, expected) in cases {
135            assert_eq!(code.as_u8(), expected, "{}", code.description());
136        }
137    }
138}