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""#;
570 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
571 assert_eq!(cfg.install.unwrap().commands(), vec!["uv sync"]);
572 }
573
574 #[test]
575 fn setup_config_array_commands() {
576 let toml_str = r#"install = ["step1", "step2"]"#;
577 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
578 assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
579 }
580
581 #[test]
582 fn setup_config_all_optional() {
583 let toml_str = "";
584 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
585 assert!(cfg.install.is_none());
586 }
587
588 #[test]
589 fn clean_config_single_string() {
590 let toml_str = r#"clean = "rm -rf dist""#;
591 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
592 assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
593 }
594
595 #[test]
596 fn clean_config_array_commands() {
597 let toml_str = r#"clean = ["step1", "step2"]"#;
598 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
599 assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
600 }
601
602 #[test]
603 fn clean_config_all_optional() {
604 let toml_str = "";
605 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
606 assert!(cfg.clean.is_none());
607 }
608
609 #[test]
610 fn build_command_config_single_strings() {
611 let toml_str = r#"
612build = "cargo build"
613build_release = "cargo build --release"
614"#;
615 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
616 assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
617 assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
618 }
619
620 #[test]
621 fn build_command_config_array_commands() {
622 let toml_str = r#"
623build = ["step1", "step2"]
624build_release = ["step1 --release", "step2 --release"]
625"#;
626 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
627 assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
628 assert_eq!(
629 cfg.build_release.unwrap().commands(),
630 vec!["step1 --release", "step2 --release"]
631 );
632 }
633
634 #[test]
635 fn build_command_config_all_optional() {
636 let toml_str = "";
637 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
638 assert!(cfg.build.is_none());
639 assert!(cfg.build_release.is_none());
640 }
641
642 #[test]
643 fn test_config_backward_compat_string() {
644 let toml_str = r#"command = "pytest""#;
645 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
646 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
647 assert!(cfg.e2e.is_none());
648 assert!(cfg.coverage.is_none());
649 }
650
651 #[test]
652 fn test_config_array_command() {
653 let toml_str = r#"command = ["cmd1", "cmd2"]"#;
654 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
655 assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
656 }
657
658 #[test]
659 fn test_config_with_coverage() {
660 let toml_str = r#"
661command = "pytest"
662coverage = "pytest --cov=. --cov-report=term-missing"
663"#;
664 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
665 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
666 assert_eq!(
667 cfg.coverage.unwrap().commands(),
668 vec!["pytest --cov=. --cov-report=term-missing"]
669 );
670 assert!(cfg.e2e.is_none());
671 }
672
673 #[test]
674 fn test_config_all_optional() {
675 let toml_str = "";
676 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
677 assert!(cfg.command.is_none());
678 assert!(cfg.e2e.is_none());
679 assert!(cfg.coverage.is_none());
680 }
681
682 #[test]
683 fn full_alef_toml_with_lint_and_update() {
684 let toml_str = r#"
686languages = ["python", "node"]
687
688[lint.python]
689format = "ruff format ."
690check = "ruff check --fix ."
691
692[lint.node]
693format = ["npx oxfmt", "npx oxlint --fix"]
694
695[update.python]
696update = "uv sync --upgrade"
697upgrade = "uv sync --all-packages --all-extras --upgrade"
698
699[update.node]
700update = "pnpm up -r"
701upgrade = ["corepack up", "pnpm up --latest -r -w"]
702"#;
703 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
704 assert!(cfg.lint.contains_key("python"));
705 assert!(cfg.lint.contains_key("node"));
706
707 let py_lint = cfg.lint.get("python").unwrap();
708 assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);
709
710 let node_lint = cfg.lint.get("node").unwrap();
711 assert_eq!(
712 node_lint.format.as_ref().unwrap().commands(),
713 vec!["npx oxfmt", "npx oxlint --fix"]
714 );
715
716 assert!(cfg.update.contains_key("python"));
717 assert!(cfg.update.contains_key("node"));
718
719 let node_update = cfg.update.get("node").unwrap();
720 assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
721 assert_eq!(
722 node_update.upgrade.as_ref().unwrap().commands(),
723 vec!["corepack up", "pnpm up --latest -r -w"]
724 );
725 }
726
727 #[test]
728 fn lint_config_with_precondition_and_before() {
729 let toml_str = r#"
730precondition = "test -f target/release/libfoo.so"
731before = "cargo build --release -p foo-ffi"
732format = "gofmt -w packages/go"
733check = "golangci-lint run ./..."
734"#;
735 let cfg: LintConfig = toml::from_str(toml_str).unwrap();
736 assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
737 assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
738 assert!(cfg.format.is_some());
739 assert!(cfg.check.is_some());
740 }
741
742 #[test]
743 fn test_config_with_before_list() {
744 let toml_str = r#"
745before = ["cd packages/python && maturin develop", "echo ready"]
746command = "pytest"
747"#;
748 let cfg: TestConfig = toml::from_str(toml_str).unwrap();
749 assert!(cfg.precondition.is_none());
750 assert_eq!(
751 cfg.before.unwrap().commands(),
752 vec!["cd packages/python && maturin develop", "echo ready"]
753 );
754 assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
755 }
756
757 #[test]
758 fn setup_config_with_precondition() {
759 let toml_str = r#"
760precondition = "which rustup"
761install = "rustup update"
762"#;
763 let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
764 assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
765 assert!(cfg.before.is_none());
766 assert!(cfg.install.is_some());
767 }
768
769 #[test]
770 fn build_command_config_with_before() {
771 let toml_str = r#"
772before = "cargo build --release -p my-lib-ffi"
773build = "cd packages/go && go build ./..."
774"#;
775 let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
776 assert!(cfg.precondition.is_none());
777 assert_eq!(
778 cfg.before.unwrap().commands(),
779 vec!["cargo build --release -p my-lib-ffi"]
780 );
781 assert!(cfg.build.is_some());
782 }
783
784 #[test]
785 fn clean_config_precondition_and_before_optional() {
786 let toml_str = r#"clean = "cargo clean""#;
787 let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
788 assert!(cfg.precondition.is_none());
789 assert!(cfg.before.is_none());
790 assert!(cfg.clean.is_some());
791 }
792
793 #[test]
794 fn update_config_with_precondition() {
795 let toml_str = r#"
796precondition = "test -f Cargo.lock"
797update = "cargo update"
798"#;
799 let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
800 assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
801 assert!(cfg.before.is_none());
802 assert!(cfg.update.is_some());
803 }
804
805 #[test]
806 fn full_alef_toml_with_precondition_and_before_across_sections() {
807 let toml_str = r#"
809languages = ["go", "python"]
810
811[lint.go]
812precondition = "test -f target/release/libmylib_ffi.so"
813before = "cargo build --release -p mylib-ffi"
814format = "gofmt -w packages/go"
815check = "golangci-lint run ./..."
816
817[lint.python]
818format = "ruff format packages/python"
819check = "ruff check --fix packages/python"
820
821[test.go]
822precondition = "test -f target/release/libmylib_ffi.so"
823before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
824command = "cd packages/go && go test ./..."
825
826[test.python]
827command = "cd packages/python && uv run pytest"
828
829[build_commands.go]
830precondition = "which go"
831before = "cargo build --release -p mylib-ffi"
832build = "cd packages/go && go build ./..."
833build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
834
835[update.go]
836precondition = "test -d packages/go"
837update = "cd packages/go && go get -u ./..."
838
839[setup.python]
840precondition = "which uv"
841install = "cd packages/python && uv sync"
842
843[clean.go]
844before = "echo cleaning go"
845clean = "cd packages/go && go clean -cache"
846"#;
847 let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
848
849 let go_lint = cfg.lint.get("go").unwrap();
851 assert_eq!(
852 go_lint.precondition.as_deref(),
853 Some("test -f target/release/libmylib_ffi.so"),
854 "lint.go precondition should be preserved"
855 );
856 assert_eq!(
857 go_lint.before.as_ref().unwrap().commands(),
858 vec!["cargo build --release -p mylib-ffi"],
859 "lint.go before should be preserved"
860 );
861 assert!(go_lint.format.is_some());
862 assert!(go_lint.check.is_some());
863
864 let py_lint = cfg.lint.get("python").unwrap();
866 assert!(
867 py_lint.precondition.is_none(),
868 "lint.python should have no precondition"
869 );
870 assert!(py_lint.before.is_none(), "lint.python should have no before");
871
872 let go_test = cfg.test.get("go").unwrap();
874 assert_eq!(
875 go_test.precondition.as_deref(),
876 Some("test -f target/release/libmylib_ffi.so"),
877 "test.go precondition should be preserved"
878 );
879 assert_eq!(
880 go_test.before.as_ref().unwrap().commands(),
881 vec![
882 "cargo build --release -p mylib-ffi",
883 "cp target/release/libmylib_ffi.so packages/go/"
884 ],
885 "test.go before list should be preserved"
886 );
887
888 let go_build = cfg.build_commands.get("go").unwrap();
890 assert_eq!(
891 go_build.precondition.as_deref(),
892 Some("which go"),
893 "build_commands.go precondition should be preserved"
894 );
895 assert_eq!(
896 go_build.before.as_ref().unwrap().commands(),
897 vec!["cargo build --release -p mylib-ffi"],
898 "build_commands.go before should be preserved"
899 );
900
901 let go_update = cfg.update.get("go").unwrap();
903 assert_eq!(
904 go_update.precondition.as_deref(),
905 Some("test -d packages/go"),
906 "update.go precondition should be preserved"
907 );
908 assert!(go_update.before.is_none(), "update.go before should be None");
909
910 let py_setup = cfg.setup.get("python").unwrap();
912 assert_eq!(
913 py_setup.precondition.as_deref(),
914 Some("which uv"),
915 "setup.python precondition should be preserved"
916 );
917 assert!(py_setup.before.is_none(), "setup.python before should be None");
918
919 let go_clean = cfg.clean.get("go").unwrap();
921 assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
922 assert_eq!(
923 go_clean.before.as_ref().unwrap().commands(),
924 vec!["echo cleaning go"],
925 "clean.go before should be preserved"
926 );
927 }
928
929 #[test]
930 fn output_template_resolves_explicit_entry() {
931 let tmpl = OutputTemplate {
932 python: Some("crates/{crate}-py/src/".to_string()),
933 ..Default::default()
934 };
935 assert_eq!(
936 tmpl.resolve("spikard", "python", true),
937 PathBuf::from("crates/spikard-py/src/")
938 );
939 }
940
941 #[test]
942 fn output_template_substitutes_lang_and_crate() {
943 let tmpl = OutputTemplate {
944 go: Some("packages/{lang}/{crate}/".to_string()),
945 ..Default::default()
946 };
947 assert_eq!(
948 tmpl.resolve("spikard-runtime", "go", true),
949 PathBuf::from("packages/go/spikard-runtime/")
950 );
951 }
952
953 #[test]
954 fn output_template_falls_back_to_multi_crate_default() {
955 let tmpl = OutputTemplate::default();
956 assert_eq!(
957 tmpl.resolve("spikard-runtime", "python", true),
958 PathBuf::from("packages/python/spikard-runtime")
959 );
960 }
961
962 #[test]
963 fn output_template_falls_back_to_single_crate_historical_default() {
964 let tmpl = OutputTemplate::default();
965 assert_eq!(
966 tmpl.resolve("spikard", "python", false),
967 PathBuf::from("packages/python")
968 );
969 assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
970 assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
971 assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
972 assert_eq!(
973 tmpl.resolve("spikard", "elixir", false),
974 PathBuf::from("packages/elixir")
975 );
976 }
977
978 #[test]
979 fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
980 let tmpl = OutputTemplate::default();
981 assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
982 assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
983 }
984
985 #[test]
986 fn output_template_deserializes_from_toml() {
987 let toml_str = r#"
988python = "packages/python/{crate}/"
989go = "packages/go/{crate}/"
990"#;
991 let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
992 assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
993 assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
994 assert!(tmpl.node.is_none());
995 }
996
997 #[test]
998 #[should_panic(expected = "path separators are not allowed")]
999 fn resolve_rejects_crate_name_with_path_separator() {
1000 let tmpl = OutputTemplate::default();
1001 tmpl.resolve("../foo", "python", false);
1002 }
1003
1004 #[test]
1005 #[should_panic(expected = "path separators are not allowed")]
1006 fn resolve_rejects_crate_name_with_backslash() {
1007 let tmpl = OutputTemplate::default();
1008 tmpl.resolve("..\\foo", "python", false);
1009 }
1010
1011 #[test]
1012 #[should_panic(expected = "NUL byte is not allowed")]
1013 fn resolve_rejects_crate_name_with_nul_byte() {
1014 let tmpl = OutputTemplate::default();
1015 tmpl.resolve("foo\0bar", "python", false);
1016 }
1017
1018 #[test]
1019 #[should_panic(expected = "would escape the project root")]
1020 fn resolve_rejects_template_that_produces_parent_dir() {
1021 let tmpl = OutputTemplate {
1023 python: Some("../../etc/{crate}".to_string()),
1024 ..Default::default()
1025 };
1026 tmpl.resolve("mylib", "python", false);
1027 }
1028
1029 #[test]
1030 fn resolve_accepts_normal_crate_name() {
1031 let tmpl = OutputTemplate::default();
1032 let path = tmpl.resolve("my-lib", "python", false);
1033 assert_eq!(path, PathBuf::from("packages/python"));
1034 }
1035}
1036
1037#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1039pub struct SyncConfig {
1040 #[serde(default)]
1042 pub extra_paths: Vec<String>,
1043 #[serde(default)]
1045 pub text_replacements: Vec<TextReplacement>,
1046}
1047
1048#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1053#[serde(deny_unknown_fields)]
1054pub struct CitationAuthor {
1055 #[serde(default, alias = "family-names")]
1057 pub family_names: Option<String>,
1058 #[serde(default, alias = "given-names")]
1060 pub given_names: Option<String>,
1061 #[serde(default)]
1063 pub name: Option<String>,
1064 #[serde(default)]
1066 pub email: Option<String>,
1067 #[serde(default)]
1069 pub orcid: Option<String>,
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct CitationConfig {
1085 pub title: String,
1087 #[serde(rename = "abstract")]
1089 pub abstract_: String,
1090 pub authors: Vec<CitationAuthor>,
1093 #[serde(default = "default_citation_message")]
1095 pub message: String,
1096 #[serde(rename = "repository-code", alias = "repository_code")]
1098 pub repository_code: String,
1099 #[serde(default)]
1101 pub url: Option<String>,
1102 #[serde(default)]
1105 pub license: Option<String>,
1106 #[serde(default, rename = "date-released", alias = "date_released")]
1108 pub date_released: Option<String>,
1109 #[serde(default)]
1111 pub doi: Option<String>,
1112}
1113
1114fn default_citation_message() -> String {
1115 "If you use this software, please cite it using the metadata below.".to_string()
1116}