sofie-cli 0.4.0

The `sofie` command: gen, check and the tools reading the app model.
Documentation
//! Committed, so a change to the architecture shows as a diff beside the code that caused it.

mod architecture;
mod data_model;
mod events;
mod flow_page;
mod markdown;
mod module_page;
mod security;
mod telemetry;

use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use sofie_model::App;

use crate::gen_command::{GenError, GenMode, GenOutcome, checked_model, io_error, sync_file};
use crate::sorted;

pub(crate) const DOCS_HEADER: &str = "<!-- @generated by `sofie docs`; do not edit. -->\n\n";

pub fn write_docs(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
    let app = checked_model(root)?;
    sync_docs(&app, mode)
}

pub(crate) fn sync_docs(app: &App, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
    let pages = render_docs(app);
    let mut files: Vec<(PathBuf, Option<String>)> = generated_pages(&docs_directory(app))?
        .into_iter()
        .filter(|existing| !pages.iter().any(|(path, _)| path == existing))
        .map(|existing| (existing, None))
        .collect();
    files.extend(pages.into_iter().map(|(path, text)| (path, Some(text))));
    let mut outcomes = Vec::new();
    for (path, text) in files {
        outcomes.extend(sync_file(&path, text, mode)?);
    }
    Ok(outcomes)
}

pub(crate) fn keeps_docs(app: &App) -> bool {
    docs_directory(app).is_dir()
}

fn docs_directory(app: &App) -> PathBuf {
    app.root.join("docs").join("gen")
}

pub(crate) fn render_docs(app: &App) -> Vec<(PathBuf, String)> {
    let directory = docs_directory(app);
    let mut pages = vec![
        (directory.join("README.md"), index(app)),
        (
            directory.join("architecture.md"),
            architecture::architecture(app),
        ),
        (directory.join("flows.md"), flow_page::flow_page(app)),
        (directory.join("security.md"), security::security(app)),
        (directory.join("telemetry.md"), telemetry::telemetry(app)),
    ];
    for module in sorted::modules(app) {
        pages.push((
            directory
                .join("modules")
                .join(format!("{}.md", module.krate.crate_name)),
            module_page::module_page(app, module),
        ));
    }
    pages
        .into_iter()
        .map(|(path, text)| (path, format!("{DOCS_HEADER}{text}")))
        .collect()
}

fn index(app: &App) -> String {
    let mut text = format!(
        "# {}\n\nGenerated from the code by `sofie docs`; `sofie check` reports it stale when the \
         code changes.\n\n- [Architecture](architecture.md): modules, tiers, areas and contracts\n\
         - [Request flows](flows.md): endpoint → action → method → event → listener → job\n\
         - [Security surface](security.md): who reaches each route, outgoing hosts, secrets\n\
         - [Telemetry](telemetry.md): spans, metrics, product events and masked fields\n",
        app.app.package
    );
    for module in sorted::modules(app) {
        let name = &module.krate.crate_name;
        let _ = writeln!(text, "- [Module `{name}`](modules/{name}.md)");
    }
    text
}

fn generated_pages(directory: &Path) -> Result<Vec<PathBuf>, GenError> {
    let entries = match fs::read_dir(directory) {
        Ok(entries) => entries,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(source) => return Err(io_error(directory, source)),
    };
    let mut pages = Vec::new();
    for entry in entries {
        let path = entry.map_err(|source| io_error(directory, source))?.path();
        if path.is_dir() {
            pages.extend(generated_pages(&path)?);
        } else if path.extension().is_some_and(|extension| extension == "md")
            && fs::read_to_string(&path)
                .map_err(|source| io_error(&path, source))?
                .starts_with(DOCS_HEADER)
        {
            pages.push(path);
        }
    }
    pages.sort();
    Ok(pages)
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use sofie_model::testing::{aggregate, app, area, field, function, module, route};
    use sofie_model::{Call, Gateway, GatewayAdapter, Method, Modifier, ProductEvent};

    use super::{module_page, security, telemetry};

    #[test]
    fn a_module_page_lists_the_checks_across_owners_by_their_callers() {
        let mut scheduling = module("scheduling", "shop/modules/scheduling");
        let file = PathBuf::from("shop/modules/scheduling/src/actions.rs");
        scheduling
            .code
            .aggregates
            .push(aggregate("Booking", Vec::new(), &file));
        let mut book = function("actions::book_slot", &file, 20);
        book.effects.calls.push(Call {
            path: vec!["Booking".to_owned(), "across".to_owned()],
            line: 24,
        });
        scheduling.code.functions.push(book);
        let shop = app("shop", vec![scheduling]);

        let page = module_page::module_page(&shop, &shop.modules[0]);

        assert!(
            page.contains("## Cross-owner checks\n\nWhether records exist across every owner, audited as `cross_owner_check`:\n\n- `Booking` in `actions::book_slot`\n"),
            "{page}"
        );
    }

    #[test]
    fn telemetry_and_security_pages_name_what_the_model_reads() {
        let mut payment = module("payment", "shop/modules/payment");
        let mut pay = route(Method::Post, "/pay", "actions::pay", 3);
        pay.modifiers.push(Modifier::Throttle("10/m".to_owned()));
        let mut hook = route(Method::Post, "/hooks/stripe", "actions::stripe_hook", 4);
        hook.webhook = Some("verify_stripe".to_owned());
        let mut receipt = route(Method::Get, "/receipt", "queries::receipt", 5);
        receipt.screen = Some("receipt".to_owned());
        payment
            .areas
            .push(area(&payment, "web", vec![pay, hook, receipt]));
        let file = PathBuf::from("shop/modules/payment/src/gateways.rs");
        payment.gateways.push(Gateway {
            name: "payments".to_owned(),
            path: vec!["Payments".to_owned()],
            adapters: vec![GatewayAdapter {
                ty: vec!["Stripe".to_owned()],
                is_adapter: true,
                vendor: Some("stripe".to_owned()),
                settings: Vec::new(),
                file: file.clone(),
                line: 9,
            }],
            file: file.clone(),
            line: 2,
        });
        payment.code.aggregates.push(aggregate(
            "Payer",
            vec![
                field("tax_number", "Option<TaxNumber>", 4),
                field("iban", "String", 5),
            ],
            &file,
        ));
        let telemetry_code = &mut payment.code.telemetry;
        telemetry_code.sensitive_values.push("TaxNumber".to_owned());
        telemetry_code
            .sensitive_fields
            .push(("Payer".to_owned(), "iban".to_owned()));
        telemetry_code
            .steps
            .push(vec!["actions".to_owned(), "charge".to_owned()]);
        telemetry_code.product_events.push(ProductEvent {
            name: "payment.paid".to_owned(),
            values: vec!["payment.total".to_owned()],
            file: PathBuf::from("shop/modules/payment/src/actions.rs"),
            line: 12,
        });
        let shop = app("shop", vec![payment]);

        let security = security::security(&shop);
        let telemetry = telemetry::telemetry(&shop);

        for expected in [
            "| POST | `/pay` | `web` | anyone | — | any | `10/m` | same origin (CSRF) |",
            "signature by `verify_stripe`, no session",
            "| `stripe` | `payment`'s `Payments` | `PAYMENT_STRIPE_BASE_URL`, `PAYMENT_STRIPE_HOSTS`",
            "- POST `/hooks/stripe`",
        ] {
            assert!(security.contains(expected), "{expected}\n{security}");
        }
        for expected in [
            "| `payment::actions::charge` | step |",
            "| `payment/web/receipt` | render | `payment.web.receipt` |",
            "request --> render",
            "| `stripe.<operation>` | gateway | a call of `payment`'s `Payments` or an `Outgoing::operation` of `stripe` |",
            "| `payment.paid` | `total` | modules/payment/src/actions.rs:12 |",
            "| `payment::Payer` | `tax_number` | `Option<TaxNumber>` |",
            "| `payment::Payer` | `iban` | `String` |",
            "| `product.events` | counter | `product.event` | `payment.paid` |",
        ] {
            assert!(telemetry.contains(expected), "{expected}\n{telemetry}");
        }
    }
}