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