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