1use std::sync::Arc;
4
5use sim_codec_lisp::LispCodecLib;
6use sim_kernel::{
7 AbiVersion, Args, CORE_FUNCTION_CLASS_ID, Callable, CapabilityName, ClassRef, CodecId, Cx,
8 Error, Export, Expr, Lib, LibManifest, LibTarget, Linker, LoadCx, Object, ObjectCompat, Result,
9 Symbol, Value, Version, read_eval_capability,
10};
11use sim_lib_server::{CookbookCapabilityProfile, CookbookWebState};
12use sim_run_core::{Bootloader, RuntimeConfigState, cli_main_entrypoint_symbol};
13
14use crate::serve::{ServeConfig, serve_with_cx};
15
16pub struct AtelierCliLib;
18
19pub struct BrowseCliLib;
21
22impl Lib for AtelierCliLib {
23 fn manifest(&self) -> LibManifest {
24 cli_manifest("atelier", "cli/main/atelier")
25 }
26
27 fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
28 register_cli_entrypoint(cx, linker, "atelier")
29 }
30}
31
32impl Lib for BrowseCliLib {
33 fn manifest(&self) -> LibManifest {
34 cli_manifest("browse", "cli/main/browse")
35 }
36
37 fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
38 register_cli_entrypoint(cx, linker, "browse")
39 }
40}
41
42fn cli_manifest(id: &str, entrypoint: &str) -> LibManifest {
43 LibManifest {
44 id: Symbol::new(id),
45 version: Version(env!("CARGO_PKG_VERSION").to_owned()),
46 abi: AbiVersion { major: 0, minor: 1 },
47 target: LibTarget::HostRegistered,
48 requires: Vec::new(),
49 capabilities: Vec::new(),
50 exports: vec![Export::Function {
51 symbol: symbol_from_slash(entrypoint),
52 function_id: None,
53 }],
54 }
55}
56
57fn register_cli_entrypoint(
58 cx: &mut LoadCx,
59 linker: &mut Linker<'_>,
60 verb: &'static str,
61) -> Result<()> {
62 linker.function_value(
63 Symbol::qualified("cli", format!("main/{verb}")),
64 cx.factory()
65 .opaque(Arc::new(WebShellCliEntrypoint { verb }))?,
66 )?;
67 Ok(())
68}
69
70#[derive(Clone)]
71struct WebShellCliEntrypoint {
72 verb: &'static str,
73}
74
75impl Object for WebShellCliEntrypoint {
76 fn display(&self, _cx: &mut Cx) -> Result<String> {
77 Ok(format!("#<function cli/main/{}>", self.verb))
78 }
79
80 fn as_any(&self) -> &dyn std::any::Any {
81 self
82 }
83}
84
85impl ObjectCompat for WebShellCliEntrypoint {
86 fn class(&self, cx: &mut Cx) -> Result<ClassRef> {
87 if let Some(value) = cx
88 .registry()
89 .class_by_symbol(&Symbol::qualified("core", "Function"))
90 {
91 return Ok(value.clone());
92 }
93 cx.factory().class_stub(
94 CORE_FUNCTION_CLASS_ID,
95 Symbol::qualified("core", "Function"),
96 )
97 }
98
99 fn as_callable(&self) -> Option<&dyn Callable> {
100 Some(self)
101 }
102}
103
104impl Callable for WebShellCliEntrypoint {
105 fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
106 verify_cli_envelope(cx, &args, self.verb)?;
107 cx.factory().bool(true)
108 }
109}
110
111fn verify_cli_envelope(cx: &mut Cx, args: &Args, verb: &str) -> Result<()> {
112 let envelope = args
113 .values()
114 .first()
115 .ok_or_else(|| Error::Eval(format!("cli/main/{verb} expects a CLI envelope")))?;
116 let envelope_verb = envelope_string_field(cx, envelope, "verb")?;
117 if envelope_verb != verb {
118 return Err(Error::Eval(format!(
119 "cli/main/{verb} received verb {envelope_verb}"
120 )));
121 }
122 let payload_args = envelope_args(cx, envelope)?;
123 if payload_args.first().map(String::as_str) != Some(verb) {
124 return Err(Error::Eval(format!(
125 "cli/main/{verb} expects the first payload argument to be {verb}"
126 )));
127 }
128 Ok(())
129}
130
131fn envelope_string_field(cx: &mut Cx, envelope: &Value, field: &str) -> Result<String> {
132 let Some(table) = envelope.object().as_table_impl() else {
133 return Err(Error::Eval("CLI envelope is not a table".to_owned()));
134 };
135 match table.get(cx, Symbol::new(field))?.object().as_expr(cx)? {
136 Expr::String(text) => Ok(text),
137 Expr::Nil => Err(Error::Eval(format!("CLI envelope field {field} is nil"))),
138 other => Err(Error::Eval(format!(
139 "CLI envelope field {field} is not a string: {other:?}"
140 ))),
141 }
142}
143
144fn envelope_args(cx: &mut Cx, envelope: &Value) -> Result<Vec<String>> {
145 let Some(table) = envelope.object().as_table_impl() else {
146 return Err(Error::Eval("CLI envelope is not a table".to_owned()));
147 };
148 let value = table.get(cx, Symbol::new("args"))?;
149 let Some(list) = value.object().as_list() else {
150 return Err(Error::Eval(
151 "CLI envelope field args is not a list".to_owned(),
152 ));
153 };
154 list.to_vec(cx, Some(64))?
155 .into_iter()
156 .map(|value| match value.object().as_expr(cx)? {
157 Expr::String(text) => Ok(text),
158 other => Err(Error::Eval(format!(
159 "CLI payload argument is not a string: {other:?}"
160 ))),
161 })
162 .collect()
163}
164
165fn symbol_from_slash(text: &str) -> Symbol {
166 match text.split_once('/') {
167 Some((head, tail)) => Symbol::qualified(head, tail),
168 None => Symbol::new(text),
169 }
170}
171
172pub const WEB_SERVE_VERB: &str = "serve";
178
179pub fn web_serve_entrypoint_symbol() -> Symbol {
181 cli_main_entrypoint_symbol(WEB_SERVE_VERB)
182}
183
184pub type CookbookStateFactory = Arc<dyn Fn(&RuntimeConfigState) -> CookbookWebState + Send + Sync>;
186
187pub fn configure_web_bootloader(loader: Bootloader) -> Bootloader {
191 configure_web_bootloader_base(loader).host_verb(WEB_SERVE_VERB, "lib/web-serve", || {
192 Box::new(WebServeLib::new())
193 })
194}
195
196pub fn configure_web_bootloader_with_cookbook(
200 loader: Bootloader,
201 config_libs: Vec<Symbol>,
202 cookbook: CookbookStateFactory,
203) -> Bootloader {
204 configure_web_bootloader_base(loader).host_verb_with_config(
205 WEB_SERVE_VERB,
206 "lib/web-serve",
207 config_libs,
208 move |config| Box::new(WebServeLib::with_cookbook(cookbook(config))),
209 )
210}
211
212fn configure_web_bootloader_base(loader: Bootloader) -> Bootloader {
213 let loader = CookbookCapabilityProfile::granted()
220 .into_iter()
221 .fold(loader, |loader, capability| {
222 loader.with_capability(capability)
223 });
224 let loader = loader.with_capability(CapabilityName::new("glasses/mic"));
227 loader.host_lib("codec/lisp", || {
228 Box::new(LispCodecLib::new(CodecId(1)).expect("lisp boot codec"))
229 })
230}
231
232pub fn web_bootloader() -> Bootloader {
236 configure_web_bootloader(Bootloader::standard())
237}
238
239pub struct WebServeLib {
241 cookbook: Option<Arc<CookbookWebState>>,
242}
243
244impl WebServeLib {
245 pub fn new() -> Self {
247 Self { cookbook: None }
248 }
249
250 pub fn with_cookbook(cookbook: CookbookWebState) -> Self {
252 Self {
253 cookbook: Some(Arc::new(cookbook)),
254 }
255 }
256}
257
258impl Default for WebServeLib {
259 fn default() -> Self {
260 Self::new()
261 }
262}
263
264impl Lib for WebServeLib {
265 fn manifest(&self) -> LibManifest {
266 LibManifest {
267 id: Symbol::qualified("lib", "web-serve"),
268 version: Version(env!("CARGO_PKG_VERSION").to_owned()),
269 abi: AbiVersion { major: 0, minor: 1 },
270 target: LibTarget::HostRegistered,
271 requires: Vec::new(),
272 capabilities: vec![read_eval_capability()],
273 exports: vec![Export::Function {
274 symbol: web_serve_entrypoint_symbol(),
275 function_id: None,
276 }],
277 }
278 }
279
280 fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
281 linker.function_value(
282 web_serve_entrypoint_symbol(),
283 cx.factory().opaque(Arc::new(WebServeEntrypoint {
284 cookbook: self.cookbook.clone(),
285 }))?,
286 )?;
287 Ok(())
288 }
289}
290
291#[derive(Clone)]
292struct WebServeEntrypoint {
293 cookbook: Option<Arc<CookbookWebState>>,
294}
295
296impl Object for WebServeEntrypoint {
297 fn display(&self, _cx: &mut Cx) -> Result<String> {
298 Ok("cli/main/serve".to_owned())
299 }
300
301 fn as_any(&self) -> &dyn std::any::Any {
302 self
303 }
304}
305
306impl ObjectCompat for WebServeEntrypoint {
307 fn as_callable(&self) -> Option<&dyn Callable> {
308 Some(self)
309 }
310}
311
312impl Callable for WebServeEntrypoint {
313 fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
314 let mut config = match args.values().first() {
317 Some(envelope) => {
318 let payload = envelope_args(cx, envelope)?;
319 parse_serve_config(payload.into_iter().skip(1))?
320 }
321 None => ServeConfig::default(),
322 };
323 config.cookbook.clone_from(&self.cookbook);
324 serve_with_cx(cx, &config)
325 .map_err(|err| Error::Eval(format!("web serve failed: {err}")))?;
326 cx.factory().bool(true)
327 }
328}
329
330fn parse_serve_config(args: impl Iterator<Item = String>) -> Result<ServeConfig> {
335 let mut config = ServeConfig::default();
336 let mut iter = args;
337 while let Some(arg) = iter.next() {
338 match arg.as_str() {
339 "--addr" => {
340 config.addr = iter
341 .next()
342 .ok_or_else(|| Error::Eval("--addr requires a value".to_owned()))?;
343 }
344 other if other.starts_with("--addr=") => {
345 config.addr = other["--addr=".len()..].to_owned();
346 }
347 "--atelier-root" => {
348 config.atelier_root = iter
349 .next()
350 .ok_or_else(|| Error::Eval("--atelier-root requires a value".to_owned()))?
351 .into();
352 }
353 other if other.starts_with("--atelier-root=") => {
354 config.atelier_root = other["--atelier-root=".len()..].into();
355 }
356 "--dry-run" => {
357 config.dry_run = true;
358 }
359 other => {
360 return Err(Error::Eval(format!("unknown serve argument: {other}")));
361 }
362 }
363 }
364 Ok(config)
365}
366
367#[cfg(test)]
368mod tests {
369 use super::parse_serve_config;
370
371 fn parse(args: &[&str]) -> super::Result<super::ServeConfig> {
372 parse_serve_config(args.iter().map(|a| (*a).to_owned()))
373 }
374
375 #[test]
376 fn missing_addr_value_errors() {
377 let err = parse(&["--addr"]).expect_err("bare --addr must error");
378 assert!(err.to_string().contains("--addr requires a value"));
379 }
380
381 #[test]
382 fn missing_atelier_root_value_errors() {
383 let err = parse(&["--atelier-root"]).expect_err("bare --atelier-root must error");
384 assert!(err.to_string().contains("--atelier-root requires a value"));
385 }
386
387 #[test]
388 fn unknown_flag_errors() {
389 let err = parse(&["--add", "0.0.0.0:80"]).expect_err("unknown flag must error");
391 assert!(err.to_string().contains("unknown serve argument: --add"));
392 }
393
394 #[test]
395 fn unknown_positional_errors() {
396 let err = parse(&["serve-extra"]).expect_err("stray positional must error");
397 assert!(
398 err.to_string()
399 .contains("unknown serve argument: serve-extra")
400 );
401 }
402
403 #[test]
404 fn dry_run_still_succeeds() {
405 let config = parse(&["--dry-run"]).expect("--dry-run must parse");
406 assert!(config.dry_run);
407 }
408
409 #[test]
410 fn addr_and_atelier_root_parse() {
411 let config = parse(&["--addr", "127.0.0.1:9000", "--atelier-root", "/tmp/atelier"])
412 .expect("valid args must parse");
413 assert_eq!(config.addr, "127.0.0.1:9000");
414 assert_eq!(config.atelier_root.to_str(), Some("/tmp/atelier"));
415 assert!(!config.dry_run);
416 }
417
418 #[test]
419 fn inline_addr_value_parses() {
420 let config = parse(&["--addr=127.0.0.1:9100"]).expect("inline addr must parse");
421 assert_eq!(config.addr, "127.0.0.1:9100");
422 }
423}