use std::path::{Path, PathBuf};
use crate::knowledge::json::Json;
const GENERATED_HEADER: &str =
"<!-- Generated by `flux docs`. Do not edit by hand; run `flux docs` to refresh. -->\n\n";
pub struct GenFile {
pub path: PathBuf,
pub content: String,
}
pub fn generate_files(root: &Path) -> Vec<GenFile> {
let docs = root.join("docs");
vec![
GenFile {
path: docs.join("commands.md"),
content: command_reference(),
},
GenFile {
path: docs.join("agents.md"),
content: agent_reference(),
},
GenFile {
path: docs.join("manifest.json"),
content: manifest(),
},
]
}
pub fn write(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut written = Vec::new();
for f in generate_files(root) {
if let Some(parent) = f.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&f.path, &f.content)?;
written.push(f.path);
}
Ok(written)
}
fn normalize_eol(s: &str) -> String {
s.replace("\r\n", "\n")
}
pub fn check(root: &Path) -> Vec<PathBuf> {
generate_files(root)
.into_iter()
.filter(|f| match std::fs::read_to_string(&f.path) {
Ok(on_disk) => normalize_eol(&on_disk) != normalize_eol(&f.content),
Err(_) => true,
})
.map(|f| f.path)
.collect()
}
fn command_reference() -> String {
let cmd = crate::cli::clap_command();
let mut md = String::from(GENERATED_HEADER);
md.push_str("# Command reference\n\n");
md.push_str("Every Flux subcommand, generated from the CLI definition.\n\n");
for sub in cmd.get_subcommands() {
let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
md.push_str(&format!("### `flux {}`\n\n{about}\n\n", sub.get_name()));
let nested: Vec<_> = sub.get_subcommands().collect();
if !nested.is_empty() {
for n in nested {
let nabout = n.get_about().map(|a| a.to_string()).unwrap_or_default();
md.push_str(&format!(
"- `flux {} {}` — {nabout}\n",
sub.get_name(),
n.get_name()
));
}
md.push('\n');
}
}
md
}
fn agent_reference() -> String {
let mut md = String::from(GENERATED_HEADER);
md.push_str("# AI agents\n\n");
md.push_str(
"Flux agents are honest, offline analyzers that write structured reports to \
`.flux-cache/reports/`. With `ai.command` set in `flux.yaml`, each report is also \
expanded by an external model.\n\n",
);
for agent in crate::agents::registry() {
md.push_str(&format!(
"### `flux agent run {}`\n\n{}\n\n",
agent.name(),
agent.description()
));
}
md
}
fn manifest() -> String {
let cmd = crate::cli::clap_command();
let commands = cmd.get_subcommands().map(|s| {
Json::Object(vec![
("name".into(), Json::s(s.get_name())),
(
"about".into(),
Json::s(s.get_about().map(|a| a.to_string()).unwrap_or_default()),
),
])
});
let agents = crate::agents::registry().into_iter().map(|a| {
Json::Object(vec![
("name".into(), Json::s(a.name())),
("description".into(), Json::s(a.description())),
])
});
let doc = Json::Object(vec![
("tool".into(), Json::s("flux")),
("commands".into(), Json::array(commands)),
("agents".into(), Json::array(agents)),
]);
doc.pretty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_then_check_is_in_sync() {
let mut dir = std::env::temp_dir();
dir.push(format!("flux-docs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(check(&dir).len(), 3);
write(&dir).unwrap();
assert!(check(&dir).is_empty());
let commands = std::fs::read_to_string(dir.join("docs/commands.md")).unwrap();
assert!(commands.contains("flux build"));
assert!(commands.contains("flux agent"));
let manifest = std::fs::read_to_string(dir.join("docs/manifest.json")).unwrap();
assert!(manifest.contains("\"tool\": \"flux\""));
assert!(manifest.contains("maintenance"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn crlf_checkout_is_not_drift() {
let mut dir = std::env::temp_dir();
dir.push(format!("flux-docs-crlf-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
write(&dir).unwrap();
assert!(check(&dir).is_empty());
for f in generate_files(&dir) {
let crlf = f.content.replace('\n', "\r\n");
std::fs::write(&f.path, crlf).unwrap();
}
assert!(
check(&dir).is_empty(),
"CRLF line endings must not be reported as out-of-sync docs"
);
let agents = dir.join("docs/agents.md");
std::fs::write(&agents, "not the generated agent catalogue\n").unwrap();
assert_eq!(check(&dir), vec![agents]);
let _ = std::fs::remove_dir_all(&dir);
}
}