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