1pub mod discover;
17pub mod fragments;
18pub mod guard;
19pub mod leftovers;
20pub mod manager;
21pub mod matrix;
22pub mod pin;
23pub mod txn;
24pub mod venue;
25
26use std::path::PathBuf;
27
28use camino::{Utf8Path, Utf8PathBuf};
29use serde::Serialize;
30
31use crate::diagnostic::{Diagnostic, Reason};
32use crate::digest::Digest;
33use crate::error::RkError;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Presence {
39 Present,
41 Absent,
43}
44
45impl Presence {
46 #[must_use]
49 pub fn of(path: &Utf8Path) -> Self {
50 if std::fs::symlink_metadata(path).is_ok() {
51 Self::Present
52 } else {
53 Self::Absent
54 }
55 }
56
57 #[must_use]
59 pub const fn is_present(self) -> bool {
60 matches!(self, Self::Present)
61 }
62}
63
64#[derive(Debug, Clone)]
67pub struct Observed {
68 pub target: Utf8PathBuf,
70 pub flake: Presence,
72 pub lock: Presence,
74 pub scan: pin::Scan,
76 pub flake_text: Option<String>,
78 pub locked_rev: Option<String>,
80 pub locked_ref: Option<String>,
82 pub managers: Vec<manager::Entry>,
84 pub wired: Option<manager::Manager>,
86 pub envrc: Presence,
89 pub envrc_sync: bool,
91 pub pending: bool,
93 pub stamp: Option<String>,
95 pub leftovers: Vec<leftovers::Leftover>,
97 pub acquisition: Option<crate::config::Acquisition>,
100}
101
102impl Observed {
103 #[must_use]
105 pub fn key(&self) -> String {
106 state_key(&self.target)
107 }
108
109 #[must_use]
111 pub const fn pin_tag(&self) -> Option<&str> {
112 match &self.scan {
113 pin::Scan::One(pin) => Some(pin.tag.as_str()),
114 _ => None,
115 }
116 }
117
118 #[must_use]
122 pub fn state(&self) -> &'static str {
123 if self.pending {
124 return "pending-recovery";
125 }
126 let named: Vec<&manager::Entry> = self
127 .managers
128 .iter()
129 .filter(|entry| entry.read.names())
130 .collect();
131 if named.is_empty() && self.acquisition.is_some() {
136 return "host";
137 }
138 if self
139 .managers
140 .iter()
141 .all(|entry| !entry.present.is_present())
142 {
143 return "no-manager";
144 }
145 let entry = match named.as_slice() {
146 [] => return "not-wired",
147 [one] => *one,
148 _ => return "ambiguous-pin",
149 };
150 match entry.read {
151 manager::PinRead::Many { .. } => return "ambiguous-pin",
152 manager::PinRead::Unpinned { .. } => return "unpinned",
153 manager::PinRead::Absent | manager::PinRead::One { .. } => {}
154 }
155 if self.leftovers.is_empty() {
156 "ready"
157 } else {
158 "superseded"
159 }
160 }
161
162 #[must_use]
164 pub fn flake_entry(&self) -> Option<&manager::Entry> {
165 self.entry(manager::Manager::Flake)
166 }
167
168 #[must_use]
170 pub fn entry(&self, manager: manager::Manager) -> Option<&manager::Entry> {
171 self.managers.iter().find(|entry| entry.manager == manager)
172 }
173}
174
175pub fn observe(target: &Utf8Path) -> Result<Observed, RkError> {
182 let target = canonical_target(target)?;
183 let files = manager::manager_files(&target, Some(manager::DEP_NAME))?;
184 let mut managers = manager::entries(&files);
185 let flake_text = files
186 .iter()
187 .find(|file| file.manager == manager::Manager::Flake)
188 .map(|file| file.text.clone());
189 let flake = Presence::of(&target.join("flake.nix"));
192 let scan = flake_text.as_deref().map_or(pin::Scan::None, pin::scan);
193 let lock_path = target.join("flake.lock");
194 let lock = Presence::of(&lock_path);
195 let (locked_rev, locked_ref_name) = if lock.is_present() {
196 locked_node(&std::fs::read(&lock_path)?)
197 } else {
198 (None, None)
199 };
200 if let Some(entry) = managers
201 .iter_mut()
202 .find(|entry| entry.manager == manager::Manager::Flake)
203 {
204 entry.lock = Some(lock);
205 entry.locked_ref.clone_from(&locked_ref_name);
206 entry.locked_rev.clone_from(&locked_rev);
207 }
208 let named: Vec<manager::Manager> = managers
209 .iter()
210 .filter(|entry| entry.read.names())
211 .map(|entry| entry.manager)
212 .collect();
213 let wired = match named.as_slice() {
214 [one] => Some(*one),
215 _ => None,
216 };
217 let envrc_path = target.join(".envrc");
218 let envrc = Presence::of(&envrc_path);
219 let envrc_sync = envrc.is_present() && has_sync_line(&std::fs::read_to_string(&envrc_path)?);
220 let key = state_key(&target);
221 let pending = marker_path(&key).is_some_and(|marker| txn::marker_is_pending(&marker));
222 let stamp = read_stamp(&key);
223 let leftovers = leftovers::scan(&target)?;
224 let acquisition =
227 crate::config::load(target.as_std_path())?.and_then(|config| config.acquisition);
228 Ok(Observed {
229 target,
230 flake,
231 lock,
232 scan,
233 flake_text,
234 locked_rev,
235 locked_ref: locked_ref_name,
236 managers,
237 wired,
238 envrc,
239 envrc_sync,
240 pending,
241 stamp,
242 leftovers,
243 acquisition,
244 })
245}
246
247#[must_use]
250pub fn has_sync_line(text: &str) -> bool {
251 text.lines()
252 .any(|line| line.trim_start().starts_with("rk self-depend sync"))
253}
254
255fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
257 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
258 return (None, None);
259 };
260 let locked = &value["nodes"]["release-kit"]["locked"];
261 let read = |field: &str| locked[field].as_str().map(str::to_owned);
262 (read("rev"), read("ref"))
263}
264
265fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
267 if !target.is_dir() {
268 return Err(RkError::missing(
269 Diagnostic::new(
270 Reason::TargetNotFound,
271 format!("target {target} is not a directory"),
272 )
273 .expected("an existing project directory to read"),
274 ));
275 }
276 Ok(target.canonicalize_utf8()?)
277}
278
279#[must_use]
283pub fn state_key(target: &Utf8Path) -> String {
284 let base = target
285 .file_name()
286 .filter(|name| !name.is_empty())
287 .unwrap_or("root");
288 let digest = Digest::of(target.as_str().as_bytes()).to_string();
289 format!("{base}-{}", &digest[..16])
290}
291
292#[must_use]
295pub fn state_dir() -> Option<PathBuf> {
296 crate::applog::state_root().map(|root| root.join("devshell"))
297}
298
299#[must_use]
301pub fn lock_path(key: &str) -> Option<PathBuf> {
302 state_dir().map(|dir| dir.join(format!("{key}.lock")))
303}
304
305#[must_use]
307pub fn stamp_path(key: &str) -> Option<PathBuf> {
308 state_dir().map(|dir| dir.join(format!("{key}.stamp")))
309}
310
311#[must_use]
313pub fn backup_dir(key: &str) -> Option<PathBuf> {
314 state_dir().map(|dir| dir.join(key).join("backup"))
315}
316
317#[must_use]
319pub fn marker_path(key: &str) -> Option<PathBuf> {
320 state_dir().map(|dir| dir.join(key).join("pending.json"))
321}
322
323#[must_use]
325pub fn read_stamp(key: &str) -> Option<String> {
326 let text = std::fs::read_to_string(stamp_path(key)?).ok()?;
327 let day = text.trim();
328 (day.len() == 10).then(|| day.to_owned())
329}
330
331#[must_use]
334pub fn normalize_tag(raw: &str) -> Option<String> {
335 let trimmed = raw.trim().trim_end_matches('/');
336 let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
337 let bare = tail.strip_prefix('v').unwrap_or(tail);
338 let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
339 && bare
340 .chars()
341 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
342 shaped.then(|| format!("v{bare}"))
343}
344
345#[cfg(test)]
346mod tests {
347 use camino::Utf8Path;
348
349 use super::{has_sync_line, locked_node, normalize_tag, state_key};
350
351 #[test]
352 fn the_tag_normalizer_folds_three_shapes_to_one() {
353 for raw in [
354 "v0.2.16",
355 "0.2.16",
356 "https://github.com/owner/release-kit/releases/tag/v0.2.16",
357 "https://github.com/owner/release-kit/releases/tag/v0.2.16/",
358 " v0.2.16\n",
359 ] {
360 assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
361 }
362 assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
363 assert_eq!(normalize_tag(""), None);
364 assert_eq!(normalize_tag("latest"), None);
365 assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
366 assert_eq!(
367 normalize_tag("https://github.com/owner/release-kit/releases/latest"),
368 None
369 );
370 }
371
372 #[test]
373 fn the_state_key_is_stable_per_checkout() {
374 let a = state_key(Utf8Path::new("/srv/one/widget"));
375 let b = state_key(Utf8Path::new("/srv/two/widget"));
376 assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
377 assert_ne!(a, b, "two clones of one project key apart");
378 assert!(a.starts_with("widget-"), "{a}");
379 assert_eq!(a.len(), "widget-".len() + 16);
380 assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
381 }
382
383 #[test]
384 fn the_sync_line_is_found_by_its_verb() {
385 assert!(has_sync_line(
386 "use flake\nrk self-depend sync --apply || true\n"
387 ));
388 assert!(has_sync_line(" rk self-depend sync\n"));
389 assert!(!has_sync_line("# rk self-depend sync\nuse flake\n"));
390 assert!(!has_sync_line(""));
391 }
392
393 #[test]
394 fn the_locked_node_reads_the_release_kit_input() {
395 let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{}}}"#;
396 assert_eq!(
397 locked_node(lock),
398 (
399 Some("9f3c".to_owned()),
400 Some("refs/tags/v0.2.16".to_owned())
401 )
402 );
403 assert_eq!(locked_node(b"not json"), (None, None));
404 assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
405 }
406}