use crate::cli::{Options, SortBy, TargetMode};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt::{self, Display};
use std::fs::{self, File, OpenOptions};
use std::io::Write;
#[cfg(unix)]
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(test)]
use std::sync::{Mutex, OnceLock};
const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransactionPhase {
Planned,
RepoUpgradeStarted,
RepoUpgradeDone,
AurPlanWritten,
RepoDepsInstallStarted,
RepoDepsInstalled,
AurSourceSyncStarted,
AurSourceSynced,
AurBuildStarted,
AurBuilt,
AurInstallStarted,
AurInstalled,
Completed,
Failed,
Aborted,
}
impl TransactionPhase {
pub fn is_finished(self) -> bool {
matches!(self, Self::Completed | Self::Aborted)
}
pub fn blocks_aur_resume(self) -> bool {
matches!(self, Self::RepoUpgradeStarted)
}
}
impl Display for TransactionPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AurItemStatus {
Pending,
SourceSynced,
BuildStarted,
Built,
InstallStarted,
Installed,
Failed,
}
impl Display for AurItemStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransactionOptions {
pub mode: TargetMode,
pub no_confirm: bool,
pub needed: bool,
pub no_check: bool,
pub prefer_bin: bool,
pub quiet: bool,
pub no_progress: bool,
pub repo_deps_as_deps: bool,
}
impl TransactionOptions {
pub fn from_options(options: &Options) -> Self {
Self {
mode: options.mode,
no_confirm: options.no_confirm,
needed: options.needed,
no_check: options.no_check,
prefer_bin: options.prefer_bin,
quiet: options.quiet,
no_progress: options.no_progress,
repo_deps_as_deps: true,
}
}
pub fn to_options(&self) -> Options {
Options {
mode: self.mode,
no_confirm: self.no_confirm,
needed: self.needed,
dry_run: false,
quiet: self.quiet,
no_check: self.no_check,
prefer_bin: self.prefer_bin,
limit: None,
sort_by: SortBy::Relevance,
search_by: crate::cli::SearchBy::NameDesc,
no_progress: self.no_progress,
resume: false,
abort: false,
repair: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transaction {
pub schema: u32,
pub id: String,
pub command: Vec<String>,
pub started_at_unix: u64,
pub updated_at_unix: u64,
pub phase: TransactionPhase,
pub refresh: bool,
pub sysupgrade: bool,
pub options: TransactionOptions,
pub repo_deps: Vec<String>,
pub aur_items: Vec<AurTransactionItem>,
pub unresolved: Vec<String>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AurTransactionItem {
pub name: String,
pub base: String,
pub version: Option<String>,
pub status: AurItemStatus,
pub build_dir: PathBuf,
pub artifacts: Vec<PathBuf>,
pub installed_as_dependency: bool,
}
pub struct TransactionLock {
_file: File,
_path: PathBuf,
}
#[derive(Debug, Clone)]
struct StatePaths {
root: PathBuf,
transactions: PathBuf,
current: PathBuf,
lock: PathBuf,
}
pub fn load_current() -> Result<Option<Transaction>> {
let current = state_paths().current;
if !current.exists() {
return Ok(None);
}
let data = fs::read_to_string(¤t)
.with_context(|| format!("read transaction state {}", current.display()))?;
let tx = serde_json::from_str::<Transaction>(&data)
.with_context(|| format!("parse transaction state {}", current.display()))?;
if tx.phase.is_finished() {
return Ok(None);
}
Ok(Some(tx))
}
pub fn create_current(
command: Vec<String>,
refresh: bool,
sysupgrade: bool,
options: &Options,
) -> Result<Transaction> {
if let Some(existing) = load_current()? {
bail!(
"active Knott transaction {} is already in progress at phase {}",
existing.id,
existing.phase
);
}
let mut tx = new_unsaved(command, refresh, sysupgrade, options);
save_current(&tx)?;
tx.updated_at_unix = unix_now();
Ok(tx)
}
pub fn new_unsaved(
command: Vec<String>,
refresh: bool,
sysupgrade: bool,
options: &Options,
) -> Transaction {
let now = unix_now();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
Transaction {
schema: SCHEMA,
id: format!("{now}-{}-{nanos}", std::process::id()),
command,
started_at_unix: now,
updated_at_unix: now,
phase: TransactionPhase::Planned,
refresh,
sysupgrade,
options: TransactionOptions::from_options(options),
repo_deps: Vec::new(),
aur_items: Vec::new(),
unresolved: Vec::new(),
last_error: None,
}
}
pub fn save_current(tx: &Transaction) -> Result<()> {
let mut tx = tx.clone();
tx.updated_at_unix = unix_now();
let paths = state_paths();
fs::create_dir_all(&paths.transactions).with_context(|| {
format!(
"create transaction directory {}",
paths.transactions.display()
)
})?;
write_json_atomic(&paths.transactions.join(format!("{}.json", tx.id)), &tx)?;
write_json_atomic(&paths.current, &tx)?;
Ok(())
}
pub fn mark_failed(tx: &mut Transaction, err: impl Display) -> Result<()> {
tx.phase = TransactionPhase::Failed;
tx.last_error = Some(err.to_string());
save_current(tx)
}
pub fn mark_completed(tx: &mut Transaction) -> Result<()> {
tx.phase = TransactionPhase::Completed;
tx.last_error = None;
save_current(tx)?;
clear_current()
}
pub fn mark_aborted(tx: &mut Transaction) -> Result<()> {
tx.phase = TransactionPhase::Aborted;
tx.last_error = None;
save_current(tx)?;
clear_current()
}
pub fn clear_current() -> Result<()> {
let current = state_paths().current;
match fs::remove_file(¤t) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => {
Err(err).with_context(|| format!("remove transaction pointer {}", current.display()))
}
}
}
pub fn acquire_lock() -> Result<TransactionLock> {
let paths = state_paths();
if let Some(parent) = paths.lock.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock directory {}", parent.display()))?;
}
fs::create_dir_all(&paths.root)
.with_context(|| format!("create state directory {}", paths.root.display()))?;
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&paths.lock)
.with_context(|| format!("open transaction lock {}", paths.lock.display()))?;
#[cfg(unix)]
{
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if result != 0 {
bail!(
"another Knott transaction is running or the lock is held: {}",
paths.lock.display()
);
}
}
Ok(TransactionLock {
_file: file,
_path: paths.lock,
})
}
pub fn state_root_path() -> PathBuf {
state_paths().root
}
pub fn transactions_dir_path() -> PathBuf {
state_paths().transactions
}
pub fn current_path() -> PathBuf {
state_paths().current
}
pub fn lock_path() -> PathBuf {
state_paths().lock
}
impl Drop for TransactionLock {
fn drop(&mut self) {
#[cfg(unix)]
unsafe {
let _ = libc::flock(self._file.as_raw_fd(), libc::LOCK_UN);
}
}
}
fn write_json_atomic<T>(path: &Path, value: &T) -> Result<()>
where
T: Serialize,
{
let Some(parent) = path.parent() else {
bail!("state path has no parent: {}", path.display());
};
fs::create_dir_all(parent)
.with_context(|| format!("create state directory {}", parent.display()))?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let tmp = parent.join(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("state"),
std::process::id(),
nanos
));
{
let mut file = File::create(&tmp)
.with_context(|| format!("create temporary state file {}", tmp.display()))?;
let data = serde_json::to_vec_pretty(value)?;
file.write_all(&data)
.with_context(|| format!("write temporary state file {}", tmp.display()))?;
file.write_all(b"\n")?;
file.sync_all()
.with_context(|| format!("sync temporary state file {}", tmp.display()))?;
}
fs::rename(&tmp, path).with_context(|| {
format!(
"atomically replace transaction state {} with {}",
path.display(),
tmp.display()
)
})?;
let _ = File::open(parent).and_then(|dir| dir.sync_all());
Ok(())
}
fn state_paths() -> StatePaths {
let state_home = env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
.unwrap_or_else(|| PathBuf::from(".local/state"));
let root = state_home.join("knott");
let transactions = root.join("transactions");
let current = root.join("current.json");
let lock = env::var_os("XDG_RUNTIME_DIR")
.map(|runtime| PathBuf::from(runtime).join("knott.lock"))
.unwrap_or_else(|| root.join("knott.lock"));
StatePaths {
root,
transactions,
current,
lock,
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_state(name: &str) -> PathBuf {
let path = env::temp_dir().join(format!(
"knott-{name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&path).unwrap();
path
}
fn options() -> Options {
Options::default()
}
#[test]
fn transaction_json_roundtrip() {
let tx = Transaction {
schema: SCHEMA,
id: "tx".into(),
command: vec!["-Syu".into()],
started_at_unix: 1,
updated_at_unix: 2,
phase: TransactionPhase::AurBuilt,
refresh: true,
sysupgrade: true,
options: TransactionOptions::from_options(&options()),
repo_deps: vec!["dep".into()],
aur_items: vec![AurTransactionItem {
name: "foo".into(),
base: "foo".into(),
version: Some("1-1".into()),
status: AurItemStatus::Built,
build_dir: PathBuf::from("/tmp/foo"),
artifacts: vec![PathBuf::from("/tmp/foo/foo.pkg.tar.zst")],
installed_as_dependency: false,
}],
unresolved: vec!["missing".into()],
last_error: None,
};
let data = serde_json::to_string(&tx).unwrap();
let decoded: Transaction = serde_json::from_str(&data).unwrap();
assert_eq!(decoded, tx);
}
#[test]
fn atomic_save_and_active_detection() {
let _guard = test_env_lock();
let state = temp_state("atomic-save");
env::set_var("XDG_STATE_HOME", &state);
env::remove_var("XDG_RUNTIME_DIR");
let mut tx = create_current(vec!["-Syu".into()], true, true, &options()).unwrap();
tx.phase = TransactionPhase::AurPlanWritten;
save_current(&tx).unwrap();
let loaded = load_current().unwrap().unwrap();
assert_eq!(loaded.id, tx.id);
assert_eq!(loaded.phase, TransactionPhase::AurPlanWritten);
assert!(current_path().exists());
assert!(transactions_dir_path()
.join(format!("{}.json", tx.id))
.exists());
}
#[test]
fn completed_and_aborted_transactions_clear_current_pointer() {
let _guard = test_env_lock();
let state = temp_state("clear-current");
env::set_var("XDG_STATE_HOME", &state);
env::remove_var("XDG_RUNTIME_DIR");
let mut tx = create_current(vec!["-Syu".into()], true, true, &options()).unwrap();
mark_completed(&mut tx).unwrap();
assert!(load_current().unwrap().is_none());
assert!(!current_path().exists());
let mut tx =
create_current(vec!["-S".into(), "foo".into()], false, false, &options()).unwrap();
mark_aborted(&mut tx).unwrap();
assert!(load_current().unwrap().is_none());
assert!(!current_path().exists());
}
}