Skip to main content

max / makenotwork

11.6 KB · 339 lines History Blame Raw
1 //! Turning the ten value kinds into something on screen.
2 //!
3 //! This module is the entire reason producers are forbidden from formatting
4 //! their own values. Every `progress` in the UI gets the same bar, every
5 //! `instant` the same relative phrasing, every `ident` the same truncation —
6 //! and consistency survives a new producer written a year from now by someone
7 //! who never read this file, because the producer never gets a say.
8 //!
9 //! Everything here is a pure function. `instant` takes `now` as an argument
10 //! rather than reading the clock, which is what keeps the whole render
11 //! snapshot-testable.
12
13 use chrono::{DateTime, Utc};
14 use ops_status::{Status, Value};
15 use ratatui::style::{Color, Modifier, Style};
16
17 /// The one place a status becomes a color.
18 pub(crate) fn status_style(status: Status) -> Style {
19 match status {
20 Status::Ok => Style::default().fg(Color::Green),
21 Status::Degraded => Style::default().fg(Color::Yellow),
22 Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
23 Status::Pending => Style::default().fg(Color::Blue),
24 // Magenta, not gray: an unreachable source is not a quiet absence, it
25 // is the loudest thing a viewer can fail to tell you.
26 Status::Unknown => Style::default().fg(Color::Magenta),
27 }
28 }
29
30 /// A short, fixed-width-ish marker so columns line up.
31 pub(crate) fn status_mark(status: Status) -> &'static str {
32 match status {
33 Status::Ok => "ok",
34 Status::Degraded => "degr",
35 Status::Failed => "FAIL",
36 Status::Pending => "pend",
37 Status::Unknown => "????",
38 }
39 }
40
41 /// Render one value. `width` is the budget for things that elide.
42 pub(crate) fn render(value: &Value, now: DateTime<Utc>, width: usize) -> String {
43 match value {
44 Value::Text { value } => value.clone(),
45 Value::Ident { value, abbrev_to } => abbreviate(value, abbrev_to.unwrap_or(12)),
46 Value::Version { value } => value.clone(),
47 Value::Instant { value } => relative(*value, now),
48 Value::Duration { seconds } => duration(*seconds),
49 Value::Progress { value, max, unit } => progress(*value, *max, unit.as_deref(), width),
50 Value::Quantity { value, unit } => quantity(*value, unit.as_deref()),
51 Value::State { value } => status_mark(*value).to_string(),
52 Value::Link { url, text } => text.clone().unwrap_or_else(|| url.clone()),
53 Value::Path { value } => middle_elide(value, width.max(12)),
54 }
55 }
56
57 /// The style a value carries on its own, if any.
58 pub(crate) fn style(value: &Value) -> Style {
59 match value {
60 Value::State { value } => status_style(*value),
61 Value::Ident { .. } | Value::Path { .. } => Style::default().fg(Color::Cyan),
62 Value::Link { .. } => Style::default()
63 .fg(Color::Cyan)
64 .add_modifier(Modifier::UNDERLINED),
65 _ => Style::default(),
66 }
67 }
68
69 /// Truncate an identifier at a character boundary.
70 ///
71 /// Char-wise rather than byte-wise: a digest is ASCII, but nothing in the
72 /// contract promises that, and slicing a multi-byte value at a byte index
73 /// panics.
74 pub(crate) fn abbreviate(value: &str, to: usize) -> String {
75 if value.chars().count() <= to {
76 return value.to_string();
77 }
78 value.chars().take(to).collect()
79 }
80
81 /// Elide the middle of a path, keeping both ends: the leading directories say
82 /// where you are and the basename says what it is. Truncating either end alone
83 /// throws away the half you needed.
84 pub(crate) fn middle_elide(value: &str, max: usize) -> String {
85 let chars: Vec<char> = value.chars().collect();
86 if chars.len() <= max {
87 return value.to_string();
88 }
89 if max <= 3 {
90 return "...".into();
91 }
92 let keep = max - 3;
93 let head = keep.div_ceil(2);
94 let tail = keep - head;
95 let mut out: String = chars[..head].iter().collect();
96 out.push_str("...");
97 out.extend(&chars[chars.len() - tail..]);
98 out
99 }
100
101 /// "3m ago", "in 2m", "just now".
102 pub(crate) fn relative(at: DateTime<Utc>, now: DateTime<Utc>) -> String {
103 let delta = now - at;
104 let secs = delta.num_seconds();
105 if secs.abs() < 5 {
106 return "just now".into();
107 }
108 let magnitude = duration(secs.abs());
109 if secs > 0 {
110 format!("{magnitude} ago")
111 } else {
112 format!("in {magnitude}")
113 }
114 }
115
116 /// Humanize a span. Two units at most: "2h 14m" is useful, "2h 14m 7s" is
117 /// noise at every scale where hours matter.
118 pub(crate) fn duration(seconds: i64) -> String {
119 let s = seconds.abs();
120 let (d, h, m, sec) = (s / 86_400, (s % 86_400) / 3600, (s % 3600) / 60, s % 60);
121 match (d, h, m) {
122 (0, 0, 0) => format!("{sec}s"),
123 (0, 0, _) => format!("{m}m {sec}s"),
124 (0, _, 0) => format!("{h}h"),
125 (0, _, _) => format!("{h}h {m}m"),
126 (_, 0, _) => format!("{d}d"),
127 (_, _, _) => format!("{d}d {h}h"),
128 }
129 }
130
131 /// A magnitude with its unit. The number is humanized; the unit is the
132 /// producer's, verbatim.
133 pub(crate) fn quantity(value: f64, unit: Option<&str>) -> String {
134 let n = humanize_number(value);
135 match unit {
136 Some(u) => format!("{n} {u}"),
137 None => n,
138 }
139 }
140
141 fn humanize_number(value: f64) -> String {
142 let abs = value.abs();
143 let (scaled, suffix) = if abs >= 1e9 {
144 (value / 1e9, "G")
145 } else if abs >= 1e6 {
146 (value / 1e6, "M")
147 } else if abs >= 1_000.0 {
148 (value / 1_000.0, "k")
149 } else {
150 (value, "")
151 };
152 let rendered = if scaled.fract().abs() < 0.05 {
153 format!("{scaled:.0}")
154 } else {
155 format!("{scaled:.1}")
156 };
157 format!("{rendered}{suffix}")
158 }
159
160 /// A progress bar plus its numbers, e.g. `[####------] 31/48 hour`.
161 pub(crate) fn progress(value: f64, max: f64, unit: Option<&str>, width: usize) -> String {
162 let numbers = match unit {
163 Some(u) => format!("{}/{} {u}", humanize_number(value), humanize_number(max)),
164 None => format!("{}/{}", humanize_number(value), humanize_number(max)),
165 };
166
167 // The bar is whatever is left after the numbers, floored at something still
168 // readable and capped so it does not sprawl across a wide terminal.
169 let bar_width = width.saturating_sub(numbers.len() + 3).clamp(4, 24);
170 let fraction = if max > 0.0 {
171 (value / max).clamp(0.0, 1.0)
172 } else {
173 0.0
174 };
175 let filled = (fraction * bar_width as f64).round() as usize;
176 let bar: String = "#".repeat(filled) + &"-".repeat(bar_width - filled);
177 format!("[{bar}] {numbers}")
178 }
179
180 /// How a progress value should be colored: complete is done, not merely far
181 /// along.
182 pub(crate) fn progress_style(value: f64, max: f64) -> Style {
183 if max > 0.0 && value >= max {
184 Style::default().fg(Color::Green)
185 } else {
186 Style::default().fg(Color::Blue)
187 }
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193 use chrono::TimeDelta;
194
195 fn now() -> DateTime<Utc> {
196 "2026-07-21T18:00:00Z".parse().unwrap()
197 }
198
199 #[test]
200 fn durations_stop_at_two_units() {
201 assert_eq!(duration(45), "45s");
202 assert_eq!(duration(125), "2m 5s");
203 assert_eq!(duration(7200), "2h");
204 assert_eq!(duration(8040), "2h 14m");
205 assert_eq!(duration(172_800), "2d");
206 assert_eq!(duration(180_000), "2d 2h");
207 }
208
209 #[test]
210 fn relative_time_reads_in_both_directions() {
211 let base = now();
212 assert_eq!(relative(base, base), "just now");
213 assert_eq!(relative(base - TimeDelta::minutes(3), base), "3m 0s ago");
214 // A producer whose clock runs ahead must not render as a negative age.
215 assert_eq!(relative(base + TimeDelta::minutes(2), base), "in 2m 0s");
216 }
217
218 #[test]
219 fn identifiers_truncate_at_a_character_boundary() {
220 assert_eq!(abbreviate("a3f9c21b7e4d8056", 8), "a3f9c21b");
221 assert_eq!(abbreviate("short", 8), "short");
222 // Multi-byte: byte slicing here would panic.
223 assert_eq!(abbreviate("ααααββββ", 4), "αααα");
224 }
225
226 #[test]
227 fn paths_keep_both_ends() {
228 let path = "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork";
229 let out = middle_elide(path, 30);
230 assert_eq!(out.chars().count(), 30);
231 assert!(out.starts_with("/srv/sando"), "{out}");
232 assert!(out.ends_with("makenotwork"), "{out}");
233 assert!(out.contains("..."));
234 }
235
236 #[test]
237 fn a_short_path_is_left_alone() {
238 assert_eq!(middle_elide("/etc/sando", 30), "/etc/sando");
239 }
240
241 #[test]
242 fn quantities_humanize_the_number_and_keep_the_producers_unit() {
243 assert_eq!(quantity(43.5, Some("MB")), "43.5 MB");
244 assert_eq!(quantity(1_200.0, None), "1.2k");
245 assert_eq!(quantity(2_000_000.0, None), "2M");
246 assert_eq!(quantity(7.0, Some("node")), "7 node");
247 }
248
249 #[test]
250 fn progress_renders_a_bar_and_its_numbers() {
251 let out = progress(31.0, 48.0, Some("hour"), 40);
252 assert!(out.contains("31/48 hour"), "{out}");
253 assert!(out.starts_with('['), "{out}");
254 assert!(out.contains('#') && out.contains('-'), "{out}");
255 }
256
257 #[test]
258 fn progress_survives_nonsense_without_panicking() {
259 // A producer bug must not take the viewer down.
260 assert!(progress(5.0, 0.0, None, 40).contains("5/0"));
261 assert!(progress(-1.0, 10.0, None, 40).starts_with('['));
262 assert!(progress(99.0, 10.0, None, 40).starts_with('['));
263 // Width smaller than the numbers still yields a bar, not a panic.
264 assert!(progress(31.0, 48.0, Some("hour"), 1).starts_with('['));
265 }
266
267 #[test]
268 fn a_complete_bar_reads_as_done() {
269 assert_eq!(
270 progress_style(48.0, 48.0),
271 Style::default().fg(Color::Green)
272 );
273 assert_eq!(progress_style(31.0, 48.0), Style::default().fg(Color::Blue));
274 }
275
276 #[test]
277 fn every_kind_renders_to_something_non_empty() {
278 let values = vec![
279 Value::Text { value: "x".into() },
280 Value::Ident {
281 value: "a3f9c21b7e4d8056".into(),
282 abbrev_to: Some(8),
283 },
284 Value::Version {
285 value: "0.10.14".into(),
286 },
287 Value::Instant {
288 value: now() - TimeDelta::minutes(3),
289 },
290 Value::Duration { seconds: 3600 },
291 Value::Progress {
292 value: 31.0,
293 max: 48.0,
294 unit: Some("hour".into()),
295 },
296 Value::Quantity {
297 value: 43.5,
298 unit: Some("MB".into()),
299 },
300 Value::State {
301 value: Status::Degraded,
302 },
303 Value::Link {
304 url: "https://makenot.work".into(),
305 text: None,
306 },
307 Value::Path {
308 value: "/srv/sando".into(),
309 },
310 ];
311 for value in values {
312 let out = render(&value, now(), 40);
313 assert!(!out.is_empty(), "{} rendered empty", value.kind());
314 assert!(!out.contains('\n'), "{} rendered multi-line", value.kind());
315 }
316 }
317
318 #[test]
319 fn a_link_prefers_its_text_and_falls_back_to_the_url() {
320 let with = Value::Link {
321 url: "https://makenot.work".into(),
322 text: Some("site".into()),
323 };
324 assert_eq!(render(&with, now(), 40), "site");
325 let without = Value::Link {
326 url: "https://makenot.work".into(),
327 text: None,
328 };
329 assert_eq!(render(&without, now(), 40), "https://makenot.work");
330 }
331
332 #[test]
333 fn an_unknown_status_is_loud_not_gray() {
334 // A source nobody can reach must not read as a quiet absence.
335 assert_eq!(status_style(Status::Unknown).fg, Some(Color::Magenta));
336 assert_eq!(status_style(Status::Ok).fg, Some(Color::Green));
337 }
338 }
339