flux-platform 1.0.1

A local-first, AI-native developer automation platform: build, test, package, and deploy from a single .flux file, and make your repository legible to AI agents.
//! The documentation engine (`flux docs`).
//!
//! Rather than *write prose with a model*, Flux keeps the mechanical, always-
//! true parts of the docs generated from live sources: the command reference
//! from the actual clap command tree, the agent catalogue from the agent
//! registry, and a machine-readable `manifest.json` that the separate `flux-web`
//! site consumes. `--check` fails when any of these drift, so CI can guard it.
//!
//! Hand-authored docs (architecture, guides) are never touched — the engine only
//! owns the files it fully generates, each stamped with a "generated" header.

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";

/// A file the engine fully owns.
pub struct GenFile {
    pub path: PathBuf,
    pub content: String,
}

/// Compute every generated file's desired content (without touching disk).
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(),
        },
    ]
}

/// Write all generated files, returning the paths written.
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)
}

/// Normalize line endings before comparing generated content to what's on disk.
///
/// The generator always emits `\n`, but a Windows checkout with
/// `core.autocrlf=true` (Git for Windows' installer default) rewrites every
/// tracked text file to `\r\n`. A byte-for-byte comparison then reports every
/// generated file as "out of sync" even though the committed content is
/// identical — `flux docs --check` was unusable for Windows contributors.
/// Line endings are not content, so they're normalized away here.
fn normalize_eol(s: &str) -> String {
    s.replace("\r\n", "\n")
}

/// Return the generated files whose on-disk content is missing or stale.
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()
}

/// Render the command reference from the real clap command tree.
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
}

/// Render the agent catalogue from the registry.
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
}

/// The machine-readable feed for the website: commands + agents.
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();

        // Before writing, everything is out of sync.
        assert_eq!(check(&dir).len(), 3);

        write(&dir).unwrap();
        assert!(check(&dir).is_empty());

        // The command reference reflects real subcommands.
        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());

        // Simulate what `core.autocrlf=true` does to the working tree on a
        // Windows checkout: every LF becomes CRLF. That must not read as drift.
        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"
        );

        // A real content change still is drift.
        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);
    }
}