use crate::error::{GwmError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::Builder;
pub const MAX_ENTRIES: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OpKind {
Remove,
Create,
}
impl OpKind {
pub fn as_str(self) -> &'static str {
match self {
OpKind::Remove => "remove",
OpKind::Create => "create",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpEntry {
pub ts: DateTime<Utc>,
pub kind: OpKind,
pub worktree: String,
pub branch: Option<String>,
pub branch_oid: Option<String>,
pub path: PathBuf,
pub deleted_branch: bool,
pub repo_root: PathBuf,
#[serde(default)]
pub undone: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Journal {
#[serde(default, rename = "op")]
entries: Vec<OpEntry>,
}
impl Journal {
pub fn load(path: &Path) -> Result<Self> {
match fs::read_to_string(path) {
Ok(raw) => {
let journal: Journal = toml::from_str(&raw)?;
Ok(journal)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e.into()),
}
}
pub fn append(&mut self, entry: OpEntry) {
self.entries.push(entry);
if self.entries.len() > MAX_ENTRIES {
let oldest_idx = self
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.ts)
.map(|(i, _)| i);
if let Some(i) = oldest_idx {
self.entries.remove(i);
}
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => {
fs::create_dir_all(p)?;
p.to_path_buf()
}
_ => PathBuf::from("."),
};
let body = toml::to_string_pretty(self)?;
let mut tmp = Builder::new()
.prefix("gwm-history-")
.suffix(".tmp")
.tempfile_in(&parent)?;
tmp.write_all(body.as_bytes())?;
tmp.persist(path).map_err(|e| GwmError::Io(e.error))?;
Ok(())
}
pub fn entries(&self) -> &[OpEntry] {
&self.entries
}
pub fn entries_for_repo<'a>(&'a self, repo_root: &'a Path) -> impl Iterator<Item = &'a OpEntry> + 'a {
self.entries.iter().filter(move |e| e.repo_root == repo_root)
}
pub fn last_for_repo<'a>(&'a self, repo_root: &'a Path) -> Option<&'a OpEntry> {
self.entries_for_repo(repo_root).max_by_key(|e| e.ts)
}
pub fn pop_last_for_repo(&mut self, repo_root: &Path) -> Option<OpEntry> {
let target = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.repo_root == repo_root)
.max_by_key(|(_, e)| e.ts)
.map(|(i, _)| i)?;
Some(self.entries.remove(target))
}
}
pub fn default_journal_path() -> Result<PathBuf> {
if let Ok(p) = std::env::var("GWM_HISTORY_FILE") {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
if let Ok(p) = std::env::var("XDG_DATA_HOME") {
if !p.is_empty() {
return Ok(PathBuf::from(p).join("gwm").join("history.toml"));
}
}
let base = dirs::data_dir().ok_or_else(|| {
GwmError::Other("could not resolve user data directory — set GWM_HISTORY_FILE to override".into())
})?;
Ok(base.join("gwm").join("history.toml"))
}
pub fn record(entry: OpEntry) -> Result<()> {
let path = default_journal_path()?;
let mut journal = Journal::load(&path)?;
journal.append(entry);
journal.save(&path)?;
Ok(())
}