memlay 0.1.4

Repo-native, conflict-resistant shared memory and codebase navigation layer for AI coding agents
//! Generated reference documentation. The binary is the single source of
//! truth: the CLI reference renders from the live clap tree, the MCP section
//! from the actual tool schemas, config defaults from `Config::default()`,
//! error codes from `ErrorCode::ALL`, and the module inventory from the
//! repository's own map plus its `architecture` memory records.
//!
//! Content lands inside marked blocks (`<!-- memlay-ref:<name>:start -->`)
//! in existing markdown pages, so hand-written prose around the blocks is
//! never touched — the same marker mechanism used for AGENTS.md/CLAUDE.md.
//! `--check` regenerates and diffs instead of writing, for CI.

use crate::cli::App;
use crate::errors::{err, ErrorCode};
use anyhow::Result;
use std::collections::BTreeMap;
use std::fmt::Write as _;

fn markers(name: &str) -> (String, String) {
    (
        format!("<!-- memlay-ref:{name}:start -->"),
        format!("<!-- memlay-ref:{name}:end -->"),
    )
}

// ------------------------------------------------------------ renderers ----

fn render_cli() -> String {
    let cmd = crate::cli_command();
    let mut out = String::from("Generated from the live command tree. Do not edit by hand.\n");
    fn write_args(out: &mut String, cmd: &clap::Command) {
        for arg in cmd.get_arguments() {
            if arg.get_id() == "help" || arg.get_id() == "version" {
                continue;
            }
            let long = arg
                .get_long()
                .map(|l| format!("--{l}"))
                .unwrap_or_else(|| format!("<{}>", arg.get_id()));
            let default = arg
                .get_default_values()
                .first()
                .map(|d| format!(" (default: {})", d.to_string_lossy()))
                .unwrap_or_default();
            let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default();
            let _ = writeln!(out, "  - `{long}` — {help}{default}");
        }
    }
    fn write_cmd(out: &mut String, cmd: &clap::Command, prefix: &str) {
        for sub in cmd.get_subcommands() {
            if sub.get_name() == "help" {
                continue;
            }
            let name = if prefix.is_empty() {
                sub.get_name().to_string()
            } else {
                format!("{prefix} {}", sub.get_name())
            };
            let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
            let _ = writeln!(out, "\n### `memlay {name}`\n\n{about}\n");
            write_args(out, sub);
            write_cmd(out, sub, &name);
        }
    }
    let _ = writeln!(out, "\n## Global options\n");
    write_args(&mut out, &cmd);
    let _ = writeln!(out, "\n## Commands");
    write_cmd(&mut out, &cmd, "");
    out
}

fn render_mcp() -> String {
    let mut out =
        String::from("Generated from the server's actual tool schemas. Do not edit by hand.\n");
    let defs = crate::mcp::tool_definitions();
    for tool in defs.as_array().into_iter().flatten() {
        let name = tool["name"].as_str().unwrap_or("?");
        let desc = tool["description"].as_str().unwrap_or("");
        let _ = writeln!(out, "\n### `{name}`\n\n{desc}\n");
        let required: Vec<&str> = tool["inputSchema"]["required"]
            .as_array()
            .into_iter()
            .flatten()
            .filter_map(|v| v.as_str())
            .collect();
        if let Some(props) = tool["inputSchema"]["properties"].as_object() {
            let _ = writeln!(out, "| input | type | required | description |");
            let _ = writeln!(out, "|---|---|---|---|");
            for (key, schema) in props {
                let ty = schema["type"]
                    .as_str()
                    .map(String::from)
                    .or_else(|| {
                        schema["enum"].as_array().map(|e| {
                            e.iter()
                                .filter_map(|v| v.as_str())
                                .collect::<Vec<_>>()
                                .join(" \\| ")
                        })
                    })
                    .unwrap_or_else(|| "object".into());
                let req = if required.contains(&key.as_str()) {
                    "yes"
                } else {
                    ""
                };
                let d = schema["description"].as_str().unwrap_or("");
                let _ = writeln!(out, "| `{key}` | {ty} | {req} | {d} |");
            }
        }
    }
    let _ = writeln!(
        out,
        "\n### Server instructions\n\n> {}",
        crate::mcp::SERVER_INSTRUCTIONS
    );
    out
}

fn render_config() -> String {
    format!(
        "Generated from `Config::default()`. Do not edit by hand.\n\n```toml\n{}```\n",
        crate::config::Config::default_toml()
    )
}

fn render_errors() -> String {
    let mut out =
        String::from("Generated from the stable error-code enum. Do not edit by hand.\n\n");
    for code in ErrorCode::ALL {
        let _ = writeln!(out, "- `{}`", code.as_str());
    }
    out
}

/// Module inventory from the repository's own map. Counts are deliberately
/// omitted so routine code changes do not churn the committed block; the
/// interesting content is the purpose, which prefers `architecture` records.
fn render_modules(app: &App, index: &crate::index::Index) -> Result<String> {
    let modules = crate::mapview::collect(&app.repo, index, None, 2)?;
    let mut out = String::from(
        "Generated from the repository module map; purposes marked `::` come from architecture memory records, `:~` are derived from READMEs/manifests. Do not edit by hand.\n\n",
    );
    for m in modules.values() {
        if m.symbols == 0 && !m.path.starts_with("src") {
            continue; // keep the inventory to code-bearing modules
        }
        let purpose = match (&m.purpose, m.purpose_source) {
            (Some(p), Some("memory")) => format!(" :: {p}"),
            (Some(p), _) => format!(" :~ {p}"),
            _ => String::new(),
        };
        let _ = writeln!(out, "- `{}`{purpose}", m.path);
    }
    Ok(out)
}

// ------------------------------------------------------------ injection ----

fn refresh_page(
    path: &std::path::Path,
    blocks: &BTreeMap<&'static str, String>,
    check: bool,
) -> Result<Option<Vec<String>>> {
    let original = std::fs::read_to_string(path)?;
    let mut updated = original.clone();
    let mut touched: Vec<String> = Vec::new();
    for (name, content) in blocks {
        let (start, end) = markers(name);
        let (Some(s), Some(e)) = (updated.find(&start), updated.find(&end)) else {
            continue;
        };
        if e < s {
            return Err(err(
                ErrorCode::InvalidRecord,
                format!("{}: markers for '{name}' are out of order", path.display()),
            ));
        }
        let replacement = format!("{start}\n{}\n{end}", content.trim_end());
        let current = &updated[s..e + end.len()];
        if current != replacement {
            updated.replace_range(s..e + end.len(), &replacement);
            touched.push(name.to_string());
        }
    }
    if touched.is_empty() {
        return Ok(None);
    }
    if !check {
        std::fs::write(path, updated)?;
    }
    Ok(Some(touched))
}

/// Regenerate every marked reference block under `dir` (default `docs/`).
/// In check mode nothing is written; stale files fail the command.
pub fn run(app: &App, dir: Option<&str>, check: bool) -> Result<()> {
    let index = crate::index::update_all(&app.repo, &app.config)?;
    let mut blocks: BTreeMap<&'static str, String> = BTreeMap::new();
    blocks.insert("cli", render_cli());
    blocks.insert("mcp", render_mcp());
    blocks.insert("config", render_config());
    blocks.insert("errors", render_errors());
    blocks.insert("modules", render_modules(app, &index)?);

    let dir = app.repo.root.join(
        dir.unwrap_or("docs")
            .replace('/', std::path::MAIN_SEPARATOR_STR),
    );
    if !dir.is_dir() {
        return Err(err(
            ErrorCode::InvalidRecord,
            format!("{} does not exist; nothing to refresh", dir.display()),
        ));
    }
    let mut stale: Vec<String> = Vec::new();
    let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)?
        .filter_map(|e| e.ok().map(|e| e.path()))
        .filter(|p| p.extension().is_some_and(|e| e == "md"))
        .collect();
    entries.sort();
    for path in entries {
        if let Some(touched) = refresh_page(&path, &blocks, check)? {
            let name = path
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string();
            stale.push(format!("{name} ({})", touched.join(", ")));
        }
    }
    if app.json {
        println!(
            "{}",
            serde_json::json!({ "checked": check, "stale_or_updated": stale })
        );
    } else if stale.is_empty() {
        println!("reference blocks up to date");
    } else {
        for s in &stale {
            println!("{} {s}", if check { "STALE" } else { "updated" });
        }
    }
    if check && !stale.is_empty() {
        return Err(err(
            ErrorCode::InvalidRecord,
            format!(
                "{} page(s) have stale reference blocks; run 'memlay docs --reference' and commit",
                stale.len()
            ),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_reference_covers_every_subcommand() {
        let text = render_cli();
        let cmd = crate::cli_command();
        for sub in cmd.get_subcommands() {
            if sub.get_name() == "help" {
                continue;
            }
            assert!(
                text.contains(&format!("`memlay {}", sub.get_name())),
                "missing {} in generated CLI reference",
                sub.get_name()
            );
        }
        assert!(text.contains("--split"));
    }

    #[test]
    fn mcp_reference_covers_exactly_the_p0_tools() {
        let text = render_mcp();
        for tool in ["context", "expand", "record"] {
            assert!(text.contains(&format!("### `{tool}`")), "{text}");
        }
        assert!(text.contains("Server instructions"));
    }

    #[test]
    fn errors_reference_lists_all_codes() {
        let text = render_errors();
        for code in ErrorCode::ALL {
            assert!(text.contains(code.as_str()));
        }
    }

    #[test]
    fn marker_injection_preserves_prose_and_detects_staleness() {
        let tmp = tempfile::tempdir().unwrap();
        let page = tmp.path().join("x.md");
        std::fs::write(
            &page,
            "# Hand prose\n\nkeep me\n\n<!-- memlay-ref:errors:start -->\nold\n<!-- memlay-ref:errors:end -->\n\nand me\n",
        )
        .unwrap();
        let mut blocks: BTreeMap<&'static str, String> = BTreeMap::new();
        blocks.insert("errors", "new content".into());
        // Check mode reports staleness without writing.
        let stale = refresh_page(&page, &blocks, true).unwrap();
        assert!(stale.is_some());
        assert!(std::fs::read_to_string(&page).unwrap().contains("old"));
        // Write mode replaces only the block.
        refresh_page(&page, &blocks, false).unwrap();
        let text = std::fs::read_to_string(&page).unwrap();
        assert!(text.contains("keep me") && text.contains("and me"));
        assert!(text.contains("new content") && !text.contains("\nold\n"));
        // Now current: nothing to do.
        assert!(refresh_page(&page, &blocks, true).unwrap().is_none());
    }
}