Skip to main content

xript_runtime/
manifest.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4use crate::error::{ValidationIssue, XriptError};
5
6#[derive(Debug, Clone, Deserialize)]
7pub struct Manifest {
8    pub xript: String,
9    pub name: String,
10    pub version: Option<String>,
11    pub title: Option<String>,
12    pub description: Option<String>,
13    pub bindings: Option<HashMap<String, Binding>>,
14    pub hooks: Option<HashMap<String, HookDef>>,
15    pub capabilities: Option<HashMap<String, Capability>>,
16    pub limits: Option<Limits>,
17    pub slots: Option<Vec<Slot>>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct Slot {
22    pub id: String,
23    pub accepts: Vec<String>,
24    pub capability: Option<String>,
25    pub multiple: Option<bool>,
26    pub style: Option<String>,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30pub struct ModManifest {
31    pub xript: String,
32    pub name: String,
33    pub version: String,
34    pub title: Option<String>,
35    pub description: Option<String>,
36    pub author: Option<String>,
37    pub capabilities: Option<Vec<String>>,
38    pub entry: Option<serde_json::Value>,
39    pub fragments: Option<Vec<FragmentDeclaration>>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct FragmentDeclaration {
44    pub id: String,
45    pub slot: String,
46    pub format: String,
47    pub source: String,
48    pub inline: Option<bool>,
49    pub bindings: Option<Vec<FragmentBinding>>,
50    pub events: Option<Vec<FragmentEvent>>,
51    pub priority: Option<i32>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct FragmentBinding {
56    pub name: String,
57    pub path: String,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct FragmentEvent {
62    pub selector: String,
63    pub on: String,
64    pub handler: String,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68#[serde(untagged)]
69pub enum Binding {
70    Namespace(NamespaceBinding),
71    Function(FunctionBinding),
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct FunctionBinding {
76    pub description: String,
77    pub params: Option<Vec<Parameter>>,
78    pub returns: Option<serde_json::Value>,
79    pub r#async: Option<bool>,
80    pub capability: Option<String>,
81    pub deprecated: Option<String>,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85pub struct NamespaceBinding {
86    pub description: String,
87    pub members: HashMap<String, Binding>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
91pub struct Parameter {
92    pub name: String,
93    pub r#type: serde_json::Value,
94    pub description: Option<String>,
95    pub default: Option<serde_json::Value>,
96    pub required: Option<bool>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct HookDef {
101    pub description: String,
102    pub phases: Option<Vec<String>>,
103    pub params: Option<Vec<Parameter>>,
104    pub capability: Option<String>,
105    pub r#async: Option<bool>,
106    pub deprecated: Option<String>,
107}
108
109#[derive(Debug, Clone, Deserialize)]
110pub struct Capability {
111    pub description: String,
112    pub risk: Option<String>,
113}
114
115#[derive(Debug, Clone, Deserialize)]
116pub struct Limits {
117    pub timeout_ms: Option<u64>,
118    pub memory_mb: Option<u64>,
119    pub max_stack_depth: Option<usize>,
120}
121
122impl Binding {
123    pub fn is_namespace(&self) -> bool {
124        matches!(self, Binding::Namespace(_))
125    }
126}
127
128pub fn validate_structure(manifest: &Manifest) -> crate::error::Result<()> {
129    let mut issues = Vec::new();
130
131    if manifest.xript.is_empty() {
132        issues.push(ValidationIssue {
133            path: "/xript".into(),
134            message: "required field 'xript' must be a non-empty string".into(),
135        });
136    }
137
138    if manifest.name.is_empty() {
139        issues.push(ValidationIssue {
140            path: "/name".into(),
141            message: "required field 'name' must be a non-empty string".into(),
142        });
143    }
144
145    if let Some(ref limits) = manifest.limits {
146        if let Some(timeout) = limits.timeout_ms {
147            if timeout == 0 {
148                issues.push(ValidationIssue {
149                    path: "/limits/timeout_ms".into(),
150                    message: "'timeout_ms' must be a positive number".into(),
151                });
152            }
153        }
154        if let Some(memory) = limits.memory_mb {
155            if memory == 0 {
156                issues.push(ValidationIssue {
157                    path: "/limits/memory_mb".into(),
158                    message: "'memory_mb' must be a positive number".into(),
159                });
160            }
161        }
162    }
163
164    if issues.is_empty() {
165        Ok(())
166    } else {
167        Err(XriptError::ManifestValidation { issues })
168    }
169}
170
171pub fn validate_mod_manifest(manifest: &ModManifest) -> crate::error::Result<()> {
172    let mut issues = Vec::new();
173
174    if manifest.xript.is_empty() {
175        issues.push(ValidationIssue {
176            path: "/xript".into(),
177            message: "required field 'xript' must be a non-empty string".into(),
178        });
179    }
180
181    if manifest.name.is_empty() {
182        issues.push(ValidationIssue {
183            path: "/name".into(),
184            message: "required field 'name' must be a non-empty string".into(),
185        });
186    }
187
188    if manifest.version.is_empty() {
189        issues.push(ValidationIssue {
190            path: "/version".into(),
191            message: "required field 'version' must be a non-empty string".into(),
192        });
193    }
194
195    if let Some(ref fragments) = manifest.fragments {
196        for (i, frag) in fragments.iter().enumerate() {
197            let prefix = format!("/fragments/{}", i);
198            if frag.id.is_empty() {
199                issues.push(ValidationIssue {
200                    path: format!("{}/id", prefix),
201                    message: "'id' must be a non-empty string".into(),
202                });
203            }
204            if frag.slot.is_empty() {
205                issues.push(ValidationIssue {
206                    path: format!("{}/slot", prefix),
207                    message: "'slot' must be a non-empty string".into(),
208                });
209            }
210            if frag.format.is_empty() {
211                issues.push(ValidationIssue {
212                    path: format!("{}/format", prefix),
213                    message: "'format' must be a non-empty string".into(),
214                });
215            }
216        }
217    }
218
219    if issues.is_empty() {
220        Ok(())
221    } else {
222        Err(XriptError::ManifestValidation { issues })
223    }
224}
225
226pub fn validate_mod_against_app(
227    mod_manifest: &ModManifest,
228    slots: &[Slot],
229    granted_capabilities: &std::collections::HashSet<String>,
230) -> Vec<ValidationIssue> {
231    let mut issues = Vec::new();
232    let slot_map: HashMap<&str, &Slot> = slots.iter().map(|s| (s.id.as_str(), s)).collect();
233
234    if let Some(ref fragments) = mod_manifest.fragments {
235        for (i, frag) in fragments.iter().enumerate() {
236            let prefix = format!("/fragments/{}", i);
237
238            match slot_map.get(frag.slot.as_str()) {
239                None => {
240                    issues.push(ValidationIssue {
241                        path: format!("{}/slot", prefix),
242                        message: format!("slot '{}' does not exist in the app manifest", frag.slot),
243                    });
244                }
245                Some(slot) => {
246                    if !slot.accepts.contains(&frag.format) {
247                        issues.push(ValidationIssue {
248                            path: format!("{}/format", prefix),
249                            message: format!(
250                                "slot '{}' does not accept format '{}'",
251                                frag.slot, frag.format
252                            ),
253                        });
254                    }
255
256                    if let Some(ref cap) = slot.capability {
257                        if !granted_capabilities.contains(cap) {
258                            issues.push(ValidationIssue {
259                                path: format!("{}/slot", prefix),
260                                message: format!(
261                                    "slot '{}' requires capability '{}'",
262                                    frag.slot, cap
263                                ),
264                            });
265                        }
266                    }
267                }
268            }
269        }
270    }
271
272    issues
273}