malvin 0.2.5

Non-interactive research and coding agent
use std::path::{Path, PathBuf};

#[path = "workspace_paths_data_root.rs"]
pub(crate) mod workspace_paths_data_root;

pub use workspace_paths_data_root::{
    git_worktree_toplevel, legacy_malvin_checks_path, malvin_acp_spawn_chamber_dir,
    malvin_checks_path, malvin_data_root, resolve_malvin_checks_path,
};

pub const MALVIN_DIR: &str = ".malvin";

pub const MALVIN_CHECKS_REL: &str = ".malvin/gates";

pub const MALVIN_CHECKS_LEGACY_REL: &str = ".malvin/checks";

pub const MALVIN_ADVICE_REL: &str = ".malvin/advice.md";

pub const MALVIN_LOGS_REL: &str = ".malvin/logs";

pub const MALVIN_CONFIG_REL: &str = ".malvin/config.toml";

pub const MALVIN_USER_HOME_DIR: &str = ".malvin_home";

pub const MALVIN_HOME_CONFIG_FILE: &str = "config.toml";

pub const MALVIN_TEST_ALLOW_HOME_CONFIG_MUTATION: &str = "MALVIN_TEST_ALLOW_HOME_CONFIG_MUTATION";

pub(crate) fn home_malvin_config_disk_io_allowed() -> bool {
    if cfg!(test) {
        std::env::var(MALVIN_TEST_ALLOW_HOME_CONFIG_MUTATION).as_deref() == Ok("1")
    } else {
        true
    }
}

pub(crate) fn assert_home_malvin_config_disk_io_allowed(op: &str) -> Result<(), String> {
    if home_malvin_config_disk_io_allowed() {
        Ok(())
    } else {
        Err(format!(
            "refusing {op} on ~/.malvin_home/config.toml without test isolation; \
             use with_isolated_home or activate_test_home (see plan.md)"
        ))
    }
}

pub(crate) fn home_malvin_config_delete_allowed() -> bool {
    home_malvin_config_disk_io_allowed()
}

pub const WORK_DIR_MANIFEST: &str = "work_dir";

const LEGACY_MALVIN_CHECKS_FILE: &str = ".malvin_checks";

const FNV1A_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV1A_PRIME: u64 = 0x0000_0100_0000_01B3;

#[must_use]
pub fn malvin_advice_path(work_dir: &Path) -> PathBuf {
    work_dir.join(MALVIN_ADVICE_REL)
}

#[must_use]
pub fn malvin_logs_root(work_dir: &Path) -> PathBuf {
    malvin_home_logs_root().join(workspace_logs_hash(work_dir))
}

#[must_use]
pub fn malvin_user_home_root() -> PathBuf {
    let configured = test_storage_home_dir().join(MALVIN_USER_HOME_DIR);
    #[cfg(test)]
    {
        if directory_is_writable_for_tests(&configured) {
            return configured;
        }
        std::env::temp_dir()
            .join(format!("malvin-test-home-{}", std::process::id()))
            .join(MALVIN_USER_HOME_DIR)
    }
    #[cfg(not(test))]
    configured
}

#[cfg(test)]
fn directory_is_writable_for_tests(path: &Path) -> bool {
    if std::fs::create_dir_all(path).is_err() {
        return false;
    }
    let probe = path.join(format!(".write-probe-{}", std::process::id()));
    let writable = std::fs::write(&probe, []).is_ok();
    let _ = std::fs::remove_file(probe);
    writable
}

#[must_use]
pub fn malvin_home_logs_root() -> PathBuf {
    malvin_user_home_root().join("logs")
}

pub const MALVIN_SNAPSHOTS_DIR: &str = "snapshots";

#[must_use]
pub fn malvin_home_snapshots_root() -> PathBuf {
    test_storage_home_dir()
        .join(".malvin")
        .join(MALVIN_SNAPSHOTS_DIR)
}

fn test_storage_home_dir() -> PathBuf {
    #[cfg(test)]
    {
        let configured = crate::user_home_dir();
        let probe = configured.join(format!(".malvin-write-probe-{}", std::process::id()));
        if std::fs::write(&probe, []).is_ok() {
            let _ = std::fs::remove_file(probe);
            return configured;
        }
        std::env::temp_dir().join(format!("malvin-test-home-{}", std::process::id()))
    }
    #[cfg(not(test))]
    crate::user_home_dir()
}

#[must_use]
pub fn snapshot_category_dir(category: &str) -> PathBuf {
    malvin_home_snapshots_root().join(category)
}

#[must_use]
pub fn malvin_config_path(_work_dir: &Path) -> PathBuf {
    malvin_home_config_path()
}

#[must_use]
pub fn malvin_home_config_path() -> PathBuf {
    malvin_user_home_root().join(MALVIN_HOME_CONFIG_FILE)
}

#[must_use]
pub fn workspace_logs_hash(work_dir: &Path) -> String {
    let abs = canonical_work_dir_for_logs(work_dir);
    format!("{:016x}", fnv1a64(abs.as_os_str().as_encoded_bytes()))
}

#[must_use]
pub fn canonical_work_dir_for_logs(work_dir: &Path) -> PathBuf {
    let resolved = if work_dir.is_absolute() {
        work_dir.to_path_buf()
    } else {
        std::env::current_dir().map_or_else(|_| work_dir.to_path_buf(), |cwd| cwd.join(work_dir))
    };
    resolved.canonicalize().unwrap_or(resolved)
}

fn fnv1a64(bytes: &[u8]) -> u64 {
    let mut hash = FNV1A_OFFSET;
    for b in bytes {
        hash ^= u64::from(*b);
        hash = hash.wrapping_mul(FNV1A_PRIME);
    }
    hash
}

#[must_use]
pub fn find_malvin_logs_root(start: &Path) -> Option<PathBuf> {
    let candidate = malvin_logs_root(start);
    if candidate.is_dir() {
        Some(candidate)
    } else {
        None
    }
}

#[must_use]
pub fn is_malvin_workspace(work_dir: &Path) -> bool {
    work_dir.join(MALVIN_DIR).is_dir()
}

pub fn write_work_dir_manifest(run_dir: &Path, work_dir: &Path) -> std::io::Result<()> {
    let abs = canonical_work_dir_for_logs(work_dir);
    std::fs::write(
        run_dir.join(WORK_DIR_MANIFEST),
        format!("{}\n", abs.display()),
    )
}

#[must_use]
pub fn read_work_dir_manifest(run_dir: &Path) -> Option<PathBuf> {
    let path = run_dir.join(WORK_DIR_MANIFEST);
    let text = std::fs::read_to_string(&path).ok()?;
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }
    Some(PathBuf::from(trimmed))
}

pub fn remove_legacy_malvin_checks_file(work_dir: &Path) {
    let legacy = work_dir.join(LEGACY_MALVIN_CHECKS_FILE);
    if legacy.is_file() {
        let _ = std::fs::remove_file(legacy);
    }
}