use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
pub fn user_home() -> Option<String> {
pixtuoid_core::platform::user_home_opt()
}
pub fn nonempty(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
pub fn nonempty_env(name: &str) -> Option<String> {
nonempty(std::env::var(name).ok())
}
pub fn expand_tilde(value: &str, home: Option<&Path>) -> PathBuf {
let v = value.trim();
match home {
Some(home) if v == "~" => home.to_path_buf(),
Some(home) => match v.strip_prefix("~/").or_else(|| v.strip_prefix("~\\")) {
Some(rest) => home.join(rest),
None => PathBuf::from(v),
},
None => PathBuf::from(v),
}
}
pub fn home_relative(rel: &str) -> PathBuf {
let home = user_home().unwrap_or_else(|| ".".into());
PathBuf::from(home).join(rel)
}
pub fn home_relative_checked(rel: &str) -> Result<PathBuf> {
checked_home_join(user_home(), rel)
}
fn checked_home_join(home: Option<String>, rel: &str) -> Result<PathBuf> {
home.map(|h| PathBuf::from(h).join(rel)).ok_or_else(|| {
anyhow!("cannot resolve the home directory (HOME/USERPROFILE unset); pass --config <path>")
})
}
pub fn default_hook_binary() -> Result<PathBuf> {
if let Ok(p) = which::which("pixtuoid-hook") {
return Ok(p);
}
let exe = std::env::current_exe().context(
"could not determine the running executable's path while locating pixtuoid-hook",
)?;
let dir = exe.parent().ok_or_else(|| anyhow!("exe has no parent"))?;
let candidate = dir.join(hook_sibling_name());
if candidate.exists() {
return Ok(candidate);
}
Err(anyhow!("could not locate pixtuoid-hook; pass --hook-path"))
}
fn hook_sibling_name() -> String {
format!("pixtuoid-hook{}", std::env::consts::EXE_SUFFIX)
}
fn sibling(target: &Path, suffix: &str) -> PathBuf {
PathBuf::from(format!("{}.{}", target.display(), suffix))
}
pub fn read_config(path: &Path) -> Result<String> {
read_resolved(&resolve_symlink(path))
}
fn read_resolved(target: &Path) -> Result<String> {
if !target.exists() {
return Ok(String::new());
}
let mut s = String::new();
File::open(target)?.read_to_string(&mut s)?;
Ok(s)
}
fn rename_with_retry(from: &Path, to: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
const MAX_ATTEMPTS: u32 = 3;
for attempt in 1..=MAX_ATTEMPTS {
match std::fs::rename(from, to) {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS => {
let _ = e; std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(e),
}
}
unreachable!()
}
#[cfg(not(windows))]
{
std::fs::rename(from, to)
}
}
#[derive(Debug)]
pub struct ConfigLock {
target: PathBuf,
file: File,
}
pub fn lock_config(path: &Path) -> Result<ConfigLock> {
let target = resolve_symlink(path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_path = sibling(&target, "lock");
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)?;
file.try_lock()
.map_err(|e| anyhow!("could not lock {}: {e}", lock_path.display()))?;
Ok(ConfigLock { target, file })
}
impl ConfigLock {
pub fn target(&self) -> &Path {
&self.target
}
pub fn read(&self) -> Result<String> {
read_resolved(&self.target)
}
pub fn backup_once(&self, suffix: &str) -> Result<Option<PathBuf>> {
backup_once_resolved(&self.target, suffix)
}
pub fn remove_backup(&self, suffix: &str) -> Result<Option<PathBuf>> {
remove_backup_resolved(&self.target, suffix)
}
pub fn write_atomic(&self, contents: &str) -> Result<()> {
let tmp = sibling(&self.target, "tmp");
{
let mut opts = OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&self.target)
.map(|m| m.permissions())
.unwrap_or_else(|_| std::fs::Permissions::from_mode(0o600));
f.set_permissions(perms)?;
}
f.write_all(contents.as_bytes())?;
f.sync_all()?;
}
rename_with_retry(&tmp, &self.target)?;
Ok(())
}
}
impl Drop for ConfigLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
pub fn write_config_atomic(path: &Path, contents: &str) -> Result<()> {
lock_config(path)?.write_atomic(contents)
}
pub fn backup_once(path: &Path, suffix: &str) -> Result<Option<PathBuf>> {
backup_once_resolved(&resolve_symlink(path), suffix)
}
fn backup_once_resolved(target: &Path, suffix: &str) -> Result<Option<PathBuf>> {
if !target.exists() {
return Ok(None);
}
let bak = sibling(target, suffix);
if bak.exists() {
return Ok(Some(bak));
}
std::fs::copy(target, &bak)?;
Ok(Some(bak))
}
pub fn remove_backup(path: &Path, suffix: &str) -> Result<Option<PathBuf>> {
remove_backup_resolved(&resolve_symlink(path), suffix)
}
fn remove_backup_resolved(target: &Path, suffix: &str) -> Result<Option<PathBuf>> {
let bak = sibling(target, suffix);
if !bak.exists() {
return Ok(None);
}
std::fs::remove_file(&bak)?;
Ok(Some(bak))
}
pub fn hook_on_path() -> bool {
which::which("pixtuoid-hook").is_ok()
}
pub fn resolve_symlink(path: &Path) -> PathBuf {
let mut cur = path.to_path_buf();
for _ in 0..32 {
match std::fs::symlink_metadata(&cur) {
Ok(meta) if meta.file_type().is_symlink() => match std::fs::read_link(&cur) {
Ok(target) => {
cur = if target.is_relative() {
cur.parent().unwrap_or(Path::new(".")).join(&target)
} else {
target
};
}
Err(_) => return cur,
},
_ => return cur,
}
}
cur
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn expand_tilde_home_some_expands_leading_tilde_only() {
let home = Path::new("/home/u");
assert_eq!(expand_tilde("~", Some(home)), home.to_path_buf());
assert_eq!(expand_tilde("~/claw", Some(home)), home.join("claw"));
assert_eq!(expand_tilde(r"~\claw", Some(home)), home.join("claw"));
assert_eq!(expand_tilde(" ~/claw ", Some(home)), home.join("claw"));
assert_eq!(expand_tilde("~foo", Some(home)), PathBuf::from("~foo"));
assert_eq!(expand_tilde("/abs/x", Some(home)), PathBuf::from("/abs/x"));
}
#[test]
fn expand_tilde_home_none_trims_only_never_expands() {
assert_eq!(expand_tilde(" /abs/x ", None), PathBuf::from("/abs/x"));
assert_eq!(expand_tilde("~/claw", None), PathBuf::from("~/claw"));
assert_eq!(expand_tilde("~", None), PathBuf::from("~"));
}
#[test]
fn rename_with_retry_moves_file() {
let dir = TempDir::new().unwrap();
let from = dir.path().join("src.tmp");
let to = dir.path().join("dst.json");
std::fs::write(&from, "hello").unwrap();
rename_with_retry(&from, &to).unwrap();
assert!(!from.exists());
assert_eq!(std::fs::read_to_string(&to).unwrap(), "hello");
}
#[test]
fn resolve_symlink_regular_file_returns_as_is() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("plain.json");
std::fs::write(&file, "{}").unwrap();
assert_eq!(resolve_symlink(&file), file);
}
#[test]
fn resolve_symlink_nonexistent_returns_as_is() {
let path = PathBuf::from("/tmp/pixtuoid-test-nonexistent-xyz");
assert_eq!(resolve_symlink(&path), path);
}
#[cfg(unix)]
#[test]
fn resolve_symlink_follows_single_hop() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real.json");
std::fs::write(&target, "{}").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert_eq!(resolve_symlink(&link), target);
}
#[cfg(unix)]
#[test]
fn resolve_symlink_follows_chain() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real.json");
std::fs::write(&target, "{}").unwrap();
let mid = dir.path().join("mid.json");
std::os::unix::fs::symlink(&target, &mid).unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&mid, &link).unwrap();
assert_eq!(resolve_symlink(&link), target);
}
#[cfg(unix)]
#[test]
fn resolve_symlink_dangling_returns_target() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("nonexistent.json");
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert_eq!(resolve_symlink(&link), target);
}
#[cfg(unix)]
#[test]
fn resolve_symlink_relative_target() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let target = sub.join("real.json");
std::fs::write(&target, "{}").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(Path::new("sub/real.json"), &link).unwrap();
let resolved = resolve_symlink(&link);
assert_eq!(
std::fs::canonicalize(&resolved).unwrap(),
std::fs::canonicalize(&target).unwrap()
);
}
#[cfg(unix)]
#[test]
fn resolve_symlink_cycle_terminates_after_budget() {
let dir = TempDir::new().unwrap();
let a = dir.path().join("a.link");
let b = dir.path().join("b.link");
std::os::unix::fs::symlink(&b, &a).unwrap();
std::os::unix::fs::symlink(&a, &b).unwrap();
let resolved = resolve_symlink(&a);
assert!(resolved == a || resolved == b, "got {resolved:?}");
}
#[test]
fn read_config_missing_returns_empty_string() {
let dir = TempDir::new().unwrap();
assert_eq!(read_config(&dir.path().join("nope.json")).unwrap(), "");
}
#[test]
fn read_config_empty_file_returns_empty_string() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("empty.json");
std::fs::write(&p, "").unwrap();
assert_eq!(read_config(&p).unwrap(), "");
}
#[test]
fn read_config_returns_raw_content() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("c.toml");
std::fs::write(&p, "a = 1\n").unwrap();
assert_eq!(read_config(&p).unwrap(), "a = 1\n");
}
#[cfg(unix)]
#[test]
fn write_config_atomic_through_symlink_preserves_link() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real.json");
std::fs::write(&target, "{}").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
write_config_atomic(&link, "{\"a\":1}").unwrap();
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(std::fs::read_to_string(&target).unwrap(), "{\"a\":1}");
}
#[test]
fn lock_config_excludes_a_second_locker_until_dropped() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("settings.json");
let guard = lock_config(&p).unwrap();
let err = lock_config(&p).expect_err("a second lock on the same config must fail");
assert!(err.to_string().contains("could not lock"), "got: {err:#}");
drop(guard);
lock_config(&p).expect("the lock is released when the guard drops");
}
#[test]
fn write_atomic_under_a_held_guard_does_not_self_deadlock() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("settings.json");
let guard = lock_config(&p).unwrap();
guard.write_atomic("{\"a\":1}").unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), "{\"a\":1}");
}
#[cfg(unix)]
#[test]
fn lock_config_resolves_symlinks_to_the_real_target() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real.json");
std::fs::write(&target, "{}").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
let guard = lock_config(&link).unwrap();
assert_eq!(guard.target(), target);
assert!(dir.path().join("real.json.lock").exists());
assert!(lock_config(&target).is_err(), "same lock via either path");
}
#[cfg(unix)]
#[test]
fn config_lock_read_and_backup_pin_the_lock_time_resolution() {
let dir = TempDir::new().unwrap();
let old = dir.path().join("old.json");
std::fs::write(&old, "old-content").unwrap();
let new = dir.path().join("new.json");
std::fs::write(&new, "new-content").unwrap();
let link = dir.path().join("link.json");
std::os::unix::fs::symlink(&old, &link).unwrap();
let guard = lock_config(&link).unwrap();
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&new, &link).unwrap();
assert_eq!(
guard.read().unwrap(),
"old-content",
"the read is pinned to the lock-time target"
);
let bak = guard.backup_once("pixtuoid.bak").unwrap().unwrap();
assert_eq!(
bak,
dir.path().join("old.json.pixtuoid.bak"),
"the backup lands beside the lock-time target"
);
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "old-content");
assert_eq!(guard.remove_backup("pixtuoid.bak").unwrap(), Some(bak));
}
#[test]
fn checked_home_join_errors_without_home() {
let err = checked_home_join(None, ".reasonix/settings.json").unwrap_err();
assert!(
err.to_string().contains("--config"),
"must point at the workaround: {err}"
);
}
#[test]
fn checked_home_join_joins_a_resolved_home() {
assert_eq!(
checked_home_join(Some("/home/u".into()), ".reasonix/settings.json").unwrap(),
PathBuf::from("/home/u/.reasonix/settings.json")
);
}
#[cfg(unix)]
#[test]
fn write_config_atomic_preserves_target_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let p = dir.path().join("settings.json");
std::fs::write(&p, "{}").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
write_config_atomic(&p, "{\"a\":1}").unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"a user-tightened settings.json must not be widened by a rewrite"
);
}
#[cfg(unix)]
#[test]
fn write_config_atomic_creates_new_files_private() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let p = dir.path().join("settings.json");
write_config_atomic(&p, "{}").unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"settings.json can carry API keys — a fresh file defaults tight"
);
}
#[test]
fn backup_and_lock_and_tmp_names_use_string_append() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("config.local.toml");
std::fs::write(&p, "x = 1\n").unwrap();
let bak = backup_once(&p, "pixtuoid.bak").unwrap().unwrap();
assert_eq!(bak.file_name().unwrap(), "config.local.toml.pixtuoid.bak");
}
#[test]
fn backup_once_idempotent_and_remove() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("settings.json");
std::fs::write(&p, "{}").unwrap();
let b1 = backup_once(&p, "pixtuoid.bak").unwrap().unwrap();
assert_eq!(b1.file_name().unwrap(), "settings.json.pixtuoid.bak");
let b2 = backup_once(&p, "pixtuoid.bak").unwrap().unwrap();
assert_eq!(b1, b2);
assert_eq!(remove_backup(&p, "pixtuoid.bak").unwrap(), Some(b1.clone()));
assert!(!b1.exists());
assert_eq!(remove_backup(&p, "pixtuoid.bak").unwrap(), None);
}
#[test]
fn user_home_is_none_when_no_home_vars() {
use pixtuoid_core::platform::resolve_user_home_opt;
assert_eq!(resolve_user_home_opt(true, None, None), None);
assert_eq!(resolve_user_home_opt(false, None, None), None);
}
#[test]
fn user_home_userprofile_wins_on_windows_home_wins_on_unix() {
use pixtuoid_core::platform::resolve_user_home_opt;
let up = Some(r"C:\Users\me".to_string());
let posix = Some("/c/Users/me".to_string());
assert_eq!(resolve_user_home_opt(true, up.clone(), posix.clone()), up);
assert_eq!(
resolve_user_home_opt(false, up, Some("/Users/me".into())),
Some("/Users/me".into())
);
}
#[test]
fn default_hook_binary_sibling_appends_exe_suffix() {
#[cfg(unix)]
assert_eq!(hook_sibling_name(), "pixtuoid-hook");
#[cfg(windows)]
assert_eq!(hook_sibling_name(), "pixtuoid-hook.exe");
}
}