majordomus_cli/web/report/
mod.rs1pub(crate) mod benchmarks;
17pub(crate) mod snapshot;
18pub 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
32pub 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
63pub 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
76pub fn directory(root: &Path, id: &str) -> PathBuf {
78 root.join(GENERATED_ROOT).join(id)
79}
80
81pub 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
90pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct Origin {
104 pub revision: Option<String>,
106 pub rendered_at: String,
108 pub version: String,
110}
111
112impl Origin {
113 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
133fn 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
150fn 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}