Skip to main content

alef_core/config/
output.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as JsonValue;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct ExcludeConfig {
8    #[serde(default)]
9    pub types: Vec<String>,
10    #[serde(default)]
11    pub functions: Vec<String>,
12    /// Exclude specific methods: "TypeName.method_name"
13    #[serde(default)]
14    pub methods: Vec<String>,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct IncludeConfig {
19    #[serde(default)]
20    pub types: Vec<String>,
21    #[serde(default)]
22    pub functions: Vec<String>,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct OutputConfig {
27    pub python: Option<PathBuf>,
28    pub node: Option<PathBuf>,
29    pub ruby: Option<PathBuf>,
30    pub php: Option<PathBuf>,
31    pub elixir: Option<PathBuf>,
32    pub wasm: Option<PathBuf>,
33    pub ffi: Option<PathBuf>,
34    pub go: Option<PathBuf>,
35    pub java: Option<PathBuf>,
36    pub kotlin: Option<PathBuf>,
37    pub dart: Option<PathBuf>,
38    pub swift: Option<PathBuf>,
39    pub csharp: Option<PathBuf>,
40    pub r: Option<PathBuf>,
41    pub zig: Option<PathBuf>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ScaffoldConfig {
46    pub description: Option<String>,
47    pub license: Option<String>,
48    pub repository: Option<String>,
49    pub homepage: Option<String>,
50    #[serde(default)]
51    pub authors: Vec<String>,
52    #[serde(default)]
53    pub keywords: Vec<String>,
54    /// Opt-in workspace `.cargo/config.toml` management. When present, alef writes
55    /// the full file with hash-based drift detection. Absent = legacy behavior
56    /// (wasm32 block only, create-if-missing, unmanaged).
57    pub cargo: Option<ScaffoldCargo>,
58}
59
60/// Opt-in management of workspace-level `.cargo/config.toml`.
61///
62/// All fields default to canonical values that produce the same `.cargo/config.toml`
63/// across polyglot repos. Override individual targets via `targets`, or inject
64/// repo-specific `[env]` entries via `env`.
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct ScaffoldCargo {
67    /// Per-target cross-compile / rustflags overrides. Defaults emit the canonical
68    /// 6-target template (macOS dynamic_lookup, Windows MSVC rust-lld x64+i686,
69    /// aarch64-linux-gnu cross-gcc, x86_64-linux-musl, wasm32 bulk-memory).
70    #[serde(default)]
71    pub targets: ScaffoldCargoTargets,
72    /// Free-form `[env]` entries copied verbatim into the generated file.
73    /// Values can be a plain string or `{ value, relative }`. Empty by default.
74    #[serde(default)]
75    pub env: HashMap<String, ScaffoldCargoEnvValue>,
76}
77
78/// Per-target opt-out flags. All default to `true`.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ScaffoldCargoTargets {
81    #[serde(default = "default_true")]
82    pub macos_dynamic_lookup: bool,
83    #[serde(default = "default_true")]
84    pub x86_64_pc_windows_msvc: bool,
85    #[serde(default = "default_true")]
86    pub i686_pc_windows_msvc: bool,
87    #[serde(default = "default_true")]
88    pub aarch64_unknown_linux_gnu: bool,
89    #[serde(default = "default_true")]
90    pub x86_64_unknown_linux_musl: bool,
91    #[serde(default = "default_true")]
92    pub wasm32_unknown_unknown: bool,
93}
94
95impl Default for ScaffoldCargoTargets {
96    fn default() -> Self {
97        Self {
98            macos_dynamic_lookup: true,
99            x86_64_pc_windows_msvc: true,
100            i686_pc_windows_msvc: true,
101            aarch64_unknown_linux_gnu: true,
102            x86_64_unknown_linux_musl: true,
103            wasm32_unknown_unknown: true,
104        }
105    }
106}
107
108fn default_true() -> bool {
109    true
110}
111
112/// Value for a `[scaffold.cargo.env]` entry. Either a bare string (renders as
113/// `KEY = "value"`) or a structured form with `value` + optional `relative`.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(untagged)]
116pub enum ScaffoldCargoEnvValue {
117    Plain(String),
118    Structured {
119        value: String,
120        #[serde(default)]
121        relative: bool,
122    },
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ReadmeConfig {
127    pub template_dir: Option<PathBuf>,
128    pub snippets_dir: Option<PathBuf>,
129    /// Deprecated: path to an external YAML config file. Prefer inline fields below.
130    pub config: Option<PathBuf>,
131    pub output_pattern: Option<String>,
132    /// Discord invite URL used in README templates.
133    pub discord_url: Option<String>,
134    /// Banner image URL used in README templates.
135    pub banner_url: Option<String>,
136    /// Per-language README configuration, keyed by language code
137    /// (e.g. "python", "typescript", "ruby"). Values are flexible JSON objects
138    /// that map directly to minijinja template context variables.
139    #[serde(default)]
140    pub languages: HashMap<String, JsonValue>,
141}
142
143/// A value that can be either a single string or a list of strings.
144///
145/// Deserializes from both `"cmd"` and `["cmd1", "cmd2"]` in TOML/JSON.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(untagged)]
148pub enum StringOrVec {
149    Single(String),
150    Multiple(Vec<String>),
151}
152
153impl StringOrVec {
154    /// Return all commands as a slice-like iterator.
155    pub fn commands(&self) -> Vec<&str> {
156        match self {
157            StringOrVec::Single(s) => vec![s.as_str()],
158            StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
159        }
160    }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164pub struct LintConfig {
165    /// Shell command that must exit 0 for lint to run; skip with warning on failure.
166    pub precondition: Option<String>,
167    /// Command(s) to run before the main lint commands; aborts on failure.
168    pub before: Option<StringOrVec>,
169    pub format: Option<StringOrVec>,
170    pub check: Option<StringOrVec>,
171    pub typecheck: Option<StringOrVec>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct UpdateConfig {
176    /// Shell command that must exit 0 for update to run; skip with warning on failure.
177    pub precondition: Option<String>,
178    /// Command(s) to run before the main update commands; aborts on failure.
179    pub before: Option<StringOrVec>,
180    /// Command(s) for safe dependency updates (compatible versions only).
181    pub update: Option<StringOrVec>,
182    /// Command(s) for aggressive updates (including incompatible/major bumps).
183    pub upgrade: Option<StringOrVec>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
187pub struct TestConfig {
188    /// Shell command that must exit 0 for test to run; skip with warning on failure.
189    pub precondition: Option<String>,
190    /// Command(s) to run before the main test commands; aborts on failure.
191    pub before: Option<StringOrVec>,
192    /// Command to run unit/integration tests for this language.
193    pub command: Option<StringOrVec>,
194    /// Command to run e2e tests for this language.
195    pub e2e: Option<StringOrVec>,
196    /// Command to run tests with coverage for this language.
197    pub coverage: Option<StringOrVec>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct SetupConfig {
202    /// Shell command that must exit 0 for setup to run; skip with warning on failure.
203    pub precondition: Option<String>,
204    /// Command(s) to run before the main setup commands; aborts on failure.
205    pub before: Option<StringOrVec>,
206    /// Command(s) to install dependencies for this language.
207    pub install: Option<StringOrVec>,
208    /// Timeout in seconds for the complete setup (precondition + before + install).
209    #[serde(default = "default_setup_timeout")]
210    pub timeout_seconds: u64,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214pub struct CleanConfig {
215    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
216    pub precondition: Option<String>,
217    /// Command(s) to run before the main clean commands; aborts on failure.
218    pub before: Option<StringOrVec>,
219    /// Command(s) to clean build artifacts for this language.
220    pub clean: Option<StringOrVec>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct BuildCommandConfig {
225    /// Shell command that must exit 0 for build to run; skip with warning on failure.
226    pub precondition: Option<String>,
227    /// Command(s) to run before the main build commands; aborts on failure.
228    pub before: Option<StringOrVec>,
229    /// Command(s) to build in debug mode.
230    pub build: Option<StringOrVec>,
231    /// Command(s) to build in release mode.
232    pub build_release: Option<StringOrVec>,
233}
234
235fn default_setup_timeout() -> u64 {
236    600
237}
238
239/// Per-language output path templates for multi-crate workspaces.
240///
241/// Each entry is a path string that may contain `{crate}` and `{lang}` placeholders.
242/// Resolved by [`OutputTemplate::resolve`] to produce a concrete path for one
243/// `(crate, language)` pair.
244///
245/// Defaults (when a language entry is absent and no per-crate explicit override is set):
246/// - Single-crate workspaces resolve to `packages/{lang}/`.
247/// - Multi-crate workspaces resolve to `packages/{lang}/{crate}/`.
248///
249/// Per-crate explicit paths in [`OutputConfig`] always win over a workspace template.
250#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
251pub struct OutputTemplate {
252    pub python: Option<String>,
253    pub node: Option<String>,
254    pub ruby: Option<String>,
255    pub php: Option<String>,
256    pub elixir: Option<String>,
257    pub wasm: Option<String>,
258    pub ffi: Option<String>,
259    pub go: Option<String>,
260    pub java: Option<String>,
261    pub kotlin: Option<String>,
262    pub dart: Option<String>,
263    pub swift: Option<String>,
264    pub csharp: Option<String>,
265    pub r: Option<String>,
266    pub zig: Option<String>,
267}
268
269impl OutputTemplate {
270    /// Resolve a `(crate, language)` pair to a concrete output path.
271    ///
272    /// Resolution order (highest priority first):
273    /// 1. Per-language template entry on `self`, if set, with `{crate}` and `{lang}`
274    ///    placeholders substituted.
275    /// 2. Default fallback: `packages/{lang}/{crate}/` if `multi_crate`, else
276    ///    language-specific historical defaults (`packages/python`, `packages/node`,
277    ///    `packages/ruby`, `packages/php`, `packages/elixir`) or `packages/{lang}` for
278    ///    languages without a historical default.
279    ///
280    /// # Panics
281    ///
282    /// Panics if `crate_name` contains a NUL byte, path separator (`/`, `\`),
283    /// or is a bare relative reference (`..`), and if the resolved path would
284    /// escape the project root via `..` components or an absolute root.
285    pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
286        validate_output_segment(crate_name, "crate_name");
287        validate_output_segment(lang, "lang");
288
289        let path = if let Some(template) = self.entry(lang) {
290            PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
291        } else if multi_crate {
292            PathBuf::from(format!("packages/{lang}/{crate_name}"))
293        } else {
294            match lang {
295                "python" => PathBuf::from("packages/python"),
296                "node" => PathBuf::from("packages/node"),
297                "ruby" => PathBuf::from("packages/ruby"),
298                "php" => PathBuf::from("packages/php"),
299                "elixir" => PathBuf::from("packages/elixir"),
300                other => PathBuf::from(format!("packages/{other}")),
301            }
302        };
303
304        validate_output_path(&path);
305        path
306    }
307
308    /// Return the raw template string for a language code, if set.
309    pub fn entry(&self, lang: &str) -> Option<&str> {
310        match lang {
311            "python" => self.python.as_deref(),
312            "node" => self.node.as_deref(),
313            "ruby" => self.ruby.as_deref(),
314            "php" => self.php.as_deref(),
315            "elixir" => self.elixir.as_deref(),
316            "wasm" => self.wasm.as_deref(),
317            "ffi" => self.ffi.as_deref(),
318            "go" => self.go.as_deref(),
319            "java" => self.java.as_deref(),
320            "kotlin" => self.kotlin.as_deref(),
321            "dart" => self.dart.as_deref(),
322            "swift" => self.swift.as_deref(),
323            "csharp" => self.csharp.as_deref(),
324            "r" => self.r.as_deref(),
325            "zig" => self.zig.as_deref(),
326            _ => None,
327        }
328    }
329}
330
331/// Validate that a user-supplied path segment (crate name or language code) does not
332/// contain characters that could enable path traversal.
333///
334/// # Panics
335///
336/// Panics if the segment contains a NUL byte, a forward slash, or a backslash.
337fn validate_output_segment(segment: &str, label: &str) {
338    if segment.contains('\0') {
339        panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
340    }
341    if segment.contains('/') || segment.contains('\\') {
342        panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
343    }
344}
345
346/// Validate that a resolved output `PathBuf` does not escape the project root.
347///
348/// # Panics
349///
350/// Panics if the path contains a `..` component or is absolute.
351fn validate_output_path(path: &std::path::Path) {
352    use std::path::Component;
353    for component in path.components() {
354        match component {
355            Component::ParentDir => {
356                panic!(
357                    "resolved output path `{}` contains `..` and would escape the project root",
358                    path.display()
359                );
360            }
361            Component::RootDir | Component::Prefix(_) => {
362                panic!(
363                    "resolved output path `{}` is absolute and would escape the project root",
364                    path.display()
365                );
366            }
367            _ => {}
368        }
369    }
370}
371
372/// A single text replacement rule for version sync.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct TextReplacement {
375    /// Glob pattern for files to process.
376    pub path: String,
377    /// Regex pattern to search for (may contain `{version}` placeholder).
378    pub search: String,
379    /// Replacement string (may contain `{version}` placeholder).
380    pub replace: String,
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn string_or_vec_single_from_toml() {
389        let toml_str = r#"format = "ruff format""#;
390        #[derive(Deserialize)]
391        struct T {
392            format: StringOrVec,
393        }
394        let t: T = toml::from_str(toml_str).unwrap();
395        assert_eq!(t.format.commands(), vec!["ruff format"]);
396    }
397
398    #[test]
399    fn string_or_vec_multiple_from_toml() {
400        let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
401        #[derive(Deserialize)]
402        struct T {
403            format: StringOrVec,
404        }
405        let t: T = toml::from_str(toml_str).unwrap();
406        assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
407    }
408
409    #[test]
410    fn lint_config_backward_compat_string() {
411        let toml_str = r#"
412format = "ruff format ."
413check = "ruff check ."
414typecheck = "mypy ."
415"#;
416        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
417        assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
418        assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
419        assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
420    }
421
422    #[test]
423    fn lint_config_array_commands() {
424        let toml_str = r#"
425format = ["cmd1", "cmd2"]
426check = "single-check"
427"#;
428        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
429        assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
430        assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
431        assert!(cfg.typecheck.is_none());
432    }
433
434    #[test]
435    fn lint_config_all_optional() {
436        let toml_str = "";
437        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
438        assert!(cfg.format.is_none());
439        assert!(cfg.check.is_none());
440        assert!(cfg.typecheck.is_none());
441    }
442
443    #[test]
444    fn update_config_from_toml() {
445        let toml_str = r#"
446update = "cargo update"
447upgrade = ["cargo upgrade --incompatible", "cargo update"]
448"#;
449        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
450        assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
451        assert_eq!(
452            cfg.upgrade.unwrap().commands(),
453            vec!["cargo upgrade --incompatible", "cargo update"]
454        );
455    }
456
457    #[test]
458    fn update_config_all_optional() {
459        let toml_str = "";
460        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
461        assert!(cfg.update.is_none());
462        assert!(cfg.upgrade.is_none());
463    }
464
465    #[test]
466    fn string_or_vec_empty_array_from_toml() {
467        let toml_str = "format = []";
468        #[derive(Deserialize)]
469        struct T {
470            format: StringOrVec,
471        }
472        let t: T = toml::from_str(toml_str).unwrap();
473        assert!(matches!(t.format, StringOrVec::Multiple(_)));
474        assert!(t.format.commands().is_empty());
475    }
476
477    #[test]
478    fn string_or_vec_single_element_array_from_toml() {
479        let toml_str = r#"format = ["cmd"]"#;
480        #[derive(Deserialize)]
481        struct T {
482            format: StringOrVec,
483        }
484        let t: T = toml::from_str(toml_str).unwrap();
485        assert_eq!(t.format.commands(), vec!["cmd"]);
486    }
487
488    #[test]
489    fn setup_config_single_string() {
490        let toml_str = r#"install = "uv sync""#;
491        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
492        assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
493    }
494
495    #[test]
496    fn setup_config_array_commands() {
497        let toml_str = r#"install = ["step1", "step2"]"#;
498        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
499        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
500    }
501
502    #[test]
503    fn setup_config_all_optional() {
504        let toml_str = "";
505        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
506        assert!(cfg.install.is_none());
507    }
508
509    #[test]
510    fn clean_config_single_string() {
511        let toml_str = r#"clean = "rm -rf dist""#;
512        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
513        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
514    }
515
516    #[test]
517    fn clean_config_array_commands() {
518        let toml_str = r#"clean = ["step1", "step2"]"#;
519        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
520        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
521    }
522
523    #[test]
524    fn clean_config_all_optional() {
525        let toml_str = "";
526        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
527        assert!(cfg.clean.is_none());
528    }
529
530    #[test]
531    fn build_command_config_single_strings() {
532        let toml_str = r#"
533build = "cargo build"
534build_release = "cargo build --release"
535"#;
536        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
537        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
538        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
539    }
540
541    #[test]
542    fn build_command_config_array_commands() {
543        let toml_str = r#"
544build = ["step1", "step2"]
545build_release = ["step1 --release", "step2 --release"]
546"#;
547        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
548        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
549        assert_eq!(
550            cfg.build_release.unwrap().commands(),
551            vec!["step1 --release", "step2 --release"]
552        );
553    }
554
555    #[test]
556    fn build_command_config_all_optional() {
557        let toml_str = "";
558        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
559        assert!(cfg.build.is_none());
560        assert!(cfg.build_release.is_none());
561    }
562
563    #[test]
564    fn test_config_backward_compat_string() {
565        let toml_str = r#"command = "pytest""#;
566        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
567        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
568        assert!(cfg.e2e.is_none());
569        assert!(cfg.coverage.is_none());
570    }
571
572    #[test]
573    fn test_config_array_command() {
574        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
575        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
576        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
577    }
578
579    #[test]
580    fn test_config_with_coverage() {
581        let toml_str = r#"
582command = "pytest"
583coverage = "pytest --cov=. --cov-report=term-missing"
584"#;
585        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
586        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
587        assert_eq!(
588            cfg.coverage.unwrap().commands(),
589            vec!["pytest --cov=. --cov-report=term-missing"]
590        );
591        assert!(cfg.e2e.is_none());
592    }
593
594    #[test]
595    fn test_config_all_optional() {
596        let toml_str = "";
597        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
598        assert!(cfg.command.is_none());
599        assert!(cfg.e2e.is_none());
600        assert!(cfg.coverage.is_none());
601    }
602
603    #[test]
604    fn full_alef_toml_with_lint_and_update() {
605        // Parse via WorkspaceConfig (new schema) — lint/update maps live there.
606        let toml_str = r#"
607languages = ["python", "node"]
608
609[lint.python]
610format = "ruff format ."
611check = "ruff check --fix ."
612
613[lint.node]
614format = ["npx oxfmt", "npx oxlint --fix"]
615
616[update.python]
617update = "uv sync --upgrade"
618upgrade = "uv sync --all-packages --all-extras --upgrade"
619
620[update.node]
621update = "pnpm up -r"
622upgrade = ["corepack up", "pnpm up --latest -r -w"]
623"#;
624        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
625        assert!(cfg.lint.contains_key("python"));
626        assert!(cfg.lint.contains_key("node"));
627
628        let py_lint = cfg.lint.get("python").unwrap();
629        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
630
631        let node_lint = cfg.lint.get("node").unwrap();
632        assert_eq!(
633            node_lint.format.as_ref().unwrap().commands(),
634            vec!["npx oxfmt", "npx oxlint --fix"]
635        );
636
637        assert!(cfg.update.contains_key("python"));
638        assert!(cfg.update.contains_key("node"));
639
640        let node_update = cfg.update.get("node").unwrap();
641        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
642        assert_eq!(
643            node_update.upgrade.as_ref().unwrap().commands(),
644            vec!["corepack up", "pnpm up --latest -r -w"]
645        );
646    }
647
648    #[test]
649    fn lint_config_with_precondition_and_before() {
650        let toml_str = r#"
651precondition = "test -f target/release/libfoo.so"
652before = "cargo build --release -p foo-ffi"
653format = "gofmt -w packages/go"
654check = "golangci-lint run ./..."
655"#;
656        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
657        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
658        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
659        assert!(cfg.format.is_some());
660        assert!(cfg.check.is_some());
661    }
662
663    #[test]
664    fn test_config_with_before_list() {
665        let toml_str = r#"
666before = ["cd packages/python && maturin develop", "echo ready"]
667command = "pytest"
668"#;
669        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
670        assert!(cfg.precondition.is_none());
671        assert_eq!(
672            cfg.before.unwrap().commands(),
673            vec!["cd packages/python && maturin develop", "echo ready"]
674        );
675        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
676    }
677
678    #[test]
679    fn setup_config_with_precondition() {
680        let toml_str = r#"
681precondition = "which rustup"
682install = "rustup update"
683"#;
684        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
685        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
686        assert!(cfg.before.is_none());
687        assert!(cfg.install.is_some());
688    }
689
690    #[test]
691    fn build_command_config_with_before() {
692        let toml_str = r#"
693before = "cargo build --release -p my-lib-ffi"
694build = "cd packages/go && go build ./..."
695"#;
696        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
697        assert!(cfg.precondition.is_none());
698        assert_eq!(
699            cfg.before.unwrap().commands(),
700            vec!["cargo build --release -p my-lib-ffi"]
701        );
702        assert!(cfg.build.is_some());
703    }
704
705    #[test]
706    fn clean_config_precondition_and_before_optional() {
707        let toml_str = r#"clean = "cargo clean""#;
708        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
709        assert!(cfg.precondition.is_none());
710        assert!(cfg.before.is_none());
711        assert!(cfg.clean.is_some());
712    }
713
714    #[test]
715    fn update_config_with_precondition() {
716        let toml_str = r#"
717precondition = "test -f Cargo.lock"
718update = "cargo update"
719"#;
720        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
721        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
722        assert!(cfg.before.is_none());
723        assert!(cfg.update.is_some());
724    }
725
726    #[test]
727    fn full_alef_toml_with_precondition_and_before_across_sections() {
728        // Parse via WorkspaceConfig (new schema).
729        let toml_str = r#"
730languages = ["go", "python"]
731
732[lint.go]
733precondition = "test -f target/release/libmylib_ffi.so"
734before = "cargo build --release -p mylib-ffi"
735format = "gofmt -w packages/go"
736check = "golangci-lint run ./..."
737
738[lint.python]
739format = "ruff format packages/python"
740check = "ruff check --fix packages/python"
741
742[test.go]
743precondition = "test -f target/release/libmylib_ffi.so"
744before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
745command = "cd packages/go && go test ./..."
746
747[test.python]
748command = "cd packages/python && uv run pytest"
749
750[build_commands.go]
751precondition = "which go"
752before = "cargo build --release -p mylib-ffi"
753build = "cd packages/go && go build ./..."
754build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
755
756[update.go]
757precondition = "test -d packages/go"
758update = "cd packages/go && go get -u ./..."
759
760[setup.python]
761precondition = "which uv"
762install = "cd packages/python && uv sync"
763
764[clean.go]
765before = "echo cleaning go"
766clean = "cd packages/go && go clean -cache"
767"#;
768        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
769
770        // lint.go: precondition and before set
771        let go_lint = cfg.lint.get("go").unwrap();
772        assert_eq!(
773            go_lint.precondition.as_deref(),
774            Some("test -f target/release/libmylib_ffi.so"),
775            "lint.go precondition should be preserved"
776        );
777        assert_eq!(
778            go_lint.before.as_ref().unwrap().commands(),
779            vec!["cargo build --release -p mylib-ffi"],
780            "lint.go before should be preserved"
781        );
782        assert!(go_lint.format.is_some());
783        assert!(go_lint.check.is_some());
784
785        // lint.python: no precondition or before
786        let py_lint = cfg.lint.get("python").unwrap();
787        assert!(
788            py_lint.precondition.is_none(),
789            "lint.python should have no precondition"
790        );
791        assert!(py_lint.before.is_none(), "lint.python should have no before");
792
793        // test.go: precondition and multi-command before
794        let go_test = cfg.test.get("go").unwrap();
795        assert_eq!(
796            go_test.precondition.as_deref(),
797            Some("test -f target/release/libmylib_ffi.so"),
798            "test.go precondition should be preserved"
799        );
800        assert_eq!(
801            go_test.before.as_ref().unwrap().commands(),
802            vec![
803                "cargo build --release -p mylib-ffi",
804                "cp target/release/libmylib_ffi.so packages/go/"
805            ],
806            "test.go before list should be preserved"
807        );
808
809        // build_commands.go: precondition and before
810        let go_build = cfg.build_commands.get("go").unwrap();
811        assert_eq!(
812            go_build.precondition.as_deref(),
813            Some("which go"),
814            "build_commands.go precondition should be preserved"
815        );
816        assert_eq!(
817            go_build.before.as_ref().unwrap().commands(),
818            vec!["cargo build --release -p mylib-ffi"],
819            "build_commands.go before should be preserved"
820        );
821
822        // update.go: precondition only, no before
823        let go_update = cfg.update.get("go").unwrap();
824        assert_eq!(
825            go_update.precondition.as_deref(),
826            Some("test -d packages/go"),
827            "update.go precondition should be preserved"
828        );
829        assert!(go_update.before.is_none(), "update.go before should be None");
830
831        // setup.python: precondition only
832        let py_setup = cfg.setup.get("python").unwrap();
833        assert_eq!(
834            py_setup.precondition.as_deref(),
835            Some("which uv"),
836            "setup.python precondition should be preserved"
837        );
838        assert!(py_setup.before.is_none(), "setup.python before should be None");
839
840        // clean.go: before only, no precondition
841        let go_clean = cfg.clean.get("go").unwrap();
842        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
843        assert_eq!(
844            go_clean.before.as_ref().unwrap().commands(),
845            vec!["echo cleaning go"],
846            "clean.go before should be preserved"
847        );
848    }
849
850    #[test]
851    fn output_template_resolves_explicit_entry() {
852        let tmpl = OutputTemplate {
853            python: Some("crates/{crate}-py/src/".to_string()),
854            ..Default::default()
855        };
856        assert_eq!(
857            tmpl.resolve("spikard", "python", true),
858            PathBuf::from("crates/spikard-py/src/")
859        );
860    }
861
862    #[test]
863    fn output_template_substitutes_lang_and_crate() {
864        let tmpl = OutputTemplate {
865            go: Some("packages/{lang}/{crate}/".to_string()),
866            ..Default::default()
867        };
868        assert_eq!(
869            tmpl.resolve("spikard-runtime", "go", true),
870            PathBuf::from("packages/go/spikard-runtime/")
871        );
872    }
873
874    #[test]
875    fn output_template_falls_back_to_multi_crate_default() {
876        let tmpl = OutputTemplate::default();
877        assert_eq!(
878            tmpl.resolve("spikard-runtime", "python", true),
879            PathBuf::from("packages/python/spikard-runtime")
880        );
881    }
882
883    #[test]
884    fn output_template_falls_back_to_single_crate_historical_default() {
885        let tmpl = OutputTemplate::default();
886        assert_eq!(
887            tmpl.resolve("spikard", "python", false),
888            PathBuf::from("packages/python")
889        );
890        assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
891        assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
892        assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
893        assert_eq!(
894            tmpl.resolve("spikard", "elixir", false),
895            PathBuf::from("packages/elixir")
896        );
897    }
898
899    #[test]
900    fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
901        let tmpl = OutputTemplate::default();
902        assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
903        assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
904    }
905
906    #[test]
907    fn output_template_deserializes_from_toml() {
908        let toml_str = r#"
909python = "packages/python/{crate}/"
910go     = "packages/go/{crate}/"
911"#;
912        let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
913        assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
914        assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
915        assert!(tmpl.node.is_none());
916    }
917
918    #[test]
919    #[should_panic(expected = "path separators are not allowed")]
920    fn resolve_rejects_crate_name_with_path_separator() {
921        let tmpl = OutputTemplate::default();
922        tmpl.resolve("../foo", "python", false);
923    }
924
925    #[test]
926    #[should_panic(expected = "path separators are not allowed")]
927    fn resolve_rejects_crate_name_with_backslash() {
928        let tmpl = OutputTemplate::default();
929        tmpl.resolve("..\\foo", "python", false);
930    }
931
932    #[test]
933    #[should_panic(expected = "NUL byte is not allowed")]
934    fn resolve_rejects_crate_name_with_nul_byte() {
935        let tmpl = OutputTemplate::default();
936        tmpl.resolve("foo\0bar", "python", false);
937    }
938
939    #[test]
940    #[should_panic(expected = "would escape the project root")]
941    fn resolve_rejects_template_that_produces_parent_dir() {
942        // A malicious template that uses .. directly.
943        let tmpl = OutputTemplate {
944            python: Some("../../etc/{crate}".to_string()),
945            ..Default::default()
946        };
947        tmpl.resolve("mylib", "python", false);
948    }
949
950    #[test]
951    fn resolve_accepts_normal_crate_name() {
952        let tmpl = OutputTemplate::default();
953        let path = tmpl.resolve("my-lib", "python", false);
954        assert_eq!(path, PathBuf::from("packages/python"));
955    }
956}
957
958/// Configuration for the `sync-versions` command.
959#[derive(Debug, Clone, Serialize, Deserialize, Default)]
960pub struct SyncConfig {
961    /// Extra file paths to update version in (glob patterns).
962    #[serde(default)]
963    pub extra_paths: Vec<String>,
964    /// Arbitrary text replacements applied during version sync.
965    #[serde(default)]
966    pub text_replacements: Vec<TextReplacement>,
967}