1use camino::Utf8Path;
11use clap::ValueEnum;
12use serde::Serialize;
13
14use super::Presence;
15use super::discover::version_order;
16use super::pin;
17use crate::error::RkError;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ValueEnum)]
21#[serde(rename_all = "kebab-case")]
22#[value(rename_all = "kebab-case")]
23pub enum Manager {
24 Flake,
26 Mise,
28 Asdf,
30 Devbox,
32}
33
34pub const MISE_FILES: [&str; 9] = [
38 "mise.toml",
39 ".mise.toml",
40 "mise/config.toml",
41 "mise/conf.d",
42 ".mise/config.toml",
43 ".mise/conf.d",
44 ".config/mise.toml",
45 ".config/mise/config.toml",
46 ".config/mise/conf.d",
47];
48
49impl Manager {
50 #[must_use]
52 pub const fn as_str(self) -> &'static str {
53 match self {
54 Self::Flake => "flake",
55 Self::Mise => "mise",
56 Self::Asdf => "asdf",
57 Self::Devbox => "devbox",
58 }
59 }
60
61 pub const ALL: [Self; 4] = [Self::Flake, Self::Mise, Self::Asdf, Self::Devbox];
63
64 #[must_use]
66 pub const fn default_file(self) -> &'static str {
67 match self {
68 Self::Flake => "flake.nix",
69 Self::Mise => "mise.toml",
70 Self::Asdf => ".tool-versions",
71 Self::Devbox => "devbox.json",
72 }
73 }
74
75 #[must_use]
77 pub const fn candidates(self) -> &'static [&'static str] {
78 match self {
79 Self::Flake => &["flake.nix"],
80 Self::Mise => &MISE_FILES,
81 Self::Asdf => &[".tool-versions"],
82 Self::Devbox => &["devbox.json"],
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct ManagerFile {
90 pub manager: Manager,
92 pub file: String,
94 #[serde(skip)]
96 pub text: String,
97}
98
99pub fn manager_files(dir: &Utf8Path, dep_name: Option<&str>) -> Result<Vec<ManagerFile>, RkError> {
109 let mut out = Vec::new();
110 for manager in Manager::ALL {
111 for candidate in manager.candidates() {
112 let Some(file) = first_config(dir, candidate, dep_name)? else {
113 continue;
114 };
115 out.push(ManagerFile {
116 manager,
117 text: std::fs::read_to_string(dir.join(&file))?,
118 file,
119 });
120 break;
121 }
122 }
123 Ok(out)
124}
125
126fn first_config(
130 dir: &Utf8Path,
131 candidate: &str,
132 dep_name: Option<&str>,
133) -> Result<Option<String>, RkError> {
134 let path = dir.join(candidate);
135 if path.is_file() {
136 return Ok(Some(candidate.to_owned()));
137 }
138 if !candidate.ends_with("conf.d") || !path.is_dir() {
139 return Ok(None);
140 }
141 let mut names: Vec<String> = std::fs::read_dir(&path)?
142 .filter_map(Result::ok)
143 .filter(|entry| entry.path().is_file())
144 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
145 .filter_map(|entry| entry.file_name().into_string().ok())
146 .collect();
147 names.sort();
148 if let Some(name) = dep_name {
149 for file in &names {
150 if first_mention(&std::fs::read_to_string(path.join(file))?, name).is_some() {
151 return Ok(Some(format!("{candidate}/{file}")));
152 }
153 }
154 }
155 Ok(names.first().map(|name| format!("{candidate}/{name}")))
156}
157
158#[must_use]
160pub fn first_mention(text: &str, name: &str) -> Option<usize> {
161 text.lines()
162 .position(|line| mentions(line, name))
163 .map(|index| index + 1)
164}
165
166fn mentions(line: &str, name: &str) -> bool {
168 let boundary = |c: Option<char>| {
169 c.is_none_or(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
170 };
171 line.match_indices(name).any(|(index, _)| {
172 boundary(line[..index].chars().next_back())
173 && boundary(line[index + name.len()..].chars().next())
174 })
175}
176
177pub const DEP_NAME: &str = "release-kit";
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum PinRead {
183 Absent,
185 Unpinned {
188 line: usize,
190 },
191 One {
193 line: usize,
195 version: String,
198 },
199 Many {
201 count: usize,
203 },
204}
205
206impl PinRead {
207 #[must_use]
209 pub const fn word(&self) -> &'static str {
210 match self {
211 Self::Absent => "absent",
212 Self::Unpinned { .. } => "unpinned",
213 Self::One { .. } => "pinned",
214 Self::Many { .. } => "ambiguous",
215 }
216 }
217
218 #[must_use]
220 pub const fn names(&self) -> bool {
221 !matches!(self, Self::Absent)
222 }
223
224 #[must_use]
226 pub fn version(&self) -> Option<&str> {
227 match self {
228 Self::One { version, .. } => Some(version),
229 _ => None,
230 }
231 }
232
233 #[must_use]
235 pub const fn lines(&self) -> Option<usize> {
236 match self {
237 Self::Absent => None,
238 Self::Unpinned { .. } | Self::One { .. } => Some(1),
239 Self::Many { count } => Some(*count),
240 }
241 }
242
243 fn fold(mentions: &[(usize, Option<String>)]) -> Self {
245 match mentions {
246 [] => Self::Absent,
247 [(line, None)] => Self::Unpinned { line: *line },
248 [(line, Some(version))] => Self::One {
249 line: *line,
250 version: version.clone(),
251 },
252 many => Self::Many { count: many.len() },
253 }
254 }
255}
256
257#[must_use]
259pub fn read_pin(manager: Manager, text: &str) -> PinRead {
260 match manager {
261 Manager::Flake => match pin::scan(text) {
262 pin::Scan::None => PinRead::Absent,
263 pin::Scan::Unpinned(line) => PinRead::Unpinned { line },
264 pin::Scan::One(pin) => PinRead::One {
265 line: pin.line,
266 version: pin.tag,
267 },
268 pin::Scan::Many(count) => PinRead::Many { count },
269 },
270 Manager::Mise => PinRead::fold(&mise_mentions(text)),
271 Manager::Asdf => PinRead::fold(&asdf_mentions(text)),
272 Manager::Devbox => PinRead::fold(&devbox_mentions(text)),
273 }
274}
275
276const QUOTE: char = '\u{22}';
279
280fn quoted_values(text: &str) -> Vec<&str> {
282 let mut out = Vec::new();
283 let mut rest = text;
284 while let Some(start) = rest.find(QUOTE) {
285 let body = &rest[start + 1..];
286 let Some(end) = body.find(QUOTE) else {
287 break;
288 };
289 out.push(&body[..end]);
290 rest = &body[end + 1..];
291 }
292 out
293}
294
295fn quoted(text: &str) -> Option<&str> {
297 quoted_values(text).into_iter().next()
298}
299
300fn mise_mentions(text: &str) -> Vec<(usize, Option<String>)> {
304 let mut out = Vec::new();
305 let mut in_tools = false;
306 for (index, raw) in text.lines().enumerate() {
307 let line = raw.trim();
308 if line.starts_with('[') {
309 in_tools = line == "[tools]";
310 continue;
311 }
312 if !in_tools || line.starts_with('#') {
313 continue;
314 }
315 let Some((key, value)) = line.split_once('=') else {
316 continue;
317 };
318 let key = key.trim().trim_matches(QUOTE).trim_matches('\'');
319 let named = key == DEP_NAME
320 || key.ends_with(&format!(":{DEP_NAME}"))
321 || key.ends_with(&format!("/{DEP_NAME}"));
322 if !named {
323 continue;
324 }
325 let value = value.trim();
326 let version = if value.starts_with('{') {
327 value
328 .split_once("version")
329 .and_then(|(_, rest)| quoted(rest))
330 .map(str::to_owned)
331 } else {
332 quoted(value).map(str::to_owned)
333 };
334 let version = version.filter(|v| !v.is_empty() && v != "latest");
335 out.push((index + 1, version));
336 }
337 out
338}
339
340fn asdf_mentions(text: &str) -> Vec<(usize, Option<String>)> {
343 text.lines()
344 .enumerate()
345 .filter_map(|(index, line)| {
346 let mut words = line.split('#').next().unwrap_or("").split_whitespace();
347 (words.next() == Some(DEP_NAME)).then(|| {
348 let version = words
349 .next()
350 .filter(|word| *word != "latest")
351 .map(str::to_owned);
352 (index + 1, version)
353 })
354 })
355 .collect()
356}
357
358fn devbox_mentions(text: &str) -> Vec<(usize, Option<String>)> {
363 let mut out = Vec::new();
364 for (index, raw) in text.lines().enumerate() {
365 let values = quoted_values(raw.trim());
366 let Some(at) = values.iter().position(|value| mentions(value, DEP_NAME)) else {
367 continue;
368 };
369 let value = values[at];
370 let version = if let Some((_, rest)) = value.split_once(&format!("{DEP_NAME}/")) {
371 rest.split('#').next().filter(|v| !v.is_empty())
372 } else if let Some((_, rest)) = value.split_once(&format!("{DEP_NAME}@")) {
373 (!rest.is_empty()).then_some(rest)
374 } else if value == DEP_NAME {
375 values.get(at + 1).copied()
376 } else {
377 None
378 };
379 let version = version.filter(|v| *v != "latest").map(str::to_owned);
380 out.push((index + 1, version));
381 }
382 out
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
388#[serde(rename_all = "kebab-case")]
389pub enum Freshness {
390 Behind,
392 Current,
394 Ahead,
396}
397
398impl Freshness {
399 #[must_use]
401 pub fn of(version: &str) -> Self {
402 match version_order(version, env!("CARGO_PKG_VERSION")) {
403 std::cmp::Ordering::Less => Self::Behind,
404 std::cmp::Ordering::Equal => Self::Current,
405 std::cmp::Ordering::Greater => Self::Ahead,
406 }
407 }
408}
409
410#[derive(Debug, Clone, Serialize)]
412pub struct Entry {
413 pub manager: Manager,
415 pub present: Presence,
417 #[serde(skip_serializing_if = "Option::is_none")]
419 pub file: Option<String>,
420 pub pin: &'static str,
422 #[serde(skip_serializing_if = "Option::is_none")]
425 pub version: Option<String>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub pin_lines: Option<usize>,
429 #[serde(skip_serializing_if = "Option::is_none")]
431 pub freshness: Option<Freshness>,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub lock: Option<Presence>,
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub locked_ref: Option<String>,
438 #[serde(skip_serializing_if = "Option::is_none")]
440 pub locked_rev: Option<String>,
441 #[serde(skip)]
443 pub read: PinRead,
444 #[serde(skip)]
446 pub text: Option<String>,
447}
448
449impl Entry {
450 #[must_use]
452 pub const fn absent(manager: Manager) -> Self {
453 Self {
454 manager,
455 present: Presence::Absent,
456 file: None,
457 pin: "absent",
458 version: None,
459 pin_lines: None,
460 freshness: None,
461 lock: None,
462 locked_ref: None,
463 locked_rev: None,
464 read: PinRead::Absent,
465 text: None,
466 }
467 }
468
469 #[must_use]
471 pub fn read(file: &ManagerFile) -> Self {
472 let read = read_pin(file.manager, &file.text);
473 Self {
474 manager: file.manager,
475 present: Presence::Present,
476 file: Some(file.file.clone()),
477 pin: read.word(),
478 version: read.version().map(str::to_owned),
479 pin_lines: read.lines(),
480 freshness: read.version().map(Freshness::of),
481 lock: None,
482 locked_ref: None,
483 locked_rev: None,
484 read,
485 text: Some(file.text.clone()),
486 }
487 }
488}
489
490impl Manager {
491 #[must_use]
495 pub const fn records_tag(self) -> bool {
496 matches!(self, Self::Flake | Self::Devbox)
497 }
498
499 #[must_use]
502 pub fn recorded(self, tag: &str) -> String {
503 let bare = tag.strip_prefix('v').unwrap_or(tag);
504 if self.records_tag() {
505 format!("v{bare}")
506 } else {
507 bare.to_owned()
508 }
509 }
510}
511
512#[must_use]
516pub fn rewrite_line(text: &str, line: usize, from: &str, to: &str) -> String {
517 let mut out = String::with_capacity(text.len() + to.len());
518 for (index, raw) in text.split_inclusive('\n').enumerate() {
519 if index + 1 != line {
520 out.push_str(raw);
521 continue;
522 }
523 let mention = raw.find(DEP_NAME).map_or(0, |at| at + DEP_NAME.len());
524 match raw[mention..].find(from) {
525 Some(at) => {
526 let start = mention + at;
527 out.push_str(&raw[..start]);
528 out.push_str(to);
529 out.push_str(&raw[start + from.len()..]);
530 }
531 None => out.push_str(raw),
532 }
533 }
534 out
535}
536
537#[must_use]
540pub fn entries(files: &[ManagerFile]) -> Vec<Entry> {
541 Manager::ALL
542 .into_iter()
543 .map(|manager| {
544 files
545 .iter()
546 .find(|file| file.manager == manager)
547 .map_or_else(|| Entry::absent(manager), Entry::read)
548 })
549 .collect()
550}
551
552#[cfg(test)]
553mod tests {
554 use super::{Freshness, MISE_FILES, Manager, PinRead, first_mention, manager_files, read_pin};
555
556 #[test]
557 fn the_first_mise_file_in_precedence_wins() {
558 let dir = tempfile::tempdir().expect("a scratch dir");
559 let root = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
560 std::fs::create_dir_all(root.join(".config/mise")).expect("mkdir");
561 std::fs::write(root.join(".config/mise/config.toml"), "[tools]\n").expect("writes");
562 std::fs::write(root.join(".mise.toml"), "[tools]\nnode = '24'\n").expect("writes");
563 std::fs::write(root.join("devbox.json"), "{}\n").expect("writes");
564 let files = manager_files(root, None).expect("reads");
565 let names: Vec<(Manager, &str)> =
566 files.iter().map(|m| (m.manager, m.file.as_str())).collect();
567 assert_eq!(
568 names,
569 [
570 (Manager::Mise, ".mise.toml"),
571 (Manager::Devbox, "devbox.json")
572 ]
573 );
574 assert_eq!(MISE_FILES[0], "mise.toml");
575 std::fs::remove_file(root.join(".mise.toml")).expect("removes");
576 std::fs::remove_file(root.join(".config/mise/config.toml")).expect("removes");
577 std::fs::create_dir_all(root.join(".mise/conf.d")).expect("mkdir");
578 std::fs::write(root.join(".mise/conf.d/tools.toml"), "[tools]\n").expect("writes");
579 std::fs::write(root.join(".mise/conf.d/env.toml"), "[env]\n").expect("writes");
580 std::fs::write(root.join(".mise/conf.d/README"), "").expect("writes");
581 let files = manager_files(root, None).expect("reads");
582 assert_eq!(
583 files[0].file, ".mise/conf.d/env.toml",
584 "a conf.d directory is mise ownership, its first toml in name order"
585 );
586 std::fs::write(
587 root.join(".mise/conf.d/tools.toml"),
588 "[tools]\n\"cargo:sample-tool\" = \"1.0.0\"\n",
589 )
590 .expect("writes");
591 let files = manager_files(root, Some("sample-tool")).expect("reads");
592 assert_eq!(
593 files[0].file, ".mise/conf.d/tools.toml",
594 "the file that already names the dependency is the destination"
595 );
596 }
597
598 #[test]
599 fn a_mention_is_found_by_line() {
600 let text = "[tools]\nnode = '24'\n\"cargo:sample-tool\" = \"1.4.0\"\n";
601 assert_eq!(first_mention(text, "sample-tool"), Some(3));
602 assert_eq!(
603 first_mention(text, "sample"),
604 None,
605 "a prefix is not a name"
606 );
607 assert_eq!(first_mention("", "sample-tool"), None);
608 }
609
610 #[test]
611 fn every_manager_has_its_files_and_the_enum_is_closed() {
612 for manager in Manager::ALL {
613 assert_eq!(manager.candidates()[0], manager.default_file());
614 }
615 assert_eq!(Manager::ALL.len(), 4);
616 }
617
618 #[test]
619 fn the_mise_reader_names_the_tool_in_any_backend() {
620 let text = "[env]\nFOO = \"release-kit\"\n[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.3.18\"\n";
621 assert_eq!(
622 read_pin(Manager::Mise, text),
623 PinRead::One {
624 line: 5,
625 version: "0.3.18".to_owned()
626 }
627 );
628 let table =
629 "[tools]\n\"ubi:gubasso/release-kit\" = { version = \"0.3.17\", exe = \"rk\" }\n";
630 assert_eq!(read_pin(Manager::Mise, table).version(), Some("0.3.17"));
631 assert_eq!(
632 read_pin(Manager::Mise, "[tools]\nrelease-kit = \"latest\"\n"),
633 PinRead::Unpinned { line: 2 }
634 );
635 assert_eq!(
636 read_pin(
637 Manager::Mise,
638 "[tools]\n\"cargo:release-kit\" = \"0.3.18\"\n\"ubi:gubasso/release-kit\" = \"0.3.18\"\n"
639 ),
640 PinRead::Many { count: 2 }
641 );
642 assert_eq!(
643 read_pin(Manager::Mise, "[tools]\nnode = \"24\"\n"),
644 PinRead::Absent
645 );
646 assert_eq!(
647 read_pin(
648 Manager::Mise,
649 "[tools]\n\"cargo:release-kit-extra\" = \"1\"\n"
650 ),
651 PinRead::Absent,
652 "a longer name is not this one"
653 );
654 }
655
656 #[test]
657 fn the_asdf_reader_takes_the_second_word() {
658 assert_eq!(
659 read_pin(Manager::Asdf, "nodejs 24.0.0\nrelease-kit 0.3.18 # rk\n"),
660 PinRead::One {
661 line: 2,
662 version: "0.3.18".to_owned()
663 }
664 );
665 assert_eq!(
666 read_pin(Manager::Asdf, "release-kit\n"),
667 PinRead::Unpinned { line: 1 }
668 );
669 assert_eq!(
670 read_pin(Manager::Asdf, "# release-kit 1\n"),
671 PinRead::Absent
672 );
673 }
674
675 #[test]
676 fn the_devbox_reader_takes_the_flake_tag_or_the_version_suffix() {
677 let reference =
678 "{\n \"packages\": [\n \"github:gubasso/release-kit/v0.3.18#default\"\n ]\n}\n";
679 assert_eq!(
680 read_pin(Manager::Devbox, reference),
681 PinRead::One {
682 line: 3,
683 version: "v0.3.18".to_owned()
684 }
685 );
686 assert_eq!(
687 read_pin(Manager::Devbox, "{\"packages\": [\"release-kit@0.3.18\"]}"),
688 PinRead::One {
689 line: 1,
690 version: "0.3.18".to_owned()
691 }
692 );
693 assert_eq!(
694 read_pin(
695 Manager::Devbox,
696 "{\"packages\": {\"release-kit\": \"0.3.18\"}}"
697 )
698 .version(),
699 Some("0.3.18")
700 );
701 assert_eq!(
702 read_pin(Manager::Devbox, "{\"packages\": [\"release-kit@latest\"]}"),
703 PinRead::Unpinned { line: 1 }
704 );
705 assert_eq!(
706 read_pin(Manager::Devbox, "{\"packages\": [\"nodejs@24\"]}"),
707 PinRead::Absent
708 );
709 }
710
711 #[test]
712 fn the_flake_reader_is_the_pin_matcher() {
713 let flake = "{\n url = \"github:gubasso/release-kit/v0.2.16\";\n}\n";
714 assert_eq!(
715 read_pin(Manager::Flake, flake),
716 PinRead::One {
717 line: 2,
718 version: "v0.2.16".to_owned()
719 }
720 );
721 }
722
723 #[test]
724 fn a_one_fact_line_is_rewritten_in_place() {
725 let mise = "[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.2.15\" # 0.2.15 was fine\n";
726 assert_eq!(
727 super::rewrite_line(mise, 3, "0.2.15", "0.2.16"),
728 "[tools]\nnode = \"24\"\n\"cargo:release-kit\" = \"0.2.16\" # 0.2.15 was fine\n",
729 "only the first occurrence after the name moves"
730 );
731 let devbox =
732 "{\r\n \"packages\": [\"github:gubasso/release-kit/v0.2.15#default\"]\r\n}\r\n";
733 assert_eq!(
734 super::rewrite_line(devbox, 2, "v0.2.15", "v0.2.16"),
735 devbox.replace("v0.2.15", "v0.2.16")
736 );
737 assert_eq!(
738 super::rewrite_line(mise, 9, "0.2.15", "0.2.16"),
739 mise,
740 "a line the file lacks changes nothing"
741 );
742 assert_eq!(Manager::Mise.recorded("v0.2.16"), "0.2.16");
743 assert_eq!(Manager::Devbox.recorded("0.2.16"), "v0.2.16");
744 assert!(Manager::Flake.records_tag());
745 assert!(!Manager::Asdf.records_tag());
746 }
747
748 #[test]
749 fn freshness_compares_with_the_binary() {
750 assert_eq!(Freshness::of("v0.0.1"), Freshness::Behind);
751 assert_eq!(Freshness::of(env!("CARGO_PKG_VERSION")), Freshness::Current);
752 assert_eq!(
753 Freshness::of(&format!("v{}", env!("CARGO_PKG_VERSION"))),
754 Freshness::Current
755 );
756 assert_eq!(Freshness::of("v999.0.0"), Freshness::Ahead);
757 }
758}