Skip to main content

max / audiofiles

26.8 KB · 718 lines History Blame Raw
1 //! The settings window's Storage section, described: the libraries on this
2 //! machine, what they hold, and the four things maintenance can do to the one
3 //! that is open.
4 //!
5 //! The largest thing the settings flip left out. `draw_storage_section` was 430
6 //! of `ui/settings_panel.rs`'s 1,182 lines and went with the file, under
7 //! `da48cb6d`: a section goes away at the flip and comes back when it is
8 //! described. This is that.
9 //!
10 //! # The refusal it was carrying, counted rather than restated
11 //!
12 //! [`settings`](super::settings)'s header ruled this section out as "library
13 //! paths, reachability, relocation: the filesystem", and the flip's own task
14 //! said to count what was actually missing before repeating that. Counted, it
15 //! was three things and two of them had already been answered elsewhere:
16 //!
17 //! - **Asking for a place** — Locate on an offline row, and Choose folder on the
18 //! Add Library form. `ec92f9cb` shipped [`Outcome::Locate`] in quasi 0.60 and
19 //! the export destination took it first. These are its second and third
20 //! consumers here.
21 //! - **Reachability and the scan's numbers** — host facts the app has already
22 //! resolved before a frame runs, so they arrive on [`Storage`](super::Storage)
23 //! the way the themes arrive on `S`. No handler reads a disk.
24 //! - **The path itself** — a string the host spells and collapses. The
25 //! description never parses one, and `~` is applied where `dirs::home_dir`
26 //! can be called.
27 //!
28 //! Nothing was left. The refusal was true when it was written and had been
29 //! false for three days, which is the argument for counting: a "no" recorded
30 //! against a vocabulary keeps its wording after the vocabulary moves.
31 //!
32 //! # The row's click is not described, and its confirmation is
33 //!
34 //! The shipped row was clickable, and clicking it asked
35 //! `ConfirmAction::SwitchLibrary` first — but only when
36 //! [`interrupting`](super::Storage::interrupting) said there was work to lose.
37 //! A [`Row::activate`] carries an [`Action`] and not an [`Act`], so it has
38 //! nowhere to put a confirmation. Rather than describe a click that could skip
39 //! the asking, the row activates only when switching is free, and the `Open`
40 //! act beside it is what carries [`Act::confirm`] when it is not. Same rule as
41 //! the shipped panel, said on the control, and it is the fourth
42 //! `ConfirmAction` variant this port has replaced with a builder method.
43 //!
44 //! # What the form's three fields cost, and what has been paid off
45 //!
46 //! `bebfd112` filed the Add Library form as the site where
47 //! `makeover_immediate::group` could not be used: one describable field out of
48 //! three, because the folder picker was a button and a path and the storage
49 //! style was a hand-rolled radio pair. Both halves have closed since —
50 //! `FieldKind::Radio` in makeover-layout 0.8.1, which the shipped form had
51 //! already taken, and [`Outcome::Locate`] for the picker — so all three are
52 //! described here and the form is one question after another with nothing
53 //! hand-rolled between them.
54 //!
55 //! The name is remembered per keystroke rather than held by the renderer, which
56 //! looks like a cost and is a requirement: the picker leaves and comes back, and
57 //! a name living only in the form would not survive the round trip. That is the
58 //! same `changes`-writes-through shape every other control on this screen has.
59 //!
60 //! # THE FINDING: an act has no standing help
61 //!
62 //! Six controls in this section carried an `on_hover_text`, and three of them
63 //! said something the label does not: what Cleanup orphans does to other synced
64 //! devices, that Backfill yields to an analysis you start, that Verify reports
65 //! into the status line. [`Field::hint`](quasi_router::Field::hint) is standing
66 //! help about an *answer* and [`Act::confirm`] is a question asked before a
67 //! *write*; there is nothing that means "standing help about this control".
68 //!
69 //! So the three are prose beside the act instead, which is what the storage
70 //! style's own hint argued for in the shipped form — help that is shown rather
71 //! than hunted for. The other three restated their labels and are gone. Whether
72 //! `Act` should carry a hint is a real question and this is its first consumer;
73 //! a hover is not the shape to ask for, since half the hosts have no pointer.
74 //!
75 //! [`Act`]: quasi_router::Act
76 //! [`Act::confirm`]: quasi_router::Act::confirm
77 //! [`Action`]: quasi_router::Action
78 //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
79 //! [`Row::activate`]: quasi_router::Row::activate
80
81 use quasi_declare::declare;
82 use quasi_router::layout::Tone;
83 use quasi_router::{Action, Choice, Locating, Outcome, Request, Response, RouteError, Router, Tag};
84
85 use super::Panels;
86
87 /// The name a picked folder comes back under.
88 const FOLDER: &str = "folder";
89
90 /// Where a library change leaves you: browsing the library it changed to.
91 const BROWSER: &str = "/";
92
93 /// The name a typed library name is submitted under.
94 const NAME: &str = "name";
95
96 /// What switching costs when there is work in flight.
97 const INTERRUPT: &str =
98 "An import or bulk action is running and will be cancelled. Open this library anyway?";
99
100 /// What removing a library does and does not do.
101 const FORGET: &str =
102 "Remove this library from the list? Its files and database are left where they are.";
103
104 /// The one fact that applies to the storage-style question rather than to
105 /// either answer.
106 const STYLE_HINT: &str = "Cannot be changed after the library is created.";
107
108 /// Register the Storage section's routes.
109 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
110 router
111 .post("/settings/storage/open/{at}", open)
112 .post("/settings/storage/rename/{at}", rename_row)
113 .post("/settings/storage/rename/{at}/save", rename)
114 .post("/settings/storage/cancel-rename", cancel_rename)
115 .post("/settings/storage/forget/{at}", forget)
116 .post("/settings/storage/locate/{at}", locate)
117 .post("/settings/storage/relocate/{at}", relocate)
118 .post("/settings/storage/scan", scan)
119 .post("/settings/storage/orphans", orphans)
120 .post("/settings/storage/backfill", backfill)
121 .post("/settings/storage/verify", verify)
122 .post("/settings/storage/folder", folder)
123 .post("/settings/storage/draft/{key}", draft)
124 .post("/settings/storage/create", create)
125 .post("/settings/storage/add", add_existing)
126 .post("/settings/storage/discard", discard)
127 }
128
129 /// `POST /settings/storage/open/{at}`
130 ///
131 /// Leaves for the browser rather than answering with Settings again, which is
132 /// what opening a library is for. Said as a navigation, since Settings is not a
133 /// window.
134 fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
135 state.storage.open(row(&request)?);
136 Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
137 }
138
139 /// `POST /settings/storage/rename/{at}`
140 ///
141 /// Shows the form; it does not rename anything. The form appears on the next
142 /// frame, because the intent that opens it lands after this answer was built —
143 /// which is what `Runtime::reload` and `Described::stale` are for.
144 fn rename_row(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
145 state.storage.rename_row(Some(row(&request)?));
146 settled(state)
147 }
148
149 /// `POST /settings/storage/rename/{at}/save`
150 ///
151 /// An empty name closes the form and changes nothing, which is the rule
152 /// [`naming`](super::naming) settled for the four modals and the same one the
153 /// shipped inline form followed.
154 fn rename(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
155 let at = row(&request)?;
156 let typed = request.payload.get(NAME).unwrap_or_default().trim();
157 if !typed.is_empty() {
158 state.storage.rename(at, typed);
159 }
160 state.storage.rename_row(None);
161 settled(state)
162 }
163
164 /// `POST /settings/storage/cancel-rename`
165 fn cancel_rename(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
166 state.storage.rename_row(None);
167 settled(state)
168 }
169
170 /// `POST /settings/storage/forget/{at}`
171 fn forget(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
172 state.storage.forget(row(&request)?);
173 settled(state)
174 }
175
176 /// `POST /settings/storage/locate/{at}`
177 ///
178 /// Second consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate) on this
179 /// host. The answer comes back to [`relocate`] with the folder under [`FOLDER`],
180 /// and a reader who backs out of the picker has answered nothing.
181 fn locate(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
182 let at = row(&request)?;
183 Ok(Response::locate(Locating::folder(
184 "Locate library directory",
185 Action::post(format!("/settings/storage/relocate/{at}")),
186 FOLDER,
187 )))
188 }
189
190 /// `POST /settings/storage/relocate/{at}`
191 fn relocate(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
192 let at = row(&request)?;
193 let folder = request.payload.get(FOLDER).unwrap_or_default();
194 if !folder.is_empty() {
195 state.storage.relocate(at, folder);
196 }
197 settled(state)
198 }
199
200 /// `POST /settings/storage/scan`
201 fn scan(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
202 state.storage.rescan();
203 settled(state)
204 }
205
206 /// `POST /settings/storage/orphans`
207 fn orphans(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
208 state.storage.cleanup_orphans();
209 settled(state)
210 }
211
212 /// `POST /settings/storage/backfill`
213 fn backfill(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
214 state.storage.backfill();
215 settled(state)
216 }
217
218 /// `POST /settings/storage/verify`
219 fn verify(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
220 state.storage.verify();
221 settled(state)
222 }
223
224 /// `POST /settings/storage/folder`
225 ///
226 /// Third consumer of [`Outcome::Locate`](quasi_router::Outcome::Locate), and the
227 /// form shape of it rather than the act shape: the answer goes to the route that
228 /// remembers the folder, and the form redraws with the path beside the control.
229 fn folder(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
230 Ok(Response::locate(Locating::folder(
231 "Choose folder",
232 Action::post("/settings/storage/draft/folder"),
233 FOLDER,
234 )))
235 }
236
237 /// `POST /settings/storage/draft/{key}`
238 ///
239 /// One route for the form's three questions, the same shape
240 /// [`settings`](super::settings) uses for every control it owns. An undeclared
241 /// key is a `NotFound` rather than a silent no-op: the address is reachable by
242 /// typing.
243 fn draft(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
244 let key = request.captures.require("key")?;
245 match key {
246 NAME => state
247 .storage
248 .draft_name(request.payload.get(NAME).unwrap_or_default()),
249 FOLDER => {
250 let folder = request.payload.get(FOLDER).unwrap_or_default();
251 if !folder.is_empty() {
252 state.storage.draft_folder(folder);
253 }
254 }
255 "style" => {
256 let style = request.payload.get("style").unwrap_or_default();
257 state.storage.draft_style(style == "reference");
258 }
259 _ => return Err(RouteError::not_found("no such field")),
260 }
261 settled(state)
262 }
263
264 /// `POST /settings/storage/create`
265 fn create(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
266 if !state.storage.draft().ready() {
267 return Err(RouteError::not_found("the form is not finished"));
268 }
269 state.storage.create();
270 Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
271 }
272
273 /// `POST /settings/storage/add`
274 fn add_existing(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
275 if !state.storage.draft().ready() {
276 return Err(RouteError::not_found("the form is not finished"));
277 }
278 state.storage.add_existing();
279 Ok(Response::from(Outcome::Goto(Action::get(BROWSER))))
280 }
281
282 /// `POST /settings/storage/discard`
283 fn discard(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
284 state.storage.discard();
285 settled(state)
286 }
287
288 /// The settings window again, which is what every act here answers with.
289 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
290 super::settings::showing(state)
291 }
292
293 /// The row an address names.
294 fn row(request: &Request) -> Result<usize, RouteError> {
295 request
296 .captures
297 .require("at")?
298 .parse()
299 .map_err(|_| RouteError::not_found("that is not a row"))
300 }
301
302 /// What the Storage section draws, read off the app.
303 pub(super) struct Storage {
304 /// The libraries, one row each.
305 libraries: Vec<Library>,
306 /// The inline rename, against whichever row is being renamed.
307 renaming: Option<Renaming>,
308 /// Scan, and the three maintenance passes over the open library.
309 maintenance: Maintenance,
310 /// Whether the open library references samples in place.
311 loose: bool,
312 /// The Add Library form.
313 draft: Draft,
314 }
315
316 /// One library, as the row it is.
317 struct Library {
318 /// What it is called.
319 name: String,
320 /// Its directory, with the home prefix collapsed the way the reader saw it.
321 shown: String,
322 /// What the row is badged with, where it is badged with anything.
323 badge: Option<&'static str>,
324 /// The one extra line the row carries.
325 ///
326 /// At most one, and structurally rather than by luck: an offline row says
327 /// its last-known path and the open row says what the scan counted, and no
328 /// row is both. The offline row's path was a hover in the shipped panel; a
329 /// hover is a host's, so it is said outright and a terminal or a screen
330 /// reader gets to keep it.
331 meta: Option<String>,
332 /// Where it sits, which is what every one of its addresses is built from.
333 at: usize,
334 /// Switching to it, where it can be switched to.
335 open: Option<Open>,
336 /// Whether it can be pointed at a new directory.
337 locatable: bool,
338 /// Whether it can be forgotten.
339 forgettable: bool,
340 }
341
342 /// Switching to a library that is not the open one.
343 struct Open {
344 /// Whether the switch would interrupt a running job, which is what makes it
345 /// ask first.
346 ///
347 /// The press is offered on the row itself only where it cannot skip an
348 /// asking. See the module header.
349 interrupting: bool,
350 }
351
352 /// The inline rename, against whichever row is being renamed.
353 struct Renaming {
354 /// Where that row sits.
355 at: usize,
356 /// What it is called now.
357 current: String,
358 }
359
360 /// Scan, and the three maintenance passes over the open library.
361 ///
362 /// Each of the three says it is running by being disabled rather than by
363 /// swapping its label, which is the shipped busy state minus the spinner: a
364 /// spinner is a renderer's way of drawing "working", and every host has one or
365 /// has something better.
366 struct Maintenance {
367 /// What the scan control reads.
368 scan: &'static str,
369 /// Whether a scan is running.
370 scanning: bool,
371 /// What the last scan counted, where one has run.
372 counted: Option<Counted>,
373 /// What the backfill control reads.
374 backfill: &'static str,
375 /// Whether the feature backfill is running.
376 backfilling: bool,
377 /// Whether the maintenance worker is busy, which is what the integrity check
378 /// shares its flag with.
379 busy: bool,
380 }
381
382 /// What the last scan counted, and how fresh the numbers are.
383 struct Counted {
384 /// The three totals.
385 totals: String,
386 /// How long ago it ran.
387 age: String,
388 /// Whether that is long enough to say so.
389 tone: Tone,
390 }
391
392 /// The Add Library form: a name, a folder, and how samples are stored.
393 struct Draft {
394 /// What the new library is to be called.
395 name: String,
396 /// The complaint, where a folder is chosen and the name is empty.
397 ///
398 /// The error the shipped form left unexplained: Create New disabled itself
399 /// and said nothing about why.
400 error: Option<&'static str>,
401 /// The folder the host picked, where one was picked.
402 folder: Option<String>,
403 /// Which storage style is chosen.
404 style: &'static str,
405 /// Whether that style is the one that breaks if the originals move.
406 loose: bool,
407 /// Whether the form has enough to act on.
408 ready: bool,
409 /// Whether it has anything in it to discard.
410 started: bool,
411 }
412
413 /// What the section draws, read off the app.
414 pub(super) fn read(state: &Panels<'_>) -> Storage {
415 let all = state.storage.libraries();
416 let scan = state.storage.scan();
417 let interrupting = state.storage.interrupting();
418 let draft = state.storage.draft();
419 let scanning = state.storage.scanning();
420 let backfilling = state.storage.backfilling();
421 Storage {
422 libraries: all
423 .iter()
424 .enumerate()
425 .map(|(at, entry)| Library {
426 name: entry.name.clone(),
427 shown: entry.shown.clone(),
428 badge: if entry.active {
429 Some("active")
430 } else if entry.reachable {
431 None
432 } else {
433 Some("offline")
434 },
435 // Only the open library has been counted: the scan reads the
436 // database that is open, and the shipped rows were path-only
437 // for the rest.
438 meta: match (entry.active, entry.reachable, scan) {
439 (true, _, Some(scan)) => Some(format!(
440 "{} samples \u{b7} {}",
441 scan.samples,
442 bytes(scan.total_bytes),
443 )),
444 (false, false, _) => Some(format!("Last known path: {}", entry.path)),
445 _ => None,
446 },
447 at,
448 open: (!entry.active && entry.reachable).then_some(Open { interrupting }),
449 locatable: !entry.active && !entry.reachable,
450 forgettable: !entry.active,
451 })
452 .collect(),
453 renaming: state.storage.renaming().map(|at| Renaming {
454 at,
455 current: all
456 .get(at)
457 .map(|entry| entry.name.clone())
458 .unwrap_or_default(),
459 }),
460 maintenance: Maintenance {
461 scan: if scanning { "Scanning..." } else { "Scan" },
462 scanning,
463 counted: scan.map(|scan| {
464 let (age, stale) = scan_age(scan.age_secs);
465 Counted {
466 totals: format!(
467 "{} samples, {} total, {} database",
468 scan.samples,
469 bytes(scan.total_bytes),
470 bytes(scan.db_bytes),
471 ),
472 age,
473 tone: if stale { Tone::Warning } else { Tone::Neutral },
474 }
475 }),
476 backfill: if backfilling {
477 "Backfilling audio features..."
478 } else {
479 "Backfill audio features"
480 },
481 backfilling,
482 busy: state.storage.busy(),
483 },
484 loose: state.storage.loose_files(),
485 draft: Draft {
486 error: (draft.folder.is_some() && draft.name.trim().is_empty())
487 .then_some("A library needs a name."),
488 style: if draft.reference_in_place {
489 "reference"
490 } else {
491 "copy"
492 },
493 loose: draft.reference_in_place,
494 ready: draft.ready(),
495 started: draft.started(),
496 name: draft.name,
497 folder: draft.folder,
498 },
499 }
500 }
501
502 declare! {
503 /// The whole section, spliced into the settings body.
504 ///
505 /// Nodes rather than a `Slot` handed in and handed back, which is the shape
506 /// a declaration is refused and the shape the settings screen cannot splice
507 /// now that it is one. The three sections beside this one made the same
508 /// move.
509 pub(super) shape section(storage: &Storage) -> Vec<Node>;
510
511 section "Storage";
512 text "Each library is an independent sample collection with its own \
513 database and files. A library can contain multiple vaults (top-level \
514 browse buckets).";
515 include libraries(storage);
516
517 for renaming in storage.renaming.iter() {
518 include rename_form(renaming);
519 }
520
521 extend maintenance(&storage.maintenance);
522
523 toned "This library uses loose-files mode. Samples are referenced in place, \
524 not duplicated." Tone::Warning when storage.loose;
525
526 extend add_library(&storage.draft);
527 }
528
529 declare! {
530 /// The libraries, one row each.
531 shape libraries(storage: &Storage) -> Node;
532
533 list {
534 for library in storage.libraries.iter() {
535 row &library.name {
536 secondary &library.shown;
537 for &badge in library.badge.iter() {
538 token Tag::badge(badge);
539 }
540 for line in library.meta.iter() {
541 meta line;
542 }
543
544 for open in library.open.iter() {
545 // See the module header: the click is only offered where it
546 // cannot skip an asking.
547 activate to post "/settings/storage/open/{library.at}"
548 unless open.interrupting;
549 act "Open" to post "/settings/storage/open/{library.at}" {
550 confirm INTERRUPT when open.interrupting;
551 }
552 }
553
554 act "Rename" to post "/settings/storage/rename/{library.at}";
555 act "Locate" to post "/settings/storage/locate/{library.at}"
556 when library.locatable;
557 act "Remove" to post "/settings/storage/forget/{library.at}"
558 when library.forgettable {
559 tone Danger;
560 confirm FORGET;
561 }
562 }
563 }
564 }
565 }
566
567 declare! {
568 /// The inline rename, against whichever row is being renamed.
569 shape rename_form(renaming: &Renaming) -> Node;
570
571 form post "/settings/storage/rename/{renaming.at}/save" {
572 submit "Save";
573 field Text NAME "New name" {
574 value &renaming.current;
575 }
576 }
577 }
578
579 declare! {
580 /// Scan, and the three maintenance passes over the open library. See
581 /// [`Maintenance`].
582 shape maintenance(maintenance: &Maintenance) -> Vec<Node>;
583
584 act maintenance.scan to post "/settings/storage/scan" {
585 disabled when maintenance.scanning;
586 }
587 for counted in maintenance.counted.iter() {
588 text &counted.totals;
589 toned &counted.age counted.tone;
590 }
591
592 text "Free disk by deleting samples no longer referenced anywhere in the \
593 library. Local-only: other synced devices keep their own copies.";
594 act "Cleanup orphans" to post "/settings/storage/orphans";
595
596 text "Compute the audio feature data used by tag suggestions for samples \
597 that don't have it yet. Runs in the background: keep working; it \
598 yields to any analysis you start and resumes later.";
599 act maintenance.backfill to post "/settings/storage/backfill" {
600 disabled when maintenance.backfilling;
601 }
602
603 text "Re-hash every stored sample and confirm its bytes still match its \
604 content address. Catches silent on-disk corruption. Runs in the \
605 background: the result appears in the status line.";
606 act "Verify library integrity" to post "/settings/storage/verify" {
607 disabled when maintenance.busy;
608 }
609 }
610
611 declare! {
612 /// The Add Library form: a name, a folder, and how samples are stored.
613 shape add_library(draft: &Draft) -> Vec<Node>;
614
615 section "Add Library";
616 field Text "create_name" "Name" {
617 value &draft.name;
618 required;
619 for &why in draft.error.iter() {
620 error why;
621 }
622 writes Action::post("/settings/storage/draft/name");
623 }
624 act "Choose folder..." to post "/settings/storage/folder";
625 for folder in draft.folder.iter() {
626 text folder;
627 }
628
629 // A radio and not a select, which is what `FieldKind::Radio` was added for
630 // in makeover-layout 0.8.1 and this is the call site named in its argument:
631 // the alternatives to an irreversible choice have to be readable without
632 // opening anything.
633 field Radio "style" "Storage style" {
634 option Choice::new("copy", "Copy samples into library (recommended)");
635 option Choice::new("reference", "Reference samples in place (loose-files mode)");
636 value draft.style;
637 hint STYLE_HINT;
638 writes Action::post("/settings/storage/draft/style");
639 }
640 toned "Moving or deleting originals will break references. This cannot be \
641 undone." Tone::Warning when draft.loose;
642
643 text "Create New makes an empty library in that folder. Add Existing adopts \
644 one that is already there.";
645 act "Create New" to post "/settings/storage/create" {
646 disabled unless draft.ready;
647 }
648 act "Add Existing" to post "/settings/storage/add" {
649 disabled unless draft.ready;
650 }
651 act "Cancel" to post "/settings/storage/discard" {
652 disabled unless draft.started;
653 }
654 }
655
656 /// A byte count, as the app spells one everywhere else.
657 fn bytes(count: u64) -> String {
658 crate::ui::widgets::format_bytes(count)
659 }
660
661 /// How fresh the scan's numbers are, and whether they are stale enough to say so.
662 ///
663 /// Lifted from the deleted `ui/settings_panel.rs` with its threshold intact: a
664 /// day old is when cached numbers stop being worth trusting.
665 fn scan_age(age_secs: i64) -> (String, bool) {
666 let stale = age_secs >= 86_400;
667 // The shipped copy ran ", re-scan to refresh." straight onto "ago." and
668 // read as "ago., re-scan". Restored as a sentence of its own.
669 let suffix = if stale { " Re-scan to refresh." } else { "" };
670 let text = if age_secs < 120 {
671 format!("Last scanned just now.{suffix}")
672 } else if age_secs < 3_600 {
673 format!("Last scanned {} minutes ago.{suffix}", age_secs / 60)
674 } else if age_secs < 86_400 {
675 let hours = age_secs / 3_600;
676 format!(
677 "Last scanned {hours} hour{} ago.{suffix}",
678 if hours == 1 { "" } else { "s" }
679 )
680 } else {
681 let days = age_secs / 86_400;
682 format!(
683 "Last scanned {days} day{} ago.{suffix}",
684 if days == 1 { "" } else { "s" }
685 )
686 };
687 (text, stale)
688 }
689
690 #[cfg(test)]
691 mod tests {
692 use super::scan_age;
693
694 #[test]
695 fn scan_age_reads_fresh_and_stale() {
696 assert_eq!(scan_age(30), ("Last scanned just now.".to_owned(), false));
697 assert_eq!(
698 scan_age(300),
699 ("Last scanned 5 minutes ago.".to_owned(), false)
700 );
701 assert_eq!(
702 scan_age(3_600),
703 ("Last scanned 1 hour ago.".to_owned(), false)
704 );
705 assert_eq!(
706 scan_age(7_200),
707 ("Last scanned 2 hours ago.".to_owned(), false)
708 );
709 let (text, stale) = scan_age(172_800);
710 assert!(stale);
711 assert_eq!(text, "Last scanned 2 days ago. Re-scan to refresh.");
712 assert_eq!(
713 scan_age(90_000).0,
714 "Last scanned 1 day ago. Re-scan to refresh."
715 );
716 }
717 }
718