Skip to main content

shape_runtime/project/
project_config.rs

1//! Project configuration parsing and discovery.
2//!
3//! Contains the top-level `ShapeProject` struct and functions for parsing
4//! `shape.toml` files and discovering project roots.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use super::dependency_spec::{DependencySpec, NativeDependencySpec, parse_native_dependencies_section};
11use super::permissions::PermissionsSection;
12use super::sandbox::SandboxSection;
13
14/// [build] section
15#[derive(Debug, Clone, Deserialize, Serialize, Default)]
16pub struct BuildSection {
17    /// "bytecode" or "native"
18    pub target: Option<String>,
19    /// Optimization level 0-3
20    #[serde(default)]
21    pub opt_level: Option<u8>,
22    /// Output directory
23    pub output: Option<String>,
24    /// External-input lock policy for compile-time operations.
25    #[serde(default)]
26    pub external: BuildExternalSection,
27}
28
29/// [build.external] section
30#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct BuildExternalSection {
32    /// Lock behavior for external compile-time inputs.
33    #[serde(default)]
34    pub mode: ExternalLockMode,
35}
36
37/// External input lock mode for compile-time workflows.
38#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum ExternalLockMode {
41    /// Dev mode: allow refreshing lock artifacts.
42    #[default]
43    Update,
44    /// Repro mode: do not refresh external artifacts.
45    Frozen,
46}
47
48/// Top-level shape.toml configuration
49#[derive(Debug, Clone, Deserialize, Serialize, Default)]
50pub struct ShapeProject {
51    #[serde(default)]
52    pub project: ProjectSection,
53    #[serde(default)]
54    pub modules: ModulesSection,
55    #[serde(default)]
56    pub dependencies: HashMap<String, DependencySpec>,
57    #[serde(default, rename = "dev-dependencies")]
58    pub dev_dependencies: HashMap<String, DependencySpec>,
59    #[serde(default)]
60    pub build: BuildSection,
61    #[serde(default)]
62    pub permissions: Option<PermissionsSection>,
63    #[serde(default)]
64    pub sandbox: Option<SandboxSection>,
65    #[serde(default)]
66    pub extensions: Vec<ExtensionEntry>,
67    #[serde(flatten, default)]
68    pub extension_sections: HashMap<String, toml::Value>,
69}
70
71/// [project] section
72#[derive(Debug, Clone, Deserialize, Serialize, Default)]
73pub struct ProjectSection {
74    #[serde(default)]
75    pub name: String,
76    #[serde(default)]
77    pub version: String,
78    /// Entry script for `shape` with no args (project mode)
79    #[serde(default)]
80    pub entry: Option<String>,
81    #[serde(default)]
82    pub authors: Vec<String>,
83    #[serde(default, rename = "shape-version")]
84    pub shape_version: Option<String>,
85    #[serde(default)]
86    pub license: Option<String>,
87    #[serde(default)]
88    pub repository: Option<String>,
89    #[serde(default)]
90    pub description: Option<String>,
91}
92
93/// [modules] section
94#[derive(Debug, Clone, Deserialize, Serialize, Default)]
95pub struct ModulesSection {
96    #[serde(default)]
97    pub paths: Vec<String>,
98}
99
100/// An extension entry in [[extensions]]
101#[derive(Debug, Clone, Deserialize, Serialize)]
102pub struct ExtensionEntry {
103    pub name: String,
104    pub path: PathBuf,
105    #[serde(default)]
106    pub config: HashMap<String, toml::Value>,
107}
108
109impl ExtensionEntry {
110    /// Convert the module config table into JSON for runtime loading.
111    pub fn config_as_json(&self) -> serde_json::Value {
112        toml_to_json(&toml::Value::Table(
113            self.config
114                .iter()
115                .map(|(k, v)| (k.clone(), v.clone()))
116                .collect(),
117        ))
118    }
119}
120
121pub(crate) fn toml_to_json(value: &toml::Value) -> serde_json::Value {
122    match value {
123        toml::Value::String(s) => serde_json::Value::String(s.clone()),
124        toml::Value::Integer(i) => serde_json::Value::Number((*i).into()),
125        toml::Value::Float(f) => serde_json::Number::from_f64(*f)
126            .map(serde_json::Value::Number)
127            .unwrap_or(serde_json::Value::Null),
128        toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
129        toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
130        toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()),
131        toml::Value::Table(table) => {
132            let map: serde_json::Map<String, serde_json::Value> = table
133                .iter()
134                .map(|(k, v)| (k.clone(), toml_to_json(v)))
135                .collect();
136            serde_json::Value::Object(map)
137        }
138    }
139}
140
141impl ShapeProject {
142    /// Validate the project configuration and return a list of errors.
143    pub fn validate(&self) -> Vec<String> {
144        let mut errors = Vec::new();
145
146        // Check project.name is non-empty if any project fields are set
147        if self.project.name.is_empty()
148            && (!self.project.version.is_empty()
149                || self.project.entry.is_some()
150                || !self.project.authors.is_empty())
151        {
152            errors.push("project.name must not be empty".to_string());
153        }
154
155        // Validate dependencies
156        Self::validate_deps(&self.dependencies, "dependencies", &mut errors);
157        Self::validate_deps(&self.dev_dependencies, "dev-dependencies", &mut errors);
158
159        // Validate build.opt_level is 0-3 if present
160        if let Some(level) = self.build.opt_level {
161            if level > 3 {
162                errors.push(format!("build.opt_level must be 0-3, got {}", level));
163            }
164        }
165
166        // Validate sandbox section
167        if let Some(ref sandbox) = self.sandbox {
168            if sandbox.memory_limit.is_some() && sandbox.memory_limit_bytes().is_none() {
169                errors.push(format!(
170                    "sandbox.memory_limit: invalid format '{}' (expected e.g. '64MB')",
171                    sandbox.memory_limit.as_deref().unwrap_or("")
172                ));
173            }
174            if sandbox.time_limit.is_some() && sandbox.time_limit_ms().is_none() {
175                errors.push(format!(
176                    "sandbox.time_limit: invalid format '{}' (expected e.g. '10s')",
177                    sandbox.time_limit.as_deref().unwrap_or("")
178                ));
179            }
180            if sandbox.deterministic && sandbox.seed.is_none() {
181                errors
182                    .push("sandbox.deterministic is true but sandbox.seed is not set".to_string());
183            }
184        }
185
186        errors
187    }
188
189    /// Compute the effective `PermissionSet` for this project.
190    ///
191    /// - If `[permissions]` is absent, returns `PermissionSet::full()` (backwards compatible).
192    /// - If present, converts the section to a `PermissionSet`.
193    pub fn effective_permission_set(&self) -> shape_abi_v1::PermissionSet {
194        match &self.permissions {
195            Some(section) => section.to_permission_set(),
196            None => shape_abi_v1::PermissionSet::full(),
197        }
198    }
199
200    /// Get an extension section as JSON value.
201    pub fn extension_section_as_json(&self, name: &str) -> Option<serde_json::Value> {
202        self.extension_sections.get(name).map(|v| toml_to_json(v))
203    }
204
205    /// Parse typed native dependency specs from `[native-dependencies]`.
206    pub fn native_dependencies(&self) -> Result<HashMap<String, NativeDependencySpec>, String> {
207        match self.extension_sections.get("native-dependencies") {
208            Some(section) => parse_native_dependencies_section(section),
209            None => Ok(HashMap::new()),
210        }
211    }
212
213    /// Get all extension section names.
214    pub fn extension_section_names(&self) -> Vec<&str> {
215        self.extension_sections.keys().map(|s| s.as_str()).collect()
216    }
217
218    /// Validate the project configuration, optionally checking for unclaimed extension sections.
219    pub fn validate_with_claimed_sections(
220        &self,
221        claimed: &std::collections::HashSet<String>,
222    ) -> Vec<String> {
223        let mut errors = self.validate();
224        for name in self.extension_section_names() {
225            if !claimed.contains(name) {
226                errors.push(format!(
227                    "Unknown section '{}' is not claimed by any loaded extension",
228                    name
229                ));
230            }
231        }
232        errors
233    }
234
235    fn validate_deps(
236        deps: &HashMap<String, DependencySpec>,
237        section: &str,
238        errors: &mut Vec<String>,
239    ) {
240        for (name, spec) in deps {
241            if let DependencySpec::Detailed(d) = spec {
242                // Cannot have both path and git
243                if d.path.is_some() && d.git.is_some() {
244                    errors.push(format!(
245                        "{}.{}: cannot specify both 'path' and 'git'",
246                        section, name
247                    ));
248                }
249                // Git deps should have at least one of tag/branch/rev
250                if d.git.is_some() && d.tag.is_none() && d.branch.is_none() && d.rev.is_none() {
251                    errors.push(format!(
252                        "{}.{}: git dependency should specify 'tag', 'branch', or 'rev'",
253                        section, name
254                    ));
255                }
256            }
257        }
258    }
259}
260
261/// Normalize project metadata into a canonical package identity with explicit fallbacks.
262pub fn normalize_package_identity_with_fallback(
263    _root_path: &Path,
264    project: &ShapeProject,
265    fallback_name: &str,
266    fallback_version: &str,
267) -> (String, String, String) {
268    let package_name = if project.project.name.trim().is_empty() {
269        fallback_name.to_string()
270    } else {
271        project.project.name.trim().to_string()
272    };
273    let package_version = if project.project.version.trim().is_empty() {
274        fallback_version.to_string()
275    } else {
276        project.project.version.trim().to_string()
277    };
278    let package_key = format!("{package_name}@{package_version}");
279    (package_name, package_version, package_key)
280}
281
282/// Normalize project metadata into a canonical package identity.
283///
284/// Empty names/versions fall back to the root directory name and `0.0.0`.
285pub fn normalize_package_identity(
286    root_path: &Path,
287    project: &ShapeProject,
288) -> (String, String, String) {
289    let fallback_root_name = root_path
290        .file_name()
291        .and_then(|name| name.to_str())
292        .filter(|name| !name.is_empty())
293        .unwrap_or("root");
294    normalize_package_identity_with_fallback(root_path, project, fallback_root_name, "0.0.0")
295}
296
297/// A discovered project root with its parsed configuration
298#[derive(Debug, Clone)]
299pub struct ProjectRoot {
300    /// The directory containing shape.toml
301    pub root_path: PathBuf,
302    /// Parsed configuration
303    pub config: ShapeProject,
304}
305
306impl ProjectRoot {
307    /// Resolve module paths relative to the project root
308    pub fn resolved_module_paths(&self) -> Vec<PathBuf> {
309        self.config
310            .modules
311            .paths
312            .iter()
313            .map(|p| self.root_path.join(p))
314            .collect()
315    }
316}
317
318/// Parse a `shape.toml` document into a `ShapeProject`.
319///
320/// This is the single source of truth for manifest parsing across CLI, runtime,
321/// and tooling.
322pub fn parse_shape_project_toml(content: &str) -> Result<ShapeProject, toml::de::Error> {
323    toml::from_str(content)
324}
325
326/// Walk up from `start_dir` looking for a `shape.toml` file.
327/// Returns `Some(ProjectRoot)` if found, `None` otherwise.
328///
329/// If a `shape.toml` file is found but contains syntax errors, an error
330/// message is printed to stderr and `None` is returned.  Use
331/// [`try_find_project_root`] when you need the error as a `Result`.
332pub fn find_project_root(start_dir: &Path) -> Option<ProjectRoot> {
333    match try_find_project_root(start_dir) {
334        Ok(result) => result,
335        Err(err) => {
336            eprintln!("Error: {}", err);
337            None
338        }
339    }
340}
341
342/// Walk up from `start_dir` looking for a `shape.toml` file.
343///
344/// Like [`find_project_root`], but returns a structured `Result` so the
345/// caller can decide how to report errors.
346///
347/// Returns:
348/// - `Ok(Some(root))` — found and parsed successfully.
349/// - `Ok(None)` — no `shape.toml` file anywhere up the directory tree.
350/// - `Err(msg)` — a `shape.toml` was found but could not be read or parsed.
351pub fn try_find_project_root(start_dir: &Path) -> Result<Option<ProjectRoot>, String> {
352    let mut current = start_dir.to_path_buf();
353    loop {
354        let candidate = current.join("shape.toml");
355        if candidate.is_file() {
356            let content = std::fs::read_to_string(&candidate)
357                .map_err(|e| format!("Failed to read {}: {}", candidate.display(), e))?;
358            let config = parse_shape_project_toml(&content)
359                .map_err(|e| format!("Malformed shape.toml at {}: {}", candidate.display(), e))?;
360            return Ok(Some(ProjectRoot {
361                root_path: current,
362                config,
363            }));
364        }
365        if !current.pop() {
366            return Ok(None);
367        }
368    }
369}