1pub mod discover;
15pub mod fragments;
16pub mod guard;
17pub mod leftovers;
18pub mod pin;
19pub mod txn;
20
21use std::path::PathBuf;
22
23use camino::{Utf8Path, Utf8PathBuf};
24use serde::Serialize;
25
26use crate::diagnostic::{Diagnostic, Reason};
27use crate::digest::Digest;
28use crate::error::RkError;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum Presence {
34 Present,
36 Absent,
38}
39
40impl Presence {
41 #[must_use]
44 pub fn of(path: &Utf8Path) -> Self {
45 if std::fs::symlink_metadata(path).is_ok() {
46 Self::Present
47 } else {
48 Self::Absent
49 }
50 }
51
52 #[must_use]
54 pub const fn is_present(self) -> bool {
55 matches!(self, Self::Present)
56 }
57}
58
59#[derive(Debug, Clone)]
62pub struct Observed {
63 pub target: Utf8PathBuf,
65 pub flake: Presence,
67 pub lock: Presence,
69 pub scan: pin::Scan,
71 pub flake_text: Option<String>,
73 pub locked_rev: Option<String>,
75 pub locked_ref: Option<String>,
77 pub envrc: Presence,
79 pub envrc_sync: bool,
81 pub pending: bool,
83 pub stamp: Option<String>,
85 pub leftovers: Vec<leftovers::Leftover>,
87}
88
89impl Observed {
90 #[must_use]
92 pub fn key(&self) -> String {
93 state_key(&self.target)
94 }
95
96 #[must_use]
98 pub fn pin_tag(&self) -> Option<&str> {
99 match &self.scan {
100 pin::Scan::One(pin) => Some(pin.tag.as_str()),
101 _ => None,
102 }
103 }
104
105 #[must_use]
107 pub fn state(&self) -> &'static str {
108 if self.pending {
109 return "pending-recovery";
110 }
111 if !self.flake.is_present() {
112 return "no-flake";
113 }
114 match self.scan {
115 pin::Scan::Many(_) => return "ambiguous-pin",
116 pin::Scan::None => return "not-wired",
117 pin::Scan::Unpinned(_) => return "unpinned",
118 pin::Scan::One(_) => {}
119 }
120 if self.leftovers.is_empty() {
121 "ready"
122 } else {
123 "superseded"
124 }
125 }
126}
127
128pub fn observe(target: &Utf8Path) -> Result<Observed, RkError> {
135 let target = canonical_target(target)?;
136 let flake_path = target.join("flake.nix");
137 let flake = Presence::of(&flake_path);
138 let flake_text = if flake.is_present() {
139 Some(std::fs::read_to_string(&flake_path)?)
140 } else {
141 None
142 };
143 let scan = flake_text.as_deref().map_or(pin::Scan::None, pin::scan);
144 let lock_path = target.join("flake.lock");
145 let lock = Presence::of(&lock_path);
146 let (locked_rev, locked_ref_name) = if lock.is_present() {
147 locked_node(&std::fs::read(&lock_path)?)
148 } else {
149 (None, None)
150 };
151 let envrc_path = target.join(".envrc");
152 let envrc = Presence::of(&envrc_path);
153 let envrc_sync = envrc.is_present() && has_sync_line(&std::fs::read_to_string(&envrc_path)?);
154 let key = state_key(&target);
155 let pending = marker_path(&key).is_some_and(|marker| txn::marker_is_pending(&marker));
156 let stamp = read_stamp(&key);
157 let leftovers = leftovers::scan(&target)?;
158 Ok(Observed {
159 target,
160 flake,
161 lock,
162 scan,
163 flake_text,
164 locked_rev,
165 locked_ref: locked_ref_name,
166 envrc,
167 envrc_sync,
168 pending,
169 stamp,
170 leftovers,
171 })
172}
173
174#[must_use]
177pub fn has_sync_line(text: &str) -> bool {
178 text.lines()
179 .any(|line| line.trim_start().starts_with("rk devshell sync"))
180}
181
182fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
184 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
185 return (None, None);
186 };
187 let locked = &value["nodes"]["release-kit"]["locked"];
188 let read = |field: &str| locked[field].as_str().map(str::to_owned);
189 (read("rev"), read("ref"))
190}
191
192fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
194 if !target.is_dir() {
195 return Err(RkError::missing(
196 Diagnostic::new(
197 Reason::TargetNotFound,
198 format!("target {target} is not a directory"),
199 )
200 .expected("an existing project directory to read"),
201 ));
202 }
203 Ok(target.canonicalize_utf8()?)
204}
205
206#[must_use]
210pub fn state_key(target: &Utf8Path) -> String {
211 let base = target
212 .file_name()
213 .filter(|name| !name.is_empty())
214 .unwrap_or("root");
215 let digest = Digest::of(target.as_str().as_bytes()).to_string();
216 format!("{base}-{}", &digest[..16])
217}
218
219#[must_use]
222pub fn state_dir() -> Option<PathBuf> {
223 crate::applog::state_root().map(|root| root.join("devshell"))
224}
225
226#[must_use]
228pub fn lock_path(key: &str) -> Option<PathBuf> {
229 state_dir().map(|dir| dir.join(format!("{key}.lock")))
230}
231
232#[must_use]
234pub fn stamp_path(key: &str) -> Option<PathBuf> {
235 state_dir().map(|dir| dir.join(format!("{key}.stamp")))
236}
237
238#[must_use]
240pub fn backup_dir(key: &str) -> Option<PathBuf> {
241 state_dir().map(|dir| dir.join(key).join("backup"))
242}
243
244#[must_use]
246pub fn marker_path(key: &str) -> Option<PathBuf> {
247 state_dir().map(|dir| dir.join(key).join("pending.json"))
248}
249
250#[must_use]
252pub fn read_stamp(key: &str) -> Option<String> {
253 let text = std::fs::read_to_string(stamp_path(key)?).ok()?;
254 let day = text.trim();
255 (day.len() == 10).then(|| day.to_owned())
256}
257
258#[must_use]
261pub fn normalize_tag(raw: &str) -> Option<String> {
262 let trimmed = raw.trim().trim_end_matches('/');
263 let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
264 let bare = tail.strip_prefix('v').unwrap_or(tail);
265 let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
266 && bare
267 .chars()
268 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
269 shaped.then(|| format!("v{bare}"))
270}
271
272#[cfg(test)]
273mod tests {
274 #![allow(clippy::expect_used)]
275
276 use camino::Utf8Path;
277
278 use super::{has_sync_line, locked_node, normalize_tag, state_key};
279
280 #[test]
281 fn the_tag_normalizer_folds_three_shapes_to_one() {
282 for raw in [
283 "v0.2.16",
284 "0.2.16",
285 "https://github.com/owner/release-kit/releases/tag/v0.2.16",
286 "https://github.com/owner/release-kit/releases/tag/v0.2.16/",
287 " v0.2.16\n",
288 ] {
289 assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
290 }
291 assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
292 assert_eq!(normalize_tag(""), None);
293 assert_eq!(normalize_tag("latest"), None);
294 assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
295 assert_eq!(
296 normalize_tag("https://github.com/owner/release-kit/releases/latest"),
297 None
298 );
299 }
300
301 #[test]
302 fn the_state_key_is_stable_per_checkout() {
303 let a = state_key(Utf8Path::new("/srv/one/widget"));
304 let b = state_key(Utf8Path::new("/srv/two/widget"));
305 assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
306 assert_ne!(a, b, "two clones of one project key apart");
307 assert!(a.starts_with("widget-"), "{a}");
308 assert_eq!(a.len(), "widget-".len() + 16);
309 assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
310 }
311
312 #[test]
313 fn the_sync_line_is_found_by_its_verb() {
314 assert!(has_sync_line(
315 "use flake\nrk devshell sync --apply || true\n"
316 ));
317 assert!(has_sync_line(" rk devshell sync\n"));
318 assert!(!has_sync_line("# rk devshell sync\nuse flake\n"));
319 assert!(!has_sync_line(""));
320 }
321
322 #[test]
323 fn the_locked_node_reads_the_release_kit_input() {
324 let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{}}}"#;
325 assert_eq!(
326 locked_node(lock),
327 (
328 Some("9f3c".to_owned()),
329 Some("refs/tags/v0.2.16".to_owned())
330 )
331 );
332 assert_eq!(locked_node(b"not json"), (None, None));
333 assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
334 }
335}