worktrunk 0.37.1

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
//! Config mutation methods with file locking.
//!
//! These methods modify the UserConfig and persist changes to disk,
//! using file locking to prevent race conditions between concurrent processes.

use fs2::FileExt;

use crate::config::ConfigError;

use crate::path::format_path_for_display;

use super::UserConfig;
use super::path;
use super::sections::CommitGenerationConfig;

const NO_CONFIG_DIR_MSG: &str = "Cannot determine config directory. Set $HOME or $XDG_CONFIG_HOME";

/// Acquire an exclusive lock on the config file for read-modify-write operations.
///
/// Uses a `.lock` file alongside the config file to coordinate between processes.
/// The lock is released when the returned guard is dropped.
pub(crate) fn acquire_config_lock(
    config_path: &std::path::Path,
) -> Result<std::fs::File, ConfigError> {
    let lock_path = config_path.with_extension("toml.lock");

    // Create parent directory if needed
    if let Some(parent) = lock_path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| ConfigError(format!("Failed to create config directory: {e}")))?;
    }

    let file = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&lock_path)
        .map_err(|e| ConfigError(format!("Failed to open lock file: {e}")))?;

    file.lock_exclusive()
        .map_err(|e| ConfigError(format!("Failed to acquire config lock: {e}")))?;

    Ok(file)
}

impl UserConfig {
    /// Execute a mutation under an exclusive file lock.
    ///
    /// Acquires lock, reloads from disk, calls the mutator, and saves if mutator returns true.
    pub(super) fn with_locked_mutation<F>(
        &mut self,
        config_path: Option<&std::path::Path>,
        mutate: F,
    ) -> Result<(), ConfigError>
    where
        F: FnOnce(&mut Self) -> bool,
    {
        let path = match config_path {
            Some(p) => p.to_path_buf(),
            None => path::config_path().ok_or_else(|| ConfigError(NO_CONFIG_DIR_MSG.into()))?,
        };
        let _lock = acquire_config_lock(&path)?;
        self.reload_from(&path)?;

        if mutate(self) {
            self.save_to(&path)?;
        }
        Ok(())
    }

    /// Reload all fields from disk so the in-memory config matches the current
    /// file state before applying mutations.
    ///
    /// The diff-based `save_to` writes ALL serializable fields, so the reload
    /// must refresh everything to avoid overwriting concurrent manual edits
    /// with stale in-memory data. After reload, the mutator applies its
    /// specific change, and `save_to` persists the full state.
    fn reload_from(&mut self, path: &std::path::Path) -> Result<(), ConfigError> {
        if !path.exists() {
            return Ok(());
        }

        let content = std::fs::read_to_string(path).map_err(|e| {
            ConfigError(format!(
                "Failed to read config file {}: {}",
                format_path_for_display(path),
                e
            ))
        })?;

        let migrated = crate::config::deprecation::migrate_content(&content);
        let disk_config: UserConfig = toml::from_str(&migrated).map_err(|e| {
            ConfigError(format!(
                "Failed to parse config file {}: {}",
                format_path_for_display(path),
                e
            ))
        })?;

        *self = disk_config;

        Ok(())
    }

    /// Set `skip-shell-integration-prompt = true` and save.
    ///
    /// Acquires lock, reloads from disk, sets flag if not already set, and saves.
    /// Pass `None` for default config path, or `Some(path)` for testing.
    pub fn set_skip_shell_integration_prompt(
        &mut self,
        config_path: Option<&std::path::Path>,
    ) -> Result<(), ConfigError> {
        self.with_locked_mutation(config_path, |config| {
            if config.skip_shell_integration_prompt {
                return false;
            }
            config.skip_shell_integration_prompt = true;
            true
        })
    }

    /// Set `skip-commit-generation-prompt = true` and save.
    ///
    /// Acquires lock, reloads from disk, sets flag if not already set, and saves.
    /// Pass `None` for default config path, or `Some(path)` for testing.
    pub fn set_skip_commit_generation_prompt(
        &mut self,
        config_path: Option<&std::path::Path>,
    ) -> Result<(), ConfigError> {
        self.with_locked_mutation(config_path, |config| {
            if config.skip_commit_generation_prompt {
                return false;
            }
            config.skip_commit_generation_prompt = true;
            true
        })
    }

    /// Set worktree-path for a specific project and save.
    ///
    /// Creates the project entry if it doesn't exist.
    /// Pass `None` for default config path, or `Some(path)` for testing.
    pub fn set_project_worktree_path(
        &mut self,
        project: &str,
        worktree_path: String,
        config_path: Option<&std::path::Path>,
    ) -> Result<(), ConfigError> {
        self.with_locked_mutation(config_path, |config| {
            let entry = config.projects.entry(project.to_string()).or_default();
            if entry.worktree_path.as_ref() == Some(&worktree_path) {
                return false;
            }
            entry.worktree_path = Some(worktree_path);
            true
        })
    }

    /// Set commit generation command and save.
    ///
    /// Sets `[commit.generation] command = ...` in the user config.
    /// Acquires lock, reloads from disk, sets the command, and saves.
    /// Pass `None` for default config path, or `Some(path)` for testing.
    pub fn set_commit_generation_command(
        &mut self,
        command: String,
        config_path: Option<&std::path::Path>,
    ) -> Result<(), ConfigError> {
        self.with_locked_mutation(config_path, |config| {
            let gen_config = config
                .commit
                .generation
                .get_or_insert_with(CommitGenerationConfig::default);

            gen_config.command = Some(command.clone());
            true
        })
    }
}