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