1pub mod dependency_spec;
13pub mod permissions;
14pub mod project_config;
15pub mod sandbox;
16
17pub use dependency_spec::*;
19pub use permissions::*;
20pub use project_config::*;
21pub use sandbox::SandboxSection;
22
23pub(crate) use project_config::toml_to_json;
25
26#[cfg(test)]
27mod tests {
28 use super::*;
29 use std::io::Write;
30 use std::path::PathBuf;
31
32 #[test]
33 fn test_parse_minimal_config() {
34 let toml_str = r#"
35[project]
36name = "test-project"
37version = "0.1.0"
38"#;
39 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
40 assert_eq!(config.project.name, "test-project");
41 assert_eq!(config.project.version, "0.1.0");
42 assert!(config.modules.paths.is_empty());
43 assert!(config.extensions.is_empty());
44 }
45
46 #[test]
47 fn test_parse_empty_config() {
48 let config: ShapeProject = parse_shape_project_toml("").unwrap();
49 assert_eq!(config.project.name, "");
50 assert!(config.modules.paths.is_empty());
51 }
52
53 #[test]
54 fn test_parse_full_config() {
55 let toml_str = r#"
56[project]
57name = "my-analysis"
58version = "0.1.0"
59
60[modules]
61paths = ["lib", "vendor"]
62
63[dependencies]
64
65[[extensions]]
66name = "market-data"
67path = "./libshape_plugin_market_data.so"
68
69[extensions.config]
70duckdb_path = "/path/to/market.duckdb"
71default_timeframe = "1d"
72"#;
73 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
74 assert_eq!(config.project.name, "my-analysis");
75 assert_eq!(config.modules.paths, vec!["lib", "vendor"]);
76 assert_eq!(config.extensions.len(), 1);
77 assert_eq!(config.extensions[0].name, "market-data");
78 assert_eq!(
79 config.extensions[0].config.get("default_timeframe"),
80 Some(&toml::Value::String("1d".to_string()))
81 );
82 }
83
84 #[test]
85 fn test_parse_config_with_entry() {
86 let toml_str = r#"
87[project]
88name = "my-analysis"
89version = "0.1.0"
90entry = "src/main.shape"
91"#;
92 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
93 assert_eq!(config.project.entry, Some("src/main.shape".to_string()));
94 }
95
96 #[test]
97 fn test_parse_config_without_entry() {
98 let toml_str = r#"
99[project]
100name = "test"
101version = "1.0.0"
102"#;
103 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
104 assert_eq!(config.project.entry, None);
105 }
106
107 #[test]
108 fn test_find_project_root_in_current_dir() {
109 let tmp = tempfile::tempdir().unwrap();
110 let toml_path = tmp.path().join("shape.toml");
111 let mut f = std::fs::File::create(&toml_path).unwrap();
112 writeln!(
113 f,
114 r#"
115[project]
116name = "found"
117version = "1.0.0"
118
119[modules]
120paths = ["src"]
121"#
122 )
123 .unwrap();
124
125 let result = find_project_root(tmp.path());
126 assert!(result.is_some());
127 let root = result.unwrap();
128 assert_eq!(root.root_path, tmp.path());
129 assert_eq!(root.config.project.name, "found");
130 }
131
132 #[test]
133 fn test_find_project_root_walks_up() {
134 let tmp = tempfile::tempdir().unwrap();
135 let toml_path = tmp.path().join("shape.toml");
137 let mut f = std::fs::File::create(&toml_path).unwrap();
138 writeln!(
139 f,
140 r#"
141[project]
142name = "parent"
143"#
144 )
145 .unwrap();
146
147 let nested = tmp.path().join("a").join("b").join("c");
149 std::fs::create_dir_all(&nested).unwrap();
150
151 let result = find_project_root(&nested);
152 assert!(result.is_some());
153 let root = result.unwrap();
154 assert_eq!(root.root_path, tmp.path());
155 assert_eq!(root.config.project.name, "parent");
156 }
157
158 #[test]
159 fn test_find_project_root_none_when_missing() {
160 let tmp = tempfile::tempdir().unwrap();
161 let nested = tmp.path().join("empty_dir");
162 std::fs::create_dir_all(&nested).unwrap();
163
164 let result = find_project_root(&nested);
165 let _ = result;
169 }
170
171 #[test]
172 fn test_resolved_module_paths() {
173 let root = ProjectRoot {
174 root_path: PathBuf::from("/home/user/project"),
175 config: ShapeProject {
176 modules: ModulesSection {
177 paths: vec!["lib".to_string(), "vendor".to_string()],
178 },
179 ..Default::default()
180 },
181 };
182
183 let resolved = root.resolved_module_paths();
184 assert_eq!(resolved.len(), 2);
185 assert_eq!(resolved[0], PathBuf::from("/home/user/project/lib"));
186 assert_eq!(resolved[1], PathBuf::from("/home/user/project/vendor"));
187 }
188
189 #[test]
192 fn test_parse_version_only_dependency() {
193 let toml_str = r#"
194[project]
195name = "dep-test"
196version = "1.0.0"
197
198[dependencies]
199finance = "0.1.0"
200"#;
201 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
202 assert_eq!(
203 config.dependencies.get("finance"),
204 Some(&DependencySpec::Version("0.1.0".to_string()))
205 );
206 }
207
208 #[test]
209 fn test_parse_path_dependency() {
210 let toml_str = r#"
211[dependencies]
212my-utils = { path = "../utils" }
213"#;
214 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
215 match config.dependencies.get("my-utils").unwrap() {
216 DependencySpec::Detailed(d) => {
217 assert_eq!(d.path.as_deref(), Some("../utils"));
218 assert!(d.git.is_none());
219 assert!(d.version.is_none());
220 }
221 other => panic!("expected Detailed, got {:?}", other),
222 }
223 }
224
225 #[test]
226 fn test_parse_git_dependency() {
227 let toml_str = r#"
228[dependencies]
229plotting = { git = "https://github.com/org/plot.git", tag = "v1.0" }
230"#;
231 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
232 match config.dependencies.get("plotting").unwrap() {
233 DependencySpec::Detailed(d) => {
234 assert_eq!(d.git.as_deref(), Some("https://github.com/org/plot.git"));
235 assert_eq!(d.tag.as_deref(), Some("v1.0"));
236 assert!(d.branch.is_none());
237 assert!(d.rev.is_none());
238 assert!(d.path.is_none());
239 }
240 other => panic!("expected Detailed, got {:?}", other),
241 }
242 }
243
244 #[test]
245 fn test_parse_git_dependency_with_branch() {
246 let toml_str = r#"
247[dependencies]
248my-lib = { git = "https://github.com/org/lib.git", branch = "develop" }
249"#;
250 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
251 match config.dependencies.get("my-lib").unwrap() {
252 DependencySpec::Detailed(d) => {
253 assert_eq!(d.git.as_deref(), Some("https://github.com/org/lib.git"));
254 assert_eq!(d.branch.as_deref(), Some("develop"));
255 }
256 other => panic!("expected Detailed, got {:?}", other),
257 }
258 }
259
260 #[test]
261 fn test_parse_git_dependency_with_rev() {
262 let toml_str = r#"
263[dependencies]
264pinned = { git = "https://github.com/org/pinned.git", rev = "abc1234" }
265"#;
266 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
267 match config.dependencies.get("pinned").unwrap() {
268 DependencySpec::Detailed(d) => {
269 assert_eq!(d.rev.as_deref(), Some("abc1234"));
270 }
271 other => panic!("expected Detailed, got {:?}", other),
272 }
273 }
274
275 #[test]
276 fn test_parse_dev_dependencies() {
277 let toml_str = r#"
278[project]
279name = "test"
280version = "1.0.0"
281
282[dev-dependencies]
283test-utils = "0.2.0"
284mock-data = { path = "../mocks" }
285"#;
286 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
287 assert_eq!(config.dev_dependencies.len(), 2);
288 assert_eq!(
289 config.dev_dependencies.get("test-utils"),
290 Some(&DependencySpec::Version("0.2.0".to_string()))
291 );
292 match config.dev_dependencies.get("mock-data").unwrap() {
293 DependencySpec::Detailed(d) => {
294 assert_eq!(d.path.as_deref(), Some("../mocks"));
295 }
296 other => panic!("expected Detailed, got {:?}", other),
297 }
298 }
299
300 #[test]
301 fn test_parse_build_section() {
302 let toml_str = r#"
303[build]
304target = "native"
305opt_level = 2
306output = "dist/"
307"#;
308 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
309 assert_eq!(config.build.target.as_deref(), Some("native"));
310 assert_eq!(config.build.opt_level, Some(2));
311 assert_eq!(config.build.output.as_deref(), Some("dist/"));
312 }
313
314 #[test]
315 fn test_parse_project_extended_fields() {
316 let toml_str = r#"
317[project]
318name = "full-project"
319version = "2.0.0"
320authors = ["Alice", "Bob"]
321shape-version = "0.5.0"
322license = "MIT"
323repository = "https://github.com/org/project"
324entry = "main.shape"
325"#;
326 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
327 assert_eq!(config.project.name, "full-project");
328 assert_eq!(config.project.version, "2.0.0");
329 assert_eq!(config.project.authors, vec!["Alice", "Bob"]);
330 assert_eq!(config.project.shape_version.as_deref(), Some("0.5.0"));
331 assert_eq!(config.project.license.as_deref(), Some("MIT"));
332 assert_eq!(
333 config.project.repository.as_deref(),
334 Some("https://github.com/org/project")
335 );
336 assert_eq!(config.project.entry.as_deref(), Some("main.shape"));
337 }
338
339 #[test]
340 fn test_parse_full_config_with_all_sections() {
341 let toml_str = r#"
342[project]
343name = "mega-project"
344version = "1.0.0"
345authors = ["Dev"]
346shape-version = "0.5.0"
347license = "Apache-2.0"
348repository = "https://github.com/org/mega"
349entry = "src/main.shape"
350
351[modules]
352paths = ["lib", "vendor"]
353
354[dependencies]
355finance = "0.1.0"
356my-utils = { path = "../utils" }
357plotting = { git = "https://github.com/org/plot.git", tag = "v1.0" }
358
359[dev-dependencies]
360test-helpers = "0.3.0"
361
362[build]
363target = "bytecode"
364opt_level = 1
365output = "out/"
366
367[[extensions]]
368name = "market-data"
369path = "./plugins/market.so"
370"#;
371 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
372 assert_eq!(config.project.name, "mega-project");
373 assert_eq!(config.project.authors, vec!["Dev"]);
374 assert_eq!(config.project.shape_version.as_deref(), Some("0.5.0"));
375 assert_eq!(config.project.license.as_deref(), Some("Apache-2.0"));
376 assert_eq!(config.modules.paths, vec!["lib", "vendor"]);
377 assert_eq!(config.dependencies.len(), 3);
378 assert_eq!(config.dev_dependencies.len(), 1);
379 assert_eq!(config.build.target.as_deref(), Some("bytecode"));
380 assert_eq!(config.build.opt_level, Some(1));
381 assert_eq!(config.extensions.len(), 1);
382 }
383
384 #[test]
385 fn test_validate_valid_project() {
386 let toml_str = r#"
387[project]
388name = "valid"
389version = "1.0.0"
390
391[dependencies]
392finance = "0.1.0"
393utils = { path = "../utils" }
394lib = { git = "https://example.com/lib.git", tag = "v1" }
395
396[build]
397opt_level = 2
398"#;
399 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
400 let errors = config.validate();
401 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
402 }
403
404 #[test]
405 fn test_validate_catches_path_and_git() {
406 let toml_str = r#"
407[dependencies]
408bad-dep = { path = "../local", git = "https://example.com/repo.git", tag = "v1" }
409"#;
410 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
411 let errors = config.validate();
412 assert!(
413 errors
414 .iter()
415 .any(|e| e.contains("bad-dep") && e.contains("path") && e.contains("git"))
416 );
417 }
418
419 #[test]
420 fn test_validate_catches_git_without_ref() {
421 let toml_str = r#"
422[dependencies]
423no-ref = { git = "https://example.com/repo.git" }
424"#;
425 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
426 let errors = config.validate();
427 assert!(
428 errors
429 .iter()
430 .any(|e| e.contains("no-ref") && e.contains("tag"))
431 );
432 }
433
434 #[test]
435 fn test_validate_git_with_branch_is_ok() {
436 let toml_str = r#"
437[dependencies]
438ok-dep = { git = "https://example.com/repo.git", branch = "main" }
439"#;
440 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
441 let errors = config.validate();
442 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
443 }
444
445 #[test]
446 fn test_validate_catches_opt_level_too_high() {
447 let toml_str = r#"
448[build]
449opt_level = 5
450"#;
451 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
452 let errors = config.validate();
453 assert!(
454 errors
455 .iter()
456 .any(|e| e.contains("opt_level") && e.contains("5"))
457 );
458 }
459
460 #[test]
461 fn test_validate_catches_empty_project_name() {
462 let toml_str = r#"
463[project]
464version = "1.0.0"
465"#;
466 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
467 let errors = config.validate();
468 assert!(errors.iter().any(|e| e.contains("project.name")));
469 }
470
471 #[test]
472 fn test_validate_dev_dependencies_errors() {
473 let toml_str = r#"
474[dev-dependencies]
475bad = { path = "../x", git = "https://example.com/x.git", tag = "v1" }
476"#;
477 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
478 let errors = config.validate();
479 assert!(
480 errors
481 .iter()
482 .any(|e| e.contains("dev-dependencies") && e.contains("bad"))
483 );
484 }
485
486 #[test]
487 fn test_empty_config_still_parses() {
488 let config: ShapeProject = parse_shape_project_toml("").unwrap();
489 assert!(config.dependencies.is_empty());
490 assert!(config.dev_dependencies.is_empty());
491 assert!(config.build.target.is_none());
492 assert!(config.build.opt_level.is_none());
493 assert!(config.project.authors.is_empty());
494 assert!(config.project.shape_version.is_none());
495 }
496
497 #[test]
498 fn test_mixed_dependency_types() {
499 let toml_str = r#"
500[dependencies]
501simple = "1.0.0"
502local = { path = "./local" }
503remote = { git = "https://example.com/repo.git", rev = "deadbeef" }
504versioned = { version = "2.0.0" }
505"#;
506 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
507 assert_eq!(config.dependencies.len(), 4);
508 assert!(matches!(
509 config.dependencies.get("simple"),
510 Some(DependencySpec::Version(_))
511 ));
512 assert!(matches!(
513 config.dependencies.get("local"),
514 Some(DependencySpec::Detailed(_))
515 ));
516 assert!(matches!(
517 config.dependencies.get("remote"),
518 Some(DependencySpec::Detailed(_))
519 ));
520 assert!(matches!(
521 config.dependencies.get("versioned"),
522 Some(DependencySpec::Detailed(_))
523 ));
524 }
525
526 #[test]
527 fn test_parse_config_with_extension_sections() {
528 let toml_str = r#"
529[project]
530name = "test"
531version = "1.0.0"
532
533[native-dependencies]
534libm = { linux = "libm.so.6", macos = "libm.dylib" }
535
536[custom-config]
537key = "value"
538"#;
539 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
540 assert_eq!(config.project.name, "test");
541 assert_eq!(config.extension_section_names().len(), 2);
542 assert!(
543 config
544 .extension_sections
545 .contains_key("native-dependencies")
546 );
547 assert!(config.extension_sections.contains_key("custom-config"));
548
549 let json = config.extension_section_as_json("custom-config").unwrap();
551 assert_eq!(json["key"], "value");
552 }
553
554 #[test]
555 fn test_parse_native_dependencies_section_typed() {
556 let section: toml::Value = toml::from_str(
557 r#"
558libm = "libm.so.6"
559duckdb = { linux = "libduckdb.so", macos = "libduckdb.dylib", windows = "duckdb.dll" }
560"#,
561 )
562 .expect("valid native dependency section");
563
564 let parsed =
565 parse_native_dependencies_section(§ion).expect("native dependencies should parse");
566 assert!(matches!(
567 parsed.get("libm"),
568 Some(NativeDependencySpec::Simple(v)) if v == "libm.so.6"
569 ));
570 assert!(matches!(
571 parsed.get("duckdb"),
572 Some(NativeDependencySpec::Detailed(_))
573 ));
574 }
575
576 #[test]
577 fn test_native_dependency_provider_parsing() {
578 let section: toml::Value = toml::from_str(
579 r#"
580libm = "libm.so.6"
581local_lib = "./native/libfoo.so"
582vendored = { provider = "vendored", path = "./vendor/libduckdb.so", version = "1.2.0", cache_key = "duckdb-1.2.0" }
583"#,
584 )
585 .expect("valid native dependency section");
586
587 let parsed =
588 parse_native_dependencies_section(§ion).expect("native dependencies should parse");
589
590 let libm = parsed.get("libm").expect("libm");
591 assert_eq!(libm.provider_for_host(), NativeDependencyProvider::System);
592 assert_eq!(libm.declared_version(), None);
593
594 let local = parsed.get("local_lib").expect("local_lib");
595 assert_eq!(local.provider_for_host(), NativeDependencyProvider::Path);
596
597 let vendored = parsed.get("vendored").expect("vendored");
598 assert_eq!(
599 vendored.provider_for_host(),
600 NativeDependencyProvider::Vendored
601 );
602 assert_eq!(vendored.declared_version(), Some("1.2.0"));
603 assert_eq!(vendored.cache_key(), Some("duckdb-1.2.0"));
604 }
605
606 #[test]
607 fn test_native_dependency_target_specific_resolution() {
608 let section: toml::Value = toml::from_str(
609 r#"
610duckdb = { provider = "vendored", targets = { "linux-x86_64-gnu" = "native/linux-x86_64-gnu/libduckdb.so", "linux-aarch64-gnu" = "native/linux-aarch64-gnu/libduckdb.so", linux = "legacy-linux.so" } }
611"#,
612 )
613 .expect("valid native dependency section");
614
615 let parsed =
616 parse_native_dependencies_section(§ion).expect("native dependencies should parse");
617 let duckdb = parsed.get("duckdb").expect("duckdb");
618
619 let linux_x86 = NativeTarget {
620 os: "linux".to_string(),
621 arch: "x86_64".to_string(),
622 env: Some("gnu".to_string()),
623 };
624 assert_eq!(
625 duckdb.resolve_for_target(&linux_x86).as_deref(),
626 Some("native/linux-x86_64-gnu/libduckdb.so")
627 );
628
629 let linux_arm = NativeTarget {
630 os: "linux".to_string(),
631 arch: "aarch64".to_string(),
632 env: Some("gnu".to_string()),
633 };
634 assert_eq!(
635 duckdb.resolve_for_target(&linux_arm).as_deref(),
636 Some("native/linux-aarch64-gnu/libduckdb.so")
637 );
638
639 let linux_unknown = NativeTarget {
640 os: "linux".to_string(),
641 arch: "riscv64".to_string(),
642 env: Some("gnu".to_string()),
643 };
644 assert_eq!(
645 duckdb.resolve_for_target(&linux_unknown).as_deref(),
646 Some("legacy-linux.so")
647 );
648 }
649
650 #[test]
651 fn test_project_native_dependencies_from_extension_section() {
652 let toml_str = r#"
653[project]
654name = "native-deps"
655version = "1.0.0"
656
657[native-dependencies]
658libm = "libm.so.6"
659"#;
660 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
661 let deps = config
662 .native_dependencies()
663 .expect("native deps should parse");
664 assert!(deps.contains_key("libm"));
665 }
666
667 #[test]
668 fn test_validate_with_claimed_sections() {
669 let toml_str = r#"
670[project]
671name = "test"
672version = "1.0.0"
673
674[native-dependencies]
675libm = { linux = "libm.so.6" }
676
677[typo-section]
678foo = "bar"
679"#;
680 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
681 let mut claimed = std::collections::HashSet::new();
682 claimed.insert("native-dependencies".to_string());
683
684 let errors = config.validate_with_claimed_sections(&claimed);
685 assert!(
686 errors
687 .iter()
688 .any(|e| e.contains("typo-section") && e.contains("not claimed"))
689 );
690 assert!(!errors.iter().any(|e| e.contains("native-dependencies")));
691 }
692
693 #[test]
694 fn test_extension_sections_empty_by_default() {
695 let config: ShapeProject = parse_shape_project_toml("").unwrap();
696 assert!(config.extension_sections.is_empty());
697 }
698
699 #[test]
702 fn test_no_permissions_section_defaults_to_full() {
703 let config: ShapeProject = parse_shape_project_toml("").unwrap();
704 assert!(config.permissions.is_none());
705 let pset = config.effective_permission_set();
706 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
707 assert!(pset.contains(&shape_abi_v1::Permission::FsWrite));
708 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
709 assert!(pset.contains(&shape_abi_v1::Permission::Process));
710 }
711
712 #[test]
713 fn test_parse_permissions_section() {
714 let toml_str = r#"
715[project]
716name = "perms-test"
717version = "1.0.0"
718
719[permissions]
720"fs.read" = true
721"fs.write" = false
722"net.connect" = true
723"net.listen" = false
724process = false
725env = true
726time = true
727random = false
728"#;
729 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
730 let perms = config.permissions.as_ref().unwrap();
731 assert_eq!(perms.fs_read, Some(true));
732 assert_eq!(perms.fs_write, Some(false));
733 assert_eq!(perms.net_connect, Some(true));
734 assert_eq!(perms.net_listen, Some(false));
735 assert_eq!(perms.process, Some(false));
736 assert_eq!(perms.env, Some(true));
737 assert_eq!(perms.time, Some(true));
738 assert_eq!(perms.random, Some(false));
739
740 let pset = config.effective_permission_set();
741 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
742 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
743 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
744 assert!(!pset.contains(&shape_abi_v1::Permission::NetListen));
745 assert!(!pset.contains(&shape_abi_v1::Permission::Process));
746 assert!(pset.contains(&shape_abi_v1::Permission::Env));
747 assert!(pset.contains(&shape_abi_v1::Permission::Time));
748 assert!(!pset.contains(&shape_abi_v1::Permission::Random));
749 }
750
751 #[test]
752 fn test_parse_permissions_with_scoped_fs() {
753 let toml_str = r#"
754[permissions]
755"fs.read" = true
756
757[permissions.fs]
758allowed = ["./data", "/tmp/cache"]
759read_only = ["./config"]
760
761[permissions.net]
762allowed_hosts = ["api.example.com", "*.internal.corp"]
763"#;
764 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
765 let perms = config.permissions.as_ref().unwrap();
766 let fs = perms.fs.as_ref().unwrap();
767 assert_eq!(fs.allowed, vec!["./data", "/tmp/cache"]);
768 assert_eq!(fs.read_only, vec!["./config"]);
769
770 let net = perms.net.as_ref().unwrap();
771 assert_eq!(
772 net.allowed_hosts,
773 vec!["api.example.com", "*.internal.corp"]
774 );
775
776 let pset = perms.to_permission_set();
777 assert!(pset.contains(&shape_abi_v1::Permission::FsScoped));
778 assert!(pset.contains(&shape_abi_v1::Permission::NetScoped));
779
780 let constraints = perms.to_scope_constraints();
781 assert_eq!(constraints.allowed_paths.len(), 3); assert_eq!(constraints.allowed_hosts.len(), 2);
783 }
784
785 #[test]
786 fn test_permissions_shorthand_pure() {
787 let section = PermissionsSection::from_shorthand("pure").unwrap();
788 let pset = section.to_permission_set();
789 assert!(pset.is_empty());
790 }
791
792 #[test]
793 fn test_permissions_shorthand_readonly() {
794 let section = PermissionsSection::from_shorthand("readonly").unwrap();
795 let pset = section.to_permission_set();
796 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
797 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
798 assert!(!pset.contains(&shape_abi_v1::Permission::NetConnect));
799 assert!(pset.contains(&shape_abi_v1::Permission::Env));
800 assert!(pset.contains(&shape_abi_v1::Permission::Time));
801 }
802
803 #[test]
804 fn test_permissions_shorthand_full() {
805 let section = PermissionsSection::from_shorthand("full").unwrap();
806 let pset = section.to_permission_set();
807 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
808 assert!(pset.contains(&shape_abi_v1::Permission::FsWrite));
809 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
810 assert!(pset.contains(&shape_abi_v1::Permission::NetListen));
811 assert!(pset.contains(&shape_abi_v1::Permission::Process));
812 }
813
814 #[test]
815 fn test_permissions_shorthand_unknown() {
816 assert!(PermissionsSection::from_shorthand("unknown").is_none());
817 }
818
819 #[test]
820 fn test_permissions_unset_fields_default_to_true() {
821 let toml_str = r#"
822[permissions]
823"fs.write" = false
824"#;
825 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
826 let pset = config.effective_permission_set();
827 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
829 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
831 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
832 assert!(pset.contains(&shape_abi_v1::Permission::Process));
833 }
834
835 #[test]
838 fn test_parse_sandbox_section() {
839 let toml_str = r#"
840[sandbox]
841enabled = true
842deterministic = true
843seed = 42
844memory_limit = "64MB"
845time_limit = "10s"
846virtual_fs = true
847
848[sandbox.seed_files]
849"data/input.csv" = "./real_data/input.csv"
850"config/settings.toml" = "./test_settings.toml"
851"#;
852 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
853 let sandbox = config.sandbox.as_ref().unwrap();
854 assert!(sandbox.enabled);
855 assert!(sandbox.deterministic);
856 assert_eq!(sandbox.seed, Some(42));
857 assert_eq!(sandbox.memory_limit.as_deref(), Some("64MB"));
858 assert_eq!(sandbox.time_limit.as_deref(), Some("10s"));
859 assert!(sandbox.virtual_fs);
860 assert_eq!(sandbox.seed_files.len(), 2);
861 assert_eq!(
862 sandbox.seed_files.get("data/input.csv").unwrap(),
863 "./real_data/input.csv"
864 );
865 }
866
867 #[test]
868 fn test_sandbox_memory_limit_parsing() {
869 let section = SandboxSection {
870 memory_limit: Some("64MB".to_string()),
871 ..Default::default()
872 };
873 assert_eq!(section.memory_limit_bytes(), Some(64 * 1024 * 1024));
874
875 let section = SandboxSection {
876 memory_limit: Some("1GB".to_string()),
877 ..Default::default()
878 };
879 assert_eq!(section.memory_limit_bytes(), Some(1024 * 1024 * 1024));
880
881 let section = SandboxSection {
882 memory_limit: Some("512KB".to_string()),
883 ..Default::default()
884 };
885 assert_eq!(section.memory_limit_bytes(), Some(512 * 1024));
886 }
887
888 #[test]
889 fn test_sandbox_time_limit_parsing() {
890 let section = SandboxSection {
891 time_limit: Some("10s".to_string()),
892 ..Default::default()
893 };
894 assert_eq!(section.time_limit_ms(), Some(10_000));
895
896 let section = SandboxSection {
897 time_limit: Some("500ms".to_string()),
898 ..Default::default()
899 };
900 assert_eq!(section.time_limit_ms(), Some(500));
901
902 let section = SandboxSection {
903 time_limit: Some("2m".to_string()),
904 ..Default::default()
905 };
906 assert_eq!(section.time_limit_ms(), Some(120_000));
907 }
908
909 #[test]
910 fn test_sandbox_invalid_limits() {
911 let section = SandboxSection {
912 memory_limit: Some("abc".to_string()),
913 ..Default::default()
914 };
915 assert!(section.memory_limit_bytes().is_none());
916
917 let section = SandboxSection {
918 time_limit: Some("forever".to_string()),
919 ..Default::default()
920 };
921 assert!(section.time_limit_ms().is_none());
922 }
923
924 #[test]
925 fn test_validate_sandbox_invalid_memory_limit() {
926 let toml_str = r#"
927[sandbox]
928enabled = true
929memory_limit = "xyz"
930"#;
931 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
932 let errors = config.validate();
933 assert!(errors.iter().any(|e| e.contains("sandbox.memory_limit")));
934 }
935
936 #[test]
937 fn test_validate_sandbox_invalid_time_limit() {
938 let toml_str = r#"
939[sandbox]
940enabled = true
941time_limit = "forever"
942"#;
943 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
944 let errors = config.validate();
945 assert!(errors.iter().any(|e| e.contains("sandbox.time_limit")));
946 }
947
948 #[test]
949 fn test_validate_sandbox_deterministic_requires_seed() {
950 let toml_str = r#"
951[sandbox]
952enabled = true
953deterministic = true
954"#;
955 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
956 let errors = config.validate();
957 assert!(errors.iter().any(|e| e.contains("sandbox.seed")));
958 }
959
960 #[test]
961 fn test_validate_sandbox_deterministic_with_seed_is_ok() {
962 let toml_str = r#"
963[sandbox]
964enabled = true
965deterministic = true
966seed = 123
967"#;
968 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
969 let errors = config.validate();
970 assert!(
971 !errors.iter().any(|e| e.contains("sandbox")),
972 "expected no sandbox errors, got: {:?}",
973 errors
974 );
975 }
976
977 #[test]
978 fn test_no_sandbox_section_is_none() {
979 let config: ShapeProject = parse_shape_project_toml("").unwrap();
980 assert!(config.sandbox.is_none());
981 }
982
983 #[test]
986 fn test_dependency_with_permission_shorthand() {
987 let toml_str = r#"
988[dependencies]
989analytics = { path = "../analytics", permissions = "pure" }
990"#;
991 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
992 match config.dependencies.get("analytics").unwrap() {
993 DependencySpec::Detailed(d) => {
994 assert_eq!(d.path.as_deref(), Some("../analytics"));
995 match d.permissions.as_ref().unwrap() {
996 PermissionPreset::Shorthand(s) => assert_eq!(s, "pure"),
997 other => panic!("expected Shorthand, got {:?}", other),
998 }
999 }
1000 other => panic!("expected Detailed, got {:?}", other),
1001 }
1002 }
1003
1004 #[test]
1005 fn test_dependency_without_permissions() {
1006 let toml_str = r#"
1007[dependencies]
1008utils = { path = "../utils" }
1009"#;
1010 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1011 match config.dependencies.get("utils").unwrap() {
1012 DependencySpec::Detailed(d) => {
1013 assert!(d.permissions.is_none());
1014 }
1015 other => panic!("expected Detailed, got {:?}", other),
1016 }
1017 }
1018
1019 #[test]
1022 fn test_full_config_with_permissions_and_sandbox() {
1023 let toml_str = r#"
1024[project]
1025name = "full-project"
1026version = "1.0.0"
1027
1028[permissions]
1029"fs.read" = true
1030"fs.write" = false
1031"net.connect" = true
1032"net.listen" = false
1033process = false
1034env = true
1035time = true
1036random = false
1037
1038[permissions.fs]
1039allowed = ["./data"]
1040
1041[sandbox]
1042enabled = false
1043deterministic = false
1044virtual_fs = false
1045"#;
1046 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1047 assert!(config.permissions.is_some());
1048 assert!(config.sandbox.is_some());
1049 let errors = config.validate();
1050 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1051 }
1052
1053 #[test]
1056 fn test_try_find_project_root_returns_error_for_malformed_toml() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 std::fs::write(tmp.path().join("shape.toml"), "this is not valid toml {{{").unwrap();
1059
1060 let result = try_find_project_root(tmp.path());
1061 assert!(result.is_err());
1062 let err = result.unwrap_err();
1063 assert!(
1064 err.contains("Malformed shape.toml"),
1065 "Expected 'Malformed shape.toml' in error, got: {}",
1066 err
1067 );
1068 }
1069
1070 #[test]
1071 fn test_try_find_project_root_returns_ok_none_when_no_toml() {
1072 let tmp = tempfile::tempdir().unwrap();
1073 let nested = tmp.path().join("empty_dir");
1074 std::fs::create_dir_all(&nested).unwrap();
1075
1076 let result = try_find_project_root(&nested);
1077 assert!(result.is_ok());
1080 }
1081
1082 #[test]
1083 fn test_try_find_project_root_parses_valid_toml() {
1084 let tmp = tempfile::tempdir().unwrap();
1085 let mut f = std::fs::File::create(tmp.path().join("shape.toml")).unwrap();
1086 writeln!(
1087 f,
1088 r#"
1089[project]
1090name = "try-test"
1091version = "1.0.0"
1092"#
1093 )
1094 .unwrap();
1095
1096 let result = try_find_project_root(tmp.path());
1097 assert!(result.is_ok());
1098 let root = result.unwrap().unwrap();
1099 assert_eq!(root.config.project.name, "try-test");
1100 }
1101
1102 #[test]
1103 fn test_find_project_root_returns_none_for_malformed_toml() {
1104 let tmp = tempfile::tempdir().unwrap();
1106 std::fs::write(tmp.path().join("shape.toml"), "[invalid\nbroken toml").unwrap();
1107
1108 let result = find_project_root(tmp.path());
1109 assert!(result.is_none());
1110 }
1111}