Skip to main content

algocline_app/service/
gendoc.rs

1//! `AppService::hub_gendoc` — embedded Lua `gen_docs` runner.
2//!
3//! Runs the bundled-packages `tools/gen_docs.lua` pipeline in an
4//! in-process `mlua` VM to produce human-readable documentation
5//! artifacts (`narrative/{pkg}.md`, `llms.txt`, `llms-full.txt`,
6//! optional `hub/{pkg}.json` / `context7.json` / `.devin/wiki.json`)
7//! from a freshly indexed `hub_index.json`.
8//!
9//! Embedding strategy:
10//!
11//! - The Lua sources under `service/lua/gendoc/` and
12//!   `service/gendoc/alc_shapes/` are pulled in with `include_str!`
13//!   at compile time and registered on `package.preload` so that
14//!   `require("tools.docs.X")` and `require("alc_shapes")` resolve
15//!   without touching the filesystem.
16//! - `alc_shapes` and all sub-modules are fully vendored via
17//!   `include_str!` from `service/gendoc/alc_shapes/*.lua`. The
18//!   `source_dir`'s on-disk `alc_shapes/` directory is never
19//!   consulted at runtime. This ensures parity for any third-party
20//!   package author invoking `alc_hub_dist` against their own source
21//!   tree without vendoring `alc_shapes` themselves.
22//! - `config_path` (optional) is a caller-supplied TOML file with
23//!   top-level `[context7]` and/or `[devin]` tables. When supplied,
24//!   those `context7` / `devin` tables are exposed under the
25//!   `tools.docs.context7_config` / `tools.docs.devin_wiki_config`
26//!   module names that `gen_docs` `require`s.
27//! - `print` / `io.stdout.write` / `io.stderr.write` are redirected
28//!   into Rust-side `String` buffers so callers observe the Lua
29//!   progress log through the MCP response instead of dropping it to
30//!   the server stderr.
31//! - `os.exit(code)` is overridden so it raises a structured Lua
32//!   error rather than terminating the process; non-zero exits are
33//!   converted into `Err(...)`.
34//!
35//! Per the project-level Error propagation rule
36//! (`CLAUDE.md §Service 層 Error 伝播規律`), every `mlua::Result` is
37//! surfaced via `?` with a `gendoc:` prefix — no `warn!` drops, no
38//! `unwrap_or_default`, no silent `Err(_) =>` branches.
39
40use 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
49// ── alc_shapes version pinning ────────────────────────────────────────
50
51/// Version string embedded in the vendored `alc_shapes/init.lua`.
52/// Must match the `M.VERSION` declaration in that file exactly.
53const 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
70/// Check the mirror's `M.VERSION` against `EMBEDDED_ALC_SHAPES_VERSION`.
71///
72/// If `source_dir` is `None` or its `alc_shapes/init.lua` does not
73/// exist, returns `Ok(())` immediately (no-op path). Otherwise reads
74/// the file, extracts `M.VERSION = "x.y.z"` with a hand-rolled parser
75/// (no `regex` dep), and fails with a typed error on mismatch.
76fn 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
98/// Hand-rolled parser for `M.VERSION = "x.y.z"` in a Lua source string.
99///
100/// Finds the first occurrence of `M.VERSION` followed (with optional
101/// whitespace) by `=` and then a double-quoted string. Returns the
102/// quoted content on success.
103fn 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
114// ── Embedded Lua sources ──────────────────────────────────────────
115
116const 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
125// ── Vendored alc_shapes (fully embedded; no disk fallback) ───────
126
127const 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
135/// All embedded preloads: vendored `alc_shapes` modules (in dependency
136/// order) followed by the `tools/docs/*` pipeline sources.
137///
138/// Registration order matters: sub-modules must appear before the
139/// modules that `require` them, matching the `alc_shapes/init.lua`
140/// dependency chain: `t` → `reflect` / `check` / `luacats` /
141/// `spec_resolver` → `instrument` → `init`.
142const EMBEDDED_TOOL_PRELOADS: &[(&str, &str)] = &[
143    // alc_shapes sub-modules (no intra-module deps except alc_shapes.t)
144    ("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 top-level (requires all sub-modules above)
151    ("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
161/// IO / exit hooks installed into the VM right before `gen_docs.lua`
162/// runs. `_gendoc_out_append` / `_gendoc_err_append` are Rust
163/// closures registered under the same names on `_G`.
164///
165/// `io.stdout` / `io.stderr` are in mlua exposed as `FILE*` userdata
166/// whose metatable rejects arbitrary `__newindex` writes — so we
167/// cannot patch `io.stdout.write` in place. Instead we replace
168/// `io.stdout` / `io.stderr` wholesale with plain Lua tables that
169/// expose a `write` method delegating to the Rust-side append
170/// closures. Both method-style (`io.stdout:write(x)`) and
171/// function-style (`io.stdout.write(io.stdout, x)`) calls are
172/// supported; both are used in the bundled `gen_docs.lua`.
173const 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
214/// Marker set on the Lua error table by the `os.exit` override.
215const EXIT_MARKER: &str = "__gendoc_exit";
216
217impl AppService {
218    /// See [`crate::EngineApi::hub_gendoc`] for parameter semantics.
219    ///
220    /// `config_path` format (TOML):
221    ///
222    /// ```toml
223    /// [context7]
224    /// projectTitle = "my project"
225    /// description = "..."
226    /// rules = []
227    ///
228    /// [devin]
229    /// project_name = "my project"
230    /// ```
231    ///
232    /// Notes:
233    /// - `context7` / `devin` are optional individually.
234    /// - When present, each key must be a table.
235    /// - TOML arrays/tables are converted recursively to Lua tables.
236    /// - See `docs/hub-gendoc-config.md` for a concrete schema example.
237    ///
238    /// Returns a JSON string of the form:
239    ///
240    /// ```json
241    /// { "source_dir": "...", "out_dir": "...", "stdout": "...", "stderr": "..." }
242    /// ```
243    ///
244    /// Non-zero `os.exit`, missing `hub_index.json`, Lua runtime
245    /// errors, and `config_path` read failures are all surfaced as
246    /// `Err` with a `gendoc:` prefix.
247    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        // Reject mismatched mirror before starting the VM: if the
268        // caller's source_dir has an alc_shapes/init.lua whose
269        // M.VERSION differs from the embedded constant, fail early with
270        // a structured error (propagated to the MCP wire response).
271        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        // Optional config_path injection — must be wired as preload
278        // *before* `gen_docs.lua` executes so that its
279        // `require("tools.docs.context7_config")` resolves.
280        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        // Run the IO / exit hook script (must come after
298        // `_gendoc_out_append` / `_gendoc_err_append` are installed,
299        // but before `gen_docs.lua` is exec'd).
300        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        // Execute `gen_docs.lua`. The file ends with `main(arg)`.
306        //
307        // `lua.load()` uses the string path of `luaL_loadbuffer`
308        // which does NOT strip a `#!` shebang line (the shebang is
309        // only accepted by `luaL_loadfile`). The embedded
310        // `gen_docs.lua` starts with `#!/usr/bin/env lua`, so we
311        // skip the first line before loading.
312        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                    // code == 0 is a clean shutdown via os.exit(0) —
331                    // fall through to the normal response.
332                } 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// ── Helpers ───────────────────────────────────────────────────────
348
349#[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
385/// Register all embedded `gen_docs` modules.
386///
387/// `alc_shapes` and its sub-modules are fully vendored via
388/// `include_str!` — no disk fallback. `tools/docs/*` pipeline sources
389/// are registered in the same pass.
390fn 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    // `globals().package.preload` is part of the Lua 5.4 / mlua
400    // contract; absence would indicate a VM that cannot run any
401    // meaningful Lua code, so `expect` with a justifying comment is
402    // the correct classification (see CLAUDE.md §Service 層 Error
403    // 伝播規律 "limited exceptions for unreachable VM invariants").
404    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    // Move the two sub-tables into the Lua registry via globals
437    // stashes so the preload closures can retrieve them on require.
438    // Using globals keeps the lifetime story simple (mlua 0.11 does
439    // not require `'static` bounds for tables stored this way).
440    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
462/// Stash the `key` sub-table into a Lua global
463/// and register a `package.preload` loader that returns it.
464///
465/// - Missing key (`None`) is a legitimate caller choice: the
466///   preload entry is simply omitted so a downstream `require` of
467///   `module_name` raises Lua's standard "module not found" error
468///   (clearer than registering a Nil loader that produces an opaque
469///   nil-index error).
470/// - Non-table values are rejected up front with an
471///   explicit `Err` — far more actionable than letting the Lua side
472///   try to index a string/number later.
473fn 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
646/// Strip a leading `#!` shebang line from a Lua source.
647///
648/// `lua.load()` (buffer-based) does not strip the shebang the way
649/// `luaL_loadfile` does. The embedded `gen_docs.lua` starts with
650/// `#!/usr/bin/env lua`, so we strip the first line before feeding
651/// the buffer to the VM.
652fn 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
670/// Extract `__gendoc_exit` from a Lua error raised by the `os.exit`
671/// override. Returns `None` for unrelated errors.
672fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
673    // `error(tbl, 0)` in Lua becomes `mlua::Error::RuntimeError`
674    // where the tostring serialization contains the table pointer
675    // (not useful) plus any __tostring metamethod output. For our
676    // structured exit we don't set __tostring, so the surfaced
677    // message may look like `table: 0x...`. That is not reliable.
678    //
679    // Instead, walk the error chain looking for a CallbackError /
680    // WithContext that wraps the raw value. mlua exposes the raw
681    // table via `Error::CallbackError::cause`. The Lua table itself
682    // is not exposed from `RuntimeError`, so we fall back to
683    // pattern-matching the string form as a best-effort:
684    // Lua's `error({__gendoc_exit = N}, 0)` with a table whose
685    // `__tostring` is unset renders as the table pointer, which
686    // loses the code.
687    //
688    // To make this robust we attach a __tostring metamethod to the
689    // raised table in the hook script so the error message embeds
690    // the code. See HOOK_SCRIPT.
691    let msg = err.to_string();
692    // Look for the marker substring emitted by __tostring (installed
693    // in HOOK_SCRIPT) in the format `__gendoc_exit=<N>`.
694    let needle = EXIT_MARKER;
695    let idx = msg.find(needle)?;
696    let rest = &msg[idx + needle.len()..];
697    // Skip any `=` / `:` / whitespace, then parse an integer.
698    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    // Keep dependency-free — we already depend on serde_json
718    // transitively but using it here avoids hand-rolled escaping
719    // bugs. Every shipped string is a plain `String` so
720    // `serde_json::Value::String` is fine.
721    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        // Build a minimal AppService through the public API is
776        // expensive; exercise the input validation logic through
777        // `ProjectionFlags` + an explicit mirror of the early
778        // return in `hub_gendoc`. Directly calling `hub_gendoc`
779        // would require a full test fixture — covered in e2e.
780        let list = vec!["context7".to_string()];
781        let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
782        assert!(flags.context7);
783        // Simulate the guard:
784        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        // Simulated error string; `extract_exit_code` doesn't care
797        // about the prefix as long as the `__gendoc_exit=N` marker is
798        // present.
799        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    // ── alc_shapes version resolver unit tests ────────────────────────
843
844    #[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        // A tempdir with no alc_shapes/ subdirectory.
880        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    /// Regression harness: vendored `alc_shapes` must satisfy the
937    /// contracts exercised by `tools/docs/projections.lua` (sorted
938    /// `S.fields`, `prim` / `elem` / `val` / `doc` keys). Catches the
939    /// class of drift that collapsed bundled `llms-full.txt` generation.
940    #[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    /// Vendored `alc_shapes` must include the full shape registry so
975    /// `projections.shape_type_string(T.ref("voted"))` resolves via the
976    /// embedded `alc_shapes` module (no disk fallback required).
977    #[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}