1use camino::Utf8Path;
13use serde::Serialize;
14
15use super::has_check_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; 3] = ["rk-bump", "rk-autobump", "rk self-depend sync"];
57
58const HOST_INSTALL_NEEDLES: [&str; 2] = ["cargo install release-kit", "cargo binstall release-kit"];
60
61const SWITCH_NEEDLE: &str = "RK_SKIP_AUTOBUMP";
63
64const 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
78const MISE_TASK_NEEDLES: [&str; 2] = ["rk-bump", "rk-autobump"];
80
81pub 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#[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
204fn 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
213fn 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
225fn 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
248fn 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
262fn 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
295const QUOTE: char = '\u{22}';
298
299fn 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 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
350fn 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
360fn 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}