steid

@jamesgill /

dce0bf3feat: browse a repository's files and history8d
1//! Browsing a repository — the file tree, a single file, and the commit log.
2//!
3//! Four routes, all reading through [`browse_repo`] and [`repo_log`], so visibility is
4//! decided in one place: a repository invisible on its page is invisible here, and
5//! `Ok(None)` from either use case renders as the same 404 as a repository that was
6//! never created.
7//!
8//! The URL carries a `/-/` separator between the revision and the path
9//! (`/tree/{rev}/-/{path}`) because a ref may contain slashes, so a candidate split
10//! would cost a ref lookup — another `git` process — on every page. The separator is
11//! unambiguous by construction instead.
12//!
13//! One route serves both directories and files. Which one a path is, is git's answer,
14//! not the URL's, and a link that had to know would be wrong the moment a file became
15//! a directory.
16
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use topcoat::{
20 Result,
21 context::Cx,
22 icon::{icon, iconify::iconify_icon},
23 router::{
24 error::{RouterErrorExt, not_found},
25 page, path_param,
26 },
27 view::{attributes, component, view},
28};
29
30use crate::{
31 application::{Browsed, FileView, RepoView, browse_repo, repo_log},
32 components::badge::{BadgeVariant, badge},
33 domain::{CommitSummary, EntryKind, RefName, RepoPath, TreeEntry},
34};
35
36use super::{
37 context::{current_actor, memberships, orgs, queries, repos, server_error},
38 repo::{clone_url, clone_url_for, repo_for},
39};
40
41/// `{rev}` from the path, raw — validation is [`RefName`]'s job.
42#[path_param]
43struct Rev(str);
44
45/// `{*path}` from the path: every remaining segment, as one string.
46#[path_param]
47struct Path(str);
48
49/// Which page of a repository is being looked at, for the nav.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(super) enum Tab {
52 Files,
53 Log,
54}
55
56/// The revision from the URL, or 404.
57///
58/// A malformed revision is a page that does not exist rather than a bad request — the
59/// same reasoning that 404s a malformed handle.
60fn rev_param(cx: &Cx) -> Result<RefName> {
61 Ok(RefName::new(path_param::<Rev>(cx)).map_err(|_| not_found())?)
62}
63
64/// The path from the URL, or 404. Same reasoning as [`rev_param`].
65fn path_arg(cx: &Cx) -> Result<RepoPath> {
66 Ok(RepoPath::new(path_param::<Path>(cx)).map_err(|_| not_found())?)
67}
68
69#[page("/{handle}/repos/{name}/tree/{rev}")]
70async fn tree_root_page(cx: &Cx) -> Result {
71 let rev = rev_param(cx)?;
72
73 view! { browsing(rev: Some(rev), path: RepoPath::root()) }
74}
75
76#[page("/{handle}/repos/{name}/tree/{rev}/-/{*path}")]
77async fn tree_path_page(cx: &Cx) -> Result {
78 let rev = rev_param(cx)?;
79 let path = path_arg(cx)?;
80
81 view! { browsing(rev: Some(rev), path: path) }
82}
83
84#[page("/{handle}/repos/{name}/log")]
85async fn log_page(_cx: &Cx) -> Result {
86 view! { history(rev: None) }
87}
88
89#[page("/{handle}/repos/{name}/log/{rev}")]
90async fn log_rev_page(cx: &Cx) -> Result {
91 let rev = rev_param(cx)?;
92
93 view! { history(rev: Some(rev)) }
94}
95
96/// Reads a path in a repository, or 404.
97///
98/// Shared with the repository page, which browses the default branch at the root.
99pub(super) async fn browsed_at(
100 cx: &Cx,
101 repo: &RepoView,
102 rev: Option<&RefName>,
103 path: &RepoPath,
104) -> Result<Browsed> {
105 Ok(browse_repo(
106 &repo.handle,
107 &repo.name,
108 rev,
109 path,
110 &current_actor(cx).await?,
111 &orgs(cx),
112 &memberships(cx),
113 &repos(cx),
114 &queries(cx),
115 )
116 .await
117 .map_err(server_error)?
118 .ok_or_not_found()?)
119}
120
121/// The tree and blob pages, which differ only in what git found at the path.
122///
123/// A component rather than a plain function because `view!` needs the request context
124/// in scope, and a component is how a body of markup gets it — the same reason the
125/// layout is a layout.
126#[component]
127async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
128 let repo = repo_for(cx).await?;
129 let browsed = browsed_at(cx, &repo, rev.as_ref(), &path).await?;
130 let clone = clone_url_for(cx, &repo);
131
132 view! {
133 repo_bar(repo: &repo, rev: browsed_rev(&browsed), active: Tab::Files)
134
135 match &browsed {
136 Browsed::Empty => {
137 clone_url(url: clone.as_str())
138 empty_repo(url: clone.as_str())
139 },
140 Browsed::Directory { rev, path, entries } => directory(
141 handle: repo.handle.as_str(),
142 name: repo.name.as_str(),
143 rev: rev,
144 path: path,
145 entries: entries,
146 ),
147 Browsed::File { rev, path, file } => blob(
148 handle: repo.handle.as_str(),
149 name: repo.name.as_str(),
150 rev: rev,
151 path: path,
152 file: file,
153 ),
154 }
155 }
156}
157
158/// The commit log page. A component for the same reason as [`browsing`].
159#[component]
160async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
161 let repo = repo_for(cx).await?;
162
163 let log = repo_log(
164 &repo.handle,
165 &repo.name,
166 rev.as_ref(),
167 &current_actor(cx).await?,
168 &orgs(cx),
169 &memberships(cx),
170 &repos(cx),
171 &queries(cx),
172 )
173 .await
174 .map_err(server_error)?
175 .ok_or_not_found()?;
176
177 view! {
178 repo_bar(
179 repo: &repo,
180 rev: rev.as_ref().map(RefName::as_str).unwrap_or_default(),
181 active: Tab::Log,
182 )
183 commit_log(commits: &log)
184 }
185}
186
187/// The revision a browse landed on, for display. Empty when there is none.
188fn browsed_rev(browsed: &Browsed) -> &str {
189 match browsed {
190 Browsed::Empty => "",
191 Browsed::Directory { rev, .. } | Browsed::File { rev, .. } => rev.as_str(),
192 }
193}
194
195// --- URLs -------------------------------------------------------------------------
196
197/// The URL for a path at a revision.
198///
199/// The revision is encoded whole, slashes included, so `feature/login` stays one
200/// segment and the `/-/` separator keeps its meaning. The path keeps its slashes,
201/// because they *are* segments.
202pub(super) fn tree_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
203 let encoded = encode(rev.as_str(), false);
204
205 if path.is_root() {
206 format!("/{handle}/repos/{name}/tree/{encoded}")
207 } else {
208 format!(
209 "/{handle}/repos/{name}/tree/{encoded}/-/{}",
210 encode(path.as_str(), true)
211 )
212 }
213}
214
215/// The commit log's URL, at a revision or at the default branch.
216fn log_url(handle: &str, name: &str, rev: &str) -> String {
217 if rev.is_empty() {
218 format!("/{handle}/repos/{name}/log")
219 } else {
220 format!("/{handle}/repos/{name}/log/{}", encode(rev, false))
221 }
222}
223
224/// Percent-encodes a URL segment.
225///
226/// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
227/// RFC 3986 and nothing else. `keep_slash` is the difference between a path, whose
228/// slashes are structure, and a revision, whose slashes are part of its name.
229fn encode(value: &str, keep_slash: bool) -> String {
230 let mut encoded = String::with_capacity(value.len());
231
232 for byte in value.bytes() {
233 match byte {
234 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
235 encoded.push(byte as char);
236 }
237 b'/' if keep_slash => encoded.push('/'),
238 other => encoded.push_str(&format!("%{other:02X}")),
239 }
240 }
241
242 encoded
243}
244
245// --- Formatting -------------------------------------------------------------------
246
247/// A file size, rounded for reading rather than for accounting.
248fn size_of(bytes: u64) -> String {
249 const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
250
251 if bytes < 1024 {
252 return format!("{bytes} B");
253 }
254
255 let mut value = bytes as f64 / 1024.0;
256 let mut unit = UNITS[0];
257
258 for next in &UNITS[1..] {
259 if value < 1024.0 {
260 break;
261 }
262
263 value /= 1024.0;
264 unit = next;
265 }
266
267 format!("{value:.1} {unit}")
268}
269
270/// How long ago something happened, in words.
271///
272/// A commit's timestamp comes from whoever made it, so it can sit in the future — a
273/// skewed clock, or a rewritten history. That reads as "just now" rather than as a
274/// negative duration.
275fn ago(time: SystemTime) -> String {
276 let Ok(elapsed) = SystemTime::now().duration_since(time) else {
277 return "just now".to_owned();
278 };
279
280 let seconds = elapsed.as_secs();
281
282 let (count, unit) = match seconds {
283 0..=59 => return "just now".to_owned(),
284 60..=3599 => (seconds / 60, "minute"),
285 3600..=86_399 => (seconds / 3600, "hour"),
286 86_400..=2_591_999 => (seconds / 86_400, "day"),
287 2_592_000..=31_535_999 => (seconds / 2_592_000, "month"),
288 _ => (seconds / 31_536_000, "year"),
289 };
290
291 if count == 1 {
292 format!("1 {unit} ago")
293 } else {
294 format!("{count} {unit}s ago")
295 }
296}
297
298/// The exact time, for the tooltip behind [`ago`].
299fn timestamp(time: SystemTime) -> String {
300 let seconds = time
301 .duration_since(UNIX_EPOCH)
302 .map(|since| since.as_secs() as i64)
303 .unwrap_or(0);
304
305 let (year, month, day) = civil_from_days(seconds.div_euclid(86_400));
306 let rest = seconds.rem_euclid(86_400);
307
308 format!(
309 "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
310 rest / 3600,
311 (rest % 3600) / 60
312 )
313}
314
315/// Days since the epoch to a calendar date, by Howard Hinnant's `civil_from_days`.
316///
317/// Written out rather than taken as a dependency: this is the whole of the date
318/// handling Steid needs, and a date library is a large thing to add for one function.
319fn civil_from_days(days: i64) -> (i64, u32, u32) {
320 // Shift the epoch to 0000-03-01, which puts the leap day at the end of the year.
321 let shifted = days + 719_468;
322 let era = shifted.div_euclid(146_097);
323 let day_of_era = shifted.rem_euclid(146_097);
324
325 let year_of_era =
326 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
327 let year = year_of_era + era * 400;
328 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
329
330 let shifted_month = (5 * day_of_year + 2) / 153;
331 let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32;
332 let month = if shifted_month < 10 {
333 shifted_month + 3
334 } else {
335 shifted_month - 9
336 } as u32;
337
338 (if month <= 2 { year + 1 } else { year }, month, day)
339}
340
341// --- Views ------------------------------------------------------------------------
342
343/// The bar every repository page carries: where you are, and what else there is.
344///
345/// Kept out of the repository page's own header so that a tree, a file and a log all
346/// read as the same repository rather than as three unrelated pages.
347#[component]
348pub(super) async fn repo_bar(repo: &RepoView, rev: &str, active: Tab) -> Result {
349 let handle = repo.handle.as_str();
350 let name = repo.name.as_str();
351 let tab = |current| {
352 if current {
353 "text-foreground border-foreground"
354 } else {
355 "text-muted-foreground border-transparent hover:text-foreground"
356 }
357 };
358
359 view! {
360 <header class="mb-6 border-b border-border pb-3">
361 <p class="font-mono text-sm text-muted-foreground">
362 <a href=(format!("/{handle}")) class="hover:text-foreground">"@" (handle)</a>
363 " / "
364 <a href=(format!("/{handle}/repos/{name}")) class="text-foreground hover:underline">
365 (name)
366 </a>
367 if !repo.visibility.is_public() {
368 " "
369 badge(variant: BadgeVariant::Outline, "Private")
370 }
371 </p>
372
373 <nav class="mt-3 flex items-center gap-5 text-sm">
374 <a
375 href=(format!("/{handle}/repos/{name}"))
376 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Files)))
377 >"Files"</a>
378 <a
379 href=(log_url(handle, name, rev))
380 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Log)))
381 >"Commits"</a>
382
383 if !rev.is_empty() {
384 <span class="ml-auto inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
385 icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
386 class="size-3.5"
387 })
388 (rev)
389 </span>
390 }
391 </nav>
392 </header>
393 }
394}
395
396/// What a repository with no commits offers instead of a listing.
397///
398/// This is the state every freshly-created repository is in, so it is the first thing
399/// its owner sees — the snippet is the point of the page, not decoration.
400#[component]
401pub(super) async fn empty_repo(url: &str) -> Result {
402 let push = format!("git remote add origin {url}\ngit branch -M main\ngit push -u origin main");
403
404 view! {
405 <div class="mt-6 rounded-lg border border-border px-4 py-5">
406 <p class="text-sm text-muted-foreground">
407 "This repository has no commits yet. Push one to see it here."
408 </p>
409 <p class="mt-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
410 "Push an existing repository"
411 </p>
412 <pre class="mt-2 overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(push)</pre>
413 </div>
414 }
415}
416
417/// The path you are at, with every ancestor linked.
418///
419/// The main way anyone moves around a tree: the last component is the current page and
420/// is deliberately not a link, so the trail reads as a position rather than a menu.
421#[component]
422async fn crumbs(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> Result {
423 let parts: Vec<&str> = path.components().collect();
424 let mut walked = RepoPath::root();
425 let mut trail: Vec<(String, String)> = Vec::new();
426
427 for (index, part) in parts.iter().enumerate() {
428 walked = walked.join(part);
429
430 let href = if index + 1 == parts.len() {
431 String::new()
432 } else {
433 tree_url(handle, name, rev, &walked)
434 };
435
436 trail.push(((*part).to_owned(), href));
437 }
438
439 view! {
440 <div class="flex flex-wrap items-center gap-1 font-mono text-sm">
441 <a
442 href=(tree_url(handle, name, rev, &RepoPath::root()))
443 class="text-muted-foreground hover:text-foreground"
444 >(name)</a>
445
446 for (part, href) in &trail {
447 <span class="text-muted-foreground">"/"</span>
448 match href.is_empty() {
449 true => <span class="font-medium">(part)</span>,
450 false => <a href=(href) class="text-muted-foreground hover:text-foreground">(part)</a>,
451 }
452 }
453 </div>
454 }
455}
456
457/// A directory listing.
458///
459/// Entries arrive ordered by the use case — directories first, then case-insensitively
460/// by name — so nothing here re-sorts them.
461#[component]
462pub(super) async fn directory(
463 handle: &str,
464 name: &str,
465 rev: &RefName,
466 path: &RepoPath,
467 entries: &[TreeEntry],
468) -> Result {
469 view! {
470 <div class="overflow-hidden rounded-lg border border-border">
471 <div class="border-b border-border px-4 py-2.5">
472 crumbs(handle: handle, name: name, rev: rev, path: path)
473 </div>
474
475 if entries.is_empty() {
476 <p class="px-4 py-6 text-center text-sm text-muted-foreground">
477 "This directory is empty."
478 </p>
479 } else {
480 <ul class="divide-y divide-border text-sm">
481 match path.parent() {
482 Some(parent) => <li class="px-4 py-2">
483 <a
484 href=(tree_url(handle, name, rev, &parent))
485 class="inline-flex items-center gap-2 font-mono text-muted-foreground hover:text-foreground"
486 >
487 icon(data: iconify_icon!("feather:corner-left-up"), attrs: attributes! {
488 class="size-4"
489 })
490 ".."
491 </a>
492 </li>,
493 None => "",
494 }
495
496 for entry in entries {
497 <li class="flex items-center gap-3 px-4 py-2">
498 entry_row(
499 handle: handle,
500 name: name,
501 rev: rev,
502 path: path,
503 entry: entry,
504 )
505 </li>
506 }
507 </ul>
508 }
509 </div>
510 }
511}
512
513/// One entry in a listing.
514///
515/// A symlink and a submodule are their own kinds, not files: a submodule is another
516/// repository Steid cannot look inside, so it is labelled and left unlinked rather
517/// than offered as a click that would 404.
518#[component]
519async fn entry_row(
520 handle: &str,
521 name: &str,
522 rev: &RefName,
523 path: &RepoPath,
524 entry: &TreeEntry,
525) -> Result {
526 let href = tree_url(handle, name, rev, &path.join(&entry.name));
527 let linkable = entry.kind != EntryKind::Submodule;
528
529 view! {
530 <span class="text-muted-foreground">
531 match entry.kind {
532 EntryKind::Tree => icon(
533 data: iconify_icon!("feather:folder"),
534 label: "Directory",
535 attrs: attributes! { class="size-4" },
536 ),
537 EntryKind::Blob => icon(
538 data: iconify_icon!("feather:file"),
539 label: "File",
540 attrs: attributes! { class="size-4" },
541 ),
542 EntryKind::Symlink => icon(
543 data: iconify_icon!("feather:link-2"),
544 label: "Symlink",
545 attrs: attributes! { class="size-4" },
546 ),
547 EntryKind::Submodule => icon(
548 data: iconify_icon!("feather:package"),
549 label: "Submodule",
550 attrs: attributes! { class="size-4" },
551 ),
552 }
553 </span>
554
555 match linkable {
556 true => <a
557 href=(href)
558 class=(if entry.kind.is_tree() {
559 "font-mono font-medium hover:underline"
560 } else {
561 "font-mono hover:underline"
562 })
563 >(&entry.name)</a>,
564 false => <span class="font-mono">(&entry.name)</span>,
565 }
566
567 match entry.kind {
568 EntryKind::Symlink => badge(variant: BadgeVariant::Outline, "symlink"),
569 EntryKind::Submodule => badge(variant: BadgeVariant::Outline, "submodule"),
570 _ => "",
571 }
572
573 <span class="ml-auto font-mono text-xs text-muted-foreground">
574 match entry.size {
575 Some(size) => (size_of(size)),
576 None if entry.kind == EntryKind::Submodule => (entry.id.short()),
577 None => "",
578 }
579 </span>
580 }
581}
582
583/// A single file.
584///
585/// Three outcomes, all of them a page rather than an error: text, something that is not
586/// text, and something too big to be worth rendering. The last says how big, because
587/// that is the only useful thing left to say about it.
588#[component]
589pub(super) async fn blob(
590 handle: &str,
591 name: &str,
592 rev: &RefName,
593 path: &RepoPath,
594 file: &FileView,
595) -> Result {
596 view! {
597 <div class="overflow-hidden rounded-lg border border-border">
598 <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
599 crumbs(handle: handle, name: name, rev: rev, path: path)
600 <span class="font-mono text-xs text-muted-foreground">(size_of(file.size))</span>
601 </div>
602
603 match &file.text {
604 Some(text) => source(text: text.as_str()),
605 None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
606 "This file is " (size_of(file.size)) ", which is too large to display. Clone the repository to read it."
607 </p>,
608 None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
609 "This file cannot be displayed as text."
610 </p>,
611 }
612 </div>
613 }
614}
615
616/// A file's contents, with line numbers.
617///
618/// A table rather than a `<pre>` with a gutter: the numbers stay put when the code
619/// scrolls sideways, and selecting the code does not drag the numbers along with it.
620#[component]
621async fn source(text: &str) -> Result {
622 view! {
623 <div class="overflow-x-auto">
624 <table class="w-full border-collapse font-mono text-xs leading-relaxed">
625 <tbody>
626 for (index, line) in text.lines().enumerate() {
627 <tr>
628 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
629 ((index + 1).to_string())
630 </td>
631 <td class="whitespace-pre px-4 align-top">
632 (if line.is_empty() { " " } else { line })
633 </td>
634 </tr>
635 }
636 </tbody>
637 </table>
638 </div>
639 }
640}
641
642/// The commit log — the most recent commits, newest first, and no paging in v1.
643#[component]
644async fn commit_log(commits: &[CommitSummary]) -> Result {
645 view! {
646 if commits.is_empty() {
647 <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
648 "No commits yet."
649 </p>
650 } else {
651 <ul class="divide-y divide-border rounded-lg border border-border">
652 for commit in commits {
653 <li class="px-4 py-3">
654 <div class="flex items-baseline justify-between gap-4">
655 <p class="text-sm font-medium">(&commit.summary)</p>
656 <code class="shrink-0 font-mono text-xs text-muted-foreground">
657 (commit.id.short())
658 </code>
659 </div>
660 <p class="mt-1 text-xs text-muted-foreground">
661 (&commit.author_name)
662 " committed "
663 <span title=(timestamp(commit.committed_at))>(ago(commit.committed_at))</span>
664 </p>
665 </li>
666 }
667 </ul>
668 }
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use std::time::Duration;
675
676 use super::*;
677
678 fn rev(value: &str) -> RefName {
679 RefName::new(value).expect("valid revision")
680 }
681
682 #[test]
683 fn a_root_tree_url_has_no_separator() {
684 assert_eq!(
685 tree_url("ada", "steid", &rev("main"), &RepoPath::root()),
686 "/ada/repos/steid/tree/main"
687 );
688 }
689
690 #[test]
691 fn a_path_follows_the_separator_with_its_slashes_intact() {
692 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
693
694 assert_eq!(
695 tree_url("ada", "steid", &rev("main"), &path),
696 "/ada/repos/steid/tree/main/-/src/domain/repo.rs"
697 );
698 }
699
700 #[test]
701 fn a_revisions_slashes_are_encoded_so_it_stays_one_segment() {
702 // Otherwise `feature/login` would look like a revision plus a path, which is
703 // the ambiguity the separator exists to remove.
704 assert_eq!(
705 tree_url("ada", "steid", &rev("feature/login"), &RepoPath::root()),
706 "/ada/repos/steid/tree/feature%2Flogin"
707 );
708 }
709
710 #[test]
711 fn names_needing_escaping_are_encoded() {
712 let path = RepoPath::new("docs/a b#c.md").expect("valid");
713
714 assert_eq!(
715 tree_url("ada", "steid", &rev("main"), &path),
716 "/ada/repos/steid/tree/main/-/docs/a%20b%23c.md"
717 );
718 }
719
720 #[test]
721 fn the_log_url_is_the_default_branch_when_no_revision_is_named() {
722 assert_eq!(log_url("ada", "steid", ""), "/ada/repos/steid/log");
723 assert_eq!(
724 log_url("ada", "steid", "feature/login"),
725 "/ada/repos/steid/log/feature%2Flogin"
726 );
727 }
728
729 #[test]
730 fn sizes_read_as_sizes() {
731 assert_eq!(size_of(0), "0 B");
732 assert_eq!(size_of(999), "999 B");
733 assert_eq!(size_of(1024), "1.0 KB");
734 assert_eq!(size_of(1_048_576), "1.0 MB");
735 assert_eq!(size_of(1_572_864), "1.5 MB");
736 }
737
738 #[test]
739 fn elapsed_time_reads_as_words() {
740 let now = SystemTime::now();
741 let since = |seconds| ago(now - Duration::from_secs(seconds));
742
743 assert_eq!(since(5), "just now");
744 assert_eq!(since(60), "1 minute ago");
745 assert_eq!(since(7200), "2 hours ago");
746 assert_eq!(since(86_400 * 3), "3 days ago");
747 assert_eq!(since(86_400 * 400), "1 year ago");
748 }
749
750 #[test]
751 fn a_commit_from_the_future_reads_as_now_rather_than_as_a_negative() {
752 // A commit carries whoever made it's clock, so this happens.
753 assert_eq!(
754 ago(SystemTime::now() + Duration::from_secs(3600)),
755 "just now"
756 );
757 }
758
759 #[test]
760 fn timestamps_are_utc_calendar_dates() {
761 assert_eq!(
762 timestamp(UNIX_EPOCH + Duration::from_secs(0)),
763 "1970-01-01 00:00 UTC"
764 );
765 // 2026-08-29T12:34:00Z
766 assert_eq!(
767 timestamp(UNIX_EPOCH + Duration::from_secs(1_788_006_840)),
768 "2026-08-29 12:34 UTC"
769 );
770 // A leap day, which is what the calendar arithmetic exists to get right.
771 assert_eq!(
772 timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
773 "2024-02-29 00:00 UTC"
774 );
775 }
776}