1use sim_cookbook::{RecipeCard, RecipeStore, next, ordered_cards, view};
2use sim_kernel::{Cx, Result};
3use sim_lib_cookbook::{SeededLibCatalog, run_recipe_with_catalog, seeded_recipe_store};
4
5use crate::cookbook_web_json::{
6 render_error_json, render_index_json, render_recipe_json, render_run_json, render_search_json,
7};
8
9const JSON: &str = "application/json; charset=utf-8";
10const HTML: &str = "text/html; charset=utf-8";
11
12#[derive(Clone, Debug)]
14pub struct CookbookWebState {
15 store: RecipeStore,
16}
17
18impl CookbookWebState {
19 pub fn seeded() -> Result<Self> {
21 Ok(Self {
22 store: seeded_recipe_store()?,
23 })
24 }
25
26 pub fn from_store(store: RecipeStore) -> Self {
28 Self { store }
29 }
30
31 pub fn empty() -> Self {
33 Self::from_store(RecipeStore::new())
34 }
35
36 pub fn store(&self) -> &RecipeStore {
38 &self.store
39 }
40
41 pub fn handle_request(
47 &self,
48 method: &str,
49 target: &str,
50 cx: Option<&mut Cx>,
51 ) -> CookbookWebResponse {
52 let (path, query) = split_target(target);
53 match (method, path) {
54 ("GET", "/cookbook") => {
55 let selected = query.and_then(|q| query_value(q, "recipe"));
56 CookbookWebResponse::html(render_page(&self.store, selected.as_deref()))
57 }
58 ("GET", "/api/cookbook") => CookbookWebResponse::json(render_index_json(&self.store)),
59 ("GET", "/api/cookbook/search") => {
60 let q = query
61 .and_then(|query| query_value(query, "q"))
62 .unwrap_or_default();
63 CookbookWebResponse::json(render_search_json(&self.store, &q))
64 }
65 ("POST", path)
66 if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") =>
67 {
68 let start = "/api/cookbook/recipe/".len();
69 let end = path.len() - "/run".len();
70 let raw_id = &path[start..end];
71 let Some(cx) = cx else {
72 return CookbookWebResponse::server_error(
73 "cookbook run endpoint requires a runtime context",
74 );
75 };
76 let card = match decode_component(raw_id, false)
77 .and_then(|id| resolve_recipe(&self.store, &id))
78 {
79 Ok(card) => card.clone(),
80 Err(err) => return response_for_resolve_error(err),
81 };
82 let catalog = SeededLibCatalog::standard();
86 match run_recipe_with_catalog(cx, &catalog, &card) {
87 Ok(run) => CookbookWebResponse::json(render_run_json(&run)),
88 Err(err) => CookbookWebResponse::server_error(err.to_string()),
89 }
90 }
91 (_, path) if path.starts_with("/api/cookbook/recipe/") && path.ends_with("/run") => {
92 CookbookWebResponse::method_not_allowed()
93 }
94 ("GET", path) if path.starts_with("/api/cookbook/recipe/") => {
95 let raw_id = &path["/api/cookbook/recipe/".len()..];
96 match decode_component(raw_id, false)
97 .and_then(|id| resolve_recipe(&self.store, &id))
98 {
99 Ok(card) => CookbookWebResponse::json(render_recipe_json(&self.store, card)),
100 Err(err) => response_for_resolve_error(err),
101 }
102 }
103 (_, "/api/cookbook") | (_, "/api/cookbook/search") | (_, "/cookbook") => {
104 CookbookWebResponse::method_not_allowed()
105 }
106 (_, path) if path.starts_with("/api/cookbook/recipe/") => {
107 CookbookWebResponse::method_not_allowed()
108 }
109 _ => CookbookWebResponse::not_found("unknown cookbook route"),
110 }
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct CookbookWebResponse {
117 pub status: u16,
119 pub content_type: &'static str,
121 pub body: String,
123}
124
125impl CookbookWebResponse {
126 fn html(body: String) -> Self {
127 Self {
128 status: 200,
129 content_type: HTML,
130 body,
131 }
132 }
133
134 fn json(body: String) -> Self {
135 Self {
136 status: 200,
137 content_type: JSON,
138 body,
139 }
140 }
141
142 fn not_found(message: impl AsRef<str>) -> Self {
143 Self::error(404, message)
144 }
145
146 fn method_not_allowed() -> Self {
147 Self::error(405, "method not allowed")
148 }
149
150 fn server_error(message: impl AsRef<str>) -> Self {
151 Self::error(500, message)
152 }
153
154 fn error(status: u16, message: impl AsRef<str>) -> Self {
155 Self {
156 status,
157 content_type: JSON,
158 body: render_error_json(message.as_ref()),
159 }
160 }
161}
162
163#[derive(Debug, PartialEq, Eq)]
164enum ResolveError {
165 Unknown(String),
166 Ambiguous(String),
167 BadRequest(String),
168}
169
170fn response_for_resolve_error(err: ResolveError) -> CookbookWebResponse {
171 match err {
172 ResolveError::Unknown(message) => CookbookWebResponse::not_found(message),
173 ResolveError::Ambiguous(message) | ResolveError::BadRequest(message) => {
174 CookbookWebResponse::error(400, message)
175 }
176 }
177}
178
179fn resolve_recipe<'a>(
180 store: &'a RecipeStore,
181 id: &str,
182) -> std::result::Result<&'a RecipeCard, ResolveError> {
183 if id.trim().is_empty() {
184 return Err(ResolveError::BadRequest(
185 "cookbook recipe id must not be empty".to_owned(),
186 ));
187 }
188 if let Some(card) = store.card(id) {
189 return Ok(card);
190 }
191 let suffix = format!("/{id}");
192 let candidates: Vec<&RecipeCard> = ordered_cards(store)
193 .into_iter()
194 .filter(|card| card.id.ends_with(&suffix))
195 .collect();
196 match candidates.as_slice() {
197 [card] => Ok(*card),
198 [] => Err(ResolveError::Unknown(format!("unknown recipe {id}"))),
199 many => Err(ResolveError::Ambiguous(format!(
200 "ambiguous recipe {id}: {}",
201 many.iter()
202 .map(|card| card.id.as_str())
203 .collect::<Vec<_>>()
204 .join(", ")
205 ))),
206 }
207}
208
209fn render_page(store: &RecipeStore, selected: Option<&str>) -> String {
210 let selected_card = selected
211 .and_then(|id| resolve_recipe(store, id).ok())
212 .or_else(|| ordered_cards(store).first().copied());
213 let selected_id = selected_card.map(|card| card.id.as_str());
214 let mut out = String::from(
215 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">"#,
216 );
217 out.push_str(r#"<aside class="cookbook-rail"><input type="search" aria-label="Search recipes" placeholder="Search" /><nav class="cookbook-tree">"#);
218 if store.is_empty() {
219 out.push_str(r#"<p class="empty-state">No recipes loaded.</p>"#);
220 } else {
221 render_tree_html(&mut out, store, selected_id);
222 }
223 out.push_str(r#"</nav></aside><section class="cookbook-main">"#);
224 match selected_card {
225 Some(card) => render_recipe_html(&mut out, store, card),
226 None => out.push_str(r#"<h1>Cookbook</h1><p class="empty-state">No recipes loaded.</p>"#),
227 }
228 out.push_str("</section></main></body></html>");
229 out
230}
231
232fn render_tree_html(out: &mut String, store: &RecipeStore, selected: Option<&str>) {
233 for book in view(store).books {
234 out.push_str("<section><h2>");
235 push_html(out, &book.title);
236 out.push_str("</h2>");
237 for chapter in book.chapters {
238 out.push_str("<div><h3>");
239 push_html(out, &chapter.title);
240 out.push_str("</h3><ul>");
241 for recipe in chapter.recipes {
242 let active = selected == Some(recipe.id.as_str());
243 out.push_str("<li><a href=\"/cookbook?recipe=");
244 push_attr(out, &recipe.id);
245 out.push('"');
246 if active {
247 out.push_str(" aria-current=\"page\" class=\"selected\"");
248 }
249 out.push('>');
250 push_html(out, &recipe.title);
251 out.push_str("</a></li>");
252 }
253 out.push_str("</ul></div>");
254 }
255 out.push_str("</section>");
256 }
257}
258
259fn render_recipe_html(out: &mut String, store: &RecipeStore, card: &RecipeCard) {
260 out.push_str("<h1>");
261 push_html(out, &card.title);
262 out.push_str("</h1><div class=\"purpose\">");
263 push_purpose_html(out, &card.purpose);
264 out.push_str("</div><div class=\"recipe-actions\"><button type=\"button\">Copy</button><button type=\"button\">Run</button></div><pre><code>");
265 push_html(out, &String::from_utf8_lossy(&card.setup));
266 out.push_str("</code></pre><section class=\"results-panel\" aria-live=\"polite\">Run this recipe to see pass/fail data.</section><footer>");
267 if let Some(next) = next(store, &card.id) {
268 out.push_str("<a href=\"/cookbook?recipe=");
269 push_attr(out, &next.id);
270 out.push_str("\">Next recipe -></a>");
271 } else {
272 out.push_str("<span>No next recipe.</span>");
273 }
274 out.push_str("</footer>");
275}
276
277fn split_target(target: &str) -> (&str, Option<&str>) {
278 match target.split_once('?') {
279 Some((path, query)) => (path, Some(query)),
280 None => (target, None),
281 }
282}
283
284fn query_value(query: &str, key: &str) -> Option<String> {
285 query.split('&').find_map(|pair| {
286 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
287 (name == key).then(|| decode_component(value, true).unwrap_or_default())
288 })
289}
290
291fn decode_component(raw: &str, plus_is_space: bool) -> std::result::Result<String, ResolveError> {
292 let bytes = raw.as_bytes();
293 let mut out = Vec::with_capacity(bytes.len());
294 let mut i = 0;
295 while i < bytes.len() {
296 match bytes[i] {
297 b'%' if i + 2 < bytes.len() => {
298 let hi = hex(bytes[i + 1]);
299 let lo = hex(bytes[i + 2]);
300 match (hi, lo) {
301 (Some(hi), Some(lo)) => out.push((hi << 4) | lo),
302 _ => {
303 return Err(ResolveError::BadRequest(
304 "invalid percent escape in cookbook route".to_owned(),
305 ));
306 }
307 }
308 i += 3;
309 }
310 b'%' => {
311 return Err(ResolveError::BadRequest(
312 "truncated percent escape in cookbook route".to_owned(),
313 ));
314 }
315 b'+' if plus_is_space => {
316 out.push(b' ');
317 i += 1;
318 }
319 byte => {
320 out.push(byte);
321 i += 1;
322 }
323 }
324 }
325 String::from_utf8(out).map_err(|err| ResolveError::BadRequest(err.to_string()))
326}
327
328fn hex(byte: u8) -> Option<u8> {
329 match byte {
330 b'0'..=b'9' => Some(byte - b'0'),
331 b'a'..=b'f' => Some(byte - b'a' + 10),
332 b'A'..=b'F' => Some(byte - b'A' + 10),
333 _ => None,
334 }
335}
336
337fn push_purpose_html(out: &mut String, purpose: &str) {
338 let mut wrote = false;
339 let mut open_p = false;
340 for raw in purpose.lines() {
341 let line = raw.trim();
342 if line.is_empty() {
343 if open_p {
344 out.push_str("</p>");
345 open_p = false;
346 }
347 continue;
348 }
349 if let Some(title) = line.strip_prefix("# ") {
350 if open_p {
351 out.push_str("</p>");
352 open_p = false;
353 }
354 out.push_str("<h2>");
355 push_html(out, title);
356 out.push_str("</h2>");
357 } else {
358 if !open_p {
359 out.push_str("<p>");
360 open_p = true;
361 } else {
362 out.push(' ');
363 }
364 push_html(out, line);
365 }
366 wrote = true;
367 }
368 if open_p {
369 out.push_str("</p>");
370 }
371 if !wrote {
372 out.push_str("<p>No purpose provided.</p>");
373 }
374}
375
376fn push_html(out: &mut String, text: &str) {
377 for ch in text.chars() {
378 match ch {
379 '&' => out.push_str("&"),
380 '<' => out.push_str("<"),
381 '>' => out.push_str(">"),
382 '"' => out.push_str("""),
383 '\'' => out.push_str("'"),
384 ch => out.push(ch),
385 }
386 }
387}
388
389fn push_attr(out: &mut String, text: &str) {
390 push_html(out, text);
391}