1use std::path::PathBuf;
41use std::sync::{Arc, Mutex};
42
43use mlua::{Lua, Table, Value};
44use serde::Deserialize;
45use thiserror::Error;
46
47use super::AppService;
48
49const EMBEDDED_ALC_SHAPES_VERSION: &str = "0.25.1";
54
55#[derive(Debug, Error)]
56enum ShapesVersionError {
57 #[error("alc_shapes version mismatch: embedded={embedded}, mirror={mirror}. {hint}")]
58 Mismatch {
59 embedded: String,
60 mirror: String,
61 hint: &'static str,
62 },
63 #[error("alc_shapes mirror init.lua at '{path}' has no parseable M.VERSION declaration")]
64 Malformed { path: PathBuf },
65}
66
67const SHAPES_VERSION_HINT: &str = "Align bundled alc_shapes/ to match core, \
68 or upgrade algocline core to the mirror version. See CHANGELOG for details.";
69
70fn check_mirror_shapes_version(source_dir: Option<&str>) -> Result<(), ShapesVersionError> {
77 let Some(dir) = source_dir else {
78 return Ok(());
79 };
80 let path: PathBuf = [dir, "alc_shapes", "init.lua"].iter().collect();
81 if !path.exists() {
82 return Ok(());
83 }
84 let src = std::fs::read_to_string(&path)
85 .map_err(|_| ShapesVersionError::Malformed { path: path.clone() })?;
86 let mirror_ver = extract_m_version(&src)
87 .ok_or_else(|| ShapesVersionError::Malformed { path: path.clone() })?;
88 if mirror_ver != EMBEDDED_ALC_SHAPES_VERSION {
89 return Err(ShapesVersionError::Mismatch {
90 embedded: EMBEDDED_ALC_SHAPES_VERSION.to_string(),
91 mirror: mirror_ver,
92 hint: SHAPES_VERSION_HINT,
93 });
94 }
95 Ok(())
96}
97
98fn extract_m_version(src: &str) -> Option<String> {
104 let marker = "M.VERSION";
105 let start = src.find(marker)?;
106 let after_marker = src[start + marker.len()..].trim_start();
107 let after_eq = after_marker.strip_prefix('=')?;
108 let after_eq = after_eq.trim_start();
109 let after_quote = after_eq.strip_prefix('"')?;
110 let end = after_quote.find('"')?;
111 Some(after_quote[..end].to_string())
112}
113
114const LUA_GEN_DOCS: &str = include_str!("lua/gendoc/gen_docs.lua");
117const LUA_DOCS_LIST: &str = include_str!("lua/gendoc/docs/list.lua");
118const LUA_DOCS_EXTRACT: &str = include_str!("lua/gendoc/docs/extract.lua");
119const LUA_DOCS_PROJECTIONS: &str = include_str!("lua/gendoc/docs/projections.lua");
120const LUA_DOCS_PKG_INFO: &str = include_str!("lua/gendoc/docs/pkg_info.lua");
121const LUA_DOCS_JSON: &str = include_str!("lua/gendoc/docs/json.lua");
122const LUA_DOCS_LINT: &str = include_str!("lua/gendoc/docs/lint.lua");
123const LUA_DOCS_ENTITY_SCHEMAS: &str = include_str!("lua/gendoc/docs/entity_schemas.lua");
124
125const LUA_ALC_SHAPES_INIT: &str = include_str!("gendoc/alc_shapes/init.lua");
128const LUA_ALC_SHAPES_T: &str = include_str!("gendoc/alc_shapes/t.lua");
129const LUA_ALC_SHAPES_REFLECT: &str = include_str!("gendoc/alc_shapes/reflect.lua");
130const LUA_ALC_SHAPES_CHECK: &str = include_str!("gendoc/alc_shapes/check.lua");
131const LUA_ALC_SHAPES_INSTRUMENT: &str = include_str!("gendoc/alc_shapes/instrument.lua");
132const LUA_ALC_SHAPES_LUACATS: &str = include_str!("gendoc/alc_shapes/luacats.lua");
133const LUA_ALC_SHAPES_SPEC_RESOLVER: &str = include_str!("gendoc/alc_shapes/spec_resolver.lua");
134
135const EMBEDDED_TOOL_PRELOADS: &[(&str, &str)] = &[
143 ("alc_shapes.t", LUA_ALC_SHAPES_T),
145 ("alc_shapes.reflect", LUA_ALC_SHAPES_REFLECT),
146 ("alc_shapes.check", LUA_ALC_SHAPES_CHECK),
147 ("alc_shapes.luacats", LUA_ALC_SHAPES_LUACATS),
148 ("alc_shapes.spec_resolver", LUA_ALC_SHAPES_SPEC_RESOLVER),
149 ("alc_shapes.instrument", LUA_ALC_SHAPES_INSTRUMENT),
150 ("alc_shapes", LUA_ALC_SHAPES_INIT),
152 ("tools.docs.list", LUA_DOCS_LIST),
153 ("tools.docs.extract", LUA_DOCS_EXTRACT),
154 ("tools.docs.projections", LUA_DOCS_PROJECTIONS),
155 ("tools.docs.pkg_info", LUA_DOCS_PKG_INFO),
156 ("tools.docs.json", LUA_DOCS_JSON),
157 ("tools.docs.lint", LUA_DOCS_LINT),
158 ("tools.docs.entity_schemas", LUA_DOCS_ENTITY_SCHEMAS),
159];
160
161const HOOK_SCRIPT: &str = r##"
174os.exit = function(code)
175 local c = code or 0
176 local tbl = { __gendoc_exit = c }
177 -- Attach __tostring so the raw mlua error message embeds the
178 -- code as "__gendoc_exit=N", letting the Rust side recover it
179 -- via substring match instead of walking CallbackError internals.
180 setmetatable(tbl, { __tostring = function(self)
181 return string.format("__gendoc_exit=%d", self.__gendoc_exit or 0)
182 end })
183 error(tbl, 0)
184end
185io.stdout = {
186 write = function(self, ...)
187 local args = { ... }
188 for i = 1, select("#", ...) do
189 args[i] = tostring(args[i])
190 end
191 _gendoc_out_append(table.concat(args))
192 return self
193 end,
194}
195io.stderr = {
196 write = function(self, ...)
197 local args = { ... }
198 for i = 1, select("#", ...) do
199 args[i] = tostring(args[i])
200 end
201 _gendoc_err_append(table.concat(args))
202 return self
203 end,
204}
205print = function(...)
206 local args = { ... }
207 for i = 1, select("#", ...) do
208 args[i] = tostring(args[i])
209 end
210 _gendoc_out_append(table.concat(args, "\t") .. "\n")
211end
212"##;
213
214const EXIT_MARKER: &str = "__gendoc_exit";
216
217impl AppService {
218 pub fn hub_gendoc(
248 &self,
249 source_dir: &str,
250 out_dir: Option<&str>,
251 projections: Option<&[String]>,
252 config_path: Option<&str>,
253 lint_strict: Option<bool>,
254 ) -> Result<String, String> {
255 let projection_flags = ProjectionFlags::from_list(projections)?;
256 if (projection_flags.context7 || projection_flags.devin) && config_path.is_none() {
257 return Err(
258 "gendoc: config_path is required when projections include context7 or devin"
259 .to_string(),
260 );
261 }
262
263 let resolved_out_dir = out_dir
264 .map(|s| s.to_string())
265 .unwrap_or_else(|| format!("{source_dir}/docs"));
266
267 check_mirror_shapes_version(Some(source_dir)).map_err(|e| format!("gendoc: {e}"))?;
272
273 let lua = Lua::new();
274
275 register_preloads(&lua)?;
276
277 if let Some(path) = config_path {
281 inject_config_preloads(&lua, path)?;
282 }
283
284 let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
285 let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
286
287 install_io_hooks(&lua, Arc::clone(&out_buf), Arc::clone(&err_buf))?;
288
289 install_argv(
290 &lua,
291 source_dir,
292 &resolved_out_dir,
293 &projection_flags,
294 lint_strict.unwrap_or(false),
295 )?;
296
297 lua.load(HOOK_SCRIPT)
301 .set_name("@embedded:gendoc/hooks.lua")
302 .exec()
303 .map_err(|e| format!("gendoc: hooks inject failed: {e}"))?;
304
305 let gen_docs_body = strip_shebang(LUA_GEN_DOCS);
313 let exec_result = lua
314 .load(gen_docs_body)
315 .set_name("@embedded:gendoc/gen_docs.lua")
316 .exec();
317
318 let stdout_txt = read_buf(&out_buf)?;
319 let stderr_txt = read_buf(&err_buf)?;
320
321 match exec_result {
322 Ok(()) => {}
323 Err(e) => {
324 if let Some(code) = extract_exit_code(&e) {
325 if code != 0 {
326 return Err(format!(
327 "gendoc: exited with code {code}\nstderr:\n{stderr_txt}"
328 ));
329 }
330 } else {
333 return Err(format!("gendoc: Lua error: {e}\nstderr:\n{stderr_txt}"));
334 }
335 }
336 }
337
338 Ok(build_response_json(
339 source_dir,
340 &resolved_out_dir,
341 &stdout_txt,
342 &stderr_txt,
343 ))
344 }
345}
346
347#[derive(Debug, Default, Clone, Copy)]
350struct ProjectionFlags {
351 hub: bool,
352 context7: bool,
353 devin: bool,
354 lint: bool,
355 lint_only: bool,
356}
357
358impl ProjectionFlags {
359 fn from_list(projections: Option<&[String]>) -> Result<Self, String> {
360 let mut f = ProjectionFlags::default();
361 let Some(list) = projections else {
362 return Ok(f);
363 };
364 for p in list {
365 match p.as_str() {
366 "hub" => f.hub = true,
367 "context7" => f.context7 = true,
368 "devin" => f.devin = true,
369 "lint" => f.lint = true,
370 "lint_only" => {
371 f.lint_only = true;
372 f.lint = true;
373 }
374 _ => {
375 return Err(format!(
376 "gendoc: unknown projection '{p}' (allowed: hub, context7, devin, lint, lint_only)"
377 ));
378 }
379 }
380 }
381 Ok(f)
382 }
383}
384
385fn register_preloads(lua: &Lua) -> Result<(), String> {
391 let preload = preload_table(lua)?;
392 for (mod_name, src) in EMBEDDED_TOOL_PRELOADS.iter().copied() {
393 register_single_preload(lua, &preload, mod_name, src)?;
394 }
395 Ok(())
396}
397
398fn preload_table(lua: &Lua) -> Result<Table, String> {
399 let package: Table = lua
405 .globals()
406 .get("package")
407 .map_err(|e| format!("gendoc: globals().package lookup failed: {e}"))?;
408 let preload: Table = package
409 .get("preload")
410 .map_err(|e| format!("gendoc: package.preload lookup failed: {e}"))?;
411 Ok(preload)
412}
413
414fn register_single_preload(
415 lua: &Lua,
416 preload: &Table,
417 mod_name: &'static str,
418 src: &'static str,
419) -> Result<(), String> {
420 let chunk_name = format!("@embedded:gendoc/{mod_name}.lua");
421 let loader = lua
422 .create_function(move |lua, ()| lua.load(src).set_name(chunk_name.clone()).eval::<Value>())
423 .map_err(|e| format!("gendoc: preload create_function failed for {mod_name}: {e}"))?;
424 preload
425 .set(mod_name, loader)
426 .map_err(|e| format!("gendoc: preload.set failed for {mod_name}: {e}"))?;
427 Ok(())
428}
429
430fn inject_config_preloads(lua: &Lua, config_path: &str) -> Result<(), String> {
431 let src = std::fs::read_to_string(config_path)
432 .map_err(|e| format!("gendoc: config_path '{config_path}' load failed: {e}"))?;
433 let config: GendocConfig = toml::from_str(&src)
434 .map_err(|e| format!("gendoc: config_path '{config_path}' parse failed: {e}"))?;
435
436 let preload = preload_table(lua)?;
441
442 inject_config_subtable(
443 lua,
444 &preload,
445 config.context7,
446 "context7",
447 "_gendoc_context7_config",
448 "tools.docs.context7_config",
449 )?;
450 inject_config_subtable(
451 lua,
452 &preload,
453 config.devin,
454 "devin",
455 "_gendoc_devin_config",
456 "tools.docs.devin_wiki_config",
457 )?;
458
459 Ok(())
460}
461
462fn inject_config_subtable(
474 lua: &Lua,
475 preload: &Table,
476 value: Option<toml::Value>,
477 key: &'static str,
478 global_key: &'static str,
479 module_name: &'static str,
480) -> Result<(), String> {
481 match value {
482 None => Ok(()),
483 Some(v) => {
484 let lua_value = toml_to_lua_value(lua, &v)
485 .map_err(|e| format!("gendoc: config '{key}' conversion failed: {e}"))?;
486 match lua_value {
487 Value::Table(_) => {
488 lua.globals()
489 .set(global_key, lua_value)
490 .map_err(|e| format!("gendoc: stash {global_key} failed: {e}"))?;
491 register_config_loader(lua, preload, module_name, global_key)
492 }
493 other => Err(format!(
494 "gendoc: config '{key}' must be a table, got {}",
495 other.type_name()
496 )),
497 }
498 }
499 }
500}
501
502#[derive(Debug, Deserialize)]
503struct GendocConfig {
504 context7: Option<toml::Value>,
505 devin: Option<toml::Value>,
506}
507
508fn toml_to_lua_value(lua: &Lua, value: &toml::Value) -> Result<Value, String> {
509 match value {
510 toml::Value::String(s) => Ok(Value::String(
511 lua.create_string(s)
512 .map_err(|e| format!("create string failed: {e}"))?,
513 )),
514 toml::Value::Integer(i) => Ok(Value::Integer(*i)),
515 toml::Value::Float(f) => Ok(Value::Number(*f)),
516 toml::Value::Boolean(b) => Ok(Value::Boolean(*b)),
517 toml::Value::Datetime(dt) => Ok(Value::String(
518 lua.create_string(dt.to_string())
519 .map_err(|e| format!("create datetime string failed: {e}"))?,
520 )),
521 toml::Value::Array(arr) => {
522 let table = lua
523 .create_table()
524 .map_err(|e| format!("create array table failed: {e}"))?;
525 for (idx, item) in arr.iter().enumerate() {
526 let v = toml_to_lua_value(lua, item)?;
527 table
528 .set((idx + 1) as i64, v)
529 .map_err(|e| format!("set array item [{idx}] failed: {e}"))?;
530 }
531 Ok(Value::Table(table))
532 }
533 toml::Value::Table(map) => {
534 let table = lua
535 .create_table()
536 .map_err(|e| format!("create map table failed: {e}"))?;
537 for (k, v) in map {
538 let vv = toml_to_lua_value(lua, v)?;
539 table
540 .set(k.as_str(), vv)
541 .map_err(|e| format!("set map key '{k}' failed: {e}"))?;
542 }
543 Ok(Value::Table(table))
544 }
545 }
546}
547
548fn register_config_loader(
549 lua: &Lua,
550 preload: &Table,
551 module_name: &'static str,
552 global_key: &'static str,
553) -> Result<(), String> {
554 let loader = lua
555 .create_function(move |lua, ()| lua.globals().get::<Value>(global_key))
556 .map_err(|e| format!("gendoc: config loader for {module_name} failed: {e}"))?;
557 preload
558 .set(module_name, loader)
559 .map_err(|e| format!("gendoc: preload.set failed for {module_name}: {e}"))?;
560 Ok(())
561}
562
563fn install_io_hooks(
564 lua: &Lua,
565 out_buf: Arc<Mutex<String>>,
566 err_buf: Arc<Mutex<String>>,
567) -> Result<(), String> {
568 let out_for_closure = Arc::clone(&out_buf);
569 let append_out = lua
570 .create_function(move |_, s: String| {
571 out_for_closure
572 .lock()
573 .map_err(|e| mlua::Error::external(format!("gendoc: out buf lock: {e}")))?
574 .push_str(&s);
575 Ok(())
576 })
577 .map_err(|e| format!("gendoc: create_function _gendoc_out_append: {e}"))?;
578
579 let err_for_closure = Arc::clone(&err_buf);
580 let append_err = lua
581 .create_function(move |_, s: String| {
582 err_for_closure
583 .lock()
584 .map_err(|e| mlua::Error::external(format!("gendoc: err buf lock: {e}")))?
585 .push_str(&s);
586 Ok(())
587 })
588 .map_err(|e| format!("gendoc: create_function _gendoc_err_append: {e}"))?;
589
590 lua.globals()
591 .set("_gendoc_out_append", append_out)
592 .map_err(|e| format!("gendoc: globals set _gendoc_out_append: {e}"))?;
593 lua.globals()
594 .set("_gendoc_err_append", append_err)
595 .map_err(|e| format!("gendoc: globals set _gendoc_err_append: {e}"))?;
596
597 Ok(())
598}
599
600fn install_argv(
601 lua: &Lua,
602 source_dir: &str,
603 out_dir: &str,
604 flags: &ProjectionFlags,
605 lint_strict: bool,
606) -> Result<(), String> {
607 let argv = lua
608 .create_table()
609 .map_err(|e| format!("gendoc: create argv table: {e}"))?;
610
611 let mut idx: i64 = 1;
612 let mut push = |v: &str| -> Result<(), String> {
613 argv.set(idx, v)
614 .map_err(|e| format!("gendoc: argv set [{idx}]: {e}"))?;
615 idx += 1;
616 Ok(())
617 };
618
619 push(source_dir)?;
620 push(out_dir)?;
621 if flags.hub {
622 push("--hub")?;
623 }
624 if flags.context7 {
625 push("--context7")?;
626 }
627 if flags.devin {
628 push("--devin")?;
629 }
630 if flags.lint_only {
631 push("--lint-only")?;
632 } else if flags.lint {
633 push("--lint")?;
634 }
635 if lint_strict {
636 push("--strict")?;
637 }
638
639 lua.globals()
640 .set("arg", argv)
641 .map_err(|e| format!("gendoc: globals set arg: {e}"))?;
642
643 Ok(())
644}
645
646fn strip_shebang(src: &str) -> &str {
653 if let Some(body) = src.strip_prefix("#!") {
654 match body.find('\n') {
655 Some(i) => &body[i + 1..],
656 None => "",
657 }
658 } else {
659 src
660 }
661}
662
663fn read_buf(buf: &Arc<Mutex<String>>) -> Result<String, String> {
664 Ok(buf
665 .lock()
666 .map_err(|e| format!("gendoc: buffer lock (read): {e}"))?
667 .clone())
668}
669
670fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
673 let msg = err.to_string();
692 let needle = EXIT_MARKER;
695 let idx = msg.find(needle)?;
696 let rest = &msg[idx + needle.len()..];
697 let digits_start = rest
699 .char_indices()
700 .find(|(_, c)| c.is_ascii_digit() || *c == '-')
701 .map(|(i, _)| i)?;
702 let tail = &rest[digits_start..];
703 let digits_end = tail
704 .char_indices()
705 .find(|(_, c)| !c.is_ascii_digit() && *c != '-')
706 .map(|(i, _)| i)
707 .unwrap_or(tail.len());
708 tail[..digits_end].parse::<i64>().ok()
709}
710
711fn build_response_json(
712 source_dir: &str,
713 out_dir: &str,
714 stdout_txt: &str,
715 stderr_txt: &str,
716) -> String {
717 let value = serde_json::json!({
722 "source_dir": source_dir,
723 "out_dir": out_dir,
724 "stdout": stdout_txt,
725 "stderr": stderr_txt,
726 });
727 value.to_string()
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
735 fn projection_flags_defaults_are_false() {
736 let f = ProjectionFlags::from_list(None).expect("projection parse");
737 assert!(!f.hub);
738 assert!(!f.context7);
739 assert!(!f.devin);
740 assert!(!f.lint);
741 assert!(!f.lint_only);
742 }
743
744 #[test]
745 fn projection_flags_parse_known_tokens() {
746 let list = vec![
747 "hub".to_string(),
748 "context7".to_string(),
749 "devin".to_string(),
750 ];
751 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
752 assert!(f.hub);
753 assert!(f.context7);
754 assert!(f.devin);
755 assert!(!f.lint);
756 }
757
758 #[test]
759 fn projection_flags_lint_only_implies_lint() {
760 let list = vec!["lint_only".to_string()];
761 let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
762 assert!(f.lint);
763 assert!(f.lint_only);
764 }
765
766 #[test]
767 fn projection_flags_unknown_is_rejected() {
768 let list = vec!["nope".to_string(), "hub".to_string()];
769 let err = ProjectionFlags::from_list(Some(&list)).expect_err("must reject unknown");
770 assert!(err.contains("unknown projection"));
771 }
772
773 #[test]
774 fn context7_without_config_is_rejected() {
775 let list = vec!["context7".to_string()];
781 let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
782 assert!(flags.context7);
783 let err_expected =
785 "gendoc: config_path is required when projections include context7 or devin";
786 let err = if (flags.context7 || flags.devin) && Option::<&str>::None.is_none() {
787 Some(err_expected.to_string())
788 } else {
789 None
790 };
791 assert_eq!(err.as_deref(), Some(err_expected));
792 }
793
794 #[test]
795 fn extract_exit_code_parses_marker_formats() {
796 let err = mlua::Error::RuntimeError(
800 "runtime error: [string \"...\"]:2: {__gendoc_exit=2}".to_string(),
801 );
802 assert_eq!(extract_exit_code(&err), Some(2));
803
804 let err = mlua::Error::RuntimeError("runtime error: __gendoc_exit: 0 (clean)".to_string());
805 assert_eq!(extract_exit_code(&err), Some(0));
806 }
807
808 #[test]
809 fn extract_exit_code_returns_none_for_unrelated_errors() {
810 let err = mlua::Error::RuntimeError("some other Lua error".to_string());
811 assert!(extract_exit_code(&err).is_none());
812 }
813
814 #[test]
815 fn strip_shebang_removes_first_line_when_prefixed() {
816 let src = "#!/usr/bin/env lua\nreturn 1\n";
817 assert_eq!(strip_shebang(src), "return 1\n");
818 }
819
820 #[test]
821 fn strip_shebang_preserves_source_without_shebang() {
822 let src = "-- no shebang\nreturn 1\n";
823 assert_eq!(strip_shebang(src), src);
824 }
825
826 #[test]
827 fn strip_shebang_handles_shebang_only_without_trailing_newline() {
828 let src = "#!/usr/bin/env lua";
829 assert_eq!(strip_shebang(src), "");
830 }
831
832 #[test]
833 fn build_response_json_round_trips() {
834 let out = build_response_json("/src", "/src/docs", "hi", "warn");
835 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
836 assert_eq!(parsed["source_dir"], "/src");
837 assert_eq!(parsed["out_dir"], "/src/docs");
838 assert_eq!(parsed["stdout"], "hi");
839 assert_eq!(parsed["stderr"], "warn");
840 }
841
842 #[test]
845 fn extract_m_version_parses_standard_format() {
846 let src = r#"local M = {}
847M.VERSION = "0.25.1"
848"#;
849 assert_eq!(extract_m_version(src).as_deref(), Some("0.25.1"));
850 }
851
852 #[test]
853 fn extract_m_version_tolerates_no_space_around_eq() {
854 let src = r#"M.VERSION="1.2.3""#;
855 assert_eq!(extract_m_version(src).as_deref(), Some("1.2.3"));
856 }
857
858 #[test]
859 fn extract_m_version_tolerates_leading_whitespace() {
860 let src = r#" M.VERSION = "9.9.9" "#;
861 assert_eq!(extract_m_version(src).as_deref(), Some("9.9.9"));
862 }
863
864 #[test]
865 fn extract_m_version_returns_none_when_absent() {
866 let src = r#"local M = {}
867return M
868"#;
869 assert!(extract_m_version(src).is_none());
870 }
871
872 #[test]
873 fn check_mirror_shapes_version_ok_when_source_dir_none() {
874 assert!(check_mirror_shapes_version(None).is_ok());
875 }
876
877 #[test]
878 fn check_mirror_shapes_version_ok_when_no_mirror_file() {
879 let tmp = tempfile::tempdir().expect("tempdir");
881 let dir = tmp.path().to_str().expect("utf-8").to_string();
882 assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
883 }
884
885 #[test]
886 fn check_mirror_shapes_version_ok_on_version_match() {
887 let tmp = tempfile::tempdir().expect("tempdir");
888 let alc_dir = tmp.path().join("alc_shapes");
889 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
890 let init = alc_dir.join("init.lua");
891 std::fs::write(
892 &init,
893 format!(
894 "local M = {{}}\nM.VERSION = \"{}\"\nreturn M\n",
895 EMBEDDED_ALC_SHAPES_VERSION
896 ),
897 )
898 .expect("write init.lua");
899 let dir = tmp.path().to_str().expect("utf-8").to_string();
900 assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
901 }
902
903 #[test]
904 fn check_mirror_shapes_version_err_on_version_mismatch() {
905 let tmp = tempfile::tempdir().expect("tempdir");
906 let alc_dir = tmp.path().join("alc_shapes");
907 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
908 let init = alc_dir.join("init.lua");
909 std::fs::write(&init, "local M = {}\nM.VERSION = \"9.9.9\"\nreturn M\n")
910 .expect("write init.lua");
911 let dir = tmp.path().to_str().expect("utf-8").to_string();
912 let err =
913 check_mirror_shapes_version(Some(&dir)).expect_err("must fail on version mismatch");
914 let msg = err.to_string();
915 assert!(
916 msg.contains(EMBEDDED_ALC_SHAPES_VERSION),
917 "embedded ver in msg: {msg}"
918 );
919 assert!(msg.contains("9.9.9"), "mirror ver in msg: {msg}");
920 assert!(msg.contains("CHANGELOG"), "hint in msg: {msg}");
921 }
922
923 #[test]
924 fn check_mirror_shapes_version_err_on_malformed() {
925 let tmp = tempfile::tempdir().expect("tempdir");
926 let alc_dir = tmp.path().join("alc_shapes");
927 std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
928 let init = alc_dir.join("init.lua");
929 std::fs::write(&init, "-- no version here\nreturn {}\n").expect("write init.lua");
930 let dir = tmp.path().to_str().expect("utf-8").to_string();
931 let err = check_mirror_shapes_version(Some(&dir)).expect_err("must fail on malformed");
932 let msg = err.to_string();
933 assert!(msg.contains("no parseable"), "malformed msg: {msg}");
934 }
935
936 #[test]
941 fn embedded_gendoc_shapes_contract_harness() {
942 let lua = Lua::new();
943 register_preloads(&lua).expect("register_preloads");
944
945 let script = r#"
946 local S = require("alc_shapes")
947 local T = require("alc_shapes.t")
948 local P = require("tools.docs.projections")
949
950 local shape = T.shape({
951 task = T.string:describe("Problem"),
952 n = T.number:is_optional(),
953 })
954 local entries = S.fields(shape)
955 assert(#entries == 2, "expected two fields")
956 assert(entries[1].name == "n" and entries[1].optional == true)
957 assert(entries[2].name == "task" and entries[2].optional == false)
958 assert(entries[2].doc == "Problem")
959 assert(P.shape_type_string(entries[2].type) == "string")
960
961 assert(P.shape_type_string(T.array_of(T.string)) == "array of string")
962 assert(P.shape_type_string(T.map_of(T.string, T.number)) == "map of string to number")
963
964 local inner = T.shape({ flag = T.boolean })
965 assert(P.shape_type_string(inner) == "shape { flag: boolean }")
966 "#;
967
968 lua.load(script)
969 .set_name("@test/embedded_gendoc_shapes_contract.lua")
970 .exec()
971 .expect("embedded shapes contract harness");
972 }
973
974 #[test]
978 fn vendored_alc_shapes_resolves_pkg_refs() {
979 let lua = Lua::new();
980 register_preloads(&lua).expect("register_preloads");
981
982 let script = r#"
983 local S = require("alc_shapes")
984 assert(type(S.voted) == "table" and rawget(S.voted, "kind") == "shape")
985 local T = require("alc_shapes.t")
986 local P = require("tools.docs.projections")
987 assert(P.shape_type_string(T.ref("voted")) == "voted")
988 "#;
989
990 lua.load(script)
991 .set_name("@test/vendored_alc_shapes_ref.lua")
992 .exec()
993 .expect("vendored alc_shapes ref resolution");
994 }
995}