1use serde::{Deserialize, Serialize};
10use serde_json::{json, Map, Value};
11
12use super::js_trim;
13use super::package::PackageIssue;
14use crate::generated::catalog::ICON_NAMES;
15
16pub const MAX_SCREENS: usize = 5;
18pub const MAX_SCREEN_TITLE: usize = 32;
20pub const NAVIGATE_FUNCTION: &str = "app.navigate";
22pub const SCREEN_PARAMS_KEY: &str = "params";
24
25const SCREEN_KEYS: &[&str] = &["template", "title", "icon", "params"];
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ResolvedScreen {
30 pub screen: String,
31 pub title: String,
32 pub icon: Option<String>,
33 pub params: Map<String, Value>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct NavigateTarget {
39 pub screen: String,
40 pub params: Map<String, Value>,
41}
42
43fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
44 PackageIssue { path: path.into(), message: message.into() }
45}
46
47fn escape(k: &str) -> String {
48 k.replace('~', "~0").replace('/', "~1")
49}
50
51fn sorted_keys(o: &Map<String, Value>) -> Vec<&String> {
52 let mut keys: Vec<&String> = o.keys().collect();
53 keys.sort();
54 keys
55}
56
57pub fn validate_package_screens(pkg: &Map<String, Value>) -> Vec<PackageIssue> {
59 let Some(list) = pkg.get("screens") else { return Vec::new() };
60 let Some(list) = list.as_array().filter(|l| !l.is_empty()) else {
61 return vec![issue("/screens", "screens is a non-empty list of {template, title, icon?, params?}")];
62 };
63 let mut issues = Vec::new();
64 if list.len() > MAX_SCREENS {
65 issues.push(issue("/screens", format!("at most {MAX_SCREENS} screens, got {}", list.len())));
66 }
67 let empty = Map::new();
68 let templates = pkg.get("templates").and_then(Value::as_object).unwrap_or(&empty);
69 let mut seen: Vec<String> = Vec::new();
70 for (i, s) in list.iter().enumerate() {
71 let at = format!("/screens/{i}");
72 let Some(s) = s.as_object() else {
73 issues.push(issue(&at, "a screen is {template, title, icon?, params?}"));
74 continue;
75 };
76 for k in sorted_keys(s) {
77 if !SCREEN_KEYS.contains(&k.as_str()) {
78 issues.push(issue(format!("{at}/{}", escape(k)), format!("unknown screen key {k}")));
79 }
80 }
81 match s.get("template").and_then(Value::as_str).filter(|t| !t.is_empty()) {
82 None => issues.push(issue(format!("{at}/template"), "template is a required string")),
83 Some(t) if !templates.contains_key(t) => issues.push(issue(format!("{at}/template"), format!("template {t} names no package template"))),
84 Some(t) if seen.iter().any(|x| x == t) => issues.push(issue(format!("{at}/template"), format!("duplicate screen {t}"))),
85 Some(t) => seen.push(t.to_string()),
86 }
87 match s.get("title").and_then(Value::as_str) {
88 Some(t) if !js_trim(t).is_empty() => {
89 if t.chars().count() > MAX_SCREEN_TITLE {
90 issues.push(issue(format!("{at}/title"), format!("title is at most {MAX_SCREEN_TITLE} characters")));
91 }
92 }
93 _ => issues.push(issue(format!("{at}/title"), "title is a required string")),
94 }
95 if let Some(icon) = s.get("icon") {
96 if !icon.as_str().is_some_and(|n| ICON_NAMES.contains(&n)) {
97 let shown = icon.as_str().map(str::to_string).unwrap_or_else(|| icon.to_string());
98 issues.push(issue(format!("{at}/icon"), format!("icon {shown} is not an Exponential UI icon")));
99 }
100 }
101 if let Some(params) = s.get("params") {
102 match params.as_object() {
103 None => issues.push(issue(format!("{at}/params"), "params is an object of strings")),
104 Some(params) => {
105 for k in sorted_keys(params) {
106 if !params[k].is_string() {
107 issues.push(issue(format!("{at}/params/{}", escape(k)), format!("params.{k} is a string")));
108 }
109 }
110 }
111 }
112 }
113 }
114 issues.extend(navigate_issues(pkg, &seen));
115 issues
116}
117
118fn cel_string_literal(v: &Value) -> Option<String> {
120 let s = js_trim(v.as_str()?);
121 for q in ['\'', '"'] {
122 if s.len() >= 2 && s.starts_with(q) && s.ends_with(q) {
123 let inner = &s[1..s.len() - 1];
124 if !inner.contains(q) && !inner.contains('\\') {
125 return Some(inner.to_string());
126 }
127 }
128 }
129 None
130}
131
132fn navigate_issues(pkg: &Map<String, Value>, screens: &[String]) -> Vec<PackageIssue> {
136 let mut issues = Vec::new();
137 let check = |screen: Option<String>, at: String, issues: &mut Vec<PackageIssue>| {
138 if let Some(screen) = screen {
139 if !screens.iter().any(|s| *s == screen) {
140 issues.push(issue(at, format!("navigate: {screen} is not a screen")));
141 }
142 }
143 };
144 if let Some(templates) = pkg.get("templates").and_then(Value::as_object) {
145 for id in sorted_keys(templates) {
146 let Some(components) = templates[id].get("components").and_then(Value::as_array) else { continue };
147 for (i, c) in components.iter().enumerate() {
148 let Some(on) = c.get("on").and_then(Value::as_object) else { continue };
149 for event in sorted_keys(on) {
150 let Some(fc) = on[event].get("functionCall").filter(|f| f.is_object()) else { continue };
151 if fc.get("call").and_then(Value::as_str) != Some(NAVIGATE_FUNCTION) {
152 continue;
153 }
154 let at = format!("/templates/{}/components/{i}/on/{}/functionCall/args", escape(id), escape(event));
155 match fc.get("args").and_then(Value::as_object) {
156 None => issues.push(issue(at, "navigate takes {screen, params?}")),
157 Some(args) => check(args.get("screen").and_then(Value::as_str).map(str::to_string), format!("{at}/screen"), &mut issues),
158 }
159 }
160 }
161 }
162 }
163 if let Some(actions) = pkg.get("actions").and_then(Value::as_object) {
164 for name in sorted_keys(actions) {
165 let Some(steps) = actions[name].get("steps").and_then(Value::as_array) else { continue };
166 for (i, step) in steps.iter().enumerate() {
167 if !step.is_object() || step.get("call").and_then(Value::as_str) != Some(NAVIGATE_FUNCTION) {
168 continue;
169 }
170 let at = format!("/actions/{}/steps/{i}/args", escape(name));
171 match step.get("args").and_then(Value::as_object) {
172 None => issues.push(issue(at, "navigate takes {screen, params?}")),
173 Some(args) => check(args.get("screen").and_then(cel_string_literal), format!("{at}/screen"), &mut issues),
174 }
175 }
176 }
177 }
178 issues
179}
180
181pub fn package_screens(pkg: &Value) -> Vec<&Map<String, Value>> {
183 pkg.get("screens").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_object).filter(|s| s.get("template").is_some_and(Value::is_string)).collect()
184}
185
186pub fn resolve_screen(pkg: &Value, screen: Option<&str>, params: Option<&Value>) -> Option<ResolvedScreen> {
190 let screens = package_screens(pkg);
191 let picked = screens.iter().find(|s| s.get("template").and_then(Value::as_str) == screen).or(screens.first())?;
192 let mut merged = Map::new();
193 for source in [picked.get("params"), params] {
194 let Some(o) = source.and_then(Value::as_object) else { continue };
195 for k in sorted_keys(o) {
196 if o[k].is_string() {
197 merged.insert(k.clone(), o[k].clone());
198 }
199 }
200 }
201 Some(ResolvedScreen {
202 screen: picked["template"].as_str().unwrap_or_default().to_string(),
203 title: picked.get("title").and_then(Value::as_str).unwrap_or_default().to_string(),
204 icon: picked.get("icon").and_then(Value::as_str).map(str::to_string),
205 params: merged,
206 })
207}
208
209pub fn navigate_target(pkg: &Value, args: &Value) -> Result<NavigateTarget, String> {
211 let Some(args) = args.as_object() else { return Err("navigate takes {screen, params?}".into()) };
212 let Some(screen) = args.get("screen").and_then(Value::as_str).filter(|s| !s.is_empty()) else { return Err("navigate: screen is a required string".into()) };
213 if !package_screens(pkg).iter().any(|s| s.get("template").and_then(Value::as_str) == Some(screen)) {
214 return Err(format!("navigate: {screen} is not a screen"));
215 }
216 let mut params = Map::new();
217 match args.get("params") {
218 None | Some(Value::Null) => {}
219 Some(Value::Object(o)) => {
220 for k in sorted_keys(o) {
221 if !o[k].is_string() {
222 return Err(format!("navigate: params.{k} is a string"));
223 }
224 params.insert(k.clone(), o[k].clone());
225 }
226 }
227 Some(_) => return Err("navigate: params is an object of strings".into()),
228 }
229 Ok(NavigateTarget { screen: screen.to_string(), params })
230}
231
232pub fn navigate_target_value(pkg: &Value, args: &Value) -> Value {
234 match navigate_target(pkg, args) {
235 Ok(t) => json!({"screen": t.screen, "params": t.params}),
236 Err(error) => json!({"error": error}),
237 }
238}
239
240pub fn screen_data(screen: &ResolvedScreen) -> Value {
242 json!({ SCREEN_PARAMS_KEY: screen.params })
243}
244
245pub fn fill_screen_params(source: &str, params: Option<&Value>) -> String {
248 let Some(params) = params.and_then(Value::as_object) else { return source.to_string() };
249 if !source.contains("${params.") {
250 return source.to_string();
251 }
252 let mut out = String::with_capacity(source.len());
253 let mut rest = source;
254 while let Some(at) = rest.find("${params.") {
255 out.push_str(&rest[..at]);
256 let after = &rest[at + "${params.".len()..];
257 let key_len = after.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
258 if key_len > 0 && after[key_len..].starts_with('}') {
259 if let Some(v) = params.get(&after[..key_len]).and_then(Value::as_str) {
260 out.push_str(&super::sources::encode_component(v));
261 }
262 rest = &after[key_len + 1..];
263 } else {
264 out.push_str("${params.");
265 rest = after;
266 }
267 }
268 out.push_str(rest);
269 out
270}