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