use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Map, Value};
use crate::ai_hooks::error::AiHookError;
pub fn home_or_err() -> Result<PathBuf, AiHookError> {
dirs::home_dir().ok_or_else(|| {
AiHookError::io(
PathBuf::from("$HOME"),
std::io::Error::other("cannot determine home directory"),
)
})
}
pub fn read_or_default_object(path: &Path) -> Result<Map<String, Value>, AiHookError> {
if !path.exists() {
return Ok(Map::new());
}
let bytes = fs::read(path).map_err(|e| AiHookError::io(path.to_path_buf(), e))?;
if !bytes.iter().any(|b| !b.is_ascii_whitespace()) {
return Ok(Map::new());
}
let value: Value =
serde_json::from_slice(&bytes).map_err(|source| AiHookError::ParseExisting {
path: path.to_path_buf(),
source,
})?;
match value {
Value::Object(map) => Ok(map),
other => Err(AiHookError::InvalidEntry {
name: path.display().to_string(),
reason: format!(
"expected top-level JSON object, found {}",
json_kind(&other)
),
}),
}
}
pub fn write_json(path: &Path, value: &Value) -> Result<(), AiHookError> {
refuse_if_symlink(path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AiHookError::io(parent.to_path_buf(), e))?;
}
let bytes = serde_json::to_vec(value)?;
let tmp_path = tempfile_path(path);
write_tempfile_then_rename(&tmp_path, path, &bytes, 0o644)
}
pub fn write_executable(path: &Path, body: &str) -> Result<(), AiHookError> {
refuse_if_symlink(path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AiHookError::io(parent.to_path_buf(), e))?;
}
let tmp_path = tempfile_path(path);
write_tempfile_then_rename(&tmp_path, path, body.as_bytes(), 0o755)
}
pub fn write_text_atomic(path: &Path, body: &str) -> Result<(), AiHookError> {
refuse_if_symlink(path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AiHookError::io(parent.to_path_buf(), e))?;
}
let tmp_path = tempfile_path(path);
write_tempfile_then_rename(&tmp_path, path, body.as_bytes(), 0o644)
}
fn refuse_if_symlink(path: &Path) -> Result<(), AiHookError> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => Err(AiHookError::SettingsPathIsSymlink {
path: path.to_path_buf(),
}),
_ => Ok(()),
}
}
fn tempfile_path(path: &Path) -> PathBuf {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
path.with_extension(format!("jarvy.tmp.{pid}.{nanos}"))
}
fn write_tempfile_then_rename(
tmp_path: &Path,
final_path: &Path,
bytes: &[u8],
#[cfg_attr(not(unix), allow(unused_variables))] mode: u32,
) -> Result<(), AiHookError> {
{
let mut opts = fs::OpenOptions::new();
opts.create_new(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(mode);
}
let mut tmp = opts
.open(tmp_path)
.map_err(|e| AiHookError::io(tmp_path.to_path_buf(), e))?;
tmp.write_all(bytes)
.map_err(|e| AiHookError::io(tmp_path.to_path_buf(), e))?;
tmp.sync_all()
.map_err(|e| AiHookError::io(tmp_path.to_path_buf(), e))?;
}
fs::rename(tmp_path, final_path).map_err(|e| AiHookError::io(final_path.to_path_buf(), e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(final_path, fs::Permissions::from_mode(mode));
}
Ok(())
}
fn json_kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn read_missing_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
let map = read_or_default_object(&path).unwrap();
assert!(map.is_empty());
}
#[test]
fn read_whitespace_only_returns_empty() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, b" \n\t \n").unwrap();
let map = read_or_default_object(&path).unwrap();
assert!(map.is_empty());
}
#[test]
fn write_then_read_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested").join("settings.json");
let value = json!({ "hooks": { "PreToolUse": [] } });
write_json(&path, &value).unwrap();
let read = read_or_default_object(&path).unwrap();
assert!(read.contains_key("hooks"));
}
#[test]
fn read_rejects_non_object_root() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, b"[1,2,3]").unwrap();
let err = read_or_default_object(&path).unwrap_err();
assert!(matches!(err, AiHookError::InvalidEntry { .. }));
}
#[cfg(unix)]
#[test]
fn write_executable_sets_mode_0755() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("hook");
write_executable(&path, "#!/bin/sh\nexit 0\n").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755);
}
#[cfg(unix)]
#[test]
fn refuses_to_write_through_symlink() {
let dir = tempdir().unwrap();
let target = dir.path().join("real-file");
fs::write(&target, b"original").unwrap();
let link = dir.path().join("settings.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
let err = write_json(&link, &json!({"hooks": {}})).unwrap_err();
assert!(matches!(err, AiHookError::SettingsPathIsSymlink { .. }));
assert_eq!(fs::read(&target).unwrap(), b"original");
}
#[test]
fn tempfile_path_includes_pid_and_nanos() {
let p = std::path::Path::new("/tmp/foo.json");
let t = tempfile_path(p);
let name = t.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("foo.jarvy.tmp."));
assert!(name.len() > "foo.jarvy.tmp.".len() + 2);
}
}