Skip to main content

shape_runtime/
project.rs

1//! Project root detection and shape.toml configuration
2//!
3//! Discovers the project root by walking up from a starting directory
4//! looking for a `shape.toml` file, then parses its configuration.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// A dependency specification: either a version string or a detailed table.
11#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
12#[serde(untagged)]
13pub enum DependencySpec {
14    /// Short form: `finance = "0.1.0"`
15    Version(String),
16    /// Table form: `my-utils = { path = "../utils" }`
17    Detailed(DetailedDependency),
18}
19
20/// Detailed dependency with path, git, or version fields.
21#[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    /// Per-dependency permission override: shorthand ("pure", "readonly", "full")
30    /// or an inline permissions table.
31    #[serde(default)]
32    pub permissions: Option<PermissionPreset>,
33}
34
35/// [build] section
36#[derive(Debug, Clone, Deserialize, Serialize, Default)]
37pub struct BuildSection {
38    /// "bytecode" or "native"
39    pub target: Option<String>,
40    /// Optimization level 0-3
41    #[serde(default)]
42    pub opt_level: Option<u8>,
43    /// Output directory
44    pub output: Option<String>,
45    /// External-input lock policy for compile-time operations.
46    #[serde(default)]
47    pub external: BuildExternalSection,
48}
49
50/// [build.external] section
51#[derive(Debug, Clone, Deserialize, Serialize, Default)]
52pub struct BuildExternalSection {
53    /// Lock behavior for external compile-time inputs.
54    #[serde(default)]
55    pub mode: ExternalLockMode,
56}
57
58/// External input lock mode for compile-time workflows.
59#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
60#[serde(rename_all = "lowercase")]
61pub enum ExternalLockMode {
62    /// Dev mode: allow refreshing lock artifacts.
63    #[default]
64    Update,
65    /// Repro mode: do not refresh external artifacts.
66    Frozen,
67}
68
69/// Entry in `[native-dependencies]`.
70///
71/// Supports either a shorthand string:
72/// `duckdb = "libduckdb.so"`
73///
74/// Or a platform-specific table:
75/// `duckdb = { linux = "libduckdb.so", macos = "libduckdb.dylib", windows = "duckdb.dll" }`
76#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
77#[serde(untagged)]
78pub enum NativeDependencySpec {
79    Simple(String),
80    Detailed(NativeDependencyDetail),
81}
82
83/// How a native dependency is provisioned.
84#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
85#[serde(rename_all = "lowercase")]
86pub enum NativeDependencyProvider {
87    /// Resolve from system loader search paths / globally installed libraries.
88    System,
89    /// Resolve from a concrete local path (project/dependency checkout).
90    Path,
91    /// Resolve from a vendored artifact and mirror to Shape's native cache.
92    Vendored,
93}
94
95/// Detailed native dependency record.
96#[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    /// Source/provider strategy for this dependency.
107    #[serde(default)]
108    pub provider: Option<NativeDependencyProvider>,
109    /// Optional declared library version used for frozen-mode lock safety,
110    /// especially for system-loaded aliases.
111    #[serde(default)]
112    pub version: Option<String>,
113    /// Optional stable cache key for vendored/native artifacts.
114    #[serde(default)]
115    pub cache_key: Option<String>,
116}
117
118impl NativeDependencySpec {
119    /// Resolve this dependency for the current host target.
120    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    /// Provider strategy for current host resolution.
165    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    /// Optional declared version for lock safety.
192    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    /// Optional explicit cache key for vendored dependencies.
200    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
218/// Parse the `[native-dependencies]` section table into typed specs.
219pub 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/// Permission shorthand: a string like "pure", "readonly", or "full",
238/// or an inline table with fine-grained booleans.
239#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
240#[serde(untagged)]
241pub enum PermissionPreset {
242    /// Shorthand name: "pure", "readonly", or "full".
243    Shorthand(String),
244    /// Inline table with per-permission booleans.
245    Table(PermissionsSection),
246}
247
248/// [permissions] section — declares what capabilities the project needs.
249///
250/// Missing fields default to `true` for backwards compatibility (unless
251/// the `--sandbox` CLI flag overrides to `PermissionSet::pure()`).
252#[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    /// Scoped filesystem constraints.
272    #[serde(default)]
273    pub fs: Option<FsPermissions>,
274    /// Scoped network constraints.
275    #[serde(default)]
276    pub net: Option<NetPermissions>,
277}
278
279/// [permissions.fs] — path-level filesystem constraints.
280#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
281pub struct FsPermissions {
282    /// Paths with full read/write access (glob patterns).
283    #[serde(default)]
284    pub allowed: Vec<String>,
285    /// Paths with read-only access (glob patterns).
286    #[serde(default)]
287    pub read_only: Vec<String>,
288}
289
290/// [permissions.net] — host-level network constraints.
291#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
292pub struct NetPermissions {
293    /// Allowed network hosts (host:port patterns, `*` wildcards).
294    #[serde(default)]
295    pub allowed_hosts: Vec<String>,
296}
297
298/// [sandbox] section — isolation settings for deterministic/testing modes.
299#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
300pub struct SandboxSection {
301    /// Whether sandbox mode is enabled.
302    #[serde(default)]
303    pub enabled: bool,
304    /// Use a deterministic runtime (fixed time, seeded RNG).
305    #[serde(default)]
306    pub deterministic: bool,
307    /// RNG seed for deterministic mode.
308    #[serde(default)]
309    pub seed: Option<u64>,
310    /// Memory limit (human-readable, e.g. "64MB").
311    #[serde(default)]
312    pub memory_limit: Option<String>,
313    /// Execution time limit (human-readable, e.g. "10s").
314    #[serde(default)]
315    pub time_limit: Option<String>,
316    /// Use a virtual filesystem instead of real I/O.
317    #[serde(default)]
318    pub virtual_fs: bool,
319    /// Seed files for the virtual filesystem: vfs_path → real_path.
320    #[serde(default)]
321    pub seed_files: HashMap<String, String>,
322}
323
324impl PermissionsSection {
325    /// Create a section from a shorthand name.
326    ///
327    /// - `"pure"` — all permissions false (no I/O).
328    /// - `"readonly"` — fs.read + env + time, nothing else.
329    /// - `"full"` — all permissions true.
330    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    /// Convert to a `PermissionSet` from shape-abi-v1.
373    ///
374    /// Unset fields (`None`) default to `true` for backwards compatibility.
375    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        // Scoped permissions
403        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    /// Build `ScopeConstraints` from the fs/net sub-sections.
419    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    /// Parse the memory_limit string (e.g. "64MB") into bytes.
435    pub fn memory_limit_bytes(&self) -> Option<u64> {
436        self.memory_limit.as_ref().and_then(|s| parse_byte_size(s))
437    }
438
439    /// Parse the time_limit string (e.g. "10s") into milliseconds.
440    pub fn time_limit_ms(&self) -> Option<u64> {
441        self.time_limit.as_ref().and_then(|s| parse_duration_ms(s))
442    }
443}
444
445/// Parse a human-readable byte size like "64MB", "1GB", "512KB".
446fn 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
460/// Parse a human-readable duration like "10s", "500ms", "2m".
461fn 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
474/// Split "64MB" into ("64", "MB").
475fn 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/// Top-level shape.toml configuration
486#[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/// [project] section
509#[derive(Debug, Clone, Deserialize, Serialize, Default)]
510pub struct ProjectSection {
511    #[serde(default)]
512    pub name: String,
513    #[serde(default)]
514    pub version: String,
515    /// Entry script for `shape` with no args (project mode)
516    #[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/// [modules] section
529#[derive(Debug, Clone, Deserialize, Serialize, Default)]
530pub struct ModulesSection {
531    #[serde(default)]
532    pub paths: Vec<String>,
533}
534
535/// An extension entry in [[extensions]]
536#[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    /// Convert the module config table into JSON for runtime loading.
546    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    /// Validate the project configuration and return a list of errors.
578    pub fn validate(&self) -> Vec<String> {
579        let mut errors = Vec::new();
580
581        // Check project.name is non-empty if any project fields are set
582        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        // Validate dependencies
591        Self::validate_deps(&self.dependencies, "dependencies", &mut errors);
592        Self::validate_deps(&self.dev_dependencies, "dev-dependencies", &mut errors);
593
594        // Validate build.opt_level is 0-3 if present
595        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        // Validate sandbox section
602        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    /// Compute the effective `PermissionSet` for this project.
625    ///
626    /// - If `[permissions]` is absent, returns `PermissionSet::full()` (backwards compatible).
627    /// - If present, converts the section to a `PermissionSet`.
628    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    /// Get an extension section as JSON value.
636    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    /// Parse typed native dependency specs from `[native-dependencies]`.
641    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    /// Get all extension section names.
649    pub fn extension_section_names(&self) -> Vec<&str> {
650        self.extension_sections.keys().map(|s| s.as_str()).collect()
651    }
652
653    /// Validate the project configuration, optionally checking for unclaimed extension sections.
654    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                // Cannot have both path and git
678                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                // Git deps should have at least one of tag/branch/rev
685                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/// A discovered project root with its parsed configuration
697#[derive(Debug, Clone)]
698pub struct ProjectRoot {
699    /// The directory containing shape.toml
700    pub root_path: PathBuf,
701    /// Parsed configuration
702    pub config: ShapeProject,
703}
704
705impl ProjectRoot {
706    /// Resolve module paths relative to the project root
707    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
717/// Parse a `shape.toml` document into a `ShapeProject`.
718///
719/// This is the single source of truth for manifest parsing across CLI, runtime,
720/// and tooling.
721pub fn parse_shape_project_toml(content: &str) -> Result<ShapeProject, toml::de::Error> {
722    toml::from_str(content)
723}
724
725/// Walk up from `start_dir` looking for a `shape.toml` file.
726/// Returns `Some(ProjectRoot)` if found, `None` otherwise.
727pub 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        // Create shape.toml in root
854        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        // Create nested directory
866        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        // May or may not be None depending on whether a shape.toml exists
884        // above tempdir. In practice, tempdir is deep enough that there won't be one.
885        // We just verify it doesn't panic.
886        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    // --- New tests for expanded schema ---
908
909    #[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        // Test JSON conversion
1268        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(&section).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(&section).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    // --- Permissions section tests ---
1374
1375    #[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); // ./data, /tmp/cache, ./config
1456        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        // Explicitly set to false
1502        assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
1503        // Not set — defaults to true
1504        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    // --- Sandbox section tests ---
1510
1511    #[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    // --- Dependency-level permissions ---
1658
1659    #[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    // --- Full config round-trip ---
1694
1695    #[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}