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}