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]
258pub fn pinned_version(target: &Utf8Path) -> Option<String> {
259 let observed = observe(target).ok()?;
260 let entry = observed.entry(observed.wired?)?;
261 entry
262 .read
263 .version()
264 .map(|version| version.trim_start_matches('v').to_owned())
265}
266
267#[must_use]
270pub fn has_sync_line(text: &str) -> bool {
271 text.lines()
272 .any(|line| line.trim_start().starts_with("rk self-depend sync"))
273}
274
275#[must_use]
278pub fn has_check_line(text: &str) -> bool {
279 text.lines()
280 .any(|line| line.trim_start().starts_with("rk self-depend check"))
281}
282
283const MAX_FOLLOWS: usize = 32;
286
287fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
295 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
296 return (None, None);
297 };
298 let nodes = &value["nodes"];
299 let root = value["root"].as_str().unwrap_or("root");
300 let Some(node) = input_node(nodes, root, root, "release-kit", 0) else {
301 return (None, None);
302 };
303 let locked = &nodes[node.as_str()]["locked"];
304 let read = |field: &str| locked[field].as_str().map(str::to_owned);
305 (read("rev"), read("ref"))
306}
307
308fn input_node(
311 nodes: &serde_json::Value,
312 root: &str,
313 node: &str,
314 input: &str,
315 depth: usize,
316) -> Option<String> {
317 match &nodes[node]["inputs"][input] {
318 serde_json::Value::String(name) => Some(name.clone()),
319 serde_json::Value::Array(path) if depth < MAX_FOLLOWS => {
320 let mut at = root.to_owned();
321 for step in path {
322 at = input_node(nodes, root, &at, step.as_str()?, depth + 1)?;
323 }
324 Some(at)
325 }
326 _ => None,
327 }
328}
329
330fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
332 if !target.is_dir() {
333 return Err(RkError::missing(
334 Diagnostic::new(
335 Reason::TargetNotFound,
336 format!("target {target} is not a directory"),
337 )
338 .expected("an existing project directory to read"),
339 ));
340 }
341 Ok(target.canonicalize_utf8()?)
342}
343
344#[must_use]
348pub fn state_key(target: &Utf8Path) -> String {
349 let base = target
350 .file_name()
351 .filter(|name| !name.is_empty())
352 .unwrap_or("root");
353 let digest = Digest::of(target.as_str().as_bytes()).to_string();
354 format!("{base}-{}", &digest[..16])
355}
356
357#[must_use]
360pub fn state_dir() -> Option<PathBuf> {
361 crate::applog::state_root().map(|root| root.join("devshell"))
362}
363
364#[must_use]
366pub fn lock_path(key: &str) -> Option<PathBuf> {
367 state_dir().map(|dir| dir.join(format!("{key}.lock")))
368}
369
370#[must_use]
373pub fn check_cache_dir() -> Option<PathBuf> {
374 crate::applog::cache_root().map(|root| root.join("self-depend"))
375}
376
377#[must_use]
379pub fn backup_dir(key: &str) -> Option<PathBuf> {
380 state_dir().map(|dir| dir.join(key).join("backup"))
381}
382
383#[must_use]
385pub fn marker_path(key: &str) -> Option<PathBuf> {
386 state_dir().map(|dir| dir.join(key).join("pending.json"))
387}
388
389#[must_use]
392pub fn normalize_tag(raw: &str) -> Option<String> {
393 let trimmed = raw.trim().trim_end_matches('/');
394 let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
395 let bare = tail.strip_prefix('v').unwrap_or(tail);
396 let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
397 && bare
398 .chars()
399 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
400 shaped.then(|| format!("v{bare}"))
401}
402
403#[cfg(test)]
404mod tests {
405 use camino::Utf8Path;
406
407 use super::{has_sync_line, locked_node, normalize_tag, state_key};
408
409 #[test]
410 fn the_tag_normalizer_folds_three_shapes_to_one() {
411 for raw in [
412 "v0.2.16",
413 "0.2.16",
414 "https://github.com/owner/release-kit/releases/tag/v0.2.16",
415 "https://github.com/owner/release-kit/releases/tag/v0.2.16/",
416 " v0.2.16\n",
417 ] {
418 assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
419 }
420 assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
421 assert_eq!(normalize_tag(""), None);
422 assert_eq!(normalize_tag("latest"), None);
423 assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
424 assert_eq!(
425 normalize_tag("https://github.com/owner/release-kit/releases/latest"),
426 None
427 );
428 }
429
430 #[test]
431 fn the_state_key_is_stable_per_checkout() {
432 let a = state_key(Utf8Path::new("/srv/one/widget"));
433 let b = state_key(Utf8Path::new("/srv/two/widget"));
434 assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
435 assert_ne!(a, b, "two clones of one project key apart");
436 assert!(a.starts_with("widget-"), "{a}");
437 assert_eq!(a.len(), "widget-".len() + 16);
438 assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
439 }
440
441 #[test]
442 fn the_sync_line_is_found_by_its_verb() {
443 assert!(has_sync_line(
444 "use flake\nrk self-depend sync --apply || true\n"
445 ));
446 assert!(has_sync_line(" rk self-depend sync\n"));
447 assert!(!has_sync_line("# rk self-depend sync\nuse flake\n"));
448 assert!(!has_sync_line(""));
449 }
450
451 #[test]
452 fn the_locked_node_reads_the_release_kit_input() {
453 let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{"inputs":{"release-kit":"release-kit"}}},"root":"root"}"#;
454 assert_eq!(
455 locked_node(lock),
456 (
457 Some("9f3c".to_owned()),
458 Some("refs/tags/v0.2.16".to_owned())
459 )
460 );
461 assert_eq!(locked_node(b"not json"), (None, None));
462 assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
463 assert_eq!(
464 locked_node(br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c"}},"root":{}}}"#),
465 (None, None),
466 "a node the root does not name is not the project's input"
467 );
468 }
469
470 #[test]
471 fn the_locked_node_follows_the_root_input() {
472 let lock = br#"{"nodes":{
473 "exobrain-tech":{"inputs":{"release-kit":"release-kit"},"locked":{"rev":"e0e0"}},
474 "release-kit":{"locked":{"rev":"0543ebe","ref":"refs/tags/v0.9.1"}},
475 "release-kit_2":{"locked":{"rev":"3fd21b5","ref":"refs/tags/v0.9.15"}},
476 "top":{"inputs":{"exobrain-tech":"exobrain-tech","release-kit":"release-kit_2"}}
477 },"root":"top","version":7}"#;
478 assert_eq!(
479 locked_node(lock),
480 (
481 Some("3fd21b5".to_owned()),
482 Some("refs/tags/v0.9.15".to_owned())
483 )
484 );
485 }
486
487 #[test]
488 fn a_followed_root_input_resolves_through_its_path() {
489 let lock = br#"{"nodes":{
490 "exobrain-tech":{"inputs":{"release-kit":"release-kit_2"}},
491 "release-kit_2":{"locked":{"rev":"3fd21b5","ref":"refs/tags/v0.9.15"}},
492 "root":{"inputs":{"exobrain-tech":"exobrain-tech","release-kit":["exobrain-tech","release-kit"]}}
493 },"root":"root","version":7}"#;
494 assert_eq!(
495 locked_node(lock),
496 (
497 Some("3fd21b5".to_owned()),
498 Some("refs/tags/v0.9.15".to_owned())
499 )
500 );
501 let chained = br#"{"nodes":{
502 "a":{"inputs":{"release-kit":["b","release-kit"]}},
503 "b":{"inputs":{"release-kit":"release-kit_3"}},
504 "release-kit_3":{"locked":{"rev":"77aa"}},
505 "root":{"inputs":{"a":"a","b":"b","release-kit":["a","release-kit"]}}
506 },"version":7}"#;
507 assert_eq!(locked_node(chained), (Some("77aa".to_owned()), None));
508 let looped = br#"{"nodes":{
509 "root":{"inputs":{"release-kit":["release-kit"]}}
510 },"version":7}"#;
511 assert_eq!(locked_node(looped), (None, None), "a loop names no input");
512 let broken = br#"{"nodes":{
513 "root":{"inputs":{"release-kit":["absent","release-kit"]}}
514 },"version":7}"#;
515 assert_eq!(locked_node(broken), (None, None));
516 }
517}