Skip to main content

alef_core/config/
tools.rs

1//! Global tooling configuration.
2//!
3//! `[tools]` is a top-level section in `alef.toml` that selects per-language
4//! package managers and dev-tool sets used by the default pipeline commands
5//! (lint, test, build, setup, update, clean). Each field has a sensible default
6//! so the section is fully optional; users only override what they need.
7
8use serde::{Deserialize, Serialize};
9
10/// Default Rust dev tools installed by `alef setup rust`.
11/// Mirrors the polyrepo's `task setup` so binding generators get a consistent
12/// developer environment out of the box.
13pub const DEFAULT_RUST_DEV_TOOLS: &[&str] = &[
14    "cargo-edit",
15    "cargo-sort",
16    "cargo-machete",
17    "cargo-deny",
18    "cargo-llvm-cov",
19];
20
21const DEFAULT_PYTHON_PM: &str = "uv";
22const DEFAULT_NODE_PM: &str = "pnpm";
23
24/// Top-level `[tools]` config. Selects which package manager / tool variants
25/// the default per-language pipeline commands target.
26///
27/// All fields are optional; getters return the documented default when unset.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct ToolsConfig {
30    /// Python package manager. One of: `"uv"`, `"pip"`, `"poetry"`. Default: `"uv"`.
31    #[serde(default)]
32    pub python_package_manager: Option<String>,
33
34    /// Node package manager. One of: `"pnpm"`, `"npm"`, `"yarn"`. Default: `"pnpm"`.
35    #[serde(default)]
36    pub node_package_manager: Option<String>,
37
38    /// Rust dev tools installed by the Rust `setup` default.
39    /// Default: see [`DEFAULT_RUST_DEV_TOOLS`].
40    #[serde(default)]
41    pub rust_dev_tools: Option<Vec<String>>,
42}
43
44/// Per-language context passed to every `default_*_config` function.
45///
46/// Bundles the global `[tools]` selection plus three optional knobs that
47/// reduce override boilerplate in consumer `alef.toml` files:
48///
49/// - `run_wrapper` — prefix every default tool invocation, e.g. wrap
50///   `ruff format …` with `uv run --no-sync` so the lint step inherits the
51///   project's package-manager environment without a full override.
52/// - `extra_lint_paths` — append additional paths to the default lint
53///   commands (`format`, `check`, `typecheck`).
54/// - `project_file` — for languages whose tools target a project descriptor
55///   (Java's `pom.xml`, C#'s `.csproj`/`.slnx`), use this file instead of
56///   the package directory.
57#[derive(Debug, Clone)]
58pub struct LangContext<'a> {
59    pub tools: &'a ToolsConfig,
60    pub run_wrapper: Option<&'a str>,
61    pub extra_lint_paths: &'a [String],
62    pub project_file: Option<&'a str>,
63}
64
65impl<'a> LangContext<'a> {
66    /// Create a context with all knobs unset (no wrapper, no extra paths,
67    /// no project file). Useful in tests and call sites that only need the
68    /// global tools selection.
69    pub fn default(tools: &'a ToolsConfig) -> Self {
70        Self {
71            tools,
72            run_wrapper: None,
73            extra_lint_paths: &[],
74            project_file: None,
75        }
76    }
77}
78
79/// Wrap `cmd` with `wrapper` (e.g. `uv run --no-sync`) when set.
80///
81/// Used by per-language defaults so a single project-level knob can prefix
82/// every default tool invocation without forcing a full command override.
83pub fn wrap_command(cmd: String, wrapper: Option<&str>) -> String {
84    match wrapper {
85        Some(w) => format!("{w} {cmd}"),
86        None => cmd,
87    }
88}
89
90/// Append space-separated `paths` to `cmd`. No-op when `paths` is empty.
91///
92/// Path entries are inserted verbatim — they must be shell-safe identifiers
93/// or quoted by the caller. The parser-level validation in
94/// `super::validation` rejects whitespace and shell metacharacters, so
95/// real-world `extra_lint_paths` values reach here pre-sanitised.
96pub fn append_paths(cmd: String, paths: &[String]) -> String {
97    if paths.is_empty() {
98        cmd
99    } else {
100        format!("{} {}", cmd, paths.join(" "))
101    }
102}
103
104/// Build a POSIX precondition that checks whether `tool` is on `PATH`.
105///
106/// The resulting command exits 0 when the tool is available and non-zero
107/// otherwise. Used by per-language defaults so a missing tool causes a
108/// graceful warn-and-skip rather than a hard failure.
109pub fn require_tool(tool: &str) -> String {
110    format!("command -v {tool} >/dev/null 2>&1")
111}
112
113/// Build a POSIX precondition requiring multiple tools to be on `PATH`.
114///
115/// Joins individual `command -v` checks with `&&` so the precondition only
116/// passes when every listed tool is present.
117pub fn require_tools(tools: &[&str]) -> String {
118    tools.iter().map(|t| require_tool(t)).collect::<Vec<_>>().join(" && ")
119}
120
121impl ToolsConfig {
122    /// Resolved Python package manager (defaults to `uv` when unset).
123    pub fn python_pm(&self) -> &str {
124        self.python_package_manager.as_deref().unwrap_or(DEFAULT_PYTHON_PM)
125    }
126
127    /// Resolved Node package manager (defaults to `pnpm` when unset).
128    pub fn node_pm(&self) -> &str {
129        self.node_package_manager.as_deref().unwrap_or(DEFAULT_NODE_PM)
130    }
131
132    /// Resolved Rust dev tools (defaults to [`DEFAULT_RUST_DEV_TOOLS`] when unset).
133    pub fn rust_tools(&self) -> Vec<&str> {
134        match self.rust_dev_tools.as_deref() {
135            Some(list) => list.iter().map(String::as_str).collect(),
136            None => DEFAULT_RUST_DEV_TOOLS.to_vec(),
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn defaults_match_documented_values() {
147        let cfg = ToolsConfig::default();
148        assert_eq!(cfg.python_pm(), "uv");
149        assert_eq!(cfg.node_pm(), "pnpm");
150        assert_eq!(
151            cfg.rust_tools(),
152            vec![
153                "cargo-edit",
154                "cargo-sort",
155                "cargo-machete",
156                "cargo-deny",
157                "cargo-llvm-cov"
158            ]
159        );
160    }
161
162    #[test]
163    fn getters_return_user_value_when_set() {
164        let cfg = ToolsConfig {
165            python_package_manager: Some("pip".to_string()),
166            node_package_manager: Some("yarn".to_string()),
167            rust_dev_tools: Some(vec!["cargo-foo".to_string(), "cargo-bar".to_string()]),
168        };
169        assert_eq!(cfg.python_pm(), "pip");
170        assert_eq!(cfg.node_pm(), "yarn");
171        assert_eq!(cfg.rust_tools(), vec!["cargo-foo", "cargo-bar"]);
172    }
173
174    #[test]
175    fn empty_rust_dev_tools_is_respected() {
176        // Users may explicitly opt out of installing any cargo tools.
177        let cfg = ToolsConfig {
178            rust_dev_tools: Some(vec![]),
179            ..Default::default()
180        };
181        assert!(cfg.rust_tools().is_empty());
182    }
183
184    #[test]
185    fn deserializes_from_toml() {
186        let toml_str = r#"
187            python_package_manager = "poetry"
188            node_package_manager = "npm"
189            rust_dev_tools = ["cargo-edit"]
190        "#;
191        let cfg: ToolsConfig = toml::from_str(toml_str).unwrap();
192        assert_eq!(cfg.python_pm(), "poetry");
193        assert_eq!(cfg.node_pm(), "npm");
194        assert_eq!(cfg.rust_tools(), vec!["cargo-edit"]);
195    }
196
197    #[test]
198    fn require_tool_emits_command_v() {
199        assert_eq!(require_tool("ruff"), "command -v ruff >/dev/null 2>&1");
200    }
201
202    #[test]
203    fn require_tools_joins_with_and() {
204        assert_eq!(
205            require_tools(&["go", "gofmt"]),
206            "command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1"
207        );
208    }
209
210    #[test]
211    fn empty_toml_uses_defaults() {
212        let cfg: ToolsConfig = toml::from_str("").unwrap();
213        assert_eq!(cfg.python_pm(), "uv");
214        assert_eq!(cfg.node_pm(), "pnpm");
215    }
216}