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/// Normalized native target used for host-aware native dependency resolution.
70#[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    /// Build the target description for the current host.
80    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    /// Stable ID used in package metadata and lockfile inputs.
93    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/// Target-qualified native dependency value.
110#[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/// Detailed target-qualified native dependency value.
129#[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/// Entry in `[native-dependencies]`.
138///
139/// Supports either a shorthand string:
140/// `duckdb = "libduckdb.so"`
141///
142/// Or a platform-specific table:
143/// `duckdb = { linux = "libduckdb.so", macos = "libduckdb.dylib", windows = "duckdb.dll" }`
144#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
145#[serde(untagged)]
146pub enum NativeDependencySpec {
147    Simple(String),
148    Detailed(NativeDependencyDetail),
149}
150
151/// How a native dependency is provisioned.
152#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
153#[serde(rename_all = "lowercase")]
154pub enum NativeDependencyProvider {
155    /// Resolve from system loader search paths / globally installed libraries.
156    System,
157    /// Resolve from a concrete local path (project/dependency checkout).
158    Path,
159    /// Resolve from a vendored artifact and mirror to Shape's native cache.
160    Vendored,
161}
162
163/// Detailed native dependency record.
164#[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    /// Target-qualified entries keyed by normalized target IDs like
175    /// `linux-x86_64-gnu` or `darwin-aarch64`.
176    #[serde(default)]
177    pub targets: HashMap<String, NativeTargetValue>,
178    /// Source/provider strategy for this dependency.
179    #[serde(default)]
180    pub provider: Option<NativeDependencyProvider>,
181    /// Optional declared library version used for frozen-mode lock safety,
182    /// especially for system-loaded aliases.
183    #[serde(default)]
184    pub version: Option<String>,
185    /// Optional stable cache key for vendored/native artifacts.
186    #[serde(default)]
187    pub cache_key: Option<String>,
188}
189
190impl NativeDependencySpec {
191    /// Resolve this dependency for an explicit target.
192    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    /// Resolve this dependency for the current host target.
236    pub fn resolve_for_host(&self) -> Option<String> {
237        self.resolve_for_target(&NativeTarget::current())
238    }
239
240    /// Provider strategy for an explicit target resolution.
241    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    /// Provider strategy for current host resolution.
275    pub fn provider_for_host(&self) -> NativeDependencyProvider {
276        self.provider_for_target(&NativeTarget::current())
277    }
278
279    /// Optional declared version for lock safety.
280    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    /// Optional explicit cache key for vendored dependencies.
288    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
306/// Parse the `[native-dependencies]` section table into typed specs.
307pub 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/// Permission shorthand: a string like "pure", "readonly", or "full",
326/// or an inline table with fine-grained booleans.
327#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
328#[serde(untagged)]
329pub enum PermissionPreset {
330    /// Shorthand name: "pure", "readonly", or "full".
331    Shorthand(String),
332    /// Inline table with per-permission booleans.
333    Table(PermissionsSection),
334}
335
336/// [permissions] section — declares what capabilities the project needs.
337///
338/// Missing fields default to `true` for backwards compatibility (unless
339/// the `--sandbox` CLI flag overrides to `PermissionSet::pure()`).
340#[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    /// Scoped filesystem constraints.
360    #[serde(default)]
361    pub fs: Option<FsPermissions>,
362    /// Scoped network constraints.
363    #[serde(default)]
364    pub net: Option<NetPermissions>,
365}
366
367/// [permissions.fs] — path-level filesystem constraints.
368#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
369pub struct FsPermissions {
370    /// Paths with full read/write access (glob patterns).
371    #[serde(default)]
372    pub allowed: Vec<String>,
373    /// Paths with read-only access (glob patterns).
374    #[serde(default)]
375    pub read_only: Vec<String>,
376}
377
378/// [permissions.net] — host-level network constraints.
379#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
380pub struct NetPermissions {
381    /// Allowed network hosts (host:port patterns, `*` wildcards).
382    #[serde(default)]
383    pub allowed_hosts: Vec<String>,
384}
385
386/// [sandbox] section — isolation settings for deterministic/testing modes.
387#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
388pub struct SandboxSection {
389    /// Whether sandbox mode is enabled.
390    #[serde(default)]
391    pub enabled: bool,
392    /// Use a deterministic runtime (fixed time, seeded RNG).
393    #[serde(default)]
394    pub deterministic: bool,
395    /// RNG seed for deterministic mode.
396    #[serde(default)]
397    pub seed: Option<u64>,
398    /// Memory limit (human-readable, e.g. "64MB").
399    #[serde(default)]
400    pub memory_limit: Option<String>,
401    /// Execution time limit (human-readable, e.g. "10s").
402    #[serde(default)]
403    pub time_limit: Option<String>,
404    /// Use a virtual filesystem instead of real I/O.
405    #[serde(default)]
406    pub virtual_fs: bool,
407    /// Seed files for the virtual filesystem: vfs_path → real_path.
408    #[serde(default)]
409    pub seed_files: HashMap<String, String>,
410}
411
412impl PermissionsSection {
413    /// Create a section from a shorthand name.
414    ///
415    /// - `"pure"` — all permissions false (no I/O).
416    /// - `"readonly"` — fs.read + env + time, nothing else.
417    /// - `"full"` — all permissions true.
418    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    /// Convert to a `PermissionSet` from shape-abi-v1.
461    ///
462    /// Unset fields (`None`) default to `true` for backwards compatibility.
463    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        // Scoped permissions
491        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    /// Build `ScopeConstraints` from the fs/net sub-sections.
507    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    /// Parse the memory_limit string (e.g. "64MB") into bytes.
523    pub fn memory_limit_bytes(&self) -> Option<u64> {
524        self.memory_limit.as_ref().and_then(|s| parse_byte_size(s))
525    }
526
527    /// Parse the time_limit string (e.g. "10s") into milliseconds.
528    pub fn time_limit_ms(&self) -> Option<u64> {
529        self.time_limit.as_ref().and_then(|s| parse_duration_ms(s))
530    }
531}
532
533/// Parse a human-readable byte size like "64MB", "1GB", "512KB".
534fn 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
548/// Parse a human-readable duration like "10s", "500ms", "2m".
549fn 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
562/// Split "64MB" into ("64", "MB").
563fn 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/// Top-level shape.toml configuration
574#[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/// [project] section
597#[derive(Debug, Clone, Deserialize, Serialize, Default)]
598pub struct ProjectSection {
599    #[serde(default)]
600    pub name: String,
601    #[serde(default)]
602    pub version: String,
603    /// Entry script for `shape` with no args (project mode)
604    #[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/// [modules] section
619#[derive(Debug, Clone, Deserialize, Serialize, Default)]
620pub struct ModulesSection {
621    #[serde(default)]
622    pub paths: Vec<String>,
623}
624
625/// An extension entry in [[extensions]]
626#[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    /// Convert the module config table into JSON for runtime loading.
636    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    /// Validate the project configuration and return a list of errors.
668    pub fn validate(&self) -> Vec<String> {
669        let mut errors = Vec::new();
670
671        // Check project.name is non-empty if any project fields are set
672        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        // Validate dependencies
681        Self::validate_deps(&self.dependencies, "dependencies", &mut errors);
682        Self::validate_deps(&self.dev_dependencies, "dev-dependencies", &mut errors);
683
684        // Validate build.opt_level is 0-3 if present
685        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        // Validate sandbox section
692        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    /// Compute the effective `PermissionSet` for this project.
715    ///
716    /// - If `[permissions]` is absent, returns `PermissionSet::full()` (backwards compatible).
717    /// - If present, converts the section to a `PermissionSet`.
718    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    /// Get an extension section as JSON value.
726    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    /// Parse typed native dependency specs from `[native-dependencies]`.
731    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    /// Get all extension section names.
739    pub fn extension_section_names(&self) -> Vec<&str> {
740        self.extension_sections.keys().map(|s| s.as_str()).collect()
741    }
742
743    /// Validate the project configuration, optionally checking for unclaimed extension sections.
744    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                // Cannot have both path and git
768                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                // Git deps should have at least one of tag/branch/rev
775                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
786/// Normalize project metadata into a canonical package identity with explicit fallbacks.
787pub 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
807/// Normalize project metadata into a canonical package identity.
808///
809/// Empty names/versions fall back to the root directory name and `0.0.0`.
810pub 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/// A discovered project root with its parsed configuration
823#[derive(Debug, Clone)]
824pub struct ProjectRoot {
825    /// The directory containing shape.toml
826    pub root_path: PathBuf,
827    /// Parsed configuration
828    pub config: ShapeProject,
829}
830
831impl ProjectRoot {
832    /// Resolve module paths relative to the project root
833    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
843/// Parse a `shape.toml` document into a `ShapeProject`.
844///
845/// This is the single source of truth for manifest parsing across CLI, runtime,
846/// and tooling.
847pub fn parse_shape_project_toml(content: &str) -> Result<ShapeProject, toml::de::Error> {
848    toml::from_str(content)
849}
850
851/// Walk up from `start_dir` looking for a `shape.toml` file.
852/// Returns `Some(ProjectRoot)` if found, `None` otherwise.
853pub 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        // Create shape.toml in root
980        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        // Create nested directory
992        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        // May or may not be None depending on whether a shape.toml exists
1010        // above tempdir. In practice, tempdir is deep enough that there won't be one.
1011        // We just verify it doesn't panic.
1012        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    // --- New tests for expanded schema ---
1034
1035    #[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        // Test JSON conversion
1394        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(&section).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(&section).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(&section).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    // --- Permissions section tests ---
1544
1545    #[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); // ./data, /tmp/cache, ./config
1626        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        // Explicitly set to false
1672        assert!(!pset.contains(&shape_abi_v1::Permission::FsWrite));
1673        // Not set — defaults to true
1674        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    // --- Sandbox section tests ---
1680
1681    #[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    // --- Dependency-level permissions ---
1828
1829    #[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    // --- Full config round-trip ---
1864
1865    #[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}