//! Forward fence, the hand-written `hx-confirm` ratchet. //! //! Task `b279b9eb`, and the half of its done condition no single change can //! reach: "no template writes `hx-confirm` by hand". Asking before a destructive //! act is a property of the act, so it belongs in the description //! (`quasi_router::Act::confirm`, `screen.rs:2787` with its builder at `:2932`), //! where a terminal host asks in its own way and no renderer can forget to ask. //! An attribute typed into Askama says it to one host only. //! //! There are 50 of them across 33 files and every one leaves as its screen is //! described, so the condition is met by the conversion program rather than by //! an edit. What this seal does is make that monotone: a new template writing //! the attribute by hand fails here, and every conversion lowers the number. //! //! # What this seal is not //! //! It does not, and cannot, say which of the 50 acts destroy something. Only 29 //! of the 50 carry a danger class within the six preceding lines, and the //! disagreement runs both ways: `templates/partials/tabs/item_details.html` has //! four confirms and no danger class at all while three of its four acts are //! destructive, and `templates/partials/admin_user_entries.html` has four danger //! classes for four confirms while two of those acts (Unlock at `:60`, Unsuspend //! at `:80`) restore rather than destroy. Ten of the fifty are non-destructive //! and keep their confirm without taking `Tone::Danger`. So a converting change //! reads its own sites; the markup is not evidence in either direction. //! //! The converted side is asserted where it is written, not here: each described //! screen's own tests check that its destructive acts carry both marks and that //! its non-destructive ones carry neither (`src/quasi/ssh_keys.rs`, //! `library_contacts.rs`, `forum_memberships.rs`). use std::fs; use std::path::{Path, PathBuf}; /// Ratchets down only, never up. /// /// 50 across 33 files on 2026-08-20, the measurement the task was written /// against and unchanged since. /// /// Three of the fifty were called out as not this program's to remove: /// `partials/ssh_keys_list.html`, `partials/git_tokens_list.html` and /// `partials/tabs/library_contacts.html`, all backing screens that were already /// described and dual-serving while the switch served nobody. `64b33b26` shipped /// the switch's deletion and took `library_contacts.html` with it, which is the /// 47 to 46 below. The other two stay: they are not tab renderings but the /// fragments `routes::api::ssh_keys` and the git-token routes answer with, and /// the described screens still call those. /// /// 48 on 2026-08-22, and the first two that left without their screens. The /// identical "Delete this blog post?" button in `tabs/project_content.html` and /// `tabs/project_blog.html` is described once in /// `crate::quasi::blog_delete_act` and called from both, which is what the /// glue-module ruling (`27d5e5b8`) bought: a shared act converts at its own /// granularity rather than once per screen. Both take `Tone::Danger`, which the /// templates were saying as `class="danger-text"` to the stylesheet alone. /// /// The two Remove buttons in `partials/link_row.html` and `partials/tag.html` /// were candidates in the same pass and are deliberately not converted: both /// target `hx-target="closest ."`, and a relative selector is not /// something `Action::replaces` can say. Filed rather than approximated. /// /// 47 on 2026-08-23, and NOT a conversion: `tabs/user_profile.html` stopped /// respelling `partials/link_row.html` and includes it instead, so the second /// hand-written copy of "Remove this link?" is gone while the rendered page /// still draws exactly one per row. Worth knowing when reading this number as /// progress: the seal counts template source, so deduplicating two templates /// lowers it without describing anything. The surviving site is still blocked /// on the `closest .link-row` target above. /// 44 on 2026-08-26, and this one was late: the six-panel tier-1 batch /// (`16d8cac4`..`14254786`) deleted `tabs/project_members.html` and /// `tabs/user_projects.html`, each carrying one, and nobody lowered the number. /// The batch ran lib tests only, and this seal is an integration-directory test, /// so it went unread until the suite ran on astra. Same shape as the globals /// seal in `8ed9ad79`, and the same lesson: a ratchet nobody runs is not a /// ratchet. Both are now `Act::confirm` -- "Remove {name} from this project?" in /// `crate::quasi::project_members` and the delete confirm in /// `crate::quasi::user_projects`. /// /// 43 on 2026-08-26, `b25dd957`: `tabs/item_sales.html` is described as /// `crate::quasi::item_sales`, and its Refund button's "Issue a full refund for /// {amount}? This cannot be undone." is `Act::confirm` with `Tone::Danger`. const HIGH_WATER: usize = 43; /// Every template, recursively. Walked rather than listed for the reason the /// frontend-globals seal walks `frontend/src`: a fence that has to be told about /// each new file silently stops covering the tree. fn templates() -> Vec { let mut files = Vec::new(); collect( &Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"), &mut files, ); files.sort(); files } fn collect(dir: &Path, out: &mut Vec) { let Ok(entries) = fs::read_dir(dir) else { return; }; for entry in entries { let path = entry.expect("dir entry").path(); if path.is_dir() { collect(&path, out); } else if path.extension().and_then(|e| e.to_str()) == Some("html") { out.push(path); } } } /// Where the attribute is written, one line per site, sorted by path. /// /// The failure message carries the list rather than a bare number, because a /// ratchet that only says "it rose" leaves the reader to find the site. fn sites() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let mut found = Vec::new(); for path in templates() { let src = fs::read_to_string(&path).unwrap_or_default(); let shown = path .strip_prefix(root) .unwrap_or(&path) .display() .to_string(); for (index, line) in src.lines().enumerate() { for _ in 0..line.matches("hx-confirm").count() { found.push(format!("{shown}:{}", index + 1)); } } } found } #[test] fn hand_written_confirms_do_not_grow() { let found = sites(); let count = found.len(); assert!( count <= HIGH_WATER, "hand-written hx-confirm rose to {count} (HIGH_WATER {HIGH_WATER}).\n\ Asking before an act is the act's own property: say it with \ Act::confirm in the described screen, not as an attribute in Askama. \ If you CONVERTED sites, lower HIGH_WATER to {count}.\n{}", found.join("\n") ); assert_eq!( count, HIGH_WATER, "hand-written hx-confirm fell to {count}. Lower HIGH_WATER to {count}, \ and say in its doc comment which screen took them.", ); } #[test] fn the_seal_reads_every_site_and_not_every_file() { // Four files hold more than one, so a fence counting files rather than // occurrences would report a conversion that removed two of four as // progress on neither. The count is of sites. let found = sites(); assert!( found.iter().any(|site| site.contains("item_details.html")), "item_details holds four of them: {found:?}" ); assert_eq!( found .iter() .filter(|site| site.contains("item_details.html")) .count(), 4, "all four, not the file once: {found:?}" ); }