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 -->"),
)
}
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
}
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; }
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)
}
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))
}
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());
let stale = refresh_page(&page, &blocks, true).unwrap();
assert!(stale.is_some());
assert!(std::fs::read_to_string(&page).unwrap().contains("old"));
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"));
assert!(refresh_page(&page, &blocks, true).unwrap().is_none());
}
}