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