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}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ReadmeConfig {
59    pub template_dir: Option<PathBuf>,
60    pub snippets_dir: Option<PathBuf>,
61    /// Deprecated: path to an external YAML config file. Prefer inline fields below.
62    pub config: Option<PathBuf>,
63    pub output_pattern: Option<String>,
64    /// Discord invite URL used in README templates.
65    pub discord_url: Option<String>,
66    /// Banner image URL used in README templates.
67    pub banner_url: Option<String>,
68    /// Per-language README configuration, keyed by language code
69    /// (e.g. "python", "typescript", "ruby"). Values are flexible JSON objects
70    /// that map directly to minijinja template context variables.
71    #[serde(default)]
72    pub languages: HashMap<String, JsonValue>,
73}
74
75/// A value that can be either a single string or a list of strings.
76///
77/// Deserializes from both `"cmd"` and `["cmd1", "cmd2"]` in TOML/JSON.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(untagged)]
80pub enum StringOrVec {
81    Single(String),
82    Multiple(Vec<String>),
83}
84
85impl StringOrVec {
86    /// Return all commands as a slice-like iterator.
87    pub fn commands(&self) -> Vec<&str> {
88        match self {
89            StringOrVec::Single(s) => vec![s.as_str()],
90            StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
91        }
92    }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct LintConfig {
97    /// Shell command that must exit 0 for lint to run; skip with warning on failure.
98    pub precondition: Option<String>,
99    /// Command(s) to run before the main lint commands; aborts on failure.
100    pub before: Option<StringOrVec>,
101    pub format: Option<StringOrVec>,
102    pub check: Option<StringOrVec>,
103    pub typecheck: Option<StringOrVec>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct UpdateConfig {
108    /// Shell command that must exit 0 for update to run; skip with warning on failure.
109    pub precondition: Option<String>,
110    /// Command(s) to run before the main update commands; aborts on failure.
111    pub before: Option<StringOrVec>,
112    /// Command(s) for safe dependency updates (compatible versions only).
113    pub update: Option<StringOrVec>,
114    /// Command(s) for aggressive updates (including incompatible/major bumps).
115    pub upgrade: Option<StringOrVec>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
119pub struct TestConfig {
120    /// Shell command that must exit 0 for test to run; skip with warning on failure.
121    pub precondition: Option<String>,
122    /// Command(s) to run before the main test commands; aborts on failure.
123    pub before: Option<StringOrVec>,
124    /// Command to run unit/integration tests for this language.
125    pub command: Option<StringOrVec>,
126    /// Command to run e2e tests for this language.
127    pub e2e: Option<StringOrVec>,
128    /// Command to run tests with coverage for this language.
129    pub coverage: Option<StringOrVec>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct SetupConfig {
134    /// Shell command that must exit 0 for setup to run; skip with warning on failure.
135    pub precondition: Option<String>,
136    /// Command(s) to run before the main setup commands; aborts on failure.
137    pub before: Option<StringOrVec>,
138    /// Command(s) to install dependencies for this language.
139    pub install: Option<StringOrVec>,
140    /// Timeout in seconds for the complete setup (precondition + before + install).
141    #[serde(default = "default_setup_timeout")]
142    pub timeout_seconds: u64,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146pub struct CleanConfig {
147    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
148    pub precondition: Option<String>,
149    /// Command(s) to run before the main clean commands; aborts on failure.
150    pub before: Option<StringOrVec>,
151    /// Command(s) to clean build artifacts for this language.
152    pub clean: Option<StringOrVec>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156pub struct BuildCommandConfig {
157    /// Shell command that must exit 0 for build to run; skip with warning on failure.
158    pub precondition: Option<String>,
159    /// Command(s) to run before the main build commands; aborts on failure.
160    pub before: Option<StringOrVec>,
161    /// Command(s) to build in debug mode.
162    pub build: Option<StringOrVec>,
163    /// Command(s) to build in release mode.
164    pub build_release: Option<StringOrVec>,
165}
166
167fn default_setup_timeout() -> u64 {
168    600
169}
170
171/// A single text replacement rule for version sync.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct TextReplacement {
174    /// Glob pattern for files to process.
175    pub path: String,
176    /// Regex pattern to search for (may contain `{version}` placeholder).
177    pub search: String,
178    /// Replacement string (may contain `{version}` placeholder).
179    pub replace: String,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn string_or_vec_single_from_toml() {
188        let toml_str = r#"format = "ruff format""#;
189        #[derive(Deserialize)]
190        struct T {
191            format: StringOrVec,
192        }
193        let t: T = toml::from_str(toml_str).unwrap();
194        assert_eq!(t.format.commands(), vec!["ruff format"]);
195    }
196
197    #[test]
198    fn string_or_vec_multiple_from_toml() {
199        let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
200        #[derive(Deserialize)]
201        struct T {
202            format: StringOrVec,
203        }
204        let t: T = toml::from_str(toml_str).unwrap();
205        assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
206    }
207
208    #[test]
209    fn lint_config_backward_compat_string() {
210        let toml_str = r#"
211format = "ruff format ."
212check = "ruff check ."
213typecheck = "mypy ."
214"#;
215        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
216        assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
217        assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
218        assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
219    }
220
221    #[test]
222    fn lint_config_array_commands() {
223        let toml_str = r#"
224format = ["cmd1", "cmd2"]
225check = "single-check"
226"#;
227        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
228        assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
229        assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
230        assert!(cfg.typecheck.is_none());
231    }
232
233    #[test]
234    fn lint_config_all_optional() {
235        let toml_str = "";
236        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
237        assert!(cfg.format.is_none());
238        assert!(cfg.check.is_none());
239        assert!(cfg.typecheck.is_none());
240    }
241
242    #[test]
243    fn update_config_from_toml() {
244        let toml_str = r#"
245update = "cargo update"
246upgrade = ["cargo upgrade --incompatible", "cargo update"]
247"#;
248        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
249        assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
250        assert_eq!(
251            cfg.upgrade.unwrap().commands(),
252            vec!["cargo upgrade --incompatible", "cargo update"]
253        );
254    }
255
256    #[test]
257    fn update_config_all_optional() {
258        let toml_str = "";
259        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
260        assert!(cfg.update.is_none());
261        assert!(cfg.upgrade.is_none());
262    }
263
264    #[test]
265    fn string_or_vec_empty_array_from_toml() {
266        let toml_str = "format = []";
267        #[derive(Deserialize)]
268        struct T {
269            format: StringOrVec,
270        }
271        let t: T = toml::from_str(toml_str).unwrap();
272        assert!(matches!(t.format, StringOrVec::Multiple(_)));
273        assert!(t.format.commands().is_empty());
274    }
275
276    #[test]
277    fn string_or_vec_single_element_array_from_toml() {
278        let toml_str = r#"format = ["cmd"]"#;
279        #[derive(Deserialize)]
280        struct T {
281            format: StringOrVec,
282        }
283        let t: T = toml::from_str(toml_str).unwrap();
284        assert_eq!(t.format.commands(), vec!["cmd"]);
285    }
286
287    #[test]
288    fn setup_config_single_string() {
289        let toml_str = r#"install = "uv sync""#;
290        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
291        assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
292    }
293
294    #[test]
295    fn setup_config_array_commands() {
296        let toml_str = r#"install = ["step1", "step2"]"#;
297        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
298        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
299    }
300
301    #[test]
302    fn setup_config_all_optional() {
303        let toml_str = "";
304        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
305        assert!(cfg.install.is_none());
306    }
307
308    #[test]
309    fn clean_config_single_string() {
310        let toml_str = r#"clean = "rm -rf dist""#;
311        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
312        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
313    }
314
315    #[test]
316    fn clean_config_array_commands() {
317        let toml_str = r#"clean = ["step1", "step2"]"#;
318        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
319        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
320    }
321
322    #[test]
323    fn clean_config_all_optional() {
324        let toml_str = "";
325        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
326        assert!(cfg.clean.is_none());
327    }
328
329    #[test]
330    fn build_command_config_single_strings() {
331        let toml_str = r#"
332build = "cargo build"
333build_release = "cargo build --release"
334"#;
335        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
336        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
337        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
338    }
339
340    #[test]
341    fn build_command_config_array_commands() {
342        let toml_str = r#"
343build = ["step1", "step2"]
344build_release = ["step1 --release", "step2 --release"]
345"#;
346        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
347        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
348        assert_eq!(
349            cfg.build_release.unwrap().commands(),
350            vec!["step1 --release", "step2 --release"]
351        );
352    }
353
354    #[test]
355    fn build_command_config_all_optional() {
356        let toml_str = "";
357        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
358        assert!(cfg.build.is_none());
359        assert!(cfg.build_release.is_none());
360    }
361
362    #[test]
363    fn test_config_backward_compat_string() {
364        let toml_str = r#"command = "pytest""#;
365        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
366        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
367        assert!(cfg.e2e.is_none());
368        assert!(cfg.coverage.is_none());
369    }
370
371    #[test]
372    fn test_config_array_command() {
373        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
374        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
375        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
376    }
377
378    #[test]
379    fn test_config_with_coverage() {
380        let toml_str = r#"
381command = "pytest"
382coverage = "pytest --cov=. --cov-report=term-missing"
383"#;
384        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
385        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
386        assert_eq!(
387            cfg.coverage.unwrap().commands(),
388            vec!["pytest --cov=. --cov-report=term-missing"]
389        );
390        assert!(cfg.e2e.is_none());
391    }
392
393    #[test]
394    fn test_config_all_optional() {
395        let toml_str = "";
396        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
397        assert!(cfg.command.is_none());
398        assert!(cfg.e2e.is_none());
399        assert!(cfg.coverage.is_none());
400    }
401
402    #[test]
403    fn full_alef_toml_with_lint_and_update() {
404        let toml_str = r#"
405languages = ["python", "node"]
406
407[crate]
408name = "test"
409sources = ["src/lib.rs"]
410
411[lint.python]
412format = "ruff format ."
413check = "ruff check --fix ."
414
415[lint.node]
416format = ["npx oxfmt", "npx oxlint --fix"]
417
418[update.python]
419update = "uv sync --upgrade"
420upgrade = "uv sync --all-packages --all-extras --upgrade"
421
422[update.node]
423update = "pnpm up -r"
424upgrade = ["corepack up", "pnpm up --latest -r -w"]
425"#;
426        let cfg: super::super::AlefConfig = toml::from_str(toml_str).unwrap();
427        let lint_map = cfg.lint.as_ref().unwrap();
428        assert!(lint_map.contains_key("python"));
429        assert!(lint_map.contains_key("node"));
430
431        let py_lint = lint_map.get("python").unwrap();
432        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
433
434        let node_lint = lint_map.get("node").unwrap();
435        assert_eq!(
436            node_lint.format.as_ref().unwrap().commands(),
437            vec!["npx oxfmt", "npx oxlint --fix"]
438        );
439
440        let update_map = cfg.update.as_ref().unwrap();
441        assert!(update_map.contains_key("python"));
442        assert!(update_map.contains_key("node"));
443
444        let node_update = update_map.get("node").unwrap();
445        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
446        assert_eq!(
447            node_update.upgrade.as_ref().unwrap().commands(),
448            vec!["corepack up", "pnpm up --latest -r -w"]
449        );
450    }
451
452    #[test]
453    fn lint_config_with_precondition_and_before() {
454        let toml_str = r#"
455precondition = "test -f target/release/libfoo.so"
456before = "cargo build --release -p foo-ffi"
457format = "gofmt -w packages/go"
458check = "golangci-lint run ./..."
459"#;
460        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
461        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
462        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
463        assert!(cfg.format.is_some());
464        assert!(cfg.check.is_some());
465    }
466
467    #[test]
468    fn test_config_with_before_list() {
469        let toml_str = r#"
470before = ["cd packages/python && maturin develop", "echo ready"]
471command = "pytest"
472"#;
473        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
474        assert!(cfg.precondition.is_none());
475        assert_eq!(
476            cfg.before.unwrap().commands(),
477            vec!["cd packages/python && maturin develop", "echo ready"]
478        );
479        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
480    }
481
482    #[test]
483    fn setup_config_with_precondition() {
484        let toml_str = r#"
485precondition = "which rustup"
486install = "rustup update"
487"#;
488        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
489        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
490        assert!(cfg.before.is_none());
491        assert!(cfg.install.is_some());
492    }
493
494    #[test]
495    fn build_command_config_with_before() {
496        let toml_str = r#"
497before = "cargo build --release -p my-lib-ffi"
498build = "cd packages/go && go build ./..."
499"#;
500        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
501        assert!(cfg.precondition.is_none());
502        assert_eq!(
503            cfg.before.unwrap().commands(),
504            vec!["cargo build --release -p my-lib-ffi"]
505        );
506        assert!(cfg.build.is_some());
507    }
508
509    #[test]
510    fn clean_config_precondition_and_before_optional() {
511        let toml_str = r#"clean = "cargo clean""#;
512        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
513        assert!(cfg.precondition.is_none());
514        assert!(cfg.before.is_none());
515        assert!(cfg.clean.is_some());
516    }
517
518    #[test]
519    fn update_config_with_precondition() {
520        let toml_str = r#"
521precondition = "test -f Cargo.lock"
522update = "cargo update"
523"#;
524        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
525        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
526        assert!(cfg.before.is_none());
527        assert!(cfg.update.is_some());
528    }
529
530    #[test]
531    fn full_alef_toml_with_precondition_and_before_across_sections() {
532        let toml_str = r#"
533languages = ["go", "python"]
534
535[crate]
536name = "mylib"
537sources = ["src/lib.rs"]
538
539[lint.go]
540precondition = "test -f target/release/libmylib_ffi.so"
541before = "cargo build --release -p mylib-ffi"
542format = "gofmt -w packages/go"
543check = "golangci-lint run ./..."
544
545[lint.python]
546format = "ruff format packages/python"
547check = "ruff check --fix packages/python"
548
549[test.go]
550precondition = "test -f target/release/libmylib_ffi.so"
551before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
552command = "cd packages/go && go test ./..."
553
554[test.python]
555command = "cd packages/python && uv run pytest"
556
557[build_commands.go]
558precondition = "which go"
559before = "cargo build --release -p mylib-ffi"
560build = "cd packages/go && go build ./..."
561build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
562
563[update.go]
564precondition = "test -d packages/go"
565update = "cd packages/go && go get -u ./..."
566
567[setup.python]
568precondition = "which uv"
569install = "cd packages/python && uv sync"
570
571[clean.go]
572before = "echo cleaning go"
573clean = "cd packages/go && go clean -cache"
574"#;
575        let cfg: super::super::AlefConfig = toml::from_str(toml_str).unwrap();
576
577        // lint.go: precondition and before set
578        let lint_map = cfg.lint.as_ref().unwrap();
579        let go_lint = lint_map.get("go").unwrap();
580        assert_eq!(
581            go_lint.precondition.as_deref(),
582            Some("test -f target/release/libmylib_ffi.so"),
583            "lint.go precondition should be preserved"
584        );
585        assert_eq!(
586            go_lint.before.as_ref().unwrap().commands(),
587            vec!["cargo build --release -p mylib-ffi"],
588            "lint.go before should be preserved"
589        );
590        assert!(go_lint.format.is_some());
591        assert!(go_lint.check.is_some());
592
593        // lint.python: no precondition or before
594        let py_lint = lint_map.get("python").unwrap();
595        assert!(
596            py_lint.precondition.is_none(),
597            "lint.python should have no precondition"
598        );
599        assert!(py_lint.before.is_none(), "lint.python should have no before");
600
601        // test.go: precondition and multi-command before
602        let test_map = cfg.test.as_ref().unwrap();
603        let go_test = test_map.get("go").unwrap();
604        assert_eq!(
605            go_test.precondition.as_deref(),
606            Some("test -f target/release/libmylib_ffi.so"),
607            "test.go precondition should be preserved"
608        );
609        assert_eq!(
610            go_test.before.as_ref().unwrap().commands(),
611            vec![
612                "cargo build --release -p mylib-ffi",
613                "cp target/release/libmylib_ffi.so packages/go/"
614            ],
615            "test.go before list should be preserved"
616        );
617
618        // build_commands.go: precondition and before
619        let build_map = cfg.build_commands.as_ref().unwrap();
620        let go_build = build_map.get("go").unwrap();
621        assert_eq!(
622            go_build.precondition.as_deref(),
623            Some("which go"),
624            "build_commands.go precondition should be preserved"
625        );
626        assert_eq!(
627            go_build.before.as_ref().unwrap().commands(),
628            vec!["cargo build --release -p mylib-ffi"],
629            "build_commands.go before should be preserved"
630        );
631
632        // update.go: precondition only, no before
633        let update_map = cfg.update.as_ref().unwrap();
634        let go_update = update_map.get("go").unwrap();
635        assert_eq!(
636            go_update.precondition.as_deref(),
637            Some("test -d packages/go"),
638            "update.go precondition should be preserved"
639        );
640        assert!(go_update.before.is_none(), "update.go before should be None");
641
642        // setup.python: precondition only
643        let setup_map = cfg.setup.as_ref().unwrap();
644        let py_setup = setup_map.get("python").unwrap();
645        assert_eq!(
646            py_setup.precondition.as_deref(),
647            Some("which uv"),
648            "setup.python precondition should be preserved"
649        );
650        assert!(py_setup.before.is_none(), "setup.python before should be None");
651
652        // clean.go: before only, no precondition
653        let clean_map = cfg.clean.as_ref().unwrap();
654        let go_clean = clean_map.get("go").unwrap();
655        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
656        assert_eq!(
657            go_clean.before.as_ref().unwrap().commands(),
658            vec!["echo cleaning go"],
659            "clean.go before should be preserved"
660        );
661    }
662}
663
664/// Configuration for the `sync-versions` command.
665#[derive(Debug, Clone, Serialize, Deserialize, Default)]
666pub struct SyncConfig {
667    /// Extra file paths to update version in (glob patterns).
668    #[serde(default)]
669    pub extra_paths: Vec<String>,
670    /// Arbitrary text replacements applied during version sync.
671    #[serde(default)]
672    pub text_replacements: Vec<TextReplacement>,
673}