pub mod discover;
pub mod fragments;
pub mod guard;
pub mod leftovers;
pub mod pin;
pub mod txn;
use std::path::PathBuf;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Presence {
Present,
Absent,
}
impl Presence {
#[must_use]
pub fn of(path: &Utf8Path) -> Self {
if std::fs::symlink_metadata(path).is_ok() {
Self::Present
} else {
Self::Absent
}
}
#[must_use]
pub const fn is_present(self) -> bool {
matches!(self, Self::Present)
}
}
#[derive(Debug, Clone)]
pub struct Observed {
pub target: Utf8PathBuf,
pub flake: Presence,
pub lock: Presence,
pub scan: pin::Scan,
pub flake_text: Option<String>,
pub locked_rev: Option<String>,
pub locked_ref: Option<String>,
pub envrc: Presence,
pub envrc_sync: bool,
pub pending: bool,
pub stamp: Option<String>,
pub leftovers: Vec<leftovers::Leftover>,
}
impl Observed {
#[must_use]
pub fn key(&self) -> String {
state_key(&self.target)
}
#[must_use]
pub fn pin_tag(&self) -> Option<&str> {
match &self.scan {
pin::Scan::One(pin) => Some(pin.tag.as_str()),
_ => None,
}
}
#[must_use]
pub fn state(&self) -> &'static str {
if self.pending {
return "pending-recovery";
}
if !self.flake.is_present() {
return "no-flake";
}
match self.scan {
pin::Scan::Many(_) => return "ambiguous-pin",
pin::Scan::None => return "not-wired",
pin::Scan::Unpinned(_) => return "unpinned",
pin::Scan::One(_) => {}
}
if self.leftovers.is_empty() {
"ready"
} else {
"superseded"
}
}
}
pub fn observe(target: &Utf8Path) -> Result<Observed, RkError> {
let target = canonical_target(target)?;
let flake_path = target.join("flake.nix");
let flake = Presence::of(&flake_path);
let flake_text = if flake.is_present() {
Some(std::fs::read_to_string(&flake_path)?)
} else {
None
};
let scan = flake_text.as_deref().map_or(pin::Scan::None, pin::scan);
let lock_path = target.join("flake.lock");
let lock = Presence::of(&lock_path);
let (locked_rev, locked_ref_name) = if lock.is_present() {
locked_node(&std::fs::read(&lock_path)?)
} else {
(None, None)
};
let envrc_path = target.join(".envrc");
let envrc = Presence::of(&envrc_path);
let envrc_sync = envrc.is_present() && has_sync_line(&std::fs::read_to_string(&envrc_path)?);
let key = state_key(&target);
let pending = marker_path(&key).is_some_and(|marker| txn::marker_is_pending(&marker));
let stamp = read_stamp(&key);
let leftovers = leftovers::scan(&target)?;
Ok(Observed {
target,
flake,
lock,
scan,
flake_text,
locked_rev,
locked_ref: locked_ref_name,
envrc,
envrc_sync,
pending,
stamp,
leftovers,
})
}
#[must_use]
pub fn has_sync_line(text: &str) -> bool {
text.lines()
.any(|line| line.trim_start().starts_with("rk devshell sync"))
}
fn locked_node(bytes: &[u8]) -> (Option<String>, Option<String>) {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return (None, None);
};
let locked = &value["nodes"]["release-kit"]["locked"];
let read = |field: &str| locked[field].as_str().map(str::to_owned);
(read("rev"), read("ref"))
}
fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory"),
)
.expected("an existing project directory to read"),
));
}
Ok(target.canonicalize_utf8()?)
}
#[must_use]
pub fn state_key(target: &Utf8Path) -> String {
let base = target
.file_name()
.filter(|name| !name.is_empty())
.unwrap_or("root");
let digest = Digest::of(target.as_str().as_bytes()).to_string();
format!("{base}-{}", &digest[..16])
}
#[must_use]
pub fn state_dir() -> Option<PathBuf> {
crate::applog::state_root().map(|root| root.join("devshell"))
}
#[must_use]
pub fn lock_path(key: &str) -> Option<PathBuf> {
state_dir().map(|dir| dir.join(format!("{key}.lock")))
}
#[must_use]
pub fn stamp_path(key: &str) -> Option<PathBuf> {
state_dir().map(|dir| dir.join(format!("{key}.stamp")))
}
#[must_use]
pub fn backup_dir(key: &str) -> Option<PathBuf> {
state_dir().map(|dir| dir.join(key).join("backup"))
}
#[must_use]
pub fn marker_path(key: &str) -> Option<PathBuf> {
state_dir().map(|dir| dir.join(key).join("pending.json"))
}
#[must_use]
pub fn read_stamp(key: &str) -> Option<String> {
let text = std::fs::read_to_string(stamp_path(key)?).ok()?;
let day = text.trim();
(day.len() == 10).then(|| day.to_owned())
}
#[must_use]
pub fn normalize_tag(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
let tail = trimmed.rsplit('/').next().unwrap_or(trimmed);
let bare = tail.strip_prefix('v').unwrap_or(tail);
let shaped = bare.chars().next().is_some_and(|c| c.is_ascii_digit())
&& bare
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
shaped.then(|| format!("v{bare}"))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use camino::Utf8Path;
use super::{has_sync_line, locked_node, normalize_tag, state_key};
#[test]
fn the_tag_normalizer_folds_three_shapes_to_one() {
for raw in [
"v0.2.16",
"0.2.16",
"https://github.com/owner/release-kit/releases/tag/v0.2.16",
"https://github.com/owner/release-kit/releases/tag/v0.2.16/",
" v0.2.16\n",
] {
assert_eq!(normalize_tag(raw).as_deref(), Some("v0.2.16"), "{raw:?}");
}
assert_eq!(normalize_tag("v0.3.0-rc.1").as_deref(), Some("v0.3.0-rc.1"));
assert_eq!(normalize_tag(""), None);
assert_eq!(normalize_tag("latest"), None);
assert_eq!(normalize_tag("vv0.2.16"), None, "a doubled v is not a tag");
assert_eq!(
normalize_tag("https://github.com/owner/release-kit/releases/latest"),
None
);
}
#[test]
fn the_state_key_is_stable_per_checkout() {
let a = state_key(Utf8Path::new("/srv/one/widget"));
let b = state_key(Utf8Path::new("/srv/two/widget"));
assert_eq!(a, state_key(Utf8Path::new("/srv/one/widget")));
assert_ne!(a, b, "two clones of one project key apart");
assert!(a.starts_with("widget-"), "{a}");
assert_eq!(a.len(), "widget-".len() + 16);
assert!(state_key(Utf8Path::new("/")).starts_with("root-"));
}
#[test]
fn the_sync_line_is_found_by_its_verb() {
assert!(has_sync_line(
"use flake\nrk devshell sync --apply || true\n"
));
assert!(has_sync_line(" rk devshell sync\n"));
assert!(!has_sync_line("# rk devshell sync\nuse flake\n"));
assert!(!has_sync_line(""));
}
#[test]
fn the_locked_node_reads_the_release_kit_input() {
let lock = br#"{"nodes":{"release-kit":{"locked":{"rev":"9f3c","ref":"refs/tags/v0.2.16"}},"root":{}}}"#;
assert_eq!(
locked_node(lock),
(
Some("9f3c".to_owned()),
Some("refs/tags/v0.2.16".to_owned())
)
);
assert_eq!(locked_node(b"not json"), (None, None));
assert_eq!(locked_node(br#"{"nodes":{}}"#), (None, None));
}
}