1use 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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
16pub struct BuildSection {
17 pub target: Option<String>,
19 #[serde(default)]
21 pub opt_level: Option<u8>,
22 pub output: Option<String>,
24 #[serde(default)]
26 pub external: BuildExternalSection,
27}
28
29#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct BuildExternalSection {
32 #[serde(default)]
34 pub mode: ExternalLockMode,
35}
36
37#[derive(Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum ExternalLockMode {
41 #[default]
43 Update,
44 Frozen,
46}
47
48#[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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
73pub struct ProjectSection {
74 #[serde(default)]
75 pub name: String,
76 #[serde(default)]
77 pub version: String,
78 #[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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
95pub struct ModulesSection {
96 #[serde(default)]
97 pub paths: Vec<String>,
98}
99
100#[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 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 pub fn validate(&self) -> Vec<String> {
144 let mut errors = Vec::new();
145
146 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 Self::validate_deps(&self.dependencies, "dependencies", &mut errors);
157 Self::validate_deps(&self.dev_dependencies, "dev-dependencies", &mut errors);
158
159 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 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 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 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 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 pub fn extension_section_names(&self) -> Vec<&str> {
215 self.extension_sections.keys().map(|s| s.as_str()).collect()
216 }
217
218 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 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 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
261pub 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
282pub 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#[derive(Debug, Clone)]
299pub struct ProjectRoot {
300 pub root_path: PathBuf,
302 pub config: ShapeProject,
304}
305
306impl ProjectRoot {
307 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
318pub fn parse_shape_project_toml(content: &str) -> Result<ShapeProject, toml::de::Error> {
323 toml::from_str(content)
324}
325
326pub 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
342pub 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}