Skip to main content

release_kit/self_depend/
leftovers.rs

1//! What a predecessor bump mechanism left in a target.
2//!
3//! The wiring `rk self-depend` lands is a replacement, never an addition: two
4//! mechanisms over the same two files fight or silently undo each other.
5//! This catalog is the hand-rolled recipe this repository itself
6//! published, plus the host install it supersedes. A file entry matches
7//! on its content and never on its name alone, so an unrelated file of
8//! the same name is never removed; a line entry names the file, the line,
9//! and the matched text. The needles are grammars, in the same class as
10//! the pin prefix: source constants, never authored text.
11
12use camino::Utf8Path;
13use serde::Serialize;
14
15use super::has_check_line;
16use super::pin::PIN_PREFIX;
17use crate::error::RkError;
18
19/// What the cleanup does with one leftover.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Action {
23    /// Delete the file: it exists only for the predecessor mechanism.
24    RemoveFile,
25    /// Rewrite `.envrc`: the invocation lines go, the sync line takes
26    /// the first one's place.
27    ReplaceLine,
28    /// Remove nothing; the report names the file, the line, and the reason.
29    Manual,
30}
31
32/// One artifact of a predecessor mechanism.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Leftover {
35    /// The catalog entry: `bump-script`, `autobump-script`, `bump-suite`,
36    /// `autobump-suite`, `envrc-invocation`, `envrc-switch`,
37    /// `just-recipe`, `mise-task`, `devshell-tooling`, or `host-install`.
38    pub id: &'static str,
39    /// The file, relative to the target.
40    pub file: String,
41    /// The one-based line, for a line entry.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub line: Option<usize>,
44    /// The matched line, trimmed, for a line entry.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub text: Option<String>,
47    /// What the cleanup does with it.
48    pub action: Action,
49    /// Why that action, one phrase.
50    pub reason: &'static str,
51}
52
53/// The lines a predecessor's `.envrc` invocation names: the hand-rolled
54/// scripts, and the sync line rk 0.9.14 and older seeded, which the check
55/// line replaces.
56const ENVRC_NEEDLES: [&str; 3] = ["rk-bump", "rk-autobump", "rk self-depend sync"];
57
58/// The host install lines the devshell pin supersedes.
59const HOST_INSTALL_NEEDLES: [&str; 2] = ["cargo install release-kit", "cargo binstall release-kit"];
60
61/// The switch the predecessor read from `.envrc.local`.
62const SWITCH_NEEDLE: &str = "RK_SKIP_AUTOBUMP";
63
64/// The files a host install line is looked for in, beside the workflows:
65/// the prose and recipe files, and the manager files whose hooks and
66/// tasks can run an install.
67const HOST_INSTALL_FILES: [&str; 8] = [
68    "README.md",
69    "justfile",
70    "Makefile",
71    ".envrc",
72    ".gitlab-ci.yml",
73    "mise.toml",
74    ".mise.toml",
75    "devbox.json",
76];
77
78/// The mise task names a predecessor bump recipe takes.
79const MISE_TASK_NEEDLES: [&str; 2] = ["rk-bump", "rk-autobump"];
80
81/// Scan a target for every leftover the catalog knows, in catalog order.
82///
83/// # Errors
84///
85/// Returns [`RkError::Io`] where the workflows directory exists and does
86/// not list; an unreadable file is no match.
87pub fn scan(target: &Utf8Path) -> Result<Vec<Leftover>, RkError> {
88    let mut found = Vec::new();
89    let read = |rel: &str| std::fs::read_to_string(target.join(rel)).ok();
90    for (id, rel, needle) in [
91        ("bump-script", "scripts/rk-bump.sh", PIN_PREFIX),
92        ("autobump-script", "scripts/rk-autobump.sh", "rk-bump.sh"),
93        ("bump-suite", "tests/rk-bump.bats", "rk-bump"),
94        ("autobump-suite", "tests/rk-autobump.bats", "rk-autobump"),
95    ] {
96        if read(rel).is_some_and(|text| text.contains(needle)) {
97            found.push(Leftover {
98                id,
99                file: rel.to_owned(),
100                line: None,
101                text: None,
102                action: Action::RemoveFile,
103                reason: "the file exists only for the predecessor bump mechanism",
104            });
105        }
106    }
107    if let Some(text) = read(".envrc") {
108        for (number, line) in lines_holding(&text, &ENVRC_NEEDLES) {
109            found.push(Leftover {
110                id: "envrc-invocation",
111                file: ".envrc".to_owned(),
112                line: Some(number),
113                text: Some(line),
114                action: Action::ReplaceLine,
115                reason: "the invocation gives way to the check line",
116            });
117        }
118    }
119    for rel in [".envrc.local", ".envrc.local.example"] {
120        if let Some(text) = read(rel) {
121            for (number, line) in lines_holding(&text, &[SWITCH_NEEDLE]) {
122                found.push(Leftover {
123                    id: "envrc-switch",
124                    file: rel.to_owned(),
125                    line: Some(number),
126                    text: Some(line),
127                    action: Action::Manual,
128                    reason: "the switch is now RK_DEVSHELL_SYNC=0, in a file the operator owns",
129                });
130            }
131        }
132    }
133    if let Some(text) = read("justfile") {
134        for (index, line) in text.lines().enumerate() {
135            if is_recipe_head(line, "rk-bump") {
136                found.push(Leftover {
137                    id: "just-recipe",
138                    file: "justfile".to_owned(),
139                    line: Some(index + 1),
140                    text: Some(line.trim().to_owned()),
141                    action: Action::Manual,
142                    reason: "a recipe body carries structure a line scan cannot judge",
143                });
144            }
145        }
146    }
147    found.extend(mise_tasks(target));
148    if let Some(text) = read("flake.nix") {
149        for (number, line) in list_members_named(&text, &["flock", "bats"]) {
150            found.push(Leftover {
151                id: "devshell-tooling",
152                file: "flake.nix".to_owned(),
153                line: Some(number),
154                text: Some(line),
155                action: Action::Manual,
156                reason: "a Nix package list carries structure a line scan cannot judge",
157            });
158        }
159    }
160    let mut host_files: Vec<String> = HOST_INSTALL_FILES.iter().map(|s| (*s).to_owned()).collect();
161    host_files.extend(workflow_files(target)?);
162    for rel in host_files {
163        if let Some(text) = read(&rel) {
164            for (number, line) in lines_holding(&text, &HOST_INSTALL_NEEDLES) {
165                found.push(Leftover {
166                    id: "host-install",
167                    file: rel.clone(),
168                    line: Some(number),
169                    text: Some(line),
170                    action: Action::Manual,
171                    reason: "an install line sits in prose or a CI step a line scan cannot judge",
172                });
173            }
174        }
175    }
176    Ok(found)
177}
178
179/// Rewrite an `.envrc` so the check line replaces the invocation.
180///
181/// Every line naming the predecessor invocation goes, and the check line
182/// takes the first removed line's place unless the file already carries
183/// one. Every other line is byte-identical. `None` where no line names
184/// the invocation.
185#[must_use]
186pub fn swap_envrc(text: &str, check_line: &str) -> Option<String> {
187    let mut out = String::with_capacity(text.len() + check_line.len() + 2);
188    let mut removed = 0;
189    let needs_line = !has_check_line(text);
190    for line in text.split_inclusive('\n') {
191        if ENVRC_NEEDLES.iter().any(|needle| line.contains(needle)) {
192            if removed == 0 && needs_line {
193                out.push_str(check_line);
194                out.push_str(if line.ends_with("\r\n") { "\r\n" } else { "\n" });
195            }
196            removed += 1;
197        } else {
198            out.push_str(line);
199        }
200    }
201    (removed > 0).then_some(out)
202}
203
204/// Every `(line number, trimmed line)` holding one of the needles.
205fn lines_holding(text: &str, needles: &[&str]) -> Vec<(usize, String)> {
206    text.lines()
207        .enumerate()
208        .filter(|(_, line)| needles.iter().any(|needle| line.contains(needle)))
209        .map(|(index, line)| (index + 1, line.trim().to_owned()))
210        .collect()
211}
212
213/// Whether a justfile line opens a recipe named `name`: the name at the
214/// start, then its parameters or nothing, then the colon.
215fn is_recipe_head(line: &str, name: &str) -> bool {
216    let Some(rest) = line.strip_prefix(name) else {
217        return false;
218    };
219    let Some(head) = rest.split(':').next() else {
220        return false;
221    };
222    rest.contains(':') && (head.is_empty() || head.starts_with(' ') || head.starts_with('\t'))
223}
224
225/// Every predecessor task table in the mise files: named, never edited.
226fn mise_tasks(target: &Utf8Path) -> Vec<Leftover> {
227    let mut found = Vec::new();
228    for rel in super::manager::MISE_FILES {
229        let Ok(text) = std::fs::read_to_string(target.join(rel)) else {
230            continue;
231        };
232        for (index, line) in text.lines().enumerate() {
233            if is_mise_task_head(line) {
234                found.push(Leftover {
235                    id: "mise-task",
236                    file: rel.to_owned(),
237                    line: Some(index + 1),
238                    text: Some(line.trim().to_owned()),
239                    action: Action::Manual,
240                    reason: "a task body carries structure a line scan cannot judge",
241                });
242            }
243        }
244    }
245    found
246}
247
248/// Whether a mise line opens a task table named for the predecessor:
249/// `[tasks.rk-bump]`, `[tasks."rk-bump"]`, or the autobump twin.
250fn is_mise_task_head(line: &str) -> bool {
251    let Some(inner) = line
252        .trim()
253        .strip_prefix("[tasks.")
254        .and_then(|rest| rest.strip_suffix(']'))
255    else {
256        return false;
257    };
258    let name = inner.trim_matches(|c| c == QUOTE || c == '\'');
259    MISE_TASK_NEEDLES.contains(&name)
260}
261
262/// Every `(line number, trimmed line)` of a Nix text that names one of
263/// the packages as a list member: a whitespace-separated token — brackets
264/// stripped — that is an attribute path ending in the package name, at a
265/// bracket depth above zero. The depth runs across the whole text, so a
266/// multi-line assignment such as `formatter =` over `pkgs.bats;` is not a
267/// list member, and a continuation line inside `[ ... ]` is. Comments
268/// are skipped.
269fn list_members_named(text: &str, packages: &[&str]) -> Vec<(usize, String)> {
270    let mut depth = 0usize;
271    let mut found = Vec::new();
272    let scrubbed = scrub_nix(text);
273    for (index, (line, code)) in text.lines().zip(scrubbed.lines()).enumerate() {
274        let mut named = false;
275        for token in code.split_whitespace() {
276            let opened = token.matches('[').count();
277            let closed = token.matches(']').count();
278            let stripped = token.trim_matches(|c| matches!(c, '[' | ']' | '(' | ')' | ';'));
279            if depth + opened > closed
280                && packages
281                    .iter()
282                    .any(|package| is_package_path(stripped, package))
283            {
284                named = true;
285            }
286            depth = (depth + opened).saturating_sub(closed);
287        }
288        if named {
289            found.push((index + 1, line.trim().to_owned()));
290        }
291    }
292    found
293}
294
295/// The double quote as a code point: the source scan that keeps whole
296/// artifacts out of the sources reads a quote literal as a string start.
297const QUOTE: char = '\u{22}';
298
299/// The Nix text with every string and comment blanked to spaces, line
300/// breaks kept, so a bracket inside `"..."`, `\'\'...\'\'`, a `#` line
301/// comment, or a `/* */` block comment never counts as syntax.
302fn scrub_nix(text: &str) -> String {
303    let bytes = text.as_bytes();
304    let mut out = String::with_capacity(text.len());
305    let mut i = 0;
306    let blank = |out: &mut String, slice: &str| {
307        for c in slice.chars() {
308            out.push(if c == '\n' { '\n' } else { ' ' });
309        }
310    };
311    while i < bytes.len() {
312        let rest = &text[i..];
313        let skip = if rest.starts_with('#') {
314            rest.find('\n').unwrap_or(rest.len())
315        } else if rest.starts_with("/*") {
316            rest.find("*/").map_or(rest.len(), |at| at + 2)
317        } else if let Some(body) = rest.strip_prefix("\'\'") {
318            body.find("\'\'").map_or(rest.len(), |at| at + 4)
319        } else if rest.starts_with(QUOTE) {
320            // Walk by character, never by byte: a multibyte character
321            // inside the string must not land a slice off a boundary.
322            let mut escaped = false;
323            let mut close = None;
324            for (at, c) in rest.char_indices().skip(1) {
325                if escaped {
326                    escaped = false;
327                } else if c == '\\' {
328                    escaped = true;
329                } else if c == QUOTE {
330                    close = Some(at + c.len_utf8());
331                    break;
332                }
333            }
334            close.unwrap_or(rest.len())
335        } else {
336            0
337        };
338        if skip == 0 {
339            let c = rest.chars().next().unwrap_or(' ');
340            out.push(c);
341            i += c.len_utf8();
342        } else {
343            blank(&mut out, &rest[..skip]);
344            i += skip;
345        }
346    }
347    out
348}
349
350/// Whether a token is an attribute path whose last segment is `package`.
351fn is_package_path(token: &str, package: &str) -> bool {
352    token.strip_suffix(package).is_some_and(|head| {
353        (head.is_empty() || head.ends_with('.'))
354            && head
355                .chars()
356                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
357    })
358}
359
360/// The workflow files under `.github/workflows`, relative to the target.
361fn workflow_files(target: &Utf8Path) -> Result<Vec<String>, RkError> {
362    let dir = target.join(".github/workflows");
363    if !dir.is_dir() {
364        return Ok(Vec::new());
365    }
366    let mut files: Vec<String> = dir
367        .read_dir_utf8()?
368        .filter_map(Result::ok)
369        .filter(|entry| entry.path().is_file())
370        .map(|entry| format!(".github/workflows/{}", entry.file_name()))
371        .collect();
372    files.sort();
373    Ok(files)
374}
375
376#[cfg(test)]
377mod tests {
378    use camino::Utf8PathBuf;
379
380    use super::{Action, is_mise_task_head, is_recipe_head, list_members_named, scan, swap_envrc};
381    use crate::self_depend::pin::PIN_PREFIX;
382
383    #[test]
384    fn a_catalog_file_matches_on_its_content_and_not_on_its_name_alone() {
385        let dir = tempfile::tempdir().expect("a scratch dir exists");
386        let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
387        std::fs::create_dir_all(target.join("scripts")).expect("scripts creates");
388        std::fs::create_dir_all(target.join("tests")).expect("tests creates");
389        std::fs::write(
390            target.join("scripts/rk-bump.sh"),
391            "#!/bin/sh\necho unrelated\n",
392        )
393        .expect("writes");
394        std::fs::write(
395            target.join("tests/rk-bump.bats"),
396            "@test unrelated { true; }\n",
397        )
398        .expect("writes");
399        assert!(
400            scan(&target).expect("scans").is_empty(),
401            "the name alone never decides"
402        );
403        std::fs::write(
404            target.join("scripts/rk-bump.sh"),
405            format!("#!/bin/sh\nPIN_PREFIX=\"{PIN_PREFIX}\"\n"),
406        )
407        .expect("writes");
408        std::fs::write(target.join("tests/rk-bump.bats"), "load rk-bump\n").expect("writes");
409        let found = scan(&target).expect("scans");
410        let ids: Vec<&str> = found.iter().map(|l| l.id).collect();
411        assert_eq!(ids, ["bump-script", "bump-suite"]);
412        assert!(found.iter().all(|l| l.action == Action::RemoveFile));
413    }
414
415    #[test]
416    fn the_envrc_swap_keeps_every_other_line() {
417        let check = "rk self-depend check || true";
418        let text = "use flake\r\n# keep\r\n# The bump runs rk-autobump on entry\r\nscripts/rk-autobump.sh || true\r\nexport FOO=1\r\n";
419        let swapped = swap_envrc(text, check).expect("a swap");
420        assert_eq!(
421            swapped,
422            "use flake\r\n# keep\r\nrk self-depend check || true\r\nexport FOO=1\r\n"
423        );
424        let already = "rk self-depend check || true\nscripts/rk-bump.sh\n";
425        assert_eq!(
426            swap_envrc(already, check).expect("a swap"),
427            "rk self-depend check || true\n",
428            "an existing check line is not doubled"
429        );
430        assert_eq!(
431            swap_envrc("use flake\nrk self-depend sync --apply || true\n", check).expect("a swap"),
432            "use flake\nrk self-depend check || true\n",
433            "the retired sync line gives way to the check line"
434        );
435        assert_eq!(swap_envrc("use flake\n", "x"), None);
436    }
437
438    #[test]
439    fn the_line_matchers_are_bounded() {
440        assert!(is_mise_task_head("[tasks.rk-bump]"));
441        assert!(is_mise_task_head("  [tasks.\"rk-autobump\"]"));
442        assert!(!is_mise_task_head("[tasks.rk-bump-all]"));
443        assert!(!is_mise_task_head("[tools]"));
444        assert!(!is_mise_task_head("rk-bump = \"x\""));
445        assert!(is_recipe_head("rk-bump:", "rk-bump"));
446        assert!(is_recipe_head("rk-bump tag='':", "rk-bump"));
447        assert!(!is_recipe_head("rk-bump-all:", "rk-bump"));
448        assert!(!is_recipe_head("    rk-bump", "rk-bump"));
449        let members = |text: &str| list_members_named(text, &["flock", "bats"]);
450        assert_eq!(
451            members("packages = [\n  pkgs.flock\n  bats # the suites\n];\n"),
452            [
453                (2, "pkgs.flock".to_owned()),
454                (3, "bats # the suites".to_owned())
455            ]
456        );
457        assert_eq!(members("packages = [ flock bats ];\n").len(), 1);
458        assert_eq!(
459            members("packages = [\n  \"]\"\n  pkgs.flock # ] in a comment\n];\n"),
460            [(3, "pkgs.flock # ] in a comment".to_owned())],
461            "a bracket in a string or a comment is not syntax"
462        );
463        assert_eq!(
464            members("packages = [\n  nixpkgs.legacyPackages.x86_64-linux.bats\n];\n").len(),
465            1
466        );
467        for not_a_member in [
468            "combats\n",
469            "[ flock-of-seagulls ]\n",
470            "# flock is gone\n",
471            "[ checks.flockTest ]\n",
472            "description = \"needs flock\";\n",
473            "[ pkgs.bats-core ]\n",
474            "formatter = pkgs.bats;\n",
475            "someTool = pkgs.flock;\n",
476            "formatter =\n  pkgs.bats;\n",
477            "someTool =\n  pkgs.flock;\n",
478            "packages = with pkgs; [\n];\nformatter = pkgs.bats;\n",
479            "description = \"[\";\nformatter = pkgs.bats;\n",
480            "description = \"café [\";\nformatter = pkgs.bats;\n",
481            "description = \"a \\\\é[\";\nformatter = pkgs.bats;\n",
482            "description = \"日本語 [\"; # ] café\nformatter = pkgs.flock;\n",
483            "/* [ */\nformatter = pkgs.flock;\n",
484            "x = \'\'[\'\';\nformatter = pkgs.bats;\n",
485        ] {
486            assert!(members(not_a_member).is_empty(), "{not_a_member:?}");
487        }
488    }
489}