Skip to main content

alef_core/config/
output.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as JsonValue;
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct ExcludeConfig {
8    #[serde(default)]
9    pub types: Vec<String>,
10    #[serde(default)]
11    pub functions: Vec<String>,
12    /// Exclude specific methods: "TypeName.method_name"
13    #[serde(default)]
14    pub methods: Vec<String>,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct IncludeConfig {
19    #[serde(default)]
20    pub types: Vec<String>,
21    #[serde(default)]
22    pub functions: Vec<String>,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct OutputConfig {
27    pub python: Option<PathBuf>,
28    pub node: Option<PathBuf>,
29    pub ruby: Option<PathBuf>,
30    pub php: Option<PathBuf>,
31    pub elixir: Option<PathBuf>,
32    pub wasm: Option<PathBuf>,
33    pub ffi: Option<PathBuf>,
34    pub gleam: Option<PathBuf>,
35    pub go: Option<PathBuf>,
36    pub java: Option<PathBuf>,
37    pub kotlin: Option<PathBuf>,
38    pub dart: Option<PathBuf>,
39    pub swift: Option<PathBuf>,
40    pub csharp: Option<PathBuf>,
41    pub r: Option<PathBuf>,
42    pub zig: Option<PathBuf>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ScaffoldConfig {
47    pub description: Option<String>,
48    pub license: Option<String>,
49    pub repository: Option<String>,
50    pub homepage: Option<String>,
51    #[serde(default)]
52    pub authors: Vec<String>,
53    #[serde(default)]
54    pub keywords: Vec<String>,
55    /// Opt-in workspace `.cargo/config.toml` management. When present, alef writes
56    /// the full file with hash-based drift detection. Absent = legacy behavior
57    /// (wasm32 block only, create-if-missing, unmanaged).
58    pub cargo: Option<ScaffoldCargo>,
59}
60
61/// Opt-in management of workspace-level `.cargo/config.toml`.
62///
63/// All fields default to canonical values that produce the same `.cargo/config.toml`
64/// across polyglot repos. Override individual targets via `targets`, or inject
65/// repo-specific `[env]` entries via `env`.
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67pub struct ScaffoldCargo {
68    /// Per-target cross-compile / rustflags overrides. Defaults emit the canonical
69    /// 6-target template (macOS dynamic_lookup, Windows MSVC rust-lld x64+i686,
70    /// aarch64-linux-gnu cross-gcc, x86_64-linux-musl, wasm32 bulk-memory).
71    #[serde(default)]
72    pub targets: ScaffoldCargoTargets,
73    /// Free-form `[env]` entries copied verbatim into the generated file.
74    /// Values can be a plain string or `{ value, relative }`. Empty by default.
75    #[serde(default)]
76    pub env: HashMap<String, ScaffoldCargoEnvValue>,
77}
78
79/// Per-target opt-out flags. All default to `true`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ScaffoldCargoTargets {
82    #[serde(default = "default_true")]
83    pub macos_dynamic_lookup: bool,
84    #[serde(default = "default_true")]
85    pub x86_64_pc_windows_msvc: bool,
86    #[serde(default = "default_true")]
87    pub i686_pc_windows_msvc: bool,
88    #[serde(default = "default_true")]
89    pub aarch64_unknown_linux_gnu: bool,
90    #[serde(default = "default_true")]
91    pub x86_64_unknown_linux_musl: bool,
92    #[serde(default = "default_true")]
93    pub wasm32_unknown_unknown: bool,
94}
95
96impl Default for ScaffoldCargoTargets {
97    fn default() -> Self {
98        Self {
99            macos_dynamic_lookup: true,
100            x86_64_pc_windows_msvc: true,
101            i686_pc_windows_msvc: true,
102            aarch64_unknown_linux_gnu: true,
103            x86_64_unknown_linux_musl: true,
104            wasm32_unknown_unknown: true,
105        }
106    }
107}
108
109fn default_true() -> bool {
110    true
111}
112
113/// Value for a `[scaffold.cargo.env]` entry. Either a bare string (renders as
114/// `KEY = "value"`) or a structured form with `value` + optional `relative`.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(untagged)]
117pub enum ScaffoldCargoEnvValue {
118    Plain(String),
119    Structured {
120        value: String,
121        #[serde(default)]
122        relative: bool,
123    },
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ReadmeConfig {
128    pub template_dir: Option<PathBuf>,
129    pub snippets_dir: Option<PathBuf>,
130    /// Deprecated: path to an external YAML config file. Prefer inline fields below.
131    pub config: Option<PathBuf>,
132    pub output_pattern: Option<String>,
133    /// Discord invite URL used in README templates.
134    pub discord_url: Option<String>,
135    /// Banner image URL used in README templates.
136    pub banner_url: Option<String>,
137    /// Per-language README configuration, keyed by language code
138    /// (e.g. "python", "typescript", "ruby"). Values are flexible JSON objects
139    /// that map directly to minijinja template context variables.
140    #[serde(default)]
141    pub languages: HashMap<String, JsonValue>,
142}
143
144/// A value that can be either a single string or a list of strings.
145///
146/// Deserializes from both `"cmd"` and `["cmd1", "cmd2"]` in TOML/JSON.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(untagged)]
149pub enum StringOrVec {
150    Single(String),
151    Multiple(Vec<String>),
152}
153
154impl StringOrVec {
155    /// Return all commands as a slice-like iterator.
156    pub fn commands(&self) -> Vec<&str> {
157        match self {
158            StringOrVec::Single(s) => vec![s.as_str()],
159            StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
160        }
161    }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct LintConfig {
166    /// Shell command that must exit 0 for lint to run; skip with warning on failure.
167    pub precondition: Option<String>,
168    /// Command(s) to run before the main lint commands; aborts on failure.
169    pub before: Option<StringOrVec>,
170    pub format: Option<StringOrVec>,
171    pub check: Option<StringOrVec>,
172    pub typecheck: Option<StringOrVec>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct UpdateConfig {
177    /// Shell command that must exit 0 for update to run; skip with warning on failure.
178    pub precondition: Option<String>,
179    /// Command(s) to run before the main update commands; aborts on failure.
180    pub before: Option<StringOrVec>,
181    /// Command(s) for safe dependency updates (compatible versions only).
182    pub update: Option<StringOrVec>,
183    /// Command(s) for aggressive updates (including incompatible/major bumps).
184    pub upgrade: Option<StringOrVec>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
188pub struct TestConfig {
189    /// Shell command that must exit 0 for test to run; skip with warning on failure.
190    pub precondition: Option<String>,
191    /// Command(s) to run before the main test commands; aborts on failure.
192    pub before: Option<StringOrVec>,
193    /// Command to run unit/integration tests for this language.
194    pub command: Option<StringOrVec>,
195    /// Command to run e2e tests for this language.
196    pub e2e: Option<StringOrVec>,
197    /// Command to run tests with coverage for this language.
198    pub coverage: Option<StringOrVec>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202pub struct SetupConfig {
203    /// Shell command that must exit 0 for setup to run; skip with warning on failure.
204    pub precondition: Option<String>,
205    /// Command(s) to run before the main setup commands; aborts on failure.
206    pub before: Option<StringOrVec>,
207    /// Command(s) to install dependencies for this language.
208    pub install: Option<StringOrVec>,
209    /// Timeout in seconds for the complete setup (precondition + before + install).
210    #[serde(default = "default_setup_timeout")]
211    pub timeout_seconds: u64,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215pub struct CleanConfig {
216    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
217    pub precondition: Option<String>,
218    /// Command(s) to run before the main clean commands; aborts on failure.
219    pub before: Option<StringOrVec>,
220    /// Command(s) to clean build artifacts for this language.
221    pub clean: Option<StringOrVec>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct BuildCommandConfig {
226    /// Shell command that must exit 0 for build to run; skip with warning on failure.
227    pub precondition: Option<String>,
228    /// Command(s) to run before the main build commands; aborts on failure.
229    pub before: Option<StringOrVec>,
230    /// Command(s) to build in debug mode.
231    pub build: Option<StringOrVec>,
232    /// Command(s) to build in release mode.
233    pub build_release: Option<StringOrVec>,
234}
235
236fn default_setup_timeout() -> u64 {
237    600
238}
239
240/// A single text replacement rule for version sync.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct TextReplacement {
243    /// Glob pattern for files to process.
244    pub path: String,
245    /// Regex pattern to search for (may contain `{version}` placeholder).
246    pub search: String,
247    /// Replacement string (may contain `{version}` placeholder).
248    pub replace: String,
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn string_or_vec_single_from_toml() {
257        let toml_str = r#"format = "ruff format""#;
258        #[derive(Deserialize)]
259        struct T {
260            format: StringOrVec,
261        }
262        let t: T = toml::from_str(toml_str).unwrap();
263        assert_eq!(t.format.commands(), vec!["ruff format"]);
264    }
265
266    #[test]
267    fn string_or_vec_multiple_from_toml() {
268        let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
269        #[derive(Deserialize)]
270        struct T {
271            format: StringOrVec,
272        }
273        let t: T = toml::from_str(toml_str).unwrap();
274        assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
275    }
276
277    #[test]
278    fn lint_config_backward_compat_string() {
279        let toml_str = r#"
280format = "ruff format ."
281check = "ruff check ."
282typecheck = "mypy ."
283"#;
284        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
285        assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
286        assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
287        assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
288    }
289
290    #[test]
291    fn lint_config_array_commands() {
292        let toml_str = r#"
293format = ["cmd1", "cmd2"]
294check = "single-check"
295"#;
296        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
297        assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
298        assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
299        assert!(cfg.typecheck.is_none());
300    }
301
302    #[test]
303    fn lint_config_all_optional() {
304        let toml_str = "";
305        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
306        assert!(cfg.format.is_none());
307        assert!(cfg.check.is_none());
308        assert!(cfg.typecheck.is_none());
309    }
310
311    #[test]
312    fn update_config_from_toml() {
313        let toml_str = r#"
314update = "cargo update"
315upgrade = ["cargo upgrade --incompatible", "cargo update"]
316"#;
317        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
318        assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
319        assert_eq!(
320            cfg.upgrade.unwrap().commands(),
321            vec!["cargo upgrade --incompatible", "cargo update"]
322        );
323    }
324
325    #[test]
326    fn update_config_all_optional() {
327        let toml_str = "";
328        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
329        assert!(cfg.update.is_none());
330        assert!(cfg.upgrade.is_none());
331    }
332
333    #[test]
334    fn string_or_vec_empty_array_from_toml() {
335        let toml_str = "format = []";
336        #[derive(Deserialize)]
337        struct T {
338            format: StringOrVec,
339        }
340        let t: T = toml::from_str(toml_str).unwrap();
341        assert!(matches!(t.format, StringOrVec::Multiple(_)));
342        assert!(t.format.commands().is_empty());
343    }
344
345    #[test]
346    fn string_or_vec_single_element_array_from_toml() {
347        let toml_str = r#"format = ["cmd"]"#;
348        #[derive(Deserialize)]
349        struct T {
350            format: StringOrVec,
351        }
352        let t: T = toml::from_str(toml_str).unwrap();
353        assert_eq!(t.format.commands(), vec!["cmd"]);
354    }
355
356    #[test]
357    fn setup_config_single_string() {
358        let toml_str = r#"install = "uv sync""#;
359        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
360        assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
361    }
362
363    #[test]
364    fn setup_config_array_commands() {
365        let toml_str = r#"install = ["step1", "step2"]"#;
366        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
367        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
368    }
369
370    #[test]
371    fn setup_config_all_optional() {
372        let toml_str = "";
373        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
374        assert!(cfg.install.is_none());
375    }
376
377    #[test]
378    fn clean_config_single_string() {
379        let toml_str = r#"clean = "rm -rf dist""#;
380        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
381        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
382    }
383
384    #[test]
385    fn clean_config_array_commands() {
386        let toml_str = r#"clean = ["step1", "step2"]"#;
387        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
388        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
389    }
390
391    #[test]
392    fn clean_config_all_optional() {
393        let toml_str = "";
394        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
395        assert!(cfg.clean.is_none());
396    }
397
398    #[test]
399    fn build_command_config_single_strings() {
400        let toml_str = r#"
401build = "cargo build"
402build_release = "cargo build --release"
403"#;
404        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
405        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
406        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
407    }
408
409    #[test]
410    fn build_command_config_array_commands() {
411        let toml_str = r#"
412build = ["step1", "step2"]
413build_release = ["step1 --release", "step2 --release"]
414"#;
415        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
416        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
417        assert_eq!(
418            cfg.build_release.unwrap().commands(),
419            vec!["step1 --release", "step2 --release"]
420        );
421    }
422
423    #[test]
424    fn build_command_config_all_optional() {
425        let toml_str = "";
426        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
427        assert!(cfg.build.is_none());
428        assert!(cfg.build_release.is_none());
429    }
430
431    #[test]
432    fn test_config_backward_compat_string() {
433        let toml_str = r#"command = "pytest""#;
434        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
435        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
436        assert!(cfg.e2e.is_none());
437        assert!(cfg.coverage.is_none());
438    }
439
440    #[test]
441    fn test_config_array_command() {
442        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
443        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
444        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
445    }
446
447    #[test]
448    fn test_config_with_coverage() {
449        let toml_str = r#"
450command = "pytest"
451coverage = "pytest --cov=. --cov-report=term-missing"
452"#;
453        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
454        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
455        assert_eq!(
456            cfg.coverage.unwrap().commands(),
457            vec!["pytest --cov=. --cov-report=term-missing"]
458        );
459        assert!(cfg.e2e.is_none());
460    }
461
462    #[test]
463    fn test_config_all_optional() {
464        let toml_str = "";
465        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
466        assert!(cfg.command.is_none());
467        assert!(cfg.e2e.is_none());
468        assert!(cfg.coverage.is_none());
469    }
470
471    #[test]
472    fn full_alef_toml_with_lint_and_update() {
473        let toml_str = r#"
474languages = ["python", "node"]
475
476[crate]
477name = "test"
478sources = ["src/lib.rs"]
479
480[lint.python]
481format = "ruff format ."
482check = "ruff check --fix ."
483
484[lint.node]
485format = ["npx oxfmt", "npx oxlint --fix"]
486
487[update.python]
488update = "uv sync --upgrade"
489upgrade = "uv sync --all-packages --all-extras --upgrade"
490
491[update.node]
492update = "pnpm up -r"
493upgrade = ["corepack up", "pnpm up --latest -r -w"]
494"#;
495        let cfg: super::super::AlefConfig = toml::from_str(toml_str).unwrap();
496        let lint_map = cfg.lint.as_ref().unwrap();
497        assert!(lint_map.contains_key("python"));
498        assert!(lint_map.contains_key("node"));
499
500        let py_lint = lint_map.get("python").unwrap();
501        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
502
503        let node_lint = lint_map.get("node").unwrap();
504        assert_eq!(
505            node_lint.format.as_ref().unwrap().commands(),
506            vec!["npx oxfmt", "npx oxlint --fix"]
507        );
508
509        let update_map = cfg.update.as_ref().unwrap();
510        assert!(update_map.contains_key("python"));
511        assert!(update_map.contains_key("node"));
512
513        let node_update = update_map.get("node").unwrap();
514        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
515        assert_eq!(
516            node_update.upgrade.as_ref().unwrap().commands(),
517            vec!["corepack up", "pnpm up --latest -r -w"]
518        );
519    }
520
521    #[test]
522    fn lint_config_with_precondition_and_before() {
523        let toml_str = r#"
524precondition = "test -f target/release/libfoo.so"
525before = "cargo build --release -p foo-ffi"
526format = "gofmt -w packages/go"
527check = "golangci-lint run ./..."
528"#;
529        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
530        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
531        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
532        assert!(cfg.format.is_some());
533        assert!(cfg.check.is_some());
534    }
535
536    #[test]
537    fn test_config_with_before_list() {
538        let toml_str = r#"
539before = ["cd packages/python && maturin develop", "echo ready"]
540command = "pytest"
541"#;
542        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
543        assert!(cfg.precondition.is_none());
544        assert_eq!(
545            cfg.before.unwrap().commands(),
546            vec!["cd packages/python && maturin develop", "echo ready"]
547        );
548        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
549    }
550
551    #[test]
552    fn setup_config_with_precondition() {
553        let toml_str = r#"
554precondition = "which rustup"
555install = "rustup update"
556"#;
557        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
558        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
559        assert!(cfg.before.is_none());
560        assert!(cfg.install.is_some());
561    }
562
563    #[test]
564    fn build_command_config_with_before() {
565        let toml_str = r#"
566before = "cargo build --release -p my-lib-ffi"
567build = "cd packages/go && go build ./..."
568"#;
569        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
570        assert!(cfg.precondition.is_none());
571        assert_eq!(
572            cfg.before.unwrap().commands(),
573            vec!["cargo build --release -p my-lib-ffi"]
574        );
575        assert!(cfg.build.is_some());
576    }
577
578    #[test]
579    fn clean_config_precondition_and_before_optional() {
580        let toml_str = r#"clean = "cargo clean""#;
581        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
582        assert!(cfg.precondition.is_none());
583        assert!(cfg.before.is_none());
584        assert!(cfg.clean.is_some());
585    }
586
587    #[test]
588    fn update_config_with_precondition() {
589        let toml_str = r#"
590precondition = "test -f Cargo.lock"
591update = "cargo update"
592"#;
593        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
594        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
595        assert!(cfg.before.is_none());
596        assert!(cfg.update.is_some());
597    }
598
599    #[test]
600    fn full_alef_toml_with_precondition_and_before_across_sections() {
601        let toml_str = r#"
602languages = ["go", "python"]
603
604[crate]
605name = "mylib"
606sources = ["src/lib.rs"]
607
608[lint.go]
609precondition = "test -f target/release/libmylib_ffi.so"
610before = "cargo build --release -p mylib-ffi"
611format = "gofmt -w packages/go"
612check = "golangci-lint run ./..."
613
614[lint.python]
615format = "ruff format packages/python"
616check = "ruff check --fix packages/python"
617
618[test.go]
619precondition = "test -f target/release/libmylib_ffi.so"
620before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
621command = "cd packages/go && go test ./..."
622
623[test.python]
624command = "cd packages/python && uv run pytest"
625
626[build_commands.go]
627precondition = "which go"
628before = "cargo build --release -p mylib-ffi"
629build = "cd packages/go && go build ./..."
630build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
631
632[update.go]
633precondition = "test -d packages/go"
634update = "cd packages/go && go get -u ./..."
635
636[setup.python]
637precondition = "which uv"
638install = "cd packages/python && uv sync"
639
640[clean.go]
641before = "echo cleaning go"
642clean = "cd packages/go && go clean -cache"
643"#;
644        let cfg: super::super::AlefConfig = toml::from_str(toml_str).unwrap();
645
646        // lint.go: precondition and before set
647        let lint_map = cfg.lint.as_ref().unwrap();
648        let go_lint = lint_map.get("go").unwrap();
649        assert_eq!(
650            go_lint.precondition.as_deref(),
651            Some("test -f target/release/libmylib_ffi.so"),
652            "lint.go precondition should be preserved"
653        );
654        assert_eq!(
655            go_lint.before.as_ref().unwrap().commands(),
656            vec!["cargo build --release -p mylib-ffi"],
657            "lint.go before should be preserved"
658        );
659        assert!(go_lint.format.is_some());
660        assert!(go_lint.check.is_some());
661
662        // lint.python: no precondition or before
663        let py_lint = lint_map.get("python").unwrap();
664        assert!(
665            py_lint.precondition.is_none(),
666            "lint.python should have no precondition"
667        );
668        assert!(py_lint.before.is_none(), "lint.python should have no before");
669
670        // test.go: precondition and multi-command before
671        let test_map = cfg.test.as_ref().unwrap();
672        let go_test = test_map.get("go").unwrap();
673        assert_eq!(
674            go_test.precondition.as_deref(),
675            Some("test -f target/release/libmylib_ffi.so"),
676            "test.go precondition should be preserved"
677        );
678        assert_eq!(
679            go_test.before.as_ref().unwrap().commands(),
680            vec![
681                "cargo build --release -p mylib-ffi",
682                "cp target/release/libmylib_ffi.so packages/go/"
683            ],
684            "test.go before list should be preserved"
685        );
686
687        // build_commands.go: precondition and before
688        let build_map = cfg.build_commands.as_ref().unwrap();
689        let go_build = build_map.get("go").unwrap();
690        assert_eq!(
691            go_build.precondition.as_deref(),
692            Some("which go"),
693            "build_commands.go precondition should be preserved"
694        );
695        assert_eq!(
696            go_build.before.as_ref().unwrap().commands(),
697            vec!["cargo build --release -p mylib-ffi"],
698            "build_commands.go before should be preserved"
699        );
700
701        // update.go: precondition only, no before
702        let update_map = cfg.update.as_ref().unwrap();
703        let go_update = update_map.get("go").unwrap();
704        assert_eq!(
705            go_update.precondition.as_deref(),
706            Some("test -d packages/go"),
707            "update.go precondition should be preserved"
708        );
709        assert!(go_update.before.is_none(), "update.go before should be None");
710
711        // setup.python: precondition only
712        let setup_map = cfg.setup.as_ref().unwrap();
713        let py_setup = setup_map.get("python").unwrap();
714        assert_eq!(
715            py_setup.precondition.as_deref(),
716            Some("which uv"),
717            "setup.python precondition should be preserved"
718        );
719        assert!(py_setup.before.is_none(), "setup.python before should be None");
720
721        // clean.go: before only, no precondition
722        let clean_map = cfg.clean.as_ref().unwrap();
723        let go_clean = clean_map.get("go").unwrap();
724        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
725        assert_eq!(
726            go_clean.before.as_ref().unwrap().commands(),
727            vec!["echo cleaning go"],
728            "clean.go before should be preserved"
729        );
730    }
731}
732
733/// Configuration for the `sync-versions` command.
734#[derive(Debug, Clone, Serialize, Deserialize, Default)]
735pub struct SyncConfig {
736    /// Extra file paths to update version in (glob patterns).
737    #[serde(default)]
738    pub extra_paths: Vec<String>,
739    /// Arbitrary text replacements applied during version sync.
740    #[serde(default)]
741    pub text_replacements: Vec<TextReplacement>,
742}