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