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