Skip to main content

max / makenotwork

7.6 KB · 177 lines History Blame Raw
1 //! Forward fence, the hand-written `hx-confirm` ratchet.
2 //!
3 //! Task `b279b9eb`, and the half of its done condition no single change can
4 //! reach: "no template writes `hx-confirm` by hand". Asking before a destructive
5 //! act is a property of the act, so it belongs in the description
6 //! (`quasi_router::Act::confirm`, `screen.rs:2787` with its builder at `:2932`),
7 //! where a terminal host asks in its own way and no renderer can forget to ask.
8 //! An attribute typed into Askama says it to one host only.
9 //!
10 //! There are 50 of them across 33 files and every one leaves as its screen is
11 //! described, so the condition is met by the conversion program rather than by
12 //! an edit. What this seal does is make that monotone: a new template writing
13 //! the attribute by hand fails here, and every conversion lowers the number.
14 //!
15 //! # What this seal is not
16 //!
17 //! It does not, and cannot, say which of the 50 acts destroy something. Only 29
18 //! of the 50 carry a danger class within the six preceding lines, and the
19 //! disagreement runs both ways: `templates/partials/tabs/item_details.html` has
20 //! four confirms and no danger class at all while three of its four acts are
21 //! destructive, and `templates/partials/admin_user_entries.html` has four danger
22 //! classes for four confirms while two of those acts (Unlock at `:60`, Unsuspend
23 //! at `:80`) restore rather than destroy. Ten of the fifty are non-destructive
24 //! and keep their confirm without taking `Tone::Danger`. So a converting change
25 //! reads its own sites; the markup is not evidence in either direction.
26 //!
27 //! The converted side is asserted where it is written, not here: each described
28 //! screen's own tests check that its destructive acts carry both marks and that
29 //! its non-destructive ones carry neither (`src/quasi/ssh_keys.rs`,
30 //! `library_contacts.rs`, `forum_memberships.rs`).
31
32 use std::fs;
33 use std::path::{Path, PathBuf};
34
35 /// Ratchets down only, never up.
36 ///
37 /// 50 across 33 files on 2026-08-20, the measurement the task was written
38 /// against and unchanged since.
39 ///
40 /// Three of the fifty were called out as not this program's to remove:
41 /// `partials/ssh_keys_list.html`, `partials/git_tokens_list.html` and
42 /// `partials/tabs/library_contacts.html`, all backing screens that were already
43 /// described and dual-serving while the switch served nobody. `64b33b26` shipped
44 /// the switch's deletion and took `library_contacts.html` with it, which is the
45 /// 47 to 46 below. The other two stay: they are not tab renderings but the
46 /// fragments `routes::api::ssh_keys` and the git-token routes answer with, and
47 /// the described screens still call those.
48 ///
49 /// 48 on 2026-08-22, and the first two that left without their screens. The
50 /// identical "Delete this blog post?" button in `tabs/project_content.html` and
51 /// `tabs/project_blog.html` is described once in
52 /// `crate::quasi::blog_delete_act` and called from both, which is what the
53 /// glue-module ruling (`27d5e5b8`) bought: a shared act converts at its own
54 /// granularity rather than once per screen. Both take `Tone::Danger`, which the
55 /// templates were saying as `class="danger-text"` to the stylesheet alone.
56 ///
57 /// The two Remove buttons in `partials/link_row.html` and `partials/tag.html`
58 /// were candidates in the same pass and are deliberately not converted: both
59 /// target `hx-target="closest .<class>"`, and a relative selector is not
60 /// something `Action::replaces` can say. Filed rather than approximated.
61 ///
62 /// 47 on 2026-08-23, and NOT a conversion: `tabs/user_profile.html` stopped
63 /// respelling `partials/link_row.html` and includes it instead, so the second
64 /// hand-written copy of "Remove this link?" is gone while the rendered page
65 /// still draws exactly one per row. Worth knowing when reading this number as
66 /// progress: the seal counts template source, so deduplicating two templates
67 /// lowers it without describing anything. The surviving site is still blocked
68 /// on the `closest .link-row` target above.
69 /// 44 on 2026-08-26, and this one was late: the six-panel tier-1 batch
70 /// (`16d8cac4`..`14254786`) deleted `tabs/project_members.html` and
71 /// `tabs/user_projects.html`, each carrying one, and nobody lowered the number.
72 /// The batch ran lib tests only, and this seal is an integration-directory test,
73 /// so it went unread until the suite ran on astra. Same shape as the globals
74 /// seal in `8ed9ad79`, and the same lesson: a ratchet nobody runs is not a
75 /// ratchet. Both are now `Act::confirm` -- "Remove {name} from this project?" in
76 /// `crate::quasi::project_members` and the delete confirm in
77 /// `crate::quasi::user_projects`.
78 ///
79 /// 43 on 2026-08-26, `b25dd957`: `tabs/item_sales.html` is described as
80 /// `crate::quasi::item_sales`, and its Refund button's "Issue a full refund for
81 /// {amount}? This cannot be undone." is `Act::confirm` with `Tone::Danger`.
82 const HIGH_WATER: usize = 43;
83
84 /// Every template, recursively. Walked rather than listed for the reason the
85 /// frontend-globals seal walks `frontend/src`: a fence that has to be told about
86 /// each new file silently stops covering the tree.
87 fn templates() -> Vec<PathBuf> {
88 let mut files = Vec::new();
89 collect(
90 &Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"),
91 &mut files,
92 );
93 files.sort();
94 files
95 }
96
97 fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
98 let Ok(entries) = fs::read_dir(dir) else {
99 return;
100 };
101 for entry in entries {
102 let path = entry.expect("dir entry").path();
103 if path.is_dir() {
104 collect(&path, out);
105 } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
106 out.push(path);
107 }
108 }
109 }
110
111 /// Where the attribute is written, one line per site, sorted by path.
112 ///
113 /// The failure message carries the list rather than a bare number, because a
114 /// ratchet that only says "it rose" leaves the reader to find the site.
115 fn sites() -> Vec<String> {
116 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
117 let mut found = Vec::new();
118
119 for path in templates() {
120 let src = fs::read_to_string(&path).unwrap_or_default();
121 let shown = path
122 .strip_prefix(root)
123 .unwrap_or(&path)
124 .display()
125 .to_string();
126 for (index, line) in src.lines().enumerate() {
127 for _ in 0..line.matches("hx-confirm").count() {
128 found.push(format!("{shown}:{}", index + 1));
129 }
130 }
131 }
132
133 found
134 }
135
136 #[test]
137 fn hand_written_confirms_do_not_grow() {
138 let found = sites();
139 let count = found.len();
140
141 assert!(
142 count <= HIGH_WATER,
143 "hand-written hx-confirm rose to {count} (HIGH_WATER {HIGH_WATER}).\n\
144 Asking before an act is the act's own property: say it with \
145 Act::confirm in the described screen, not as an attribute in Askama. \
146 If you CONVERTED sites, lower HIGH_WATER to {count}.\n{}",
147 found.join("\n")
148 );
149
150 assert_eq!(
151 count, HIGH_WATER,
152 "hand-written hx-confirm fell to {count}. Lower HIGH_WATER to {count}, \
153 and say in its doc comment which screen took them.",
154 );
155 }
156
157 #[test]
158 fn the_seal_reads_every_site_and_not_every_file() {
159 // Four files hold more than one, so a fence counting files rather than
160 // occurrences would report a conversion that removed two of four as
161 // progress on neither. The count is of sites.
162 let found = sites();
163
164 assert!(
165 found.iter().any(|site| site.contains("item_details.html")),
166 "item_details holds four of them: {found:?}"
167 );
168 assert_eq!(
169 found
170 .iter()
171 .filter(|site| site.contains("item_details.html"))
172 .count(),
173 4,
174 "all four, not the file once: {found:?}"
175 );
176 }
177