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