1use camino::Utf8Path;
13use serde::Serialize;
14
15use super::has_sync_line;
16use super::pin::PIN_PREFIX;
17use crate::error::RkError;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Action {
23 RemoveFile,
25 ReplaceLine,
28 Manual,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Leftover {
35 pub id: &'static str,
39 pub file: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub line: Option<usize>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub text: Option<String>,
47 pub action: Action,
49 pub reason: &'static str,
51}
52
53const ENVRC_NEEDLES: [&str; 2] = ["rk-bump", "rk-autobump"];
55
56const HOST_INSTALL_NEEDLES: [&str; 2] = ["cargo install release-kit", "cargo binstall release-kit"];
58
59const SWITCH_NEEDLE: &str = "RK_SKIP_AUTOBUMP";
61
62const HOST_INSTALL_FILES: [&str; 4] = ["README.md", "justfile", ".envrc", ".gitlab-ci.yml"];
64
65pub 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#[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
187fn 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
196fn 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
208fn 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
241const QUOTE: char = '\u{22}';
244
245fn 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 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
296fn 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
306fn 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}