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, backward-compat) is a caller-supplied TOML file
23//!   with top-level `context7` and/or `devin` tables.  When `config_path` is
24//!   omitted and the active projections include `context7` or `devin`, the
25//!   project root's `alc.toml` is auto-explored for `[hub.context7]` /
26//!   `[hub.devin]` sections; core defaults are used when those sections are
27//!   absent.  In all cases the resolved tables are exposed under the
28//!   `tools.docs.context7_config` / `tools.docs.devin_wiki_config` module
29//!   names that `gen_docs` `require`s.  `.lua` config files are no longer
30//!   accepted (retired in v0.26); see `docs/hub-gendoc-config.md` for the
31//!   migration guide.
32//! - `print` / `io.stdout.write` / `io.stderr.write` are redirected
33//!   into Rust-side `String` buffers so callers observe the Lua
34//!   progress log through the MCP response instead of dropping it to
35//!   the server stderr.
36//! - `os.exit(code)` is overridden so it raises a structured Lua
37//!   error rather than terminating the process; non-zero exits are
38//!   converted into `Err(...)`.
39//!
40//! Per the project-level Error propagation rule
41//! (`CLAUDE.md §Service 層 Error 伝播規律`), every `mlua::Result` is
42//! surfaced via `?` with a `gendoc:` prefix — no `warn!` drops, no
43//! `unwrap_or_default`, no silent `Err(_) =>` branches.
44
45use std::path::PathBuf;
46use std::sync::{Arc, Mutex};
47
48use mlua::{Lua, Table, Value};
49use semver::{Version, VersionReq};
50use serde::Deserialize;
51use thiserror::Error;
52
53use super::hub_dist_preset::load_hub_projection_config;
54use super::project::resolve_project_root;
55use super::AppService;
56
57pub mod alc_shapes_codegen;
58pub mod templates;
59
60// ── alc_shapes version pinning ────────────────────────────────────────
61
62/// Version string embedded in the vendored `alc_shapes/init.lua`.
63/// Must match the `M.VERSION` declaration in that file exactly.
64pub(crate) const EMBEDDED_ALC_SHAPES_VERSION: &str = "0.25.1";
65
66#[derive(Debug, Error)]
67enum ShapesVersionError {
68    #[error("alc_shapes version mismatch: embedded={embedded}, mirror={mirror}. {hint}")]
69    Mismatch {
70        embedded: String,
71        mirror: String,
72        hint: &'static str,
73    },
74    #[error("alc_shapes mirror init.lua at '{path}' has no parseable M.VERSION declaration")]
75    Malformed { path: PathBuf },
76}
77
78const SHAPES_VERSION_HINT: &str = "Align bundled alc_shapes/ to match core, \
79    or upgrade algocline core to the mirror version. See CHANGELOG for details.";
80
81// ── pkg compat-range error variants ──────────────────────────────
82
83#[derive(Debug, Error)]
84enum ShapesCompatError {
85    #[error(
86        "pkg '{pkg_name}': alc_shapes_compat range '{declared_range}' does not match \
87         embedded alc_shapes@{actual_version}. {hint}"
88    )]
89    Violation {
90        pkg_name: String,
91        declared_range: String,
92        actual_version: String,
93        hint: &'static str,
94    },
95    #[error(
96        "pkg '{pkg_name}': alc_shapes_compat value '{value}' is not a valid semver range: {cause}"
97    )]
98    Malformed {
99        pkg_name: String,
100        value: String,
101        cause: String,
102    },
103    #[error("I/O error reading pkg compat from '{path}': {cause}")]
104    Io { path: PathBuf, cause: String },
105}
106
107/// Top-level error type for `AppService::hub_gendoc`.
108///
109/// Wraps the typed pre-flight errors (`ShapesVersionError`,
110/// `ShapesCompatError`) via `#[from]` so callers in this module can
111/// `?`-propagate without stringifying. The MCP wire layer converts the
112/// variant to a `gendoc:`-prefixed string at the AppService boundary
113/// (`Result<String, String>`), but the structure is preserved inside
114/// this module so future consumers (telemetry, CLI JSON output) can
115/// match on the variant rather than parsing a formatted string.
116#[derive(Debug, Error)]
117enum HubGendocError {
118    #[error("{0}")]
119    ShapesVersion(#[from] ShapesVersionError),
120    #[error("{0}")]
121    ShapesCompat(#[from] ShapesCompatError),
122}
123
124const SHAPES_COMPAT_VIOLATION_HINT: &str = "Declare a wider alc_shapes_compat range in M.meta, \
125     or upgrade/downgrade algocline core to a matching version.";
126
127/// Check the mirror's `M.VERSION` against `EMBEDDED_ALC_SHAPES_VERSION`.
128///
129/// If `source_dir` is `None` or its `alc_shapes/init.lua` does not
130/// exist, returns `Ok(())` immediately (no-op path). Otherwise reads
131/// the file, extracts `M.VERSION = "x.y.z"` with a hand-rolled parser
132/// (no `regex` dep), and fails with a typed error on mismatch.
133fn check_mirror_shapes_version(source_dir: Option<&str>) -> Result<(), ShapesVersionError> {
134    let Some(dir) = source_dir else {
135        return Ok(());
136    };
137    let path: PathBuf = [dir, "alc_shapes", "init.lua"].iter().collect();
138    if !path.exists() {
139        return Ok(());
140    }
141    let src = std::fs::read_to_string(&path)
142        .map_err(|_| ShapesVersionError::Malformed { path: path.clone() })?;
143    let mirror_ver = extract_m_version(&src)
144        .ok_or_else(|| ShapesVersionError::Malformed { path: path.clone() })?;
145    if mirror_ver != EMBEDDED_ALC_SHAPES_VERSION {
146        return Err(ShapesVersionError::Mismatch {
147            embedded: EMBEDDED_ALC_SHAPES_VERSION.to_string(),
148            mirror: mirror_ver,
149            hint: SHAPES_VERSION_HINT,
150        });
151    }
152    Ok(())
153}
154
155/// Hand-rolled parser that extracts the double-quoted value after a
156/// `<marker> = "..."` pattern in a Lua source string.
157///
158/// Finds the first occurrence of `marker` followed (with optional
159/// whitespace) by `=` and then a double-quoted string. Returns the
160/// quoted content on success, `None` when the pattern is absent or
161/// malformed.
162///
163/// Both `extract_m_version` and `extract_m_meta_compat` delegate to
164/// this shared helper to avoid duplicating the parsing logic.
165fn extract_quoted_value<'a>(src: &'a str, marker: &str) -> Option<&'a str> {
166    let start = src.find(marker)?;
167    let after_marker = src[start + marker.len()..].trim_start();
168    let after_eq = after_marker.strip_prefix('=')?;
169    let after_eq = after_eq.trim_start();
170    let after_quote = after_eq.strip_prefix('"')?;
171    let end = after_quote.find('"')?;
172    Some(&after_quote[..end])
173}
174
175/// Hand-rolled parser for `M.VERSION = "x.y.z"` in a Lua source string.
176///
177/// Finds the first occurrence of `M.VERSION` followed (with optional
178/// whitespace) by `=` and then a double-quoted string. Returns the
179/// quoted content on success.
180fn extract_m_version(src: &str) -> Option<String> {
181    extract_quoted_value(src, "M.VERSION").map(str::to_string)
182}
183
184/// Hand-rolled parser for `alc_shapes_compat = "..."` in a pkg `init.lua`.
185///
186/// Matches the pattern `alc_shapes_compat` (optionally preceded by
187/// `M.meta.` or other context) followed by `= "..."`. Returns a borrow
188/// into `src` on success, `None` when the field is absent.
189fn extract_m_meta_compat(src: &str) -> Option<&str> {
190    extract_quoted_value(src, "alc_shapes_compat")
191}
192
193/// Scan every package directory under `source_dir` and verify that each
194/// package's declared `alc_shapes_compat` semver range includes the
195/// embedded alc_shapes version.
196///
197/// **Dispatch rules** (applied per package `init.lua`):
198/// - No `alc_shapes_compat` field → push a warning string (undeclared,
199///   backward compat) and continue.
200/// - Malformed range → return `Err(ShapesCompatMalformed)`.
201/// - Range declared and in-range → continue silently.
202/// - Range declared but out-of-range → return `Err(ShapesCompatViolation)`.
203///
204/// Packages whose directories do not contain an `init.lua` are silently
205/// skipped (same rule as `build_index`). The `alc_shapes/` directory
206/// (no `M.meta.name`) is naturally excluded because `extract_m_meta_compat`
207/// will return `None` and the warning path handles that without error.
208///
209/// Returns `(warnings, ())` on success; the first package that violates
210/// its declared range terminates the scan with `Err`.
211fn check_pkg_compat(source_dir: &str) -> Result<Vec<String>, ShapesCompatError> {
212    let current = Version::parse(EMBEDDED_ALC_SHAPES_VERSION)
213        .expect("EMBEDDED_ALC_SHAPES_VERSION is a valid semver constant");
214
215    let pkg_dir = std::path::Path::new(source_dir);
216    let dir_entries = std::fs::read_dir(pkg_dir).map_err(|e| ShapesCompatError::Io {
217        path: pkg_dir.to_path_buf(),
218        cause: e.to_string(),
219    })?;
220
221    let mut warnings = Vec::new();
222
223    for entry in dir_entries {
224        let entry = entry.map_err(|e| ShapesCompatError::Io {
225            path: pkg_dir.to_path_buf(),
226            cause: e.to_string(),
227        })?;
228        if !entry.path().is_dir() {
229            continue;
230        }
231        let dir_name = match entry.file_name().to_str() {
232            Some(n) if !n.starts_with('.') && !n.starts_with('_') => n.to_string(),
233            _ => continue,
234        };
235
236        let init_lua = entry.path().join("init.lua");
237        if !init_lua.exists() {
238            continue;
239        }
240
241        let src = std::fs::read_to_string(&init_lua).map_err(|e| ShapesCompatError::Io {
242            path: init_lua.clone(),
243            cause: e.to_string(),
244        })?;
245
246        match extract_m_meta_compat(&src) {
247            None => {
248                warnings.push(format!(
249                    "pkg {dir_name}: alc_shapes_compat not declared, \
250                     continuing with current alc_shapes@{EMBEDDED_ALC_SHAPES_VERSION}"
251                ));
252            }
253            Some(raw) => {
254                let range = VersionReq::parse(raw).map_err(|e| ShapesCompatError::Malformed {
255                    pkg_name: dir_name.clone(),
256                    value: raw.to_string(),
257                    cause: e.to_string(),
258                })?;
259
260                if !range.matches(&current) {
261                    return Err(ShapesCompatError::Violation {
262                        pkg_name: dir_name,
263                        declared_range: raw.to_string(),
264                        actual_version: EMBEDDED_ALC_SHAPES_VERSION.to_string(),
265                        hint: SHAPES_COMPAT_VIOLATION_HINT,
266                    });
267                }
268                // In-range: continue silently.
269            }
270        }
271    }
272
273    Ok(warnings)
274}
275
276// ── Embedded Lua sources ──────────────────────────────────────────
277
278const LUA_GEN_DOCS: &str = include_str!("lua/gendoc/gen_docs.lua");
279const LUA_DOCS_LIST: &str = include_str!("lua/gendoc/docs/list.lua");
280const LUA_DOCS_EXTRACT: &str = include_str!("lua/gendoc/docs/extract.lua");
281const LUA_DOCS_PROJECTIONS: &str = include_str!("lua/gendoc/docs/projections.lua");
282const LUA_DOCS_PKG_INFO: &str = include_str!("lua/gendoc/docs/pkg_info.lua");
283const LUA_DOCS_JSON: &str = include_str!("lua/gendoc/docs/json.lua");
284const LUA_DOCS_LINT: &str = include_str!("lua/gendoc/docs/lint.lua");
285const LUA_DOCS_ENTITY_SCHEMAS: &str = include_str!("lua/gendoc/docs/entity_schemas.lua");
286
287// ── Vendored alc_shapes (fully embedded; no disk fallback) ───────
288
289const LUA_ALC_SHAPES_INIT: &str = include_str!("gendoc/alc_shapes/init.lua");
290const LUA_ALC_SHAPES_T: &str = include_str!("gendoc/alc_shapes/t.lua");
291const LUA_ALC_SHAPES_REFLECT: &str = include_str!("gendoc/alc_shapes/reflect.lua");
292const LUA_ALC_SHAPES_CHECK: &str = include_str!("gendoc/alc_shapes/check.lua");
293const LUA_ALC_SHAPES_INSTRUMENT: &str = include_str!("gendoc/alc_shapes/instrument.lua");
294const LUA_ALC_SHAPES_LUACATS: &str = include_str!("gendoc/alc_shapes/luacats.lua");
295const LUA_ALC_SHAPES_SPEC_RESOLVER: &str = include_str!("gendoc/alc_shapes/spec_resolver.lua");
296
297/// All embedded preloads: vendored `alc_shapes` modules (in dependency
298/// order) followed by the `tools/docs/*` pipeline sources.
299///
300/// Registration order matters: sub-modules must appear before the
301/// modules that `require` them, matching the `alc_shapes/init.lua`
302/// dependency chain: `t` → `reflect` / `check` / `luacats` /
303/// `spec_resolver` → `instrument` → `init`.
304const EMBEDDED_TOOL_PRELOADS: &[(&str, &str)] = &[
305    // alc_shapes sub-modules (no intra-module deps except alc_shapes.t)
306    ("alc_shapes.t", LUA_ALC_SHAPES_T),
307    ("alc_shapes.reflect", LUA_ALC_SHAPES_REFLECT),
308    ("alc_shapes.check", LUA_ALC_SHAPES_CHECK),
309    ("alc_shapes.luacats", LUA_ALC_SHAPES_LUACATS),
310    ("alc_shapes.spec_resolver", LUA_ALC_SHAPES_SPEC_RESOLVER),
311    ("alc_shapes.instrument", LUA_ALC_SHAPES_INSTRUMENT),
312    // alc_shapes top-level (requires all sub-modules above)
313    ("alc_shapes", LUA_ALC_SHAPES_INIT),
314    ("tools.docs.list", LUA_DOCS_LIST),
315    ("tools.docs.extract", LUA_DOCS_EXTRACT),
316    ("tools.docs.projections", LUA_DOCS_PROJECTIONS),
317    ("tools.docs.pkg_info", LUA_DOCS_PKG_INFO),
318    ("tools.docs.json", LUA_DOCS_JSON),
319    ("tools.docs.lint", LUA_DOCS_LINT),
320    ("tools.docs.entity_schemas", LUA_DOCS_ENTITY_SCHEMAS),
321];
322
323/// IO / exit hooks installed into the VM right before `gen_docs.lua`
324/// runs. `_gendoc_out_append` / `_gendoc_err_append` are Rust
325/// closures registered under the same names on `_G`.
326///
327/// `io.stdout` / `io.stderr` are in mlua exposed as `FILE*` userdata
328/// whose metatable rejects arbitrary `__newindex` writes — so we
329/// cannot patch `io.stdout.write` in place. Instead we replace
330/// `io.stdout` / `io.stderr` wholesale with plain Lua tables that
331/// expose a `write` method delegating to the Rust-side append
332/// closures. Both method-style (`io.stdout:write(x)`) and
333/// function-style (`io.stdout.write(io.stdout, x)`) calls are
334/// supported; both are used in the bundled `gen_docs.lua`.
335const HOOK_SCRIPT: &str = r##"
336os.exit = function(code)
337    local c = code or 0
338    local tbl = { __gendoc_exit = c }
339    -- Attach __tostring so the raw mlua error message embeds the
340    -- code as "__gendoc_exit=N", letting the Rust side recover it
341    -- via substring match instead of walking CallbackError internals.
342    setmetatable(tbl, { __tostring = function(self)
343        return string.format("__gendoc_exit=%d", self.__gendoc_exit or 0)
344    end })
345    error(tbl, 0)
346end
347io.stdout = {
348    write = function(self, ...)
349        local args = { ... }
350        for i = 1, select("#", ...) do
351            args[i] = tostring(args[i])
352        end
353        _gendoc_out_append(table.concat(args))
354        return self
355    end,
356}
357io.stderr = {
358    write = function(self, ...)
359        local args = { ... }
360        for i = 1, select("#", ...) do
361            args[i] = tostring(args[i])
362        end
363        _gendoc_err_append(table.concat(args))
364        return self
365    end,
366}
367print = function(...)
368    local args = { ... }
369    for i = 1, select("#", ...) do
370        args[i] = tostring(args[i])
371    end
372    _gendoc_out_append(table.concat(args, "\t") .. "\n")
373end
374"##;
375
376/// Marker set on the Lua error table by the `os.exit` override.
377const EXIT_MARKER: &str = "__gendoc_exit";
378
379impl AppService {
380    /// See [`crate::EngineApi::hub_gendoc`] for parameter semantics.
381    ///
382    /// `config_path` format (TOML):
383    ///
384    /// ```toml
385    /// [context7]
386    /// projectTitle = "my project"
387    /// description = "..."
388    /// rules = []
389    ///
390    /// [devin]
391    /// project_name = "my project"
392    /// ```
393    ///
394    /// Notes:
395    /// - `context7` / `devin` are optional individually.
396    /// - When present, each key must be a table.
397    /// - TOML arrays/tables are converted recursively to Lua tables.
398    /// - See `docs/hub-gendoc-config.md` for a concrete schema example.
399    ///
400    /// Returns a JSON string of the form:
401    ///
402    /// ```json
403    /// { "source_dir": "...", "out_dir": "...", "stdout": "...", "stderr": "..." }
404    /// ```
405    ///
406    /// Non-zero `os.exit`, missing `hub_index.json`, Lua runtime
407    /// errors, and `config_path` read failures are all surfaced as
408    /// `Err` with a `gendoc:` prefix.
409    pub fn hub_gendoc(
410        &self,
411        source_dir: &str,
412        out_dir: Option<&str>,
413        projections: Option<&[String]>,
414        config_path: Option<&str>,
415        lint_strict: Option<bool>,
416    ) -> Result<String, String> {
417        let projection_flags = ProjectionFlags::from_list(projections)?;
418
419        let resolved_out_dir = out_dir
420            .map(|s| s.to_string())
421            .unwrap_or_else(|| format!("{source_dir}/docs"));
422
423        // Reject mismatched mirror before starting the VM: if the
424        // caller's source_dir has an alc_shapes/init.lua whose
425        // M.VERSION differs from the embedded constant, fail early with
426        // a structured error. Variant structure is preserved via
427        // `HubGendocError` (`?` + `#[from]`) and stringified only at
428        // this function's `Result<String, String>` boundary.
429        let compat_warnings = run_preflight(source_dir).map_err(|e| format!("gendoc: {e}"))?;
430
431        let lua = Lua::new();
432
433        register_preloads(&lua)?;
434
435        // Config injection — must be wired as preload *before* `gen_docs.lua`
436        // executes so that `require("tools.docs.context7_config")` resolves.
437        //
438        // Dispatch order:
439        //   1. `.lua` config_path → explicit error (.lua support retired in v0.26)
440        //   2. `.toml` config_path → flat [context7]/[devin] backward-compat path
441        //   3. None + context7/devin projection → alc.toml auto-explore + core defaults
442        //   4. None + no context7/devin → skip (no config needed)
443        match config_path {
444            Some(path) if path.to_lowercase().ends_with(".lua") => {
445                return Err(
446                    "gendoc: config_path extension '.lua' is no longer supported; use .toml"
447                        .to_string(),
448                );
449            }
450            Some(path) => {
451                // Backward-compat: flat [context7] / [devin] TOML file.
452                inject_config_preloads_toml(&lua, &preload_table(&lua)?, path)?;
453            }
454            None if projection_flags.context7 || projection_flags.devin => {
455                // New path: auto-explore alc.toml and merge with core defaults.
456                let project_root = resolve_project_root(Some(source_dir));
457                let merged = load_hub_projection_config(project_root.as_deref())?;
458                inject_config_subtable(
459                    &lua,
460                    &preload_table(&lua)?,
461                    Some(merged.to_context7_toml()),
462                    "context7",
463                    "_gendoc_context7_config",
464                    "tools.docs.context7_config",
465                )?;
466                inject_config_subtable(
467                    &lua,
468                    &preload_table(&lua)?,
469                    Some(merged.to_devin_toml()),
470                    "devin",
471                    "_gendoc_devin_config",
472                    "tools.docs.devin_wiki_config",
473                )?;
474            }
475            None => {
476                // No context7/devin projections requested — config injection not needed.
477            }
478        }
479
480        let out_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
481        let err_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
482
483        install_io_hooks(&lua, Arc::clone(&out_buf), Arc::clone(&err_buf))?;
484
485        install_argv(
486            &lua,
487            source_dir,
488            &resolved_out_dir,
489            &projection_flags,
490            lint_strict.unwrap_or(false),
491        )?;
492
493        // Run the IO / exit hook script (must come after
494        // `_gendoc_out_append` / `_gendoc_err_append` are installed,
495        // but before `gen_docs.lua` is exec'd).
496        lua.load(HOOK_SCRIPT)
497            .set_name("@embedded:gendoc/hooks.lua")
498            .exec()
499            .map_err(|e| format!("gendoc: hooks inject failed: {e}"))?;
500
501        // Execute `gen_docs.lua`. The file ends with `main(arg)`.
502        //
503        // `lua.load()` uses the string path of `luaL_loadbuffer`
504        // which does NOT strip a `#!` shebang line (the shebang is
505        // only accepted by `luaL_loadfile`). The embedded
506        // `gen_docs.lua` starts with `#!/usr/bin/env lua`, so we
507        // skip the first line before loading.
508        let gen_docs_body = strip_shebang(LUA_GEN_DOCS);
509        let exec_result = lua
510            .load(gen_docs_body)
511            .set_name("@embedded:gendoc/gen_docs.lua")
512            .exec();
513
514        let stdout_txt = read_buf(&out_buf)?;
515        let stderr_txt = read_buf(&err_buf)?;
516
517        match exec_result {
518            Ok(()) => {}
519            Err(e) => {
520                if let Some(code) = extract_exit_code(&e) {
521                    if code != 0 {
522                        return Err(format!(
523                            "gendoc: exited with code {code}\nstderr:\n{stderr_txt}"
524                        ));
525                    }
526                    // code == 0 is a clean shutdown via os.exit(0) —
527                    // fall through to the normal response.
528                } else {
529                    return Err(format!("gendoc: Lua error: {e}\nstderr:\n{stderr_txt}"));
530                }
531            }
532        }
533
534        Ok(build_response_json(
535            source_dir,
536            &resolved_out_dir,
537            &stdout_txt,
538            &stderr_txt,
539            &compat_warnings,
540        ))
541    }
542}
543
544/// Pre-flight: mirror version check, then compat-range scan.
545///
546/// Returns the list of compat warnings on success. Typed
547/// `HubGendocError` variants (`ShapesVersion` / `ShapesCompat`) are
548/// propagated via `?` and `#[from]` so the caller preserves variant
549/// structure until the MCP wire boundary stringifies.
550fn run_preflight(source_dir: &str) -> Result<Vec<String>, HubGendocError> {
551    check_mirror_shapes_version(Some(source_dir))?;
552    let warnings = check_pkg_compat(source_dir)?;
553    Ok(warnings)
554}
555
556// ── Helpers ───────────────────────────────────────────────────────
557
558#[derive(Debug, Default, Clone, Copy)]
559struct ProjectionFlags {
560    hub: bool,
561    context7: bool,
562    devin: bool,
563    lint: bool,
564    lint_only: bool,
565    luacats: bool,
566    /// narrative/{pkg}.md files are unconditionally emitted by the embedded
567    /// gen_docs.lua when lint_only=false, so this flag only acts as an
568    /// allowlist gate on the Rust side (approach A).
569    narrative: bool,
570    /// llms.txt and llms-full.txt are unconditionally emitted by the embedded
571    /// gen_docs.lua when lint_only=false, so this flag only acts as an
572    /// allowlist gate on the Rust side (approach A).
573    llms: bool,
574}
575
576impl ProjectionFlags {
577    fn from_list(projections: Option<&[String]>) -> Result<Self, String> {
578        let mut f = ProjectionFlags::default();
579        let Some(list) = projections else {
580            return Ok(f);
581        };
582        for p in list {
583            match p.as_str() {
584                "hub" => f.hub = true,
585                "context7" => f.context7 = true,
586                "devin" => f.devin = true,
587                "lint" => f.lint = true,
588                "lint_only" => {
589                    f.lint_only = true;
590                    f.lint = true;
591                }
592                "luacats" => f.luacats = true,
593                "narrative" => f.narrative = true,
594                "llms" => f.llms = true,
595                _ => {
596                    return Err(format!(
597                        "gendoc: unknown projection '{p}' (allowed: hub, context7, devin, lint, lint_only, luacats, narrative, llms)"
598                    ));
599                }
600            }
601        }
602        Ok(f)
603    }
604}
605
606/// Register all embedded `gen_docs` modules.
607///
608/// `alc_shapes` and its sub-modules are fully vendored via
609/// `include_str!` — no disk fallback. `tools/docs/*` pipeline sources
610/// are registered in the same pass.
611fn register_preloads(lua: &Lua) -> Result<(), String> {
612    let preload = preload_table(lua)?;
613    for (mod_name, src) in EMBEDDED_TOOL_PRELOADS.iter().copied() {
614        register_single_preload(lua, &preload, mod_name, src)?;
615    }
616    Ok(())
617}
618
619fn preload_table(lua: &Lua) -> Result<Table, String> {
620    // `globals().package.preload` is part of the Lua 5.4 / mlua
621    // contract; absence would indicate a VM that cannot run any
622    // meaningful Lua code, so `expect` with a justifying comment is
623    // the correct classification (see CLAUDE.md §Service 層 Error
624    // 伝播規律 "limited exceptions for unreachable VM invariants").
625    let package: Table = lua
626        .globals()
627        .get("package")
628        .map_err(|e| format!("gendoc: globals().package lookup failed: {e}"))?;
629    let preload: Table = package
630        .get("preload")
631        .map_err(|e| format!("gendoc: package.preload lookup failed: {e}"))?;
632    Ok(preload)
633}
634
635fn register_single_preload(
636    lua: &Lua,
637    preload: &Table,
638    mod_name: &'static str,
639    src: &'static str,
640) -> Result<(), String> {
641    let chunk_name = format!("@embedded:gendoc/{mod_name}.lua");
642    let loader = lua
643        .create_function(move |lua, ()| lua.load(src).set_name(chunk_name.clone()).eval::<Value>())
644        .map_err(|e| format!("gendoc: preload create_function failed for {mod_name}: {e}"))?;
645    preload
646        .set(mod_name, loader)
647        .map_err(|e| format!("gendoc: preload.set failed for {mod_name}: {e}"))?;
648    Ok(())
649}
650
651/// Extension-dispatch shim retained for unit tests.  The hot path in
652/// `hub_gendoc` dispatches directly; this function is used by the
653/// `inject_config_preloads_{lua_rejected,unknown_extension}` unit tests to
654/// verify the error messages in isolation.
655#[cfg(test)]
656fn inject_config_preloads(lua: &Lua, config_path: &str) -> Result<(), String> {
657    let ext = std::path::Path::new(config_path)
658        .extension()
659        .and_then(|e| e.to_str())
660        .unwrap_or("");
661
662    let preload = preload_table(lua)?;
663
664    if ext.eq_ignore_ascii_case("lua") {
665        Err("gendoc: config_path extension '.lua' is no longer supported; use .toml".to_string())
666    } else if ext.eq_ignore_ascii_case("toml") {
667        inject_config_preloads_toml(lua, &preload, config_path)
668    } else {
669        Err(format!(
670            "gendoc: config_path '{config_path}' unsupported extension (expected .toml)"
671        ))
672    }
673}
674
675fn inject_config_preloads_toml(
676    lua: &Lua,
677    preload: &Table,
678    config_path: &str,
679) -> Result<(), String> {
680    let src = std::fs::read_to_string(config_path)
681        .map_err(|e| format!("gendoc: config_path '{config_path}' load failed: {e}"))?;
682    let config: FlatGendocConfig = toml::from_str(&src)
683        .map_err(|e| format!("gendoc: config_path '{config_path}' parse failed: {e}"))?;
684
685    // Move the two sub-tables into the Lua registry via globals
686    // stashes so the preload closures can retrieve them on require.
687    // Using globals keeps the lifetime story simple (mlua 0.11 does
688    // not require `'static` bounds for tables stored this way).
689    inject_config_subtable(
690        lua,
691        preload,
692        config.context7,
693        "context7",
694        "_gendoc_context7_config",
695        "tools.docs.context7_config",
696    )?;
697    inject_config_subtable(
698        lua,
699        preload,
700        config.devin,
701        "devin",
702        "_gendoc_devin_config",
703        "tools.docs.devin_wiki_config",
704    )?;
705
706    Ok(())
707}
708
709/// Stash the `key` sub-table into a Lua global
710/// and register a `package.preload` loader that returns it.
711///
712/// - Missing key (`None`) is a legitimate caller choice: the
713///   preload entry is simply omitted so a downstream `require` of
714///   `module_name` raises Lua's standard "module not found" error
715///   (clearer than registering a Nil loader that produces an opaque
716///   nil-index error).
717/// - Non-table values are rejected up front with an
718///   explicit `Err` — far more actionable than letting the Lua side
719///   try to index a string/number later.
720fn inject_config_subtable(
721    lua: &Lua,
722    preload: &Table,
723    value: Option<toml::Value>,
724    key: &'static str,
725    global_key: &'static str,
726    module_name: &'static str,
727) -> Result<(), String> {
728    match value {
729        None => Ok(()),
730        Some(v) => {
731            let lua_value = toml_to_lua_value(lua, &v)
732                .map_err(|e| format!("gendoc: config '{key}' conversion failed: {e}"))?;
733            match lua_value {
734                Value::Table(_) => {
735                    lua.globals()
736                        .set(global_key, lua_value)
737                        .map_err(|e| format!("gendoc: stash {global_key} failed: {e}"))?;
738                    register_config_loader(lua, preload, module_name, global_key)
739                }
740                other => Err(format!(
741                    "gendoc: config '{key}' must be a table, got {}",
742                    other.type_name()
743                )),
744            }
745        }
746    }
747}
748
749/// Flat TOML config file shape for backward-compat `config_path=*.toml` callers.
750///
751/// Consumers using the old flat `[context7]` / `[devin]` schema continue to
752/// work unchanged.  The new `alc.toml [hub.*]` path uses
753/// `load_hub_projection_config` instead.
754#[derive(Debug, Deserialize)]
755struct FlatGendocConfig {
756    context7: Option<toml::Value>,
757    devin: Option<toml::Value>,
758}
759
760fn toml_to_lua_value(lua: &Lua, value: &toml::Value) -> Result<Value, String> {
761    match value {
762        toml::Value::String(s) => Ok(Value::String(
763            lua.create_string(s)
764                .map_err(|e| format!("create string failed: {e}"))?,
765        )),
766        toml::Value::Integer(i) => Ok(Value::Integer(*i)),
767        toml::Value::Float(f) => Ok(Value::Number(*f)),
768        toml::Value::Boolean(b) => Ok(Value::Boolean(*b)),
769        toml::Value::Datetime(dt) => Ok(Value::String(
770            lua.create_string(dt.to_string())
771                .map_err(|e| format!("create datetime string failed: {e}"))?,
772        )),
773        toml::Value::Array(arr) => {
774            let table = lua
775                .create_table()
776                .map_err(|e| format!("create array table failed: {e}"))?;
777            for (idx, item) in arr.iter().enumerate() {
778                let v = toml_to_lua_value(lua, item)?;
779                table
780                    .set((idx + 1) as i64, v)
781                    .map_err(|e| format!("set array item [{idx}] failed: {e}"))?;
782            }
783            Ok(Value::Table(table))
784        }
785        toml::Value::Table(map) => {
786            let table = lua
787                .create_table()
788                .map_err(|e| format!("create map table failed: {e}"))?;
789            for (k, v) in map {
790                let vv = toml_to_lua_value(lua, v)?;
791                table
792                    .set(k.as_str(), vv)
793                    .map_err(|e| format!("set map key '{k}' failed: {e}"))?;
794            }
795            Ok(Value::Table(table))
796        }
797    }
798}
799
800fn register_config_loader(
801    lua: &Lua,
802    preload: &Table,
803    module_name: &'static str,
804    global_key: &'static str,
805) -> Result<(), String> {
806    let loader = lua
807        .create_function(move |lua, ()| lua.globals().get::<Value>(global_key))
808        .map_err(|e| format!("gendoc: config loader for {module_name} failed: {e}"))?;
809    preload
810        .set(module_name, loader)
811        .map_err(|e| format!("gendoc: preload.set failed for {module_name}: {e}"))?;
812    Ok(())
813}
814
815fn install_io_hooks(
816    lua: &Lua,
817    out_buf: Arc<Mutex<String>>,
818    err_buf: Arc<Mutex<String>>,
819) -> Result<(), String> {
820    let out_for_closure = Arc::clone(&out_buf);
821    let append_out = lua
822        .create_function(move |_, s: String| {
823            out_for_closure
824                .lock()
825                .map_err(|e| mlua::Error::external(format!("gendoc: out buf lock: {e}")))?
826                .push_str(&s);
827            Ok(())
828        })
829        .map_err(|e| format!("gendoc: create_function _gendoc_out_append: {e}"))?;
830
831    let err_for_closure = Arc::clone(&err_buf);
832    let append_err = lua
833        .create_function(move |_, s: String| {
834            err_for_closure
835                .lock()
836                .map_err(|e| mlua::Error::external(format!("gendoc: err buf lock: {e}")))?
837                .push_str(&s);
838            Ok(())
839        })
840        .map_err(|e| format!("gendoc: create_function _gendoc_err_append: {e}"))?;
841
842    lua.globals()
843        .set("_gendoc_out_append", append_out)
844        .map_err(|e| format!("gendoc: globals set _gendoc_out_append: {e}"))?;
845    lua.globals()
846        .set("_gendoc_err_append", append_err)
847        .map_err(|e| format!("gendoc: globals set _gendoc_err_append: {e}"))?;
848
849    Ok(())
850}
851
852fn install_argv(
853    lua: &Lua,
854    source_dir: &str,
855    out_dir: &str,
856    flags: &ProjectionFlags,
857    lint_strict: bool,
858) -> Result<(), String> {
859    let argv = lua
860        .create_table()
861        .map_err(|e| format!("gendoc: create argv table: {e}"))?;
862
863    let mut idx: i64 = 1;
864    let mut push = |v: &str| -> Result<(), String> {
865        argv.set(idx, v)
866            .map_err(|e| format!("gendoc: argv set [{idx}]: {e}"))?;
867        idx += 1;
868        Ok(())
869    };
870
871    push(source_dir)?;
872    push(out_dir)?;
873    if flags.hub {
874        push("--hub")?;
875    }
876    if flags.context7 {
877        push("--context7")?;
878    }
879    if flags.devin {
880        push("--devin")?;
881    }
882    if flags.lint_only {
883        push("--lint-only")?;
884    } else if flags.lint {
885        push("--lint")?;
886    }
887    if lint_strict {
888        push("--strict")?;
889    }
890    if flags.luacats {
891        push("--luacats")?;
892    }
893
894    lua.globals()
895        .set("arg", argv)
896        .map_err(|e| format!("gendoc: globals set arg: {e}"))?;
897
898    Ok(())
899}
900
901/// Strip a leading `#!` shebang line from a Lua source.
902///
903/// `lua.load()` (buffer-based) does not strip the shebang the way
904/// `luaL_loadfile` does. The embedded `gen_docs.lua` starts with
905/// `#!/usr/bin/env lua`, so we strip the first line before feeding
906/// the buffer to the VM.
907fn strip_shebang(src: &str) -> &str {
908    if let Some(body) = src.strip_prefix("#!") {
909        match body.find('\n') {
910            Some(i) => &body[i + 1..],
911            None => "",
912        }
913    } else {
914        src
915    }
916}
917
918fn read_buf(buf: &Arc<Mutex<String>>) -> Result<String, String> {
919    Ok(buf
920        .lock()
921        .map_err(|e| format!("gendoc: buffer lock (read): {e}"))?
922        .clone())
923}
924
925/// Extract `__gendoc_exit` from a Lua error raised by the `os.exit`
926/// override. Returns `None` for unrelated errors.
927fn extract_exit_code(err: &mlua::Error) -> Option<i64> {
928    // `error(tbl, 0)` in Lua becomes `mlua::Error::RuntimeError`
929    // where the tostring serialization contains the table pointer
930    // (not useful) plus any __tostring metamethod output. For our
931    // structured exit we don't set __tostring, so the surfaced
932    // message may look like `table: 0x...`. That is not reliable.
933    //
934    // Instead, walk the error chain looking for a CallbackError /
935    // WithContext that wraps the raw value. mlua exposes the raw
936    // table via `Error::CallbackError::cause`. The Lua table itself
937    // is not exposed from `RuntimeError`, so we fall back to
938    // pattern-matching the string form as a best-effort:
939    // Lua's `error({__gendoc_exit = N}, 0)` with a table whose
940    // `__tostring` is unset renders as the table pointer, which
941    // loses the code.
942    //
943    // To make this robust we attach a __tostring metamethod to the
944    // raised table in the hook script so the error message embeds
945    // the code. See HOOK_SCRIPT.
946    let msg = err.to_string();
947    // Look for the marker substring emitted by __tostring (installed
948    // in HOOK_SCRIPT) in the format `__gendoc_exit=<N>`.
949    let needle = EXIT_MARKER;
950    let idx = msg.find(needle)?;
951    let rest = &msg[idx + needle.len()..];
952    // Skip any `=` / `:` / whitespace, then parse an integer.
953    let digits_start = rest
954        .char_indices()
955        .find(|(_, c)| c.is_ascii_digit() || *c == '-')
956        .map(|(i, _)| i)?;
957    let tail = &rest[digits_start..];
958    let digits_end = tail
959        .char_indices()
960        .find(|(_, c)| !c.is_ascii_digit() && *c != '-')
961        .map(|(i, _)| i)
962        .unwrap_or(tail.len());
963    tail[..digits_end].parse::<i64>().ok()
964}
965
966fn build_response_json(
967    source_dir: &str,
968    out_dir: &str,
969    stdout_txt: &str,
970    stderr_txt: &str,
971    warnings: &[String],
972) -> String {
973    // Keep dependency-free — we already depend on serde_json
974    // transitively but using it here avoids hand-rolled escaping
975    // bugs. Every shipped string is a plain `String` so
976    // `serde_json::Value::String` is fine.
977    let value = serde_json::json!({
978        "source_dir": source_dir,
979        "out_dir": out_dir,
980        "stdout": stdout_txt,
981        "stderr": stderr_txt,
982        "warnings": warnings,
983    });
984    value.to_string()
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990
991    #[test]
992    fn projection_flags_defaults_are_false() {
993        let f = ProjectionFlags::from_list(None).expect("projection parse");
994        assert!(!f.hub);
995        assert!(!f.context7);
996        assert!(!f.devin);
997        assert!(!f.lint);
998        assert!(!f.lint_only);
999    }
1000
1001    #[test]
1002    fn projection_flags_parse_known_tokens() {
1003        let list = vec![
1004            "hub".to_string(),
1005            "context7".to_string(),
1006            "devin".to_string(),
1007        ];
1008        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1009        assert!(f.hub);
1010        assert!(f.context7);
1011        assert!(f.devin);
1012        assert!(!f.lint);
1013    }
1014
1015    #[test]
1016    fn projection_flags_lint_only_implies_lint() {
1017        let list = vec!["lint_only".to_string()];
1018        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1019        assert!(f.lint);
1020        assert!(f.lint_only);
1021    }
1022
1023    #[test]
1024    fn projection_flags_luacats_parses() {
1025        let list = vec!["luacats".to_string()];
1026        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1027        assert!(f.luacats);
1028        assert!(!f.hub);
1029        assert!(!f.lint);
1030    }
1031
1032    #[test]
1033    fn projection_flags_unknown_is_rejected() {
1034        let list = vec!["nope".to_string(), "hub".to_string()];
1035        let err = ProjectionFlags::from_list(Some(&list)).expect_err("must reject unknown");
1036        assert!(err.contains("unknown projection"));
1037    }
1038
1039    #[test]
1040    fn projection_flags_narrative_and_llms_parse() {
1041        // narrative and llms are accepted as valid projections.
1042        // On the gen_docs.lua side these are unconditionally emitted when
1043        // lint_only=false, so the Rust flags act only as an allowlist gate
1044        // (approach A: no argv is pushed to gen_docs.lua for these).
1045        let list = vec!["narrative".to_string(), "llms".to_string()];
1046        let f = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1047        assert!(f.narrative, "narrative flag must be set");
1048        assert!(f.llms, "llms flag must be set");
1049        assert!(!f.hub, "hub must remain false");
1050        assert!(!f.lint, "lint must remain false");
1051    }
1052
1053    #[test]
1054    fn context7_without_config_is_rejected() {
1055        // Build a minimal AppService through the public API is
1056        // expensive; exercise the input validation logic through
1057        // `ProjectionFlags` + an explicit mirror of the early
1058        // return in `hub_gendoc`. Directly calling `hub_gendoc`
1059        // would require a full test fixture — covered in e2e.
1060        let list = vec!["context7".to_string()];
1061        let flags = ProjectionFlags::from_list(Some(&list)).expect("projection parse");
1062        assert!(flags.context7);
1063        // Simulate the guard:
1064        let err_expected =
1065            "gendoc: config_path is required when projections include context7 or devin";
1066        let err = if (flags.context7 || flags.devin) && Option::<&str>::None.is_none() {
1067            Some(err_expected.to_string())
1068        } else {
1069            None
1070        };
1071        assert_eq!(err.as_deref(), Some(err_expected));
1072    }
1073
1074    #[test]
1075    fn extract_exit_code_parses_marker_formats() {
1076        // Simulated error string; `extract_exit_code` doesn't care
1077        // about the prefix as long as the `__gendoc_exit=N` marker is
1078        // present.
1079        let err = mlua::Error::RuntimeError(
1080            "runtime error: [string \"...\"]:2: {__gendoc_exit=2}".to_string(),
1081        );
1082        assert_eq!(extract_exit_code(&err), Some(2));
1083
1084        let err = mlua::Error::RuntimeError("runtime error: __gendoc_exit: 0 (clean)".to_string());
1085        assert_eq!(extract_exit_code(&err), Some(0));
1086    }
1087
1088    #[test]
1089    fn extract_exit_code_returns_none_for_unrelated_errors() {
1090        let err = mlua::Error::RuntimeError("some other Lua error".to_string());
1091        assert!(extract_exit_code(&err).is_none());
1092    }
1093
1094    #[test]
1095    fn strip_shebang_removes_first_line_when_prefixed() {
1096        let src = "#!/usr/bin/env lua\nreturn 1\n";
1097        assert_eq!(strip_shebang(src), "return 1\n");
1098    }
1099
1100    #[test]
1101    fn strip_shebang_preserves_source_without_shebang() {
1102        let src = "-- no shebang\nreturn 1\n";
1103        assert_eq!(strip_shebang(src), src);
1104    }
1105
1106    #[test]
1107    fn strip_shebang_handles_shebang_only_without_trailing_newline() {
1108        let src = "#!/usr/bin/env lua";
1109        assert_eq!(strip_shebang(src), "");
1110    }
1111
1112    #[test]
1113    fn build_response_json_round_trips() {
1114        let out = build_response_json("/src", "/src/docs", "hi", "warn", &[]);
1115        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1116        assert_eq!(parsed["source_dir"], "/src");
1117        assert_eq!(parsed["out_dir"], "/src/docs");
1118        assert_eq!(parsed["stdout"], "hi");
1119        assert_eq!(parsed["stderr"], "warn");
1120        assert_eq!(parsed["warnings"], serde_json::json!([]));
1121    }
1122
1123    #[test]
1124    fn build_response_json_includes_warnings() {
1125        let warnings = vec![
1126            "pkg foo: alc_shapes_compat not declared, continuing with current alc_shapes@0.25.1"
1127                .to_string(),
1128        ];
1129        let out = build_response_json("/src", "/src/docs", "", "", &warnings);
1130        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1131        assert_eq!(parsed["warnings"][0], warnings[0].as_str());
1132    }
1133
1134    // ── alc_shapes version resolver unit tests ────────────────────────
1135
1136    #[test]
1137    fn extract_m_version_parses_standard_format() {
1138        let src = r#"local M = {}
1139M.VERSION = "0.25.1"
1140"#;
1141        assert_eq!(extract_m_version(src).as_deref(), Some("0.25.1"));
1142    }
1143
1144    #[test]
1145    fn extract_m_version_tolerates_no_space_around_eq() {
1146        let src = r#"M.VERSION="1.2.3""#;
1147        assert_eq!(extract_m_version(src).as_deref(), Some("1.2.3"));
1148    }
1149
1150    #[test]
1151    fn extract_m_version_tolerates_leading_whitespace() {
1152        let src = r#"  M.VERSION = "9.9.9"  "#;
1153        assert_eq!(extract_m_version(src).as_deref(), Some("9.9.9"));
1154    }
1155
1156    #[test]
1157    fn extract_m_version_returns_none_when_absent() {
1158        let src = r#"local M = {}
1159return M
1160"#;
1161        assert!(extract_m_version(src).is_none());
1162    }
1163
1164    #[test]
1165    fn check_mirror_shapes_version_ok_when_source_dir_none() {
1166        assert!(check_mirror_shapes_version(None).is_ok());
1167    }
1168
1169    #[test]
1170    fn check_mirror_shapes_version_ok_when_no_mirror_file() {
1171        // A tempdir with no alc_shapes/ subdirectory.
1172        let tmp = tempfile::tempdir().expect("tempdir");
1173        let dir = tmp.path().to_str().expect("utf-8").to_string();
1174        assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
1175    }
1176
1177    #[test]
1178    fn check_mirror_shapes_version_ok_on_version_match() {
1179        let tmp = tempfile::tempdir().expect("tempdir");
1180        let alc_dir = tmp.path().join("alc_shapes");
1181        std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1182        let init = alc_dir.join("init.lua");
1183        std::fs::write(
1184            &init,
1185            format!(
1186                "local M = {{}}\nM.VERSION = \"{}\"\nreturn M\n",
1187                EMBEDDED_ALC_SHAPES_VERSION
1188            ),
1189        )
1190        .expect("write init.lua");
1191        let dir = tmp.path().to_str().expect("utf-8").to_string();
1192        assert!(check_mirror_shapes_version(Some(&dir)).is_ok());
1193    }
1194
1195    #[test]
1196    fn check_mirror_shapes_version_err_on_version_mismatch() {
1197        let tmp = tempfile::tempdir().expect("tempdir");
1198        let alc_dir = tmp.path().join("alc_shapes");
1199        std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1200        let init = alc_dir.join("init.lua");
1201        std::fs::write(&init, "local M = {}\nM.VERSION = \"9.9.9\"\nreturn M\n")
1202            .expect("write init.lua");
1203        let dir = tmp.path().to_str().expect("utf-8").to_string();
1204        let err =
1205            check_mirror_shapes_version(Some(&dir)).expect_err("must fail on version mismatch");
1206        let msg = err.to_string();
1207        assert!(
1208            msg.contains(EMBEDDED_ALC_SHAPES_VERSION),
1209            "embedded ver in msg: {msg}"
1210        );
1211        assert!(msg.contains("9.9.9"), "mirror ver in msg: {msg}");
1212        assert!(msg.contains("CHANGELOG"), "hint in msg: {msg}");
1213    }
1214
1215    #[test]
1216    fn check_mirror_shapes_version_err_on_malformed() {
1217        let tmp = tempfile::tempdir().expect("tempdir");
1218        let alc_dir = tmp.path().join("alc_shapes");
1219        std::fs::create_dir_all(&alc_dir).expect("mkdir alc_shapes");
1220        let init = alc_dir.join("init.lua");
1221        std::fs::write(&init, "-- no version here\nreturn {}\n").expect("write init.lua");
1222        let dir = tmp.path().to_str().expect("utf-8").to_string();
1223        let err = check_mirror_shapes_version(Some(&dir)).expect_err("must fail on malformed");
1224        let msg = err.to_string();
1225        assert!(msg.contains("no parseable"), "malformed msg: {msg}");
1226    }
1227
1228    /// Regression harness: vendored `alc_shapes` must satisfy the
1229    /// contracts exercised by `tools/docs/projections.lua` (sorted
1230    /// `S.fields`, `prim` / `elem` / `val` / `doc` keys). Catches the
1231    /// class of drift that collapsed bundled `llms-full.txt` generation.
1232    #[test]
1233    fn embedded_gendoc_shapes_contract_harness() {
1234        let lua = Lua::new();
1235        register_preloads(&lua).expect("register_preloads");
1236
1237        let script = r#"
1238            local S = require("alc_shapes")
1239            local T = require("alc_shapes.t")
1240            local P = require("tools.docs.projections")
1241
1242            local shape = T.shape({
1243                task = T.string:describe("Problem"),
1244                n = T.number:is_optional(),
1245            })
1246            local entries = S.fields(shape)
1247            assert(#entries == 2, "expected two fields")
1248            assert(entries[1].name == "n" and entries[1].optional == true)
1249            assert(entries[2].name == "task" and entries[2].optional == false)
1250            assert(entries[2].doc == "Problem")
1251            assert(P.shape_type_string(entries[2].type) == "string")
1252
1253            assert(P.shape_type_string(T.array_of(T.string)) == "array of string")
1254            assert(P.shape_type_string(T.map_of(T.string, T.number)) == "map of string to number")
1255
1256            local inner = T.shape({ flag = T.boolean })
1257            assert(P.shape_type_string(inner) == "shape { flag: boolean }")
1258        "#;
1259
1260        lua.load(script)
1261            .set_name("@test/embedded_gendoc_shapes_contract.lua")
1262            .exec()
1263            .expect("embedded shapes contract harness");
1264    }
1265
1266    /// Vendored `alc_shapes` must include the full shape registry so
1267    /// `projections.shape_type_string(T.ref("voted"))` resolves via the
1268    /// embedded `alc_shapes` module (no disk fallback required).
1269    #[test]
1270    fn vendored_alc_shapes_resolves_pkg_refs() {
1271        let lua = Lua::new();
1272        register_preloads(&lua).expect("register_preloads");
1273
1274        let script = r#"
1275            local S = require("alc_shapes")
1276            assert(type(S.voted) == "table" and rawget(S.voted, "kind") == "shape")
1277            local T = require("alc_shapes.t")
1278            local P = require("tools.docs.projections")
1279            assert(P.shape_type_string(T.ref("voted")) == "voted")
1280        "#;
1281
1282        lua.load(script)
1283            .set_name("@test/vendored_alc_shapes_ref.lua")
1284            .exec()
1285            .expect("vendored alc_shapes ref resolution");
1286    }
1287
1288    // ── alc_shapes_compat extraction / dispatcher unit tests ──────────
1289
1290    #[test]
1291    fn extract_quoted_value_finds_marker() {
1292        let src = r#"M.meta.alc_shapes_compat = ">=0.25.0, <0.26""#;
1293        assert_eq!(
1294            extract_quoted_value(src, "alc_shapes_compat"),
1295            Some(">=0.25.0, <0.26")
1296        );
1297    }
1298
1299    #[test]
1300    fn extract_quoted_value_returns_none_when_absent() {
1301        let src = "local M = {}\nreturn M\n";
1302        assert!(extract_quoted_value(src, "alc_shapes_compat").is_none());
1303    }
1304
1305    #[test]
1306    fn extract_m_meta_compat_finds_field() {
1307        let src = r#"M.meta = { alc_shapes_compat = ">=0.25.0, <0.26", name = "pkg" }"#;
1308        assert_eq!(extract_m_meta_compat(src), Some(">=0.25.0, <0.26"));
1309    }
1310
1311    #[test]
1312    fn extract_m_meta_compat_returns_none_when_absent() {
1313        let src = r#"M.meta = { name = "pkg", version = "0.1.0" }"#;
1314        assert!(extract_m_meta_compat(src).is_none());
1315    }
1316
1317    #[test]
1318    fn check_pkg_compat_warns_on_undeclared() {
1319        let tmp = tempfile::tempdir().expect("tempdir");
1320        let pkg_dir = tmp.path().join("pkg_foo");
1321        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_foo");
1322        std::fs::write(
1323            pkg_dir.join("init.lua"),
1324            "local M = {}\nM.meta = { name = 'pkg_foo' }\nreturn M\n",
1325        )
1326        .expect("write init.lua");
1327
1328        let result = check_pkg_compat(tmp.path().to_str().expect("utf-8")).expect("no error");
1329        assert_eq!(result.len(), 1);
1330        assert!(
1331            result[0].contains("alc_shapes_compat not declared"),
1332            "expected undeclared warning, got: {}",
1333            result[0]
1334        );
1335    }
1336
1337    #[test]
1338    fn check_pkg_compat_ok_on_in_range() {
1339        let tmp = tempfile::tempdir().expect("tempdir");
1340        let pkg_dir = tmp.path().join("pkg_bar");
1341        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_bar");
1342        // 0.25.1 is in >=0.25.0, <0.26 — note: double-quoted Lua strings
1343        std::fs::write(
1344            pkg_dir.join("init.lua"),
1345            "local M = {}\nM.meta = { name = \"pkg_bar\", alc_shapes_compat = \">=0.25.0, <0.26\" }\nreturn M\n",
1346        )
1347        .expect("write init.lua");
1348
1349        let result = check_pkg_compat(tmp.path().to_str().expect("utf-8")).expect("no error");
1350        assert!(result.is_empty(), "expected no warnings for in-range pkg");
1351    }
1352
1353    #[test]
1354    fn check_pkg_compat_err_on_out_of_range() {
1355        let tmp = tempfile::tempdir().expect("tempdir");
1356        let pkg_dir = tmp.path().join("pkg_baz");
1357        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_baz");
1358        // 0.25.1 is NOT in >=0.26.0, <0.27
1359        std::fs::write(
1360            pkg_dir.join("init.lua"),
1361            "local M = {}\nM.meta = { name = \"pkg_baz\", alc_shapes_compat = \">=0.26.0, <0.27\" }\nreturn M\n",
1362        )
1363        .expect("write init.lua");
1364
1365        let err = check_pkg_compat(tmp.path().to_str().expect("utf-8"))
1366            .expect_err("must fail on out-of-range");
1367        let msg = err.to_string();
1368        assert!(msg.contains("pkg_baz"), "pkg_name in error: {msg}");
1369        assert!(msg.contains(">=0.26.0, <0.27"), "range in error: {msg}");
1370        assert!(
1371            msg.contains(EMBEDDED_ALC_SHAPES_VERSION),
1372            "version in error: {msg}"
1373        );
1374        assert!(
1375            msg.contains("ShapesCompatViolation") || msg.contains("does not match"),
1376            "violation in error: {msg}"
1377        );
1378    }
1379
1380    // ── inject_config_preloads unit tests ────────────────────────────
1381
1382    #[test]
1383    fn inject_config_preloads_lua_rejected() {
1384        let tmp = tempfile::tempdir().expect("tempdir");
1385        let cfg = tmp.path().join("config.lua");
1386        std::fs::write(&cfg, "return {}").expect("write config.lua");
1387
1388        let lua = Lua::new();
1389        let err = inject_config_preloads(&lua, cfg.to_str().expect("utf-8"))
1390            .expect_err("must fail for .lua extension");
1391        assert!(
1392            err.contains("'.lua' is no longer supported"),
1393            "expected '.lua' is no longer supported in: {err}"
1394        );
1395    }
1396
1397    #[test]
1398    fn inject_config_preloads_unknown_extension() {
1399        let tmp = tempfile::tempdir().expect("tempdir");
1400        let cfg = tmp.path().join("config.yaml");
1401        std::fs::write(&cfg, "context7:\n  projectTitle: x\n").expect("write config.yaml");
1402
1403        let lua = Lua::new();
1404        let err = inject_config_preloads(&lua, cfg.to_str().expect("utf-8"))
1405            .expect_err("must fail on unknown extension");
1406        assert!(
1407            err.contains("unsupported extension (expected .toml)"),
1408            "expected 'unsupported extension (expected .toml)' in: {err}"
1409        );
1410    }
1411
1412    #[test]
1413    fn check_pkg_compat_err_on_malformed_range() {
1414        let tmp = tempfile::tempdir().expect("tempdir");
1415        let pkg_dir = tmp.path().join("pkg_qux");
1416        std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg_qux");
1417        std::fs::write(
1418            pkg_dir.join("init.lua"),
1419            "local M = {}\nM.meta = { name = \"pkg_qux\", alc_shapes_compat = \"not a semver range\" }\nreturn M\n",
1420        )
1421        .expect("write init.lua");
1422
1423        let err = check_pkg_compat(tmp.path().to_str().expect("utf-8"))
1424            .expect_err("must fail on malformed range");
1425        let msg = err.to_string();
1426        assert!(msg.contains("pkg_qux"), "pkg_name in error: {msg}");
1427        assert!(msg.contains("not a semver range"), "value in error: {msg}");
1428        assert!(
1429            msg.contains("Malformed") || msg.contains("valid semver"),
1430            "malformed label in error: {msg}"
1431        );
1432    }
1433}