use crate::web::viewer::Viewer;
use camino::Utf8Path;
use std::fmt::{self, Display};
pub fn record(viewer: &Viewer, action: impl Display) {
let actor = if viewer.is_signed_in() {
viewer.username()
} else {
GUEST
};
record_as(actor, action);
}
pub fn record_as(actor: &str, action: impl Display) {
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
println!("{}", line(&now, actor, &action));
}
pub const GUEST: &str = "guest";
fn line(time: &dyn Display, actor: &str, action: &dyn Display) -> String {
format!("{time} {actor} {action}")
}
pub fn quoted(text: &str) -> impl Display + '_ {
Quoted(text)
}
struct Quoted<'a>(&'a str);
impl Display for Quoted<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self.0)
}
}
pub fn file<'a>(base: &Utf8Path, path: &'a Utf8Path) -> impl Display + 'a {
File(path.strip_prefix(base).unwrap_or(path))
}
struct File<'a>(&'a Utf8Path);
impl Display for File<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = if self.0.extension() == Some("menu") {
"menu"
} else {
"recipe"
};
write!(f, "{kind} {}", quoted(self.0.as_str()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_line_reads_as_a_sentence() {
assert_eq!(
line(&"2026-09-27 08:41:56", "alice", &"signed in"),
"2026-09-27 08:41:56 alice signed in"
);
}
#[test]
fn files_are_named_by_kind_and_relative_path() {
let base = Utf8Path::new("/recipes");
assert_eq!(
file(base, Utf8Path::new("/recipes/Soups/Pea soup.cook")).to_string(),
"recipe \"Soups/Pea soup.cook\""
);
assert_eq!(
file(base, Utf8Path::new("/recipes/Week.menu")).to_string(),
"menu \"Week.menu\""
);
assert_eq!(
file(base, Utf8Path::new("/elsewhere/A.cook")).to_string(),
"recipe \"/elsewhere/A.cook\""
);
}
#[test]
fn request_text_cannot_break_the_line() {
let forged = "Soup\n2026-09-27 08:00:00 admin deleted everything";
let shown = quoted(forged).to_string();
assert!(!shown.contains('\n'), "{shown}");
assert_eq!(quoted("Crème brûlée").to_string(), "\"Crème brûlée\"");
}
}