use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const MARKER_FILE: &str = ".inflight";
pub fn marker_path(frame_dir: &Path) -> PathBuf {
frame_dir.join(MARKER_FILE)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Operation {
CrossTrackMove {
moves: Vec<MovedTask>,
source_track: String,
target_track: String,
},
TrackArchive { track_id: String, file: String },
ActorMerge {
sources: Vec<String>,
target: String,
},
Triage {
index: usize,
title: String,
track_id: String,
},
}
impl Operation {
pub fn name(&self) -> &'static str {
match self {
Operation::CrossTrackMove { .. } => "mv --track",
Operation::TrackArchive { .. } => "track archive",
Operation::ActorMerge { .. } => "actor merge",
Operation::Triage { .. } => "triage",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MovedTask {
pub old_id: String,
pub new_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Marker {
pub command: String,
pub started: String,
#[serde(flatten)]
pub operation: Operation,
}
pub fn read(frame_dir: &Path) -> Option<Marker> {
let text = fs::read_to_string(marker_path(frame_dir)).ok()?;
toml::from_str(&text).ok()
}
pub fn clear(frame_dir: &Path) -> io::Result<()> {
match fs::remove_file(marker_path(frame_dir)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[must_use = "an uncommitted InFlight marks the operation as interrupted"]
pub struct InFlight {
path: PathBuf,
committed: bool,
}
impl InFlight {
pub fn begin(frame_dir: &Path, operation: Operation, command: &str) -> io::Result<Self> {
let marker = Marker {
command: command.to_string(),
started: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
operation,
};
let text = toml::to_string_pretty(&marker)
.map_err(|e| io::Error::other(format!("serialize {MARKER_FILE}: {e}")))?;
let path = marker_path(frame_dir);
fs::write(&path, text)?;
Ok(InFlight {
path,
committed: false,
})
}
pub fn commit(mut self) {
self.committed = true;
}
}
impl Drop for InFlight {
fn drop(&mut self) {
if self.committed {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn op() -> Operation {
Operation::CrossTrackMove {
moves: vec![MovedTask {
old_id: "A-001".into(),
new_id: "B-001".into(),
}],
source_track: "a".into(),
target_track: "b".into(),
}
}
#[test]
fn committing_removes_the_marker() {
let tmp = TempDir::new().unwrap();
let guard = InFlight::begin(tmp.path(), op(), "fr mv A-001 --track b").unwrap();
assert!(read(tmp.path()).is_some(), "marker written on begin");
guard.commit();
assert!(read(tmp.path()).is_none(), "marker removed on commit");
}
#[test]
fn dropping_without_committing_leaves_the_marker() {
let tmp = TempDir::new().unwrap();
{
let _guard = InFlight::begin(tmp.path(), op(), "fr mv A-001 --track b").unwrap();
}
let marker = read(tmp.path()).expect("marker should survive an uncommitted drop");
assert_eq!(marker.operation, op());
assert_eq!(marker.command, "fr mv A-001 --track b");
}
#[test]
fn round_trips_every_operation_shape() {
let tmp = TempDir::new().unwrap();
for operation in [
op(),
Operation::TrackArchive {
track_id: "a".into(),
file: "tracks/a.md".into(),
},
Operation::ActorMerge {
sources: vec!["x".into(), "z".into()],
target: "y".into(),
},
Operation::Triage {
index: 2,
title: "an item".into(),
track_id: "a".into(),
},
] {
let guard = InFlight::begin(tmp.path(), operation.clone(), "cmd").unwrap();
std::mem::forget(guard); assert_eq!(read(tmp.path()).unwrap().operation, operation);
clear(tmp.path()).unwrap();
}
}
#[test]
fn clearing_an_absent_marker_succeeds() {
let tmp = TempDir::new().unwrap();
clear(tmp.path()).unwrap();
clear(tmp.path()).unwrap();
}
#[test]
fn an_unparseable_marker_reads_as_absent() {
let tmp = TempDir::new().unwrap();
fs::write(marker_path(tmp.path()), "not toml {{{").unwrap();
assert!(read(tmp.path()).is_none());
}
}