1use sim_cookbook::{EmbeddedDir, RecipeCard, RecipeStore, next, ordered_cards, view};
2use sim_kernel::{Cx, Result};
3use sim_lib_cookbook::{
4 LifecycleAction, LoadableLibList, SeededLibCatalog, projected_recipe_store,
5 run_lifecycle_action, run_recipe_with_loadable_libs,
6};
7
8use crate::cookbook_web_json::{
9 render_error_json, render_index_json, render_recipe_json, render_run_json, render_search_json,
10};
11
12const JSON: &str = "application/json; charset=utf-8";
13const HTML: &str = "text/html; charset=utf-8";
14
15pub struct CookbookWebState {
17 directory: LoadableLibList,
18 diagnostics: Vec<String>,
19 extra_books: Vec<EmbeddedDir>,
20}
21
22impl CookbookWebState {
23 pub fn seeded() -> Result<Self> {
25 Ok(Self::from_loadable_libs(
26 SeededLibCatalog::loadable_libs(),
27 Vec::new(),
28 ))
29 }
30
31 pub fn seeded_with_books(extra: &[(&str, EmbeddedDir)]) -> Result<Self> {
37 let mut state = Self::seeded()?;
38 state.extra_books = extra.iter().map(|(_, recipes)| *recipes).collect();
39 Ok(state)
40 }
41
42 pub fn from_loadable_libs(directory: LoadableLibList, diagnostics: Vec<String>) -> Self {
44 Self {
45 directory,
46 diagnostics,
47 extra_books: Vec::new(),
48 }
49 }
50
51 pub fn empty() -> Self {
53 Self::from_loadable_libs(LoadableLibList::new(Vec::new()), Vec::new())
54 }
55
56 pub fn store(&self, cx: &Cx) -> Result<RecipeStore> {
58 let mut store = projected_recipe_store(cx, &self.directory)?;
59 for recipes in &self.extra_books {
60 let _ = store.register_book(recipes);
61 }
62 Ok(store)
63 }
64
65 pub fn handle_request(
71 &self,
72 method: &str,
73 target: &str,
74 cx: Option<&mut Cx>,
75 ) -> CookbookWebResponse {
76 let (path, query) = split_target(target);
77 match (method, path) {
78 ("GET", "/cookbook") => {
79 let Some(cx) = cx else {
80 return CookbookWebResponse::server_error(
81 "cookbook page requires a runtime context",
82 );
83 };
84 let store = match self.store(cx) {
85 Ok(store) => store,
86 Err(err) => return CookbookWebResponse::server_error(err.to_string()),
87 };
88 let selected = query.and_then(|q| query_value(q, "recipe"));
89 CookbookWebResponse::html(render_page(&store, selected.as_deref()))
90 }
91 ("GET", "/api/cookbook") => {
92 let Some(cx) = cx else {
93 return CookbookWebResponse::server_error(
94 "cookbook index requires a runtime context",
95 );
96 };
97 match self.store(cx) {
98 Ok(store) => {
99 CookbookWebResponse::json(render_index_json(cx, &store, &self.diagnostics))
100 }
101 Err(err) => CookbookWebResponse::server_error(err.to_string()),
102 }
103 }
104 ("GET", "/api/cookbook/search") => {
105 let Some(cx) = cx else {
106 return CookbookWebResponse::server_error(
107 "cookbook search requires a runtime context",
108 );
109 };
110 let q = query
111 .and_then(|query| query_value(query, "q"))
112 .unwrap_or_default();
113 match self.store(cx) {
114 Ok(store) => CookbookWebResponse::json(render_search_json(cx, &store, &q)),
115 Err(err) => CookbookWebResponse::server_error(err.to_string()),
116 }
117 }
118 ("POST", path)
119 if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") =>
120 {
121 let start = "/api/cookbook/recipe/".len();
122 let end = path.len() - "/run".len();
123 let raw_id = &path[start..end];
124 let Some(cx) = cx else {
125 return CookbookWebResponse::server_error(
126 "cookbook run endpoint requires a runtime context",
127 );
128 };
129 let store = match self.store(cx) {
130 Ok(store) => store,
131 Err(err) => return CookbookWebResponse::server_error(err.to_string()),
132 };
133 let id = match decode_component(raw_id, false) {
134 Ok(id) => id,
135 Err(err) => return response_for_resolve_error(err),
136 };
137 let card = match resolve_recipe(&store, &id) {
138 Ok(card) => card.clone(),
139 Err(err) => {
140 if let Some((action, lib)) = stale_lifecycle_id(&self.directory, &id) {
141 let run = run_lifecycle_action(cx, &self.directory, action, lib, &id);
142 return CookbookWebResponse::json(render_run_json(&run));
143 }
144 return response_for_resolve_error(err);
145 }
146 };
147 match run_recipe_with_loadable_libs(cx, &self.directory, &card) {
148 Ok(run) => CookbookWebResponse::json(render_run_json(&run)),
149 Err(err) => CookbookWebResponse::server_error(err.to_string()),
150 }
151 }
152 (_, path) if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") => {
153 CookbookWebResponse::method_not_allowed()
154 }
155 ("GET", path) if path.starts_with("/api/cookbook/recipe/") => {
156 let Some(cx) = cx else {
157 return CookbookWebResponse::server_error(
158 "cookbook detail requires a runtime context",
159 );
160 };
161 let store = match self.store(cx) {
162 Ok(store) => store,
163 Err(err) => return CookbookWebResponse::server_error(err.to_string()),
164 };
165 let raw_id = &path["/api/cookbook/recipe/".len()..];
166 match decode_component(raw_id, false).and_then(|id| resolve_recipe(&store, &id)) {
167 Ok(card) => CookbookWebResponse::json(render_recipe_json(cx, &store, card)),
168 Err(err) => response_for_resolve_error(err),
169 }
170 }
171 (_, "/api/cookbook") | (_, "/api/cookbook/search") | (_, "/cookbook") => {
172 CookbookWebResponse::method_not_allowed()
173 }
174 (_, path) if path.starts_with("/api/cookbook/recipe/") => {
175 CookbookWebResponse::method_not_allowed()
176 }
177 _ => CookbookWebResponse::not_found("unknown cookbook route"),
178 }
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct CookbookWebResponse {
185 pub status: u16,
187 pub content_type: &'static str,
189 pub body: String,
191}
192
193impl CookbookWebResponse {
194 fn html(body: String) -> Self {
195 Self {
196 status: 200,
197 content_type: HTML,
198 body,
199 }
200 }
201
202 fn json(body: String) -> Self {
203 Self {
204 status: 200,
205 content_type: JSON,
206 body,
207 }
208 }
209
210 fn not_found(message: impl AsRef<str>) -> Self {
211 Self::error(404, message)
212 }
213
214 fn method_not_allowed() -> Self {
215 Self::error(405, "method not allowed")
216 }
217
218 fn server_error(message: impl AsRef<str>) -> Self {
219 Self::error(500, message)
220 }
221
222 fn error(status: u16, message: impl AsRef<str>) -> Self {
223 Self {
224 status,
225 content_type: JSON,
226 body: render_error_json(message.as_ref()),
227 }
228 }
229}
230
231#[derive(Debug, PartialEq, Eq)]
232enum ResolveError {
233 Unknown(String),
234 Ambiguous(String),
235 BadRequest(String),
236}
237
238fn response_for_resolve_error(err: ResolveError) -> CookbookWebResponse {
239 match err {
240 ResolveError::Unknown(message) => CookbookWebResponse::not_found(message),
241 ResolveError::Ambiguous(message) | ResolveError::BadRequest(message) => {
242 CookbookWebResponse::error(400, message)
243 }
244 }
245}
246
247fn stale_lifecycle_id<'a>(
248 directory: &'a LoadableLibList,
249 id: &str,
250) -> Option<(LifecycleAction, &'a str)> {
251 let (action, lib) = if let Some(lib) = id.strip_prefix("cookbook/load/") {
252 (LifecycleAction::Load, lib)
253 } else if let Some(lib) = id.strip_suffix("/cookbook-lifecycle/unload") {
254 (LifecycleAction::Unload, lib)
255 } else {
256 return None;
257 };
258 directory
259 .entry(lib)
260 .map(|entry| (action, entry.id.as_str()))
261}
262
263fn resolve_recipe<'a>(
264 store: &'a RecipeStore,
265 id: &str,
266) -> std::result::Result<&'a RecipeCard, ResolveError> {
267 if id.trim().is_empty() {
268 return Err(ResolveError::BadRequest(
269 "cookbook recipe id must not be empty".to_owned(),
270 ));
271 }
272 if let Some(card) = store.card(id) {
273 return Ok(card);
274 }
275 let suffix = format!("/{id}");
276 let candidates: Vec<&RecipeCard> = ordered_cards(store)
277 .into_iter()
278 .filter(|card| card.id.ends_with(&suffix))
279 .collect();
280 match candidates.as_slice() {
281 [card] => Ok(*card),
282 [] => Err(ResolveError::Unknown(format!("unknown recipe {id}"))),
283 many => Err(ResolveError::Ambiguous(format!(
284 "ambiguous recipe {id}: {}",
285 many.iter()
286 .map(|card| card.id.as_str())
287 .collect::<Vec<_>>()
288 .join(", ")
289 ))),
290 }
291}
292
293fn render_page(store: &RecipeStore, selected: Option<&str>) -> String {
294 let selected_card = selected
295 .and_then(|id| resolve_recipe(store, id).ok())
296 .or_else(|| ordered_cards(store).first().copied());
297 let selected_id = selected_card.map(|card| card.id.as_str());
298 let mut out = String::from(
299 r#"<!DOCTYPE html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SIM Cookbook</title><link rel="stylesheet" href="/styles/theme.css" /></head><body><nav class="top-nav"><a href="/">Shell</a><a href="/cookbook" aria-current="page">Cookbook</a></nav><main class="cookbook-shell">"#,
300 );
301 out.push_str(r#"<aside class="cookbook-rail"><input type="search" aria-label="Search recipes" placeholder="Search" /><nav class="cookbook-tree">"#);
302 if store.is_empty() {
303 out.push_str(r#"<p class="empty-state">No recipes loaded.</p>"#);
304 } else {
305 render_tree_html(&mut out, store, selected_id);
306 }
307 out.push_str(r#"</nav></aside><section class="cookbook-main">"#);
308 match selected_card {
309 Some(card) => render_recipe_html(&mut out, store, card),
310 None => out.push_str(r#"<h1>Cookbook</h1><p class="empty-state">No recipes loaded.</p>"#),
311 }
312 out.push_str("</section></main></body></html>");
313 out
314}
315
316fn render_tree_html(out: &mut String, store: &RecipeStore, selected: Option<&str>) {
317 for book in view(store).books {
318 out.push_str("<section><h2>");
319 push_html(out, &book.title);
320 out.push_str("</h2>");
321 for chapter in book.chapters {
322 out.push_str("<div><h3>");
323 push_html(out, &chapter.title);
324 out.push_str("</h3><ul>");
325 for recipe in chapter.recipes {
326 let active = selected == Some(recipe.id.as_str());
327 out.push_str("<li><a href=\"/cookbook?recipe=");
328 push_attr(out, &recipe.id);
329 out.push('"');
330 if active {
331 out.push_str(" aria-current=\"page\" class=\"selected\"");
332 }
333 out.push('>');
334 push_html(out, &recipe.title);
335 out.push_str("</a></li>");
336 }
337 out.push_str("</ul></div>");
338 }
339 out.push_str("</section>");
340 }
341}
342
343fn render_recipe_html(out: &mut String, store: &RecipeStore, card: &RecipeCard) {
344 out.push_str("<h1>");
345 push_html(out, &card.title);
346 out.push_str("</h1><div class=\"purpose\">");
347 push_purpose_html(out, &card.purpose);
348 out.push_str("</div><div class=\"recipe-actions\"><button type=\"button\">Copy</button><button type=\"button\">Run</button></div><pre><code>");
349 push_html(out, &String::from_utf8_lossy(&card.setup));
350 out.push_str("</code></pre><section class=\"results-panel\" aria-live=\"polite\">Run this recipe to see pass/fail data.</section><footer>");
351 if let Some(next) = next(store, &card.id) {
352 out.push_str("<a href=\"/cookbook?recipe=");
353 push_attr(out, &next.id);
354 out.push_str("\">Next recipe -></a>");
355 } else {
356 out.push_str("<span>No next recipe.</span>");
357 }
358 out.push_str("</footer>");
359}
360
361fn split_target(target: &str) -> (&str, Option<&str>) {
362 match target.split_once('?') {
363 Some((path, query)) => (path, Some(query)),
364 None => (target, None),
365 }
366}
367
368fn query_value(query: &str, key: &str) -> Option<String> {
369 query.split('&').find_map(|pair| {
370 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
371 (name == key).then(|| decode_component(value, true).unwrap_or_default())
372 })
373}
374
375fn decode_component(raw: &str, plus_is_space: bool) -> std::result::Result<String, ResolveError> {
376 let bytes = raw.as_bytes();
377 let mut out = Vec::with_capacity(bytes.len());
378 let mut i = 0;
379 while i < bytes.len() {
380 match bytes[i] {
381 b'%' if i + 2 < bytes.len() => {
382 let hi = hex(bytes[i + 1]);
383 let lo = hex(bytes[i + 2]);
384 match (hi, lo) {
385 (Some(hi), Some(lo)) => out.push((hi << 4) | lo),
386 _ => {
387 return Err(ResolveError::BadRequest(
388 "invalid percent escape in cookbook route".to_owned(),
389 ));
390 }
391 }
392 i += 3;
393 }
394 b'%' => {
395 return Err(ResolveError::BadRequest(
396 "truncated percent escape in cookbook route".to_owned(),
397 ));
398 }
399 b'+' if plus_is_space => {
400 out.push(b' ');
401 i += 1;
402 }
403 byte => {
404 out.push(byte);
405 i += 1;
406 }
407 }
408 }
409 String::from_utf8(out).map_err(|err| ResolveError::BadRequest(err.to_string()))
410}
411
412fn hex(byte: u8) -> Option<u8> {
413 match byte {
414 b'0'..=b'9' => Some(byte - b'0'),
415 b'a'..=b'f' => Some(byte - b'a' + 10),
416 b'A'..=b'F' => Some(byte - b'A' + 10),
417 _ => None,
418 }
419}
420
421fn push_purpose_html(out: &mut String, purpose: &str) {
422 let mut wrote = false;
423 let mut open_p = false;
424 for raw in purpose.lines() {
425 let line = raw.trim();
426 if line.is_empty() {
427 if open_p {
428 out.push_str("</p>");
429 open_p = false;
430 }
431 continue;
432 }
433 if let Some(title) = line.strip_prefix("# ") {
434 if open_p {
435 out.push_str("</p>");
436 open_p = false;
437 }
438 out.push_str("<h2>");
439 push_html(out, title);
440 out.push_str("</h2>");
441 } else {
442 if !open_p {
443 out.push_str("<p>");
444 open_p = true;
445 } else {
446 out.push(' ');
447 }
448 push_html(out, line);
449 }
450 wrote = true;
451 }
452 if open_p {
453 out.push_str("</p>");
454 }
455 if !wrote {
456 out.push_str("<p>No purpose provided.</p>");
457 }
458}
459
460fn push_html(out: &mut String, text: &str) {
461 for ch in text.chars() {
462 match ch {
463 '&' => out.push_str("&"),
464 '<' => out.push_str("<"),
465 '>' => out.push_str(">"),
466 '"' => out.push_str("""),
467 '\'' => out.push_str("'"),
468 ch => out.push(ch),
469 }
470 }
471}
472
473fn push_attr(out: &mut String, text: &str) {
474 push_html(out, text);
475}