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 #[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 #[serde(default)]
58 pub generated_header: Option<GeneratedHeaderConfig>,
59 #[serde(default)]
61 pub precommit: Option<PrecommitConfig>,
62 pub cargo: Option<ScaffoldCargo>,
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
69pub struct GeneratedHeaderConfig {
70 #[serde(default)]
72 pub issues_url: Option<String>,
73 #[serde(default)]
75 pub regenerate_command: Option<String>,
76 #[serde(default)]
78 pub verify_command: Option<String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
82pub struct PrecommitConfig {
83 #[serde(default)]
85 pub include_shared_hooks: Option<bool>,
86 #[serde(default)]
88 pub shared_hooks_repo: Option<String>,
89 #[serde(default)]
91 pub shared_hooks_rev: Option<String>,
92 #[serde(default)]
94 pub include_alef_hooks: Option<bool>,
95 #[serde(default)]
97 pub alef_hooks_repo: Option<String>,
98 #[serde(default)]
100 pub alef_hooks_rev: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct ScaffoldCargo {
110 #[serde(default)]
114 pub targets: ScaffoldCargoTargets,
115 #[serde(default)]
118 pub env: HashMap<String, ScaffoldCargoEnvValue>,
119}
120
121#[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#[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 pub config: Option<PathBuf>,
174 pub output_pattern: Option<String>,
175 pub discord_url: Option<String>,
177 pub banner_url: Option<String>,
179 #[serde(default)]
183 pub languages: HashMap<String, JsonValue>,
184}
185
186#[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 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 pub precondition: Option<String>,
210 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 pub precondition: Option<String>,
221 pub before: Option<StringOrVec>,
223 pub update: Option<StringOrVec>,
225 pub upgrade: Option<StringOrVec>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
230pub struct TestConfig {
231 pub precondition: Option<String>,
233 pub before: Option<StringOrVec>,
235 pub command: Option<StringOrVec>,
237 pub e2e: Option<StringOrVec>,
239 pub coverage: Option<StringOrVec>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct SetupConfig {
245 pub precondition: Option<String>,
247 pub before: Option<StringOrVec>,
249 pub install: Option<StringOrVec>,
251 #[serde(default = "default_setup_timeout")]
253 pub timeout_seconds: u64,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257pub struct CleanConfig {
258 pub precondition: Option<String>,
260 pub before: Option<StringOrVec>,
262 pub clean: Option<StringOrVec>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
267pub struct BuildCommandConfig {
268 pub precondition: Option<String>,
270 pub before: Option<StringOrVec>,
272 pub build: Option<StringOrVec>,
274 pub build_release: Option<StringOrVec>,
276}
277
278impl BuildCommandConfig {
279 pub fn merge_overlay(mut self, other: &Self) -> Self {
285 if other.precondition.is_some() {
286 self.precondition = other.precondition.clone();
287 }
288 if other.before.is_some() {
289 self.before = other.before.clone();
290 }
291 if other.build.is_some() {
292 self.build = other.build.clone();
293 }
294 if other.build_release.is_some() {
295 self.build_release = other.build_release.clone();
296 }
297 self
298 }
299}
300
301fn default_setup_timeout() -> u64 {
302 600
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
317pub struct OutputTemplate {
318 pub python: Option<String>,
319 pub node: Option<String>,
320 pub ruby: Option<String>,
321 pub php: Option<String>,
322 pub elixir: Option<String>,
323 pub wasm: Option<String>,
324 pub ffi: Option<String>,
325 pub go: Option<String>,
326 pub java: Option<String>,
327 pub kotlin: Option<String>,
328 pub kotlin_android: Option<String>,
329 pub dart: Option<String>,
330 pub swift: Option<String>,
331 pub gleam: Option<String>,
332 pub csharp: Option<String>,
333 pub r: Option<String>,
334 pub zig: Option<String>,
335}
336
337impl OutputTemplate {
338 pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
354 validate_output_segment(crate_name, "crate_name");
355 validate_output_segment(lang, "lang");
356
357 let path = if let Some(template) = self.entry(lang) {
358 PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
359 } else if multi_crate {
360 PathBuf::from(format!("packages/{lang}/{crate_name}"))
361 } else {
362 match lang {
363 "python" => PathBuf::from("packages/python"),
364 "node" => PathBuf::from("packages/node"),
365 "ruby" => PathBuf::from("packages/ruby"),
366 "php" => PathBuf::from("packages/php"),
367 "elixir" => PathBuf::from("packages/elixir"),
368 other => PathBuf::from(format!("packages/{other}")),
369 }
370 };
371
372 validate_output_path(&path);
373 path
374 }
375
376 pub fn entry(&self, lang: &str) -> Option<&str> {
378 match lang {
379 "python" => self.python.as_deref(),
380 "node" => self.node.as_deref(),
381 "ruby" => self.ruby.as_deref(),
382 "php" => self.php.as_deref(),
383 "elixir" => self.elixir.as_deref(),
384 "wasm" => self.wasm.as_deref(),
385 "ffi" => self.ffi.as_deref(),
386 "go" => self.go.as_deref(),
387 "java" => self.java.as_deref(),
388 "kotlin" => self.kotlin.as_deref(),
389 "kotlin_android" => self.kotlin_android.as_deref(),
390 "dart" => self.dart.as_deref(),
391 "swift" => self.swift.as_deref(),
392 "gleam" => self.gleam.as_deref(),
393 "csharp" => self.csharp.as_deref(),
394 "r" => self.r.as_deref(),
395 "zig" => self.zig.as_deref(),
396 _ => None,
397 }
398 }
399}
400
401fn validate_output_segment(segment: &str, label: &str) {
408 if segment.contains('\0') {
409 panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
410 }
411 if segment.contains('/') || segment.contains('\\') {
412 panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
413 }
414}
415
416fn validate_output_path(path: &std::path::Path) {
422 use std::path::Component;
423 for component in path.components() {
424 match component {
425 Component::ParentDir => {
426 panic!(
427 "resolved output path `{}` contains `..` and would escape the project root",
428 path.display()
429 );
430 }
431 Component::RootDir | Component::Prefix(_) => {
432 panic!(
433 "resolved output path `{}` is absolute and would escape the project root",
434 path.display()
435 );
436 }
437 _ => {}
438 }
439 }
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct TextReplacement {
445 pub path: String,
447 pub search: String,
449 pub replace: String,
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn string_or_vec_single_from_toml() {
459 let toml_str = r#"format = "ruff format""#;
460 #[derive(Deserialize)]
461 struct T {
462 format: StringOrVec,
463 }
464 let t: T = toml::from_str(toml_str).unwrap();
465 assert_eq!(t.format.commands(), vec!["ruff format"]);
466 }
467
468 #[test]
469 fn string_or_vec_multiple_from_toml() {
470 let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
471 #[derive(Deserialize)]
472 struct T {
473 format: StringOrVec,
474 }
475 let t: T = toml::from_str(toml_str).unwrap();
476 assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
477 }
478
479 #[test]
480 fn lint_config_backward_compat_string() {
481 let toml_str = r#"
482format = "ruff format ."
483check = "ruff check ."
484typecheck = "mypy ."
485"#;
486 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
487 assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
488 assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
489 assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
490 }
491
492 #[test]
493 fn lint_config_array_commands() {
494 let toml_str = r#"
495format = ["cmd1", "cmd2"]
496check = "single-check"
497"#;
498 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
499 assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
500 assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
501 assert!(cfg.typecheck.is_none());
502 }
503
504 #[test]
505 fn lint_config_all_optional() {
506 let toml_str = "";
507 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
508 assert!(cfg.format.is_none());
509 assert!(cfg.check.is_none());
510 assert!(cfg.typecheck.is_none());
511 }
512
513 #[test]
514 fn update_config_from_toml() {
515 let toml_str = r#"
516update = "cargo update"
517upgrade = ["cargo upgrade --incompatible", "cargo update"]
518"#;
519 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
520 assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
521 assert_eq!(
522 cfg.upgrade.unwrap().commands(),
523 vec!["cargo upgrade --incompatible", "cargo update"]
524 );
525 }
526
527 #[test]
528 fn update_config_all_optional() {
529 let toml_str = "";
530 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
531 assert!(cfg.update.is_none());
532 assert!(cfg.upgrade.is_none());
533 }
534
535 #[test]
536 fn string_or_vec_empty_array_from_toml() {
537 let toml_str = "format = []";
538 #[derive(Deserialize)]
539 struct T {
540 format: StringOrVec,
541 }
542 let t: T = toml::from_str(toml_str).unwrap();
543 assert!(matches!(t.format, StringOrVec::Multiple(_)));
544 assert!(t.format.commands().is_empty());
545 }
546
547 #[test]
548 fn string_or_vec_single_element_array_from_toml() {
549 let toml_str = r#"format = ["cmd"]"#;
550 #[derive(Deserialize)]
551 struct T {
552 format: StringOrVec,
553 }
554 let t: T = toml::from_str(toml_str).unwrap();
555 assert_eq!(t.format.commands(), vec!["cmd"]);
556 }
557
558 #[test]
559 fn setup_config_single_string() {
560 let toml_str = r#"install = "uv sync""#;
561 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
562 assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
563 }
564
565 #[test]
566 fn setup_config_array_commands() {
567 let toml_str = r#"install = ["step1", "step2"]"#;
568 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
569 assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
570 }
571
572 #[test]
573 fn setup_config_all_optional() {
574 let toml_str = "";
575 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
576 assert!(cfg.install.is_none());
577 }
578
579 #[test]
580 fn clean_config_single_string() {
581 let toml_str = r#"clean = "rm -rf dist""#;
582 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
583 assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
584 }
585
586 #[test]
587 fn clean_config_array_commands() {
588 let toml_str = r#"clean = ["step1", "step2"]"#;
589 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
590 assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
591 }
592
593 #[test]
594 fn clean_config_all_optional() {
595 let toml_str = "";
596 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
597 assert!(cfg.clean.is_none());
598 }
599
600 #[test]
601 fn build_command_config_single_strings() {
602 let toml_str = r#"
603build = "cargo build"
604build_release = "cargo build --release"
605"#;
606 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
607 assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
608 assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
609 }
610
611 #[test]
612 fn build_command_config_array_commands() {
613 let toml_str = r#"
614build = ["step1", "step2"]
615build_release = ["step1 --release", "step2 --release"]
616"#;
617 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
618 assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
619 assert_eq!(
620 cfg.build_release.unwrap().commands(),
621 vec!["step1 --release", "step2 --release"]
622 );
623 }
624
625 #[test]
626 fn build_command_config_all_optional() {
627 let toml_str = "";
628 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
629 assert!(cfg.build.is_none());
630 assert!(cfg.build_release.is_none());
631 }
632
633 #[test]
634 fn test_config_backward_compat_string() {
635 let toml_str = r#"command = "pytest""#;
636 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
637 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
638 assert!(cfg.e2e.is_none());
639 assert!(cfg.coverage.is_none());
640 }
641
642 #[test]
643 fn test_config_array_command() {
644 let toml_str = r#"command = ["cmd1", "cmd2"]"#;
645 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
646 assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
647 }
648
649 #[test]
650 fn test_config_with_coverage() {
651 let toml_str = r#"
652command = "pytest"
653coverage = "pytest --cov=. --cov-report=term-missing"
654"#;
655 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
656 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
657 assert_eq!(
658 cfg.coverage.unwrap().commands(),
659 vec!["pytest --cov=. --cov-report=term-missing"]
660 );
661 assert!(cfg.e2e.is_none());
662 }
663
664 #[test]
665 fn test_config_all_optional() {
666 let toml_str = "";
667 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
668 assert!(cfg.command.is_none());
669 assert!(cfg.e2e.is_none());
670 assert!(cfg.coverage.is_none());
671 }
672
673 #[test]
674 fn full_alef_toml_with_lint_and_update() {
675 let toml_str = r#"
677languages = ["python", "node"]
678
679[lint.python]
680format = "ruff format ."
681check = "ruff check --fix ."
682
683[lint.node]
684format = ["npx oxfmt", "npx oxlint --fix"]
685
686[update.python]
687update = "uv sync --upgrade"
688upgrade = "uv sync --all-packages --all-extras --upgrade"
689
690[update.node]
691update = "pnpm up -r"
692upgrade = ["corepack up", "pnpm up --latest -r -w"]
693"#;
694 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
695 assert!(cfg.lint.contains_key("python"));
696 assert!(cfg.lint.contains_key("node"));
697
698 let py_lint = cfg.lint.get("python").unwrap();
699 assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
700
701 let node_lint = cfg.lint.get("node").unwrap();
702 assert_eq!(
703 node_lint.format.as_ref().unwrap().commands(),
704 vec!["npx oxfmt", "npx oxlint --fix"]
705 );
706
707 assert!(cfg.update.contains_key("python"));
708 assert!(cfg.update.contains_key("node"));
709
710 let node_update = cfg.update.get("node").unwrap();
711 assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
712 assert_eq!(
713 node_update.upgrade.as_ref().unwrap().commands(),
714 vec!["corepack up", "pnpm up --latest -r -w"]
715 );
716 }
717
718 #[test]
719 fn lint_config_with_precondition_and_before() {
720 let toml_str = r#"
721precondition = "test -f target/release/libfoo.so"
722before = "cargo build --release -p foo-ffi"
723format = "gofmt -w packages/go"
724check = "golangci-lint run ./..."
725"#;
726 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
727 assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
728 assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
729 assert!(cfg.format.is_some());
730 assert!(cfg.check.is_some());
731 }
732
733 #[test]
734 fn test_config_with_before_list() {
735 let toml_str = r#"
736before = ["cd packages/python && maturin develop", "echo ready"]
737command = "pytest"
738"#;
739 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
740 assert!(cfg.precondition.is_none());
741 assert_eq!(
742 cfg.before.unwrap().commands(),
743 vec!["cd packages/python && maturin develop", "echo ready"]
744 );
745 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
746 }
747
748 #[test]
749 fn setup_config_with_precondition() {
750 let toml_str = r#"
751precondition = "which rustup"
752install = "rustup update"
753"#;
754 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
755 assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
756 assert!(cfg.before.is_none());
757 assert!(cfg.install.is_some());
758 }
759
760 #[test]
761 fn build_command_config_with_before() {
762 let toml_str = r#"
763before = "cargo build --release -p my-lib-ffi"
764build = "cd packages/go && go build ./..."
765"#;
766 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
767 assert!(cfg.precondition.is_none());
768 assert_eq!(
769 cfg.before.unwrap().commands(),
770 vec!["cargo build --release -p my-lib-ffi"]
771 );
772 assert!(cfg.build.is_some());
773 }
774
775 #[test]
776 fn clean_config_precondition_and_before_optional() {
777 let toml_str = r#"clean = "cargo clean""#;
778 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
779 assert!(cfg.precondition.is_none());
780 assert!(cfg.before.is_none());
781 assert!(cfg.clean.is_some());
782 }
783
784 #[test]
785 fn update_config_with_precondition() {
786 let toml_str = r#"
787precondition = "test -f Cargo.lock"
788update = "cargo update"
789"#;
790 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
791 assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
792 assert!(cfg.before.is_none());
793 assert!(cfg.update.is_some());
794 }
795
796 #[test]
797 fn full_alef_toml_with_precondition_and_before_across_sections() {
798 let toml_str = r#"
800languages = ["go", "python"]
801
802[lint.go]
803precondition = "test -f target/release/libmylib_ffi.so"
804before = "cargo build --release -p mylib-ffi"
805format = "gofmt -w packages/go"
806check = "golangci-lint run ./..."
807
808[lint.python]
809format = "ruff format packages/python"
810check = "ruff check --fix packages/python"
811
812[test.go]
813precondition = "test -f target/release/libmylib_ffi.so"
814before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
815command = "cd packages/go && go test ./..."
816
817[test.python]
818command = "cd packages/python && uv run pytest"
819
820[build_commands.go]
821precondition = "which go"
822before = "cargo build --release -p mylib-ffi"
823build = "cd packages/go && go build ./..."
824build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
825
826[update.go]
827precondition = "test -d packages/go"
828update = "cd packages/go && go get -u ./..."
829
830[setup.python]
831precondition = "which uv"
832install = "cd packages/python && uv sync"
833
834[clean.go]
835before = "echo cleaning go"
836clean = "cd packages/go && go clean -cache"
837"#;
838 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
839
840 let go_lint = cfg.lint.get("go").unwrap();
842 assert_eq!(
843 go_lint.precondition.as_deref(),
844 Some("test -f target/release/libmylib_ffi.so"),
845 "lint.go precondition should be preserved"
846 );
847 assert_eq!(
848 go_lint.before.as_ref().unwrap().commands(),
849 vec!["cargo build --release -p mylib-ffi"],
850 "lint.go before should be preserved"
851 );
852 assert!(go_lint.format.is_some());
853 assert!(go_lint.check.is_some());
854
855 let py_lint = cfg.lint.get("python").unwrap();
857 assert!(
858 py_lint.precondition.is_none(),
859 "lint.python should have no precondition"
860 );
861 assert!(py_lint.before.is_none(), "lint.python should have no before");
862
863 let go_test = cfg.test.get("go").unwrap();
865 assert_eq!(
866 go_test.precondition.as_deref(),
867 Some("test -f target/release/libmylib_ffi.so"),
868 "test.go precondition should be preserved"
869 );
870 assert_eq!(
871 go_test.before.as_ref().unwrap().commands(),
872 vec![
873 "cargo build --release -p mylib-ffi",
874 "cp target/release/libmylib_ffi.so packages/go/"
875 ],
876 "test.go before list should be preserved"
877 );
878
879 let go_build = cfg.build_commands.get("go").unwrap();
881 assert_eq!(
882 go_build.precondition.as_deref(),
883 Some("which go"),
884 "build_commands.go precondition should be preserved"
885 );
886 assert_eq!(
887 go_build.before.as_ref().unwrap().commands(),
888 vec!["cargo build --release -p mylib-ffi"],
889 "build_commands.go before should be preserved"
890 );
891
892 let go_update = cfg.update.get("go").unwrap();
894 assert_eq!(
895 go_update.precondition.as_deref(),
896 Some("test -d packages/go"),
897 "update.go precondition should be preserved"
898 );
899 assert!(go_update.before.is_none(), "update.go before should be None");
900
901 let py_setup = cfg.setup.get("python").unwrap();
903 assert_eq!(
904 py_setup.precondition.as_deref(),
905 Some("which uv"),
906 "setup.python precondition should be preserved"
907 );
908 assert!(py_setup.before.is_none(), "setup.python before should be None");
909
910 let go_clean = cfg.clean.get("go").unwrap();
912 assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
913 assert_eq!(
914 go_clean.before.as_ref().unwrap().commands(),
915 vec!["echo cleaning go"],
916 "clean.go before should be preserved"
917 );
918 }
919
920 #[test]
921 fn output_template_resolves_explicit_entry() {
922 let tmpl = OutputTemplate {
923 python: Some("crates/{crate}-py/src/".to_string()),
924 ..Default::default()
925 };
926 assert_eq!(
927 tmpl.resolve("spikard", "python", true),
928 PathBuf::from("crates/spikard-py/src/")
929 );
930 }
931
932 #[test]
933 fn output_template_substitutes_lang_and_crate() {
934 let tmpl = OutputTemplate {
935 go: Some("packages/{lang}/{crate}/".to_string()),
936 ..Default::default()
937 };
938 assert_eq!(
939 tmpl.resolve("spikard-runtime", "go", true),
940 PathBuf::from("packages/go/spikard-runtime/")
941 );
942 }
943
944 #[test]
945 fn output_template_falls_back_to_multi_crate_default() {
946 let tmpl = OutputTemplate::default();
947 assert_eq!(
948 tmpl.resolve("spikard-runtime", "python", true),
949 PathBuf::from("packages/python/spikard-runtime")
950 );
951 }
952
953 #[test]
954 fn output_template_falls_back_to_single_crate_historical_default() {
955 let tmpl = OutputTemplate::default();
956 assert_eq!(
957 tmpl.resolve("spikard", "python", false),
958 PathBuf::from("packages/python")
959 );
960 assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
961 assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
962 assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
963 assert_eq!(
964 tmpl.resolve("spikard", "elixir", false),
965 PathBuf::from("packages/elixir")
966 );
967 }
968
969 #[test]
970 fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
971 let tmpl = OutputTemplate::default();
972 assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
973 assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
974 }
975
976 #[test]
977 fn output_template_deserializes_from_toml() {
978 let toml_str = r#"
979python = "packages/python/{crate}/"
980go = "packages/go/{crate}/"
981"#;
982 let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
983 assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
984 assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
985 assert!(tmpl.node.is_none());
986 }
987
988 #[test]
989 #[should_panic(expected = "path separators are not allowed")]
990 fn resolve_rejects_crate_name_with_path_separator() {
991 let tmpl = OutputTemplate::default();
992 tmpl.resolve("../foo", "python", false);
993 }
994
995 #[test]
996 #[should_panic(expected = "path separators are not allowed")]
997 fn resolve_rejects_crate_name_with_backslash() {
998 let tmpl = OutputTemplate::default();
999 tmpl.resolve("..\\foo", "python", false);
1000 }
1001
1002 #[test]
1003 #[should_panic(expected = "NUL byte is not allowed")]
1004 fn resolve_rejects_crate_name_with_nul_byte() {
1005 let tmpl = OutputTemplate::default();
1006 tmpl.resolve("foo\0bar", "python", false);
1007 }
1008
1009 #[test]
1010 #[should_panic(expected = "would escape the project root")]
1011 fn resolve_rejects_template_that_produces_parent_dir() {
1012 let tmpl = OutputTemplate {
1014 python: Some("../../etc/{crate}".to_string()),
1015 ..Default::default()
1016 };
1017 tmpl.resolve("mylib", "python", false);
1018 }
1019
1020 #[test]
1021 fn resolve_accepts_normal_crate_name() {
1022 let tmpl = OutputTemplate::default();
1023 let path = tmpl.resolve("my-lib", "python", false);
1024 assert_eq!(path, PathBuf::from("packages/python"));
1025 }
1026}
1027
1028#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1030pub struct SyncConfig {
1031 #[serde(default)]
1033 pub extra_paths: Vec<String>,
1034 #[serde(default)]
1036 pub text_replacements: Vec<TextReplacement>,
1037}