1mod 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}