Skip to main content

sofie_cli/docs/
mod.rs

1//! Committed, so a change to the architecture shows as a diff beside the code that caused it.
2
3mod architecture;
4mod data_model;
5mod events;
6mod flow_page;
7mod markdown;
8mod module_page;
9mod security;
10mod telemetry;
11
12use std::fmt::Write as _;
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use sofie_model::App;
18
19use crate::gen_command::{GenError, GenMode, GenOutcome, checked_model, io_error, sync_file};
20use crate::sorted;
21
22pub(crate) const DOCS_HEADER: &str = "<!-- @generated by `sofie docs`; do not edit. -->\n\n";
23
24pub fn write_docs(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
25    let app = checked_model(root)?;
26    sync_docs(&app, mode)
27}
28
29pub(crate) fn sync_docs(app: &App, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
30    let pages = render_docs(app);
31    let mut files: Vec<(PathBuf, Option<String>)> = generated_pages(&docs_directory(app))?
32        .into_iter()
33        .filter(|existing| !pages.iter().any(|(path, _)| path == existing))
34        .map(|existing| (existing, None))
35        .collect();
36    files.extend(pages.into_iter().map(|(path, text)| (path, Some(text))));
37    let mut outcomes = Vec::new();
38    for (path, text) in files {
39        outcomes.extend(sync_file(&path, text, mode)?);
40    }
41    Ok(outcomes)
42}
43
44pub(crate) fn keeps_docs(app: &App) -> bool {
45    docs_directory(app).is_dir()
46}
47
48fn docs_directory(app: &App) -> PathBuf {
49    app.root.join("docs").join("gen")
50}
51
52pub(crate) fn render_docs(app: &App) -> Vec<(PathBuf, String)> {
53    let directory = docs_directory(app);
54    let mut pages = vec![
55        (directory.join("README.md"), index(app)),
56        (
57            directory.join("architecture.md"),
58            architecture::architecture(app),
59        ),
60        (directory.join("flows.md"), flow_page::flow_page(app)),
61        (directory.join("security.md"), security::security(app)),
62        (directory.join("telemetry.md"), telemetry::telemetry(app)),
63    ];
64    for module in sorted::modules(app) {
65        pages.push((
66            directory
67                .join("modules")
68                .join(format!("{}.md", module.krate.crate_name)),
69            module_page::module_page(app, module),
70        ));
71    }
72    pages
73        .into_iter()
74        .map(|(path, text)| (path, format!("{DOCS_HEADER}{text}")))
75        .collect()
76}
77
78fn index(app: &App) -> String {
79    let mut text = format!(
80        "# {}\n\nGenerated from the code by `sofie docs`; `sofie check` reports it stale when the \
81         code changes.\n\n- [Architecture](architecture.md): modules, tiers, areas and contracts\n\
82         - [Request flows](flows.md): endpoint → action → method → event → listener → job\n\
83         - [Security surface](security.md): who reaches each route, outgoing hosts, secrets\n\
84         - [Telemetry](telemetry.md): spans, metrics, product events and masked fields\n",
85        app.app.package
86    );
87    for module in sorted::modules(app) {
88        let name = &module.krate.crate_name;
89        let _ = writeln!(text, "- [Module `{name}`](modules/{name}.md)");
90    }
91    text
92}
93
94fn generated_pages(directory: &Path) -> Result<Vec<PathBuf>, GenError> {
95    let entries = match fs::read_dir(directory) {
96        Ok(entries) => entries,
97        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
98        Err(source) => return Err(io_error(directory, source)),
99    };
100    let mut pages = Vec::new();
101    for entry in entries {
102        let path = entry.map_err(|source| io_error(directory, source))?.path();
103        if path.is_dir() {
104            pages.extend(generated_pages(&path)?);
105        } else if path.extension().is_some_and(|extension| extension == "md")
106            && fs::read_to_string(&path)
107                .map_err(|source| io_error(&path, source))?
108                .starts_with(DOCS_HEADER)
109        {
110            pages.push(path);
111        }
112    }
113    pages.sort();
114    Ok(pages)
115}
116
117#[cfg(test)]
118mod tests {
119    use std::path::PathBuf;
120
121    use sofie_model::testing::{aggregate, app, area, field, function, module, route};
122    use sofie_model::{Call, Gateway, GatewayAdapter, Method, Modifier, ProductEvent};
123
124    use super::{module_page, security, telemetry};
125
126    #[test]
127    fn a_module_page_lists_the_checks_across_owners_by_their_callers() {
128        let mut scheduling = module("scheduling", "shop/modules/scheduling");
129        let file = PathBuf::from("shop/modules/scheduling/src/actions.rs");
130        scheduling
131            .code
132            .aggregates
133            .push(aggregate("Booking", Vec::new(), &file));
134        let mut book = function("actions::book_slot", &file, 20);
135        book.effects.calls.push(Call {
136            path: vec!["Booking".to_owned(), "across".to_owned()],
137            line: 24,
138        });
139        scheduling.code.functions.push(book);
140        let shop = app("shop", vec![scheduling]);
141
142        let page = module_page::module_page(&shop, &shop.modules[0]);
143
144        assert!(
145            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"),
146            "{page}"
147        );
148    }
149
150    #[test]
151    fn telemetry_and_security_pages_name_what_the_model_reads() {
152        let mut payment = module("payment", "shop/modules/payment");
153        let mut pay = route(Method::Post, "/pay", "actions::pay", 3);
154        pay.modifiers.push(Modifier::Throttle("10/m".to_owned()));
155        let mut hook = route(Method::Post, "/hooks/stripe", "actions::stripe_hook", 4);
156        hook.webhook = Some("verify_stripe".to_owned());
157        let mut receipt = route(Method::Get, "/receipt", "queries::receipt", 5);
158        receipt.screen = Some("receipt".to_owned());
159        payment
160            .areas
161            .push(area(&payment, "web", vec![pay, hook, receipt]));
162        let file = PathBuf::from("shop/modules/payment/src/gateways.rs");
163        payment.gateways.push(Gateway {
164            name: "payments".to_owned(),
165            path: vec!["Payments".to_owned()],
166            adapters: vec![GatewayAdapter {
167                ty: vec!["Stripe".to_owned()],
168                is_adapter: true,
169                vendor: Some("stripe".to_owned()),
170                settings: Vec::new(),
171                file: file.clone(),
172                line: 9,
173            }],
174            file: file.clone(),
175            line: 2,
176        });
177        payment.code.aggregates.push(aggregate(
178            "Payer",
179            vec![
180                field("tax_number", "Option<TaxNumber>", 4),
181                field("iban", "String", 5),
182            ],
183            &file,
184        ));
185        let telemetry_code = &mut payment.code.telemetry;
186        telemetry_code.sensitive_values.push("TaxNumber".to_owned());
187        telemetry_code
188            .sensitive_fields
189            .push(("Payer".to_owned(), "iban".to_owned()));
190        telemetry_code
191            .steps
192            .push(vec!["actions".to_owned(), "charge".to_owned()]);
193        telemetry_code.product_events.push(ProductEvent {
194            name: "payment.paid".to_owned(),
195            values: vec!["payment.total".to_owned()],
196            file: PathBuf::from("shop/modules/payment/src/actions.rs"),
197            line: 12,
198        });
199        let shop = app("shop", vec![payment]);
200
201        let security = security::security(&shop);
202        let telemetry = telemetry::telemetry(&shop);
203
204        for expected in [
205            "| POST | `/pay` | `web` | anyone | — | any | `10/m` | same origin (CSRF) |",
206            "signature by `verify_stripe`, no session",
207            "| `stripe` | `payment`'s `Payments` | `PAYMENT_STRIPE_BASE_URL`, `PAYMENT_STRIPE_HOSTS`",
208            "- POST `/hooks/stripe`",
209        ] {
210            assert!(security.contains(expected), "{expected}\n{security}");
211        }
212        for expected in [
213            "| `payment::actions::charge` | step |",
214            "| `payment/web/receipt` | render | `payment.web.receipt` |",
215            "request --> render",
216            "| `stripe.<operation>` | gateway | a call of `payment`'s `Payments` or an `Outgoing::operation` of `stripe` |",
217            "| `payment.paid` | `total` | modules/payment/src/actions.rs:12 |",
218            "| `payment::Payer` | `tax_number` | `Option<TaxNumber>` |",
219            "| `payment::Payer` | `iban` | `String` |",
220            "| `product.events` | counter | `product.event` | `payment.paid` |",
221        ] {
222            assert!(telemetry.contains(expected), "{expected}\n{telemetry}");
223        }
224    }
225}