apm-core 0.1.15

Core library for APM — a git-native project manager for parallel AI coding agents.
Documentation
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::sync::{Mutex, OnceLock};

static LOGGER: OnceLock<Option<Mutex<std::io::BufWriter<std::fs::File>>>> = OnceLock::new();
static AGENT: OnceLock<String> = OnceLock::new();

pub fn default_log_path(project_name: &str) -> std::path::PathBuf {
    #[cfg(target_os = "macos")]
    {
        let home = std::env::var("HOME").unwrap_or_default();
        std::path::PathBuf::from(home)
            .join("Library/Logs/apm")
            .join(format!("{project_name}.log"))
    }
    #[cfg(not(target_os = "macos"))]
    {
        let base = std::env::var("XDG_STATE_HOME")
            .map(std::path::PathBuf::from)
            .unwrap_or_else(|_| {
                let home = std::env::var("HOME").unwrap_or_default();
                std::path::PathBuf::from(home).join(".local/state")
            });
        base.join("apm").join(format!("{project_name}.log"))
    }
}

pub fn resolve_log_path(project_name: &str, override_path: Option<&std::path::Path>) -> std::path::PathBuf {
    if let Some(p) = override_path {
        expand_tilde(p)
    } else {
        default_log_path(project_name)
    }
}

fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf {
    let s = path.to_string_lossy();
    if let Some(rest) = s.strip_prefix("~/") {
        let home = std::env::var("HOME").unwrap_or_default();
        std::path::PathBuf::from(home).join(rest)
    } else {
        path.to_path_buf()
    }
}

pub fn init(root: &Path, log_file: &Path, agent: &str) {
    AGENT.get_or_init(|| agent.to_string());
    let path = if log_file.is_absolute() {
        log_file.to_path_buf()
    } else {
        root.join(log_file)
    };
    let file = OpenOptions::new().create(true).append(true).open(&path).ok();
    LOGGER.get_or_init(|| file.map(|f| Mutex::new(std::io::BufWriter::new(f))));
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn default_log_path_contains_apm_and_ends_log() {
        let p = default_log_path("myproject");
        let s = p.to_string_lossy();
        assert!(s.contains("apm"), "path should contain 'apm': {s}");
        assert!(s.ends_with(".log"), "path should end with .log: {s}");
        assert!(s.contains("myproject"), "path should contain project name: {s}");
    }
    #[test]
    fn tilde_expansion() {
        let home = std::env::var("HOME").unwrap();
        let result = expand_tilde(std::path::Path::new("~/foo.log"));
        assert_eq!(result, std::path::PathBuf::from(&home).join("foo.log"));
    }
}

pub fn log(action: &str, detail: &str) {
    let Some(Some(mutex)) = LOGGER.get() else { return };
    let agent = AGENT.get().map(|s| s.as_str()).unwrap_or("apm");
    let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
    let line = format!("{now} [{agent}] {action} {detail}\n");
    if let Ok(mut writer) = mutex.lock() {
        let _ = writer.write_all(line.as_bytes());
        let _ = writer.flush();
    }
}