1use camino::{Utf8Path, Utf8PathBuf};
10use serde::Serialize;
11
12use super::{Manager, canonical_dir};
13use crate::error::RkError;
14
15pub 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#[derive(Debug, Clone, Serialize)]
32pub struct ManagerFile {
33 pub manager: Manager,
35 pub file: String,
37 #[serde(skip)]
39 pub text: String,
40}
41
42#[derive(Debug, Clone, Serialize)]
44pub struct Already {
45 pub manager: Manager,
47 pub file: String,
49 pub line: usize,
51}
52
53#[derive(Debug, Clone)]
55pub struct Target {
56 pub path: Utf8PathBuf,
58 pub tech: Option<&'static str>,
60 pub managers: Vec<ManagerFile>,
62 pub envrc_use_flake: bool,
64 pub already: Vec<Already>,
66}
67
68impl Target {
69 #[must_use]
71 pub fn file_of(&self, manager: Manager) -> Option<&ManagerFile> {
72 self.managers.iter().find(|m| m.manager == manager)
73 }
74
75 #[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
87pub 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
127pub 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
160fn 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#[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#[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}