Skip to main content

max / makenotwork

5.3 KB · 144 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 /// Two sites are deliberately not converted: the Remove buttons in
41 /// `partials/link_row.html` and `partials/tag.html` both target
42 /// `hx-target="closest .<class>"`, and a relative selector is not something
43 /// `Action::replaces` can say. Filed rather than approximated.
44 ///
45 /// `partials/ssh_keys_list.html` and `partials/git_tokens_list.html` are not
46 /// this program's to remove either: they are not tab renderings but the
47 /// fragments `routes::api::ssh_keys` and the git-token routes answer with, and
48 /// the described screens still call those.
49 const HIGH_WATER: usize = 43;
50
51 /// Every template, recursively. Walked rather than listed for the reason the
52 /// frontend-globals seal walks `frontend/src`: a fence that has to be told about
53 /// each new file silently stops covering the tree.
54 fn templates() -> Vec<PathBuf> {
55 let mut files = Vec::new();
56 collect(
57 &Path::new(env!("CARGO_MANIFEST_DIR")).join("templates"),
58 &mut files,
59 );
60 files.sort();
61 files
62 }
63
64 fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
65 let Ok(entries) = fs::read_dir(dir) else {
66 return;
67 };
68 for entry in entries {
69 let path = entry.expect("dir entry").path();
70 if path.is_dir() {
71 collect(&path, out);
72 } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
73 out.push(path);
74 }
75 }
76 }
77
78 /// Where the attribute is written, one line per site, sorted by path.
79 ///
80 /// The failure message carries the list rather than a bare number, because a
81 /// ratchet that only says "it rose" leaves the reader to find the site.
82 fn sites() -> Vec<String> {
83 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
84 let mut found = Vec::new();
85
86 for path in templates() {
87 let src = fs::read_to_string(&path).unwrap_or_default();
88 let shown = path
89 .strip_prefix(root)
90 .unwrap_or(&path)
91 .display()
92 .to_string();
93 for (index, line) in src.lines().enumerate() {
94 for _ in 0..line.matches("hx-confirm").count() {
95 found.push(format!("{shown}:{}", index + 1));
96 }
97 }
98 }
99
100 found
101 }
102
103 #[test]
104 fn hand_written_confirms_do_not_grow() {
105 let found = sites();
106 let count = found.len();
107
108 assert!(
109 count <= HIGH_WATER,
110 "hand-written hx-confirm rose to {count} (HIGH_WATER {HIGH_WATER}).\n\
111 Asking before an act is the act's own property: say it with \
112 Act::confirm in the described screen, not as an attribute in Askama. \
113 If you CONVERTED sites, lower HIGH_WATER to {count}.\n{}",
114 found.join("\n")
115 );
116
117 assert_eq!(
118 count, HIGH_WATER,
119 "hand-written hx-confirm fell to {count}. Lower HIGH_WATER to {count}, \
120 and say in its doc comment which screen took them.",
121 );
122 }
123
124 #[test]
125 fn the_seal_reads_every_site_and_not_every_file() {
126 // Four files hold more than one, so a fence counting files rather than
127 // occurrences would report a conversion that removed two of four as
128 // progress on neither. The count is of sites.
129 let found = sites();
130
131 assert!(
132 found.iter().any(|site| site.contains("item_details.html")),
133 "item_details holds four of them: {found:?}"
134 );
135 assert_eq!(
136 found
137 .iter()
138 .filter(|site| site.contains("item_details.html"))
139 .count(),
140 4,
141 "all four, not the file once: {found:?}"
142 );
143 }
144