use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::error::NapError;
use crate::vcs::VcsBackend;
#[derive(Debug, Clone)]
pub struct AutopublishConfig {
pub interval: Duration,
pub branch: String,
pub message_template: String,
pub author: String,
}
impl Default for AutopublishConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(60),
branch: "main".to_string(),
message_template: "autopublish: periodic checkpoint".to_string(),
author: "nap-autopublish <nap@nap.dev>".to_string(),
}
}
}
pub struct AutopublishWorker {
workspace_path: PathBuf,
backend: Box<dyn VcsBackend>,
config: AutopublishConfig,
}
impl AutopublishWorker {
pub fn new(
workspace_path: PathBuf,
backend: Box<dyn VcsBackend>,
config: AutopublishConfig,
) -> Self {
Self {
workspace_path,
backend,
config,
}
}
pub fn start(self) -> Result<AutopublishHandle, NapError> {
if !self.workspace_path.exists() {
return Err(NapError::VcsError(format!(
"autopublish workspace does not exist: {:?}",
self.workspace_path
)));
}
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
let config = self.config;
let workspace_path = self.workspace_path;
let backend = self.backend;
std::thread::Builder::new()
.name("nap-autopublish".into())
.spawn(move || {
loop {
if !running_clone.load(Ordering::Relaxed) {
break;
}
let result = Self::publish_once(&workspace_path, backend.as_ref(), &config);
match result {
Ok(Some(sig)) => {
tracing::info!(
target: "nap::autopublish",
"autopublish committed revision {}",
sig
);
}
Ok(None) => {
tracing::debug!(
target: "nap::autopublish",
"autopublish: no changes to commit"
);
}
Err(e) => {
tracing::warn!(
target: "nap::autopublish",
"autopublish attempt failed (will retry): {}",
e
);
}
}
std::thread::sleep(config.interval);
}
})
.map_err(|e| {
NapError::VcsError(format!("failed to spawn autopublish thread: {}", e))
})?;
Ok(AutopublishHandle { running })
}
fn publish_once(
workspace_path: &Path,
backend: &dyn VcsBackend,
config: &AutopublishConfig,
) -> Result<Option<String>, NapError> {
let sig = backend.commit(workspace_path, &config.message_template, &config.author)?;
if sig.is_empty() || sig.contains("nothing to commit") {
return Ok(None);
}
Ok(Some(sig))
}
}
#[derive(Debug)]
pub struct AutopublishHandle {
running: Arc<AtomicBool>,
}
impl AutopublishHandle {
pub fn stop(self) {
self.running.store(false, Ordering::Relaxed);
}
}
impl Drop for AutopublishHandle {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockBackend {
signature: String,
}
impl VcsBackend for MockBackend {
fn init(&self, _path: &std::path::Path) -> Result<(), NapError> {
Ok(())
}
fn commit(
&self,
_path: &std::path::Path,
_message: &str,
_author: &str,
) -> Result<String, NapError> {
let sig = self.signature.clone();
Ok(sig)
}
fn read_file_at_ref(
&self,
_repo_path: &std::path::Path,
_file_path: &str,
_reference: Option<&str>,
) -> Result<String, NapError> {
Ok(String::new())
}
fn log(
&self,
_path: &std::path::Path,
_file: Option<&str>,
_limit: usize,
) -> Result<Vec<crate::vcs::CommitInfo>, NapError> {
Ok(Vec::new())
}
fn create_branch(&self, _path: &std::path::Path, _name: &str) -> Result<(), NapError> {
Ok(())
}
fn switch_branch(&self, _path: &std::path::Path, _name: &str) -> Result<(), NapError> {
Ok(())
}
fn create_tag(&self, _path: &std::path::Path, _name: &str) -> Result<(), NapError> {
Ok(())
}
fn current_branch(&self, _path: &std::path::Path) -> Result<String, NapError> {
Ok("main".to_string())
}
fn head_hash(&self, _path: &std::path::Path) -> Result<String, NapError> {
Ok("HEAD".to_string())
}
fn list_branches(&self, _path: &std::path::Path) -> Result<Vec<String>, NapError> {
Ok(vec!["main".to_string()])
}
fn list_tags(&self, _path: &std::path::Path) -> Result<Vec<String>, NapError> {
Ok(Vec::new())
}
fn add_remote(
&self,
_path: &std::path::Path,
_name: &str,
_url: &str,
) -> Result<(), NapError> {
Ok(())
}
fn remove_remote(&self, _path: &std::path::Path, _name: &str) -> Result<(), NapError> {
Ok(())
}
fn list_remotes(&self, _path: &std::path::Path) -> Result<Vec<(String, String)>, NapError> {
Ok(Vec::new())
}
fn push(
&self,
_path: &std::path::Path,
_remote: Option<&str>,
_branch: Option<&str>,
) -> Result<(), NapError> {
Ok(())
}
fn pull(
&self,
_path: &std::path::Path,
_remote: Option<&str>,
_branch: Option<&str>,
) -> Result<(), NapError> {
Ok(())
}
}
#[test]
fn test_publish_once_returns_sig() {
let config = AutopublishConfig::default();
let backend = MockBackend {
signature: "abc123".to_string(),
};
let path = Path::new("/tmp/nonexistent");
let result = AutopublishWorker::publish_once(path, &backend, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), Some("abc123".to_string()));
}
#[test]
fn test_publish_once_empty_sig() {
let config = AutopublishConfig::default();
let backend = MockBackend {
signature: String::new(),
};
let path = Path::new("/tmp/nonexistent");
let result = AutopublishWorker::publish_once(path, &backend, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_default_config() {
let config = AutopublishConfig::default();
assert_eq!(config.interval, Duration::from_secs(60));
assert_eq!(config.branch, "main");
assert_eq!(config.message_template, "autopublish: periodic checkpoint");
}
#[test]
fn test_start_fails_on_nonexistent_workspace() {
let config = AutopublishConfig::default();
let backend = MockBackend {
signature: "sig".to_string(),
};
let worker = AutopublishWorker::new(
PathBuf::from("/nonexistent-path-12345"),
Box::new(backend),
config,
);
let result = worker.start();
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("does not exist"),
"expected 'does not exist' error"
);
}
#[test]
fn test_handle_stop() {
let dir = tempfile::TempDir::new().unwrap();
let config = AutopublishConfig {
interval: Duration::from_millis(10),
..Default::default()
};
let backend = MockBackend {
signature: "sig".to_string(),
};
let worker = AutopublishWorker::new(dir.path().to_path_buf(), Box::new(backend), config);
let handle = worker.start().unwrap();
handle.stop();
}
}