heroforge-core 0.2.2

Pure Rust core library for reading and writing Fossil SCM repositories
Documentation
//! Background commit timer and synchronous commit helpers.
//!
//! Due to SQLite's threading constraints (rusqlite::Connection is not Sync),
//! we use a timer-based approach where:
//! - A background thread tracks elapsed time
//! - When commit time arrives, it sets a flag
//! - The actual commit is performed synchronously by the caller
//!
//! This module provides:
//! - `CommitTimer` - Background timer that signals when commits are due
//! - `commit_now` - Synchronous commit helper
//! - `CommitConfig` - Configuration for commit behavior

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;

use crate::fs::errors::{FsError, FsResult};
use crate::fs::staging::{Staging, StagingState};
use crate::repo::Repository;

/// Default commit interval (1 minute)
pub const DEFAULT_COMMIT_INTERVAL: Duration = Duration::from_secs(60);

/// Minimum commit interval
pub const MIN_COMMIT_INTERVAL: Duration = Duration::from_secs(5);

/// Commit worker configuration
#[derive(Debug, Clone)]
pub struct CommitConfig {
    /// Interval between automatic commits
    pub interval: Duration,

    /// Maximum time to wait for lock before skipping commit
    pub lock_timeout: Duration,

    /// Whether to commit on shutdown
    pub commit_on_shutdown: bool,
}

impl Default for CommitConfig {
    fn default() -> Self {
        Self {
            interval: DEFAULT_COMMIT_INTERVAL,
            lock_timeout: Duration::from_secs(30),
            commit_on_shutdown: true,
        }
    }
}

/// A timer that signals when commits are due.
///
/// This runs a background thread that sets a flag when the commit interval elapses.
/// The actual commit must be performed by calling `try_commit()` with the repository.
pub struct CommitTimer {
    /// Thread handle
    handle: Option<JoinHandle<()>>,

    /// Signal to stop the timer
    stop_signal: Arc<AtomicBool>,

    /// Signal that a commit is due
    commit_due: Arc<AtomicBool>,

    /// Signal that a forced commit is requested
    force_commit: Arc<AtomicBool>,

    /// Configuration (kept for future use)
    #[allow(dead_code)]
    config: CommitConfig,
}

impl CommitTimer {
    /// Start a new commit timer
    pub fn start(config: CommitConfig) -> Self {
        let stop_signal = Arc::new(AtomicBool::new(false));
        let commit_due = Arc::new(AtomicBool::new(false));
        let force_commit = Arc::new(AtomicBool::new(false));

        let stop_clone = Arc::clone(&stop_signal);
        let commit_due_clone = Arc::clone(&commit_due);
        let force_clone = Arc::clone(&force_commit);
        let interval = config.interval;

        let handle = thread::Builder::new()
            .name("heroforge-commit-timer".to_string())
            .spawn(move || {
                Self::timer_loop(stop_clone, commit_due_clone, force_clone, interval);
            })
            .expect("Failed to spawn commit timer thread");

        Self {
            handle: Some(handle),
            stop_signal,
            commit_due,
            force_commit,
            config,
        }
    }

    /// Timer loop that runs in the background
    fn timer_loop(
        stop_signal: Arc<AtomicBool>,
        commit_due: Arc<AtomicBool>,
        force_commit: Arc<AtomicBool>,
        interval: Duration,
    ) {
        let sleep_interval = Duration::from_millis(100);
        let mut elapsed = Duration::ZERO;

        loop {
            if stop_signal.load(Ordering::SeqCst) {
                // Signal final commit on stop
                commit_due.store(true, Ordering::SeqCst);
                break;
            }

            // Check for forced commit
            if force_commit.swap(false, Ordering::SeqCst) {
                commit_due.store(true, Ordering::SeqCst);
                elapsed = Duration::ZERO;
            }

            // Check if interval elapsed
            if elapsed >= interval {
                commit_due.store(true, Ordering::SeqCst);
                elapsed = Duration::ZERO;
            }

            thread::sleep(sleep_interval);
            elapsed += sleep_interval;
        }
    }

    /// Check if a commit is due and try to perform it
    pub fn try_commit(&self, staging: &Staging, repo: &Repository) -> FsResult<Option<String>> {
        if !self.commit_due.swap(false, Ordering::SeqCst) {
            return Ok(None);
        }

        let result = commit_now(staging, repo)?;
        if result == "no-changes" {
            Ok(None)
        } else {
            Ok(Some(result))
        }
    }

    /// Request an immediate forced commit
    pub fn force_commit(&self) {
        self.force_commit.store(true, Ordering::SeqCst);
    }

    /// Check if a commit is currently due
    pub fn is_commit_due(&self) -> bool {
        self.commit_due.load(Ordering::SeqCst)
    }

    /// Stop the timer
    pub fn stop(&mut self) {
        self.stop_signal.store(true, Ordering::SeqCst);
        if let Some(handle) = self.handle.take() {
            let _ = handle.join();
        }
    }

    /// Check if the timer is still running
    pub fn is_running(&self) -> bool {
        self.handle
            .as_ref()
            .map(|h| !h.is_finished())
            .unwrap_or(false)
    }
}

impl Drop for CommitTimer {
    fn drop(&mut self) {
        self.stop();
    }
}

/// Legacy CommitWorker type alias for backwards compatibility
pub type CommitWorker = CommitTimer;

impl CommitWorker {
    /// Start a new commit worker (delegates to CommitTimer)
    ///
    /// Note: The repo parameter is ignored as commits happen synchronously.
    /// Use `try_commit()` or `commit_now()` to perform actual commits.
    pub fn start_legacy(
        _staging: Staging,
        _repo: Arc<Repository>,
        config: CommitConfig,
    ) -> FsResult<Self> {
        Ok(CommitTimer::start(config))
    }
}

/// Perform the actual commit operation
fn do_commit(state: &mut StagingState, repo: &Repository) -> FsResult<String> {
    let author = state.author().to_string();
    let branch = state.branch().to_string();

    // Collect staged changes (new/modified files and deletions)
    let mut staged_files: Vec<(String, Vec<u8>)> = Vec::new();
    let mut deletions: std::collections::HashSet<String> = std::collections::HashSet::new();

    for (path, staged_file) in state.files() {
        if staged_file.is_deleted {
            deletions.insert(path.clone());
        } else if staged_file.modified {
            let content = state.read_file(path)?;
            staged_files.push((path.clone(), content));
        }
    }

    if staged_files.is_empty() && deletions.is_empty() {
        // Nothing to commit
        state.mark_clean();
        return Ok("no-changes".to_string());
    }

    // Get parent commit hash and inherit files from parent
    let parent_hash = repo
        .branches()
        .get(&branch)
        .ok()
        .and_then(|b| b.tip().ok())
        .map(|c| c.hash);

    // Build complete file list: parent files + staged changes - deletions
    let mut files_to_commit: Vec<(String, Vec<u8>)> = Vec::new();
    let staged_paths: std::collections::HashSet<String> =
        staged_files.iter().map(|(p, _)| p.clone()).collect();

    // First, add files from parent that aren't being modified or deleted
    if let Some(ref parent) = parent_hash {
        if let Ok(parent_files) = repo.list_files_internal(parent) {
            for file_info in parent_files {
                // Skip if this file is being deleted
                if deletions.contains(&file_info.name) {
                    continue;
                }
                // Skip if this file is being replaced with staged content
                if staged_paths.contains(&file_info.name) {
                    continue;
                }
                // Read the file content from parent and include it
                if let Ok(content) = repo.read_file_internal(parent, &file_info.name) {
                    files_to_commit.push((file_info.name, content));
                }
            }
        }
    }

    // Add all staged files (new and modified)
    files_to_commit.extend(staged_files);

    // Build commit message
    let deletions_vec: Vec<String> = deletions.iter().cloned().collect();
    let message = build_commit_message(&files_to_commit, &deletions_vec);

    // Convert files for commit
    let files_refs: Vec<(&str, &[u8])> = files_to_commit
        .iter()
        .map(|(p, c)| (p.as_str(), c.as_slice()))
        .collect();

    // Perform commit
    let commit_hash = repo
        .commit_internal(
            &files_refs,
            &message,
            &author,
            parent_hash.as_deref(),
            Some(&branch),
        )
        .map_err(|e| FsError::DatabaseError(format!("Commit failed: {}", e)))?;

    // Clear staging after successful commit
    state.clear()?;

    Ok(commit_hash)
}

/// Build a commit message from the staged changes
fn build_commit_message(files: &[(String, Vec<u8>)], deletions: &[String]) -> String {
    let mut parts = Vec::new();

    if !files.is_empty() {
        if files.len() == 1 {
            parts.push(format!("Update {}", files[0].0));
        } else {
            parts.push(format!("Update {} files", files.len()));
        }
    }

    if !deletions.is_empty() {
        if deletions.len() == 1 {
            parts.push(format!("Delete {}", deletions[0]));
        } else {
            parts.push(format!("Delete {} files", deletions.len()));
        }
    }

    if parts.is_empty() {
        "Auto-commit".to_string()
    } else {
        parts.join(", ")
    }
}

/// Synchronous commit helper (for forced commits from main thread)
pub fn commit_now(staging: &Staging, repo: &Repository) -> FsResult<String> {
    let mut state = staging.write();

    if !state.is_dirty() {
        return Ok("no-changes".to_string());
    }

    do_commit(&mut state, repo)
}

/// Force a commit and wait for completion
pub fn force_commit_sync(staging: &Staging, repo: &Repository) -> FsResult<String> {
    commit_now(staging, repo)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_commit_config_default() {
        let config = CommitConfig::default();
        assert_eq!(config.interval, DEFAULT_COMMIT_INTERVAL);
        assert!(config.commit_on_shutdown);
    }

    #[test]
    fn test_build_commit_message() {
        let files = vec![
            ("file1.txt".to_string(), vec![1, 2, 3]),
            ("file2.txt".to_string(), vec![4, 5, 6]),
        ];
        let deletions = vec!["old.txt".to_string()];

        let msg = build_commit_message(&files, &deletions);
        assert!(msg.contains("Update 2 files"));
        assert!(msg.contains("Delete old.txt"));
    }

    #[test]
    fn test_build_commit_message_single_file() {
        let files = vec![("readme.md".to_string(), vec![1, 2, 3])];
        let deletions: Vec<String> = vec![];

        let msg = build_commit_message(&files, &deletions);
        assert_eq!(msg, "Update readme.md");
    }

    #[test]
    fn test_commit_timer_creation() {
        let config = CommitConfig::default();
        let mut timer = CommitTimer::start(config);
        assert!(timer.is_running());
        timer.stop();
    }
}