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 --no-install-project --no-install-workspace""#;
570        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
571        assert_eq!(
572            cfg.install.unwrap().commands(),
573            vec!["uv sync --no-install-project --no-install-workspace"]
574        );
575    }
576
577    #[test]
578    fn setup_config_array_commands() {
579        let toml_str = r#"install = ["step1", "step2"]"#;
580        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
581        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
582    }
583
584    #[test]
585    fn setup_config_all_optional() {
586        let toml_str = "";
587        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
588        assert!(cfg.install.is_none());
589    }
590
591    #[test]
592    fn clean_config_single_string() {
593        let toml_str = r#"clean = "rm -rf dist""#;
594        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
595        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
596    }
597
598    #[test]
599    fn clean_config_array_commands() {
600        let toml_str = r#"clean = ["step1", "step2"]"#;
601        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
602        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
603    }
604
605    #[test]
606    fn clean_config_all_optional() {
607        let toml_str = "";
608        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
609        assert!(cfg.clean.is_none());
610    }
611
612    #[test]
613    fn build_command_config_single_strings() {
614        let toml_str = r#"
615build = "cargo build"
616build_release = "cargo build --release"
617"#;
618        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
619        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
620        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
621    }
622
623    #[test]
624    fn build_command_config_array_commands() {
625        let toml_str = r#"
626build = ["step1", "step2"]
627build_release = ["step1 --release", "step2 --release"]
628"#;
629        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
630        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
631        assert_eq!(
632            cfg.build_release.unwrap().commands(),
633            vec!["step1 --release", "step2 --release"]
634        );
635    }
636
637    #[test]
638    fn build_command_config_all_optional() {
639        let toml_str = "";
640        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
641        assert!(cfg.build.is_none());
642        assert!(cfg.build_release.is_none());
643    }
644
645    #[test]
646    fn test_config_backward_compat_string() {
647        let toml_str = r#"command = "pytest""#;
648        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
649        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
650        assert!(cfg.e2e.is_none());
651        assert!(cfg.coverage.is_none());
652    }
653
654    #[test]
655    fn test_config_array_command() {
656        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
657        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
658        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
659    }
660
661    #[test]
662    fn test_config_with_coverage() {
663        let toml_str = r#"
664command = "pytest"
665coverage = "pytest --cov=. --cov-report=term-missing"
666"#;
667        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
668        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
669        assert_eq!(
670            cfg.coverage.unwrap().commands(),
671            vec!["pytest --cov=. --cov-report=term-missing"]
672        );
673        assert!(cfg.e2e.is_none());
674    }
675
676    #[test]
677    fn test_config_all_optional() {
678        let toml_str = "";
679        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
680        assert!(cfg.command.is_none());
681        assert!(cfg.e2e.is_none());
682        assert!(cfg.coverage.is_none());
683    }
684
685    #[test]
686    fn full_alef_toml_with_lint_and_update() {
687        // Parse via WorkspaceConfig (new schema) — lint/update maps live there.
688        let toml_str = r#"
689languages = ["python", "node"]
690
691[lint.python]
692format = "ruff format ."
693check = "ruff check --fix ."
694
695[lint.node]
696format = ["npx oxfmt", "npx oxlint --fix"]
697
698[update.python]
699update = "uv sync --upgrade"
700upgrade = "uv sync --all-packages --all-extras --upgrade"
701
702[update.node]
703update = "pnpm up -r"
704upgrade = ["corepack up", "pnpm up --latest -r -w"]
705"#;
706        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
707        assert!(cfg.lint.contains_key("python"));
708        assert!(cfg.lint.contains_key("node"));
709
710        let py_lint = cfg.lint.get("python").unwrap();
711        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
712
713        let node_lint = cfg.lint.get("node").unwrap();
714        assert_eq!(
715            node_lint.format.as_ref().unwrap().commands(),
716            vec!["npx oxfmt", "npx oxlint --fix"]
717        );
718
719        assert!(cfg.update.contains_key("python"));
720        assert!(cfg.update.contains_key("node"));
721
722        let node_update = cfg.update.get("node").unwrap();
723        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
724        assert_eq!(
725            node_update.upgrade.as_ref().unwrap().commands(),
726            vec!["corepack up", "pnpm up --latest -r -w"]
727        );
728    }
729
730    #[test]
731    fn lint_config_with_precondition_and_before() {
732        let toml_str = r#"
733precondition = "test -f target/release/libfoo.so"
734before = "cargo build --release -p foo-ffi"
735format = "gofmt -w packages/go"
736check = "golangci-lint run ./..."
737"#;
738        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
739        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
740        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
741        assert!(cfg.format.is_some());
742        assert!(cfg.check.is_some());
743    }
744
745    #[test]
746    fn test_config_with_before_list() {
747        let toml_str = r#"
748before = ["cd packages/python && maturin develop", "echo ready"]
749command = "pytest"
750"#;
751        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
752        assert!(cfg.precondition.is_none());
753        assert_eq!(
754            cfg.before.unwrap().commands(),
755            vec!["cd packages/python && maturin develop", "echo ready"]
756        );
757        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
758    }
759
760    #[test]
761    fn setup_config_with_precondition() {
762        let toml_str = r#"
763precondition = "which rustup"
764install = "rustup update"
765"#;
766        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
767        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
768        assert!(cfg.before.is_none());
769        assert!(cfg.install.is_some());
770    }
771
772    #[test]
773    fn build_command_config_with_before() {
774        let toml_str = r#"
775before = "cargo build --release -p my-lib-ffi"
776build = "cd packages/go && go build ./..."
777"#;
778        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
779        assert!(cfg.precondition.is_none());
780        assert_eq!(
781            cfg.before.unwrap().commands(),
782            vec!["cargo build --release -p my-lib-ffi"]
783        );
784        assert!(cfg.build.is_some());
785    }
786
787    #[test]
788    fn clean_config_precondition_and_before_optional() {
789        let toml_str = r#"clean = "cargo clean""#;
790        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
791        assert!(cfg.precondition.is_none());
792        assert!(cfg.before.is_none());
793        assert!(cfg.clean.is_some());
794    }
795
796    #[test]
797    fn update_config_with_precondition() {
798        let toml_str = r#"
799precondition = "test -f Cargo.lock"
800update = "cargo update"
801"#;
802        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
803        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
804        assert!(cfg.before.is_none());
805        assert!(cfg.update.is_some());
806    }
807
808    #[test]
809    fn full_alef_toml_with_precondition_and_before_across_sections() {
810        // Parse via WorkspaceConfig (new schema).
811        let toml_str = r#"
812languages = ["go", "python"]
813
814[lint.go]
815precondition = "test -f target/release/libmylib_ffi.so"
816before = "cargo build --release -p mylib-ffi"
817format = "gofmt -w packages/go"
818check = "golangci-lint run ./..."
819
820[lint.python]
821format = "ruff format packages/python"
822check = "ruff check --fix packages/python"
823
824[test.go]
825precondition = "test -f target/release/libmylib_ffi.so"
826before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
827command = "cd packages/go && go test ./..."
828
829[test.python]
830command = "cd packages/python && uv run --no-sync pytest"
831
832[build_commands.go]
833precondition = "which go"
834before = "cargo build --release -p mylib-ffi"
835build = "cd packages/go && go build ./..."
836build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
837
838[update.go]
839precondition = "test -d packages/go"
840update = "cd packages/go && go get -u ./..."
841
842[setup.python]
843precondition = "which uv"
844install = "cd packages/python && uv sync --no-install-project --no-install-workspace"
845
846[clean.go]
847before = "echo cleaning go"
848clean = "cd packages/go && go clean -cache"
849"#;
850        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
851
852        // lint.go: precondition and before set
853        let go_lint = cfg.lint.get("go").unwrap();
854        assert_eq!(
855            go_lint.precondition.as_deref(),
856            Some("test -f target/release/libmylib_ffi.so"),
857            "lint.go precondition should be preserved"
858        );
859        assert_eq!(
860            go_lint.before.as_ref().unwrap().commands(),
861            vec!["cargo build --release -p mylib-ffi"],
862            "lint.go before should be preserved"
863        );
864        assert!(go_lint.format.is_some());
865        assert!(go_lint.check.is_some());
866
867        // lint.python: no precondition or before
868        let py_lint = cfg.lint.get("python").unwrap();
869        assert!(
870            py_lint.precondition.is_none(),
871            "lint.python should have no precondition"
872        );
873        assert!(py_lint.before.is_none(), "lint.python should have no before");
874
875        // test.go: precondition and multi-command before
876        let go_test = cfg.test.get("go").unwrap();
877        assert_eq!(
878            go_test.precondition.as_deref(),
879            Some("test -f target/release/libmylib_ffi.so"),
880            "test.go precondition should be preserved"
881        );
882        assert_eq!(
883            go_test.before.as_ref().unwrap().commands(),
884            vec![
885                "cargo build --release -p mylib-ffi",
886                "cp target/release/libmylib_ffi.so packages/go/"
887            ],
888            "test.go before list should be preserved"
889        );
890
891        // build_commands.go: precondition and before
892        let go_build = cfg.build_commands.get("go").unwrap();
893        assert_eq!(
894            go_build.precondition.as_deref(),
895            Some("which go"),
896            "build_commands.go precondition should be preserved"
897        );
898        assert_eq!(
899            go_build.before.as_ref().unwrap().commands(),
900            vec!["cargo build --release -p mylib-ffi"],
901            "build_commands.go before should be preserved"
902        );
903
904        // update.go: precondition only, no before
905        let go_update = cfg.update.get("go").unwrap();
906        assert_eq!(
907            go_update.precondition.as_deref(),
908            Some("test -d packages/go"),
909            "update.go precondition should be preserved"
910        );
911        assert!(go_update.before.is_none(), "update.go before should be None");
912
913        // setup.python: precondition only
914        let py_setup = cfg.setup.get("python").unwrap();
915        assert_eq!(
916            py_setup.precondition.as_deref(),
917            Some("which uv"),
918            "setup.python precondition should be preserved"
919        );
920        assert!(py_setup.before.is_none(), "setup.python before should be None");
921
922        // clean.go: before only, no precondition
923        let go_clean = cfg.clean.get("go").unwrap();
924        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
925        assert_eq!(
926            go_clean.before.as_ref().unwrap().commands(),
927            vec!["echo cleaning go"],
928            "clean.go before should be preserved"
929        );
930    }
931
932    #[test]
933    fn output_template_resolves_explicit_entry() {
934        let tmpl = OutputTemplate {
935            python: Some("crates/{crate}-py/src/".to_string()),
936            ..Default::default()
937        };
938        assert_eq!(
939            tmpl.resolve("spikard", "python", true),
940            PathBuf::from("crates/spikard-py/src/")
941        );
942    }
943
944    #[test]
945    fn output_template_substitutes_lang_and_crate() {
946        let tmpl = OutputTemplate {
947            go: Some("packages/{lang}/{crate}/".to_string()),
948            ..Default::default()
949        };
950        assert_eq!(
951            tmpl.resolve("spikard-runtime", "go", true),
952            PathBuf::from("packages/go/spikard-runtime/")
953        );
954    }
955
956    #[test]
957    fn output_template_falls_back_to_multi_crate_default() {
958        let tmpl = OutputTemplate::default();
959        assert_eq!(
960            tmpl.resolve("spikard-runtime", "python", true),
961            PathBuf::from("packages/python/spikard-runtime")
962        );
963    }
964
965    #[test]
966    fn output_template_falls_back_to_single_crate_historical_default() {
967        let tmpl = OutputTemplate::default();
968        assert_eq!(
969            tmpl.resolve("spikard", "python", false),
970            PathBuf::from("packages/python")
971        );
972        assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
973        assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
974        assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
975        assert_eq!(
976            tmpl.resolve("spikard", "elixir", false),
977            PathBuf::from("packages/elixir")
978        );
979    }
980
981    #[test]
982    fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
983        let tmpl = OutputTemplate::default();
984        assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
985        assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
986    }
987
988    #[test]
989    fn output_template_deserializes_from_toml() {
990        let toml_str = r#"
991python = "packages/python/{crate}/"
992go     = "packages/go/{crate}/"
993"#;
994        let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
995        assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
996        assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
997        assert!(tmpl.node.is_none());
998    }
999
1000    #[test]
1001    #[should_panic(expected = "path separators are not allowed")]
1002    fn resolve_rejects_crate_name_with_path_separator() {
1003        let tmpl = OutputTemplate::default();
1004        tmpl.resolve("../foo", "python", false);
1005    }
1006
1007    #[test]
1008    #[should_panic(expected = "path separators are not allowed")]
1009    fn resolve_rejects_crate_name_with_backslash() {
1010        let tmpl = OutputTemplate::default();
1011        tmpl.resolve("..\\foo", "python", false);
1012    }
1013
1014    #[test]
1015    #[should_panic(expected = "NUL byte is not allowed")]
1016    fn resolve_rejects_crate_name_with_nul_byte() {
1017        let tmpl = OutputTemplate::default();
1018        tmpl.resolve("foo\0bar", "python", false);
1019    }
1020
1021    #[test]
1022    #[should_panic(expected = "would escape the project root")]
1023    fn resolve_rejects_template_that_produces_parent_dir() {
1024        // A malicious template that uses .. directly.
1025        let tmpl = OutputTemplate {
1026            python: Some("../../etc/{crate}".to_string()),
1027            ..Default::default()
1028        };
1029        tmpl.resolve("mylib", "python", false);
1030    }
1031
1032    #[test]
1033    fn resolve_accepts_normal_crate_name() {
1034        let tmpl = OutputTemplate::default();
1035        let path = tmpl.resolve("my-lib", "python", false);
1036        assert_eq!(path, PathBuf::from("packages/python"));
1037    }
1038}
1039
1040/// Configuration for the `sync-versions` command.
1041#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1042pub struct SyncConfig {
1043    /// Extra file paths to update version in (glob patterns).
1044    #[serde(default)]
1045    pub extra_paths: Vec<String>,
1046    /// Arbitrary text replacements applied during version sync.
1047    #[serde(default)]
1048    pub text_replacements: Vec<TextReplacement>,
1049}
1050
1051/// A single author entry in a `CITATION.cff` file. Per the Citation File Format
1052/// schema, each entry is either a person (uses `family_names` + `given_names`)
1053/// or a legal entity (uses `name`). Validation lives in the renderer rather
1054/// than in serde because the choice is mutually exclusive.
1055#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1056#[serde(deny_unknown_fields)]
1057pub struct CitationAuthor {
1058    /// Person author: family name(s).
1059    #[serde(default, alias = "family-names")]
1060    pub family_names: Option<String>,
1061    /// Person author: given name(s).
1062    #[serde(default, alias = "given-names")]
1063    pub given_names: Option<String>,
1064    /// Entity author: organisation or legal-entity name.
1065    #[serde(default)]
1066    pub name: Option<String>,
1067    /// Optional contact email (applies to either person or entity).
1068    #[serde(default)]
1069    pub email: Option<String>,
1070    /// Optional ORCID iD URL (`https://orcid.org/0000-0000-0000-0000`).
1071    #[serde(default)]
1072    pub orcid: Option<String>,
1073}
1074
1075/// Configuration for the alef-generated `CITATION.cff` file at the repo root.
1076///
1077/// When this section is present in `alef.toml`, `alef sync-versions` writes a
1078/// fully-rendered Citation File Format YAML using these fields plus the current
1079/// workspace version (read from `Cargo.toml`). When absent, alef falls back to
1080/// updating the `version:` line of a hand-authored CITATION.cff in place.
1081///
1082/// All field names follow Rust convention; the renderer emits the canonical
1083/// CFF kebab-case keys (`cff-version`, `repository-code`, `date-released`,
1084/// `family-names`, `given-names`).
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1086#[serde(deny_unknown_fields)]
1087pub struct CitationConfig {
1088    /// Software title (`title:`). Required.
1089    pub title: String,
1090    /// One-paragraph summary (`abstract:`). Required.
1091    #[serde(rename = "abstract")]
1092    pub abstract_: String,
1093    /// Authors list — at least one entry required. Persons and legal entities
1094    /// can be mixed (e.g. `Na'aman Hirschfeld` + `Kreuzberg, Inc.`).
1095    pub authors: Vec<CitationAuthor>,
1096    /// Canonical citation message shown to consumers (`message:`).
1097    #[serde(default = "default_citation_message")]
1098    pub message: String,
1099    /// Source-code repository URL (`repository-code:`). Required.
1100    #[serde(rename = "repository-code", alias = "repository_code")]
1101    pub repository_code: String,
1102    /// Project landing-page URL (`url:`). Optional.
1103    #[serde(default)]
1104    pub url: Option<String>,
1105    /// SPDX license identifier (`license:`). When omitted, the renderer falls
1106    /// back to `Cargo.toml [workspace.package].license`.
1107    #[serde(default)]
1108    pub license: Option<String>,
1109    /// Release date in `YYYY-MM-DD` form (`date-released:`). Optional.
1110    #[serde(default, rename = "date-released", alias = "date_released")]
1111    pub date_released: Option<String>,
1112    /// Persistent DOI for the cited release (`doi:`). Optional.
1113    #[serde(default)]
1114    pub doi: Option<String>,
1115}
1116
1117fn default_citation_message() -> String {
1118    "If you use this software, please cite it using the metadata below.".to_string()
1119}