agent-team-mail-core 1.1.0

Daemon-free core library for local agent team mail workflows.
Documentation
use std::collections::BTreeSet;
use std::fs;
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

use serde::{Deserialize, Serialize};
use tracing::warn;

use crate::error::{AtmError, AtmErrorCode, AtmErrorKind};
use crate::persistence;
use crate::process::process_is_alive;

#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct SendAlertState {
    #[serde(default)]
    pub(super) missing_team_config_keys: BTreeSet<String>,
}

/// Owner-layer state path for ATM-owned send alert coordination state.
pub(super) fn state_path(home_dir: &Path) -> PathBuf {
    home_dir.join(".config").join("atm").join("state.json")
}

pub(super) fn lock_path(home_dir: &Path) -> PathBuf {
    home_dir.join(".config").join("atm").join("state.lock")
}

pub(super) fn missing_team_config_alert_key(team_dir: &Path) -> String {
    team_dir.join("config.json").display().to_string()
}

pub(super) fn load(path: &Path) -> Result<SendAlertState, AtmError> {
    if !path.exists() {
        return Ok(SendAlertState::default());
    }

    let raw = fs::read_to_string(path).map_err(|error| {
        AtmError::new(
            AtmErrorKind::Config,
            format!(
                "failed to read send alert state at {}: {error}",
                path.display()
            ),
        )
        .with_recovery("Check ATM config-state permissions or remove the damaged state file before retrying the send command.")
        .with_source(error)
    })?;
    serde_json::from_str(&raw).map_err(|error| {
        AtmError::new(
            AtmErrorKind::Config,
            format!(
                "failed to parse send alert state at {}: {error}",
                path.display()
            ),
        )
        .with_recovery(
            "Remove the malformed send alert state file so ATM can recreate it on the next send.",
        )
        .with_source(error)
    })
}

/// Persist ATM-owned send alert coordination state through one owner helper.
pub(super) fn save(path: &Path, state: &SendAlertState) -> Result<(), AtmError> {
    let data = serde_json::to_vec(state)?;
    persistence::atomic_write_bytes(
        path,
        &data,
        AtmErrorKind::Config,
        "send alert state",
        "Check ATM config-state directory permissions and rerun the send operation.",
    )
}

pub(super) fn acquire_lock(path: &Path) -> Option<SendAlertLock> {
    if let Some(parent) = path.parent()
        && let Err(error) = fs::create_dir_all(parent)
    {
        warn!(
            code = %AtmErrorCode::WarningSendAlertStateDegraded,
            %error,
            path = %parent.display(),
            "failed to create send alert lock directory"
        );
        return None;
    }

    for _ in 0..100 {
        match OpenOptions::new().write(true).create_new(true).open(path) {
            Ok(mut file) => {
                let pid = std::process::id().to_string();
                if let Err(error) = std::io::Write::write_all(&mut file, pid.as_bytes()) {
                    warn!(
                        code = %AtmErrorCode::WarningSendAlertStateDegraded,
                        %error,
                        path = %path.display(),
                        "failed to write send alert lock pid"
                    );
                    let _ = fs::remove_file(path);
                    return None;
                }
                return Some(SendAlertLock {
                    path: path.to_path_buf(),
                });
            }
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
                if evict_stale_send_alert_lock(path) {
                    thread::sleep(Duration::from_millis(10));
                    continue;
                }
                thread::sleep(Duration::from_millis(10));
            }
            Err(error) => {
                warn!(
                    code = %AtmErrorCode::WarningSendAlertStateDegraded,
                    %error,
                    path = %path.display(),
                    "failed to create send alert lock"
                );
                return None;
            }
        }
    }

    None
}

pub(super) fn register_missing_team_config_alert(home_dir: &Path, key: &str) -> bool {
    let state_path = state_path(home_dir);
    let lock_path = lock_path(home_dir);
    let Some(_guard) = acquire_lock(&lock_path) else {
        warn!(
            code = %AtmErrorCode::WarningSendAlertStateDegraded,
            path = %lock_path.display(),
            "failed to acquire send alert lock; skipping team-lead notification"
        );
        return false;
    };

    let mut state = match load(&state_path) {
        Ok(state) => state,
        Err(error) => {
            warn!(
                code = %AtmErrorCode::WarningSendAlertStateDegraded,
                %error,
                path = %state_path.display(),
                "failed to read send state file - defaulting to empty state"
            );
            SendAlertState::default()
        }
    };
    if state.missing_team_config_keys.contains(key) {
        return false;
    }

    state.missing_team_config_keys.insert(key.to_string());
    if let Err(error) = save(&state_path, &state) {
        warn!(
            code = %AtmErrorCode::WarningSendAlertStateDegraded,
            %error,
            path = %state_path.display(),
            "failed to save send alert dedup state"
        );
    }
    true
}

pub(super) fn clear_missing_team_config_alert(home_dir: &Path, key: &str) {
    let state_path = state_path(home_dir);
    let lock_path = lock_path(home_dir);
    let Some(_guard) = acquire_lock(&lock_path) else {
        warn!(
            code = %AtmErrorCode::WarningSendAlertStateDegraded,
            path = %lock_path.display(),
            "failed to acquire send alert lock while clearing dedup state"
        );
        return;
    };

    let Ok(mut state) = load(&state_path) else {
        return;
    };
    if !state.missing_team_config_keys.remove(key) {
        return;
    }

    if let Err(error) = save(&state_path, &state) {
        warn!(
            code = %AtmErrorCode::WarningSendAlertStateDegraded,
            %error,
            path = %state_path.display(),
            "failed to clear send alert dedup state"
        );
    }
}

pub(super) struct SendAlertLock {
    path: PathBuf,
}

impl Drop for SendAlertLock {
    fn drop(&mut self) {
        if let Err(error) = fs::remove_file(&self.path)
            && error.kind() != std::io::ErrorKind::NotFound
        {
            warn!(
                code = %AtmErrorCode::WarningSendAlertStateDegraded,
                %error,
                path = %self.path.display(),
                "failed to remove send alert lock"
            );
        }
    }
}

fn evict_stale_send_alert_lock(path: &Path) -> bool {
    let Ok(raw) = fs::read_to_string(path) else {
        return false;
    };
    let Ok(pid) = raw.trim().parse::<u32>() else {
        return false;
    };
    if process_is_alive(pid) {
        return false;
    }

    match fs::remove_file(path) {
        Ok(()) => true,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
        Err(error) => {
            warn!(
                code = %AtmErrorCode::WarningSendAlertStateDegraded,
                %error,
                path = %path.display(),
                pid,
                "failed to evict stale send alert lock"
            );
            false
        }
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::{
        SendAlertState, acquire_lock, clear_missing_team_config_alert, load, lock_path,
        missing_team_config_alert_key, register_missing_team_config_alert, save, state_path,
    };

    #[test]
    fn load_send_alert_state_missing_file_returns_default() {
        let tempdir = tempdir().expect("tempdir");
        let path = state_path(tempdir.path());

        let state = load(&path).expect("default state");

        assert!(state.missing_team_config_keys.is_empty());
    }

    #[test]
    fn load_send_alert_state_defaults_missing_keys_field() {
        let tempdir = tempdir().expect("tempdir");
        let path = state_path(tempdir.path());
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("state dir");
        }
        fs::write(&path, "{}").expect("state file");

        let state = load(&path).expect("compat state");

        assert!(state.missing_team_config_keys.is_empty());
    }

    #[test]
    fn load_send_alert_state_read_errors_are_config_errors() {
        let tempdir = tempdir().expect("tempdir");
        let path = state_path(tempdir.path());
        fs::create_dir_all(&path).expect("directory instead of file");

        let error = load(&path).expect_err("read error");

        assert!(error.is_config());
        assert!(error.message.contains("failed to read send alert state"));
    }

    #[test]
    fn save_send_alert_state_writes_expected_json_shape() {
        let tempdir = tempdir().expect("tempdir");
        let path = state_path(tempdir.path());
        let mut state = SendAlertState::default();
        state
            .missing_team_config_keys
            .insert("teams/zeta/config.json".to_string());
        state
            .missing_team_config_keys
            .insert("teams/alpha/config.json".to_string());

        save(&path, &state).expect("save");

        let raw = fs::read_to_string(&path).expect("saved state");
        assert_eq!(
            raw,
            "{\"missing_team_config_keys\":[\"teams/alpha/config.json\",\"teams/zeta/config.json\"]}"
        );
    }

    #[test]
    fn acquire_send_alert_lock_creates_parent_writes_pid_and_cleans_up_on_drop() {
        let tempdir = tempdir().expect("tempdir");
        let path = lock_path(tempdir.path());

        let guard = acquire_lock(&path).expect("lock guard");

        assert!(
            path.parent().expect("lock parent").exists(),
            "lock parent directory should be created"
        );
        assert_eq!(
            fs::read_to_string(&path).expect("lock contents").trim(),
            std::process::id().to_string()
        );
        drop(guard);
        assert!(!path.exists());
    }

    #[test]
    fn acquire_send_alert_lock_returns_none_while_live_pid_lock_exists() {
        let tempdir = tempdir().expect("tempdir");
        let path = lock_path(tempdir.path());

        let guard = acquire_lock(&path).expect("first lock");
        let initial_contents = fs::read_to_string(&path).expect("initial lock contents");

        assert!(acquire_lock(&path).is_none());
        assert_eq!(
            fs::read_to_string(&path).expect("lock contents after second attempt"),
            initial_contents
        );

        drop(guard);
        assert!(!path.exists());
    }
    #[test]
    fn register_missing_team_config_alert_deduplicates_key() {
        let tempdir = tempdir().expect("tempdir");
        let key = missing_team_config_alert_key(tempdir.path());

        assert!(register_missing_team_config_alert(tempdir.path(), &key));
        assert!(!register_missing_team_config_alert(tempdir.path(), &key));

        let state = load(&state_path(tempdir.path())).expect("state");
        assert_eq!(state.missing_team_config_keys.len(), 1);
        assert!(state.missing_team_config_keys.contains(&key));
    }

    #[test]
    fn clear_missing_team_config_alert_removes_existing_key() {
        let tempdir = tempdir().expect("tempdir");
        let key = missing_team_config_alert_key(tempdir.path());
        assert!(register_missing_team_config_alert(tempdir.path(), &key));

        clear_missing_team_config_alert(tempdir.path(), &key);

        let state = load(&state_path(tempdir.path())).expect("state");
        assert!(state.missing_team_config_keys.is_empty());
    }
}