use std::path::Path;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use crate::backup::git_ops::GitOps;
use crate::config::{BackupConfig, ClinConfig};
pub enum BackupJob {
Auto(String),
Flush(String),
}
const DEBOUNCE: Duration = Duration::from_secs(2);
pub const FLUSH_BOUND: Duration = Duration::from_secs(15);
pub fn spawn(
git_lock: Arc<Mutex<()>>,
status: Arc<Mutex<Option<String>>>,
) -> (Sender<BackupJob>, Receiver<()>) {
let (tx, rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
std::thread::Builder::new()
.name("clin-backup-worker".into())
.spawn(move || {
worker_loop(&rx, &git_lock, &status, &done_tx);
})
.expect("failed to spawn backup worker");
(tx, done_rx)
}
fn worker_loop(
rx: &Receiver<BackupJob>,
git_lock: &Arc<Mutex<()>>,
status: &Arc<Mutex<Option<String>>>,
done: &Sender<()>,
) {
loop {
let first = match rx.recv() {
Ok(job) => job,
Err(_) => {
let _ = done.send(());
return;
}
};
match first {
BackupJob::Flush(msg) => {
while rx.try_recv().is_ok() {}
run_backup(git_lock, status, &msg);
}
BackupJob::Auto(msg) => {
let mut current = msg;
let start = Instant::now();
'debounce: loop {
let remaining = DEBOUNCE.saturating_sub(start.elapsed());
if remaining.is_zero() {
break;
}
match rx.recv_timeout(remaining) {
Ok(BackupJob::Auto(m)) => current = m,
Ok(BackupJob::Flush(m)) => {
current = m;
while rx.try_recv().is_ok() {}
break 'debounce;
}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => {
run_backup(git_lock, status, ¤t);
let _ = done.send(());
return;
}
}
}
run_backup(git_lock, status, ¤t);
}
}
}
}
fn run_backup(git_lock: &Arc<Mutex<()>>, status: &Arc<Mutex<Option<String>>>, message: &str) {
let config = ClinConfig::load().unwrap_or_default();
let vault_path = config
.effective_storage_path()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
perform(git_lock, status, &vault_path, &config.backup, message);
}
pub(crate) fn perform(
git_lock: &Arc<Mutex<()>>,
status: &Arc<Mutex<Option<String>>>,
vault_path: &Path,
backup: &BackupConfig,
message: &str,
) {
if !backup.enabled {
return;
}
let result = (|| -> anyhow::Result<()> {
let _guard = git_lock.lock();
let git_ops = GitOps::init(vault_path)?;
if git_ops.has_changes().unwrap_or(false) {
git_ops.add_all().and_then(|_| git_ops.commit(message))?;
if backup.auto_push
&& let Some(remote) = &backup.remote_name
{
git_ops.push(remote)?;
}
}
Ok(())
})();
*status.lock() = result.err().map(|e| e.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn locks() -> (Arc<Mutex<()>>, Arc<Mutex<Option<String>>>) {
(Arc::new(Mutex::new(())), Arc::new(Mutex::new(None)))
}
#[test]
fn perform_creates_commit() {
let tmp = tempdir().expect("tempdir");
let vault = tmp.path();
GitOps::init(vault).expect("init");
{
let repo = git2::Repository::open(vault).expect("open repo");
let mut cfg = repo.config().expect("config");
cfg.set_str("user.name", "test").expect("set user.name");
cfg.set_str("user.email", "test@test.com")
.expect("set user.email");
}
fs::write(vault.join("note.md"), "hello").expect("write");
let (git_lock, status) = locks();
perform(
&git_lock,
&status,
vault,
&BackupConfig {
enabled: true,
..Default::default()
},
"t",
);
assert!(status.lock().is_none(), "status should be clean");
let git_ops = GitOps::init(vault).expect("init");
assert!(
!git_ops.log(1).unwrap_or_default().is_empty(),
"expected a commit"
);
}
#[test]
fn perform_records_error_status() {
let tmp = tempdir().expect("tempdir");
let file_path = tmp.path().join("not-a-dir");
fs::write(&file_path, "x").expect("write");
let (git_lock, status) = locks();
perform(
&git_lock,
&status,
&file_path,
&BackupConfig {
enabled: true,
..Default::default()
},
"t",
);
assert!(status.lock().is_some(), "expected an error status");
}
#[test]
fn perform_disabled_is_noop() {
let tmp = tempdir().expect("tempdir");
let vault = tmp.path();
GitOps::init(vault).expect("init");
fs::write(vault.join("note.md"), "hello").expect("write");
let (git_lock, status) = locks();
perform(
&git_lock,
&status,
vault,
&BackupConfig {
enabled: false,
..Default::default()
},
"t",
);
assert!(status.lock().is_none(), "disabled must not set status");
let git_ops = GitOps::init(vault).expect("init");
assert!(
git_ops.log(1).unwrap_or_default().is_empty(),
"disabled must not commit"
);
}
#[test]
fn worker_shuts_down_on_drop() {
let (git_lock, status) = locks();
let (tx, done_rx) = spawn(git_lock, status);
tx.send(BackupJob::Auto("a".into())).expect("send 1");
tx.send(BackupJob::Auto("b".into())).expect("send 2");
drop(tx);
let received = done_rx.recv_timeout(FLUSH_BOUND);
assert!(
received.is_ok(),
"worker should signal shutdown: {received:?}"
);
}
}