//! Forward fence, the hand-written `hx-confirm` ratchet. //! //! 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. //! //! Each remaining site 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 acts destroy something. A danger class //! near a confirm proves nothing in either direction: //! `templates/partials/tabs/item_details.html` carries four confirms and no //! danger class while three of its four acts are destructive, and //! `templates/partials/admin_user_entries.html` carries four danger classes for //! four confirms while two of those acts restore rather than destroy. Some //! confirms are non-destructive and keep their confirm without taking //! `Tone::Danger`. So a converting change reads its own sites; the markup is not //! evidence. //! //! 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. /// /// The seal counts template source, so deduplicating two templates lowers it /// without describing anything. Read it against what moved before treating it /// as a progress figure. /// /// 43 to 30: thirteen sites left through glue modules, which is the whole of /// what a described act can say against a route the description layer does not /// serve. `session_acts` took the three in `tabs/user_sessions.html`, /// `promo_code_acts` the two in `partials/promo_codes_list.html`, `repo_acts` /// the two in `tabs/project_code.html`, `library_acts` the two in /// `tabs/library_collections.html`, and `clip_acts`, `license_key_act`, /// `cart_act` and `link_remove_act` one each from `partials/insertion_list.html`, /// `partials/item_license_keys.html`, `pages/cart.html` and /// `partials/link_row.html`. `clip_acts` also took the Remove in /// `partials/placement_list.html`, which the seal never counted: it is /// destructive with no confirm, and describing it beside its asking twin is /// where the ruling's separation is proved. /// /// `link_row.html` was one of the two the shape recorded as unsayable. /// `Action::replacing_enclosing` says it now. The other, `partials/tag.html`, /// stays: its button reads a glyph and takes its name from `aria-label`, and a /// described act renders its label as text, so converting it would move a /// prompt and lose a name. /// /// What is left splits four ways, none of it a matter of effort. Measured /// 2026-09-01 by reading the verb, target and swap at each of the 30: /// /// - Ten carry no `hx-target` and refresh, reload or navigate through /// `data-after` glue. `Action::invalidating` emits `data-replaces` and /// nothing under `frontend/src` performs it, so describing one today would /// be describing a control that stops working. /// - Ten answer into a region with `hx-swap="innerHTML"`: the four admin /// entry partials, both admin dashboards, the broadcast result, and the two /// dual-serve fragments below. Every described act emits /// `hx-swap="outerMorph"`, so the container would be replaced by its own /// contents and the next press would find no target. /// - Five are submit buttons inside a form that carries the verb. An act is a /// control with an action of its own, so these convert with their form. /// - Five are neither: two tag glyphs (above), the two domain rows in /// `tabs/user_profile.html`, which target `closest .form-section` and also /// run a `data-after` verb, and the Remove in `tabs/library_purchases.html`, /// which is a row of a context menu rather than a button standing among /// others. /// /// `partials/ssh_keys_list.html` and `partials/git_tokens_list.html` are not /// this program's to remove either: 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. const HIGH_WATER: usize = 30; /// 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:?}" ); }