Skip to main content

shape_runtime/project/
mod.rs

1//! Project root detection and shape.toml configuration
2//!
3//! Discovers the project root by walking up from a starting directory
4//! looking for a `shape.toml` file, then parses its configuration.
5//!
6//! This module is split into submodules for maintainability:
7//! - [`dependency_spec`] — dependency specification types and native dependency handling
8//! - [`permissions`] — permission-related types and logic
9//! - [`sandbox`] — sandbox configuration and parsing helpers
10//! - [`project_config`] — project configuration parsing and discovery
11
12pub mod dependency_spec;
13pub mod permissions;
14pub mod project_config;
15pub mod sandbox;
16
17// Re-export all public items at the module root to preserve the existing API.
18pub use dependency_spec::*;
19pub use permissions::*;
20pub use project_config::*;
21pub use sandbox::SandboxSection;
22
23// Re-export crate-internal items used by other modules.
24pub(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        // Create shape.toml in root
136        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        // Create nested directory
148        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        // May or may not be None depending on whether a shape.toml exists
166        // above tempdir. In practice, tempdir is deep enough that there won't be one.
167        // We just verify it doesn't panic.
168        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    // --- New tests for expanded schema ---
190
191    #[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        // Test JSON conversion
550        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(&section).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(&section).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(&section).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    // --- Permissions section tests ---
700
701    #[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); // ./data, /tmp/cache, ./config
782        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        // Explicitly set to false
828        assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
829        // Not set — defaults to true
830        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    // --- Sandbox section tests ---
836
837    #[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    // --- Dependency-level permissions ---
984
985    #[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    // --- Full config round-trip ---
1020
1021    #[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    // --- MED-22: Malformed shape.toml error reporting ---
1054
1055    #[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        // Should return Ok(None) — not an error, just no project found.
1078        // (May find a shape.toml above tempdir, so we just verify no panic/error.)
1079        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        // find_project_root should return None (not panic) for malformed TOML
1105        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}