Skip to main content

ubt_cli/
config.rs

1use indexmap::IndexMap;
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5use crate::error::{Result, UbtError};
6
7// ── Built-in names (used for alias conflict detection) ─────────────────
8
9pub 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// ── Config structs ─────────────────────────────────────────────────────
50
51#[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
66// ── Parsing ────────────────────────────────────────────────────────────
67
68/// Parse a TOML string into an `UbtConfig`.
69pub 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
83// ── Alias validation ───────────────────────────────────────────────────
84
85/// Ensure no alias shadows a built-in command or group name.
86pub 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
104// ── Config discovery ───────────────────────────────────────────────────
105
106/// Locate and parse `ubt.toml`, returning the config and project root.
107///
108/// Resolution order:
109/// 1. `UBT_CONFIG` environment variable (explicit path).
110/// 2. Walk upward from `start_dir` looking for `ubt.toml`.
111pub fn find_config(start_dir: &Path) -> Result<Option<(UbtConfig, PathBuf)>> {
112    // 1. Honour UBT_CONFIG env var
113    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    // 2. Walk upward
122    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
137/// Load and validate the project configuration.
138pub 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// ── Tests ──────────────────────────────────────────────────────────────
149
150#[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        // Ensure UBT_CONFIG is not set so the walk-up logic is exercised.
287        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}