1use std::path::PathBuf;
46use std::sync::{Arc, Mutex};
47
48use mlua::{Lua, Table, Value};
49use semver::{Version, VersionReq};
50use serde::Deserialize;
51use thiserror::Error;
52
53use super::hub_dist_preset::load_hub_projection_config;
54use super::project::resolve_project_root;
55use super::AppService;
56
57pub mod alc_shapes_codegen;
58pub mod templates;
59
60pub(crate) const EMBEDDED_ALC_SHAPES_VERSION: &str = "0.25.1";
65
66#[derive(Debug, Error)]
67enum ShapesVersionError {
68 #[error("alc_shapes version mismatch: embedded={embedded}, mirror={mirror}. {hint}")]
69 Mismatch {
70 embedded: String,
71 mirror: String,
72 hint: &'static str,
73 },
74 #[error("alc_shapes mirror init.lua at '{path}' has no parseable M.VERSION declaration")]
75 Malformed { path: PathBuf },
76}
77
78const SHAPES_VERSION_HINT: &str = "Align bundled alc_shapes/ to match core, \
79 or upgrade algocline core to the mirror version. See CHANGELOG for details.";
80
81#[derive(Debug, Error)]
84enum ShapesCompatError {
85 #[error(
86 "pkg '{pkg_name}': alc_shapes_compat range '{declared_range}' does not match \
87 embedded alc_shapes@{actual_version}. {hint}"
88 )]
89 Violation {
90 pkg_name: String,
91 declared_range: String,
92 actual_version: String,
93 hint: &'static str,
94 },
95 #[error(
96 "pkg '{pkg_name}': alc_shapes_compat value '{value}' is not a valid semver range: {cause}"
97 )]
98 Malformed {
99 pkg_name: String,
100 value: String,
101 cause: String,
102 },
103 #[error("I/O error reading pkg compat from '{path}': {cause}")]
104 Io { path: PathBuf, cause: String },
105}
106
107#[derive(Debug, Error)]
117enum HubGendocError {
118 #[error("{0}")]
119 ShapesVersion(#[from] ShapesVersionError),
120 #[error("{0}")]
121 ShapesCompat(#[from] ShapesCompatError),
122}
123
124const SHAPES_COMPAT_VIOLATION_HINT: &str = "Declare a wider alc_shapes_compat range in M.meta, \
125 or upgrade/downgrade algocline core to a matching version.";
126
127fn check_mirror_shapes_version(source_dir: Option<&str>) -> Result<(), ShapesVersionError> {
134 let Some(dir) = source_dir else {
135 return Ok(());
136 };
137 let path: PathBuf = [dir, "alc_shapes", "init.lua"].iter().collect();
138 if !path.exists() {
139 return Ok(());
140 }
141 let src = std::fs::read_to_string(&path)
142 .map_err(|_| ShapesVersionError::Malformed { path: path.clone() })?;
143 let mirror_ver = extract_m_version(&src)
144 .ok_or_else(|| ShapesVersionError::Malformed { path: path.clone() })?;
145 if mirror_ver != EMBEDDED_ALC_SHAPES_VERSION {
146 return Err(ShapesVersionError::Mismatch {
147 embedded: EMBEDDED_ALC_SHAPES_VERSION.to_string(),
148 mirror: mirror_ver,
149 hint: SHAPES_VERSION_HINT,
150 });
151 }
152 Ok(())
153}
154
155fn extract_quoted_value<'a>(src: &'a str, marker: &str) -> Option<&'a str> {
166 let start = src.find(marker)?;
167 let after_marker = src[start + marker.len()..].trim_start();
168 let after_eq = after_marker.strip_prefix('=')?;
169 let after_eq = after_eq.trim_start();
170 let after_quote = after_eq.strip_prefix('"')?;
171 let end = after_quote.find('"')?;
172 Some(&after_quote[..end])
173}
174
175fn extract_m_version(src: &str) -> Option<String> {
181 extract_quoted_value(src, "M.VERSION").map(str::to_string)
182}
183
184fn extract_m_meta_compat(src: &str) -> Option<&str> {
190 extract_quoted_value(src, "alc_shapes_compat")
191}
192
193fn check_pkg_compat(source_dir: &str) -> Result<Vec<String>, ShapesCompatError> {
212 let current = Version::parse(EMBEDDED_ALC_SHAPES_VERSION)
213 .expect("EMBEDDED_ALC_SHAPES_VERSION is a valid semver constant");
214
215 let pkg_dir = std::path::Path::new(source_dir);
216 let dir_entries = std::fs::read_dir(pkg_dir).map_err(|e| ShapesCompatError::Io {
217 path: pkg_dir.to_path_buf(),
218 cause: e.to_string(),
219 })?;
220
221 let mut warnings = Vec::new();
222
223 for entry in dir_entries {
224 let entry = entry.map_err(|e| ShapesCompatError::Io {
225 path: pkg_dir.to_path_buf(),
226 cause: e.to_string(),
227 })?;
228 if !entry.path().is_dir() {
229 continue;
230 }
231 let dir_name = match entry.file_name().to_str() {
232 Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
233 _ => continue,
234 };
235
236 let init_lua = entry.path().join("init.lua");
237 if !init_lua.exists() {
238 continue;
239 }
240
241 let src = std::fs::read_to_string(&init_lua).map_err(|e| ShapesCompatError::Io {
242 path: init_lua.clone(),
243 cause: e.to_string(),
244 })?;
245
246 match extract_m_meta_compat(&src) {
247 None => {
248 warnings.push(format!(
249 "pkg {dir_name}: alc_shapes_compat not declared, \
250 continuing with current alc_shapes@{EMBEDDED_ALC_SHAPES_VERSION}"
251 ));
252 }
253 Some(raw) => {
254 let range = VersionReq::parse(raw).map_err(|e| ShapesCompatError::Malformed {
255 pkg_name: dir_name.clone(),
256 value: raw.to_string(),
257 cause: e.to_string(),
258 })?;
259
260 if !range.matches(¤t) {
261 return Err(ShapesCompatError::Violation {
262 pkg_name: dir_name,
263 declared_range: raw.to_string(),
264 actual_version: EMBEDDED_ALC_SHAPES_VERSION.to_string(),
265 hint: SHAPES_COMPAT_VIOLATION_HINT,
266 });
267 }
268 }
270 }
271 }
272
273 Ok(warnings)
274}
275
276const LUA_GEN_DOCS: &str = include_str!("lua/gendoc/gen_docs.lua");
279const LUA_DOCS_LIST: &str = include_str!("lua/gendoc/docs/list.lua");
280const LUA_DOCS_EXTRACT: &str = include_str!("lua/gendoc/docs/extract.lua");
281const LUA_DOCS_PROJECTIONS: &str = include_str!("lua/gendoc/docs/projections.lua");
282const LUA_DOCS_PKG_INFO: &str = include_str!("lua/gendoc/docs/pkg_info.lua");
283const LUA_DOCS_JSON: &str = include_str!("lua/gendoc/docs/json.lua");
284const LUA_DOCS_LINT: &str = include_str!("lua/gendoc/docs/lint.lua");
285const LUA_DOCS_ENTITY_SCHEMAS: &str = include_str!("lua/gendoc/docs/entity_schemas.lua");
286
287const LUA_ALC_SHAPES_INIT: &str = include_str!("gendoc/alc_shapes/init.lua");
290const LUA_ALC_SHAPES_T: &str = include_str!("gendoc/alc_shapes/t.lua");
291const LUA_ALC_SHAPES_REFLECT: &str = include_str!("gendoc/alc_shapes/reflect.lua");
292const LUA_ALC_SHAPES_CHECK: &str = include_str!("gendoc/alc_shapes/check.lua");
293const LUA_ALC_SHAPES_INSTRUMENT: &str = include_str!("gendoc/alc_shapes/instrument.lua");
294const LUA_ALC_SHAPES_LUACATS: &str = include_str!("gendoc/alc_shapes/luacats.lua");
295const LUA_ALC_SHAPES_SPEC_RESOLVER: &str = include_str!("gendoc/alc_shapes/spec_resolver.lua");
296
297const EMBEDDED_TOOL_PRELOADS: &[(&str, &str)] = &[
305 ("alc_shapes.t", LUA_ALC_SHAPES_T),
307 ("alc_shapes.reflect", LUA_ALC_SHAPES_REFLECT),
308 ("alc_shapes.check", LUA_ALC_SHAPES_CHECK),
309 ("alc_shapes.luacats", LUA_ALC_SHAPES_LUACATS),
310 ("alc_shapes.spec_resolver", LUA_ALC_SHAPES_SPEC_RESOLVER),
311 ("alc_shapes.instrument", LUA_ALC_SHAPES_INSTRUMENT),
312 ("alc_shapes", LUA_ALC_SHAPES_INIT),
314 ("tools.docs.list", LUA_DOCS_LIST),
315 ("tools.docs.extract", LUA_DOCS_EXTRACT),
316 ("tools.docs.projections", LUA_DOCS_PROJECTIONS),
317 ("tools.docs.pkg_info", LUA_DOCS_PKG_INFO),
318 ("tools.docs.json", LUA_DOCS_JSON),
319 ("tools.docs.lint", LUA_DOCS_LINT),
320 ("tools.docs.entity_schemas", LUA_DOCS_ENTITY_SCHEMAS),
321];
322
323const HOOK_SCRIPT: &str = r##"
336os.exit = function(code)
337 local c = code or 0
338 local tbl = { __gendoc_exit = c }
339 -- Attach __tostring so the raw mlua error message embeds the
340 -- code as "__gendoc_exit=N", letting the Rust side recover it
341 -- via substring match instead of walking CallbackError internals.
342 setmetatable(tbl, { __tostring = function(self)
343 return string.format("__gendoc_exit=%d", self.__gendoc_exit or 0)
344 end })
345 error(tbl, 0)
346end
347io.stdout = {
348 write = function(self, ...)
349 local args = { ... }
350 for i = 1, select("#", ...) do
351 args[i] = tostring(args[i])
352 end
353 _gendoc_out_append(table.concat(args))
354 return self
355 end,
356}
357io.stderr = {
358 write = function(self, ...)
359 local args = { ... }
360 for i = 1, select("#", ...) do
361 args[i] = tostring(args[i])
362 end
363 _gendoc_err_append(table.concat(args))
364 return self
365 end,
366}
367print = function(...)
368 local args = { ... }
369 for i = 1, select("#", ...) do
370 args[i] = tostring(args[i])
371 end
372 _gendoc_out_append(table.concat(args, "\t") .. "\n")
373end
374"##;
375
376const EXIT_MARKER: &str = "__gendoc_exit";
378
379impl AppService {
380 pub fn hub_gendoc(
410 &self,
411 source_dir: &str,
412 out_dir: Option<&str>,
413 projections: Option<&[String]>,
414 config_path: Option<&str>,
415 lint_strict: Option<bool>,
416 ) -> Result<String, String> {
417 let projection_flags = ProjectionFlags::from_list(projections)?;
418
419 let resolved_out_dir = out_dir
420 .map(|s| s.to_string())
421 .unwrap_or_else(|| format!("{source_dir}/docs"));
422
423 let compat_warnings = run_preflight(source_dir).map_err(|e| format!("gendoc: {e}"))?;
430
431 let lua = Lua::new();
432
433 register_preloads(&lua)?;
434
435 match config_path {
444 Some(path) if path.to_lowercase().ends_with(".lua") => {
445 return Err(
446 "gendoc: config_path extension '.lua' is no longer supported; use .toml"
447 .to_string(),
448 );
449 }
450 Some(path) => {
451 inject_config_preloads_toml(&lua, &preload_table(&lua)?, path)?;
453 }
454 None if projection_flags.context7 || projection_flags.devin => {
455 let project_root = resolve_project_root(Some(source_dir));
457 let merged = load_hub_projection_config(project_root.as_deref())?;
458 inject_config_subtable(
459 &lua,
460 &preload_table(&lua)?,
461 Some(merged.to_context7_toml()),
462 "context7",
463 "_gendoc_context7_config",
464 "tools.docs.context7_config",
465 )?;
466 inject_config_subtable(
467 &lua,
468 &preload_table(&lua)?,
469 Some(merged.to_devin_toml()),
470 "devin",
471 "_gendoc_devin_config",
472 "tools.docs.devin_wiki_config",
473 )?;
474 }
475 None => {
476 }
478 }
479
480 let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
481 let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
482
483 install_io_hooks(&lua, Arc::clone(&out_buf), Arc::clone(&err_buf))?;
484
485 install_argv(
486 &lua,
487 source_dir,
488 &resolved_out_dir,
489 &projection_flags,
490 lint_strict.unwrap_or(false),
491 )?;
492
493 lua.load(HOOK_SCRIPT)
497 .set_name("@embedded:gendoc/hooks.lua")
498 .exec()
499 .map_err(|e| format!("gendoc: hooks inject failed: {e}"))?;
500
501 let gen_docs_body = strip_shebang(LUA_GEN_DOCS);
509 let exec_result = lua
510 .load(gen_docs_body)
511 .set_name("@embedded:gendoc/gen_docs.lua")
512 .exec();
513
514 let stdout_txt = read_buf(&out_buf)?;
515 let stderr_txt = read_buf(&err_buf)?;
516
517 match exec_result {
518 Ok(()) => {}
519 Err(e) => {
520 if let Some(code) = extract_exit_code(&e) {
521 if code != 0 {
522 return Err(format!(
523 "gendoc: exited with code {code}\nstderr:\n{stderr_txt}"
524 ));
525 }
526 } else {
529 return Err(format!("gendoc: Lua error: {e}\nstderr:\n{stderr_txt}"));
530 }
531 }
532 }
533
534 Ok(build_response_json(
535 source_dir,
536 &resolved_out_dir,
537 &stdout_txt,
538 &stderr_txt,
539 &compat_warnings,
540 ))
541 }
542}
543
544fn run_preflight(source_dir: &str) -> Result<Vec<String>, HubGendocError> {
551 check_mirror_shapes_version(Some(source_dir))?;
552 let warnings = check_pkg_compat(source_dir)?;
553 Ok(warnings)
554}
555
556#[derive(Debug, Default, Clone, Copy)]
559struct ProjectionFlags {
560 hub: bool,
561 context7: bool,
562 devin: bool,
563 lint: bool,
564 lint_only: bool,
565 luacats: bool,
566 narrative: bool,
570 llms: bool,
574}
575
576impl ProjectionFlags {
577 fn from_list(projections: Option<&[String]>) -> Result<Self, String> {
578 let mut f = ProjectionFlags::default();
579 let Some(list) = projections else {
580 return Ok(f);
581 };
582 for p in list {
583 match p.as_str() {
584 "hub" => f.hub = true,
585 "context7" => f.context7 = true,
586 "devin" => f.devin = true,
587 "lint" => f.lint = true,
588 "lint_only" => {
589 f.lint_only = true;
590 f.lint = true;
591 }
592 "luacats" => f.luacats = true,
593 "narrative" => f.narrative = true,
594 "llms" => f.llms = true,
595 _ => {
596 return Err(format!(
597 "gendoc: unknown projection '{p}' (allowed: hub, context7, devin, lint, lint_only, luacats, narrative, llms)"
598 ));
599 }
600 }
601 }
602 Ok(f)
603 }
604}
605
606fn register_preloads(lua: &Lua) -> Result<(), String> {
612 let preload = preload_table(lua)?;
613 for (mod_name, src) in EMBEDDED_TOOL_PRELOADS.iter().copied() {
614 register_single_preload(lua, &preload, mod_name, src)?;
615 }
616 Ok(())
617}
618
619fn preload_table(lua: &Lua) -> Result<Table, String> {
620 let package: Table = lua
626 .globals()
627 .get("package")
628 .map_err(|e| format!("gendoc: globals().package lookup failed: {e}"))?;
629 let preload: Table = package
630 .get("preload")
631 .map_err(|e| format!("gendoc: package.preload lookup failed: {e}"))?;
632 Ok(preload)
633}
634
635fn register_single_preload(
636 lua: &Lua,
637 preload: &Table,
638 mod_name: &'static str,
639 src: &'static str,
640) -> Result<(), String> {
641 let chunk_name = format!("@embedded:gendoc/{mod_name}.lua");
642 let loader = lua
643 .create_function(move |lua, ()| lua.load(src).set_name(chunk_name.clone()).eval::<Value>())
644 .map_err(|e| format!("gendoc: preload create_function failed for {mod_name}: {e}"))?;
645 preload
646 .set(mod_name, loader)
647 .map_err(|e| format!("gendoc: preload.set failed for {mod_name}: {e}"))?;
648 Ok(())
649}
650
651#[cfg(test)]
656fn inject_config_preloads(lua: &Lua, config_path: &str) -> Result<(), String> {
657 let ext = std::path::Path::new(config_path)
658 .extension()
659 .and_then(|e| e.to_str())
660 .unwrap_or("");
661
662 let preload = preload_table(lua)?;
663
664 if ext.eq_ignore_ascii_case("lua") {
665 Err("gendoc: config_path extension '.lua' is no longer supported; use .toml".to_string())
666 } else if ext.eq_ignore_ascii_case("toml") {
667 inject_config_preloads_toml(lua, &preload, config_path)
668 } else {
669 Err(format!(
670 "gendoc: config_path '{config_path}' unsupported extension (expected .toml)"
671 ))
672 }
673}
674
675fn inject_config_preloads_toml(
676 lua: &Lua,
677 preload: &Table,
678 config_path: &str,
679) -> Result<(), String> {
680 let src = std::fs::read_to_string(config_path)
681 .map_err(|e| format!("gendoc: config_path '{config_path}' load failed: {e}"))?;
682 let config: FlatGendocConfig = toml::from_str(&src)
683 .map_err(|e| format!("gendoc: config_path '{config_path}' parse failed: {e}"))?;
684
685 inject_config_subtable(
690 lua,
691 preload,
692 config.context7,
693 "context7",
694 "_gendoc_context7_config",
695 "tools.docs.context7_config",
696 )?;
697 inject_config_subtable(
698 lua,
699 preload,
700 config.devin,
701 "devin",
702 "_gendoc_devin_config",
703 "tools.docs.devin_wiki_config",
704 )?;
705
706 Ok(())
707}
708
709fn inject_config_subtable(
721 lua: &Lua,
722 preload: &Table,
723 value: Option<toml::Value>,
724 key: &'static str,
725 global_key: &'static str,
726 module_name: &'static str,
727) -> Result<(), String> {
728 match value {
729 None => Ok(()),
730 Some(v) => {
731 let lua_value = toml_to_lua_value(lua, &v)
732 .map_err(|e| format!("gendoc: config '{key}' conversion failed: {e}"))?;
733 match lua_value {
734 Value::Table(_) => {
735 lua.globals()
736 .set(global_key, lua_value)
737 .map_err(|e| format!("gendoc: stash {global_key} failed: {e}"))?;
738 register_config_loader(lua, preload, module_name, global_key)
739 }
740 other => Err(format!(
741 "gendoc: config '{key}' must be a table, got {}",
742 other.type_name()
743 )),
744 }
745 }
746 }
747}
748
749#[derive(Debug, Deserialize)]
755struct FlatGendocConfig {
756 context7: Option<toml::Value>,
757 devin: Option<toml::Value>,
758}
759
760fn toml_to_lua_value(lua: &Lua, value: &toml::Value) -> Result<Value, String> {
761 match value {
762 toml::Value::String(s) => Ok(Value::String(
763 lua.create_string(s)
764 .map_err(|e| format!("create string failed: {e}"))?,
765 )),
766 toml::Value::Integer(i) => Ok(Value::Integer(*i)),
767 toml::Value::Float(f) => Ok(Value::Number(*f)),
768 toml::Value::Boolean(b) => Ok(Value::Boolean(*b)),
769 toml::Value::Datetime(dt) => Ok(Value::String(
770 lua.create_string(dt.to_string())
771 .map_err(|e| format!("create datetime string failed: {e}"))?,
772 )),
773 toml::Value::Array(arr) => {
774 let table = lua
775 .create_table()
776 .map_err(|e| format!("create array table failed: {e}"))?;
777 for (idx, item) in arr.iter().enumerate() {
778 let v = toml_to_lua_value(lua, item)?;
779 table
780 .set((idx + 1) as i64, v)
781 .map_err(|e| format!("set array item [{idx}] failed: {e}"))?;
782 }
783 Ok(Value::Table(table))
784 }
785 toml::Value::Table(map) => {
786 let table = lua
787 .create_table()
788 .map_err(|e| format!("create map table failed: {e}"))?;
789 for (k, v) in map {
790 let vv = toml_to_lua_value(lua, v)?;
791 table
792 .set(k.as_str(), vv)
793 .map_err(|e| format!("set map key '{k}' failed: {e}"))?;
794 }
795 Ok(Value::Table(table))
796 }
797 }
798}
799
800fn register_config_loader(
801 lua: &Lua,
802 preload: &Table,
803 module_name: &'static str,
804 global_key: &'static str,
805) -> Result<(), String> {
806 let loader = lua
807 .create_function(move |lua, ()| lua.globals().get::<Value>(global_key))
808 .map_err(|e| format!("gendoc: config loader for {module_name} failed: {e}"))?;
809 preload
810 .set(module_name, loader)
811 .map_err(|e| format!("gendoc: preload.set failed for {module_name}: {e}"))?;
812 Ok(())
813}
814
815fn install_io_hooks(
816 lua: &Lua,
817 out_buf: Arc<Mutex<String>>,
818 err_buf: Arc<Mutex<String>>,
819) -> Result<(), String> {
820 let out_for_closure = Arc::clone(&out_buf);
821 let append_out = lua
822 .create_function(move |_, s: String| {
823 out_for_closure
824 .lock()
825 .map_err(|e| mlua::Error::external(format!("gendoc: out buf lock: {e}")))?
826 .push_str(&s);
827 Ok(())
828 })
829 .map_err(|e| format!("gendoc: create_function _gendoc_out_append: {e}"))?;
830
831 let err_for_closure = Arc::clone(&err_buf);
832 let append_err = lua
833 .create_function(move |_, s: String| {
834 err_for_closure
835 .lock()
836 .map_err(|e| mlua::Error::external(format!("gendoc: err buf lock: {e}")))?
837 .push_str(&s);
838 Ok(())
839 })
840 .map_err(|e| format!("gendoc: create_function _gendoc_err_append: {e}"))?;
841
842 lua.globals()
843 .set("_gendoc_out_append", append_out)
844 .map_err(|e| format!("gendoc: globals set _gendoc_out_append: {e}"))?;
845 lua.globals()
846 .set("_gendoc_err_append", append_err)
847 .map_err(|e| format!("gendoc: globals set _gendoc_err_append: {e}"))?;
848
849 Ok(())
850}
851
852fn install_argv(
853 lua: &Lua,
854 source_dir: &str,
855 out_dir: &str,
856 flags: &ProjectionFlags,
857 lint_strict: bool,
858) -> Result<(), String> {
859 let argv = lua
860 .create_table()
861 .map_err(|e| format!("gendoc: create argv table: {e}"))?;
862
863 let mut idx: i64 = 1;
864 let mut push = |v: &str| -> Result<(), String> {
865 argv.set(idx, v)
866 .map_err(|e| format!("gendoc: argv set [{idx}]: {e}"))?;
867 idx += 1;
868 Ok(())
869 };
870
871 push(source_dir)?;
872 push(out_dir)?;
873 if flags.hub {
874 push("--hub")?;
875 }
876 if flags.context7 {
877 push("--context7")?;
878 }
879 if flags.devin {
880 push("--devin")?;
881 }
882 if flags.lint_only {
883 push("--lint-only")?;
884 } else if flags.lint {
885 push("--lint")?;
886 }
887 if lint_strict {
888 push("--strict")?;
889 }
890 if flags.luacats {
891 push("--luacats")?;
892 }
893
894 lua.globals()
895 .set("arg", argv)
896 .map_err(|e| format!("gendoc: globals set arg: {e}"))?;
897
898 Ok(())
899}
900
901fn strip_shebang(src: &str) -> &str {
908 if let Some(body) = src.strip_prefix("#!") {
909 match body.find('\n') {
910 Some(i) => &body[i + 1..],
911 None => "",
912 }
913 } else {
914 src
915 }
916}
917
918fn read_buf(buf: &Arc<Mutex<String>>) -> Result<String, String> {
919 Ok(buf
920 .lock()
921 .map_err(|e| format!("gendoc: buffer lock (read): {e}"))?
922 .clone())
923}
924
925fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
928 let msg = err.to_string();
947 let needle = EXIT_MARKER;
950 let idx = msg.find(needle)?;
951 let rest = &msg[idx + needle.len()..];
952 let digits_start = rest
954 .char_indices()
955 .find(|(_, c)| c.is_ascii_digit() || *c == '-')
956 .map(|(i, _)| i)?;
957 let tail = &rest[digits_start..];
958 let digits_end = tail
959 .char_indices()
960 .find(|(_, c)| !c.is_ascii_digit() && *c != '-')
961 .map(|(i, _)| i)
962 .unwrap_or(tail.len());
963 tail[..digits_end].parse::<i64>().ok()
964}
965
966fn build_response_json(
967 source_dir: &str,
968 out_dir: &str,
969 stdout_txt: &str,
970 stderr_txt: &str,
971 warnings: &[String],
972) -> String {
973 let value = serde_json::json!({
978 "source_dir": source_dir,
979 "out_dir": out_dir,
980 "stdout": stdout_txt,
981 "stderr": stderr_txt,
982 "warnings": warnings,
983 });
984 value.to_string()
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990
991 #[test]
992 fn projection_flags_defaults_are_false() {
993 let f = ProjectionFlags::from_list(None).expect("projection parse");
994 assert!(!f.hub);
995 assert!(!f.context7);
996 assert!(!f.devin);
997 assert!(!f.lint);
998 assert!(!f.lint_only);
999 }
1000
1001 #[test]
1002 fn projection_flags_parse_known_tokens() {
1003 let list = vec![
1004 "hub".to_string(),
1005 "context7".to_string(),
1006 "devin".to_string(),
1007 ];
1008 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1009 assert!(f.hub);
1010 assert!(f.context7);
1011 assert!(f.devin);
1012 assert!(!f.lint);
1013 }
1014
1015 #[test]
1016 fn projection_flags_lint_only_implies_lint() {
1017 let list = vec!["lint_only".to_string()];
1018 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1019 assert!(f.lint);
1020 assert!(f.lint_only);
1021 }
1022
1023 #[test]
1024 fn projection_flags_luacats_parses() {
1025 let list = vec!["luacats".to_string()];
1026 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1027 assert!(f.luacats);
1028 assert!(!f.hub);
1029 assert!(!f.lint);
1030 }
1031
1032 #[test]
1033 fn projection_flags_unknown_is_rejected() {
1034 let list = vec!["nope".to_string(), "hub".to_string()];
1035 let err = ProjectionFlags::from_list(Some(&list)).expect_err("must reject unknown");
1036 assert!(err.contains("unknown projection"));
1037 }
1038
1039 #[test]
1040 fn projection_flags_narrative_and_llms_parse() {
1041 let list = vec!["narrative".to_string(), "llms".to_string()];
1046 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1047 assert!(f.narrative, "narrative flag must be set");
1048 assert!(f.llms, "llms flag must be set");
1049 assert!(!f.hub, "hub must remain false");
1050 assert!(!f.lint, "lint must remain false");
1051 }
1052
1053 #[test]
1054 fn context7_without_config_is_rejected() {
1055 let list = vec!["context7".to_string()];
1061 let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1062 assert!(flags.context7);
1063 let err_expected =
1065 "gendoc: config_path is required when projections include context7 or devin";
1066 let err = if (flags.context7 || flags.devin) && Option::<&str>::None.is_none() {
1067 Some(err_expected.to_string())
1068 } else {
1069 None
1070 };
1071 assert_eq!(err.as_deref(), Some(err_expected));
1072 }
1073
1074 #[test]
1075 fn extract_exit_code_parses_marker_formats() {
1076 let err = mlua::Error::RuntimeError(
1080 "runtime error: [string \"...\"]:2: {__gendoc_exit=2}".to_string(),
1081 );
1082 assert_eq!(extract_exit_code(&err), Some(2));
1083
1084 let err = mlua::Error::RuntimeError("runtime error: __gendoc_exit: 0 (clean)".to_string());
1085 assert_eq!(extract_exit_code(&err), Some(0));
1086 }
1087
1088 #[test]
1089 fn extract_exit_code_returns_none_for_unrelated_errors() {
1090 let err = mlua::Error::RuntimeError("some other Lua error".to_string());
1091 assert!(extract_exit_code(&err).is_none());
1092 }
1093
1094 #[test]
1095 fn strip_shebang_removes_first_line_when_prefixed() {
1096 let src = "#!/usr/bin/env lua\nreturn 1\n";
1097 assert_eq!(strip_shebang(src), "return 1\n");
1098 }
1099
1100 #[test]
1101 fn strip_shebang_preserves_source_without_shebang() {
1102 let src = "-- no shebang\nreturn 1\n";
1103 assert_eq!(strip_shebang(src), src);
1104 }
1105
1106 #[test]
1107 fn strip_shebang_handles_shebang_only_without_trailing_newline() {
1108 let src = "#!/usr/bin/env lua";
1109 assert_eq!(strip_shebang(src), "");
1110 }
1111
1112 #[test]
1113 fn build_response_json_round_trips() {
1114 let out = build_response_json("/src", "/src/docs", "hi", "warn", &[]);
1115 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1116 assert_eq!(parsed["source_dir"], "/src");
1117 assert_eq!(parsed["out_dir"], "/src/docs");
1118 assert_eq!(parsed["stdout"], "hi");
1119 assert_eq!(parsed["stderr"], "warn");
1120 assert_eq!(parsed["warnings"], serde_json::json!([]));
1121 }
1122
1123 #[test]
1124 fn build_response_json_includes_warnings() {
1125 let warnings = vec![
1126 "pkg foo: alc_shapes_compat not declared, continuing with current alc_shapes@0.25.1"
1127 .to_string(),
1128 ];
1129 let out = build_response_json("/src", "/src/docs", "", "", &warnings);
1130 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1131 assert_eq!(parsed["warnings"][0], warnings[0].as_str());
1132 }
1133
1134 #[test]
1137 fn extract_m_version_parses_standard_format() {
1138 let src = r#"local M = {}
1139M.VERSION = "0.25.1"
1140"#;
1141 assert_eq!(extract_m_version(src).as_deref(), Some("0.25.1"));
1142 }
1143
1144 #[test]
1145 fn extract_m_version_tolerates_no_space_around_eq() {
1146 let src = r#"M.VERSION="1.2.3""#;
1147 assert_eq!(extract_m_version(src).as_deref(), Some("1.2.3"));
1148 }
1149
1150 #[test]
1151 fn extract_m_version_tolerates_leading_whitespace() {
1152 let src = r#" M.VERSION = "9.9.9" "#;
1153 assert_eq!(extract_m_version(src).as_deref(), Some("9.9.9"));
1154 }
1155
1156 #[test]
1157 fn extract_m_version_returns_none_when_absent() {
1158 let src = r#"local M = {}
1159return M
1160"#;
1161 assert!(extract_m_version(src).is_none());
1162 }
1163
1164 #[test]
1165 fn check_mirror_shapes_version_ok_when_source_dir_none() {
1166 assert!(check_mirror_shapes_version(None).is_ok());
1167 }
1168
1169 #[test]
1170 fn check_mirror_shapes_version_ok_when_no_mirror_file() {
1171 let tmp = tempfile::tempdir().expect("tempdir");
1173 let dir = tmp.path().to_str().expect("utf-8").to_string();
1174 assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
1175 }
1176
1177 #[test]
1178 fn check_mirror_shapes_version_ok_on_version_match() {
1179 let tmp = tempfile::tempdir().expect("tempdir");
1180 let alc_dir = tmp.path().join("alc_shapes");
1181 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1182 let init = alc_dir.join("init.lua");
1183 std::fs::write(
1184 &init,
1185 format!(
1186 "local M = {{}}\nM.VERSION = \"{}\"\nreturn M\n",
1187 EMBEDDED_ALC_SHAPES_VERSION
1188 ),
1189 )
1190 .expect("write init.lua");
1191 let dir = tmp.path().to_str().expect("utf-8").to_string();
1192 assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
1193 }
1194
1195 #[test]
1196 fn check_mirror_shapes_version_err_on_version_mismatch() {
1197 let tmp = tempfile::tempdir().expect("tempdir");
1198 let alc_dir = tmp.path().join("alc_shapes");
1199 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1200 let init = alc_dir.join("init.lua");
1201 std::fs::write(&init, "local M = {}\nM.VERSION = \"9.9.9\"\nreturn M\n")
1202 .expect("write init.lua");
1203 let dir = tmp.path().to_str().expect("utf-8").to_string();
1204 let err =
1205 check_mirror_shapes_version(Some(&dir)).expect_err("must fail on version mismatch");
1206 let msg = err.to_string();
1207 assert!(
1208 msg.contains(EMBEDDED_ALC_SHAPES_VERSION),
1209 "embedded ver in msg: {msg}"
1210 );
1211 assert!(msg.contains("9.9.9"), "mirror ver in msg: {msg}");
1212 assert!(msg.contains("CHANGELOG"), "hint in msg: {msg}");
1213 }
1214
1215 #[test]
1216 fn check_mirror_shapes_version_err_on_malformed() {
1217 let tmp = tempfile::tempdir().expect("tempdir");
1218 let alc_dir = tmp.path().join("alc_shapes");
1219 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1220 let init = alc_dir.join("init.lua");
1221 std::fs::write(&init, "-- no version here\nreturn {}\n").expect("write init.lua");
1222 let dir = tmp.path().to_str().expect("utf-8").to_string();
1223 let err = check_mirror_shapes_version(Some(&dir)).expect_err("must fail on malformed");
1224 let msg = err.to_string();
1225 assert!(msg.contains("no parseable"), "malformed msg: {msg}");
1226 }
1227
1228 #[test]
1233 fn embedded_gendoc_shapes_contract_harness() {
1234 let lua = Lua::new();
1235 register_preloads(&lua).expect("register_preloads");
1236
1237 let script = r#"
1238 local S = require("alc_shapes")
1239 local T = require("alc_shapes.t")
1240 local P = require("tools.docs.projections")
1241
1242 local shape = T.shape({
1243 task = T.string:describe("Problem"),
1244 n = T.number:is_optional(),
1245 })
1246 local entries = S.fields(shape)
1247 assert(#entries == 2, "expected two fields")
1248 assert(entries[1].name == "n" and entries[1].optional == true)
1249 assert(entries[2].name == "task" and entries[2].optional == false)
1250 assert(entries[2].doc == "Problem")
1251 assert(P.shape_type_string(entries[2].type) == "string")
1252
1253 assert(P.shape_type_string(T.array_of(T.string)) == "array of string")
1254 assert(P.shape_type_string(T.map_of(T.string, T.number)) == "map of string to number")
1255
1256 local inner = T.shape({ flag = T.boolean })
1257 assert(P.shape_type_string(inner) == "shape { flag: boolean }")
1258 "#;
1259
1260 lua.load(script)
1261 .set_name("@test/embedded_gendoc_shapes_contract.lua")
1262 .exec()
1263 .expect("embedded shapes contract harness");
1264 }
1265
1266 #[test]
1270 fn vendored_alc_shapes_resolves_pkg_refs() {
1271 let lua = Lua::new();
1272 register_preloads(&lua).expect("register_preloads");
1273
1274 let script = r#"
1275 local S = require("alc_shapes")
1276 assert(type(S.voted) == "table" and rawget(S.voted, "kind") == "shape")
1277 local T = require("alc_shapes.t")
1278 local P = require("tools.docs.projections")
1279 assert(P.shape_type_string(T.ref("voted")) == "voted")
1280 "#;
1281
1282 lua.load(script)
1283 .set_name("@test/vendored_alc_shapes_ref.lua")
1284 .exec()
1285 .expect("vendored alc_shapes ref resolution");
1286 }
1287
1288 #[test]
1291 fn extract_quoted_value_finds_marker() {
1292 let src = r#"M.meta.alc_shapes_compat = ">=0.25.0, <0.26""#;
1293 assert_eq!(
1294 extract_quoted_value(src, "alc_shapes_compat"),
1295 Some(">=0.25.0, <0.26")
1296 );
1297 }
1298
1299 #[test]
1300 fn extract_quoted_value_returns_none_when_absent() {
1301 let src = "local M = {}\nreturn M\n";
1302 assert!(extract_quoted_value(src, "alc_shapes_compat").is_none());
1303 }
1304
1305 #[test]
1306 fn extract_m_meta_compat_finds_field() {
1307 let src = r#"M.meta = { alc_shapes_compat = ">=0.25.0, <0.26", name = "pkg" }"#;
1308 assert_eq!(extract_m_meta_compat(src), Some(">=0.25.0, <0.26"));
1309 }
1310
1311 #[test]
1312 fn extract_m_meta_compat_returns_none_when_absent() {
1313 let src = r#"M.meta = { name = "pkg", version = "0.1.0" }"#;
1314 assert!(extract_m_meta_compat(src).is_none());
1315 }
1316
1317 #[test]
1318 fn check_pkg_compat_warns_on_undeclared() {
1319 let tmp = tempfile::tempdir().expect("tempdir");
1320 let pkg_dir = tmp.path().join("pkg_foo");
1321 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_foo");
1322 std::fs::write(
1323 pkg_dir.join("init.lua"),
1324 "local M = {}\nM.meta = { name = 'pkg_foo' }\nreturn M\n",
1325 )
1326 .expect("write init.lua");
1327
1328 let result = check_pkg_compat(tmp.path().to_str().expect("utf-8")).expect("no error");
1329 assert_eq!(result.len(), 1);
1330 assert!(
1331 result[0].contains("alc_shapes_compat not declared"),
1332 "expected undeclared warning, got: {}",
1333 result[0]
1334 );
1335 }
1336
1337 #[test]
1338 fn check_pkg_compat_ok_on_in_range() {
1339 let tmp = tempfile::tempdir().expect("tempdir");
1340 let pkg_dir = tmp.path().join("pkg_bar");
1341 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_bar");
1342 std::fs::write(
1344 pkg_dir.join("init.lua"),
1345 "local M = {}\nM.meta = { name = \"pkg_bar\", alc_shapes_compat = \">=0.25.0, <0.26\" }\nreturn M\n",
1346 )
1347 .expect("write init.lua");
1348
1349 let result = check_pkg_compat(tmp.path().to_str().expect("utf-8")).expect("no error");
1350 assert!(result.is_empty(), "expected no warnings for in-range pkg");
1351 }
1352
1353 #[test]
1354 fn check_pkg_compat_err_on_out_of_range() {
1355 let tmp = tempfile::tempdir().expect("tempdir");
1356 let pkg_dir = tmp.path().join("pkg_baz");
1357 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_baz");
1358 std::fs::write(
1360 pkg_dir.join("init.lua"),
1361 "local M = {}\nM.meta = { name = \"pkg_baz\", alc_shapes_compat = \">=0.26.0, <0.27\" }\nreturn M\n",
1362 )
1363 .expect("write init.lua");
1364
1365 let err = check_pkg_compat(tmp.path().to_str().expect("utf-8"))
1366 .expect_err("must fail on out-of-range");
1367 let msg = err.to_string();
1368 assert!(msg.contains("pkg_baz"), "pkg_name in error: {msg}");
1369 assert!(msg.contains(">=0.26.0, <0.27"), "range in error: {msg}");
1370 assert!(
1371 msg.contains(EMBEDDED_ALC_SHAPES_VERSION),
1372 "version in error: {msg}"
1373 );
1374 assert!(
1375 msg.contains("ShapesCompatViolation") || msg.contains("does not match"),
1376 "violation in error: {msg}"
1377 );
1378 }
1379
1380 #[test]
1383 fn inject_config_preloads_lua_rejected() {
1384 let tmp = tempfile::tempdir().expect("tempdir");
1385 let cfg = tmp.path().join("config.lua");
1386 std::fs::write(&cfg, "return {}").expect("write config.lua");
1387
1388 let lua = Lua::new();
1389 let err = inject_config_preloads(&lua, cfg.to_str().expect("utf-8"))
1390 .expect_err("must fail for .lua extension");
1391 assert!(
1392 err.contains("'.lua' is no longer supported"),
1393 "expected '.lua' is no longer supported in: {err}"
1394 );
1395 }
1396
1397 #[test]
1398 fn inject_config_preloads_unknown_extension() {
1399 let tmp = tempfile::tempdir().expect("tempdir");
1400 let cfg = tmp.path().join("config.yaml");
1401 std::fs::write(&cfg, "context7:\n projectTitle: x\n").expect("write config.yaml");
1402
1403 let lua = Lua::new();
1404 let err = inject_config_preloads(&lua, cfg.to_str().expect("utf-8"))
1405 .expect_err("must fail on unknown extension");
1406 assert!(
1407 err.contains("unsupported extension (expected .toml)"),
1408 "expected 'unsupported extension (expected .toml)' in: {err}"
1409 );
1410 }
1411
1412 #[test]
1413 fn check_pkg_compat_err_on_malformed_range() {
1414 let tmp = tempfile::tempdir().expect("tempdir");
1415 let pkg_dir = tmp.path().join("pkg_qux");
1416 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_qux");
1417 std::fs::write(
1418 pkg_dir.join("init.lua"),
1419 "local M = {}\nM.meta = { name = \"pkg_qux\", alc_shapes_compat = \"not a semver range\" }\nreturn M\n",
1420 )
1421 .expect("write init.lua");
1422
1423 let err = check_pkg_compat(tmp.path().to_str().expect("utf-8"))
1424 .expect_err("must fail on malformed range");
1425 let msg = err.to_string();
1426 assert!(msg.contains("pkg_qux"), "pkg_name in error: {msg}");
1427 assert!(msg.contains("not a semver range"), "value in error: {msg}");
1428 assert!(
1429 msg.contains("Malformed") || msg.contains("valid semver"),
1430 "malformed label in error: {msg}"
1431 );
1432 }
1433}