agent-team-mail-core 1.0.3

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

use crate::error::{AtmError, AtmErrorKind};

/// Process a send `--file` reference under the ATM file-policy rules.
///
/// # Errors
///
/// Returns [`AtmError`] when the source file is missing, the team share
/// directory cannot be created, the source path has no terminal file name, or
/// copying the file into the share directory fails.
pub fn process_file_reference(
    file_path: &Path,
    message_text: Option<&str>,
    team_name: &str,
    current_dir: &Path,
    home_dir: &Path,
) -> Result<String, AtmError> {
    if !file_path.is_file() {
        return Err(AtmError::file_policy(format!(
            "file not found: {}",
            file_path.display()
        )));
    }

    if is_file_in_repo(file_path, current_dir) {
        return Ok(render_reference_message(message_text, file_path));
    }

    let share_dir = home_dir
        .join(".config")
        .join("atm")
        .join("share")
        .join(team_name);
    fs::create_dir_all(&share_dir).map_err(|error| {
        AtmError::new(
            AtmErrorKind::FilePolicy,
            format!(
                "failed to create share directory {}: {error}",
                share_dir.display()
            ),
        )
        .with_source(error)
        .with_recovery(
            "Check the ATM share directory permissions for the target team and retry the send.",
        )
    })?;

    let file_name = file_path.file_name().ok_or_else(|| {
        AtmError::file_policy("file path has no file name").with_recovery(
            "Pass a concrete file path with a terminal file name or retry with inline message text.",
        )
    })?;
    let share_copy = share_dir.join(file_name);
    fs::copy(file_path, &share_copy).map_err(|error| {
        AtmError::file_policy(format!("failed to copy file into share directory: {error}"))
            .with_source(error)
            .with_recovery(
                "Check source/share permissions and available disk space, then retry the send.",
            )
    })?;

    Ok(render_reference_message(message_text, &share_copy))
}

fn render_reference_message(message_text: Option<&str>, file_path: &Path) -> String {
    match message_text.filter(|message| !message.trim().is_empty()) {
        Some(message_text) => {
            format!("{message_text}\n\nFile reference: {}", file_path.display())
        }
        None => format!("File reference: {}", file_path.display()),
    }
}

fn is_file_in_repo(file_path: &Path, current_dir: &Path) -> bool {
    match (canonical(file_path), find_git_root(current_dir)) {
        (Some(file_path), Some(repo_root)) => file_path.starts_with(repo_root),
        _ => false,
    }
}

fn canonical(path: &Path) -> Option<PathBuf> {
    path.canonicalize().ok()
}

fn find_git_root(start_dir: &Path) -> Option<PathBuf> {
    let mut current = Some(start_dir);
    while let Some(dir) = current {
        if dir.join(".git").exists() {
            return canonical(dir);
        }
        current = dir.parent();
    }
    None
}