Mega Mind v0.5.4

paths.rs

Rust · 155 lines · 6.3 KB
//! Where things live on disk: home, data and cache folders.

use std::fs;
use std::path::{Path, PathBuf};

/// The user's home directory: HOME on Unix; on Windows USERPROFILE first, because Git Bash and MSYS
/// set HOME to a Unix-style spelling of the profile folder (drive letter as a first folder) that
/// native programs, Claude Code, Codex and Grok don't use, so skills and hooks written there would
/// never be read.
pub fn home_dir() -> PathBuf {
    let order = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
    order
        .iter()
        .find_map(|v| std::env::var_os(v).filter(|s| !s.is_empty()))
        .map(PathBuf::from)
        .unwrap_or_default()
}

/// Where per-user data lives: ~/.local/share on Unix, %LOCALAPPDATA% on Windows.
pub fn data_dir() -> PathBuf {
    if cfg!(windows)
        && let Some(d) = std::env::var_os("LOCALAPPDATA") {
            return PathBuf::from(d);
        }
    std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home_dir().join(".local/share"))
}

/// Where caches live: ~/.cache on Unix, %LOCALAPPDATA% on Windows.
pub fn cache_dir() -> PathBuf {
    if cfg!(windows)
        && let Some(d) = std::env::var_os("LOCALAPPDATA") {
            return PathBuf::from(d);
        }
    std::env::var_os("XDG_CACHE_HOME").filter(|v| !v.is_empty()).map(PathBuf::from).unwrap_or_else(|| home_dir().join(".cache"))
}

/// Create a folder only its owner can open (0700 on Unix; Windows profile folders are already private).
pub fn create_private_dir(dir: &Path) {
    let _ = fs::create_dir_all(dir);
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
    }
}

/// Where Azure tokens are cached between runs: the private cache folder, outside the mind, so a
/// copy or backup of the mind never carries a live token.
pub fn auth_cache_path() -> PathBuf {
    app_cache_dir().join("auth-cache.json")
}

/// The data and cache folder name.
pub const FOLDER_NAMES: [&str; 1] = ["mega-mind"];

/// The first of these environment variables that is set and not empty.
pub fn env_setting(names: &[&str]) -> Option<std::ffi::OsString> {
    names.iter().find_map(|n| std::env::var_os(n).filter(|v| !v.is_empty()))
}

/// Real data in `d`: one of `markers` is a file, or a folder with something in it.
fn holds(d: &Path, markers: &[&str]) -> bool {
    markers.iter().any(|m| {
        let p = d.join(m);
        if p.is_dir() { fs::read_dir(&p).is_ok_and(|mut r| r.next().is_some()) } else { p.is_file() }
    })
}

/// The folder named after the product (`mega-mind`) under `root`.
pub(super) fn named_dir(root: &Path, suffix: &str, strong: &[&str], weak: &[&str]) -> PathBuf {
    let dirs: Vec<PathBuf> = FOLDER_NAMES.iter().map(|n| root.join(format!("{n}{suffix}"))).collect();
    if let Some(d) = dirs.iter().find(|d| holds(d, strong)).or_else(|| dirs.iter().find(|d| holds(d, weak))) {
        return d.clone();
    }
    dirs[0].clone()
}

pub(super) const DATA_MARKERS: &[&str] = &["entries"];
/// A mind that has been set up but has no entries yet: every mind is a git repository (a stray
/// config.toml is not a mind).
pub(super) const DATA_WEAK_MARKERS: &[&str] = &[".git"];
const CACHE_MARKERS: &[&str] = &["auth-cache.json", "models"];

/// Mega Mind's own cache folder (embedding model, Azure tokens). Never inside the mind: on
/// Windows the data and cache roots are both %LOCALAPPDATA%, so the cache gets its own folder there.
pub fn app_cache_dir() -> PathBuf {
    if cfg!(windows) && std::env::var_os("LOCALAPPDATA").is_some() {
        return named_dir(&cache_dir(), "-cache", CACHE_MARKERS, &[]);
    }
    named_dir(&cache_dir(), "", CACHE_MARKERS, &[])
}

/// An existing folder's full path, `..` and links resolved (without Windows' `\\?\` prefix).
pub fn absolute_dir(p: &Path) -> std::io::Result<PathBuf> {
    let real = p.canonicalize()?;
    let s = real.to_string_lossy();
    Ok(match s.strip_prefix(r"\\?\") {
        Some(rest) if !rest.starts_with("UNC\\") => PathBuf::from(rest),
        _ => real,
    })
}

/// `~`, `~/x`, `$HOME/x` and `%USERPROFILE%\x` as the home folder (typed at setup's prompts, which
/// no shell expands).
pub fn expand(p: &str) -> PathBuf {
    let p = p.trim();
    if p == "~" || p == "$HOME" || p.eq_ignore_ascii_case("%USERPROFILE%") {
        return home_dir();
    }
    // `join` with a rooted rest replaces the whole path (`~/\\\\host\\share` would become a network
    // path): such a rest is never joined; the text stays as written.
    let rooted = |r: &str| r.starts_with(['/', '\\']) || Path::new(r).has_root() || r.as_bytes().get(1) == Some(&b':');
    for prefix in ["~/", "~\\", "$HOME/", "${HOME}/"] {
        if let Some(rest) = p.strip_prefix(prefix) {
            return if rooted(rest) { PathBuf::from(p) } else { home_dir().join(rest) };
        }
    }
    // By `get`, never by slicing: byte 13 can fall inside a character (`/home/山田太郎/code`).
    if p.get(..13).is_some_and(|h| h.eq_ignore_ascii_case("%USERPROFILE%"))
        && let Some(rest) = p.get(14..).filter(|_| matches!(p.get(13..14), Some("\\" | "/")))
    {
        return if rooted(rest) { PathBuf::from(p) } else { home_dir().join(rest) };
    }
    PathBuf::from(p)
}

/// Replace `path` with `content` so a crash or a full disk never leaves it empty or half written:
/// written beside it (with its permissions), flushed, then renamed over it.
pub fn write_atomic(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
    use std::io::Write;
    let name = path.file_name().unwrap_or_default().to_string_lossy();
    let tmp = path.with_file_name(format!(".{name}.tmp-{}", std::process::id()));
    let perms = std::fs::metadata(path).ok().map(|m| m.permissions());
    let _ = std::fs::remove_file(&tmp);
    let mut opts = std::fs::OpenOptions::new();
    opts.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        opts.mode(perms.as_ref().map_or(0o666, |p| p.mode() & 0o777));
    }
    let res = (|| {
        let mut f = opts.open(&tmp)?;
        f.write_all(content.as_ref())?;
        f.sync_all()?;
        if let Some(p) = &perms {
            std::fs::set_permissions(&tmp, p.clone())?;
        }
        std::fs::rename(&tmp, path)
    })();
    if res.is_err() {
        let _ = std::fs::remove_file(&tmp);
    }
    res
}