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    /// Optional working directory (relative to repo root) for setup commands.
255    ///
256    /// When set, install commands run from `base_dir.join(workdir)` instead of
257    /// `base_dir`. Required for languages whose manifest does not live at the
258    /// workspace root (Swift's `Package.swift`, Kotlin-Android's `gradlew`,
259    /// Dart's `pubspec.yaml`, Zig's `build.zig`). Defaults to `None` (run from
260    /// repo root).
261    #[serde(default)]
262    pub workdir: Option<PathBuf>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub struct CleanConfig {
267    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
268    pub precondition: Option<String>,
269    /// Command(s) to run before the main clean commands; aborts on failure.
270    pub before: Option<StringOrVec>,
271    /// Command(s) to clean build artifacts for this language.
272    pub clean: Option<StringOrVec>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct BuildCommandConfig {
277    /// Shell command that must exit 0 for build to run; skip with warning on failure.
278    pub precondition: Option<String>,
279    /// Command(s) to run before the main build commands; aborts on failure.
280    pub before: Option<StringOrVec>,
281    /// Command(s) to build in debug mode.
282    pub build: Option<StringOrVec>,
283    /// Command(s) to build in release mode.
284    pub build_release: Option<StringOrVec>,
285}
286
287impl BuildCommandConfig {
288    /// Overlay `other` onto this config field-by-field.
289    ///
290    /// Used for build command defaults where built-ins, workspace defaults, and
291    /// crate overrides should compose without forcing callers to restate every
292    /// command field.
293    pub fn merge_overlay(mut self, other: &Self) -> Self {
294        if other.precondition.is_some() {
295            self.precondition = other.precondition.clone();
296        }
297        if other.before.is_some() {
298            self.before = other.before.clone();
299        }
300        if other.build.is_some() {
301            self.build = other.build.clone();
302        }
303        if other.build_release.is_some() {
304            self.build_release = other.build_release.clone();
305        }
306        self
307    }
308}
309
310fn default_setup_timeout() -> u64 {
311    600
312}
313
314/// Per-language output path templates for multi-crate workspaces.
315///
316/// Each entry is a path string that may contain `{crate}` and `{lang}` placeholders.
317/// Resolved by [`OutputTemplate::resolve`] to produce a concrete path for one
318/// `(crate, language)` pair.
319///
320/// Defaults (when a language entry is absent and no per-crate explicit override is set):
321/// - Single-crate workspaces resolve to `packages/{lang}/`.
322/// - Multi-crate workspaces resolve to `packages/{lang}/{crate}/`.
323///
324/// Per-crate explicit paths in [`OutputConfig`] always win over a workspace template.
325#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
326pub struct OutputTemplate {
327    pub python: Option<String>,
328    pub node: Option<String>,
329    pub ruby: Option<String>,
330    pub php: Option<String>,
331    pub elixir: Option<String>,
332    pub wasm: Option<String>,
333    pub ffi: Option<String>,
334    pub go: Option<String>,
335    pub java: Option<String>,
336    pub kotlin: Option<String>,
337    pub kotlin_android: Option<String>,
338    pub dart: Option<String>,
339    pub swift: Option<String>,
340    pub gleam: Option<String>,
341    pub csharp: Option<String>,
342    pub r: Option<String>,
343    pub zig: Option<String>,
344}
345
346impl OutputTemplate {
347    /// Resolve a `(crate, language)` pair to a concrete output path.
348    ///
349    /// Resolution order (highest priority first):
350    /// 1. Per-language template entry on `self`, if set, with `{crate}` and `{lang}`
351    ///    placeholders substituted.
352    /// 2. Default fallback: `packages/{lang}/{crate}/` if `multi_crate`, else
353    ///    language-specific historical defaults (`packages/python`, `packages/node`,
354    ///    `packages/ruby`, `packages/php`, `packages/elixir`) or `packages/{lang}` for
355    ///    languages without a historical default.
356    ///
357    /// # Panics
358    ///
359    /// Panics if `crate_name` contains a NUL byte, path separator (`/`, `\`),
360    /// or is a bare relative reference (`..`), and if the resolved path would
361    /// escape the project root via `..` components or an absolute root.
362    pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
363        validate_output_segment(crate_name, "crate_name");
364        validate_output_segment(lang, "lang");
365
366        let path = if let Some(template) = self.entry(lang) {
367            PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
368        } else if multi_crate {
369            PathBuf::from(format!("packages/{lang}/{crate_name}"))
370        } else {
371            match lang {
372                "python" => PathBuf::from("packages/python"),
373                "node" => PathBuf::from("packages/node"),
374                "ruby" => PathBuf::from("packages/ruby"),
375                "php" => PathBuf::from("packages/php"),
376                "elixir" => PathBuf::from("packages/elixir"),
377                other => PathBuf::from(format!("packages/{other}")),
378            }
379        };
380
381        validate_output_path(&path);
382        path
383    }
384
385    /// Return the raw template string for a language code, if set.
386    pub fn entry(&self, lang: &str) -> Option<&str> {
387        match lang {
388            "python" => self.python.as_deref(),
389            "node" => self.node.as_deref(),
390            "ruby" => self.ruby.as_deref(),
391            "php" => self.php.as_deref(),
392            "elixir" => self.elixir.as_deref(),
393            "wasm" => self.wasm.as_deref(),
394            "ffi" => self.ffi.as_deref(),
395            "go" => self.go.as_deref(),
396            "java" => self.java.as_deref(),
397            "kotlin" => self.kotlin.as_deref(),
398            "kotlin_android" => self.kotlin_android.as_deref(),
399            "dart" => self.dart.as_deref(),
400            "swift" => self.swift.as_deref(),
401            "gleam" => self.gleam.as_deref(),
402            "csharp" => self.csharp.as_deref(),
403            "r" => self.r.as_deref(),
404            "zig" => self.zig.as_deref(),
405            _ => None,
406        }
407    }
408}
409
410/// Validate that a user-supplied path segment (crate name or language code) does not
411/// contain characters that could enable path traversal.
412///
413/// # Panics
414///
415/// Panics if the segment contains a NUL byte, a forward slash, or a backslash.
416fn validate_output_segment(segment: &str, label: &str) {
417    if segment.contains('\0') {
418        panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
419    }
420    if segment.contains('/') || segment.contains('\\') {
421        panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
422    }
423}
424
425/// Validate that a resolved output `PathBuf` does not escape the project root.
426///
427/// # Panics
428///
429/// Panics if the path contains a `..` component or is absolute.
430fn validate_output_path(path: &std::path::Path) {
431    use std::path::Component;
432    for component in path.components() {
433        match component {
434            Component::ParentDir => {
435                panic!(
436                    "resolved output path `{}` contains `..` and would escape the project root",
437                    path.display()
438                );
439            }
440            Component::RootDir | Component::Prefix(_) => {
441                panic!(
442                    "resolved output path `{}` is absolute and would escape the project root",
443                    path.display()
444                );
445            }
446            _ => {}
447        }
448    }
449}
450
451/// A single text replacement rule for version sync.
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct TextReplacement {
454    /// Glob pattern for files to process.
455    pub path: String,
456    /// Regex pattern to search for (may contain `{version}` placeholder).
457    pub search: String,
458    /// Replacement string (may contain `{version}` placeholder).
459    pub replace: String,
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn string_or_vec_single_from_toml() {
468        let toml_str = r#"format = "ruff format""#;
469        #[derive(Deserialize)]
470        struct T {
471            format: StringOrVec,
472        }
473        let t: T = toml::from_str(toml_str).unwrap();
474        assert_eq!(t.format.commands(), vec!["ruff format"]);
475    }
476
477    #[test]
478    fn string_or_vec_multiple_from_toml() {
479        let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
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!["cmd1", "cmd2", "cmd3"]);
486    }
487
488    #[test]
489    fn lint_config_backward_compat_string() {
490        let toml_str = r#"
491format = "ruff format ."
492check = "ruff check ."
493typecheck = "mypy ."
494"#;
495        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
496        assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
497        assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
498        assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
499    }
500
501    #[test]
502    fn lint_config_array_commands() {
503        let toml_str = r#"
504format = ["cmd1", "cmd2"]
505check = "single-check"
506"#;
507        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
508        assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
509        assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
510        assert!(cfg.typecheck.is_none());
511    }
512
513    #[test]
514    fn lint_config_all_optional() {
515        let toml_str = "";
516        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
517        assert!(cfg.format.is_none());
518        assert!(cfg.check.is_none());
519        assert!(cfg.typecheck.is_none());
520    }
521
522    #[test]
523    fn update_config_from_toml() {
524        let toml_str = r#"
525update = "cargo update"
526upgrade = ["cargo upgrade --incompatible", "cargo update"]
527"#;
528        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
529        assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
530        assert_eq!(
531            cfg.upgrade.unwrap().commands(),
532            vec!["cargo upgrade --incompatible", "cargo update"]
533        );
534    }
535
536    #[test]
537    fn update_config_all_optional() {
538        let toml_str = "";
539        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
540        assert!(cfg.update.is_none());
541        assert!(cfg.upgrade.is_none());
542    }
543
544    #[test]
545    fn string_or_vec_empty_array_from_toml() {
546        let toml_str = "format = []";
547        #[derive(Deserialize)]
548        struct T {
549            format: StringOrVec,
550        }
551        let t: T = toml::from_str(toml_str).unwrap();
552        assert!(matches!(t.format, StringOrVec::Multiple(_)));
553        assert!(t.format.commands().is_empty());
554    }
555
556    #[test]
557    fn string_or_vec_single_element_array_from_toml() {
558        let toml_str = r#"format = ["cmd"]"#;
559        #[derive(Deserialize)]
560        struct T {
561            format: StringOrVec,
562        }
563        let t: T = toml::from_str(toml_str).unwrap();
564        assert_eq!(t.format.commands(), vec!["cmd"]);
565    }
566
567    #[test]
568    fn setup_config_single_string() {
569        let toml_str = r#"install = "uv sync""#;
570        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
571        assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
572    }
573
574    #[test]
575    fn setup_config_array_commands() {
576        let toml_str = r#"install = ["step1", "step2"]"#;
577        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
578        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
579    }
580
581    #[test]
582    fn setup_config_all_optional() {
583        let toml_str = "";
584        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
585        assert!(cfg.install.is_none());
586    }
587
588    #[test]
589    fn clean_config_single_string() {
590        let toml_str = r#"clean = "rm -rf dist""#;
591        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
592        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
593    }
594
595    #[test]
596    fn clean_config_array_commands() {
597        let toml_str = r#"clean = ["step1", "step2"]"#;
598        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
599        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
600    }
601
602    #[test]
603    fn clean_config_all_optional() {
604        let toml_str = "";
605        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
606        assert!(cfg.clean.is_none());
607    }
608
609    #[test]
610    fn build_command_config_single_strings() {
611        let toml_str = r#"
612build = "cargo build"
613build_release = "cargo build --release"
614"#;
615        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
616        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
617        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
618    }
619
620    #[test]
621    fn build_command_config_array_commands() {
622        let toml_str = r#"
623build = ["step1", "step2"]
624build_release = ["step1 --release", "step2 --release"]
625"#;
626        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
627        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
628        assert_eq!(
629            cfg.build_release.unwrap().commands(),
630            vec!["step1 --release", "step2 --release"]
631        );
632    }
633
634    #[test]
635    fn build_command_config_all_optional() {
636        let toml_str = "";
637        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
638        assert!(cfg.build.is_none());
639        assert!(cfg.build_release.is_none());
640    }
641
642    #[test]
643    fn test_config_backward_compat_string() {
644        let toml_str = r#"command = "pytest""#;
645        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
646        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
647        assert!(cfg.e2e.is_none());
648        assert!(cfg.coverage.is_none());
649    }
650
651    #[test]
652    fn test_config_array_command() {
653        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
654        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
655        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
656    }
657
658    #[test]
659    fn test_config_with_coverage() {
660        let toml_str = r#"
661command = "pytest"
662coverage = "pytest --cov=. --cov-report=term-missing"
663"#;
664        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
665        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
666        assert_eq!(
667            cfg.coverage.unwrap().commands(),
668            vec!["pytest --cov=. --cov-report=term-missing"]
669        );
670        assert!(cfg.e2e.is_none());
671    }
672
673    #[test]
674    fn test_config_all_optional() {
675        let toml_str = "";
676        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
677        assert!(cfg.command.is_none());
678        assert!(cfg.e2e.is_none());
679        assert!(cfg.coverage.is_none());
680    }
681
682    #[test]
683    fn full_alef_toml_with_lint_and_update() {
684        // Parse via WorkspaceConfig (new schema) — lint/update maps live there.
685        let toml_str = r#"
686languages = ["python", "node"]
687
688[lint.python]
689format = "ruff format ."
690check = "ruff check --fix ."
691
692[lint.node]
693format = ["npx oxfmt", "npx oxlint --fix"]
694
695[update.python]
696update = "uv sync --upgrade"
697upgrade = "uv sync --all-packages --all-extras --upgrade"
698
699[update.node]
700update = "pnpm up -r"
701upgrade = ["corepack up", "pnpm up --latest -r -w"]
702"#;
703        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
704        assert!(cfg.lint.contains_key("python"));
705        assert!(cfg.lint.contains_key("node"));
706
707        let py_lint = cfg.lint.get("python").unwrap();
708        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
709
710        let node_lint = cfg.lint.get("node").unwrap();
711        assert_eq!(
712            node_lint.format.as_ref().unwrap().commands(),
713            vec!["npx oxfmt", "npx oxlint --fix"]
714        );
715
716        assert!(cfg.update.contains_key("python"));
717        assert!(cfg.update.contains_key("node"));
718
719        let node_update = cfg.update.get("node").unwrap();
720        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
721        assert_eq!(
722            node_update.upgrade.as_ref().unwrap().commands(),
723            vec!["corepack up", "pnpm up --latest -r -w"]
724        );
725    }
726
727    #[test]
728    fn lint_config_with_precondition_and_before() {
729        let toml_str = r#"
730precondition = "test -f target/release/libfoo.so"
731before = "cargo build --release -p foo-ffi"
732format = "gofmt -w packages/go"
733check = "golangci-lint run ./..."
734"#;
735        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
736        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
737        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
738        assert!(cfg.format.is_some());
739        assert!(cfg.check.is_some());
740    }
741
742    #[test]
743    fn test_config_with_before_list() {
744        let toml_str = r#"
745before = ["cd packages/python && maturin develop", "echo ready"]
746command = "pytest"
747"#;
748        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
749        assert!(cfg.precondition.is_none());
750        assert_eq!(
751            cfg.before.unwrap().commands(),
752            vec!["cd packages/python && maturin develop", "echo ready"]
753        );
754        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
755    }
756
757    #[test]
758    fn setup_config_with_precondition() {
759        let toml_str = r#"
760precondition = "which rustup"
761install = "rustup update"
762"#;
763        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
764        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
765        assert!(cfg.before.is_none());
766        assert!(cfg.install.is_some());
767    }
768
769    #[test]
770    fn build_command_config_with_before() {
771        let toml_str = r#"
772before = "cargo build --release -p my-lib-ffi"
773build = "cd packages/go && go build ./..."
774"#;
775        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
776        assert!(cfg.precondition.is_none());
777        assert_eq!(
778            cfg.before.unwrap().commands(),
779            vec!["cargo build --release -p my-lib-ffi"]
780        );
781        assert!(cfg.build.is_some());
782    }
783
784    #[test]
785    fn clean_config_precondition_and_before_optional() {
786        let toml_str = r#"clean = "cargo clean""#;
787        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
788        assert!(cfg.precondition.is_none());
789        assert!(cfg.before.is_none());
790        assert!(cfg.clean.is_some());
791    }
792
793    #[test]
794    fn update_config_with_precondition() {
795        let toml_str = r#"
796precondition = "test -f Cargo.lock"
797update = "cargo update"
798"#;
799        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
800        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
801        assert!(cfg.before.is_none());
802        assert!(cfg.update.is_some());
803    }
804
805    #[test]
806    fn full_alef_toml_with_precondition_and_before_across_sections() {
807        // Parse via WorkspaceConfig (new schema).
808        let toml_str = r#"
809languages = ["go", "python"]
810
811[lint.go]
812precondition = "test -f target/release/libmylib_ffi.so"
813before = "cargo build --release -p mylib-ffi"
814format = "gofmt -w packages/go"
815check = "golangci-lint run ./..."
816
817[lint.python]
818format = "ruff format packages/python"
819check = "ruff check --fix packages/python"
820
821[test.go]
822precondition = "test -f target/release/libmylib_ffi.so"
823before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
824command = "cd packages/go && go test ./..."
825
826[test.python]
827command = "cd packages/python && uv run pytest"
828
829[build_commands.go]
830precondition = "which go"
831before = "cargo build --release -p mylib-ffi"
832build = "cd packages/go && go build ./..."
833build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
834
835[update.go]
836precondition = "test -d packages/go"
837update = "cd packages/go && go get -u ./..."
838
839[setup.python]
840precondition = "which uv"
841install = "cd packages/python && uv sync"
842
843[clean.go]
844before = "echo cleaning go"
845clean = "cd packages/go && go clean -cache"
846"#;
847        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
848
849        // lint.go: precondition and before set
850        let go_lint = cfg.lint.get("go").unwrap();
851        assert_eq!(
852            go_lint.precondition.as_deref(),
853            Some("test -f target/release/libmylib_ffi.so"),
854            "lint.go precondition should be preserved"
855        );
856        assert_eq!(
857            go_lint.before.as_ref().unwrap().commands(),
858            vec!["cargo build --release -p mylib-ffi"],
859            "lint.go before should be preserved"
860        );
861        assert!(go_lint.format.is_some());
862        assert!(go_lint.check.is_some());
863
864        // lint.python: no precondition or before
865        let py_lint = cfg.lint.get("python").unwrap();
866        assert!(
867            py_lint.precondition.is_none(),
868            "lint.python should have no precondition"
869        );
870        assert!(py_lint.before.is_none(), "lint.python should have no before");
871
872        // test.go: precondition and multi-command before
873        let go_test = cfg.test.get("go").unwrap();
874        assert_eq!(
875            go_test.precondition.as_deref(),
876            Some("test -f target/release/libmylib_ffi.so"),
877            "test.go precondition should be preserved"
878        );
879        assert_eq!(
880            go_test.before.as_ref().unwrap().commands(),
881            vec![
882                "cargo build --release -p mylib-ffi",
883                "cp target/release/libmylib_ffi.so packages/go/"
884            ],
885            "test.go before list should be preserved"
886        );
887
888        // build_commands.go: precondition and before
889        let go_build = cfg.build_commands.get("go").unwrap();
890        assert_eq!(
891            go_build.precondition.as_deref(),
892            Some("which go"),
893            "build_commands.go precondition should be preserved"
894        );
895        assert_eq!(
896            go_build.before.as_ref().unwrap().commands(),
897            vec!["cargo build --release -p mylib-ffi"],
898            "build_commands.go before should be preserved"
899        );
900
901        // update.go: precondition only, no before
902        let go_update = cfg.update.get("go").unwrap();
903        assert_eq!(
904            go_update.precondition.as_deref(),
905            Some("test -d packages/go"),
906            "update.go precondition should be preserved"
907        );
908        assert!(go_update.before.is_none(), "update.go before should be None");
909
910        // setup.python: precondition only
911        let py_setup = cfg.setup.get("python").unwrap();
912        assert_eq!(
913            py_setup.precondition.as_deref(),
914            Some("which uv"),
915            "setup.python precondition should be preserved"
916        );
917        assert!(py_setup.before.is_none(), "setup.python before should be None");
918
919        // clean.go: before only, no precondition
920        let go_clean = cfg.clean.get("go").unwrap();
921        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
922        assert_eq!(
923            go_clean.before.as_ref().unwrap().commands(),
924            vec!["echo cleaning go"],
925            "clean.go before should be preserved"
926        );
927    }
928
929    #[test]
930    fn output_template_resolves_explicit_entry() {
931        let tmpl = OutputTemplate {
932            python: Some("crates/{crate}-py/src/".to_string()),
933            ..Default::default()
934        };
935        assert_eq!(
936            tmpl.resolve("spikard", "python", true),
937            PathBuf::from("crates/spikard-py/src/")
938        );
939    }
940
941    #[test]
942    fn output_template_substitutes_lang_and_crate() {
943        let tmpl = OutputTemplate {
944            go: Some("packages/{lang}/{crate}/".to_string()),
945            ..Default::default()
946        };
947        assert_eq!(
948            tmpl.resolve("spikard-runtime", "go", true),
949            PathBuf::from("packages/go/spikard-runtime/")
950        );
951    }
952
953    #[test]
954    fn output_template_falls_back_to_multi_crate_default() {
955        let tmpl = OutputTemplate::default();
956        assert_eq!(
957            tmpl.resolve("spikard-runtime", "python", true),
958            PathBuf::from("packages/python/spikard-runtime")
959        );
960    }
961
962    #[test]
963    fn output_template_falls_back_to_single_crate_historical_default() {
964        let tmpl = OutputTemplate::default();
965        assert_eq!(
966            tmpl.resolve("spikard", "python", false),
967            PathBuf::from("packages/python")
968        );
969        assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
970        assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
971        assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
972        assert_eq!(
973            tmpl.resolve("spikard", "elixir", false),
974            PathBuf::from("packages/elixir")
975        );
976    }
977
978    #[test]
979    fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
980        let tmpl = OutputTemplate::default();
981        assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
982        assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
983    }
984
985    #[test]
986    fn output_template_deserializes_from_toml() {
987        let toml_str = r#"
988python = "packages/python/{crate}/"
989go     = "packages/go/{crate}/"
990"#;
991        let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
992        assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
993        assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
994        assert!(tmpl.node.is_none());
995    }
996
997    #[test]
998    #[should_panic(expected = "path separators are not allowed")]
999    fn resolve_rejects_crate_name_with_path_separator() {
1000        let tmpl = OutputTemplate::default();
1001        tmpl.resolve("../foo", "python", false);
1002    }
1003
1004    #[test]
1005    #[should_panic(expected = "path separators are not allowed")]
1006    fn resolve_rejects_crate_name_with_backslash() {
1007        let tmpl = OutputTemplate::default();
1008        tmpl.resolve("..\\foo", "python", false);
1009    }
1010
1011    #[test]
1012    #[should_panic(expected = "NUL byte is not allowed")]
1013    fn resolve_rejects_crate_name_with_nul_byte() {
1014        let tmpl = OutputTemplate::default();
1015        tmpl.resolve("foo\0bar", "python", false);
1016    }
1017
1018    #[test]
1019    #[should_panic(expected = "would escape the project root")]
1020    fn resolve_rejects_template_that_produces_parent_dir() {
1021        // A malicious template that uses .. directly.
1022        let tmpl = OutputTemplate {
1023            python: Some("../../etc/{crate}".to_string()),
1024            ..Default::default()
1025        };
1026        tmpl.resolve("mylib", "python", false);
1027    }
1028
1029    #[test]
1030    fn resolve_accepts_normal_crate_name() {
1031        let tmpl = OutputTemplate::default();
1032        let path = tmpl.resolve("my-lib", "python", false);
1033        assert_eq!(path, PathBuf::from("packages/python"));
1034    }
1035}
1036
1037/// Configuration for the `sync-versions` command.
1038#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1039pub struct SyncConfig {
1040    /// Extra file paths to update version in (glob patterns).
1041    #[serde(default)]
1042    pub extra_paths: Vec<String>,
1043    /// Arbitrary text replacements applied during version sync.
1044    #[serde(default)]
1045    pub text_replacements: Vec<TextReplacement>,
1046}
1047
1048/// A single author entry in a `CITATION.cff` file. Per the Citation File Format
1049/// schema, each entry is either a person (uses `family_names` + `given_names`)
1050/// or a legal entity (uses `name`). Validation lives in the renderer rather
1051/// than in serde because the choice is mutually exclusive.
1052#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1053#[serde(deny_unknown_fields)]
1054pub struct CitationAuthor {
1055    /// Person author: family name(s).
1056    #[serde(default, alias = "family-names")]
1057    pub family_names: Option<String>,
1058    /// Person author: given name(s).
1059    #[serde(default, alias = "given-names")]
1060    pub given_names: Option<String>,
1061    /// Entity author: organisation or legal-entity name.
1062    #[serde(default)]
1063    pub name: Option<String>,
1064    /// Optional contact email (applies to either person or entity).
1065    #[serde(default)]
1066    pub email: Option<String>,
1067    /// Optional ORCID iD URL (`https://orcid.org/0000-0000-0000-0000`).
1068    #[serde(default)]
1069    pub orcid: Option<String>,
1070}
1071
1072/// Configuration for the alef-generated `CITATION.cff` file at the repo root.
1073///
1074/// When this section is present in `alef.toml`, `alef sync-versions` writes a
1075/// fully-rendered Citation File Format YAML using these fields plus the current
1076/// workspace version (read from `Cargo.toml`). When absent, alef falls back to
1077/// updating the `version:` line of a hand-authored CITATION.cff in place.
1078///
1079/// All field names follow Rust convention; the renderer emits the canonical
1080/// CFF kebab-case keys (`cff-version`, `repository-code`, `date-released`,
1081/// `family-names`, `given-names`).
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct CitationConfig {
1085    /// Software title (`title:`). Required.
1086    pub title: String,
1087    /// One-paragraph summary (`abstract:`). Required.
1088    #[serde(rename = "abstract")]
1089    pub abstract_: String,
1090    /// Authors list — at least one entry required. Persons and legal entities
1091    /// can be mixed (e.g. `Na'aman Hirschfeld` + `Kreuzberg, Inc.`).
1092    pub authors: Vec<CitationAuthor>,
1093    /// Canonical citation message shown to consumers (`message:`).
1094    #[serde(default = "default_citation_message")]
1095    pub message: String,
1096    /// Source-code repository URL (`repository-code:`). Required.
1097    #[serde(rename = "repository-code", alias = "repository_code")]
1098    pub repository_code: String,
1099    /// Project landing-page URL (`url:`). Optional.
1100    #[serde(default)]
1101    pub url: Option<String>,
1102    /// SPDX license identifier (`license:`). When omitted, the renderer falls
1103    /// back to `Cargo.toml [workspace.package].license`.
1104    #[serde(default)]
1105    pub license: Option<String>,
1106    /// Release date in `YYYY-MM-DD` form (`date-released:`). Optional.
1107    #[serde(default, rename = "date-released", alias = "date_released")]
1108    pub date_released: Option<String>,
1109    /// Persistent DOI for the cited release (`doi:`). Optional.
1110    #[serde(default)]
1111    pub doi: Option<String>,
1112}
1113
1114fn default_citation_message() -> String {
1115    "If you use this software, please cite it using the metadata below.".to_string()
1116}