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/` are pulled in with
12//!   `include_str!` at compile time and registered on
13//!   `package.preload` so that `require("tools.docs.X")` resolves
14//!   without touching the filesystem.
15//! - `alc_shapes` / `alc_shapes.t` (bundled-packages runtime
16//!   dependency used by `extract.lua` / `projections.lua` etc.) are
17//!   satisfied by minimal stubs — `gen_docs` only uses them for
18//!   shape-validation side effects that are not load-bearing for the
19//!   artifacts.
20//! - `config_path` (optional) is a caller-supplied TOML file with
21//!   top-level `[context7]` and/or `[devin]` tables. When supplied,
22//!   those `context7` / `devin` tables are exposed under the
23//!   `tools.docs.context7_config` / `tools.docs.devin_wiki_config`
24//!   module names that `gen_docs` `require`s.
25//! - `print` / `io.stdout.write` / `io.stderr.write` are redirected
26//!   into Rust-side `String` buffers so callers observe the Lua
27//!   progress log through the MCP response instead of dropping it to
28//!   the server stderr.
29//! - `os.exit(code)` is overridden so it raises a structured Lua
30//!   error rather than terminating the process; non-zero exits are
31//!   converted into `Err(...)`.
32//!
33//! Per the project-level Error propagation rule
34//! (`CLAUDE.md §Service 層 Error 伝播規律`), every `mlua::Result` is
35//! surfaced via `?` with a `gendoc:` prefix — no `warn!` drops, no
36//! `unwrap_or_default`, no silent `Err(_) =>` branches.
37
38use std::sync::{Arc, Mutex};
39
40use mlua::{Lua, Table, Value};
41use serde::Deserialize;
42
43use super::AppService;
44
45// ── Embedded Lua sources ──────────────────────────────────────────
46
47const LUA_GEN_DOCS: &str = include_str!("lua/gendoc/gen_docs.lua");
48const LUA_DOCS_LIST: &str = include_str!("lua/gendoc/docs/list.lua");
49const LUA_DOCS_EXTRACT: &str = include_str!("lua/gendoc/docs/extract.lua");
50const LUA_DOCS_PROJECTIONS: &str = include_str!("lua/gendoc/docs/projections.lua");
51const LUA_DOCS_PKG_INFO: &str = include_str!("lua/gendoc/docs/pkg_info.lua");
52const LUA_DOCS_JSON: &str = include_str!("lua/gendoc/docs/json.lua");
53const LUA_DOCS_LINT: &str = include_str!("lua/gendoc/docs/lint.lua");
54const LUA_DOCS_ENTITY_SCHEMAS: &str = include_str!("lua/gendoc/docs/entity_schemas.lua");
55
56/// Minimal pass-through stub for `alc_shapes`.
57///
58/// `gen_docs` uses the shapes module for non-load-bearing validation
59/// that is tolerated to pass unconditionally at publish time — the
60/// authoritative validation lives in the bundled-packages CI, not in
61/// the embedded runner. The surface required by the embedded docs
62/// modules is limited to `check` / `assert_dev` / `fields` /
63/// `infer`.
64const LUA_ALC_SHAPES_STUB: &str = r#"
65local M = {}
66M.check = function(_v, _schema, _opts) return true, nil end
67M.assert_dev = function(_v, _schema, _opts) return true end
68M.fields = function(schema) return (schema and schema.fields) or {} end
69M.infer = function(v) return v end
70return M
71"#;
72
73/// Minimal stub for `alc_shapes.t` — just enough type constructors to
74/// satisfy the top-level `local T = require("alc_shapes.t")` imports in
75/// `entity_schemas.lua`, `extract.lua`, `projections.lua`, `lint.lua`.
76///
77/// Constructors return opaque `{ kind = "...", ... }` tables. The only
78/// structural invariant any embedded caller relies on is the presence
79/// of `.kind`, so these stubs preserve that. `_internal.is_schema`
80/// accepts anything table-shaped so downstream `is_schema` guards
81/// don't accidentally reject stub-produced schemas.
82const LUA_ALC_SHAPES_T_STUB: &str = r##"
83local T = {}
84
85-- Method table installed on every schema object. `is_optional()`
86-- wraps the receiver in an optional variant (used by
87-- entity_schemas.lua for structurally-optional fields).
88local schema_mt = {}
89schema_mt.__index = {
90    is_optional = function(self)
91        return setmetatable({ kind = "optional", inner = self }, schema_mt)
92    end,
93}
94
95local function make_schema(tbl)
96    return setmetatable(tbl, schema_mt)
97end
98
99T.any    = make_schema({ kind = "any" })
100T.string = make_schema({ kind = "prim", name = "string" })
101T.number = make_schema({ kind = "prim", name = "number" })
102T.bool   = make_schema({ kind = "prim", name = "bool" })
103-- Aliases occasionally used by older docs modules.
104T.str    = T.string
105T.num    = T.number
106
107T.ref           = function(name) return make_schema({ kind = "ref", name = name }) end
108T.list          = function(t) return make_schema({ kind = "list", item = t }) end
109T.array_of      = function(t) return make_schema({ kind = "array_of", item = t }) end
110T.map           = function(k, v) return make_schema({ kind = "map", key = k, value = v }) end
111T.map_of        = function(k, v) return make_schema({ kind = "map_of", key = k, value = v }) end
112T.opt           = function(t) return make_schema({ kind = "optional", inner = t }) end
113T.optional      = T.opt
114T.one_of        = function(values) return make_schema({ kind = "one_of", values = values }) end
115T.shape         = function(fields, opts)
116    return make_schema({ kind = "shape", fields = fields, opts = opts or {} })
117end
118T.described     = function(schema, desc)
119    return make_schema({ kind = "described", inner = schema, desc = desc })
120end
121T.discriminated = function(tag, variants)
122    return make_schema({ kind = "discriminated", tag = tag, variants = variants })
123end
124
125T._internal = {
126    is_schema = function(v) return type(v) == "table" end,
127}
128
129return T
130"##;
131
132/// Lua module name → embedded source. Registered on `package.preload`
133/// inside `register_preloads`.
134const PRELOAD_MODULES: &[(&str, &str)] = &[
135    ("tools.docs.list", LUA_DOCS_LIST),
136    ("tools.docs.extract", LUA_DOCS_EXTRACT),
137    ("tools.docs.projections", LUA_DOCS_PROJECTIONS),
138    ("tools.docs.pkg_info", LUA_DOCS_PKG_INFO),
139    ("tools.docs.json", LUA_DOCS_JSON),
140    ("tools.docs.lint", LUA_DOCS_LINT),
141    ("tools.docs.entity_schemas", LUA_DOCS_ENTITY_SCHEMAS),
142    ("alc_shapes", LUA_ALC_SHAPES_STUB),
143    ("alc_shapes.t", LUA_ALC_SHAPES_T_STUB),
144];
145
146/// IO / exit hooks installed into the VM right before `gen_docs.lua`
147/// runs. `_gendoc_out_append` / `_gendoc_err_append` are Rust
148/// closures registered under the same names on `_G`.
149///
150/// `io.stdout` / `io.stderr` are in mlua exposed as `FILE*` userdata
151/// whose metatable rejects arbitrary `__newindex` writes — so we
152/// cannot patch `io.stdout.write` in place. Instead we replace
153/// `io.stdout` / `io.stderr` wholesale with plain Lua tables that
154/// expose a `write` method delegating to the Rust-side append
155/// closures. Both method-style (`io.stdout:write(x)`) and
156/// function-style (`io.stdout.write(io.stdout, x)`) calls are
157/// supported; both are used in the bundled `gen_docs.lua`.
158const HOOK_SCRIPT: &str = r##"
159os.exit = function(code)
160    local c = code or 0
161    local tbl = { __gendoc_exit = c }
162    -- Attach __tostring so the raw mlua error message embeds the
163    -- code as "__gendoc_exit=N", letting the Rust side recover it
164    -- via substring match instead of walking CallbackError internals.
165    setmetatable(tbl, { __tostring = function(self)
166        return string.format("__gendoc_exit=%d", self.__gendoc_exit or 0)
167    end })
168    error(tbl, 0)
169end
170io.stdout = {
171    write = function(self, ...)
172        local args = { ... }
173        for i = 1, select("#", ...) do
174            args[i] = tostring(args[i])
175        end
176        _gendoc_out_append(table.concat(args))
177        return self
178    end,
179}
180io.stderr = {
181    write = function(self, ...)
182        local args = { ... }
183        for i = 1, select("#", ...) do
184            args[i] = tostring(args[i])
185        end
186        _gendoc_err_append(table.concat(args))
187        return self
188    end,
189}
190print = function(...)
191    local args = { ... }
192    for i = 1, select("#", ...) do
193        args[i] = tostring(args[i])
194    end
195    _gendoc_out_append(table.concat(args, "\t") .. "\n")
196end
197"##;
198
199/// Marker set on the Lua error table by the `os.exit` override.
200const EXIT_MARKER: &str = "__gendoc_exit";
201
202impl AppService {
203    /// See [`crate::EngineApi::hub_gendoc`] for parameter semantics.
204    ///
205    /// `config_path` format (TOML):
206    ///
207    /// ```toml
208    /// [context7]
209    /// projectTitle = "my project"
210    /// description = "..."
211    /// rules = []
212    ///
213    /// [devin]
214    /// project_name = "my project"
215    /// ```
216    ///
217    /// Notes:
218    /// - `context7` / `devin` are optional individually.
219    /// - When present, each key must be a table.
220    /// - TOML arrays/tables are converted recursively to Lua tables.
221    /// - See `docs/hub-gendoc-config.md` for a concrete schema example.
222    ///
223    /// Returns a JSON string of the form:
224    ///
225    /// ```json
226    /// { "source_dir": "...", "out_dir": "...", "stdout": "...", "stderr": "..." }
227    /// ```
228    ///
229    /// Non-zero `os.exit`, missing `hub_index.json`, Lua runtime
230    /// errors, and `config_path` read failures are all surfaced as
231    /// `Err` with a `gendoc:` prefix.
232    pub fn hub_gendoc(
233        &self,
234        source_dir: &str,
235        out_dir: Option<&str>,
236        projections: Option<&[String]>,
237        config_path: Option<&str>,
238        lint_strict: Option<bool>,
239    ) -> Result<String, String> {
240        let projection_flags = ProjectionFlags::from_list(projections)?;
241        if (projection_flags.context7 || projection_flags.devin) && config_path.is_none() {
242            return Err(
243                "gendoc: config_path is required when projections include context7 or devin"
244                    .to_string(),
245            );
246        }
247
248        let resolved_out_dir = out_dir
249            .map(|s| s.to_string())
250            .unwrap_or_else(|| format!("{source_dir}/docs"));
251
252        let lua = Lua::new();
253
254        register_preloads(&lua)?;
255
256        // Optional config_path injection — must be wired as preload
257        // *before* `gen_docs.lua` executes so that its
258        // `require("tools.docs.context7_config")` resolves.
259        if let Some(path) = config_path {
260            inject_config_preloads(&lua, path)?;
261        }
262
263        let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
264        let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
265
266        install_io_hooks(&lua, Arc::clone(&out_buf), Arc::clone(&err_buf))?;
267
268        install_argv(
269            &lua,
270            source_dir,
271            &resolved_out_dir,
272            &projection_flags,
273            lint_strict.unwrap_or(false),
274        )?;
275
276        // Run the IO / exit hook script (must come after
277        // `_gendoc_out_append` / `_gendoc_err_append` are installed,
278        // but before `gen_docs.lua` is exec'd).
279        lua.load(HOOK_SCRIPT)
280            .set_name("@embedded:gendoc/hooks.lua")
281            .exec()
282            .map_err(|e| format!("gendoc: hooks inject failed: {e}"))?;
283
284        // Execute `gen_docs.lua`. The file ends with `main(arg)`.
285        //
286        // `lua.load()` uses the string path of `luaL_loadbuffer`
287        // which does NOT strip a `#!` shebang line (the shebang is
288        // only accepted by `luaL_loadfile`). The embedded
289        // `gen_docs.lua` starts with `#!/usr/bin/env lua`, so we
290        // skip the first line before loading.
291        let gen_docs_body = strip_shebang(LUA_GEN_DOCS);
292        let exec_result = lua
293            .load(gen_docs_body)
294            .set_name("@embedded:gendoc/gen_docs.lua")
295            .exec();
296
297        let stdout_txt = read_buf(&out_buf)?;
298        let stderr_txt = read_buf(&err_buf)?;
299
300        match exec_result {
301            Ok(()) => {}
302            Err(e) => {
303                if let Some(code) = extract_exit_code(&e) {
304                    if code != 0 {
305                        return Err(format!(
306                            "gendoc: exited with code {code}\nstderr:\n{stderr_txt}"
307                        ));
308                    }
309                    // code == 0 is a clean shutdown via os.exit(0) —
310                    // fall through to the normal response.
311                } else {
312                    return Err(format!("gendoc: Lua error: {e}\nstderr:\n{stderr_txt}"));
313                }
314            }
315        }
316
317        Ok(build_response_json(
318            source_dir,
319            &resolved_out_dir,
320            &stdout_txt,
321            &stderr_txt,
322        ))
323    }
324}
325
326// ── Helpers ───────────────────────────────────────────────────────
327
328#[derive(Debug, Default, Clone, Copy)]
329struct ProjectionFlags {
330    hub: bool,
331    context7: bool,
332    devin: bool,
333    lint: bool,
334    lint_only: bool,
335}
336
337impl ProjectionFlags {
338    fn from_list(projections: Option<&[String]>) -> Result<Self, String> {
339        let mut f = ProjectionFlags::default();
340        let Some(list) = projections else {
341            return Ok(f);
342        };
343        for p in list {
344            match p.as_str() {
345                "hub" => f.hub = true,
346                "context7" => f.context7 = true,
347                "devin" => f.devin = true,
348                "lint" => f.lint = true,
349                "lint_only" => {
350                    f.lint_only = true;
351                    f.lint = true;
352                }
353                _ => {
354                    return Err(format!(
355                        "gendoc: unknown projection '{p}' (allowed: hub, context7, devin, lint, lint_only)"
356                    ));
357                }
358            }
359        }
360        Ok(f)
361    }
362}
363
364fn register_preloads(lua: &Lua) -> Result<(), String> {
365    let preload = preload_table(lua)?;
366    for (mod_name, src) in PRELOAD_MODULES.iter().copied() {
367        register_single_preload(lua, &preload, mod_name, src)?;
368    }
369    Ok(())
370}
371
372fn preload_table(lua: &Lua) -> Result<Table, String> {
373    // `globals().package.preload` is part of the Lua 5.4 / mlua
374    // contract; absence would indicate a VM that cannot run any
375    // meaningful Lua code, so `expect` with a justifying comment is
376    // the correct classification (see CLAUDE.md §Service 層 Error
377    // 伝播規律 "limited exceptions for unreachable VM invariants").
378    let package: Table = lua
379        .globals()
380        .get("package")
381        .map_err(|e| format!("gendoc: globals().package lookup failed: {e}"))?;
382    let preload: Table = package
383        .get("preload")
384        .map_err(|e| format!("gendoc: package.preload lookup failed: {e}"))?;
385    Ok(preload)
386}
387
388fn register_single_preload(
389    lua: &Lua,
390    preload: &Table,
391    mod_name: &'static str,
392    src: &'static str,
393) -> Result<(), String> {
394    let chunk_name = format!("@embedded:gendoc/{mod_name}.lua");
395    let loader = lua
396        .create_function(move |lua, ()| lua.load(src).set_name(chunk_name.clone()).eval::<Value>())
397        .map_err(|e| format!("gendoc: preload create_function failed for {mod_name}: {e}"))?;
398    preload
399        .set(mod_name, loader)
400        .map_err(|e| format!("gendoc: preload.set failed for {mod_name}: {e}"))?;
401    Ok(())
402}
403
404fn inject_config_preloads(lua: &Lua, config_path: &str) -> Result<(), String> {
405    let src = std::fs::read_to_string(config_path)
406        .map_err(|e| format!("gendoc: config_path '{config_path}' load failed: {e}"))?;
407    let config: GendocConfig = toml::from_str(&src)
408        .map_err(|e| format!("gendoc: config_path '{config_path}' parse failed: {e}"))?;
409
410    // Move the two sub-tables into the Lua registry via globals
411    // stashes so the preload closures can retrieve them on require.
412    // Using globals keeps the lifetime story simple (mlua 0.11 does
413    // not require `'static` bounds for tables stored this way).
414    let preload = preload_table(lua)?;
415
416    inject_config_subtable(
417        lua,
418        &preload,
419        config.context7,
420        "context7",
421        "_gendoc_context7_config",
422        "tools.docs.context7_config",
423    )?;
424    inject_config_subtable(
425        lua,
426        &preload,
427        config.devin,
428        "devin",
429        "_gendoc_devin_config",
430        "tools.docs.devin_wiki_config",
431    )?;
432
433    Ok(())
434}
435
436/// Stash the `key` sub-table into a Lua global
437/// and register a `package.preload` loader that returns it.
438///
439/// - Missing key (`None`) is a legitimate caller choice: the
440///   preload entry is simply omitted so a downstream `require` of
441///   `module_name` raises Lua's standard "module not found" error
442///   (clearer than registering a Nil loader that produces an opaque
443///   nil-index error).
444/// - Non-table values are rejected up front with an
445///   explicit `Err` — far more actionable than letting the Lua side
446///   try to index a string/number later.
447fn inject_config_subtable(
448    lua: &Lua,
449    preload: &Table,
450    value: Option<toml::Value>,
451    key: &'static str,
452    global_key: &'static str,
453    module_name: &'static str,
454) -> Result<(), String> {
455    match value {
456        None => Ok(()),
457        Some(v) => {
458            let lua_value = toml_to_lua_value(lua, &v)
459                .map_err(|e| format!("gendoc: config '{key}' conversion failed: {e}"))?;
460            match lua_value {
461                Value::Table(_) => {
462                    lua.globals()
463                        .set(global_key, lua_value)
464                        .map_err(|e| format!("gendoc: stash {global_key} failed: {e}"))?;
465                    register_config_loader(lua, preload, module_name, global_key)
466                }
467                other => Err(format!(
468                    "gendoc: config '{key}' must be a table, got {}",
469                    other.type_name()
470                )),
471            }
472        }
473    }
474}
475
476#[derive(Debug, Deserialize)]
477struct GendocConfig {
478    context7: Option<toml::Value>,
479    devin: Option<toml::Value>,
480}
481
482fn toml_to_lua_value(lua: &Lua, value: &toml::Value) -> Result<Value, String> {
483    match value {
484        toml::Value::String(s) => Ok(Value::String(
485            lua.create_string(s)
486                .map_err(|e| format!("create string failed: {e}"))?,
487        )),
488        toml::Value::Integer(i) => Ok(Value::Integer(*i)),
489        toml::Value::Float(f) => Ok(Value::Number(*f)),
490        toml::Value::Boolean(b) => Ok(Value::Boolean(*b)),
491        toml::Value::Datetime(dt) => Ok(Value::String(
492            lua.create_string(dt.to_string())
493                .map_err(|e| format!("create datetime string failed: {e}"))?,
494        )),
495        toml::Value::Array(arr) => {
496            let table = lua
497                .create_table()
498                .map_err(|e| format!("create array table failed: {e}"))?;
499            for (idx, item) in arr.iter().enumerate() {
500                let v = toml_to_lua_value(lua, item)?;
501                table
502                    .set((idx + 1) as i64, v)
503                    .map_err(|e| format!("set array item [{idx}] failed: {e}"))?;
504            }
505            Ok(Value::Table(table))
506        }
507        toml::Value::Table(map) => {
508            let table = lua
509                .create_table()
510                .map_err(|e| format!("create map table failed: {e}"))?;
511            for (k, v) in map {
512                let vv = toml_to_lua_value(lua, v)?;
513                table
514                    .set(k.as_str(), vv)
515                    .map_err(|e| format!("set map key '{k}' failed: {e}"))?;
516            }
517            Ok(Value::Table(table))
518        }
519    }
520}
521
522fn register_config_loader(
523    lua: &Lua,
524    preload: &Table,
525    module_name: &'static str,
526    global_key: &'static str,
527) -> Result<(), String> {
528    let loader = lua
529        .create_function(move |lua, ()| lua.globals().get::<Value>(global_key))
530        .map_err(|e| format!("gendoc: config loader for {module_name} failed: {e}"))?;
531    preload
532        .set(module_name, loader)
533        .map_err(|e| format!("gendoc: preload.set failed for {module_name}: {e}"))?;
534    Ok(())
535}
536
537fn install_io_hooks(
538    lua: &Lua,
539    out_buf: Arc<Mutex<String>>,
540    err_buf: Arc<Mutex<String>>,
541) -> Result<(), String> {
542    let out_for_closure = Arc::clone(&out_buf);
543    let append_out = lua
544        .create_function(move |_, s: String| {
545            out_for_closure
546                .lock()
547                .map_err(|e| mlua::Error::external(format!("gendoc: out buf lock: {e}")))?
548                .push_str(&s);
549            Ok(())
550        })
551        .map_err(|e| format!("gendoc: create_function _gendoc_out_append: {e}"))?;
552
553    let err_for_closure = Arc::clone(&err_buf);
554    let append_err = lua
555        .create_function(move |_, s: String| {
556            err_for_closure
557                .lock()
558                .map_err(|e| mlua::Error::external(format!("gendoc: err buf lock: {e}")))?
559                .push_str(&s);
560            Ok(())
561        })
562        .map_err(|e| format!("gendoc: create_function _gendoc_err_append: {e}"))?;
563
564    lua.globals()
565        .set("_gendoc_out_append", append_out)
566        .map_err(|e| format!("gendoc: globals set _gendoc_out_append: {e}"))?;
567    lua.globals()
568        .set("_gendoc_err_append", append_err)
569        .map_err(|e| format!("gendoc: globals set _gendoc_err_append: {e}"))?;
570
571    Ok(())
572}
573
574fn install_argv(
575    lua: &Lua,
576    source_dir: &str,
577    out_dir: &str,
578    flags: &ProjectionFlags,
579    lint_strict: bool,
580) -> Result<(), String> {
581    let argv = lua
582        .create_table()
583        .map_err(|e| format!("gendoc: create argv table: {e}"))?;
584
585    let mut idx: i64 = 1;
586    let mut push = |v: &str| -> Result<(), String> {
587        argv.set(idx, v)
588            .map_err(|e| format!("gendoc: argv set [{idx}]: {e}"))?;
589        idx += 1;
590        Ok(())
591    };
592
593    push(source_dir)?;
594    push(out_dir)?;
595    if flags.hub {
596        push("--hub")?;
597    }
598    if flags.context7 {
599        push("--context7")?;
600    }
601    if flags.devin {
602        push("--devin")?;
603    }
604    if flags.lint_only {
605        push("--lint-only")?;
606    } else if flags.lint {
607        push("--lint")?;
608    }
609    if lint_strict {
610        push("--strict")?;
611    }
612
613    lua.globals()
614        .set("arg", argv)
615        .map_err(|e| format!("gendoc: globals set arg: {e}"))?;
616
617    Ok(())
618}
619
620/// Strip a leading `#!` shebang line from a Lua source.
621///
622/// `lua.load()` (buffer-based) does not strip the shebang the way
623/// `luaL_loadfile` does. The embedded `gen_docs.lua` starts with
624/// `#!/usr/bin/env lua`, so we strip the first line before feeding
625/// the buffer to the VM.
626fn strip_shebang(src: &str) -> &str {
627    if let Some(body) = src.strip_prefix("#!") {
628        match body.find('\n') {
629            Some(i) => &body[i + 1..],
630            None => "",
631        }
632    } else {
633        src
634    }
635}
636
637fn read_buf(buf: &Arc<Mutex<String>>) -> Result<String, String> {
638    Ok(buf
639        .lock()
640        .map_err(|e| format!("gendoc: buffer lock (read): {e}"))?
641        .clone())
642}
643
644/// Extract `__gendoc_exit` from a Lua error raised by the `os.exit`
645/// override. Returns `None` for unrelated errors.
646fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
647    // `error(tbl, 0)` in Lua becomes `mlua::Error::RuntimeError`
648    // where the tostring serialization contains the table pointer
649    // (not useful) plus any __tostring metamethod output. For our
650    // structured exit we don't set __tostring, so the surfaced
651    // message may look like `table: 0x...`. That is not reliable.
652    //
653    // Instead, walk the error chain looking for a CallbackError /
654    // WithContext that wraps the raw value. mlua exposes the raw
655    // table via `Error::CallbackError::cause`. The Lua table itself
656    // is not exposed from `RuntimeError`, so we fall back to
657    // pattern-matching the string form as a best-effort:
658    // Lua's `error({__gendoc_exit = N}, 0)` with a table whose
659    // `__tostring` is unset renders as the table pointer, which
660    // loses the code.
661    //
662    // To make this robust we attach a __tostring metamethod to the
663    // raised table in the hook script so the error message embeds
664    // the code. See HOOK_SCRIPT.
665    let msg = err.to_string();
666    // Look for the marker substring emitted by __tostring (installed
667    // in HOOK_SCRIPT) in the format `__gendoc_exit=<N>`.
668    let needle = EXIT_MARKER;
669    let idx = msg.find(needle)?;
670    let rest = &msg[idx + needle.len()..];
671    // Skip any `=` / `:` / whitespace, then parse an integer.
672    let digits_start = rest
673        .char_indices()
674        .find(|(_, c)| c.is_ascii_digit() || *c == '-')
675        .map(|(i, _)| i)?;
676    let tail = &rest[digits_start..];
677    let digits_end = tail
678        .char_indices()
679        .find(|(_, c)| !c.is_ascii_digit() && *c != '-')
680        .map(|(i, _)| i)
681        .unwrap_or(tail.len());
682    tail[..digits_end].parse::<i64>().ok()
683}
684
685fn build_response_json(
686    source_dir: &str,
687    out_dir: &str,
688    stdout_txt: &str,
689    stderr_txt: &str,
690) -> String {
691    // Keep dependency-free — we already depend on serde_json
692    // transitively but using it here avoids hand-rolled escaping
693    // bugs. Every shipped string is a plain `String` so
694    // `serde_json::Value::String` is fine.
695    let value = serde_json::json!({
696        "source_dir": source_dir,
697        "out_dir": out_dir,
698        "stdout": stdout_txt,
699        "stderr": stderr_txt,
700    });
701    value.to_string()
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn projection_flags_defaults_are_false() {
710        let f = ProjectionFlags::from_list(None).expect("projection parse");
711        assert!(!f.hub);
712        assert!(!f.context7);
713        assert!(!f.devin);
714        assert!(!f.lint);
715        assert!(!f.lint_only);
716    }
717
718    #[test]
719    fn projection_flags_parse_known_tokens() {
720        let list = vec![
721            "hub".to_string(),
722            "context7".to_string(),
723            "devin".to_string(),
724        ];
725        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
726        assert!(f.hub);
727        assert!(f.context7);
728        assert!(f.devin);
729        assert!(!f.lint);
730    }
731
732    #[test]
733    fn projection_flags_lint_only_implies_lint() {
734        let list = vec!["lint_only".to_string()];
735        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
736        assert!(f.lint);
737        assert!(f.lint_only);
738    }
739
740    #[test]
741    fn projection_flags_unknown_is_rejected() {
742        let list = vec!["nope".to_string(), "hub".to_string()];
743        let err = ProjectionFlags::from_list(Some(&list)).expect_err("must reject unknown");
744        assert!(err.contains("unknown projection"));
745    }
746
747    #[test]
748    fn context7_without_config_is_rejected() {
749        // Build a minimal AppService through the public API is
750        // expensive; exercise the input validation logic through
751        // `ProjectionFlags` + an explicit mirror of the early
752        // return in `hub_gendoc`. Directly calling `hub_gendoc`
753        // would require a full test fixture — covered in e2e.
754        let list = vec!["context7".to_string()];
755        let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
756        assert!(flags.context7);
757        // Simulate the guard:
758        let err_expected =
759            "gendoc: config_path is required when projections include context7 or devin";
760        let err = if (flags.context7 || flags.devin) && Option::<&str>::None.is_none() {
761            Some(err_expected.to_string())
762        } else {
763            None
764        };
765        assert_eq!(err.as_deref(), Some(err_expected));
766    }
767
768    #[test]
769    fn extract_exit_code_parses_marker_formats() {
770        // Simulated error string; `extract_exit_code` doesn't care
771        // about the prefix as long as the `__gendoc_exit=N` marker is
772        // present.
773        let err = mlua::Error::RuntimeError(
774            "runtime error: [string \"...\"]:2: {__gendoc_exit=2}".to_string(),
775        );
776        assert_eq!(extract_exit_code(&err), Some(2));
777
778        let err = mlua::Error::RuntimeError("runtime error: __gendoc_exit: 0 (clean)".to_string());
779        assert_eq!(extract_exit_code(&err), Some(0));
780    }
781
782    #[test]
783    fn extract_exit_code_returns_none_for_unrelated_errors() {
784        let err = mlua::Error::RuntimeError("some other Lua error".to_string());
785        assert!(extract_exit_code(&err).is_none());
786    }
787
788    #[test]
789    fn strip_shebang_removes_first_line_when_prefixed() {
790        let src = "#!/usr/bin/env lua\nreturn 1\n";
791        assert_eq!(strip_shebang(src), "return 1\n");
792    }
793
794    #[test]
795    fn strip_shebang_preserves_source_without_shebang() {
796        let src = "-- no shebang\nreturn 1\n";
797        assert_eq!(strip_shebang(src), src);
798    }
799
800    #[test]
801    fn strip_shebang_handles_shebang_only_without_trailing_newline() {
802        let src = "#!/usr/bin/env lua";
803        assert_eq!(strip_shebang(src), "");
804    }
805
806    #[test]
807    fn build_response_json_round_trips() {
808        let out = build_response_json("/src", "/src/docs", "hi", "warn");
809        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
810        assert_eq!(parsed["source_dir"], "/src");
811        assert_eq!(parsed["out_dir"], "/src/docs");
812        assert_eq!(parsed["stdout"], "hi");
813        assert_eq!(parsed["stderr"], "warn");
814    }
815}