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 #[serde(default)]
262 pub workdir: Option<PathBuf>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub struct CleanConfig {
267 pub precondition: Option<String>,
269 pub before: Option<StringOrVec>,
271 pub clean: Option<StringOrVec>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct BuildCommandConfig {
277 pub precondition: Option<String>,
279 pub before: Option<StringOrVec>,
281 pub build: Option<StringOrVec>,
283 pub build_release: Option<StringOrVec>,
285}
286
287impl BuildCommandConfig {
288 pub fn merge_overlay(mut self, other: &Self) -> Self {
294 if other.precondition.is_some() {
295 self.precondition = other.precondition.clone();
296 }
297 if other.before.is_some() {
298 self.before = other.before.clone();
299 }
300 if other.build.is_some() {
301 self.build = other.build.clone();
302 }
303 if other.build_release.is_some() {
304 self.build_release = other.build_release.clone();
305 }
306 self
307 }
308}
309
310fn default_setup_timeout() -> u64 {
311 600
312}
313
314#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
326pub struct OutputTemplate {
327 pub python: Option<String>,
328 pub node: Option<String>,
329 pub ruby: Option<String>,
330 pub php: Option<String>,
331 pub elixir: Option<String>,
332 pub wasm: Option<String>,
333 pub ffi: Option<String>,
334 pub go: Option<String>,
335 pub java: Option<String>,
336 pub kotlin: Option<String>,
337 pub kotlin_android: Option<String>,
338 pub dart: Option<String>,
339 pub swift: Option<String>,
340 pub gleam: Option<String>,
341 pub csharp: Option<String>,
342 pub r: Option<String>,
343 pub zig: Option<String>,
344}
345
346impl OutputTemplate {
347 pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
363 validate_output_segment(crate_name, "crate_name");
364 validate_output_segment(lang, "lang");
365
366 let path = if let Some(template) = self.entry(lang) {
367 PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
368 } else if multi_crate {
369 PathBuf::from(format!("packages/{lang}/{crate_name}"))
370 } else {
371 match lang {
372 "python" => PathBuf::from("packages/python"),
373 "node" => PathBuf::from("packages/node"),
374 "ruby" => PathBuf::from("packages/ruby"),
375 "php" => PathBuf::from("packages/php"),
376 "elixir" => PathBuf::from("packages/elixir"),
377 other => PathBuf::from(format!("packages/{other}")),
378 }
379 };
380
381 validate_output_path(&path);
382 path
383 }
384
385 pub fn entry(&self, lang: &str) -> Option<&str> {
387 match lang {
388 "python" => self.python.as_deref(),
389 "node" => self.node.as_deref(),
390 "ruby" => self.ruby.as_deref(),
391 "php" => self.php.as_deref(),
392 "elixir" => self.elixir.as_deref(),
393 "wasm" => self.wasm.as_deref(),
394 "ffi" => self.ffi.as_deref(),
395 "go" => self.go.as_deref(),
396 "java" => self.java.as_deref(),
397 "kotlin" => self.kotlin.as_deref(),
398 "kotlin_android" => self.kotlin_android.as_deref(),
399 "dart" => self.dart.as_deref(),
400 "swift" => self.swift.as_deref(),
401 "gleam" => self.gleam.as_deref(),
402 "csharp" => self.csharp.as_deref(),
403 "r" => self.r.as_deref(),
404 "zig" => self.zig.as_deref(),
405 _ => None,
406 }
407 }
408}
409
410fn validate_output_segment(segment: &str, label: &str) {
417 if segment.contains('\0') {
418 panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
419 }
420 if segment.contains('/') || segment.contains('\\') {
421 panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
422 }
423}
424
425fn validate_output_path(path: &std::path::Path) {
431 use std::path::Component;
432 for component in path.components() {
433 match component {
434 Component::ParentDir => {
435 panic!(
436 "resolved output path `{}` contains `..` and would escape the project root",
437 path.display()
438 );
439 }
440 Component::RootDir | Component::Prefix(_) => {
441 panic!(
442 "resolved output path `{}` is absolute and would escape the project root",
443 path.display()
444 );
445 }
446 _ => {}
447 }
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct TextReplacement {
454 pub path: String,
456 pub search: String,
458 pub replace: String,
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn string_or_vec_single_from_toml() {
468 let toml_str = r#"format = "ruff format""#;
469 #[derive(Deserialize)]
470 struct T {
471 format: StringOrVec,
472 }
473 let t: T = toml::from_str(toml_str).unwrap();
474 assert_eq!(t.format.commands(), vec!["ruff format"]);
475 }
476
477 #[test]
478 fn string_or_vec_multiple_from_toml() {
479 let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
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!["cmd1", "cmd2", "cmd3"]);
486 }
487
488 #[test]
489 fn lint_config_backward_compat_string() {
490 let toml_str = r#"
491format = "ruff format ."
492check = "ruff check ."
493typecheck = "mypy ."
494"#;
495 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
496 assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
497 assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
498 assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
499 }
500
501 #[test]
502 fn lint_config_array_commands() {
503 let toml_str = r#"
504format = ["cmd1", "cmd2"]
505check = "single-check"
506"#;
507 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
508 assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
509 assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
510 assert!(cfg.typecheck.is_none());
511 }
512
513 #[test]
514 fn lint_config_all_optional() {
515 let toml_str = "";
516 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
517 assert!(cfg.format.is_none());
518 assert!(cfg.check.is_none());
519 assert!(cfg.typecheck.is_none());
520 }
521
522 #[test]
523 fn update_config_from_toml() {
524 let toml_str = r#"
525update = "cargo update"
526upgrade = ["cargo upgrade --incompatible", "cargo update"]
527"#;
528 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
529 assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
530 assert_eq!(
531 cfg.upgrade.unwrap().commands(),
532 vec!["cargo upgrade --incompatible", "cargo update"]
533 );
534 }
535
536 #[test]
537 fn update_config_all_optional() {
538 let toml_str = "";
539 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
540 assert!(cfg.update.is_none());
541 assert!(cfg.upgrade.is_none());
542 }
543
544 #[test]
545 fn string_or_vec_empty_array_from_toml() {
546 let toml_str = "format = []";
547 #[derive(Deserialize)]
548 struct T {
549 format: StringOrVec,
550 }
551 let t: T = toml::from_str(toml_str).unwrap();
552 assert!(matches!(t.format, StringOrVec::Multiple(_)));
553 assert!(t.format.commands().is_empty());
554 }
555
556 #[test]
557 fn string_or_vec_single_element_array_from_toml() {
558 let toml_str = r#"format = ["cmd"]"#;
559 #[derive(Deserialize)]
560 struct T {
561 format: StringOrVec,
562 }
563 let t: T = toml::from_str(toml_str).unwrap();
564 assert_eq!(t.format.commands(), vec!["cmd"]);
565 }
566
567 #[test]
568 fn setup_config_single_string() {
569 let toml_str = r#"install = "uv sync --no-install-project --no-install-workspace""#;
570 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
571 assert_eq!(
572 cfg.install.unwrap().commands(),
573 vec!["uv sync --no-install-project --no-install-workspace"]
574 );
575 }
576
577 #[test]
578 fn setup_config_array_commands() {
579 let toml_str = r#"install = ["step1", "step2"]"#;
580 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
581 assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
582 }
583
584 #[test]
585 fn setup_config_all_optional() {
586 let toml_str = "";
587 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
588 assert!(cfg.install.is_none());
589 }
590
591 #[test]
592 fn clean_config_single_string() {
593 let toml_str = r#"clean = "rm -rf dist""#;
594 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
595 assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
596 }
597
598 #[test]
599 fn clean_config_array_commands() {
600 let toml_str = r#"clean = ["step1", "step2"]"#;
601 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
602 assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
603 }
604
605 #[test]
606 fn clean_config_all_optional() {
607 let toml_str = "";
608 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
609 assert!(cfg.clean.is_none());
610 }
611
612 #[test]
613 fn build_command_config_single_strings() {
614 let toml_str = r#"
615build = "cargo build"
616build_release = "cargo build --release"
617"#;
618 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
619 assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
620 assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
621 }
622
623 #[test]
624 fn build_command_config_array_commands() {
625 let toml_str = r#"
626build = ["step1", "step2"]
627build_release = ["step1 --release", "step2 --release"]
628"#;
629 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
630 assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
631 assert_eq!(
632 cfg.build_release.unwrap().commands(),
633 vec!["step1 --release", "step2 --release"]
634 );
635 }
636
637 #[test]
638 fn build_command_config_all_optional() {
639 let toml_str = "";
640 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
641 assert!(cfg.build.is_none());
642 assert!(cfg.build_release.is_none());
643 }
644
645 #[test]
646 fn test_config_backward_compat_string() {
647 let toml_str = r#"command = "pytest""#;
648 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
649 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
650 assert!(cfg.e2e.is_none());
651 assert!(cfg.coverage.is_none());
652 }
653
654 #[test]
655 fn test_config_array_command() {
656 let toml_str = r#"command = ["cmd1", "cmd2"]"#;
657 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
658 assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
659 }
660
661 #[test]
662 fn test_config_with_coverage() {
663 let toml_str = r#"
664command = "pytest"
665coverage = "pytest --cov=. --cov-report=term-missing"
666"#;
667 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
668 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
669 assert_eq!(
670 cfg.coverage.unwrap().commands(),
671 vec!["pytest --cov=. --cov-report=term-missing"]
672 );
673 assert!(cfg.e2e.is_none());
674 }
675
676 #[test]
677 fn test_config_all_optional() {
678 let toml_str = "";
679 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
680 assert!(cfg.command.is_none());
681 assert!(cfg.e2e.is_none());
682 assert!(cfg.coverage.is_none());
683 }
684
685 #[test]
686 fn full_alef_toml_with_lint_and_update() {
687 let toml_str = r#"
689languages = ["python", "node"]
690
691[lint.python]
692format = "ruff format ."
693check = "ruff check --fix ."
694
695[lint.node]
696format = ["npx oxfmt", "npx oxlint --fix"]
697
698[update.python]
699update = "uv sync --upgrade"
700upgrade = "uv sync --all-packages --all-extras --upgrade"
701
702[update.node]
703update = "pnpm up -r"
704upgrade = ["corepack up", "pnpm up --latest -r -w"]
705"#;
706 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
707 assert!(cfg.lint.contains_key("python"));
708 assert!(cfg.lint.contains_key("node"));
709
710 let py_lint = cfg.lint.get("python").unwrap();
711 assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
712
713 let node_lint = cfg.lint.get("node").unwrap();
714 assert_eq!(
715 node_lint.format.as_ref().unwrap().commands(),
716 vec!["npx oxfmt", "npx oxlint --fix"]
717 );
718
719 assert!(cfg.update.contains_key("python"));
720 assert!(cfg.update.contains_key("node"));
721
722 let node_update = cfg.update.get("node").unwrap();
723 assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
724 assert_eq!(
725 node_update.upgrade.as_ref().unwrap().commands(),
726 vec!["corepack up", "pnpm up --latest -r -w"]
727 );
728 }
729
730 #[test]
731 fn lint_config_with_precondition_and_before() {
732 let toml_str = r#"
733precondition = "test -f target/release/libfoo.so"
734before = "cargo build --release -p foo-ffi"
735format = "gofmt -w packages/go"
736check = "golangci-lint run ./..."
737"#;
738 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
739 assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
740 assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
741 assert!(cfg.format.is_some());
742 assert!(cfg.check.is_some());
743 }
744
745 #[test]
746 fn test_config_with_before_list() {
747 let toml_str = r#"
748before = ["cd packages/python && maturin develop", "echo ready"]
749command = "pytest"
750"#;
751 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
752 assert!(cfg.precondition.is_none());
753 assert_eq!(
754 cfg.before.unwrap().commands(),
755 vec!["cd packages/python && maturin develop", "echo ready"]
756 );
757 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
758 }
759
760 #[test]
761 fn setup_config_with_precondition() {
762 let toml_str = r#"
763precondition = "which rustup"
764install = "rustup update"
765"#;
766 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
767 assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
768 assert!(cfg.before.is_none());
769 assert!(cfg.install.is_some());
770 }
771
772 #[test]
773 fn build_command_config_with_before() {
774 let toml_str = r#"
775before = "cargo build --release -p my-lib-ffi"
776build = "cd packages/go && go build ./..."
777"#;
778 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
779 assert!(cfg.precondition.is_none());
780 assert_eq!(
781 cfg.before.unwrap().commands(),
782 vec!["cargo build --release -p my-lib-ffi"]
783 );
784 assert!(cfg.build.is_some());
785 }
786
787 #[test]
788 fn clean_config_precondition_and_before_optional() {
789 let toml_str = r#"clean = "cargo clean""#;
790 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
791 assert!(cfg.precondition.is_none());
792 assert!(cfg.before.is_none());
793 assert!(cfg.clean.is_some());
794 }
795
796 #[test]
797 fn update_config_with_precondition() {
798 let toml_str = r#"
799precondition = "test -f Cargo.lock"
800update = "cargo update"
801"#;
802 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
803 assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
804 assert!(cfg.before.is_none());
805 assert!(cfg.update.is_some());
806 }
807
808 #[test]
809 fn full_alef_toml_with_precondition_and_before_across_sections() {
810 let toml_str = r#"
812languages = ["go", "python"]
813
814[lint.go]
815precondition = "test -f target/release/libmylib_ffi.so"
816before = "cargo build --release -p mylib-ffi"
817format = "gofmt -w packages/go"
818check = "golangci-lint run ./..."
819
820[lint.python]
821format = "ruff format packages/python"
822check = "ruff check --fix packages/python"
823
824[test.go]
825precondition = "test -f target/release/libmylib_ffi.so"
826before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
827command = "cd packages/go && go test ./..."
828
829[test.python]
830command = "cd packages/python && uv run --no-sync pytest"
831
832[build_commands.go]
833precondition = "which go"
834before = "cargo build --release -p mylib-ffi"
835build = "cd packages/go && go build ./..."
836build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
837
838[update.go]
839precondition = "test -d packages/go"
840update = "cd packages/go && go get -u ./..."
841
842[setup.python]
843precondition = "which uv"
844install = "cd packages/python && uv sync --no-install-project --no-install-workspace"
845
846[clean.go]
847before = "echo cleaning go"
848clean = "cd packages/go && go clean -cache"
849"#;
850 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
851
852 let go_lint = cfg.lint.get("go").unwrap();
854 assert_eq!(
855 go_lint.precondition.as_deref(),
856 Some("test -f target/release/libmylib_ffi.so"),
857 "lint.go precondition should be preserved"
858 );
859 assert_eq!(
860 go_lint.before.as_ref().unwrap().commands(),
861 vec!["cargo build --release -p mylib-ffi"],
862 "lint.go before should be preserved"
863 );
864 assert!(go_lint.format.is_some());
865 assert!(go_lint.check.is_some());
866
867 let py_lint = cfg.lint.get("python").unwrap();
869 assert!(
870 py_lint.precondition.is_none(),
871 "lint.python should have no precondition"
872 );
873 assert!(py_lint.before.is_none(), "lint.python should have no before");
874
875 let go_test = cfg.test.get("go").unwrap();
877 assert_eq!(
878 go_test.precondition.as_deref(),
879 Some("test -f target/release/libmylib_ffi.so"),
880 "test.go precondition should be preserved"
881 );
882 assert_eq!(
883 go_test.before.as_ref().unwrap().commands(),
884 vec![
885 "cargo build --release -p mylib-ffi",
886 "cp target/release/libmylib_ffi.so packages/go/"
887 ],
888 "test.go before list should be preserved"
889 );
890
891 let go_build = cfg.build_commands.get("go").unwrap();
893 assert_eq!(
894 go_build.precondition.as_deref(),
895 Some("which go"),
896 "build_commands.go precondition should be preserved"
897 );
898 assert_eq!(
899 go_build.before.as_ref().unwrap().commands(),
900 vec!["cargo build --release -p mylib-ffi"],
901 "build_commands.go before should be preserved"
902 );
903
904 let go_update = cfg.update.get("go").unwrap();
906 assert_eq!(
907 go_update.precondition.as_deref(),
908 Some("test -d packages/go"),
909 "update.go precondition should be preserved"
910 );
911 assert!(go_update.before.is_none(), "update.go before should be None");
912
913 let py_setup = cfg.setup.get("python").unwrap();
915 assert_eq!(
916 py_setup.precondition.as_deref(),
917 Some("which uv"),
918 "setup.python precondition should be preserved"
919 );
920 assert!(py_setup.before.is_none(), "setup.python before should be None");
921
922 let go_clean = cfg.clean.get("go").unwrap();
924 assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
925 assert_eq!(
926 go_clean.before.as_ref().unwrap().commands(),
927 vec!["echo cleaning go"],
928 "clean.go before should be preserved"
929 );
930 }
931
932 #[test]
933 fn output_template_resolves_explicit_entry() {
934 let tmpl = OutputTemplate {
935 python: Some("crates/{crate}-py/src/".to_string()),
936 ..Default::default()
937 };
938 assert_eq!(
939 tmpl.resolve("spikard", "python", true),
940 PathBuf::from("crates/spikard-py/src/")
941 );
942 }
943
944 #[test]
945 fn output_template_substitutes_lang_and_crate() {
946 let tmpl = OutputTemplate {
947 go: Some("packages/{lang}/{crate}/".to_string()),
948 ..Default::default()
949 };
950 assert_eq!(
951 tmpl.resolve("spikard-runtime", "go", true),
952 PathBuf::from("packages/go/spikard-runtime/")
953 );
954 }
955
956 #[test]
957 fn output_template_falls_back_to_multi_crate_default() {
958 let tmpl = OutputTemplate::default();
959 assert_eq!(
960 tmpl.resolve("spikard-runtime", "python", true),
961 PathBuf::from("packages/python/spikard-runtime")
962 );
963 }
964
965 #[test]
966 fn output_template_falls_back_to_single_crate_historical_default() {
967 let tmpl = OutputTemplate::default();
968 assert_eq!(
969 tmpl.resolve("spikard", "python", false),
970 PathBuf::from("packages/python")
971 );
972 assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
973 assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
974 assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
975 assert_eq!(
976 tmpl.resolve("spikard", "elixir", false),
977 PathBuf::from("packages/elixir")
978 );
979 }
980
981 #[test]
982 fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
983 let tmpl = OutputTemplate::default();
984 assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
985 assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
986 }
987
988 #[test]
989 fn output_template_deserializes_from_toml() {
990 let toml_str = r#"
991python = "packages/python/{crate}/"
992go = "packages/go/{crate}/"
993"#;
994 let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
995 assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
996 assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
997 assert!(tmpl.node.is_none());
998 }
999
1000 #[test]
1001 #[should_panic(expected = "path separators are not allowed")]
1002 fn resolve_rejects_crate_name_with_path_separator() {
1003 let tmpl = OutputTemplate::default();
1004 tmpl.resolve("../foo", "python", false);
1005 }
1006
1007 #[test]
1008 #[should_panic(expected = "path separators are not allowed")]
1009 fn resolve_rejects_crate_name_with_backslash() {
1010 let tmpl = OutputTemplate::default();
1011 tmpl.resolve("..\\foo", "python", false);
1012 }
1013
1014 #[test]
1015 #[should_panic(expected = "NUL byte is not allowed")]
1016 fn resolve_rejects_crate_name_with_nul_byte() {
1017 let tmpl = OutputTemplate::default();
1018 tmpl.resolve("foo\0bar", "python", false);
1019 }
1020
1021 #[test]
1022 #[should_panic(expected = "would escape the project root")]
1023 fn resolve_rejects_template_that_produces_parent_dir() {
1024 let tmpl = OutputTemplate {
1026 python: Some("../../etc/{crate}".to_string()),
1027 ..Default::default()
1028 };
1029 tmpl.resolve("mylib", "python", false);
1030 }
1031
1032 #[test]
1033 fn resolve_accepts_normal_crate_name() {
1034 let tmpl = OutputTemplate::default();
1035 let path = tmpl.resolve("my-lib", "python", false);
1036 assert_eq!(path, PathBuf::from("packages/python"));
1037 }
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1042pub struct SyncConfig {
1043 #[serde(default)]
1045 pub extra_paths: Vec<String>,
1046 #[serde(default)]
1048 pub text_replacements: Vec<TextReplacement>,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1056#[serde(deny_unknown_fields)]
1057pub struct CitationAuthor {
1058 #[serde(default, alias = "family-names")]
1060 pub family_names: Option<String>,
1061 #[serde(default, alias = "given-names")]
1063 pub given_names: Option<String>,
1064 #[serde(default)]
1066 pub name: Option<String>,
1067 #[serde(default)]
1069 pub email: Option<String>,
1070 #[serde(default)]
1072 pub orcid: Option<String>,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1086#[serde(deny_unknown_fields)]
1087pub struct CitationConfig {
1088 pub title: String,
1090 #[serde(rename = "abstract")]
1092 pub abstract_: String,
1093 pub authors: Vec<CitationAuthor>,
1096 #[serde(default = "default_citation_message")]
1098 pub message: String,
1099 #[serde(rename = "repository-code", alias = "repository_code")]
1101 pub repository_code: String,
1102 #[serde(default)]
1104 pub url: Option<String>,
1105 #[serde(default)]
1108 pub license: Option<String>,
1109 #[serde(default, rename = "date-released", alias = "date_released")]
1111 pub date_released: Option<String>,
1112 #[serde(default)]
1114 pub doi: Option<String>,
1115}
1116
1117fn default_citation_message() -> String {
1118 "If you use this software, please cite it using the metadata below.".to_string()
1119}