Skip to main content

release_kit/depend/
target.rs

1//! What the target manages its development tools with.
2//!
3//! Four managers are recognized by their files: a flake, a mise
4//! configuration in the precedence mise documents, asdf's
5//! `.tool-versions`, and `devbox.json`. The observation reads the files
6//! and reports where the dependency's name already appears; it judges
7//! nothing else.
8
9use camino::{Utf8Path, Utf8PathBuf};
10use serde::Serialize;
11
12use super::{Manager, canonical_dir};
13use crate::error::RkError;
14
15/// The mise configuration paths, in mise's precedence order, the
16/// highest first: a file, or a `conf.d` directory whose `*.toml` entries
17/// all load. A local override file is left out: it is not committed.
18pub const MISE_FILES: [&str; 9] = [
19    "mise.toml",
20    ".mise.toml",
21    "mise/config.toml",
22    "mise/conf.d",
23    ".mise/config.toml",
24    ".mise/conf.d",
25    ".config/mise.toml",
26    ".config/mise/config.toml",
27    ".config/mise/conf.d",
28];
29
30/// One manager and the file that declares it.
31#[derive(Debug, Clone, Serialize)]
32pub struct ManagerFile {
33    /// The manager.
34    pub manager: Manager,
35    /// The file, relative to the target.
36    pub file: String,
37    /// Its text.
38    #[serde(skip)]
39    pub text: String,
40}
41
42/// Where a manager file already names the dependency.
43#[derive(Debug, Clone, Serialize)]
44pub struct Already {
45    /// The manager.
46    pub manager: Manager,
47    /// The file, relative to the target.
48    pub file: String,
49    /// The first line naming it, 1-based.
50    pub line: usize,
51}
52
53/// Everything the offline pass reads from the target.
54#[derive(Debug, Clone)]
55pub struct Target {
56    /// The target, canonical.
57    pub path: Utf8PathBuf,
58    /// `rust`, `python`, `node`, or `bash`, where a manifest says.
59    pub tech: Option<&'static str>,
60    /// The managers present, in the closed order.
61    pub managers: Vec<ManagerFile>,
62    /// Whether `.envrc` carries `use flake`.
63    pub envrc_use_flake: bool,
64    /// Where the dependency is already named.
65    pub already: Vec<Already>,
66}
67
68impl Target {
69    /// The file a manager declares itself in, where present.
70    #[must_use]
71    pub fn file_of(&self, manager: Manager) -> Option<&ManagerFile> {
72        self.managers.iter().find(|m| m.manager == manager)
73    }
74
75    /// The default file a manager is seeded into when absent.
76    #[must_use]
77    pub const fn default_file(manager: Manager) -> &'static str {
78        match manager {
79            Manager::Flake => "flake.nix",
80            Manager::Mise => "mise.toml",
81            Manager::Asdf => ".tool-versions",
82            Manager::Devbox => "devbox.json",
83        }
84    }
85}
86
87/// Read the target, offline.
88///
89/// # Errors
90///
91/// Returns [`RkError::Missing`] for a path that is not a directory and
92/// [`RkError::Io`] where a present file does not read.
93pub fn observe(path: &Utf8Path, dep_name: Option<&str>) -> Result<Target, RkError> {
94    let path = canonical_dir(path, "target")?;
95    let managers = manager_files(&path, dep_name)?;
96    let envrc_use_flake = match std::fs::read_to_string(path.join(".envrc")) {
97        Ok(text) => text.lines().any(|line| {
98            let mut words = line.split_whitespace();
99            words.next() == Some("use") && words.next() == Some("flake")
100        }),
101        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
102        Err(e) => return Err(RkError::Io(e)),
103    };
104    let already = dep_name
105        .map(|name| {
106            managers
107                .iter()
108                .filter_map(|m| {
109                    first_mention(&m.text, name).map(|line| Already {
110                        manager: m.manager,
111                        file: m.file.clone(),
112                        line,
113                    })
114                })
115                .collect()
116        })
117        .unwrap_or_default();
118    Ok(Target {
119        tech: tech_or_node(&path),
120        path,
121        managers,
122        envrc_use_flake,
123        already,
124    })
125}
126
127/// The manager files present, one per manager, in the closed order.
128///
129/// For mise, the first path in precedence wins, and inside a `conf.d`
130/// directory the file that already names the dependency wins over the
131/// first in name order.
132///
133/// # Errors
134///
135/// Returns [`RkError::Io`] where a present file does not read.
136pub fn manager_files(dir: &Utf8Path, dep_name: Option<&str>) -> Result<Vec<ManagerFile>, RkError> {
137    let mut out = Vec::new();
138    for manager in Manager::ALL {
139        let candidates: &[&str] = match manager {
140            Manager::Flake => &["flake.nix"],
141            Manager::Mise => &MISE_FILES,
142            Manager::Asdf => &[".tool-versions"],
143            Manager::Devbox => &["devbox.json"],
144        };
145        for candidate in candidates {
146            let Some(file) = first_config(dir, candidate, dep_name)? else {
147                continue;
148            };
149            out.push(ManagerFile {
150                manager,
151                text: std::fs::read_to_string(dir.join(&file))?,
152                file,
153            });
154            break;
155        }
156    }
157    Ok(out)
158}
159
160/// The configuration file a candidate path resolves to: the file
161/// itself, or, under a `conf.d` directory, the first `*.toml` in name
162/// order that already names the dependency, else the first in name order.
163fn first_config(
164    dir: &Utf8Path,
165    candidate: &str,
166    dep_name: Option<&str>,
167) -> Result<Option<String>, RkError> {
168    let path = dir.join(candidate);
169    if path.is_file() {
170        return Ok(Some(candidate.to_owned()));
171    }
172    if !candidate.ends_with("conf.d") || !path.is_dir() {
173        return Ok(None);
174    }
175    let mut names: Vec<String> = std::fs::read_dir(&path)?
176        .filter_map(Result::ok)
177        .filter(|entry| entry.path().is_file())
178        .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
179        .filter_map(|entry| entry.file_name().into_string().ok())
180        .collect();
181    names.sort();
182    if let Some(name) = dep_name {
183        for file in &names {
184            if first_mention(&std::fs::read_to_string(path.join(file))?, name).is_some() {
185                return Ok(Some(format!("{candidate}/{file}")));
186            }
187        }
188    }
189    Ok(names.first().map(|name| format!("{candidate}/{name}")))
190}
191
192/// The first line naming `name` as a word, 1-based.
193#[must_use]
194pub fn first_mention(text: &str, name: &str) -> Option<usize> {
195    let boundary = |c: Option<char>| {
196        c.is_none_or(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
197    };
198    text.lines()
199        .position(|line| {
200            line.match_indices(name).any(|(index, _)| {
201                boundary(line[..index].chars().next_back())
202                    && boundary(line[index + name.len()..].chars().next())
203            })
204        })
205        .map(|index| index + 1)
206}
207
208/// The technology, with `node` read from `package.json` after the
209/// version files the bindings define.
210#[must_use]
211pub fn tech_or_node(dir: &Utf8Path) -> Option<&'static str> {
212    crate::detect::tech_of(dir.as_std_path())
213        .or_else(|| dir.join("package.json").is_file().then_some("node"))
214}
215
216#[cfg(test)]
217mod tests {
218    #![allow(clippy::expect_used)]
219
220    use super::{MISE_FILES, Manager, first_mention, manager_files, tech_or_node};
221
222    #[test]
223    fn the_first_mise_file_in_precedence_wins() {
224        let dir = tempfile::tempdir().expect("a scratch dir");
225        let root = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
226        std::fs::create_dir_all(root.join(".config/mise")).expect("mkdir");
227        std::fs::write(root.join(".config/mise/config.toml"), "[tools]\n").expect("writes");
228        std::fs::write(root.join(".mise.toml"), "[tools]\nnode = '24'\n").expect("writes");
229        std::fs::write(root.join("devbox.json"), "{}\n").expect("writes");
230        let files = manager_files(root, None).expect("reads");
231        let names: Vec<(Manager, &str)> =
232            files.iter().map(|m| (m.manager, m.file.as_str())).collect();
233        assert_eq!(
234            names,
235            [
236                (Manager::Mise, ".mise.toml"),
237                (Manager::Devbox, "devbox.json")
238            ]
239        );
240        assert_eq!(MISE_FILES[0], "mise.toml");
241        std::fs::remove_file(root.join(".mise.toml")).expect("removes");
242        std::fs::remove_file(root.join(".config/mise/config.toml")).expect("removes");
243        std::fs::create_dir_all(root.join(".mise/conf.d")).expect("mkdir");
244        std::fs::write(root.join(".mise/conf.d/tools.toml"), "[tools]\n").expect("writes");
245        std::fs::write(root.join(".mise/conf.d/env.toml"), "[env]\n").expect("writes");
246        std::fs::write(root.join(".mise/conf.d/README"), "").expect("writes");
247        let files = manager_files(root, None).expect("reads");
248        assert_eq!(
249            files[0].file, ".mise/conf.d/env.toml",
250            "a conf.d directory is mise ownership, its first toml in name order"
251        );
252        std::fs::write(
253            root.join(".mise/conf.d/tools.toml"),
254            "[tools]\n\"cargo:sample-tool\" = \"1.0.0\"\n",
255        )
256        .expect("writes");
257        let files = manager_files(root, Some("sample-tool")).expect("reads");
258        assert_eq!(
259            files[0].file, ".mise/conf.d/tools.toml",
260            "the file that already names the dependency is the destination"
261        );
262    }
263
264    #[test]
265    fn a_mention_is_found_by_line() {
266        let text = "[tools]\nnode = '24'\n\"cargo:sample-tool\" = \"1.4.0\"\n";
267        assert_eq!(first_mention(text, "sample-tool"), Some(3));
268        assert_eq!(
269            first_mention(text, "sample"),
270            None,
271            "a prefix is not a name"
272        );
273        assert_eq!(first_mention("", "sample-tool"), None);
274    }
275
276    #[test]
277    fn node_is_read_from_package_json_after_the_version_files() {
278        let dir = tempfile::tempdir().expect("a scratch dir");
279        let root = camino::Utf8Path::from_path(dir.path()).expect("utf-8");
280        assert_eq!(tech_or_node(root), None);
281        std::fs::write(root.join("package.json"), "{}").expect("writes");
282        assert_eq!(tech_or_node(root), Some("node"));
283        std::fs::write(root.join("Cargo.toml"), "").expect("writes");
284        assert_eq!(tech_or_node(root), Some("rust"));
285    }
286}