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