Skip to main content

spec_driven_docs/self_depend/
manager.rs

1//! The manager axis, and what a target carries of each.
2//!
3//! A manager owns a file in the repository, and that file records a
4//! version. A shell loader such as direnv is not a manager: it loads an
5//! environment and records no version, so it is reported beside the list.
6
7use camino::{Utf8Path, Utf8PathBuf};
8use serde::Serialize;
9
10use crate::self_depend::pin::{self, Pin};
11
12/// What a project declares its development tools in.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, clap::ValueEnum)]
14#[serde(rename_all = "kebab-case")]
15pub enum Manager {
16    /// A Nix flake: an input pinned at a tag and its package in the devshell.
17    Flake,
18    /// mise: one `[tools]` entry in its configuration file.
19    Mise,
20    /// asdf: one line in `.tool-versions`.
21    Asdf,
22    /// devbox: one entry in the `packages` array of `devbox.json`.
23    Devbox,
24}
25
26impl Manager {
27    /// Every manager, in report order.
28    pub const ALL: [Self; 4] = [Self::Flake, Self::Mise, Self::Asdf, Self::Devbox];
29
30    /// The kebab-case word the command line and the report use.
31    #[must_use]
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Flake => "flake",
35            Self::Mise => "mise",
36            Self::Asdf => "asdf",
37            Self::Devbox => "devbox",
38        }
39    }
40
41    /// The files the manager reads at a project root, in search order.
42    ///
43    /// The first is the one a seed writes.
44    #[must_use]
45    pub const fn files(self) -> &'static [&'static str] {
46        match self {
47            Self::Flake => &["flake.nix"],
48            Self::Mise => &["mise.toml", ".mise.toml"],
49            Self::Asdf => &[".tool-versions"],
50            Self::Devbox => &["devbox.json"],
51        }
52    }
53
54    /// The lock beside the manager file, where the manager keeps one.
55    #[must_use]
56    pub const fn lock_file(self) -> Option<&'static str> {
57        match self {
58            Self::Flake => Some("flake.lock"),
59            Self::Mise | Self::Asdf | Self::Devbox => None,
60        }
61    }
62}
63
64impl std::fmt::Display for Manager {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70/// One manager, as a target carries it.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Detected {
73    /// Which manager.
74    pub manager: Manager,
75    /// The manager file the target carries, relative to its root.
76    pub file: Option<Utf8PathBuf>,
77    /// The pin the file records for this tool.
78    pub pin: Option<Pin>,
79}
80
81impl Detected {
82    /// Whether the manager file names this tool.
83    #[must_use]
84    pub const fn names_this_tool(&self) -> bool {
85        self.pin.is_some()
86    }
87}
88
89/// Every manager, detected once, in report order.
90///
91/// One detection serves the status report and the add verb both.
92#[must_use]
93pub fn detect(target: &Utf8Path) -> Vec<Detected> {
94    Manager::ALL
95        .into_iter()
96        .map(|manager| detect_one(target, manager))
97        .collect()
98}
99
100fn detect_one(target: &Utf8Path, manager: Manager) -> Detected {
101    for file in manager.files() {
102        let path = target.join(file);
103        let Ok(text) = std::fs::read_to_string(&path) else {
104            continue;
105        };
106        return Detected {
107            manager,
108            file: Some(Utf8PathBuf::from(*file)),
109            pin: pin::read(manager, &text),
110        };
111    }
112    Detected {
113        manager,
114        file: None,
115        pin: None,
116    }
117}
118
119/// The managers whose file names this tool.
120#[must_use]
121pub fn wired(detected: &[Detected]) -> Vec<&Detected> {
122    detected
123        .iter()
124        .filter(|held| held.names_this_tool())
125        .collect()
126}
127
128#[cfg(test)]
129mod tests {
130    #![allow(clippy::unwrap_used, reason = "a test panics as its failure signal")]
131
132    use super::*;
133
134    #[test]
135    fn every_manager_is_detected_once_in_order() {
136        let dir = tempfile::tempdir().unwrap();
137        let root = Utf8Path::from_path(dir.path()).unwrap();
138        std::fs::write(root.join(".tool-versions"), "nodejs 20.0.0\n").unwrap();
139        let held = detect(root);
140        let order: Vec<Manager> = held.iter().map(|d| d.manager).collect();
141        assert_eq!(order, Manager::ALL);
142        assert_eq!(
143            held[2].file.as_deref(),
144            Some(Utf8Path::new(".tool-versions"))
145        );
146        assert!(held[2].pin.is_none());
147        assert!(held[0].file.is_none());
148        assert!(wired(&held).is_empty());
149    }
150
151    #[test]
152    fn a_mise_file_is_found_under_either_name() {
153        let dir = tempfile::tempdir().unwrap();
154        let root = Utf8Path::from_path(dir.path()).unwrap();
155        std::fs::write(root.join(".mise.toml"), "[tools]\n").unwrap();
156        let held = detect_one(root, Manager::Mise);
157        assert_eq!(held.file.as_deref(), Some(Utf8Path::new(".mise.toml")));
158    }
159}