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
}