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