Skip to main content

alef/core/config/resolved/
lookups.rs

1//! Pipeline config lookups: lint, test, setup, update, clean, build, extras.
2
3use std::collections::HashMap;
4
5use super::ResolvedCrateConfig;
6use crate::core::config::extras::{AdapterConfig, Language};
7use crate::core::config::output::{
8    BuildCommandConfig, CleanConfig, LintConfig, SetupConfig, TestAppRunConfig, TestConfig, UpdateConfig,
9};
10use crate::core::config::tools::LangContext;
11use crate::core::config::{
12    build_defaults, clean_defaults, lint_defaults, setup_defaults, test_apps_run_defaults, test_defaults,
13    update_defaults,
14};
15
16impl ResolvedCrateConfig {
17    /// Find the [`AdapterConfig`] whose `name` matches `fn_name`, if any.
18    ///
19    /// Used by e2e codegen to check whether a call function is routed through
20    /// a streaming adapter and, if so, to retrieve its `request_type` so the
21    /// generated test wraps the raw `mock_url` binding in the typed request
22    /// constructor.
23    pub fn adapter_for_function(&self, fn_name: &str) -> Option<&AdapterConfig> {
24        self.adapters.iter().find(|a| a.name == fn_name)
25    }
26    /// Get the package output directory for a language.
27    /// Uses resolved output_paths (from [crates.output] config) if available,
28    /// otherwise falls back to scaffold_output overrides and hardcoded defaults.
29    /// For Node and Wasm, checks `crate_dir` override before the default formula.
30    pub fn package_dir(&self, lang: Language) -> String {
31        // First priority: use resolved output_paths from [crates.output] config.
32        // Skip for languages whose [crates.output] entry points to a source
33        // sub-directory rather than the package root (where the publish
34        // manifest — pyproject.toml, package.json, Package.swift, build.zig,
35        // pubspec.yaml, build.gradle.kts, mix.exs, gemspec, pom.xml — lives):
36        //   - Python:        `crates/{name}-py/src/`
37        //   - Node/Wasm:     `crates/{name}-{lang}/src/`
38        //   - Elixir:        `packages/elixir/native/<nif>/src/`
39        //   - Ruby:          `packages/ruby/ext/<ext>/src/`
40        //   - Java:          `packages/java/src/main/java/`
41        //   - Swift:         `packages/swift/Sources/<Module>/`
42        //   - Zig:           `packages/zig/src/`
43        //   - Dart:          `packages/dart/lib/` (when overridden) or root
44        //   - Kotlin:        `packages/kotlin/src/main/kotlin/.../`
45        //   - KotlinAndroid: `packages/kotlin-android/src/main/kotlin/.../`
46        //   - R:             `packages/r/src/rust/src/` (extendr Rust source — DESCRIPTION lives at packages/r/)
47        //   - Go:            computed from module_major config (see hardcoded defaults below)
48        // These languages fall through to the hardcoded defaults below, which
49        // return the package root that `publish validate` and packager helpers
50        // expect.
51        if !matches!(
52            lang,
53            Language::Python
54                | Language::Node
55                | Language::Wasm
56                | Language::Elixir
57                | Language::Ruby
58                | Language::Java
59                | Language::Swift
60                | Language::Zig
61                | Language::Dart
62                | Language::Kotlin
63                | Language::KotlinAndroid
64                | Language::R
65                | Language::Go
66        ) && let Some(output_path) = self.output_paths.get(&lang.to_string())
67        {
68            return output_path.to_string_lossy().to_string();
69        }
70
71        // Second priority: scaffold_output overrides
72        let override_path = match lang {
73            Language::Python => self.python.as_ref().and_then(|c| c.scaffold_output.as_ref()),
74            Language::Node => self.node.as_ref().and_then(|c| c.scaffold_output.as_ref()),
75            Language::Ruby => self.ruby.as_ref().and_then(|c| c.scaffold_output.as_ref()),
76            Language::Php => self.php.as_ref().and_then(|c| c.scaffold_output.as_ref()),
77            Language::Elixir => self.elixir.as_ref().and_then(|c| c.scaffold_output.as_ref()),
78            _ => None,
79        };
80        if let Some(p) = override_path {
81            return p.to_string_lossy().to_string();
82        }
83
84        // Third priority: hardcoded defaults
85        match lang {
86            Language::Python => "packages/python".to_string(),
87            // NAPI-RS + wasm-bindgen emit their published npm package into the same
88            // directory as the Rust binding crate (`crates/{name}-node/`,
89            // `crates/{name}-wasm/`) — those manifests are the actual publish source,
90            // and the historical `packages/{node,wasm}/` scaffolds were dead weight
91            // that modern alef-scaffold no longer emits. Setup/test/clean defaults
92            // need to track the live crate dir, not the dead packages one.
93            //
94            // The default formula `crates/{name}-{lang}` assumes the crate name includes
95            // any `-rs` or similar suffix. When the consumer strips the suffix for
96            // publication matching, use the `crate_dir` override.
97            Language::Node => self
98                .node
99                .as_ref()
100                .and_then(|c| c.crate_dir.as_ref())
101                .map(|s| s.to_string())
102                .unwrap_or_else(|| format!("crates/{}-node", self.name)),
103            Language::Wasm => self
104                .wasm
105                .as_ref()
106                .and_then(|c| c.crate_dir.as_ref())
107                .map(|s| s.to_string())
108                .unwrap_or_else(|| format!("crates/{}-wasm", self.name)),
109            Language::Ruby => "packages/ruby".to_string(),
110            Language::Php => "packages/php".to_string(),
111            Language::Elixir => "packages/elixir".to_string(),
112            Language::Go => match self.go.as_ref().and_then(|c| c.module_major) {
113                Some(n) if n >= 2 => format!("packages/go/v{n}"),
114                _ => "packages/go".to_string(),
115            },
116            Language::KotlinAndroid => "packages/kotlin-android".to_string(),
117            _ => format!("packages/{lang}"),
118        }
119    }
120
121    /// Get the run_wrapper for a language, if set.
122    pub fn run_wrapper_for_language(&self, lang: Language) -> Option<&str> {
123        match lang {
124            Language::Python => self.python.as_ref().and_then(|c| c.run_wrapper.as_deref()),
125            Language::Node => self.node.as_ref().and_then(|c| c.run_wrapper.as_deref()),
126            Language::Ruby => self.ruby.as_ref().and_then(|c| c.run_wrapper.as_deref()),
127            Language::Php => self.php.as_ref().and_then(|c| c.run_wrapper.as_deref()),
128            Language::Elixir => self.elixir.as_ref().and_then(|c| c.run_wrapper.as_deref()),
129            Language::Wasm => self.wasm.as_ref().and_then(|c| c.run_wrapper.as_deref()),
130            Language::Go => self.go.as_ref().and_then(|c| c.run_wrapper.as_deref()),
131            Language::Java => self.java.as_ref().and_then(|c| c.run_wrapper.as_deref()),
132            Language::Csharp => self.csharp.as_ref().and_then(|c| c.run_wrapper.as_deref()),
133            Language::R => self.r.as_ref().and_then(|c| c.run_wrapper.as_deref()),
134            Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.run_wrapper.as_deref()),
135            Language::KotlinAndroid => self.kotlin_android.as_ref().and_then(|c| c.run_wrapper.as_deref()),
136            Language::Dart => self.dart.as_ref().and_then(|c| c.run_wrapper.as_deref()),
137            Language::Swift => self.swift.as_ref().and_then(|c| c.run_wrapper.as_deref()),
138            Language::Gleam => self.gleam.as_ref().and_then(|c| c.run_wrapper.as_deref()),
139            Language::Zig => self.zig.as_ref().and_then(|c| c.run_wrapper.as_deref()),
140            Language::Ffi | Language::Rust | Language::C | Language::Jni => None,
141        }
142    }
143
144    /// Get the extra_lint_paths for a language.
145    pub fn extra_lint_paths_for_language(&self, lang: Language) -> &[String] {
146        match lang {
147            Language::Python => self
148                .python
149                .as_ref()
150                .map(|c| c.extra_lint_paths.as_slice())
151                .unwrap_or(&[]),
152            Language::Node => self.node.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
153            Language::Ruby => self.ruby.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
154            Language::Php => self.php.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
155            Language::Elixir => self
156                .elixir
157                .as_ref()
158                .map(|c| c.extra_lint_paths.as_slice())
159                .unwrap_or(&[]),
160            Language::Wasm => self.wasm.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
161            Language::Go => self.go.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
162            Language::Java => self.java.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
163            Language::Csharp => self
164                .csharp
165                .as_ref()
166                .map(|c| c.extra_lint_paths.as_slice())
167                .unwrap_or(&[]),
168            Language::R => self.r.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
169            Language::Kotlin => self
170                .kotlin
171                .as_ref()
172                .map(|c| c.extra_lint_paths.as_slice())
173                .unwrap_or(&[]),
174            Language::KotlinAndroid => self
175                .kotlin_android
176                .as_ref()
177                .map(|c| c.extra_lint_paths.as_slice())
178                .unwrap_or(&[]),
179            Language::Dart => self.dart.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
180            Language::Swift => self
181                .swift
182                .as_ref()
183                .map(|c| c.extra_lint_paths.as_slice())
184                .unwrap_or(&[]),
185            Language::Gleam => self
186                .gleam
187                .as_ref()
188                .map(|c| c.extra_lint_paths.as_slice())
189                .unwrap_or(&[]),
190            Language::Zig => self.zig.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
191            Language::Ffi | Language::Rust | Language::C | Language::Jni => &[],
192        }
193    }
194
195    /// Get the project_file for a language (Java or C# only).
196    pub fn project_file_for_language(&self, lang: Language) -> Option<&str> {
197        match lang {
198            Language::Java => self.java.as_ref().and_then(|c| c.project_file.as_deref()),
199            Language::Csharp => self.csharp.as_ref().and_then(|c| c.project_file.as_deref()),
200            _ => None,
201        }
202    }
203
204    /// Get the effective lint configuration for a language.
205    pub fn lint_config_for_language(&self, lang: Language) -> LintConfig {
206        let lang_str = lang.to_string();
207        if let Some(explicit) = self.lint.get(&lang_str) {
208            return explicit.clone();
209        }
210        let output_dir = self.package_dir(lang);
211        let run_wrapper = self.run_wrapper_for_language(lang);
212        let extra_lint_paths = self.extra_lint_paths_for_language(lang);
213        let project_file = self.project_file_for_language(lang);
214        let ctx = LangContext {
215            tools: &self.tools,
216            run_wrapper,
217            extra_lint_paths,
218            project_file,
219        };
220        lint_defaults::default_lint_config(lang, &output_dir, &ctx)
221    }
222
223    /// Get the effective update configuration for a language.
224    pub fn update_config_for_language(&self, lang: Language) -> UpdateConfig {
225        let lang_str = lang.to_string();
226        if let Some(explicit) = self.update.get(&lang_str) {
227            return explicit.clone();
228        }
229        let output_dir = self.package_dir(lang);
230        let ctx = LangContext {
231            tools: &self.tools,
232            run_wrapper: None,
233            extra_lint_paths: &[],
234            project_file: None,
235        };
236        update_defaults::default_update_config(lang, &output_dir, &ctx)
237    }
238
239    /// Resolve the test-app run configuration for a registry test-app `name`.
240    ///
241    /// `name` is an `[e2e].languages` entry — usually a language slug (`python`,
242    /// `node`, …) but also string-only registry targets such as `brew`. Returns
243    /// the explicit `[crates.e2e.registry.run.<name>]` override if present, else
244    /// the default from [`test_apps_run_defaults`]. The test app lives under the
245    /// registry `output` directory (default `test_apps`).
246    pub fn test_apps_run_config_for_name(&self, name: &str) -> TestAppRunConfig {
247        let test_apps_dir = self
248            .e2e
249            .as_ref()
250            .map(|e2e| e2e.registry.output.as_str())
251            .unwrap_or("test_apps");
252        if let Some(explicit) = self.e2e.as_ref().and_then(|e2e| e2e.registry.run.get(name)) {
253            return explicit.clone();
254        }
255        let ctx = LangContext {
256            tools: &self.tools,
257            run_wrapper: None,
258            extra_lint_paths: &[],
259            project_file: None,
260        };
261        // Most names map to a Language enum variant; string-only registry targets
262        // (e.g. `brew`) fall back to the name-based default.
263        let parsed: Result<Language, _> = toml::Value::String(name.to_string()).try_into();
264        match parsed {
265            Ok(lang) => {
266                // Resolve the published package version the same way the e2e
267                // codegen does (registry override → base package → workspace
268                // version): a run command may need to forward it to a generated
269                // installer (e.g. PHP's `install.sh`). The package entry is keyed
270                // by the language slug.
271                let published_version = self
272                    .e2e
273                    .as_ref()
274                    .and_then(|e2e| e2e.resolve_package(name))
275                    .and_then(|pkg| pkg.version)
276                    .or_else(|| self.resolved_version());
277                // For Go, pass the Go module path so cgo bindings can vendor + generate.
278                let go_module = if lang == Language::Go {
279                    Some(self.go_module())
280                } else {
281                    None
282                };
283                test_apps_run_defaults::default_test_apps_run_config(
284                    lang,
285                    test_apps_dir,
286                    &ctx,
287                    published_version.as_deref(),
288                    go_module.as_deref(),
289                )
290            }
291            Err(_) => test_apps_run_defaults::default_test_apps_run_config_for_name(name, test_apps_dir, &ctx),
292        }
293    }
294
295    /// Get the effective test configuration for a language.
296    pub fn test_config_for_language(&self, lang: Language) -> TestConfig {
297        let lang_str = lang.to_string();
298        if let Some(explicit) = self.test.get(&lang_str) {
299            return explicit.clone();
300        }
301        let output_dir = self.package_dir(lang);
302        let run_wrapper = self.run_wrapper_for_language(lang);
303        let project_file = self.project_file_for_language(lang);
304        let ctx = LangContext {
305            tools: &self.tools,
306            run_wrapper,
307            extra_lint_paths: &[],
308            project_file,
309        };
310        test_defaults::default_test_config(lang, &output_dir, &ctx)
311    }
312
313    /// Get the effective setup configuration for a language.
314    pub fn setup_config_for_language(&self, lang: Language) -> SetupConfig {
315        let lang_str = lang.to_string();
316        if let Some(explicit) = self.setup.get(&lang_str) {
317            return explicit.clone();
318        }
319        let output_dir = self.package_dir(lang);
320        let ctx = LangContext {
321            tools: &self.tools,
322            run_wrapper: None,
323            extra_lint_paths: &[],
324            project_file: None,
325        };
326        setup_defaults::default_setup_config(lang, &output_dir, &ctx)
327    }
328
329    /// Get the effective clean configuration for a language.
330    pub fn clean_config_for_language(&self, lang: Language) -> CleanConfig {
331        let lang_str = lang.to_string();
332        if let Some(explicit) = self.clean.get(&lang_str) {
333            return explicit.clone();
334        }
335        let output_dir = self.package_dir(lang);
336        let ctx = LangContext {
337            tools: &self.tools,
338            run_wrapper: None,
339            extra_lint_paths: &[],
340            project_file: None,
341        };
342        clean_defaults::default_clean_config(lang, &output_dir, &ctx)
343    }
344
345    /// Get the effective build command configuration for a language.
346    pub fn build_command_config_for_language(&self, lang: Language) -> BuildCommandConfig {
347        let lang_str = lang.to_string();
348        let output_dir = self.package_dir(lang);
349        let run_wrapper = self.run_wrapper_for_language(lang);
350        let project_file = self.project_file_for_language(lang);
351        let ctx = LangContext {
352            tools: &self.tools,
353            run_wrapper,
354            extra_lint_paths: &[],
355            project_file,
356        };
357        let default = build_defaults::default_build_config(lang, &output_dir, &self.name, &ctx);
358        if let Some(explicit) = self.build_commands.get(&lang_str) {
359            default.merge_overlay(explicit)
360        } else {
361            default
362        }
363    }
364
365    /// Get the features to use for a specific language's binding crate.
366    pub fn features_for_language(&self, lang: Language) -> &[String] {
367        let override_features = match lang {
368            Language::Python => self.python.as_ref().and_then(|c| c.features.as_deref()),
369            Language::Node => self.node.as_ref().and_then(|c| c.features.as_deref()),
370            Language::Ruby => self.ruby.as_ref().and_then(|c| c.features.as_deref()),
371            Language::Php => self.php.as_ref().and_then(|c| c.features.as_deref()),
372            Language::Elixir => self.elixir.as_ref().and_then(|c| c.features.as_deref()),
373            Language::Wasm => self.wasm.as_ref().and_then(|c| c.features.as_deref()),
374            Language::Ffi => self.ffi.as_ref().and_then(|c| c.features.as_deref()),
375            Language::Go => self.go.as_ref().and_then(|c| c.features.as_deref()),
376            Language::Java => self.java.as_ref().and_then(|c| c.features.as_deref()),
377            Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.features.as_deref()),
378            Language::KotlinAndroid => self.kotlin_android.as_ref().and_then(|c| c.features.as_deref()),
379            Language::Csharp => self.csharp.as_ref().and_then(|c| c.features.as_deref()),
380            Language::R => self.r.as_ref().and_then(|c| c.features.as_deref()),
381            Language::Zig => self.zig.as_ref().and_then(|c| c.features.as_deref()),
382            Language::Dart => self.dart.as_ref().and_then(|c| c.features.as_deref()),
383            Language::Swift => self.swift.as_ref().and_then(|c| c.features.as_deref()),
384            Language::Gleam => self.gleam.as_ref().and_then(|c| c.features.as_deref()),
385            Language::Rust | Language::C | Language::Jni => None,
386        };
387        override_features.unwrap_or(&self.features)
388    }
389
390    /// Get the merged extra dependencies for a specific language's binding crate.
391    pub fn extra_deps_for_language(&self, lang: Language) -> HashMap<String, toml::Value> {
392        let mut deps = self.extra_dependencies.clone();
393        let lang_deps = match lang {
394            Language::Python => self.python.as_ref().map(|c| &c.extra_dependencies),
395            Language::Node => self.node.as_ref().map(|c| &c.extra_dependencies),
396            Language::Ruby => self.ruby.as_ref().map(|c| &c.extra_dependencies),
397            Language::Php => self.php.as_ref().map(|c| &c.extra_dependencies),
398            Language::Elixir => self.elixir.as_ref().map(|c| &c.extra_dependencies),
399            Language::Wasm => self.wasm.as_ref().map(|c| &c.extra_dependencies),
400            Language::Dart => self.dart.as_ref().map(|c| &c.extra_dependencies),
401            Language::Swift => self.swift.as_ref().map(|c| &c.extra_dependencies),
402            _ => None,
403        };
404        if let Some(lang_deps) = lang_deps {
405            deps.extend(lang_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
406        }
407        let exclude: &[String] = match lang {
408            Language::Wasm => self
409                .wasm
410                .as_ref()
411                .map(|c| c.exclude_extra_dependencies.as_slice())
412                .unwrap_or(&[]),
413            Language::Dart => self
414                .dart
415                .as_ref()
416                .map(|c| c.exclude_extra_dependencies.as_slice())
417                .unwrap_or(&[]),
418            Language::Swift => self
419                .swift
420                .as_ref()
421                .map(|c| c.exclude_extra_dependencies.as_slice())
422                .unwrap_or(&[]),
423            _ => &[],
424        };
425        for key in exclude {
426            deps.remove(key);
427        }
428        deps
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use crate::core::config::extras::Language;
435    use crate::core::config::new_config::NewAlefConfig;
436
437    fn resolved_one(toml: &str) -> super::super::ResolvedCrateConfig {
438        let cfg: NewAlefConfig = toml::from_str(toml).unwrap();
439        cfg.resolve().unwrap().remove(0)
440    }
441
442    fn minimal() -> super::super::ResolvedCrateConfig {
443        resolved_one(
444            r#"
445[workspace]
446languages = ["python", "node"]
447
448[[crates]]
449name = "test-lib"
450sources = ["src/lib.rs"]
451"#,
452        )
453    }
454
455    #[test]
456    fn resolved_lint_config_inherits_workspace_when_crate_unset() {
457        let r = resolved_one(
458            r#"
459[workspace]
460languages = ["python"]
461
462[workspace.lint.python]
463check = "ruff check ."
464
465[[crates]]
466name = "test-lib"
467sources = ["src/lib.rs"]
468"#,
469        );
470        let lint = r.lint_config_for_language(Language::Python);
471        assert_eq!(lint.check.unwrap().commands(), vec!["ruff check ."]);
472    }
473
474    #[test]
475    fn resolved_lint_config_crate_overrides_workspace_field_wholesale() {
476        let r = resolved_one(
477            r#"
478[workspace]
479languages = ["python"]
480
481[workspace.lint.python]
482check = "ruff check ."
483
484[[crates]]
485name = "test-lib"
486sources = ["src/lib.rs"]
487
488[crates.lint.python]
489check = "ruff check crates/test-lib-py/"
490"#,
491        );
492        let lint = r.lint_config_for_language(Language::Python);
493        assert_eq!(lint.check.unwrap().commands(), vec!["ruff check crates/test-lib-py/"]);
494    }
495
496    #[test]
497    fn resolved_features_per_language_overrides_crate_default() {
498        let r = resolved_one(
499            r#"
500[workspace]
501languages = ["python"]
502
503[[crates]]
504name = "test-lib"
505sources = ["src/lib.rs"]
506features = ["base"]
507
508[crates.python]
509features = ["python-extra"]
510"#,
511        );
512        assert_eq!(r.features_for_language(Language::Python), &["python-extra"]);
513        assert_eq!(r.features_for_language(Language::Node), &["base"]);
514    }
515
516    #[test]
517    fn resolved_extra_deps_crate_value_wins_on_key_collision() {
518        let r = resolved_one(
519            r#"
520[workspace]
521languages = ["python"]
522
523[[crates]]
524name = "test-lib"
525sources = ["src/lib.rs"]
526
527[crates.extra_dependencies]
528tokio = "1"
529
530[crates.python]
531extra_dependencies = { tokio = "2" }
532"#,
533        );
534        let deps = r.extra_deps_for_language(Language::Python);
535        let tokio = deps.get("tokio").unwrap().as_str().unwrap();
536        assert_eq!(tokio, "2", "per-language extra_dep should win on collision");
537    }
538
539    #[test]
540    fn resolved_extra_deps_excludes_apply_after_merge() {
541        let r = resolved_one(
542            r#"
543[workspace]
544languages = ["wasm"]
545
546[[crates]]
547name = "test-lib"
548sources = ["src/lib.rs"]
549
550[crates.extra_dependencies]
551tokio = "1"
552serde = "1"
553
554[crates.wasm]
555exclude_extra_dependencies = ["tokio"]
556"#,
557        );
558        let deps = r.extra_deps_for_language(Language::Wasm);
559        assert!(!deps.contains_key("tokio"), "excluded dep should be absent");
560        assert!(deps.contains_key("serde"), "non-excluded dep should be present");
561    }
562
563    #[test]
564    fn resolved_extra_deps_includes_swift_overrides() {
565        let r = resolved_one(
566            r#"
567[workspace]
568languages = ["swift"]
569
570[[crates]]
571name = "test-lib"
572sources = ["src/lib.rs"]
573
574[crates.extra_dependencies]
575serde = "1"
576
577[crates.swift.extra_dependencies]
578tokio = "1"
579"#,
580        );
581        let deps = r.extra_deps_for_language(Language::Swift);
582        assert!(deps.contains_key("serde"), "crate-level dep should be present");
583        assert!(deps.contains_key("tokio"), "Swift dep should be present");
584    }
585
586    #[test]
587    fn package_dir_defaults_are_correct() {
588        let r = minimal();
589        assert_eq!(r.package_dir(Language::Python), "packages/python");
590        // Node/Wasm default to the live NAPI-RS/wasm-bindgen crate dirs, not the
591        // dead packages/{node,wasm}/ scaffolds.
592        assert_eq!(r.package_dir(Language::Node), format!("crates/{}-node", r.name));
593        assert_eq!(r.package_dir(Language::Wasm), format!("crates/{}-wasm", r.name));
594        assert_eq!(r.package_dir(Language::Ruby), "packages/ruby");
595        // Go defaults to "packages/go" when module_major is None
596        assert_eq!(r.package_dir(Language::Go), "packages/go");
597        assert_eq!(r.package_dir(Language::Java), "packages/java");
598        assert_eq!(r.package_dir(Language::Kotlin), "packages/kotlin");
599        assert_eq!(r.package_dir(Language::KotlinAndroid), "packages/kotlin-android");
600    }
601
602    #[test]
603    fn package_dir_go_with_module_major() {
604        // Test that Go respects module_major configuration:
605        // - None or 0/1: "packages/go"
606        // - 2+: "packages/go/v{N}"
607        let r_none = resolved_one(
608            r#"
609[workspace]
610languages = ["go"]
611
612[[crates]]
613name = "test-lib"
614sources = ["src/lib.rs"]
615"#,
616        );
617        assert_eq!(
618            r_none.package_dir(Language::Go),
619            "packages/go",
620            "Go without module_major should default to 'packages/go'"
621        );
622
623        let r_v1 = resolved_one(
624            r#"
625[workspace]
626languages = ["go"]
627
628[[crates]]
629name = "test-lib"
630sources = ["src/lib.rs"]
631
632[crates.go]
633module_major = 1
634"#,
635        );
636        assert_eq!(
637            r_v1.package_dir(Language::Go),
638            "packages/go",
639            "Go with module_major = 1 should use 'packages/go'"
640        );
641
642        let r_v2 = resolved_one(
643            r#"
644[workspace]
645languages = ["go"]
646
647[[crates]]
648name = "test-lib"
649sources = ["src/lib.rs"]
650
651[crates.go]
652module_major = 2
653"#,
654        );
655        assert_eq!(
656            r_v2.package_dir(Language::Go),
657            "packages/go/v2",
658            "Go with module_major = 2 should use 'packages/go/v2'"
659        );
660
661        let r_v5 = resolved_one(
662            r#"
663[workspace]
664languages = ["go"]
665
666[[crates]]
667name = "test-lib"
668sources = ["src/lib.rs"]
669
670[crates.go]
671module_major = 5
672"#,
673        );
674        assert_eq!(
675            r_v5.package_dir(Language::Go),
676            "packages/go/v5",
677            "Go with module_major = 5 should use 'packages/go/v5'"
678        );
679    }
680
681    #[test]
682    fn package_dir_r_ignores_source_output_override() {
683        // The extendr `[crates.output]` for R points at the Rust source path
684        // (`packages/r/src/rust/src/`), but the R package manifest (`DESCRIPTION`)
685        // lives at the package root. `package_dir` must return the root so
686        // `Rscript -e "remotes::install_deps()"` finds DESCRIPTION rather than
687        // failing with "cannot open the connection".
688        let r = resolved_one(
689            r#"
690[workspace]
691languages = ["r"]
692
693[[crates]]
694name = "demo"
695sources = ["src/lib.rs"]
696
697[crates.output]
698r = "packages/r/src/rust/src/"
699"#,
700        );
701        assert_eq!(r.package_dir(Language::R), "packages/r");
702    }
703
704    #[test]
705    fn package_dir_python_ignores_source_output_override() {
706        // The PyO3 `[crates.output]` points at the Rust source directory
707        // (`crates/demo-py/src/`), but the publish manifest (`pyproject.toml`)
708        // lives at the central Python package root.
709        let r = resolved_one(
710            r#"
711[workspace]
712languages = ["python"]
713
714[[crates]]
715name = "demo"
716sources = ["src/lib.rs"]
717
718[crates.output]
719python = "crates/demo-py/src/"
720"#,
721        );
722        assert_eq!(r.package_dir(Language::Python), "packages/python");
723    }
724
725    #[test]
726    fn package_dir_kotlin_ignores_source_output_override() {
727        // The JVM Kotlin `[crates.output]` points at the deep source tree
728        // (`packages/kotlin/src/main/kotlin/.../`), but the publish manifest
729        // (`build.gradle.kts`) lives at the package root. package_dir must return
730        // the root so sync-versions and the kotlin packager find the manifest.
731        let r = resolved_one(
732            r#"
733[workspace]
734languages = ["kotlin"]
735
736[[crates]]
737name = "demo"
738sources = ["src/lib.rs"]
739
740[crates.kotlin]
741package = "dev.demo"
742target = "jvm"
743
744[crates.output]
745kotlin = "packages/kotlin/src/main/kotlin/dev/demo/kt/"
746"#,
747        );
748        assert_eq!(r.package_dir(Language::Kotlin), "packages/kotlin");
749    }
750
751    #[test]
752    fn package_dir_node_crate_dir_override_takes_precedence() {
753        let r = resolved_one(
754            r#"
755[workspace]
756languages = ["node"]
757
758[[crates]]
759name = "sample-markdown-rs"
760sources = ["src/lib.rs"]
761
762[crates.node]
763crate_dir = "crates/sample-markdown-node"
764"#,
765        );
766        assert_eq!(
767            r.package_dir(Language::Node),
768            "crates/sample-markdown-node",
769            "crate_dir override should be used instead of default formula"
770        );
771    }
772
773    #[test]
774    fn package_dir_wasm_crate_dir_override_takes_precedence() {
775        let r = resolved_one(
776            r#"
777[workspace]
778languages = ["wasm"]
779
780[[crates]]
781name = "sample-markdown-rs"
782sources = ["src/lib.rs"]
783
784[crates.wasm]
785crate_dir = "crates/sample-markdown-wasm"
786"#,
787        );
788        assert_eq!(
789            r.package_dir(Language::Wasm),
790            "crates/sample-markdown-wasm",
791            "crate_dir override should be used instead of default formula"
792        );
793    }
794
795    #[test]
796    fn package_dir_node_without_override_uses_default_formula() {
797        let r = resolved_one(
798            r#"
799[workspace]
800languages = ["node"]
801
802[[crates]]
803name = "test-lib"
804sources = ["src/lib.rs"]
805"#,
806        );
807        assert_eq!(
808            r.package_dir(Language::Node),
809            "crates/test-lib-node",
810            "default formula should apply when crate_dir is not set"
811        );
812    }
813
814    #[test]
815    fn package_dir_wasm_without_override_uses_default_formula() {
816        let r = resolved_one(
817            r#"
818[workspace]
819languages = ["wasm"]
820
821[[crates]]
822name = "test-lib"
823sources = ["src/lib.rs"]
824"#,
825        );
826        assert_eq!(
827            r.package_dir(Language::Wasm),
828            "crates/test-lib-wasm",
829            "default formula should apply when crate_dir is not set"
830        );
831    }
832
833    #[test]
834    fn package_dir_sample_markdown_scenario_with_both_overrides() {
835        // Reproduce the STY-4 scenario: sample-markdown strips `-rs` for Node and Wasm
836        // so the actual crate dirs don't follow the default formula.
837        let r = resolved_one(
838            r#"
839[workspace]
840languages = ["node", "wasm"]
841
842[[crates]]
843name = "sample-markdown-rs"
844sources = ["src/lib.rs"]
845
846[crates.node]
847crate_dir = "crates/sample-markdown-node"
848
849[crates.wasm]
850crate_dir = "crates/sample-markdown-wasm"
851"#,
852        );
853        // Without overrides, the formula would produce sample-markdown-rs-{node,wasm}
854        // (WRONG). With overrides, we get the correct stripped names.
855        assert_eq!(
856            r.package_dir(Language::Node),
857            "crates/sample-markdown-node",
858            "Node override should return exact path without formula"
859        );
860        assert_eq!(
861            r.package_dir(Language::Wasm),
862            "crates/sample-markdown-wasm",
863            "Wasm override should return exact path without formula"
864        );
865    }
866}