use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use eter::{Eterator, GcGeneration, SnapshotRef};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::trace;
use crate::config::UpstreamSettings;
use crate::identifier::EntryAtom;
use crate::tide::TideResolution;
pub const LOCK_FILE_NAME: &str = "Sirno.lock.toml";
const LOCK_FILE_HEADER: &str = "\
# This file is generated and managed by Sirno.
# Do not edit it by hand.
";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SirnoLock {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frost: Option<FrostLock>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub upstreams: UpstreamLockMap,
#[serde(default, skip_serializing_if = "TideLock::is_empty")]
pub tide: TideLock,
}
pub type UpstreamLockMap = IndexMap<EntryAtom, UpstreamLock>;
impl SirnoLock {
pub fn current(snapshot: SnapshotRef) -> Self {
Self {
frost: Some(FrostLock::current(snapshot)),
upstreams: UpstreamLockMap::new(),
tide: TideLock::default(),
}
}
pub fn checked_out(snapshot: SnapshotRef, mutable: bool) -> Self {
Self {
frost: Some(FrostLock::checked_out(snapshot, mutable)),
upstreams: UpstreamLockMap::new(),
tide: TideLock::default(),
}
}
pub fn path_for_config(config_path: impl AsRef<Path>) -> PathBuf {
config_path.as_ref().parent().unwrap_or_else(|| Path::new(".")).join(LOCK_FILE_NAME)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, LockError> {
let path = path.as_ref();
trace!("sirno lock load begin: path={}", path.display());
let source = fs::read_to_string(path)
.map_err(|source| LockError::Read { path: path.to_path_buf(), source })?;
let lock: Self = toml::from_str(&source)
.map_err(|source| LockError::Parse { path: path.to_path_buf(), source })?;
lock.validate()?;
trace!("sirno lock load end");
Ok(lock)
}
pub fn from_file_if_exists(path: impl AsRef<Path>) -> Result<Option<Self>, LockError> {
match Self::from_file(path) {
| Ok(lock) => Ok(Some(lock)),
| Err(LockError::Read { source, .. }) if source.kind() == ErrorKind::NotFound => {
Ok(None)
}
| Err(source) => Err(source),
}
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), LockError> {
let path = path.as_ref();
trace!("sirno lock write begin: path={}", path.display());
let source = self.to_toml()?;
let temporary_path = Self::temporary_path(path);
let mut file =
OpenOptions::new().write(true).create_new(true).open(&temporary_path).map_err(
|source| LockError::CreateTemporary { path: temporary_path.clone(), source },
)?;
if let Err(source) = file.write_all(source.as_bytes()) {
drop(file);
let _ = fs::remove_file(&temporary_path);
return Err(LockError::WriteTemporary { path: temporary_path, source });
}
if let Err(source) = file.sync_all() {
drop(file);
let _ = fs::remove_file(&temporary_path);
return Err(LockError::WriteTemporary { path: temporary_path, source });
}
drop(file);
if let Err(source) = fs::rename(&temporary_path, path) {
let _ = fs::remove_file(&temporary_path);
return Err(LockError::Replace { path: path.to_path_buf(), temporary_path, source });
}
trace!("sirno lock write end");
Ok(())
}
fn validate(&self) -> Result<(), LockError> {
if let Some(frost) = &self.frost {
frost.validate()?;
}
if self.frost.is_none() && !self.tide.is_empty() {
return Err(LockError::TideWithoutFrost);
}
for (domain, upstream) in &self.upstreams {
upstream.validate(domain)?;
}
Ok(())
}
fn to_toml(&self) -> Result<String, LockError> {
self.validate()?;
let mut source = String::from(LOCK_FILE_HEADER);
source.push_str(&toml::to_string_pretty(self).map_err(LockError::Render)?);
Ok(source)
}
fn temporary_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().unwrap_or_else(|| OsStr::new(LOCK_FILE_NAME));
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let mut temporary_name = OsString::from(".");
temporary_name.push(file_name);
temporary_name.push(format!(".{}.{}.tmp", std::process::id(), nonce));
parent.join(temporary_name)
}
}
impl Default for SirnoLock {
fn default() -> Self {
Self { frost: None, upstreams: UpstreamLockMap::new(), tide: TideLock::default() }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamLock {
pub git: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
pub project: PathBuf,
pub lake: PathBuf,
pub commit: String,
}
impl UpstreamLock {
pub fn new(settings: &UpstreamSettings, lake: PathBuf, commit: impl Into<String>) -> Self {
Self {
git: settings.git.clone(),
branch: settings.branch.clone(),
tag: settings.tag.clone(),
rev: settings.rev.clone(),
project: settings.project.clone(),
lake,
commit: commit.into(),
}
}
pub fn matches_settings(&self, settings: &UpstreamSettings) -> bool {
self.git == settings.git
&& self.branch == settings.branch
&& self.tag == settings.tag
&& self.rev == settings.rev
&& self.project == settings.project
}
fn validate(&self, domain: &EntryAtom) -> Result<(), LockError> {
if self.git.trim().is_empty() {
return Err(LockError::UpstreamGitSource(domain.clone()));
}
if self.commit.trim().is_empty() {
return Err(LockError::UpstreamCommit(domain.clone()));
}
let ref_count = [self.branch.as_ref(), self.tag.as_ref(), self.rev.as_ref()]
.into_iter()
.flatten()
.count();
if ref_count != 1 {
return Err(LockError::UpstreamRefSelector(domain.clone()));
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TideLock {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resolved: Vec<TideResolution>,
}
impl TideLock {
pub fn is_empty(&self) -> bool {
self.resolved.is_empty()
}
pub fn set_resolved(&mut self, mut resolved: Vec<TideResolution>) {
resolved.sort();
resolved.dedup();
self.resolved = resolved;
}
pub fn clear(&mut self) {
self.resolved.clear();
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FrostLock {
pub status: FrostLockStatus,
pub generation: u64,
pub version: u64,
#[serde(default, skip_serializing_if = "is_false")]
pub mutable: bool,
}
impl FrostLock {
pub fn current(snapshot: SnapshotRef) -> Self {
Self {
status: FrostLockStatus::Current,
generation: snapshot.generation.number(),
version: snapshot.version(),
mutable: false,
}
}
pub fn checked_out(snapshot: SnapshotRef, mutable: bool) -> Self {
Self {
status: FrostLockStatus::CheckedOut,
generation: snapshot.generation.number(),
version: snapshot.version(),
mutable,
}
}
pub fn snapshot_ref(&self) -> SnapshotRef {
SnapshotRef::new(GcGeneration(self.generation), Eterator(self.version))
}
pub fn is_checked_out(&self) -> bool {
self.status == FrostLockStatus::CheckedOut
}
pub fn is_unsafe_mutable_checkout(&self) -> bool {
self.is_checked_out() && self.mutable
}
fn validate(&self) -> Result<(), LockError> {
if self.status == FrostLockStatus::Current && self.mutable {
return Err(LockError::CurrentMutable);
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FrostLockStatus {
Current,
CheckedOut,
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Error)]
pub enum LockError {
#[error("failed to read lock file {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse lock file {path}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to render lock file")]
Render(#[source] toml::ser::Error),
#[error("current frost state cannot be marked mutable")]
CurrentMutable,
#[error("tide lock state requires frost state")]
TideWithoutFrost,
#[error("locked upstream `{0}` git source must not be empty")]
UpstreamGitSource(EntryAtom),
#[error("locked upstream `{0}` must configure exactly one of branch, tag, or rev")]
UpstreamRefSelector(EntryAtom),
#[error("locked upstream `{0}` commit must not be empty")]
UpstreamCommit(EntryAtom),
#[error("failed to create temporary lock file {path}")]
CreateTemporary {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write temporary lock file {path}")]
WriteTemporary {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to replace lock file {path} with temporary lock file {temporary_path}")]
Replace {
path: PathBuf,
temporary_path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_current_frost_lock() {
let lock = SirnoLock::current(SnapshotRef::new(GcGeneration::INITIAL, Eterator(7)));
let rendered = lock.to_toml().unwrap();
assert_eq!(
rendered,
"\
# This file is generated and managed by Sirno.
# Do not edit it by hand.
[frost]
status = \"current\"
generation = 0
version = 7
"
);
}
#[test]
fn lock_path_uses_toml_suffix() {
let path = SirnoLock::path_for_config("/project/Sirno.toml");
assert_eq!(path, PathBuf::from("/project/Sirno.lock.toml"));
}
#[test]
fn renders_mutable_checkout_lock() {
let lock = SirnoLock::checked_out(SnapshotRef::new(GcGeneration(2), Eterator(3)), true);
let rendered = lock.to_toml().unwrap();
assert_eq!(
rendered,
"\
# This file is generated and managed by Sirno.
# Do not edit it by hand.
[frost]
status = \"checked-out\"
generation = 2
version = 3
mutable = true
"
);
}
#[test]
fn renders_upstream_lock_without_frost() {
let settings = UpstreamSettings::branch("../core.git", "main");
let lock = SirnoLock {
frost: None,
upstreams: UpstreamLockMap::from([(
EntryAtom::new("core").unwrap(),
UpstreamLock::new(&settings, PathBuf::from("docs"), "0123456789abcdef"),
)]),
tide: TideLock::default(),
};
let rendered = lock.to_toml().unwrap();
let read: SirnoLock = toml::from_str(&rendered).unwrap();
assert_eq!(read, lock);
assert!(rendered.contains("[upstreams.core]"));
assert!(rendered.contains("git = \"../core.git\""));
assert!(rendered.contains("branch = \"main\""));
assert!(rendered.contains("lake = \"docs\""));
assert!(rendered.contains("commit = \"0123456789abcdef\""));
}
#[test]
fn rejects_mutable_current_lock() {
let error = toml::from_str::<SirnoLock>(
r#"
[frost]
status = "current"
generation = 0
version = 3
mutable = true
"#,
)
.unwrap()
.validate()
.unwrap_err();
assert!(matches!(error, LockError::CurrentMutable));
}
#[test]
fn lock_write_replaces_existing_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(LOCK_FILE_NAME);
SirnoLock::current(SnapshotRef::new(GcGeneration::INITIAL, Eterator(1)))
.write(&path)
.unwrap();
SirnoLock::current(SnapshotRef::new(GcGeneration::INITIAL, Eterator(2)))
.write(&path)
.unwrap();
let rendered = fs::read_to_string(&path).unwrap();
assert!(rendered.contains("version = 2"));
assert!(!rendered.contains("version = 1"));
let paths = fs::read_dir(temp.path()).unwrap().count();
assert_eq!(paths, 1);
}
}