1use std::sync::{Arc, Mutex};
39
40use mlua::{Lua, Table, Value};
41use serde::Deserialize;
42
43use super::AppService;
44
45const LUA_GEN_DOCS: &str = include_str!("lua/gendoc/gen_docs.lua");
48const LUA_DOCS_LIST: &str = include_str!("lua/gendoc/docs/list.lua");
49const LUA_DOCS_EXTRACT: &str = include_str!("lua/gendoc/docs/extract.lua");
50const LUA_DOCS_PROJECTIONS: &str = include_str!("lua/gendoc/docs/projections.lua");
51const LUA_DOCS_PKG_INFO: &str = include_str!("lua/gendoc/docs/pkg_info.lua");
52const LUA_DOCS_JSON: &str = include_str!("lua/gendoc/docs/json.lua");
53const LUA_DOCS_LINT: &str = include_str!("lua/gendoc/docs/lint.lua");
54const LUA_DOCS_ENTITY_SCHEMAS: &str = include_str!("lua/gendoc/docs/entity_schemas.lua");
55
56const LUA_ALC_SHAPES_STUB: &str = r#"
65local M = {}
66M.check = function(_v, _schema, _opts) return true, nil end
67M.assert_dev = function(_v, _schema, _opts) return true end
68M.fields = function(schema) return (schema and schema.fields) or {} end
69M.infer = function(v) return v end
70return M
71"#;
72
73const LUA_ALC_SHAPES_T_STUB: &str = r##"
83local T = {}
84
85-- Method table installed on every schema object. `is_optional()`
86-- wraps the receiver in an optional variant (used by
87-- entity_schemas.lua for structurally-optional fields).
88local schema_mt = {}
89schema_mt.__index = {
90 is_optional = function(self)
91 return setmetatable({ kind = "optional", inner = self }, schema_mt)
92 end,
93}
94
95local function make_schema(tbl)
96 return setmetatable(tbl, schema_mt)
97end
98
99T.any = make_schema({ kind = "any" })
100T.string = make_schema({ kind = "prim", name = "string" })
101T.number = make_schema({ kind = "prim", name = "number" })
102T.bool = make_schema({ kind = "prim", name = "bool" })
103-- Aliases occasionally used by older docs modules.
104T.str = T.string
105T.num = T.number
106
107T.ref = function(name) return make_schema({ kind = "ref", name = name }) end
108T.list = function(t) return make_schema({ kind = "list", item = t }) end
109T.array_of = function(t) return make_schema({ kind = "array_of", item = t }) end
110T.map = function(k, v) return make_schema({ kind = "map", key = k, value = v }) end
111T.map_of = function(k, v) return make_schema({ kind = "map_of", key = k, value = v }) end
112T.opt = function(t) return make_schema({ kind = "optional", inner = t }) end
113T.optional = T.opt
114T.one_of = function(values) return make_schema({ kind = "one_of", values = values }) end
115T.shape = function(fields, opts)
116 return make_schema({ kind = "shape", fields = fields, opts = opts or {} })
117end
118T.described = function(schema, desc)
119 return make_schema({ kind = "described", inner = schema, desc = desc })
120end
121T.discriminated = function(tag, variants)
122 return make_schema({ kind = "discriminated", tag = tag, variants = variants })
123end
124
125T._internal = {
126 is_schema = function(v) return type(v) == "table" end,
127}
128
129return T
130"##;
131
132const PRELOAD_MODULES: &[(&str, &str)] = &[
135 ("tools.docs.list", LUA_DOCS_LIST),
136 ("tools.docs.extract", LUA_DOCS_EXTRACT),
137 ("tools.docs.projections", LUA_DOCS_PROJECTIONS),
138 ("tools.docs.pkg_info", LUA_DOCS_PKG_INFO),
139 ("tools.docs.json", LUA_DOCS_JSON),
140 ("tools.docs.lint", LUA_DOCS_LINT),
141 ("tools.docs.entity_schemas", LUA_DOCS_ENTITY_SCHEMAS),
142 ("alc_shapes", LUA_ALC_SHAPES_STUB),
143 ("alc_shapes.t", LUA_ALC_SHAPES_T_STUB),
144];
145
146const HOOK_SCRIPT: &str = r##"
159os.exit = function(code)
160 local c = code or 0
161 local tbl = { __gendoc_exit = c }
162 -- Attach __tostring so the raw mlua error message embeds the
163 -- code as "__gendoc_exit=N", letting the Rust side recover it
164 -- via substring match instead of walking CallbackError internals.
165 setmetatable(tbl, { __tostring = function(self)
166 return string.format("__gendoc_exit=%d", self.__gendoc_exit or 0)
167 end })
168 error(tbl, 0)
169end
170io.stdout = {
171 write = function(self, ...)
172 local args = { ... }
173 for i = 1, select("#", ...) do
174 args[i] = tostring(args[i])
175 end
176 _gendoc_out_append(table.concat(args))
177 return self
178 end,
179}
180io.stderr = {
181 write = function(self, ...)
182 local args = { ... }
183 for i = 1, select("#", ...) do
184 args[i] = tostring(args[i])
185 end
186 _gendoc_err_append(table.concat(args))
187 return self
188 end,
189}
190print = function(...)
191 local args = { ... }
192 for i = 1, select("#", ...) do
193 args[i] = tostring(args[i])
194 end
195 _gendoc_out_append(table.concat(args, "\t") .. "\n")
196end
197"##;
198
199const EXIT_MARKER: &str = "__gendoc_exit";
201
202impl AppService {
203 pub fn hub_gendoc(
233 &self,
234 source_dir: &str,
235 out_dir: Option<&str>,
236 projections: Option<&[String]>,
237 config_path: Option<&str>,
238 lint_strict: Option<bool>,
239 ) -> Result<String, String> {
240 let projection_flags = ProjectionFlags::from_list(projections)?;
241 if (projection_flags.context7 || projection_flags.devin) && config_path.is_none() {
242 return Err(
243 "gendoc: config_path is required when projections include context7 or devin"
244 .to_string(),
245 );
246 }
247
248 let resolved_out_dir = out_dir
249 .map(|s| s.to_string())
250 .unwrap_or_else(|| format!("{source_dir}/docs"));
251
252 let lua = Lua::new();
253
254 register_preloads(&lua)?;
255
256 if let Some(path) = config_path {
260 inject_config_preloads(&lua, path)?;
261 }
262
263 let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
264 let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
265
266 install_io_hooks(&lua, Arc::clone(&out_buf), Arc::clone(&err_buf))?;
267
268 install_argv(
269 &lua,
270 source_dir,
271 &resolved_out_dir,
272 &projection_flags,
273 lint_strict.unwrap_or(false),
274 )?;
275
276 lua.load(HOOK_SCRIPT)
280 .set_name("@embedded:gendoc/hooks.lua")
281 .exec()
282 .map_err(|e| format!("gendoc: hooks inject failed: {e}"))?;
283
284 let gen_docs_body = strip_shebang(LUA_GEN_DOCS);
292 let exec_result = lua
293 .load(gen_docs_body)
294 .set_name("@embedded:gendoc/gen_docs.lua")
295 .exec();
296
297 let stdout_txt = read_buf(&out_buf)?;
298 let stderr_txt = read_buf(&err_buf)?;
299
300 match exec_result {
301 Ok(()) => {}
302 Err(e) => {
303 if let Some(code) = extract_exit_code(&e) {
304 if code != 0 {
305 return Err(format!(
306 "gendoc: exited with code {code}\nstderr:\n{stderr_txt}"
307 ));
308 }
309 } else {
312 return Err(format!("gendoc: Lua error: {e}\nstderr:\n{stderr_txt}"));
313 }
314 }
315 }
316
317 Ok(build_response_json(
318 source_dir,
319 &resolved_out_dir,
320 &stdout_txt,
321 &stderr_txt,
322 ))
323 }
324}
325
326#[derive(Debug, Default, Clone, Copy)]
329struct ProjectionFlags {
330 hub: bool,
331 context7: bool,
332 devin: bool,
333 lint: bool,
334 lint_only: bool,
335}
336
337impl ProjectionFlags {
338 fn from_list(projections: Option<&[String]>) -> Result<Self, String> {
339 let mut f = ProjectionFlags::default();
340 let Some(list) = projections else {
341 return Ok(f);
342 };
343 for p in list {
344 match p.as_str() {
345 "hub" => f.hub = true,
346 "context7" => f.context7 = true,
347 "devin" => f.devin = true,
348 "lint" => f.lint = true,
349 "lint_only" => {
350 f.lint_only = true;
351 f.lint = true;
352 }
353 _ => {
354 return Err(format!(
355 "gendoc: unknown projection '{p}' (allowed: hub, context7, devin, lint, lint_only)"
356 ));
357 }
358 }
359 }
360 Ok(f)
361 }
362}
363
364fn register_preloads(lua: &Lua) -> Result<(), String> {
365 let preload = preload_table(lua)?;
366 for (mod_name, src) in PRELOAD_MODULES.iter().copied() {
367 register_single_preload(lua, &preload, mod_name, src)?;
368 }
369 Ok(())
370}
371
372fn preload_table(lua: &Lua) -> Result<Table, String> {
373 let package: Table = lua
379 .globals()
380 .get("package")
381 .map_err(|e| format!("gendoc: globals().package lookup failed: {e}"))?;
382 let preload: Table = package
383 .get("preload")
384 .map_err(|e| format!("gendoc: package.preload lookup failed: {e}"))?;
385 Ok(preload)
386}
387
388fn register_single_preload(
389 lua: &Lua,
390 preload: &Table,
391 mod_name: &'static str,
392 src: &'static str,
393) -> Result<(), String> {
394 let chunk_name = format!("@embedded:gendoc/{mod_name}.lua");
395 let loader = lua
396 .create_function(move |lua, ()| lua.load(src).set_name(chunk_name.clone()).eval::<Value>())
397 .map_err(|e| format!("gendoc: preload create_function failed for {mod_name}: {e}"))?;
398 preload
399 .set(mod_name, loader)
400 .map_err(|e| format!("gendoc: preload.set failed for {mod_name}: {e}"))?;
401 Ok(())
402}
403
404fn inject_config_preloads(lua: &Lua, config_path: &str) -> Result<(), String> {
405 let src = std::fs::read_to_string(config_path)
406 .map_err(|e| format!("gendoc: config_path '{config_path}' load failed: {e}"))?;
407 let config: GendocConfig = toml::from_str(&src)
408 .map_err(|e| format!("gendoc: config_path '{config_path}' parse failed: {e}"))?;
409
410 let preload = preload_table(lua)?;
415
416 inject_config_subtable(
417 lua,
418 &preload,
419 config.context7,
420 "context7",
421 "_gendoc_context7_config",
422 "tools.docs.context7_config",
423 )?;
424 inject_config_subtable(
425 lua,
426 &preload,
427 config.devin,
428 "devin",
429 "_gendoc_devin_config",
430 "tools.docs.devin_wiki_config",
431 )?;
432
433 Ok(())
434}
435
436fn inject_config_subtable(
448 lua: &Lua,
449 preload: &Table,
450 value: Option<toml::Value>,
451 key: &'static str,
452 global_key: &'static str,
453 module_name: &'static str,
454) -> Result<(), String> {
455 match value {
456 None => Ok(()),
457 Some(v) => {
458 let lua_value = toml_to_lua_value(lua, &v)
459 .map_err(|e| format!("gendoc: config '{key}' conversion failed: {e}"))?;
460 match lua_value {
461 Value::Table(_) => {
462 lua.globals()
463 .set(global_key, lua_value)
464 .map_err(|e| format!("gendoc: stash {global_key} failed: {e}"))?;
465 register_config_loader(lua, preload, module_name, global_key)
466 }
467 other => Err(format!(
468 "gendoc: config '{key}' must be a table, got {}",
469 other.type_name()
470 )),
471 }
472 }
473 }
474}
475
476#[derive(Debug, Deserialize)]
477struct GendocConfig {
478 context7: Option<toml::Value>,
479 devin: Option<toml::Value>,
480}
481
482fn toml_to_lua_value(lua: &Lua, value: &toml::Value) -> Result<Value, String> {
483 match value {
484 toml::Value::String(s) => Ok(Value::String(
485 lua.create_string(s)
486 .map_err(|e| format!("create string failed: {e}"))?,
487 )),
488 toml::Value::Integer(i) => Ok(Value::Integer(*i)),
489 toml::Value::Float(f) => Ok(Value::Number(*f)),
490 toml::Value::Boolean(b) => Ok(Value::Boolean(*b)),
491 toml::Value::Datetime(dt) => Ok(Value::String(
492 lua.create_string(dt.to_string())
493 .map_err(|e| format!("create datetime string failed: {e}"))?,
494 )),
495 toml::Value::Array(arr) => {
496 let table = lua
497 .create_table()
498 .map_err(|e| format!("create array table failed: {e}"))?;
499 for (idx, item) in arr.iter().enumerate() {
500 let v = toml_to_lua_value(lua, item)?;
501 table
502 .set((idx + 1) as i64, v)
503 .map_err(|e| format!("set array item [{idx}] failed: {e}"))?;
504 }
505 Ok(Value::Table(table))
506 }
507 toml::Value::Table(map) => {
508 let table = lua
509 .create_table()
510 .map_err(|e| format!("create map table failed: {e}"))?;
511 for (k, v) in map {
512 let vv = toml_to_lua_value(lua, v)?;
513 table
514 .set(k.as_str(), vv)
515 .map_err(|e| format!("set map key '{k}' failed: {e}"))?;
516 }
517 Ok(Value::Table(table))
518 }
519 }
520}
521
522fn register_config_loader(
523 lua: &Lua,
524 preload: &Table,
525 module_name: &'static str,
526 global_key: &'static str,
527) -> Result<(), String> {
528 let loader = lua
529 .create_function(move |lua, ()| lua.globals().get::<Value>(global_key))
530 .map_err(|e| format!("gendoc: config loader for {module_name} failed: {e}"))?;
531 preload
532 .set(module_name, loader)
533 .map_err(|e| format!("gendoc: preload.set failed for {module_name}: {e}"))?;
534 Ok(())
535}
536
537fn install_io_hooks(
538 lua: &Lua,
539 out_buf: Arc<Mutex<String>>,
540 err_buf: Arc<Mutex<String>>,
541) -> Result<(), String> {
542 let out_for_closure = Arc::clone(&out_buf);
543 let append_out = lua
544 .create_function(move |_, s: String| {
545 out_for_closure
546 .lock()
547 .map_err(|e| mlua::Error::external(format!("gendoc: out buf lock: {e}")))?
548 .push_str(&s);
549 Ok(())
550 })
551 .map_err(|e| format!("gendoc: create_function _gendoc_out_append: {e}"))?;
552
553 let err_for_closure = Arc::clone(&err_buf);
554 let append_err = lua
555 .create_function(move |_, s: String| {
556 err_for_closure
557 .lock()
558 .map_err(|e| mlua::Error::external(format!("gendoc: err buf lock: {e}")))?
559 .push_str(&s);
560 Ok(())
561 })
562 .map_err(|e| format!("gendoc: create_function _gendoc_err_append: {e}"))?;
563
564 lua.globals()
565 .set("_gendoc_out_append", append_out)
566 .map_err(|e| format!("gendoc: globals set _gendoc_out_append: {e}"))?;
567 lua.globals()
568 .set("_gendoc_err_append", append_err)
569 .map_err(|e| format!("gendoc: globals set _gendoc_err_append: {e}"))?;
570
571 Ok(())
572}
573
574fn install_argv(
575 lua: &Lua,
576 source_dir: &str,
577 out_dir: &str,
578 flags: &ProjectionFlags,
579 lint_strict: bool,
580) -> Result<(), String> {
581 let argv = lua
582 .create_table()
583 .map_err(|e| format!("gendoc: create argv table: {e}"))?;
584
585 let mut idx: i64 = 1;
586 let mut push = |v: &str| -> Result<(), String> {
587 argv.set(idx, v)
588 .map_err(|e| format!("gendoc: argv set [{idx}]: {e}"))?;
589 idx += 1;
590 Ok(())
591 };
592
593 push(source_dir)?;
594 push(out_dir)?;
595 if flags.hub {
596 push("--hub")?;
597 }
598 if flags.context7 {
599 push("--context7")?;
600 }
601 if flags.devin {
602 push("--devin")?;
603 }
604 if flags.lint_only {
605 push("--lint-only")?;
606 } else if flags.lint {
607 push("--lint")?;
608 }
609 if lint_strict {
610 push("--strict")?;
611 }
612
613 lua.globals()
614 .set("arg", argv)
615 .map_err(|e| format!("gendoc: globals set arg: {e}"))?;
616
617 Ok(())
618}
619
620fn strip_shebang(src: &str) -> &str {
627 if let Some(body) = src.strip_prefix("#!") {
628 match body.find('\n') {
629 Some(i) => &body[i + 1..],
630 None => "",
631 }
632 } else {
633 src
634 }
635}
636
637fn read_buf(buf: &Arc<Mutex<String>>) -> Result<String, String> {
638 Ok(buf
639 .lock()
640 .map_err(|e| format!("gendoc: buffer lock (read): {e}"))?
641 .clone())
642}
643
644fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
647 let msg = err.to_string();
666 let needle = EXIT_MARKER;
669 let idx = msg.find(needle)?;
670 let rest = &msg[idx + needle.len()..];
671 let digits_start = rest
673 .char_indices()
674 .find(|(_, c)| c.is_ascii_digit() || *c == '-')
675 .map(|(i, _)| i)?;
676 let tail = &rest[digits_start..];
677 let digits_end = tail
678 .char_indices()
679 .find(|(_, c)| !c.is_ascii_digit() && *c != '-')
680 .map(|(i, _)| i)
681 .unwrap_or(tail.len());
682 tail[..digits_end].parse::<i64>().ok()
683}
684
685fn build_response_json(
686 source_dir: &str,
687 out_dir: &str,
688 stdout_txt: &str,
689 stderr_txt: &str,
690) -> String {
691 let value = serde_json::json!({
696 "source_dir": source_dir,
697 "out_dir": out_dir,
698 "stdout": stdout_txt,
699 "stderr": stderr_txt,
700 });
701 value.to_string()
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn projection_flags_defaults_are_false() {
710 let f = ProjectionFlags::from_list(None).expect("projection parse");
711 assert!(!f.hub);
712 assert!(!f.context7);
713 assert!(!f.devin);
714 assert!(!f.lint);
715 assert!(!f.lint_only);
716 }
717
718 #[test]
719 fn projection_flags_parse_known_tokens() {
720 let list = vec![
721 "hub".to_string(),
722 "context7".to_string(),
723 "devin".to_string(),
724 ];
725 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
726 assert!(f.hub);
727 assert!(f.context7);
728 assert!(f.devin);
729 assert!(!f.lint);
730 }
731
732 #[test]
733 fn projection_flags_lint_only_implies_lint() {
734 let list = vec!["lint_only".to_string()];
735 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
736 assert!(f.lint);
737 assert!(f.lint_only);
738 }
739
740 #[test]
741 fn projection_flags_unknown_is_rejected() {
742 let list = vec!["nope".to_string(), "hub".to_string()];
743 let err = ProjectionFlags::from_list(Some(&list)).expect_err("must reject unknown");
744 assert!(err.contains("unknown projection"));
745 }
746
747 #[test]
748 fn context7_without_config_is_rejected() {
749 let list = vec!["context7".to_string()];
755 let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
756 assert!(flags.context7);
757 let err_expected =
759 "gendoc: config_path is required when projections include context7 or devin";
760 let err = if (flags.context7 || flags.devin) && Option::<&str>::None.is_none() {
761 Some(err_expected.to_string())
762 } else {
763 None
764 };
765 assert_eq!(err.as_deref(), Some(err_expected));
766 }
767
768 #[test]
769 fn extract_exit_code_parses_marker_formats() {
770 let err = mlua::Error::RuntimeError(
774 "runtime error: [string \"...\"]:2: {__gendoc_exit=2}".to_string(),
775 );
776 assert_eq!(extract_exit_code(&err), Some(2));
777
778 let err = mlua::Error::RuntimeError("runtime error: __gendoc_exit: 0 (clean)".to_string());
779 assert_eq!(extract_exit_code(&err), Some(0));
780 }
781
782 #[test]
783 fn extract_exit_code_returns_none_for_unrelated_errors() {
784 let err = mlua::Error::RuntimeError("some other Lua error".to_string());
785 assert!(extract_exit_code(&err).is_none());
786 }
787
788 #[test]
789 fn strip_shebang_removes_first_line_when_prefixed() {
790 let src = "#!/usr/bin/env lua\nreturn 1\n";
791 assert_eq!(strip_shebang(src), "return 1\n");
792 }
793
794 #[test]
795 fn strip_shebang_preserves_source_without_shebang() {
796 let src = "-- no shebang\nreturn 1\n";
797 assert_eq!(strip_shebang(src), src);
798 }
799
800 #[test]
801 fn strip_shebang_handles_shebang_only_without_trailing_newline() {
802 let src = "#!/usr/bin/env lua";
803 assert_eq!(strip_shebang(src), "");
804 }
805
806 #[test]
807 fn build_response_json_round_trips() {
808 let out = build_response_json("/src", "/src/docs", "hi", "warn");
809 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
810 assert_eq!(parsed["source_dir"], "/src");
811 assert_eq!(parsed["out_dir"], "/src/docs");
812 assert_eq!(parsed["stdout"], "hi");
813 assert_eq!(parsed["stderr"], "warn");
814 }
815}