Skip to main content

max / makenotwork

7.4 KB · 178 lines History Blame Raw
1 //! Forward fence, the hand-written `hx-confirm` ratchet.
2 //!
3 //! No template writes `hx-confirm` by hand. Asking before a destructive
4 //! act is a property of the act, so it belongs in the description
5 //! (`quasi_router::Act::confirm`, `screen.rs:2787` with its builder at `:2932`),
6 //! where a terminal host asks in its own way and no renderer can forget to ask.
7 //! An attribute typed into Askama says it to one host only.
8 //!
9 //! Each remaining site leaves as its screen is described, so the condition is
10 //! met by the conversion program rather than by an edit. What this seal does is
11 //! make that monotone: a new template writing the attribute by hand fails here,
12 //! and every conversion lowers the number.
13 //!
14 //! # What this seal is not
15 //!
16 //! It does not, and cannot, say which acts destroy something. A danger class
17 //! near a confirm proves nothing in either direction:
18 //! `templates/partials/tabs/item_details.html` carries four confirms and no
19 //! danger class while three of its four acts are destructive, and
20 //! `templates/partials/admin_user_entries.html` carries four danger classes for
21 //! four confirms while two of those acts restore rather than destroy. Some
22 //! confirms are non-destructive and keep their confirm without taking
23 //! `Tone::Danger`. So a converting change reads its own sites; the markup is not
24 //! evidence.
25 //!
26 //! The converted side is asserted where it is written, not here: each described
27 //! screen's own tests check that its destructive acts carry both marks and that
28 //! its non-destructive ones carry neither (`src/quasi/ssh_keys.rs`,
29 //! `library_contacts.rs`, `forum_memberships.rs`).
30
31 use std::fs;
32 use std::path::{Path, PathBuf};
33
34 /// Ratchets down only, never up.
35 ///
36 /// The seal counts template source, so deduplicating two templates lowers it
37 /// without describing anything. Read it against what moved before treating it
38 /// as a progress figure.
39 ///
40 /// 43 to 30: thirteen sites left through glue modules, which is the whole of
41 /// what a described act can say against a route the description layer does not
42 /// serve. `session_acts` took the three in `tabs/user_sessions.html`,
43 /// `promo_code_acts` the two in `partials/promo_codes_list.html`, `repo_acts`
44 /// the two in `tabs/project_code.html`, `library_acts` the two in
45 /// `tabs/library_collections.html`, and `clip_acts`, `license_key_act`,
46 /// `cart_act` and `link_remove_act` one each from `partials/insertion_list.html`,
47 /// `partials/item_license_keys.html`, `pages/cart.html` and
48 /// `partials/link_row.html`. `clip_acts` also took the Remove in
49 /// `partials/placement_list.html`, which the seal never counted: it is
50 /// destructive with no confirm, and describing it beside its asking twin is
51 /// where the ruling's separation is proved.
52 ///
53 /// `link_row.html` was one of the two the shape recorded as unsayable.
54 /// `Action::replacing_enclosing` says it now. The other, `partials/tag.html`,
55 /// stays: its button reads a glyph and takes its name from `aria-label`, and a
56 /// described act renders its label as text, so converting it would move a
57 /// prompt and lose a name.
58 ///
59 /// What is left splits four ways, none of it a matter of effort. Measured
60 /// 2026-09-01 by reading the verb, target and swap at each of the 30:
61 ///
62 /// - Ten carry no `hx-target` and refresh, reload or navigate through
63 /// `data-after` glue. `Action::invalidating` emits `data-replaces` and
64 /// nothing under `frontend/src` performs it, so describing one today would
65 /// be describing a control that stops working.
66 /// - Ten answer into a region with `hx-swap="innerHTML"`: the four admin
67 /// entry partials, both admin dashboards, the broadcast result, and the two
68 /// dual-serve fragments below. Every described act emits
69 /// `hx-swap="outerMorph"`, so the container would be replaced by its own
70 /// contents and the next press would find no target.
71 /// - Five are submit buttons inside a form that carries the verb. An act is a
72 /// control with an action of its own, so these convert with their form.
73 /// - Five are neither: two tag glyphs (above), the two domain rows in
74 /// `tabs/user_profile.html`, which target `closest .form-section` and also
75 /// run a `data-after` verb, and the Remove in `tabs/library_purchases.html`,
76 /// which is a row of a context menu rather than a button standing among
77 /// others.
78 ///
79 /// `partials/ssh_keys_list.html` and `partials/git_tokens_list.html` are not
80 /// this program's to remove either: they are not tab renderings but the
81 /// fragments `routes::api::ssh_keys` and the git-token routes answer with, and
82 /// the described screens still call those.
83 const HIGH_WATER: usize = 30;
84
85 /// Every template, recursively. Walked rather than listed for the reason the
86 /// frontend-globals seal walks `frontend/src`: a fence that has to be told about
87 /// each new file silently stops covering the tree.
88 fn templates() -> Vec<PathBuf> {
89 let mut files = Vec::new();
90 collect(
91 &Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"),
92 &mut files,
93 );
94 files.sort();
95 files
96 }
97
98 fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
99 let Ok(entries) = fs::read_dir(dir) else {
100 return;
101 };
102 for entry in entries {
103 let path = entry.expect("dir entry").path();
104 if path.is_dir() {
105 collect(&path, out);
106 } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
107 out.push(path);
108 }
109 }
110 }
111
112 /// Where the attribute is written, one line per site, sorted by path.
113 ///
114 /// The failure message carries the list rather than a bare number, because a
115 /// ratchet that only says "it rose" leaves the reader to find the site.
116 fn sites() -> Vec<String> {
117 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
118 let mut found = Vec::new();
119
120 for path in templates() {
121 let src = fs::read_to_string(&path).unwrap_or_default();
122 let shown = path
123 .strip_prefix(root)
124 .unwrap_or(&path)
125 .display()
126 .to_string();
127 for (index, line) in src.lines().enumerate() {
128 for _ in 0..line.matches("hx-confirm").count() {
129 found.push(format!("{shown}:{}", index + 1));
130 }
131 }
132 }
133
134 found
135 }
136
137 #[test]
138 fn hand_written_confirms_do_not_grow() {
139 let found = sites();
140 let count = found.len();
141
142 assert!(
143 count <= HIGH_WATER,
144 "hand-written hx-confirm rose to {count} (HIGH_WATER {HIGH_WATER}).\n\
145 Asking before an act is the act's own property: say it with \
146 Act::confirm in the described screen, not as an attribute in Askama. \
147 If you CONVERTED sites, lower HIGH_WATER to {count}.\n{}",
148 found.join("\n")
149 );
150
151 assert_eq!(
152 count, HIGH_WATER,
153 "hand-written hx-confirm fell to {count}. Lower HIGH_WATER to {count}, \
154 and say in its doc comment which screen took them.",
155 );
156 }
157
158 #[test]
159 fn the_seal_reads_every_site_and_not_every_file() {
160 // Four files hold more than one, so a fence counting files rather than
161 // occurrences would report a conversion that removed two of four as
162 // progress on neither. The count is of sites.
163 let found = sites();
164
165 assert!(
166 found.iter().any(|site| site.contains("item_details.html")),
167 "item_details holds four of them: {found:?}"
168 );
169 assert_eq!(
170 found
171 .iter()
172 .filter(|site| site.contains("item_details.html"))
173 .count(),
174 4,
175 "all four, not the file once: {found:?}"
176 );
177 }
178