Skip to main content

release_kit/devshell/
leftovers.rs

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