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 dart: Option<PathBuf>,
38 pub swift: Option<PathBuf>,
39 pub csharp: Option<PathBuf>,
40 pub r: Option<PathBuf>,
41 pub zig: Option<PathBuf>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ScaffoldConfig {
46 pub description: Option<String>,
47 pub license: Option<String>,
48 pub repository: Option<String>,
49 pub homepage: Option<String>,
50 #[serde(default)]
51 pub authors: Vec<String>,
52 #[serde(default)]
53 pub keywords: Vec<String>,
54 pub cargo: Option<ScaffoldCargo>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct ScaffoldCargo {
67 #[serde(default)]
71 pub targets: ScaffoldCargoTargets,
72 #[serde(default)]
75 pub env: HashMap<String, ScaffoldCargoEnvValue>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ScaffoldCargoTargets {
81 #[serde(default = "default_true")]
82 pub macos_dynamic_lookup: bool,
83 #[serde(default = "default_true")]
84 pub x86_64_pc_windows_msvc: bool,
85 #[serde(default = "default_true")]
86 pub i686_pc_windows_msvc: bool,
87 #[serde(default = "default_true")]
88 pub aarch64_unknown_linux_gnu: bool,
89 #[serde(default = "default_true")]
90 pub x86_64_unknown_linux_musl: bool,
91 #[serde(default = "default_true")]
92 pub wasm32_unknown_unknown: bool,
93}
94
95impl Default for ScaffoldCargoTargets {
96 fn default() -> Self {
97 Self {
98 macos_dynamic_lookup: true,
99 x86_64_pc_windows_msvc: true,
100 i686_pc_windows_msvc: true,
101 aarch64_unknown_linux_gnu: true,
102 x86_64_unknown_linux_musl: true,
103 wasm32_unknown_unknown: true,
104 }
105 }
106}
107
108fn default_true() -> bool {
109 true
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(untagged)]
116pub enum ScaffoldCargoEnvValue {
117 Plain(String),
118 Structured {
119 value: String,
120 #[serde(default)]
121 relative: bool,
122 },
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ReadmeConfig {
127 pub template_dir: Option<PathBuf>,
128 pub snippets_dir: Option<PathBuf>,
129 pub config: Option<PathBuf>,
131 pub output_pattern: Option<String>,
132 pub discord_url: Option<String>,
134 pub banner_url: Option<String>,
136 #[serde(default)]
140 pub languages: HashMap<String, JsonValue>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(untagged)]
148pub enum StringOrVec {
149 Single(String),
150 Multiple(Vec<String>),
151}
152
153impl StringOrVec {
154 pub fn commands(&self) -> Vec<&str> {
156 match self {
157 StringOrVec::Single(s) => vec![s.as_str()],
158 StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
164pub struct LintConfig {
165 pub precondition: Option<String>,
167 pub before: Option<StringOrVec>,
169 pub format: Option<StringOrVec>,
170 pub check: Option<StringOrVec>,
171 pub typecheck: Option<StringOrVec>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct UpdateConfig {
176 pub precondition: Option<String>,
178 pub before: Option<StringOrVec>,
180 pub update: Option<StringOrVec>,
182 pub upgrade: Option<StringOrVec>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
187pub struct TestConfig {
188 pub precondition: Option<String>,
190 pub before: Option<StringOrVec>,
192 pub command: Option<StringOrVec>,
194 pub e2e: Option<StringOrVec>,
196 pub coverage: Option<StringOrVec>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
201pub struct SetupConfig {
202 pub precondition: Option<String>,
204 pub before: Option<StringOrVec>,
206 pub install: Option<StringOrVec>,
208 #[serde(default = "default_setup_timeout")]
210 pub timeout_seconds: u64,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214pub struct CleanConfig {
215 pub precondition: Option<String>,
217 pub before: Option<StringOrVec>,
219 pub clean: Option<StringOrVec>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct BuildCommandConfig {
225 pub precondition: Option<String>,
227 pub before: Option<StringOrVec>,
229 pub build: Option<StringOrVec>,
231 pub build_release: Option<StringOrVec>,
233}
234
235fn default_setup_timeout() -> u64 {
236 600
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
251pub struct OutputTemplate {
252 pub python: Option<String>,
253 pub node: Option<String>,
254 pub ruby: Option<String>,
255 pub php: Option<String>,
256 pub elixir: Option<String>,
257 pub wasm: Option<String>,
258 pub ffi: Option<String>,
259 pub go: Option<String>,
260 pub java: Option<String>,
261 pub kotlin: Option<String>,
262 pub dart: Option<String>,
263 pub swift: Option<String>,
264 pub csharp: Option<String>,
265 pub r: Option<String>,
266 pub zig: Option<String>,
267}
268
269impl OutputTemplate {
270 pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
286 validate_output_segment(crate_name, "crate_name");
287 validate_output_segment(lang, "lang");
288
289 let path = if let Some(template) = self.entry(lang) {
290 PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
291 } else if multi_crate {
292 PathBuf::from(format!("packages/{lang}/{crate_name}"))
293 } else {
294 match lang {
295 "python" => PathBuf::from("packages/python"),
296 "node" => PathBuf::from("packages/node"),
297 "ruby" => PathBuf::from("packages/ruby"),
298 "php" => PathBuf::from("packages/php"),
299 "elixir" => PathBuf::from("packages/elixir"),
300 other => PathBuf::from(format!("packages/{other}")),
301 }
302 };
303
304 validate_output_path(&path);
305 path
306 }
307
308 pub fn entry(&self, lang: &str) -> Option<&str> {
310 match lang {
311 "python" => self.python.as_deref(),
312 "node" => self.node.as_deref(),
313 "ruby" => self.ruby.as_deref(),
314 "php" => self.php.as_deref(),
315 "elixir" => self.elixir.as_deref(),
316 "wasm" => self.wasm.as_deref(),
317 "ffi" => self.ffi.as_deref(),
318 "go" => self.go.as_deref(),
319 "java" => self.java.as_deref(),
320 "kotlin" => self.kotlin.as_deref(),
321 "dart" => self.dart.as_deref(),
322 "swift" => self.swift.as_deref(),
323 "csharp" => self.csharp.as_deref(),
324 "r" => self.r.as_deref(),
325 "zig" => self.zig.as_deref(),
326 _ => None,
327 }
328 }
329}
330
331fn validate_output_segment(segment: &str, label: &str) {
338 if segment.contains('\0') {
339 panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
340 }
341 if segment.contains('/') || segment.contains('\\') {
342 panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
343 }
344}
345
346fn validate_output_path(path: &std::path::Path) {
352 use std::path::Component;
353 for component in path.components() {
354 match component {
355 Component::ParentDir => {
356 panic!(
357 "resolved output path `{}` contains `..` and would escape the project root",
358 path.display()
359 );
360 }
361 Component::RootDir | Component::Prefix(_) => {
362 panic!(
363 "resolved output path `{}` is absolute and would escape the project root",
364 path.display()
365 );
366 }
367 _ => {}
368 }
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct TextReplacement {
375 pub path: String,
377 pub search: String,
379 pub replace: String,
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn string_or_vec_single_from_toml() {
389 let toml_str = r#"format = "ruff format""#;
390 #[derive(Deserialize)]
391 struct T {
392 format: StringOrVec,
393 }
394 let t: T = toml::from_str(toml_str).unwrap();
395 assert_eq!(t.format.commands(), vec!["ruff format"]);
396 }
397
398 #[test]
399 fn string_or_vec_multiple_from_toml() {
400 let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
401 #[derive(Deserialize)]
402 struct T {
403 format: StringOrVec,
404 }
405 let t: T = toml::from_str(toml_str).unwrap();
406 assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
407 }
408
409 #[test]
410 fn lint_config_backward_compat_string() {
411 let toml_str = r#"
412format = "ruff format ."
413check = "ruff check ."
414typecheck = "mypy ."
415"#;
416 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
417 assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
418 assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
419 assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
420 }
421
422 #[test]
423 fn lint_config_array_commands() {
424 let toml_str = r#"
425format = ["cmd1", "cmd2"]
426check = "single-check"
427"#;
428 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
429 assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
430 assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
431 assert!(cfg.typecheck.is_none());
432 }
433
434 #[test]
435 fn lint_config_all_optional() {
436 let toml_str = "";
437 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
438 assert!(cfg.format.is_none());
439 assert!(cfg.check.is_none());
440 assert!(cfg.typecheck.is_none());
441 }
442
443 #[test]
444 fn update_config_from_toml() {
445 let toml_str = r#"
446update = "cargo update"
447upgrade = ["cargo upgrade --incompatible", "cargo update"]
448"#;
449 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
450 assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
451 assert_eq!(
452 cfg.upgrade.unwrap().commands(),
453 vec!["cargo upgrade --incompatible", "cargo update"]
454 );
455 }
456
457 #[test]
458 fn update_config_all_optional() {
459 let toml_str = "";
460 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
461 assert!(cfg.update.is_none());
462 assert!(cfg.upgrade.is_none());
463 }
464
465 #[test]
466 fn string_or_vec_empty_array_from_toml() {
467 let toml_str = "format = []";
468 #[derive(Deserialize)]
469 struct T {
470 format: StringOrVec,
471 }
472 let t: T = toml::from_str(toml_str).unwrap();
473 assert!(matches!(t.format, StringOrVec::Multiple(_)));
474 assert!(t.format.commands().is_empty());
475 }
476
477 #[test]
478 fn string_or_vec_single_element_array_from_toml() {
479 let toml_str = r#"format = ["cmd"]"#;
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!["cmd"]);
486 }
487
488 #[test]
489 fn setup_config_single_string() {
490 let toml_str = r#"install = "uv sync""#;
491 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
492 assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
493 }
494
495 #[test]
496 fn setup_config_array_commands() {
497 let toml_str = r#"install = ["step1", "step2"]"#;
498 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
499 assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
500 }
501
502 #[test]
503 fn setup_config_all_optional() {
504 let toml_str = "";
505 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
506 assert!(cfg.install.is_none());
507 }
508
509 #[test]
510 fn clean_config_single_string() {
511 let toml_str = r#"clean = "rm -rf dist""#;
512 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
513 assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
514 }
515
516 #[test]
517 fn clean_config_array_commands() {
518 let toml_str = r#"clean = ["step1", "step2"]"#;
519 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
520 assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
521 }
522
523 #[test]
524 fn clean_config_all_optional() {
525 let toml_str = "";
526 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
527 assert!(cfg.clean.is_none());
528 }
529
530 #[test]
531 fn build_command_config_single_strings() {
532 let toml_str = r#"
533build = "cargo build"
534build_release = "cargo build --release"
535"#;
536 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
537 assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
538 assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
539 }
540
541 #[test]
542 fn build_command_config_array_commands() {
543 let toml_str = r#"
544build = ["step1", "step2"]
545build_release = ["step1 --release", "step2 --release"]
546"#;
547 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
548 assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
549 assert_eq!(
550 cfg.build_release.unwrap().commands(),
551 vec!["step1 --release", "step2 --release"]
552 );
553 }
554
555 #[test]
556 fn build_command_config_all_optional() {
557 let toml_str = "";
558 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
559 assert!(cfg.build.is_none());
560 assert!(cfg.build_release.is_none());
561 }
562
563 #[test]
564 fn test_config_backward_compat_string() {
565 let toml_str = r#"command = "pytest""#;
566 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
567 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
568 assert!(cfg.e2e.is_none());
569 assert!(cfg.coverage.is_none());
570 }
571
572 #[test]
573 fn test_config_array_command() {
574 let toml_str = r#"command = ["cmd1", "cmd2"]"#;
575 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
576 assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
577 }
578
579 #[test]
580 fn test_config_with_coverage() {
581 let toml_str = r#"
582command = "pytest"
583coverage = "pytest --cov=. --cov-report=term-missing"
584"#;
585 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
586 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
587 assert_eq!(
588 cfg.coverage.unwrap().commands(),
589 vec!["pytest --cov=. --cov-report=term-missing"]
590 );
591 assert!(cfg.e2e.is_none());
592 }
593
594 #[test]
595 fn test_config_all_optional() {
596 let toml_str = "";
597 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
598 assert!(cfg.command.is_none());
599 assert!(cfg.e2e.is_none());
600 assert!(cfg.coverage.is_none());
601 }
602
603 #[test]
604 fn full_alef_toml_with_lint_and_update() {
605 let toml_str = r#"
607languages = ["python", "node"]
608
609[lint.python]
610format = "ruff format ."
611check = "ruff check --fix ."
612
613[lint.node]
614format = ["npx oxfmt", "npx oxlint --fix"]
615
616[update.python]
617update = "uv sync --upgrade"
618upgrade = "uv sync --all-packages --all-extras --upgrade"
619
620[update.node]
621update = "pnpm up -r"
622upgrade = ["corepack up", "pnpm up --latest -r -w"]
623"#;
624 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
625 assert!(cfg.lint.contains_key("python"));
626 assert!(cfg.lint.contains_key("node"));
627
628 let py_lint = cfg.lint.get("python").unwrap();
629 assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
630
631 let node_lint = cfg.lint.get("node").unwrap();
632 assert_eq!(
633 node_lint.format.as_ref().unwrap().commands(),
634 vec!["npx oxfmt", "npx oxlint --fix"]
635 );
636
637 assert!(cfg.update.contains_key("python"));
638 assert!(cfg.update.contains_key("node"));
639
640 let node_update = cfg.update.get("node").unwrap();
641 assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
642 assert_eq!(
643 node_update.upgrade.as_ref().unwrap().commands(),
644 vec!["corepack up", "pnpm up --latest -r -w"]
645 );
646 }
647
648 #[test]
649 fn lint_config_with_precondition_and_before() {
650 let toml_str = r#"
651precondition = "test -f target/release/libfoo.so"
652before = "cargo build --release -p foo-ffi"
653format = "gofmt -w packages/go"
654check = "golangci-lint run ./..."
655"#;
656 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
657 assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
658 assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
659 assert!(cfg.format.is_some());
660 assert!(cfg.check.is_some());
661 }
662
663 #[test]
664 fn test_config_with_before_list() {
665 let toml_str = r#"
666before = ["cd packages/python && maturin develop", "echo ready"]
667command = "pytest"
668"#;
669 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
670 assert!(cfg.precondition.is_none());
671 assert_eq!(
672 cfg.before.unwrap().commands(),
673 vec!["cd packages/python && maturin develop", "echo ready"]
674 );
675 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
676 }
677
678 #[test]
679 fn setup_config_with_precondition() {
680 let toml_str = r#"
681precondition = "which rustup"
682install = "rustup update"
683"#;
684 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
685 assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
686 assert!(cfg.before.is_none());
687 assert!(cfg.install.is_some());
688 }
689
690 #[test]
691 fn build_command_config_with_before() {
692 let toml_str = r#"
693before = "cargo build --release -p my-lib-ffi"
694build = "cd packages/go && go build ./..."
695"#;
696 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
697 assert!(cfg.precondition.is_none());
698 assert_eq!(
699 cfg.before.unwrap().commands(),
700 vec!["cargo build --release -p my-lib-ffi"]
701 );
702 assert!(cfg.build.is_some());
703 }
704
705 #[test]
706 fn clean_config_precondition_and_before_optional() {
707 let toml_str = r#"clean = "cargo clean""#;
708 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
709 assert!(cfg.precondition.is_none());
710 assert!(cfg.before.is_none());
711 assert!(cfg.clean.is_some());
712 }
713
714 #[test]
715 fn update_config_with_precondition() {
716 let toml_str = r#"
717precondition = "test -f Cargo.lock"
718update = "cargo update"
719"#;
720 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
721 assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
722 assert!(cfg.before.is_none());
723 assert!(cfg.update.is_some());
724 }
725
726 #[test]
727 fn full_alef_toml_with_precondition_and_before_across_sections() {
728 let toml_str = r#"
730languages = ["go", "python"]
731
732[lint.go]
733precondition = "test -f target/release/libmylib_ffi.so"
734before = "cargo build --release -p mylib-ffi"
735format = "gofmt -w packages/go"
736check = "golangci-lint run ./..."
737
738[lint.python]
739format = "ruff format packages/python"
740check = "ruff check --fix packages/python"
741
742[test.go]
743precondition = "test -f target/release/libmylib_ffi.so"
744before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
745command = "cd packages/go && go test ./..."
746
747[test.python]
748command = "cd packages/python && uv run pytest"
749
750[build_commands.go]
751precondition = "which go"
752before = "cargo build --release -p mylib-ffi"
753build = "cd packages/go && go build ./..."
754build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
755
756[update.go]
757precondition = "test -d packages/go"
758update = "cd packages/go && go get -u ./..."
759
760[setup.python]
761precondition = "which uv"
762install = "cd packages/python && uv sync"
763
764[clean.go]
765before = "echo cleaning go"
766clean = "cd packages/go && go clean -cache"
767"#;
768 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
769
770 let go_lint = cfg.lint.get("go").unwrap();
772 assert_eq!(
773 go_lint.precondition.as_deref(),
774 Some("test -f target/release/libmylib_ffi.so"),
775 "lint.go precondition should be preserved"
776 );
777 assert_eq!(
778 go_lint.before.as_ref().unwrap().commands(),
779 vec!["cargo build --release -p mylib-ffi"],
780 "lint.go before should be preserved"
781 );
782 assert!(go_lint.format.is_some());
783 assert!(go_lint.check.is_some());
784
785 let py_lint = cfg.lint.get("python").unwrap();
787 assert!(
788 py_lint.precondition.is_none(),
789 "lint.python should have no precondition"
790 );
791 assert!(py_lint.before.is_none(), "lint.python should have no before");
792
793 let go_test = cfg.test.get("go").unwrap();
795 assert_eq!(
796 go_test.precondition.as_deref(),
797 Some("test -f target/release/libmylib_ffi.so"),
798 "test.go precondition should be preserved"
799 );
800 assert_eq!(
801 go_test.before.as_ref().unwrap().commands(),
802 vec![
803 "cargo build --release -p mylib-ffi",
804 "cp target/release/libmylib_ffi.so packages/go/"
805 ],
806 "test.go before list should be preserved"
807 );
808
809 let go_build = cfg.build_commands.get("go").unwrap();
811 assert_eq!(
812 go_build.precondition.as_deref(),
813 Some("which go"),
814 "build_commands.go precondition should be preserved"
815 );
816 assert_eq!(
817 go_build.before.as_ref().unwrap().commands(),
818 vec!["cargo build --release -p mylib-ffi"],
819 "build_commands.go before should be preserved"
820 );
821
822 let go_update = cfg.update.get("go").unwrap();
824 assert_eq!(
825 go_update.precondition.as_deref(),
826 Some("test -d packages/go"),
827 "update.go precondition should be preserved"
828 );
829 assert!(go_update.before.is_none(), "update.go before should be None");
830
831 let py_setup = cfg.setup.get("python").unwrap();
833 assert_eq!(
834 py_setup.precondition.as_deref(),
835 Some("which uv"),
836 "setup.python precondition should be preserved"
837 );
838 assert!(py_setup.before.is_none(), "setup.python before should be None");
839
840 let go_clean = cfg.clean.get("go").unwrap();
842 assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
843 assert_eq!(
844 go_clean.before.as_ref().unwrap().commands(),
845 vec!["echo cleaning go"],
846 "clean.go before should be preserved"
847 );
848 }
849
850 #[test]
851 fn output_template_resolves_explicit_entry() {
852 let tmpl = OutputTemplate {
853 python: Some("crates/{crate}-py/src/".to_string()),
854 ..Default::default()
855 };
856 assert_eq!(
857 tmpl.resolve("spikard", "python", true),
858 PathBuf::from("crates/spikard-py/src/")
859 );
860 }
861
862 #[test]
863 fn output_template_substitutes_lang_and_crate() {
864 let tmpl = OutputTemplate {
865 go: Some("packages/{lang}/{crate}/".to_string()),
866 ..Default::default()
867 };
868 assert_eq!(
869 tmpl.resolve("spikard-runtime", "go", true),
870 PathBuf::from("packages/go/spikard-runtime/")
871 );
872 }
873
874 #[test]
875 fn output_template_falls_back_to_multi_crate_default() {
876 let tmpl = OutputTemplate::default();
877 assert_eq!(
878 tmpl.resolve("spikard-runtime", "python", true),
879 PathBuf::from("packages/python/spikard-runtime")
880 );
881 }
882
883 #[test]
884 fn output_template_falls_back_to_single_crate_historical_default() {
885 let tmpl = OutputTemplate::default();
886 assert_eq!(
887 tmpl.resolve("spikard", "python", false),
888 PathBuf::from("packages/python")
889 );
890 assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
891 assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
892 assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
893 assert_eq!(
894 tmpl.resolve("spikard", "elixir", false),
895 PathBuf::from("packages/elixir")
896 );
897 }
898
899 #[test]
900 fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
901 let tmpl = OutputTemplate::default();
902 assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
903 assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
904 }
905
906 #[test]
907 fn output_template_deserializes_from_toml() {
908 let toml_str = r#"
909python = "packages/python/{crate}/"
910go = "packages/go/{crate}/"
911"#;
912 let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
913 assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
914 assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
915 assert!(tmpl.node.is_none());
916 }
917
918 #[test]
919 #[should_panic(expected = "path separators are not allowed")]
920 fn resolve_rejects_crate_name_with_path_separator() {
921 let tmpl = OutputTemplate::default();
922 tmpl.resolve("../foo", "python", false);
923 }
924
925 #[test]
926 #[should_panic(expected = "path separators are not allowed")]
927 fn resolve_rejects_crate_name_with_backslash() {
928 let tmpl = OutputTemplate::default();
929 tmpl.resolve("..\\foo", "python", false);
930 }
931
932 #[test]
933 #[should_panic(expected = "NUL byte is not allowed")]
934 fn resolve_rejects_crate_name_with_nul_byte() {
935 let tmpl = OutputTemplate::default();
936 tmpl.resolve("foo\0bar", "python", false);
937 }
938
939 #[test]
940 #[should_panic(expected = "would escape the project root")]
941 fn resolve_rejects_template_that_produces_parent_dir() {
942 let tmpl = OutputTemplate {
944 python: Some("../../etc/{crate}".to_string()),
945 ..Default::default()
946 };
947 tmpl.resolve("mylib", "python", false);
948 }
949
950 #[test]
951 fn resolve_accepts_normal_crate_name() {
952 let tmpl = OutputTemplate::default();
953 let path = tmpl.resolve("my-lib", "python", false);
954 assert_eq!(path, PathBuf::from("packages/python"));
955 }
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, Default)]
960pub struct SyncConfig {
961 #[serde(default)]
963 pub extra_paths: Vec<String>,
964 #[serde(default)]
966 pub text_replacements: Vec<TextReplacement>,
967}