1use indexmap::IndexMap;
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5use crate::error::{Result, UbtError};
6
7pub const BUILTIN_COMMANDS: &[&str] = &[
10 "dep.install",
11 "dep.remove",
12 "dep.update",
13 "dep.outdated",
14 "dep.list",
15 "dep.audit",
16 "dep.lock",
17 "dep.why",
18 "build",
19 "start",
20 "run",
21 "fmt",
22 "run-file",
23 "exec",
24 "test",
25 "lint",
26 "check",
27 "db.migrate",
28 "db.rollback",
29 "db.seed",
30 "db.create",
31 "db.drop",
32 "db.reset",
33 "db.status",
34 "init",
35 "clean",
36 "release",
37 "publish",
38 "tool.info",
39 "tool.doctor",
40 "tool.list",
41 "tool.docs",
42 "config.show",
43 "info",
44 "completions",
45];
46
47pub const BUILTIN_GROUPS: &[&str] = &["dep", "db", "tool", "config"];
48
49#[derive(Debug, Clone, Deserialize, Default)]
52pub struct ProjectConfig {
53 pub tool: Option<String>,
54}
55
56#[derive(Debug, Clone, Deserialize, Default)]
57pub struct UbtConfig {
58 #[serde(default)]
59 pub project: Option<ProjectConfig>,
60 #[serde(default)]
61 pub commands: IndexMap<String, String>,
62 #[serde(default)]
63 pub aliases: IndexMap<String, String>,
64}
65
66pub fn parse_config(content: &str) -> Result<UbtConfig> {
70 toml::from_str(content).map_err(|e| {
71 let line = e.span().map(|s| {
72 content
73 .bytes()
74 .take(s.start)
75 .filter(|&b| b == b'\n')
76 .count()
77 + 1
78 });
79 UbtError::config_error(line, e.message())
80 })
81}
82
83pub fn validate_aliases(config: &UbtConfig) -> Result<()> {
87 for alias in config.aliases.keys() {
88 if BUILTIN_COMMANDS.contains(&alias.as_str()) {
89 return Err(UbtError::AliasConflict {
90 alias: alias.clone(),
91 command: alias.clone(),
92 });
93 }
94 if BUILTIN_GROUPS.contains(&alias.as_str()) {
95 return Err(UbtError::AliasConflict {
96 alias: alias.clone(),
97 command: alias.clone(),
98 });
99 }
100 }
101 Ok(())
102}
103
104pub fn find_config(start_dir: &Path) -> Result<Option<(UbtConfig, PathBuf)>> {
112 if let Ok(config_path) = std::env::var("UBT_CONFIG") {
114 let path = PathBuf::from(&config_path);
115 let content = std::fs::read_to_string(&path)?;
116 let config = parse_config(&content)?;
117 let project_root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
118 return Ok(Some((config, project_root)));
119 }
120
121 let mut current = start_dir.to_path_buf();
123 loop {
124 let candidate = current.join("ubt.toml");
125 if candidate.is_file() {
126 let content = std::fs::read_to_string(&candidate)?;
127 let config = parse_config(&content)?;
128 return Ok(Some((config, current)));
129 }
130 if !current.pop() {
131 break;
132 }
133 }
134 Ok(None)
135}
136
137pub fn load_config(start_dir: &Path) -> Result<Option<(UbtConfig, PathBuf)>> {
139 match find_config(start_dir)? {
140 Some((config, root)) => {
141 validate_aliases(&config)?;
142 Ok(Some((config, root)))
143 }
144 None => Ok(None),
145 }
146}
147
148#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::path::Path;
154 use tempfile::TempDir;
155
156 #[test]
157 fn parse_rails_example() {
158 let input = r#"
159[project]
160tool = "bundler"
161
162[commands]
163start = "bin/rails server"
164test = "bin/rails test"
165lint = "bundle exec rubocop"
166fmt = "bundle exec rubocop -a"
167"db.migrate" = "bin/rails db:migrate"
168"db.rollback" = "bin/rails db:rollback STEP={{args}}"
169"db.seed" = "bin/rails db:seed"
170"db.create" = "bin/rails db:create"
171"db.drop" = "bin/rails db:drop"
172"db.reset" = "bin/rails db:reset"
173"db.status" = "bin/rails db:migrate:status"
174run = "bin/rails {{args}}"
175
176[aliases]
177console = "bin/rails console"
178routes = "bin/rails routes"
179generate = "bin/rails generate"
180"#;
181 let config = parse_config(input).unwrap();
182 assert_eq!(config.project.unwrap().tool.unwrap(), "bundler");
183 assert_eq!(config.commands.len(), 12);
184 assert_eq!(config.aliases.len(), 3);
185 }
186
187 #[test]
188 fn parse_node_prisma_example() {
189 let input = r#"
190[project]
191tool = "pnpm"
192
193[commands]
194start = "pnpm run dev"
195build = "pnpm run build"
196test = "pnpm exec vitest"
197lint = "pnpm exec eslint ."
198fmt = "pnpm exec prettier --write ."
199"fmt.check" = "pnpm exec prettier --check ."
200"db.migrate" = "pnpm exec prisma migrate deploy"
201"db.seed" = "pnpm exec prisma db seed"
202"db.status" = "pnpm exec prisma migrate status"
203"db.reset" = "pnpm exec prisma migrate reset"
204
205[aliases]
206studio = "pnpm exec prisma studio"
207generate = "pnpm exec prisma generate"
208typecheck = "pnpm exec tsc --noEmit"
209"#;
210 let config = parse_config(input).unwrap();
211 assert_eq!(config.project.unwrap().tool.unwrap(), "pnpm");
212 assert_eq!(config.commands.len(), 10);
213 assert_eq!(config.aliases.len(), 3);
214 }
215
216 #[test]
217 fn parse_minimal_config() {
218 let input = "[project]\ntool = \"go\"";
219 let config = parse_config(input).unwrap();
220 assert_eq!(config.project.unwrap().tool.unwrap(), "go");
221 assert_eq!(config.commands.len(), 0);
222 assert_eq!(config.aliases.len(), 0);
223 }
224
225 #[test]
226 fn parse_empty_config() {
227 let config = parse_config("").unwrap();
228 assert!(config.project.is_none());
229 assert_eq!(config.commands.len(), 0);
230 assert_eq!(config.aliases.len(), 0);
231 }
232
233 #[test]
234 fn parse_invalid_toml_returns_config_error() {
235 let result = parse_config("[invalid");
236 assert!(result.is_err());
237 let err = result.unwrap_err();
238 assert!(matches!(err, UbtError::ConfigError { .. }));
239 }
240
241 #[test]
242 fn validate_alias_conflicting_with_command() {
243 let mut aliases = IndexMap::new();
244 aliases.insert("test".to_string(), "something".to_string());
245 let config = UbtConfig {
246 project: None,
247 commands: IndexMap::new(),
248 aliases,
249 };
250 let err = validate_aliases(&config).unwrap_err();
251 match err {
252 UbtError::AliasConflict { alias, command } => {
253 assert_eq!(alias, "test");
254 assert_eq!(command, "test");
255 }
256 other => panic!("expected AliasConflict, got: {other:?}"),
257 }
258 }
259
260 #[test]
261 fn validate_alias_conflicting_with_group() {
262 let mut aliases = IndexMap::new();
263 aliases.insert("dep".to_string(), "something".to_string());
264 let config = UbtConfig {
265 project: None,
266 commands: IndexMap::new(),
267 aliases,
268 };
269 let err = validate_aliases(&config).unwrap_err();
270 match err {
271 UbtError::AliasConflict { alias, command } => {
272 assert_eq!(alias, "dep");
273 assert_eq!(command, "dep");
274 }
275 other => panic!("expected AliasConflict, got: {other:?}"),
276 }
277 }
278
279 #[test]
280 fn find_config_walks_upward() {
281 let dir = TempDir::new().unwrap();
282 std::fs::write(dir.path().join("ubt.toml"), "[project]\ntool = \"go\"").unwrap();
283 let nested = dir.path().join("a").join("b").join("c");
284 std::fs::create_dir_all(&nested).unwrap();
285
286 temp_env::with_var("UBT_CONFIG", None::<&str>, || {
288 let result = find_config(&nested).unwrap().unwrap();
289 assert_eq!(result.0.project.unwrap().tool.unwrap(), "go");
290 assert_eq!(result.1, dir.path());
291 });
292 }
293
294 #[test]
295 fn find_config_returns_none_when_absent() {
296 let dir = TempDir::new().unwrap();
297
298 temp_env::with_var("UBT_CONFIG", None::<&str>, || {
299 let result = find_config(dir.path()).unwrap();
300 assert!(result.is_none());
301 });
302 }
303
304 #[test]
305 fn find_config_respects_ubt_config_env() {
306 let dir = TempDir::new().unwrap();
307 let config_path = dir.path().join("custom.toml");
308 std::fs::write(&config_path, "[project]\ntool = \"custom\"").unwrap();
309
310 temp_env::with_var("UBT_CONFIG", Some(config_path.to_str().unwrap()), || {
311 let (config, root) = find_config(Path::new("/tmp")).unwrap().unwrap();
312 assert_eq!(config.project.unwrap().tool.unwrap(), "custom");
313 assert_eq!(root, dir.path());
314 });
315 }
316}