Skip to main content

ubt_cli/
error.rs

1use thiserror::Error;
2
3/// Unified error type for UBT.
4#[derive(Debug, Error)]
5pub enum UbtError {
6    #[error("Error: {tool} is not installed.{install_guidance}")]
7    ToolNotFound {
8        tool: String,
9        install_guidance: String,
10    },
11
12    #[error("\"{command}\" is not supported by the {plugin} plugin. {hint}")]
13    CommandUnsupported {
14        command: String,
15        plugin: String,
16        hint: String,
17    },
18
19    #[error("No command configured for \"{command}\". Add it to ubt.toml:\n\n  [commands]\n  \"{command}\" = \"your command here\"")]
20    CommandUnmapped { command: String },
21
22    #[error("{message}")]
23    ConfigError { message: String },
24
25    #[error("Multiple plugins detected: {plugins}. Set tool in ubt.toml:\n\n  [project]\n  tool = \"{suggested_tool}\"")]
26    PluginConflict {
27        plugins: String,
28        suggested_tool: String,
29    },
30
31    #[error("Could not detect project type. Run \"ubt init\" or create ubt.toml.")]
32    NoPluginMatch,
33
34    #[error("Failed to load plugin \"{name}\": {detail}")]
35    PluginLoadError { name: String, detail: String },
36
37    #[error("Template error: {0}")]
38    TemplateError(String),
39
40    #[error("Execution error: {0}")]
41    ExecutionError(String),
42
43    #[error("Alias \"{alias}\" conflicts with built-in command \"{command}\"")]
44    AliasConflict { alias: String, command: String },
45
46    #[error("Unknown command \"{name}\". Run \"ubt --help\" for available commands.")]
47    UnknownCommand { name: String },
48
49    #[error(transparent)]
50    Io(#[from] std::io::Error),
51}
52
53/// Convenience result type for UBT operations.
54pub type Result<T> = std::result::Result<T, UbtError>;
55
56impl UbtError {
57    /// Create a `ToolNotFound` error with optional install guidance.
58    pub fn tool_not_found(tool: impl Into<String>, install_help: Option<&str>) -> Self {
59        let install_guidance = match install_help {
60            Some(help) => format!("\n\n{help}"),
61            None => String::new(),
62        };
63        UbtError::ToolNotFound {
64            tool: tool.into(),
65            install_guidance,
66        }
67    }
68
69    /// Create a `ConfigError` with optional line number context.
70    pub fn config_error(line: Option<usize>, detail: impl Into<String>) -> Self {
71        let detail = detail.into();
72        let message = match line {
73            Some(n) => format!("Error in ubt.toml [line {n}]: {detail}"),
74            None => format!("Error in ubt.toml: {detail}"),
75        };
76        UbtError::ConfigError { message }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn tool_not_found_with_install_help() {
86        let err = UbtError::tool_not_found("npm", Some("Install via: https://nodejs.org"));
87        assert_eq!(
88            err.to_string(),
89            "Error: npm is not installed.\n\nInstall via: https://nodejs.org"
90        );
91    }
92
93    #[test]
94    fn tool_not_found_without_install_help() {
95        let err = UbtError::tool_not_found("cargo", None);
96        assert_eq!(err.to_string(), "Error: cargo is not installed.");
97    }
98
99    #[test]
100    fn command_unsupported_formats() {
101        let err = UbtError::CommandUnsupported {
102            command: "lint".into(),
103            plugin: "go".into(),
104            hint: "Try \"ubt run lint\" instead.".into(),
105        };
106        assert_eq!(
107            err.to_string(),
108            "\"lint\" is not supported by the go plugin. Try \"ubt run lint\" instead."
109        );
110    }
111
112    #[test]
113    fn command_unmapped_formats() {
114        let err = UbtError::CommandUnmapped {
115            command: "deploy".into(),
116        };
117        assert_eq!(
118            err.to_string(),
119            "No command configured for \"deploy\". Add it to ubt.toml:\n\n  [commands]\n  \"deploy\" = \"your command here\""
120        );
121    }
122
123    #[test]
124    fn config_error_with_line() {
125        let err = UbtError::config_error(Some(42), "invalid key");
126        assert_eq!(err.to_string(), "Error in ubt.toml [line 42]: invalid key");
127    }
128
129    #[test]
130    fn config_error_without_line() {
131        let err = UbtError::config_error(None, "missing section");
132        assert_eq!(err.to_string(), "Error in ubt.toml: missing section");
133    }
134
135    #[test]
136    fn plugin_conflict_formats() {
137        let err = UbtError::PluginConflict {
138            plugins: "node, bun".into(),
139            suggested_tool: "node".into(),
140        };
141        assert_eq!(
142            err.to_string(),
143            "Multiple plugins detected: node, bun. Set tool in ubt.toml:\n\n  [project]\n  tool = \"node\""
144        );
145    }
146
147    #[test]
148    fn no_plugin_match_formats() {
149        let err = UbtError::NoPluginMatch;
150        assert_eq!(
151            err.to_string(),
152            "Could not detect project type. Run \"ubt init\" or create ubt.toml."
153        );
154    }
155
156    #[test]
157    fn plugin_load_error_formats() {
158        let err = UbtError::PluginLoadError {
159            name: "rust".into(),
160            detail: "file not found".into(),
161        };
162        assert_eq!(
163            err.to_string(),
164            "Failed to load plugin \"rust\": file not found"
165        );
166    }
167
168    #[test]
169    fn template_error_formats() {
170        let err = UbtError::TemplateError("unresolved placeholder".into());
171        assert_eq!(err.to_string(), "Template error: unresolved placeholder");
172    }
173
174    #[test]
175    fn execution_error_formats() {
176        let err = UbtError::ExecutionError("process killed".into());
177        assert_eq!(err.to_string(), "Execution error: process killed");
178    }
179
180    #[test]
181    fn alias_conflict_formats() {
182        let err = UbtError::AliasConflict {
183            alias: "t".into(),
184            command: "test".into(),
185        };
186        assert_eq!(
187            err.to_string(),
188            "Alias \"t\" conflicts with built-in command \"test\""
189        );
190    }
191
192    #[test]
193    fn io_error_from_conversion() {
194        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
195        let ubt_err: UbtError = io_err.into();
196        assert!(matches!(ubt_err, UbtError::Io(_)));
197        assert_eq!(ubt_err.to_string(), "gone");
198    }
199
200    #[test]
201    fn error_is_send_and_sync() {
202        fn assert_send_sync<T: Send + Sync>() {}
203        assert_send_sync::<UbtError>();
204    }
205
206    #[test]
207    fn result_type_alias_works() {
208        fn returns_ok() -> Result<i32> {
209            Ok(42)
210        }
211        fn returns_err() -> Result<i32> {
212            Err(UbtError::NoPluginMatch)
213        }
214        assert_eq!(returns_ok().unwrap(), 42);
215        assert!(returns_err().is_err());
216    }
217}