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