1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
12#[serde(untagged)]
13pub enum DependencySpec {
14 Version(String),
16 Detailed(DetailedDependency),
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
22pub struct DetailedDependency {
23 pub version: Option<String>,
24 pub path: Option<String>,
25 pub git: Option<String>,
26 pub tag: Option<String>,
27 pub branch: Option<String>,
28 pub rev: Option<String>,
29 #[serde(default)]
32 pub permissions: Option<PermissionPreset>,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize, Default)]
37pub struct BuildSection {
38 pub target: Option<String>,
40 #[serde(default)]
42 pub opt_level: Option<u8>,
43 pub output: Option<String>,
45 #[serde(default)]
47 pub external: BuildExternalSection,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize, Default)]
52pub struct BuildExternalSection {
53 #[serde(default)]
55 pub mode: ExternalLockMode,
56}
57
58#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
60#[serde(rename_all = "lowercase")]
61pub enum ExternalLockMode {
62 #[default]
64 Update,
65 Frozen,
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
77#[serde(untagged)]
78pub enum NativeDependencySpec {
79 Simple(String),
80 Detailed(NativeDependencyDetail),
81}
82
83#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
85#[serde(rename_all = "lowercase")]
86pub enum NativeDependencyProvider {
87 System,
89 Path,
91 Vendored,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
97pub struct NativeDependencyDetail {
98 #[serde(default)]
99 pub linux: Option<String>,
100 #[serde(default)]
101 pub macos: Option<String>,
102 #[serde(default)]
103 pub windows: Option<String>,
104 #[serde(default)]
105 pub path: Option<String>,
106 #[serde(default)]
108 pub provider: Option<NativeDependencyProvider>,
109 #[serde(default)]
112 pub version: Option<String>,
113 #[serde(default)]
115 pub cache_key: Option<String>,
116}
117
118impl NativeDependencySpec {
119 pub fn resolve_for_host(&self) -> Option<String> {
121 match self {
122 NativeDependencySpec::Simple(value) => Some(value.clone()),
123 NativeDependencySpec::Detailed(detail) => {
124 #[cfg(target_os = "linux")]
125 {
126 detail
127 .linux
128 .clone()
129 .or_else(|| detail.path.clone())
130 .or_else(|| detail.macos.clone())
131 .or_else(|| detail.windows.clone())
132 }
133 #[cfg(target_os = "macos")]
134 {
135 detail
136 .macos
137 .clone()
138 .or_else(|| detail.path.clone())
139 .or_else(|| detail.linux.clone())
140 .or_else(|| detail.windows.clone())
141 }
142 #[cfg(target_os = "windows")]
143 {
144 detail
145 .windows
146 .clone()
147 .or_else(|| detail.path.clone())
148 .or_else(|| detail.linux.clone())
149 .or_else(|| detail.macos.clone())
150 }
151 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
152 {
153 detail
154 .path
155 .clone()
156 .or_else(|| detail.linux.clone())
157 .or_else(|| detail.macos.clone())
158 .or_else(|| detail.windows.clone())
159 }
160 }
161 }
162 }
163
164 pub fn provider_for_host(&self) -> NativeDependencyProvider {
166 match self {
167 NativeDependencySpec::Simple(value) => {
168 if native_dep_looks_path_like(value) {
169 NativeDependencyProvider::Path
170 } else {
171 NativeDependencyProvider::System
172 }
173 }
174 NativeDependencySpec::Detailed(detail) => {
175 if let Some(provider) = &detail.provider {
176 return provider.clone();
177 }
178 if detail
179 .path
180 .as_deref()
181 .is_some_and(native_dep_looks_path_like)
182 {
183 NativeDependencyProvider::Path
184 } else {
185 NativeDependencyProvider::System
186 }
187 }
188 }
189 }
190
191 pub fn declared_version(&self) -> Option<&str> {
193 match self {
194 NativeDependencySpec::Simple(_) => None,
195 NativeDependencySpec::Detailed(detail) => detail.version.as_deref(),
196 }
197 }
198
199 pub fn cache_key(&self) -> Option<&str> {
201 match self {
202 NativeDependencySpec::Simple(_) => None,
203 NativeDependencySpec::Detailed(detail) => detail.cache_key.as_deref(),
204 }
205 }
206}
207
208fn native_dep_looks_path_like(spec: &str) -> bool {
209 let path = std::path::Path::new(spec);
210 path.is_absolute()
211 || spec.starts_with("./")
212 || spec.starts_with("../")
213 || spec.contains('/')
214 || spec.contains('\\')
215 || (spec.len() >= 2 && spec.as_bytes()[1] == b':')
216}
217
218pub fn parse_native_dependencies_section(
220 section: &toml::Value,
221) -> Result<HashMap<String, NativeDependencySpec>, String> {
222 let table = section
223 .as_table()
224 .ok_or_else(|| "native-dependencies section must be a table".to_string())?;
225
226 let mut out = HashMap::new();
227 for (name, value) in table {
228 let spec: NativeDependencySpec =
229 value.clone().try_into().map_err(|e: toml::de::Error| {
230 format!("native-dependencies.{} has invalid format: {}", name, e)
231 })?;
232 out.insert(name.clone(), spec);
233 }
234 Ok(out)
235}
236
237#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
240#[serde(untagged)]
241pub enum PermissionPreset {
242 Shorthand(String),
244 Table(PermissionsSection),
246}
247
248#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
253pub struct PermissionsSection {
254 #[serde(default, rename = "fs.read")]
255 pub fs_read: Option<bool>,
256 #[serde(default, rename = "fs.write")]
257 pub fs_write: Option<bool>,
258 #[serde(default, rename = "net.connect")]
259 pub net_connect: Option<bool>,
260 #[serde(default, rename = "net.listen")]
261 pub net_listen: Option<bool>,
262 #[serde(default)]
263 pub process: Option<bool>,
264 #[serde(default)]
265 pub env: Option<bool>,
266 #[serde(default)]
267 pub time: Option<bool>,
268 #[serde(default)]
269 pub random: Option<bool>,
270
271 #[serde(default)]
273 pub fs: Option<FsPermissions>,
274 #[serde(default)]
276 pub net: Option<NetPermissions>,
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
281pub struct FsPermissions {
282 #[serde(default)]
284 pub allowed: Vec<String>,
285 #[serde(default)]
287 pub read_only: Vec<String>,
288}
289
290#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
292pub struct NetPermissions {
293 #[serde(default)]
295 pub allowed_hosts: Vec<String>,
296}
297
298#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
300pub struct SandboxSection {
301 #[serde(default)]
303 pub enabled: bool,
304 #[serde(default)]
306 pub deterministic: bool,
307 #[serde(default)]
309 pub seed: Option<u64>,
310 #[serde(default)]
312 pub memory_limit: Option<String>,
313 #[serde(default)]
315 pub time_limit: Option<String>,
316 #[serde(default)]
318 pub virtual_fs: bool,
319 #[serde(default)]
321 pub seed_files: HashMap<String, String>,
322}
323
324impl PermissionsSection {
325 pub fn from_shorthand(name: &str) -> Option<Self> {
331 match name {
332 "pure" => Some(Self {
333 fs_read: Some(false),
334 fs_write: Some(false),
335 net_connect: Some(false),
336 net_listen: Some(false),
337 process: Some(false),
338 env: Some(false),
339 time: Some(false),
340 random: Some(false),
341 fs: None,
342 net: None,
343 }),
344 "readonly" => Some(Self {
345 fs_read: Some(true),
346 fs_write: Some(false),
347 net_connect: Some(false),
348 net_listen: Some(false),
349 process: Some(false),
350 env: Some(true),
351 time: Some(true),
352 random: Some(false),
353 fs: None,
354 net: None,
355 }),
356 "full" => Some(Self {
357 fs_read: Some(true),
358 fs_write: Some(true),
359 net_connect: Some(true),
360 net_listen: Some(true),
361 process: Some(true),
362 env: Some(true),
363 time: Some(true),
364 random: Some(true),
365 fs: None,
366 net: None,
367 }),
368 _ => None,
369 }
370 }
371
372 pub fn to_permission_set(&self) -> shape_abi_v1::PermissionSet {
376 use shape_abi_v1::Permission;
377 let mut set = shape_abi_v1::PermissionSet::pure();
378 if self.fs_read.unwrap_or(true) {
379 set.insert(Permission::FsRead);
380 }
381 if self.fs_write.unwrap_or(true) {
382 set.insert(Permission::FsWrite);
383 }
384 if self.net_connect.unwrap_or(true) {
385 set.insert(Permission::NetConnect);
386 }
387 if self.net_listen.unwrap_or(true) {
388 set.insert(Permission::NetListen);
389 }
390 if self.process.unwrap_or(true) {
391 set.insert(Permission::Process);
392 }
393 if self.env.unwrap_or(true) {
394 set.insert(Permission::Env);
395 }
396 if self.time.unwrap_or(true) {
397 set.insert(Permission::Time);
398 }
399 if self.random.unwrap_or(true) {
400 set.insert(Permission::Random);
401 }
402 if self.fs.as_ref().map_or(false, |fs| {
404 !fs.allowed.is_empty() || !fs.read_only.is_empty()
405 }) {
406 set.insert(Permission::FsScoped);
407 }
408 if self
409 .net
410 .as_ref()
411 .map_or(false, |net| !net.allowed_hosts.is_empty())
412 {
413 set.insert(Permission::NetScoped);
414 }
415 set
416 }
417
418 pub fn to_scope_constraints(&self) -> shape_abi_v1::ScopeConstraints {
420 let mut constraints = shape_abi_v1::ScopeConstraints::none();
421 if let Some(ref fs) = self.fs {
422 let mut paths = fs.allowed.clone();
423 paths.extend(fs.read_only.iter().cloned());
424 constraints.allowed_paths = paths;
425 }
426 if let Some(ref net) = self.net {
427 constraints.allowed_hosts = net.allowed_hosts.clone();
428 }
429 constraints
430 }
431}
432
433impl SandboxSection {
434 pub fn memory_limit_bytes(&self) -> Option<u64> {
436 self.memory_limit.as_ref().and_then(|s| parse_byte_size(s))
437 }
438
439 pub fn time_limit_ms(&self) -> Option<u64> {
441 self.time_limit.as_ref().and_then(|s| parse_duration_ms(s))
442 }
443}
444
445fn parse_byte_size(s: &str) -> Option<u64> {
447 let s = s.trim();
448 let (num_part, suffix) = split_numeric_suffix(s)?;
449 let value: u64 = num_part.parse().ok()?;
450 let multiplier = match suffix.to_uppercase().as_str() {
451 "B" | "" => 1,
452 "KB" | "K" => 1024,
453 "MB" | "M" => 1024 * 1024,
454 "GB" | "G" => 1024 * 1024 * 1024,
455 _ => return None,
456 };
457 Some(value * multiplier)
458}
459
460fn parse_duration_ms(s: &str) -> Option<u64> {
462 let s = s.trim();
463 let (num_part, suffix) = split_numeric_suffix(s)?;
464 let value: u64 = num_part.parse().ok()?;
465 let multiplier = match suffix.to_lowercase().as_str() {
466 "ms" => 1,
467 "s" | "" => 1000,
468 "m" | "min" => 60_000,
469 _ => return None,
470 };
471 Some(value * multiplier)
472}
473
474fn split_numeric_suffix(s: &str) -> Option<(&str, &str)> {
476 let idx = s
477 .find(|c: char| !c.is_ascii_digit() && c != '.')
478 .unwrap_or(s.len());
479 if idx == 0 {
480 return None;
481 }
482 Some((&s[..idx], &s[idx..]))
483}
484
485#[derive(Debug, Clone, Deserialize, Serialize, Default)]
487pub struct ShapeProject {
488 #[serde(default)]
489 pub project: ProjectSection,
490 #[serde(default)]
491 pub modules: ModulesSection,
492 #[serde(default)]
493 pub dependencies: HashMap<String, DependencySpec>,
494 #[serde(default, rename = "dev-dependencies")]
495 pub dev_dependencies: HashMap<String, DependencySpec>,
496 #[serde(default)]
497 pub build: BuildSection,
498 #[serde(default)]
499 pub permissions: Option<PermissionsSection>,
500 #[serde(default)]
501 pub sandbox: Option<SandboxSection>,
502 #[serde(default)]
503 pub extensions: Vec<ExtensionEntry>,
504 #[serde(flatten, default)]
505 pub extension_sections: HashMap<String, toml::Value>,
506}
507
508#[derive(Debug, Clone, Deserialize, Serialize, Default)]
510pub struct ProjectSection {
511 #[serde(default)]
512 pub name: String,
513 #[serde(default)]
514 pub version: String,
515 #[serde(default)]
517 pub entry: Option<String>,
518 #[serde(default)]
519 pub authors: Vec<String>,
520 #[serde(default, rename = "shape-version")]
521 pub shape_version: Option<String>,
522 #[serde(default)]
523 pub license: Option<String>,
524 #[serde(default)]
525 pub repository: Option<String>,
526}
527
528#[derive(Debug, Clone, Deserialize, Serialize, Default)]
530pub struct ModulesSection {
531 #[serde(default)]
532 pub paths: Vec<String>,
533}
534
535#[derive(Debug, Clone, Deserialize, Serialize)]
537pub struct ExtensionEntry {
538 pub name: String,
539 pub path: PathBuf,
540 #[serde(default)]
541 pub config: HashMap<String, toml::Value>,
542}
543
544impl ExtensionEntry {
545 pub fn config_as_json(&self) -> serde_json::Value {
547 toml_to_json(&toml::Value::Table(
548 self.config
549 .iter()
550 .map(|(k, v)| (k.clone(), v.clone()))
551 .collect(),
552 ))
553 }
554}
555
556pub(crate) fn toml_to_json(value: &toml::Value) -> serde_json::Value {
557 match value {
558 toml::Value::String(s) => serde_json::Value::String(s.clone()),
559 toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
560 toml::Value::Float(f) => serde_json::Number::from_f64(*f)
561 .map(serde_json::Value::Number)
562 .unwrap_or(serde_json::Value::Null),
563 toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
564 toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
565 toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()),
566 toml::Value::Table(table) => {
567 let map: serde_json::Map<String, serde_json::Value> = table
568 .iter()
569 .map(|(k, v)| (k.clone(), toml_to_json(v)))
570 .collect();
571 serde_json::Value::Object(map)
572 }
573 }
574}
575
576impl ShapeProject {
577 pub fn validate(&self) -> Vec<String> {
579 let mut errors = Vec::new();
580
581 if self.project.name.is_empty()
583 && (!self.project.version.is_empty()
584 || self.project.entry.is_some()
585 || !self.project.authors.is_empty())
586 {
587 errors.push("project.name must not be empty".to_string());
588 }
589
590 Self::validate_deps(&self.dependencies, "dependencies", &mut errors);
592 Self::validate_deps(&self.dev_dependencies, "dev-dependencies", &mut errors);
593
594 if let Some(level) = self.build.opt_level {
596 if level > 3 {
597 errors.push(format!("build.opt_level must be 0-3, got {}", level));
598 }
599 }
600
601 if let Some(ref sandbox) = self.sandbox {
603 if sandbox.memory_limit.is_some() && sandbox.memory_limit_bytes().is_none() {
604 errors.push(format!(
605 "sandbox.memory_limit: invalid format '{}' (expected e.g. '64MB')",
606 sandbox.memory_limit.as_deref().unwrap_or("")
607 ));
608 }
609 if sandbox.time_limit.is_some() && sandbox.time_limit_ms().is_none() {
610 errors.push(format!(
611 "sandbox.time_limit: invalid format '{}' (expected e.g. '10s')",
612 sandbox.time_limit.as_deref().unwrap_or("")
613 ));
614 }
615 if sandbox.deterministic && sandbox.seed.is_none() {
616 errors
617 .push("sandbox.deterministic is true but sandbox.seed is not set".to_string());
618 }
619 }
620
621 errors
622 }
623
624 pub fn effective_permission_set(&self) -> shape_abi_v1::PermissionSet {
629 match &self.permissions {
630 Some(section) => section.to_permission_set(),
631 None => shape_abi_v1::PermissionSet::full(),
632 }
633 }
634
635 pub fn extension_section_as_json(&self, name: &str) -> Option<serde_json::Value> {
637 self.extension_sections.get(name).map(|v| toml_to_json(v))
638 }
639
640 pub fn native_dependencies(&self) -> Result<HashMap<String, NativeDependencySpec>, String> {
642 match self.extension_sections.get("native-dependencies") {
643 Some(section) => parse_native_dependencies_section(section),
644 None => Ok(HashMap::new()),
645 }
646 }
647
648 pub fn extension_section_names(&self) -> Vec<&str> {
650 self.extension_sections.keys().map(|s| s.as_str()).collect()
651 }
652
653 pub fn validate_with_claimed_sections(
655 &self,
656 claimed: &std::collections::HashSet<String>,
657 ) -> Vec<String> {
658 let mut errors = self.validate();
659 for name in self.extension_section_names() {
660 if !claimed.contains(name) {
661 errors.push(format!(
662 "Unknown section '{}' is not claimed by any loaded extension",
663 name
664 ));
665 }
666 }
667 errors
668 }
669
670 fn validate_deps(
671 deps: &HashMap<String, DependencySpec>,
672 section: &str,
673 errors: &mut Vec<String>,
674 ) {
675 for (name, spec) in deps {
676 if let DependencySpec::Detailed(d) = spec {
677 if d.path.is_some() && d.git.is_some() {
679 errors.push(format!(
680 "{}.{}: cannot specify both 'path' and 'git'",
681 section, name
682 ));
683 }
684 if d.git.is_some() && d.tag.is_none() && d.branch.is_none() && d.rev.is_none() {
686 errors.push(format!(
687 "{}.{}: git dependency should specify 'tag', 'branch', or 'rev'",
688 section, name
689 ));
690 }
691 }
692 }
693 }
694}
695
696#[derive(Debug, Clone)]
698pub struct ProjectRoot {
699 pub root_path: PathBuf,
701 pub config: ShapeProject,
703}
704
705impl ProjectRoot {
706 pub fn resolved_module_paths(&self) -> Vec<PathBuf> {
708 self.config
709 .modules
710 .paths
711 .iter()
712 .map(|p| self.root_path.join(p))
713 .collect()
714 }
715}
716
717pub fn parse_shape_project_toml(content: &str) -> Result<ShapeProject, toml::de::Error> {
722 toml::from_str(content)
723}
724
725pub fn find_project_root(start_dir: &Path) -> Option<ProjectRoot> {
728 let mut current = start_dir.to_path_buf();
729 loop {
730 let candidate = current.join("shape.toml");
731 if candidate.is_file() {
732 let content = std::fs::read_to_string(&candidate).ok()?;
733 let config = parse_shape_project_toml(&content).ok()?;
734 return Some(ProjectRoot {
735 root_path: current,
736 config,
737 });
738 }
739 if !current.pop() {
740 return None;
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use std::io::Write;
749
750 #[test]
751 fn test_parse_minimal_config() {
752 let toml_str = r#"
753[project]
754name = "test-project"
755version = "0.1.0"
756"#;
757 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
758 assert_eq!(config.project.name, "test-project");
759 assert_eq!(config.project.version, "0.1.0");
760 assert!(config.modules.paths.is_empty());
761 assert!(config.extensions.is_empty());
762 }
763
764 #[test]
765 fn test_parse_empty_config() {
766 let config: ShapeProject = parse_shape_project_toml("").unwrap();
767 assert_eq!(config.project.name, "");
768 assert!(config.modules.paths.is_empty());
769 }
770
771 #[test]
772 fn test_parse_full_config() {
773 let toml_str = r#"
774[project]
775name = "my-analysis"
776version = "0.1.0"
777
778[modules]
779paths = ["lib", "vendor"]
780
781[dependencies]
782
783[[extensions]]
784name = "market-data"
785path = "./libshape_plugin_market_data.so"
786
787[extensions.config]
788duckdb_path = "/path/to/market.duckdb"
789default_timeframe = "1d"
790"#;
791 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
792 assert_eq!(config.project.name, "my-analysis");
793 assert_eq!(config.modules.paths, vec!["lib", "vendor"]);
794 assert_eq!(config.extensions.len(), 1);
795 assert_eq!(config.extensions[0].name, "market-data");
796 assert_eq!(
797 config.extensions[0].config.get("default_timeframe"),
798 Some(&toml::Value::String("1d".to_string()))
799 );
800 }
801
802 #[test]
803 fn test_parse_config_with_entry() {
804 let toml_str = r#"
805[project]
806name = "my-analysis"
807version = "0.1.0"
808entry = "src/main.shape"
809"#;
810 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
811 assert_eq!(config.project.entry, Some("src/main.shape".to_string()));
812 }
813
814 #[test]
815 fn test_parse_config_without_entry() {
816 let toml_str = r#"
817[project]
818name = "test"
819version = "1.0.0"
820"#;
821 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
822 assert_eq!(config.project.entry, None);
823 }
824
825 #[test]
826 fn test_find_project_root_in_current_dir() {
827 let tmp = tempfile::tempdir().unwrap();
828 let toml_path = tmp.path().join("shape.toml");
829 let mut f = std::fs::File::create(&toml_path).unwrap();
830 writeln!(
831 f,
832 r#"
833[project]
834name = "found"
835version = "1.0.0"
836
837[modules]
838paths = ["src"]
839"#
840 )
841 .unwrap();
842
843 let result = find_project_root(tmp.path());
844 assert!(result.is_some());
845 let root = result.unwrap();
846 assert_eq!(root.root_path, tmp.path());
847 assert_eq!(root.config.project.name, "found");
848 }
849
850 #[test]
851 fn test_find_project_root_walks_up() {
852 let tmp = tempfile::tempdir().unwrap();
853 let toml_path = tmp.path().join("shape.toml");
855 let mut f = std::fs::File::create(&toml_path).unwrap();
856 writeln!(
857 f,
858 r#"
859[project]
860name = "parent"
861"#
862 )
863 .unwrap();
864
865 let nested = tmp.path().join("a").join("b").join("c");
867 std::fs::create_dir_all(&nested).unwrap();
868
869 let result = find_project_root(&nested);
870 assert!(result.is_some());
871 let root = result.unwrap();
872 assert_eq!(root.root_path, tmp.path());
873 assert_eq!(root.config.project.name, "parent");
874 }
875
876 #[test]
877 fn test_find_project_root_none_when_missing() {
878 let tmp = tempfile::tempdir().unwrap();
879 let nested = tmp.path().join("empty_dir");
880 std::fs::create_dir_all(&nested).unwrap();
881
882 let result = find_project_root(&nested);
883 let _ = result;
887 }
888
889 #[test]
890 fn test_resolved_module_paths() {
891 let root = ProjectRoot {
892 root_path: PathBuf::from("/home/user/project"),
893 config: ShapeProject {
894 modules: ModulesSection {
895 paths: vec!["lib".to_string(), "vendor".to_string()],
896 },
897 ..Default::default()
898 },
899 };
900
901 let resolved = root.resolved_module_paths();
902 assert_eq!(resolved.len(), 2);
903 assert_eq!(resolved[0], PathBuf::from("/home/user/project/lib"));
904 assert_eq!(resolved[1], PathBuf::from("/home/user/project/vendor"));
905 }
906
907 #[test]
910 fn test_parse_version_only_dependency() {
911 let toml_str = r#"
912[project]
913name = "dep-test"
914version = "1.0.0"
915
916[dependencies]
917finance = "0.1.0"
918"#;
919 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
920 assert_eq!(
921 config.dependencies.get("finance"),
922 Some(&DependencySpec::Version("0.1.0".to_string()))
923 );
924 }
925
926 #[test]
927 fn test_parse_path_dependency() {
928 let toml_str = r#"
929[dependencies]
930my-utils = { path = "../utils" }
931"#;
932 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
933 match config.dependencies.get("my-utils").unwrap() {
934 DependencySpec::Detailed(d) => {
935 assert_eq!(d.path.as_deref(), Some("../utils"));
936 assert!(d.git.is_none());
937 assert!(d.version.is_none());
938 }
939 other => panic!("expected Detailed, got {:?}", other),
940 }
941 }
942
943 #[test]
944 fn test_parse_git_dependency() {
945 let toml_str = r#"
946[dependencies]
947plotting = { git = "https://github.com/org/plot.git", tag = "v1.0" }
948"#;
949 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
950 match config.dependencies.get("plotting").unwrap() {
951 DependencySpec::Detailed(d) => {
952 assert_eq!(d.git.as_deref(), Some("https://github.com/org/plot.git"));
953 assert_eq!(d.tag.as_deref(), Some("v1.0"));
954 assert!(d.branch.is_none());
955 assert!(d.rev.is_none());
956 assert!(d.path.is_none());
957 }
958 other => panic!("expected Detailed, got {:?}", other),
959 }
960 }
961
962 #[test]
963 fn test_parse_git_dependency_with_branch() {
964 let toml_str = r#"
965[dependencies]
966my-lib = { git = "https://github.com/org/lib.git", branch = "develop" }
967"#;
968 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
969 match config.dependencies.get("my-lib").unwrap() {
970 DependencySpec::Detailed(d) => {
971 assert_eq!(d.git.as_deref(), Some("https://github.com/org/lib.git"));
972 assert_eq!(d.branch.as_deref(), Some("develop"));
973 }
974 other => panic!("expected Detailed, got {:?}", other),
975 }
976 }
977
978 #[test]
979 fn test_parse_git_dependency_with_rev() {
980 let toml_str = r#"
981[dependencies]
982pinned = { git = "https://github.com/org/pinned.git", rev = "abc1234" }
983"#;
984 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
985 match config.dependencies.get("pinned").unwrap() {
986 DependencySpec::Detailed(d) => {
987 assert_eq!(d.rev.as_deref(), Some("abc1234"));
988 }
989 other => panic!("expected Detailed, got {:?}", other),
990 }
991 }
992
993 #[test]
994 fn test_parse_dev_dependencies() {
995 let toml_str = r#"
996[project]
997name = "test"
998version = "1.0.0"
999
1000[dev-dependencies]
1001test-utils = "0.2.0"
1002mock-data = { path = "../mocks" }
1003"#;
1004 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1005 assert_eq!(config.dev_dependencies.len(), 2);
1006 assert_eq!(
1007 config.dev_dependencies.get("test-utils"),
1008 Some(&DependencySpec::Version("0.2.0".to_string()))
1009 );
1010 match config.dev_dependencies.get("mock-data").unwrap() {
1011 DependencySpec::Detailed(d) => {
1012 assert_eq!(d.path.as_deref(), Some("../mocks"));
1013 }
1014 other => panic!("expected Detailed, got {:?}", other),
1015 }
1016 }
1017
1018 #[test]
1019 fn test_parse_build_section() {
1020 let toml_str = r#"
1021[build]
1022target = "native"
1023opt_level = 2
1024output = "dist/"
1025"#;
1026 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1027 assert_eq!(config.build.target.as_deref(), Some("native"));
1028 assert_eq!(config.build.opt_level, Some(2));
1029 assert_eq!(config.build.output.as_deref(), Some("dist/"));
1030 }
1031
1032 #[test]
1033 fn test_parse_project_extended_fields() {
1034 let toml_str = r#"
1035[project]
1036name = "full-project"
1037version = "2.0.0"
1038authors = ["Alice", "Bob"]
1039shape-version = "0.5.0"
1040license = "MIT"
1041repository = "https://github.com/org/project"
1042entry = "main.shape"
1043"#;
1044 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1045 assert_eq!(config.project.name, "full-project");
1046 assert_eq!(config.project.version, "2.0.0");
1047 assert_eq!(config.project.authors, vec!["Alice", "Bob"]);
1048 assert_eq!(config.project.shape_version.as_deref(), Some("0.5.0"));
1049 assert_eq!(config.project.license.as_deref(), Some("MIT"));
1050 assert_eq!(
1051 config.project.repository.as_deref(),
1052 Some("https://github.com/org/project")
1053 );
1054 assert_eq!(config.project.entry.as_deref(), Some("main.shape"));
1055 }
1056
1057 #[test]
1058 fn test_parse_full_config_with_all_sections() {
1059 let toml_str = r#"
1060[project]
1061name = "mega-project"
1062version = "1.0.0"
1063authors = ["Dev"]
1064shape-version = "0.5.0"
1065license = "Apache-2.0"
1066repository = "https://github.com/org/mega"
1067entry = "src/main.shape"
1068
1069[modules]
1070paths = ["lib", "vendor"]
1071
1072[dependencies]
1073finance = "0.1.0"
1074my-utils = { path = "../utils" }
1075plotting = { git = "https://github.com/org/plot.git", tag = "v1.0" }
1076
1077[dev-dependencies]
1078test-helpers = "0.3.0"
1079
1080[build]
1081target = "bytecode"
1082opt_level = 1
1083output = "out/"
1084
1085[[extensions]]
1086name = "market-data"
1087path = "./plugins/market.so"
1088"#;
1089 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1090 assert_eq!(config.project.name, "mega-project");
1091 assert_eq!(config.project.authors, vec!["Dev"]);
1092 assert_eq!(config.project.shape_version.as_deref(), Some("0.5.0"));
1093 assert_eq!(config.project.license.as_deref(), Some("Apache-2.0"));
1094 assert_eq!(config.modules.paths, vec!["lib", "vendor"]);
1095 assert_eq!(config.dependencies.len(), 3);
1096 assert_eq!(config.dev_dependencies.len(), 1);
1097 assert_eq!(config.build.target.as_deref(), Some("bytecode"));
1098 assert_eq!(config.build.opt_level, Some(1));
1099 assert_eq!(config.extensions.len(), 1);
1100 }
1101
1102 #[test]
1103 fn test_validate_valid_project() {
1104 let toml_str = r#"
1105[project]
1106name = "valid"
1107version = "1.0.0"
1108
1109[dependencies]
1110finance = "0.1.0"
1111utils = { path = "../utils" }
1112lib = { git = "https://example.com/lib.git", tag = "v1" }
1113
1114[build]
1115opt_level = 2
1116"#;
1117 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1118 let errors = config.validate();
1119 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1120 }
1121
1122 #[test]
1123 fn test_validate_catches_path_and_git() {
1124 let toml_str = r#"
1125[dependencies]
1126bad-dep = { path = "../local", git = "https://example.com/repo.git", tag = "v1" }
1127"#;
1128 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1129 let errors = config.validate();
1130 assert!(
1131 errors
1132 .iter()
1133 .any(|e| e.contains("bad-dep") && e.contains("path") && e.contains("git"))
1134 );
1135 }
1136
1137 #[test]
1138 fn test_validate_catches_git_without_ref() {
1139 let toml_str = r#"
1140[dependencies]
1141no-ref = { git = "https://example.com/repo.git" }
1142"#;
1143 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1144 let errors = config.validate();
1145 assert!(
1146 errors
1147 .iter()
1148 .any(|e| e.contains("no-ref") && e.contains("tag"))
1149 );
1150 }
1151
1152 #[test]
1153 fn test_validate_git_with_branch_is_ok() {
1154 let toml_str = r#"
1155[dependencies]
1156ok-dep = { git = "https://example.com/repo.git", branch = "main" }
1157"#;
1158 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1159 let errors = config.validate();
1160 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1161 }
1162
1163 #[test]
1164 fn test_validate_catches_opt_level_too_high() {
1165 let toml_str = r#"
1166[build]
1167opt_level = 5
1168"#;
1169 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1170 let errors = config.validate();
1171 assert!(
1172 errors
1173 .iter()
1174 .any(|e| e.contains("opt_level") && e.contains("5"))
1175 );
1176 }
1177
1178 #[test]
1179 fn test_validate_catches_empty_project_name() {
1180 let toml_str = r#"
1181[project]
1182version = "1.0.0"
1183"#;
1184 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1185 let errors = config.validate();
1186 assert!(errors.iter().any(|e| e.contains("project.name")));
1187 }
1188
1189 #[test]
1190 fn test_validate_dev_dependencies_errors() {
1191 let toml_str = r#"
1192[dev-dependencies]
1193bad = { path = "../x", git = "https://example.com/x.git", tag = "v1" }
1194"#;
1195 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1196 let errors = config.validate();
1197 assert!(
1198 errors
1199 .iter()
1200 .any(|e| e.contains("dev-dependencies") && e.contains("bad"))
1201 );
1202 }
1203
1204 #[test]
1205 fn test_empty_config_still_parses() {
1206 let config: ShapeProject = parse_shape_project_toml("").unwrap();
1207 assert!(config.dependencies.is_empty());
1208 assert!(config.dev_dependencies.is_empty());
1209 assert!(config.build.target.is_none());
1210 assert!(config.build.opt_level.is_none());
1211 assert!(config.project.authors.is_empty());
1212 assert!(config.project.shape_version.is_none());
1213 }
1214
1215 #[test]
1216 fn test_mixed_dependency_types() {
1217 let toml_str = r#"
1218[dependencies]
1219simple = "1.0.0"
1220local = { path = "./local" }
1221remote = { git = "https://example.com/repo.git", rev = "deadbeef" }
1222versioned = { version = "2.0.0" }
1223"#;
1224 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1225 assert_eq!(config.dependencies.len(), 4);
1226 assert!(matches!(
1227 config.dependencies.get("simple"),
1228 Some(DependencySpec::Version(_))
1229 ));
1230 assert!(matches!(
1231 config.dependencies.get("local"),
1232 Some(DependencySpec::Detailed(_))
1233 ));
1234 assert!(matches!(
1235 config.dependencies.get("remote"),
1236 Some(DependencySpec::Detailed(_))
1237 ));
1238 assert!(matches!(
1239 config.dependencies.get("versioned"),
1240 Some(DependencySpec::Detailed(_))
1241 ));
1242 }
1243
1244 #[test]
1245 fn test_parse_config_with_extension_sections() {
1246 let toml_str = r#"
1247[project]
1248name = "test"
1249version = "1.0.0"
1250
1251[native-dependencies]
1252libm = { linux = "libm.so.6", macos = "libm.dylib" }
1253
1254[custom-config]
1255key = "value"
1256"#;
1257 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1258 assert_eq!(config.project.name, "test");
1259 assert_eq!(config.extension_section_names().len(), 2);
1260 assert!(
1261 config
1262 .extension_sections
1263 .contains_key("native-dependencies")
1264 );
1265 assert!(config.extension_sections.contains_key("custom-config"));
1266
1267 let json = config.extension_section_as_json("custom-config").unwrap();
1269 assert_eq!(json["key"], "value");
1270 }
1271
1272 #[test]
1273 fn test_parse_native_dependencies_section_typed() {
1274 let section: toml::Value = toml::from_str(
1275 r#"
1276libm = "libm.so.6"
1277duckdb = { linux = "libduckdb.so", macos = "libduckdb.dylib", windows = "duckdb.dll" }
1278"#,
1279 )
1280 .expect("valid native dependency section");
1281
1282 let parsed =
1283 parse_native_dependencies_section(§ion).expect("native dependencies should parse");
1284 assert!(matches!(
1285 parsed.get("libm"),
1286 Some(NativeDependencySpec::Simple(v)) if v == "libm.so.6"
1287 ));
1288 assert!(matches!(
1289 parsed.get("duckdb"),
1290 Some(NativeDependencySpec::Detailed(_))
1291 ));
1292 }
1293
1294 #[test]
1295 fn test_native_dependency_provider_parsing() {
1296 let section: toml::Value = toml::from_str(
1297 r#"
1298libm = "libm.so.6"
1299local_lib = "./native/libfoo.so"
1300vendored = { provider = "vendored", path = "./vendor/libduckdb.so", version = "1.2.0", cache_key = "duckdb-1.2.0" }
1301"#,
1302 )
1303 .expect("valid native dependency section");
1304
1305 let parsed =
1306 parse_native_dependencies_section(§ion).expect("native dependencies should parse");
1307
1308 let libm = parsed.get("libm").expect("libm");
1309 assert_eq!(libm.provider_for_host(), NativeDependencyProvider::System);
1310 assert_eq!(libm.declared_version(), None);
1311
1312 let local = parsed.get("local_lib").expect("local_lib");
1313 assert_eq!(local.provider_for_host(), NativeDependencyProvider::Path);
1314
1315 let vendored = parsed.get("vendored").expect("vendored");
1316 assert_eq!(
1317 vendored.provider_for_host(),
1318 NativeDependencyProvider::Vendored
1319 );
1320 assert_eq!(vendored.declared_version(), Some("1.2.0"));
1321 assert_eq!(vendored.cache_key(), Some("duckdb-1.2.0"));
1322 }
1323
1324 #[test]
1325 fn test_project_native_dependencies_from_extension_section() {
1326 let toml_str = r#"
1327[project]
1328name = "native-deps"
1329version = "1.0.0"
1330
1331[native-dependencies]
1332libm = "libm.so.6"
1333"#;
1334 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1335 let deps = config
1336 .native_dependencies()
1337 .expect("native deps should parse");
1338 assert!(deps.contains_key("libm"));
1339 }
1340
1341 #[test]
1342 fn test_validate_with_claimed_sections() {
1343 let toml_str = r#"
1344[project]
1345name = "test"
1346version = "1.0.0"
1347
1348[native-dependencies]
1349libm = { linux = "libm.so.6" }
1350
1351[typo-section]
1352foo = "bar"
1353"#;
1354 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1355 let mut claimed = std::collections::HashSet::new();
1356 claimed.insert("native-dependencies".to_string());
1357
1358 let errors = config.validate_with_claimed_sections(&claimed);
1359 assert!(
1360 errors
1361 .iter()
1362 .any(|e| e.contains("typo-section") && e.contains("not claimed"))
1363 );
1364 assert!(!errors.iter().any(|e| e.contains("native-dependencies")));
1365 }
1366
1367 #[test]
1368 fn test_extension_sections_empty_by_default() {
1369 let config: ShapeProject = parse_shape_project_toml("").unwrap();
1370 assert!(config.extension_sections.is_empty());
1371 }
1372
1373 #[test]
1376 fn test_no_permissions_section_defaults_to_full() {
1377 let config: ShapeProject = parse_shape_project_toml("").unwrap();
1378 assert!(config.permissions.is_none());
1379 let pset = config.effective_permission_set();
1380 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
1381 assert!(pset.contains(&shape_abi_v1::Permission::FsWrite));
1382 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
1383 assert!(pset.contains(&shape_abi_v1::Permission::Process));
1384 }
1385
1386 #[test]
1387 fn test_parse_permissions_section() {
1388 let toml_str = r#"
1389[project]
1390name = "perms-test"
1391version = "1.0.0"
1392
1393[permissions]
1394"fs.read" = true
1395"fs.write" = false
1396"net.connect" = true
1397"net.listen" = false
1398process = false
1399env = true
1400time = true
1401random = false
1402"#;
1403 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1404 let perms = config.permissions.as_ref().unwrap();
1405 assert_eq!(perms.fs_read, Some(true));
1406 assert_eq!(perms.fs_write, Some(false));
1407 assert_eq!(perms.net_connect, Some(true));
1408 assert_eq!(perms.net_listen, Some(false));
1409 assert_eq!(perms.process, Some(false));
1410 assert_eq!(perms.env, Some(true));
1411 assert_eq!(perms.time, Some(true));
1412 assert_eq!(perms.random, Some(false));
1413
1414 let pset = config.effective_permission_set();
1415 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
1416 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
1417 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
1418 assert!(!pset.contains(&shape_abi_v1::Permission::NetListen));
1419 assert!(!pset.contains(&shape_abi_v1::Permission::Process));
1420 assert!(pset.contains(&shape_abi_v1::Permission::Env));
1421 assert!(pset.contains(&shape_abi_v1::Permission::Time));
1422 assert!(!pset.contains(&shape_abi_v1::Permission::Random));
1423 }
1424
1425 #[test]
1426 fn test_parse_permissions_with_scoped_fs() {
1427 let toml_str = r#"
1428[permissions]
1429"fs.read" = true
1430
1431[permissions.fs]
1432allowed = ["./data", "/tmp/cache"]
1433read_only = ["./config"]
1434
1435[permissions.net]
1436allowed_hosts = ["api.example.com", "*.internal.corp"]
1437"#;
1438 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1439 let perms = config.permissions.as_ref().unwrap();
1440 let fs = perms.fs.as_ref().unwrap();
1441 assert_eq!(fs.allowed, vec!["./data", "/tmp/cache"]);
1442 assert_eq!(fs.read_only, vec!["./config"]);
1443
1444 let net = perms.net.as_ref().unwrap();
1445 assert_eq!(
1446 net.allowed_hosts,
1447 vec!["api.example.com", "*.internal.corp"]
1448 );
1449
1450 let pset = perms.to_permission_set();
1451 assert!(pset.contains(&shape_abi_v1::Permission::FsScoped));
1452 assert!(pset.contains(&shape_abi_v1::Permission::NetScoped));
1453
1454 let constraints = perms.to_scope_constraints();
1455 assert_eq!(constraints.allowed_paths.len(), 3); assert_eq!(constraints.allowed_hosts.len(), 2);
1457 }
1458
1459 #[test]
1460 fn test_permissions_shorthand_pure() {
1461 let section = PermissionsSection::from_shorthand("pure").unwrap();
1462 let pset = section.to_permission_set();
1463 assert!(pset.is_empty());
1464 }
1465
1466 #[test]
1467 fn test_permissions_shorthand_readonly() {
1468 let section = PermissionsSection::from_shorthand("readonly").unwrap();
1469 let pset = section.to_permission_set();
1470 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
1471 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
1472 assert!(!pset.contains(&shape_abi_v1::Permission::NetConnect));
1473 assert!(pset.contains(&shape_abi_v1::Permission::Env));
1474 assert!(pset.contains(&shape_abi_v1::Permission::Time));
1475 }
1476
1477 #[test]
1478 fn test_permissions_shorthand_full() {
1479 let section = PermissionsSection::from_shorthand("full").unwrap();
1480 let pset = section.to_permission_set();
1481 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
1482 assert!(pset.contains(&shape_abi_v1::Permission::FsWrite));
1483 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
1484 assert!(pset.contains(&shape_abi_v1::Permission::NetListen));
1485 assert!(pset.contains(&shape_abi_v1::Permission::Process));
1486 }
1487
1488 #[test]
1489 fn test_permissions_shorthand_unknown() {
1490 assert!(PermissionsSection::from_shorthand("unknown").is_none());
1491 }
1492
1493 #[test]
1494 fn test_permissions_unset_fields_default_to_true() {
1495 let toml_str = r#"
1496[permissions]
1497"fs.write" = false
1498"#;
1499 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1500 let pset = config.effective_permission_set();
1501 assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
1503 assert!(pset.contains(&shape_abi_v1::Permission::FsRead));
1505 assert!(pset.contains(&shape_abi_v1::Permission::NetConnect));
1506 assert!(pset.contains(&shape_abi_v1::Permission::Process));
1507 }
1508
1509 #[test]
1512 fn test_parse_sandbox_section() {
1513 let toml_str = r#"
1514[sandbox]
1515enabled = true
1516deterministic = true
1517seed = 42
1518memory_limit = "64MB"
1519time_limit = "10s"
1520virtual_fs = true
1521
1522[sandbox.seed_files]
1523"data/input.csv" = "./real_data/input.csv"
1524"config/settings.toml" = "./test_settings.toml"
1525"#;
1526 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1527 let sandbox = config.sandbox.as_ref().unwrap();
1528 assert!(sandbox.enabled);
1529 assert!(sandbox.deterministic);
1530 assert_eq!(sandbox.seed, Some(42));
1531 assert_eq!(sandbox.memory_limit.as_deref(), Some("64MB"));
1532 assert_eq!(sandbox.time_limit.as_deref(), Some("10s"));
1533 assert!(sandbox.virtual_fs);
1534 assert_eq!(sandbox.seed_files.len(), 2);
1535 assert_eq!(
1536 sandbox.seed_files.get("data/input.csv").unwrap(),
1537 "./real_data/input.csv"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_sandbox_memory_limit_parsing() {
1543 let section = SandboxSection {
1544 memory_limit: Some("64MB".to_string()),
1545 ..Default::default()
1546 };
1547 assert_eq!(section.memory_limit_bytes(), Some(64 * 1024 * 1024));
1548
1549 let section = SandboxSection {
1550 memory_limit: Some("1GB".to_string()),
1551 ..Default::default()
1552 };
1553 assert_eq!(section.memory_limit_bytes(), Some(1024 * 1024 * 1024));
1554
1555 let section = SandboxSection {
1556 memory_limit: Some("512KB".to_string()),
1557 ..Default::default()
1558 };
1559 assert_eq!(section.memory_limit_bytes(), Some(512 * 1024));
1560 }
1561
1562 #[test]
1563 fn test_sandbox_time_limit_parsing() {
1564 let section = SandboxSection {
1565 time_limit: Some("10s".to_string()),
1566 ..Default::default()
1567 };
1568 assert_eq!(section.time_limit_ms(), Some(10_000));
1569
1570 let section = SandboxSection {
1571 time_limit: Some("500ms".to_string()),
1572 ..Default::default()
1573 };
1574 assert_eq!(section.time_limit_ms(), Some(500));
1575
1576 let section = SandboxSection {
1577 time_limit: Some("2m".to_string()),
1578 ..Default::default()
1579 };
1580 assert_eq!(section.time_limit_ms(), Some(120_000));
1581 }
1582
1583 #[test]
1584 fn test_sandbox_invalid_limits() {
1585 let section = SandboxSection {
1586 memory_limit: Some("abc".to_string()),
1587 ..Default::default()
1588 };
1589 assert!(section.memory_limit_bytes().is_none());
1590
1591 let section = SandboxSection {
1592 time_limit: Some("forever".to_string()),
1593 ..Default::default()
1594 };
1595 assert!(section.time_limit_ms().is_none());
1596 }
1597
1598 #[test]
1599 fn test_validate_sandbox_invalid_memory_limit() {
1600 let toml_str = r#"
1601[sandbox]
1602enabled = true
1603memory_limit = "xyz"
1604"#;
1605 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1606 let errors = config.validate();
1607 assert!(errors.iter().any(|e| e.contains("sandbox.memory_limit")));
1608 }
1609
1610 #[test]
1611 fn test_validate_sandbox_invalid_time_limit() {
1612 let toml_str = r#"
1613[sandbox]
1614enabled = true
1615time_limit = "forever"
1616"#;
1617 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1618 let errors = config.validate();
1619 assert!(errors.iter().any(|e| e.contains("sandbox.time_limit")));
1620 }
1621
1622 #[test]
1623 fn test_validate_sandbox_deterministic_requires_seed() {
1624 let toml_str = r#"
1625[sandbox]
1626enabled = true
1627deterministic = true
1628"#;
1629 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1630 let errors = config.validate();
1631 assert!(errors.iter().any(|e| e.contains("sandbox.seed")));
1632 }
1633
1634 #[test]
1635 fn test_validate_sandbox_deterministic_with_seed_is_ok() {
1636 let toml_str = r#"
1637[sandbox]
1638enabled = true
1639deterministic = true
1640seed = 123
1641"#;
1642 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1643 let errors = config.validate();
1644 assert!(
1645 !errors.iter().any(|e| e.contains("sandbox")),
1646 "expected no sandbox errors, got: {:?}",
1647 errors
1648 );
1649 }
1650
1651 #[test]
1652 fn test_no_sandbox_section_is_none() {
1653 let config: ShapeProject = parse_shape_project_toml("").unwrap();
1654 assert!(config.sandbox.is_none());
1655 }
1656
1657 #[test]
1660 fn test_dependency_with_permission_shorthand() {
1661 let toml_str = r#"
1662[dependencies]
1663analytics = { path = "../analytics", permissions = "pure" }
1664"#;
1665 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1666 match config.dependencies.get("analytics").unwrap() {
1667 DependencySpec::Detailed(d) => {
1668 assert_eq!(d.path.as_deref(), Some("../analytics"));
1669 match d.permissions.as_ref().unwrap() {
1670 PermissionPreset::Shorthand(s) => assert_eq!(s, "pure"),
1671 other => panic!("expected Shorthand, got {:?}", other),
1672 }
1673 }
1674 other => panic!("expected Detailed, got {:?}", other),
1675 }
1676 }
1677
1678 #[test]
1679 fn test_dependency_without_permissions() {
1680 let toml_str = r#"
1681[dependencies]
1682utils = { path = "../utils" }
1683"#;
1684 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1685 match config.dependencies.get("utils").unwrap() {
1686 DependencySpec::Detailed(d) => {
1687 assert!(d.permissions.is_none());
1688 }
1689 other => panic!("expected Detailed, got {:?}", other),
1690 }
1691 }
1692
1693 #[test]
1696 fn test_full_config_with_permissions_and_sandbox() {
1697 let toml_str = r#"
1698[project]
1699name = "full-project"
1700version = "1.0.0"
1701
1702[permissions]
1703"fs.read" = true
1704"fs.write" = false
1705"net.connect" = true
1706"net.listen" = false
1707process = false
1708env = true
1709time = true
1710random = false
1711
1712[permissions.fs]
1713allowed = ["./data"]
1714
1715[sandbox]
1716enabled = false
1717deterministic = false
1718virtual_fs = false
1719"#;
1720 let config: ShapeProject = parse_shape_project_toml(toml_str).unwrap();
1721 assert!(config.permissions.is_some());
1722 assert!(config.sandbox.is_some());
1723 let errors = config.validate();
1724 assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1725 }
1726}