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