Skip to main content

alef_core/config/
setup_defaults.rs

1use std::path::PathBuf;
2
3use super::extras::Language;
4use super::output::{SetupConfig, StringOrVec};
5use super::tools::{LangContext, require_tool};
6
7/// Return the default working directory (relative to repo root) for a language's
8/// setup commands. Languages whose manifest file lives outside the repo root
9/// (Swift's `Package.swift`, Kotlin-Android's `gradlew`, Dart's `pubspec.yaml`,
10/// Zig's `build.zig`) need install commands run from their package directory or
11/// the underlying tool will not find the manifest.
12pub fn default_setup_workdir(lang: Language) -> Option<PathBuf> {
13    match lang {
14        Language::Swift => Some(PathBuf::from("packages/swift")),
15        Language::KotlinAndroid => Some(PathBuf::from("packages/kotlin-android")),
16        Language::Dart => Some(PathBuf::from("packages/dart")),
17        Language::Zig => Some(PathBuf::from("packages/zig")),
18        _ => None,
19    }
20}
21
22/// Stand-alone factory matching the spec's `setup_config_for_language(lang)`
23/// signature: returns a setup config with only the `workdir` field populated
24/// (per-language default). Used by tests and callers that only need the workdir
25/// default without resolving the full command pipeline (which requires
26/// `output_dir` and `LangContext`).
27pub fn setup_config_for_language(lang: Language) -> SetupConfig {
28    SetupConfig {
29        precondition: None,
30        before: None,
31        install: None,
32        timeout_seconds: 600,
33        workdir: default_setup_workdir(lang),
34    }
35}
36
37/// Return the default setup configuration for a language.
38///
39/// The `output_dir` is the package directory where scaffolded files live
40/// (e.g. `packages/python`). It is substituted into command templates.
41/// `ctx` provides the package manager selection.
42pub(crate) fn default_setup_config(lang: Language, output_dir: &str, ctx: &LangContext) -> SetupConfig {
43    match lang {
44        Language::Rust => {
45            let mut commands: Vec<String> = vec!["rustup update stable".to_string()];
46            commands.extend(
47                ctx.tools
48                    .rust_tools()
49                    .iter()
50                    .map(|t| format!("cargo install {t} --locked")),
51            );
52            commands.push("rustup component add rustfmt clippy".to_string());
53            SetupConfig {
54                precondition: Some(require_tool("cargo")),
55                before: None,
56                install: Some(StringOrVec::Multiple(commands)),
57                timeout_seconds: 600,
58                workdir: default_setup_workdir(lang),
59            }
60        }
61        Language::Python => {
62            let pm = ctx.tools.python_pm();
63            let install_cmd = match pm {
64                "pip" => format!("cd {output_dir} && pip install -e ."),
65                "poetry" => format!("cd {output_dir} && poetry install"),
66                _ => format!("cd {output_dir} && uv sync"),
67            };
68            SetupConfig {
69                precondition: Some(require_tool(pm)),
70                before: None,
71                install: Some(StringOrVec::Single(install_cmd)),
72                timeout_seconds: 600,
73                workdir: default_setup_workdir(lang),
74            }
75        }
76        Language::Node | Language::Wasm => {
77            let pm = ctx.tools.node_pm();
78            let install_cmd = match pm {
79                "npm" => format!("cd {output_dir} && npm install"),
80                "yarn" => format!("cd {output_dir} && yarn install"),
81                _ => format!("cd {output_dir} && pnpm install"),
82            };
83            SetupConfig {
84                precondition: Some(require_tool(pm)),
85                before: None,
86                install: Some(StringOrVec::Single(install_cmd)),
87                timeout_seconds: 600,
88                workdir: default_setup_workdir(lang),
89            }
90        }
91        Language::Go => SetupConfig {
92            precondition: Some(require_tool("go")),
93            before: None,
94            install: Some(StringOrVec::Single(format!(
95                "cd {output_dir} && GOWORK=off go mod download"
96            ))),
97            timeout_seconds: 600,
98            workdir: default_setup_workdir(lang),
99        },
100        Language::Ruby => SetupConfig {
101            precondition: Some(require_tool("bundle")),
102            before: None,
103            install: Some(StringOrVec::Single(format!("cd {output_dir} && bundle install"))),
104            timeout_seconds: 600,
105            workdir: default_setup_workdir(lang),
106        },
107        Language::Php => SetupConfig {
108            precondition: Some(require_tool("composer")),
109            before: None,
110            install: Some(StringOrVec::Single(format!("cd {output_dir} && composer install"))),
111            timeout_seconds: 600,
112            workdir: default_setup_workdir(lang),
113        },
114        Language::Java => SetupConfig {
115            precondition: Some(require_tool("mvn")),
116            before: None,
117            install: Some(StringOrVec::Single(format!(
118                "mvn -f {output_dir}/pom.xml dependency:resolve -q"
119            ))),
120            timeout_seconds: 600,
121            workdir: default_setup_workdir(lang),
122        },
123        Language::Csharp => SetupConfig {
124            // Both `dotnet` AND a discoverable .sln/.csproj must exist under output_dir, or
125            // `dotnet restore` walks the entire repo (including target/ and node_modules/)
126            // looking for a project file and times out. Skip cleanly when no project is present.
127            precondition: Some(format!(
128                "command -v dotnet >/dev/null 2>&1 && [ -n \"$(find {output_dir} -maxdepth 3 \\( -name '*.sln' -o -name '*.csproj' \\) 2>/dev/null | head -1)\" ]"
129            )),
130            before: None,
131            // Resolve the first .sln/.csproj under output_dir (depth 3) — same approach as
132            // the C# upgrade default. Avoids the unbounded directory walk that caused the
133            // 600s timeout on CI.
134            install: Some(StringOrVec::Single(format!(
135                "dotnet restore $(find {output_dir} -maxdepth 3 \\( -name '*.sln' -o -name '*.csproj' \\) 2>/dev/null | head -1)"
136            ))),
137            timeout_seconds: 600,
138            workdir: default_setup_workdir(lang),
139        },
140        Language::Elixir => SetupConfig {
141            precondition: Some(require_tool("mix")),
142            before: None,
143            install: Some(StringOrVec::Single(format!("cd {output_dir} && mix deps.get"))),
144            timeout_seconds: 600,
145            workdir: default_setup_workdir(lang),
146        },
147        Language::R => SetupConfig {
148            precondition: Some(require_tool("Rscript")),
149            before: None,
150            install: Some(StringOrVec::Single(format!(
151                "cd {output_dir} && Rscript -e \"remotes::install_deps()\""
152            ))),
153            timeout_seconds: 600,
154            workdir: default_setup_workdir(lang),
155        },
156        Language::Ffi => SetupConfig {
157            // FFI shares cargo with the parent Rust crate; there is no
158            // separate install step and therefore nothing to precondition.
159            precondition: None,
160            before: None,
161            install: None,
162            timeout_seconds: 600,
163            workdir: default_setup_workdir(lang),
164        },
165        Language::C => SetupConfig {
166            precondition: None,
167            before: None,
168            install: None,
169            timeout_seconds: 600,
170            workdir: default_setup_workdir(lang),
171        },
172        Language::Kotlin | Language::KotlinAndroid => SetupConfig {
173            precondition: Some(require_tool("gradle")),
174            before: None,
175            install: Some(StringOrVec::Single("gradle build --refresh-dependencies".to_string())),
176            timeout_seconds: 600,
177            workdir: default_setup_workdir(lang),
178        },
179        Language::Swift => SetupConfig {
180            precondition: Some(require_tool("swift")),
181            before: None,
182            install: Some(StringOrVec::Single("swift package resolve".to_string())),
183            timeout_seconds: 600,
184            workdir: default_setup_workdir(lang),
185        },
186        Language::Dart => SetupConfig {
187            precondition: Some(require_tool("dart")),
188            before: None,
189            install: Some(StringOrVec::Single("dart pub get".to_string())),
190            timeout_seconds: 600,
191            workdir: default_setup_workdir(lang),
192        },
193        Language::Zig => SetupConfig {
194            precondition: Some(require_tool("zig")),
195            before: None,
196            install: Some(StringOrVec::Single("zig build --fetch".to_string())),
197            timeout_seconds: 600,
198            workdir: default_setup_workdir(lang),
199        },
200        Language::Gleam => SetupConfig {
201            precondition: Some(require_tool("gleam")),
202            before: None,
203            install: Some(StringOrVec::Single(format!("cd {output_dir} && gleam deps download"))),
204            timeout_seconds: 600,
205            workdir: default_setup_workdir(lang),
206        },
207        Language::Jni => SetupConfig {
208            precondition: None,
209            before: None,
210            install: None,
211            timeout_seconds: 600,
212            workdir: default_setup_workdir(lang),
213        },
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::super::tools::ToolsConfig;
220    use super::*;
221
222    fn all_languages() -> Vec<Language> {
223        vec![
224            Language::Python,
225            Language::Node,
226            Language::Wasm,
227            Language::Ruby,
228            Language::Php,
229            Language::Go,
230            Language::Java,
231            Language::Csharp,
232            Language::Elixir,
233            Language::R,
234            Language::Ffi,
235            Language::Rust,
236            Language::Kotlin,
237            Language::Swift,
238            Language::Dart,
239            Language::Gleam,
240            Language::Zig,
241        ]
242    }
243
244    fn cfg(lang: Language, dir: &str) -> SetupConfig {
245        let tools = ToolsConfig::default();
246        let ctx = LangContext::default(&tools);
247        default_setup_config(lang, dir, &ctx)
248    }
249
250    #[test]
251    fn ffi_has_no_install_command() {
252        let c = cfg(Language::Ffi, "packages/ffi");
253        assert!(c.install.is_none());
254    }
255
256    #[test]
257    fn non_ffi_languages_have_install_command() {
258        for lang in all_languages() {
259            if matches!(lang, Language::Ffi) {
260                continue;
261            }
262            let c = cfg(lang, "packages/test");
263            assert!(c.install.is_some(), "{lang} should have a default install command");
264        }
265    }
266
267    #[test]
268    fn non_ffi_languages_have_default_precondition() {
269        for lang in all_languages() {
270            if matches!(lang, Language::Ffi) {
271                continue;
272            }
273            let c = cfg(lang, "packages/test");
274            let pre = c
275                .precondition
276                .unwrap_or_else(|| panic!("{lang} should have a precondition"));
277            assert!(pre.starts_with("command -v "));
278        }
279    }
280
281    #[test]
282    fn rust_install_lists_full_tool_set() {
283        let c = cfg(Language::Rust, "packages/rust");
284        let install = c.install.unwrap();
285        let cmds = install.commands();
286        let joined = cmds.join(" || ");
287        assert!(joined.contains("rustup update stable"));
288        for tool in super::super::tools::DEFAULT_RUST_DEV_TOOLS {
289            assert!(
290                joined.contains(&format!("cargo install {tool} --locked")),
291                "Rust setup should install {tool}, got: {joined}"
292            );
293        }
294        assert!(joined.contains("rustup component add rustfmt clippy"));
295    }
296
297    #[test]
298    fn rust_install_respects_user_tool_list() {
299        let tools = ToolsConfig {
300            rust_dev_tools: Some(vec!["cargo-edit".to_string(), "cargo-foo".to_string()]),
301            ..Default::default()
302        };
303        let ctx = LangContext::default(&tools);
304        let c = default_setup_config(Language::Rust, "packages/rust", &ctx);
305        let cmds = c.install.unwrap().commands().join(" || ");
306        assert!(cmds.contains("cargo install cargo-edit --locked"));
307        assert!(cmds.contains("cargo install cargo-foo --locked"));
308        // Default tools that aren't in the user override should be absent.
309        assert!(!cmds.contains("cargo install cargo-deny"));
310    }
311
312    fn python_tools(pm: &str) -> ToolsConfig {
313        ToolsConfig {
314            python_package_manager: Some(pm.to_string()),
315            ..Default::default()
316        }
317    }
318
319    fn node_tools(pm: &str) -> ToolsConfig {
320        ToolsConfig {
321            node_package_manager: Some(pm.to_string()),
322            ..Default::default()
323        }
324    }
325
326    #[test]
327    fn python_setup_dispatches_on_package_manager() {
328        for (pm, expected_install, expected_pre) in [
329            ("uv", "uv sync", "command -v uv >/dev/null 2>&1"),
330            ("pip", "pip install -e", "command -v pip >/dev/null 2>&1"),
331            ("poetry", "poetry install", "command -v poetry >/dev/null 2>&1"),
332        ] {
333            let tools = python_tools(pm);
334            let ctx = LangContext::default(&tools);
335            let c = default_setup_config(Language::Python, "packages/python", &ctx);
336            assert!(c.install.unwrap().commands().join(" ").contains(expected_install));
337            assert_eq!(c.precondition.as_deref(), Some(expected_pre));
338        }
339    }
340
341    #[test]
342    fn node_setup_dispatches_on_package_manager() {
343        for (pm, expected_install) in [
344            ("pnpm", "pnpm install"),
345            ("npm", "npm install"),
346            ("yarn", "yarn install"),
347        ] {
348            let tools = node_tools(pm);
349            let ctx = LangContext::default(&tools);
350            let c = default_setup_config(Language::Node, "packages/node", &ctx);
351            assert!(c.install.unwrap().commands().join(" ").contains(expected_install));
352        }
353    }
354
355    #[test]
356    fn python_uses_uv_sync_by_default() {
357        let c = cfg(Language::Python, "packages/python");
358        let install = c.install.unwrap().commands().join(" ");
359        assert!(install.contains("uv sync"));
360        assert!(install.contains("packages/python"));
361    }
362
363    #[test]
364    fn node_uses_pnpm_install_by_default() {
365        let c = cfg(Language::Node, "packages/node");
366        let install = c.install.unwrap().commands().join(" ");
367        assert!(install.contains("pnpm install"));
368    }
369
370    #[test]
371    fn wasm_matches_node() {
372        // Same package manager invocation, only the output dir differs.
373        let node = cfg(Language::Node, "packages/foo");
374        let wasm = cfg(Language::Wasm, "packages/foo");
375        assert_eq!(
376            node.install.unwrap().commands().join(" "),
377            wasm.install.unwrap().commands().join(" "),
378            "WASM and Node should share install command"
379        );
380    }
381
382    #[test]
383    fn go_uses_go_mod_download() {
384        let c = cfg(Language::Go, "packages/go");
385        let install = c.install.unwrap().commands().join(" ");
386        assert!(install.contains("go mod download"));
387    }
388
389    #[test]
390    fn ruby_uses_bundle_install() {
391        let c = cfg(Language::Ruby, "packages/ruby");
392        let install = c.install.unwrap().commands().join(" ");
393        assert!(install.contains("bundle install"));
394    }
395
396    #[test]
397    fn java_uses_maven_dependency_resolve() {
398        let c = cfg(Language::Java, "packages/java");
399        let install = c.install.unwrap().commands().join(" ");
400        assert!(install.contains("mvn"));
401        assert!(install.contains("dependency:resolve"));
402    }
403
404    #[test]
405    fn csharp_uses_dotnet_restore() {
406        let c = cfg(Language::Csharp, "packages/csharp");
407        let install = c.install.unwrap().commands().join(" ");
408        assert!(install.contains("dotnet restore"));
409    }
410
411    #[test]
412    fn elixir_uses_mix_deps_get() {
413        let c = cfg(Language::Elixir, "packages/elixir");
414        let install = c.install.unwrap().commands().join(" ");
415        assert!(install.contains("mix deps.get"));
416    }
417
418    #[test]
419    fn r_uses_remotes_install_deps() {
420        let c = cfg(Language::R, "packages/r");
421        let install = c.install.unwrap().commands().join(" ");
422        assert!(install.contains("remotes::install_deps()"));
423    }
424
425    #[test]
426    fn gleam_uses_gleam_deps_download() {
427        let c = cfg(Language::Gleam, "packages/gleam");
428        let install = c.install.unwrap().commands().join(" ");
429        assert!(
430            install.contains("gleam deps download"),
431            "Gleam setup should use gleam deps download, got: {install}"
432        );
433        assert_eq!(c.precondition.as_deref(), Some("command -v gleam >/dev/null 2>&1"));
434    }
435
436    #[test]
437    fn output_dir_substituted_in_commands() {
438        let c = cfg(Language::Go, "my/custom/path");
439        let install = c.install.unwrap().commands().join(" ");
440        assert!(install.contains("my/custom/path"));
441    }
442}