Skip to main content

majordomus_cli/web/report/
mod.rs

1//! The reports this repository generates as web surfaces: the test run, the benchmark run,
2//! the UI conformance audit that is a section of the first, and the UI itself read once at a
3//! pinned commit, which a publication carries at the UI's mount (`snapshot`, ADR 0141).
4//!
5//! A report is a *rendering* of evidence somebody else produced. The suite writes its
6//! results, the benchmark run writes its own, and this module turns either into a directory
7//! under the generated web root: an `index.html` a person reads, the machine-readable
8//! results beside it, and the `surface.json` that declares what the directory is for. It
9//! renders; it never runs anything and never decides what passed.
10//!
11//! The HTML is deliberately self-contained — its own style, no external asset, and every
12//! link relative — so that the same directory works at `/tests`, at `/reports/tests` or
13//! opened from disk. That is the whole of the "static-safe URL" problem, solved once by not
14//! creating it.
15
16pub(crate) mod benchmarks;
17pub(crate) mod snapshot;
18/// The page style every generated surface uses. It lives beside the model now that the
19/// home page renders through it too; this re-export keeps the reports' own path to it.
20pub use super::html;
21pub mod tests;
22pub mod ui;
23
24use std::path::{Path, PathBuf};
25
26use serde::Serialize;
27
28use super::discover::{Declaration, DECLARATION_FILE, DECLARATION_SCHEMA, GENERATED_ROOT};
29use super::model::{Availability, Category, Visibility};
30use crate::error::{Error, Result};
31
32/// What every report writes beside its rendering: the declaration that makes the directory
33/// a surface.
34///
35/// The producer states the intent — its id, where it belongs, what built it — and discovery
36/// reads it back. Nothing else in the repository is told that the surface exists.
37pub fn declare(root: &Path, id: &str, title: &str, producer: &str) -> Result<PathBuf> {
38    let dir = directory(root, id);
39    std::fs::create_dir_all(&dir).map_err(|e| Error::io(dir.display().to_string(), e))?;
40    let declaration = Declaration {
41        schema: DECLARATION_SCHEMA.into(),
42        id: id.into(),
43        mount: format!("/{id}"),
44        title: Some(title.into()),
45        index: Some("index.html".into()),
46        producer: Some(producer.into()),
47        availability: Some(Availability::Both),
48        category: Some(Category::Report),
49        visibility: Some(Visibility::Public),
50        built_from: Origin::read(root).revision,
51    };
52    let path = dir.join(DECLARATION_FILE);
53    let mut body =
54        serde_json::to_string_pretty(&declaration).map_err(|e| Error::InvalidSurface {
55            surface: id.into(),
56            reason: format!("its declaration cannot be serialised: {e}"),
57        })?;
58    body.push('\n');
59    std::fs::write(&path, body).map_err(|e| Error::io(path.display().to_string(), e))?;
60    Ok(dir)
61}
62
63/// Declare a surface only when nothing has declared it yet.
64///
65/// A report that is a *section* of another's surface still needs that surface to exist —
66/// an unreachable section is not a report — but it must never restate the enclosing
67/// producer's identity over the top of it. So: create when absent, leave alone when there.
68pub fn declare_if_absent(root: &Path, id: &str, title: &str, producer: &str) -> Result<PathBuf> {
69    let dir = directory(root, id);
70    if dir.join(DECLARATION_FILE).is_file() {
71        return Ok(dir);
72    }
73    declare(root, id, title, producer)
74}
75
76/// Where a report's directory is, absolute.
77pub fn directory(root: &Path, id: &str) -> PathBuf {
78    root.join(GENERATED_ROOT).join(id)
79}
80
81/// Write one file inside a report's directory, creating what it needs.
82pub fn write(dir: &Path, name: &str, body: &str) -> Result<()> {
83    let path = dir.join(name);
84    if let Some(parent) = path.parent() {
85        std::fs::create_dir_all(parent).map_err(|e| Error::io(parent.display().to_string(), e))?;
86    }
87    std::fs::write(&path, body).map_err(|e| Error::io(path.display().to_string(), e))
88}
89
90/// Write a value as pretty JSON beside a rendering: the evidence the page was made from,
91/// kept because a page nobody can check is not evidence.
92pub fn write_json<T: Serialize>(dir: &Path, name: &str, value: &T) -> Result<()> {
93    let mut body = serde_json::to_string_pretty(value).map_err(|e| Error::InvalidSurface {
94        surface: name.into(),
95        reason: format!("cannot be serialised: {e}"),
96    })?;
97    body.push('\n');
98    write(dir, name, &body)
99}
100
101/// What a report says about where it came from, printed on every page.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct Origin {
104    /// The commit the evidence was produced from, when git could say.
105    pub revision: Option<String>,
106    /// When the report was rendered, in UTC.
107    pub rendered_at: String,
108    /// The executable that rendered it.
109    pub version: String,
110}
111
112impl Origin {
113    /// Read the origin from the repository, without failing when git is not there: a report
114    /// rendered outside a checkout is still a report.
115    pub fn read(root: &Path) -> Self {
116        let revision = std::process::Command::new("git")
117            .args(["rev-parse", "--short", "HEAD"])
118            .current_dir(root)
119            .output()
120            .ok()
121            .filter(|o| o.status.success())
122            .and_then(|o| String::from_utf8(o.stdout).ok())
123            .map(|s| s.trim().to_string())
124            .filter(|s| !s.is_empty());
125        Origin {
126            revision,
127            rendered_at: crate::web::report::now(),
128            version: crate::VERSION.into(),
129        }
130    }
131}
132
133/// The current time as `YYYY-MM-DDTHH:MM:SSZ`, without a date dependency.
134fn now() -> String {
135    let secs = std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .map(|d| d.as_secs())
138        .unwrap_or(0) as i64;
139    let days = secs.div_euclid(86_400);
140    let rest = secs.rem_euclid(86_400);
141    let (year, month, day) = civil_from_days(days);
142    format!(
143        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
144        rest / 3600,
145        (rest % 3600) / 60,
146        rest % 60
147    )
148}
149
150/// Days since the Unix epoch to a civil date (Howard Hinnant's algorithm).
151fn civil_from_days(z: i64) -> (i64, u32, u32) {
152    let z = z + 719_468;
153    let era = z.div_euclid(146_097);
154    let doe = z.rem_euclid(146_097);
155    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
156    let y = yoe + era * 400;
157    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
158    let mp = (5 * doy + 2) / 153;
159    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
160    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
161    (if m <= 2 { y + 1 } else { y }, m, d)
162}
163
164#[cfg(test)]
165mod unit {
166    use super::*;
167
168    #[test]
169    fn a_declaration_makes_a_directory_discoverable() {
170        let tmp = tempfile::tempdir().unwrap();
171        let dir = declare(
172            tmp.path(),
173            "example",
174            "Example report",
175            "the example producer",
176        )
177        .unwrap();
178        assert!(dir.join(DECLARATION_FILE).is_file());
179        let found = crate::web::discover::generated(tmp.path()).unwrap();
180        assert_eq!(found.len(), 1);
181        assert_eq!(found[0].id, "example");
182        assert_eq!(found[0].mount.as_str(), "/example");
183        assert_eq!(found[0].producer, "the example producer");
184    }
185
186    #[test]
187    fn the_epoch_and_a_known_day_render_as_the_dates_they_are() {
188        assert_eq!(civil_from_days(0), (1970, 1, 1));
189        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
190        assert_eq!(civil_from_days(20_608), (2026, 6, 4));
191    }
192
193    #[test]
194    fn a_rendered_time_has_the_shape_every_report_prints() {
195        let stamp = now();
196        assert_eq!(stamp.len(), 20, "{stamp}");
197        assert!(stamp.ends_with('Z'));
198        assert!(stamp.contains('T'));
199    }
200}