use crate::errors::TokenSaveError;
use std::path::{Path, PathBuf};
pub fn load_json_file(path: &Path) -> serde_json::Value {
if path.exists() {
let contents = std::fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&contents).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
}
}
pub fn load_json_file_strict(path: &Path) -> crate::errors::Result<serde_json::Value> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let contents = std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
message: format!("cannot read {}: {e}", path.display()),
})?;
if contents.trim().is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&contents).map_err(|e| TokenSaveError::Config {
message: format!(
"cannot parse {} as JSON: {e}\n \
Hint: fix the JSON syntax manually and re-run the command,\n \
or delete the file to start fresh",
path.display()
),
})
}
pub fn backup_config_file(path: &Path) -> crate::errors::Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let backup_path = PathBuf::from(format!("{}.bak", path.display()));
let staging_path = PathBuf::from(format!("{}.bak.new", path.display()));
let content = std::fs::read(path).map_err(|e| TokenSaveError::Config {
message: format!(
"failed to read {} for backup: {e}\n \
Hint: check file permissions",
path.display()
),
})?;
std::fs::write(&staging_path, &content).map_err(|e| {
std::fs::remove_file(&staging_path).ok();
TokenSaveError::Config {
message: format!(
"failed to write backup staging file {}: {e}\n \
Hint: check available disk space and permissions",
staging_path.display()
),
}
})?;
std::fs::rename(&staging_path, &backup_path).map_err(|e| {
std::fs::remove_file(&staging_path).ok();
TokenSaveError::Config {
message: format!(
"failed to create backup {}: {e}\n \
Hint: check file permissions",
backup_path.display()
),
}
})?;
Ok(Some(backup_path))
}
pub fn restore_config_backup(original: &Path, backup: &Path) {
match std::fs::copy(backup, original) {
Ok(_) => {
eprintln!(
"\x1b[33mâš \x1b[0m Restored {} from backup",
original.display()
);
}
Err(e) => {
eprintln!(
"\x1b[31m✗\x1b[0m Failed to auto-restore {} from backup: {e}",
original.display()
);
eprintln!(
" Manual recovery: cp '{}' '{}'",
backup.display(),
original.display()
);
}
}
}
pub fn safe_write_json_file(
path: &Path,
value: &serde_json::Value,
backup: Option<&Path>,
) -> crate::errors::Result<()> {
let pretty = serde_json::to_string_pretty(value).map_err(|e| TokenSaveError::Config {
message: format!("failed to serialize JSON for {}: {e}", path.display()),
})?;
if serde_json::from_str::<serde_json::Value>(&pretty).is_err() {
return Err(TokenSaveError::Config {
message: format!(
"internal error: serialized JSON for {} failed re-parse validation.\n \
This is a bug in tokensave — please report it.",
path.display()
),
});
}
let real_path = resolve_symlink_target(path).map_err(|e| TokenSaveError::Config {
message: format!(
"cannot safely resolve symlink {}: {e}\n \
Refusing to write — the symlink was left untouched.",
path.display()
),
})?;
if let Some(parent) = real_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| TokenSaveError::Config {
message: format!("cannot create directory {}: {e}", parent.display()),
})?;
}
let content = format!("{pretty}\n");
let new_path = PathBuf::from(format!("{}.new", real_path.display()));
if let Err(e) = std::fs::write(&new_path, &content) {
std::fs::remove_file(&new_path).ok(); return Err(TokenSaveError::Config {
message: format!(
"failed to write new config file {}: {e}",
new_path.display()
),
});
}
if let Err(e) = std::fs::rename(&new_path, &real_path) {
std::fs::remove_file(&new_path).ok(); let hint = if let Some(b) = backup {
format!(
"\n Backup is at: {}\n \
The original file was NOT modified.",
b.display()
)
} else {
"\n The original file was NOT modified.".to_string()
};
return Err(TokenSaveError::Config {
message: format!(
"failed to rename {} → {}: {e}{hint}",
new_path.display(),
real_path.display()
),
});
}
Ok(())
}
pub(crate) fn safe_write_text_file(path: &Path, content: &str) -> crate::errors::Result<()> {
let real_path = resolve_symlink_target(path).map_err(|e| TokenSaveError::Config {
message: format!(
"cannot safely resolve symlink {}: {e}\n \
Refusing to write — the symlink was left untouched.",
path.display()
),
})?;
if let Some(parent) = real_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| TokenSaveError::Config {
message: format!("cannot create directory {}: {e}", parent.display()),
})?;
}
let new_path = PathBuf::from(format!("{}.new", real_path.display()));
if let Err(e) = std::fs::write(&new_path, content) {
std::fs::remove_file(&new_path).ok(); return Err(TokenSaveError::Config {
message: format!("failed to write new file {}: {e}", new_path.display()),
});
}
if let Err(e) = std::fs::rename(&new_path, &real_path) {
std::fs::remove_file(&new_path).ok(); return Err(TokenSaveError::Config {
message: format!(
"failed to rename {} → {}: {e}\n The original file was NOT modified.",
new_path.display(),
real_path.display()
),
});
}
Ok(())
}
pub(crate) fn resolve_symlink_target(path: &Path) -> std::result::Result<PathBuf, String> {
let is_symlink = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink());
if !is_symlink {
return Ok(path.to_path_buf());
}
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(canonical);
}
walk_dangling_symlink_chain(path)
}
const MAX_SYMLINK_HOPS: usize = 40;
fn walk_dangling_symlink_chain(path: &Path) -> std::result::Result<PathBuf, String> {
let mut current = path.to_path_buf();
let mut seen = std::collections::HashSet::new();
let mut hops = 0usize;
loop {
if !seen.insert(current.clone()) {
return Err(format!(
"symlink cycle detected at {} while resolving {}",
current.display(),
path.display()
));
}
match std::fs::symlink_metadata(¤t) {
Ok(meta) if meta.file_type().is_symlink() => {
if hops >= MAX_SYMLINK_HOPS {
return Err(format!(
"symlink chain from {} exceeds {MAX_SYMLINK_HOPS} hops",
path.display()
));
}
hops += 1;
let link_target = std::fs::read_link(¤t)
.map_err(|e| format!("cannot read symlink {}: {e}", current.display()))?;
current = if link_target.is_absolute() {
link_target
} else {
current
.parent()
.ok_or_else(|| {
format!("symlink {} has no parent directory", current.display())
})?
.join(&link_target)
};
}
_ => return Ok(current),
}
}
}
pub fn write_json_file(path: &Path, value: &serde_json::Value) -> crate::errors::Result<()> {
let backup = backup_config_file(path)?;
safe_write_json_file(path, value, backup.as_deref())?;
eprintln!("\x1b[32m✔\x1b[0m Wrote {}", path.display());
Ok(())
}
pub fn backup_and_write_json(path: &Path, value: &serde_json::Value) -> bool {
let backup = backup_config_file(path).ok().flatten();
safe_write_json_file(path, value, backup.as_deref()).is_ok()
}
pub(crate) fn normalize_path_separators(path: &str) -> String {
path.replace('\\', "/")
}
pub fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
}
pub(crate) fn expand_tilde(s: &str, home: &Path) -> String {
if let Some(rest) = s.strip_prefix("~/") {
return home.join(rest).to_string_lossy().replace('\\', "/");
}
if s == "~" {
return home.to_string_lossy().to_string();
}
s.to_string()
}
pub fn parse_jsonc(input: &str) -> serde_json::Value {
let stripped = strip_jsonc_comments(input);
serde_json::from_str(&stripped).unwrap_or_else(|_| serde_json::json!({}))
}
pub fn load_jsonc_file(path: &Path) -> serde_json::Value {
let Ok(contents) = std::fs::read_to_string(path) else {
return serde_json::json!({});
};
parse_jsonc(&contents)
}
fn strip_jsonc_comments(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
let mut in_string = false;
while i < len {
if in_string {
if chars[i] == '\\' && i + 1 < len {
out.push(chars[i]);
out.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == '"' {
in_string = false;
}
out.push(chars[i]);
i += 1;
continue;
}
if chars[i] == '"' {
in_string = true;
out.push(chars[i]);
i += 1;
continue;
}
if chars[i] == '/' && i + 1 < len && chars[i + 1] == '/' {
while i < len && chars[i] != '\n' {
i += 1;
}
continue;
}
if chars[i] == '/' && i + 1 < len && chars[i + 1] == '*' {
i += 2;
while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2; continue;
}
out.push(chars[i]);
i += 1;
}
remove_trailing_commas(&out)
}
fn remove_trailing_commas(input: &str) -> String {
let bytes = input.as_bytes();
let len = bytes.len();
let mut out = Vec::with_capacity(len);
let mut i = 0;
while i < len {
if bytes[i] == b',' {
let mut j = i + 1;
while j < len
&& (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r')
{
j += 1;
}
if j < len && (bytes[j] == b'}' || bytes[j] == b']') {
i += 1;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
}
pub fn load_jsonc_file_strict(path: &Path) -> crate::errors::Result<serde_json::Value> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let contents = std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
message: format!("cannot read {}: {e}", path.display()),
})?;
if contents.trim().is_empty() {
return Ok(serde_json::json!({}));
}
let stripped = strip_jsonc_comments(&contents);
serde_json::from_str(&stripped).map_err(|e| TokenSaveError::Config {
message: format!(
"cannot parse {} as JSONC: {e}\n \
Hint: fix the JSON syntax manually and re-run the command,\n \
or delete the file to start fresh",
path.display()
),
})
}
pub fn vscode_data_dir(home: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
{
home.join("Library/Application Support/Code")
}
#[cfg(target_os = "linux")]
{
home.join(".config/Code")
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
let appdata_path = PathBuf::from(&appdata);
if appdata_path.starts_with(home) {
return appdata_path.join("Code");
}
}
home.join("AppData/Roaming/Code")
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
home.join(".config/Code")
}
}
pub fn vscode_insiders_data_dir(home: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
{
home.join("Library/Application Support/Code - Insiders")
}
#[cfg(target_os = "linux")]
{
home.join(".config/Code - Insiders")
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
let appdata_path = PathBuf::from(&appdata);
if appdata_path.starts_with(home) {
return appdata_path.join("Code - Insiders");
}
}
home.join("AppData/Roaming/Code - Insiders")
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
home.join(".config/Code - Insiders")
}
}
pub fn copilot_cli_dir(home: &Path) -> PathBuf {
home.join(".copilot")
}
pub fn copilot_jetbrains_dir(home: &Path) -> PathBuf {
#[cfg(target_os = "windows")]
{
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
let localappdata_path = PathBuf::from(&localappdata);
if localappdata_path.starts_with(home) {
return localappdata_path.join("github-copilot/intellij");
}
}
home.join("AppData/Local/github-copilot/intellij")
}
#[cfg(not(target_os = "windows"))]
{
home.join(".config/github-copilot/intellij")
}
}
pub fn load_toml_file(path: &Path) -> crate::errors::Result<toml::Value> {
if !path.exists() {
return Ok(toml::Value::Table(toml::map::Map::new()));
}
let contents = std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
message: format!("failed to read {}: {e}", path.display()),
})?;
if contents.trim().is_empty() {
return Ok(toml::Value::Table(toml::map::Map::new()));
}
let table: toml::Table = toml::from_str(&contents).map_err(|e| TokenSaveError::Config {
message: format!(
"failed to parse {} as TOML: {e}. Refusing to overwrite — fix the file or remove it manually.",
path.display()
),
})?;
Ok(toml::Value::Table(table))
}
fn backup_file(path: &Path) -> crate::errors::Result<()> {
if !path.exists() {
return Ok(());
}
let mut backup = path.as_os_str().to_owned();
backup.push(".bak");
let backup = std::path::PathBuf::from(backup);
std::fs::copy(path, &backup).map_err(|e| TokenSaveError::Config {
message: format!(
"failed to back up {} to {}: {e}",
path.display(),
backup.display()
),
})?;
eprintln!(
"\x1b[32m✔\x1b[0m Backed up {} to {}",
path.display(),
backup.display()
);
Ok(())
}
pub fn write_toml_file(path: &Path, value: &toml::Value) -> crate::errors::Result<()> {
backup_file(path)?;
let contents = toml::to_string_pretty(value).unwrap_or_else(|_| String::new());
std::fs::write(path, contents).map_err(|e| TokenSaveError::Config {
message: format!("failed to write {}: {e}", path.display()),
})?;
eprintln!("\x1b[32m✔\x1b[0m Wrote {}", path.display());
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod jsonc_tests {
use super::*;
#[test]
fn parse_jsonc_plain_json() {
let input = r#"{"key": "value", "num": 42}"#;
let v = parse_jsonc(input);
assert_eq!(v["key"], "value");
assert_eq!(v["num"], 42);
}
#[test]
fn parse_jsonc_line_comment() {
let input = "{\n // this is a comment\n \"key\": \"val\"\n}";
let v = parse_jsonc(input);
assert_eq!(v["key"], "val");
}
#[test]
fn parse_jsonc_block_comment() {
let input = "{ /* block comment */ \"key\": \"val\" }";
let v = parse_jsonc(input);
assert_eq!(v["key"], "val");
}
#[test]
fn parse_jsonc_trailing_comma_object() {
let input = r#"{"a": 1, "b": 2,}"#;
let v = parse_jsonc(input);
assert_eq!(v["a"], 1);
assert_eq!(v["b"], 2);
}
#[test]
fn parse_jsonc_trailing_comma_array() {
let input = r#"{"items": [1, 2, 3,]}"#;
let v = parse_jsonc(input);
assert_eq!(v["items"][2], 3);
}
#[test]
fn parse_jsonc_combined() {
let input = "{\n // comment\n \"x\": /* inline */ 99,\n}";
let v = parse_jsonc(input);
assert_eq!(v["x"], 99);
}
#[test]
fn parse_jsonc_url_in_string_not_stripped() {
let input = r#"{"url": "https://example.com/path"}"#;
let v = parse_jsonc(input);
assert_eq!(v["url"], "https://example.com/path");
}
#[test]
fn parse_jsonc_invalid_falls_back_to_empty() {
let input = "not valid json at all !!!";
let v = parse_jsonc(input);
assert_eq!(v, serde_json::json!({}));
}
#[test]
fn parse_jsonc_empty_string() {
let v = parse_jsonc("");
assert_eq!(v, serde_json::json!({}));
}
#[test]
fn parse_jsonc_trailing_comma_with_whitespace() {
let input = "{\n \"a\": 1 ,\n}";
let v = parse_jsonc(input);
assert_eq!(v["a"], 1);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod safe_config_tests {
use crate::agents::fs::MAX_SYMLINK_HOPS;
use crate::agents::*;
use std::fs;
fn tmpdir() -> tempfile::TempDir {
tempfile::tempdir().expect("failed to create temp dir")
}
#[test]
fn backup_returns_none_when_file_missing() {
let dir = tmpdir();
let path = dir.path().join("nonexistent.json");
let result = backup_config_file(&path).unwrap();
assert!(result.is_none());
}
#[test]
fn backup_creates_bak_with_identical_content() {
let dir = tmpdir();
let path = dir.path().join("config.json");
let original = r#"{"existing": "data", "nested": {"key": 1}}"#;
fs::write(&path, original).unwrap();
let backup = backup_config_file(&path)
.unwrap()
.expect("should create backup");
assert!(backup.exists());
assert_eq!(fs::read_to_string(&backup).unwrap(), original);
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn backup_staging_file_is_cleaned_up() {
let dir = tmpdir();
let path = dir.path().join("config.json");
fs::write(&path, "{}").unwrap();
backup_config_file(&path).unwrap();
let staging = dir.path().join("config.json.bak.new");
assert!(!staging.exists(), ".bak.new staging file should be removed");
}
#[test]
fn strict_load_returns_empty_for_missing_file() {
let dir = tmpdir();
let path = dir.path().join("nope.json");
let val = load_json_file_strict(&path).unwrap();
assert_eq!(val, serde_json::json!({}));
}
#[test]
fn strict_load_returns_empty_for_blank_file() {
let dir = tmpdir();
let path = dir.path().join("empty.json");
fs::write(&path, " \n ").unwrap();
let val = load_json_file_strict(&path).unwrap();
assert_eq!(val, serde_json::json!({}));
}
#[test]
fn strict_load_parses_valid_json() {
let dir = tmpdir();
let path = dir.path().join("valid.json");
fs::write(&path, r#"{"hello": "world", "n": 42}"#).unwrap();
let val = load_json_file_strict(&path).unwrap();
assert_eq!(val["hello"], "world");
assert_eq!(val["n"], 42);
}
#[test]
fn strict_load_errors_on_invalid_json() {
let dir = tmpdir();
let path = dir.path().join("bad.json");
fs::write(&path, "not json {{{").unwrap();
let err = load_json_file_strict(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cannot parse"), "error: {msg}");
assert!(
msg.contains("bad.json"),
"error should mention filename: {msg}"
);
}
#[test]
fn strict_load_errors_on_truncated_json() {
let dir = tmpdir();
let path = dir.path().join("trunc.json");
fs::write(&path, r#"{"key": "value", "incomplete"#).unwrap();
assert!(load_json_file_strict(&path).is_err());
}
#[test]
fn strict_jsonc_load_returns_empty_for_missing() {
let dir = tmpdir();
let path = dir.path().join("nope.jsonc");
let val = load_jsonc_file_strict(&path).unwrap();
assert_eq!(val, serde_json::json!({}));
}
#[test]
fn strict_jsonc_load_parses_valid_jsonc() {
let dir = tmpdir();
let path = dir.path().join("settings.json");
fs::write(
&path,
"{\n // comment\n \"key\": \"val\",\n /* block */ \"n\": 1,\n}",
)
.unwrap();
let val = load_jsonc_file_strict(&path).unwrap();
assert_eq!(val["key"], "val");
assert_eq!(val["n"], 1);
}
#[test]
fn strict_jsonc_load_errors_on_garbage() {
let dir = tmpdir();
let path = dir.path().join("garbage.json");
fs::write(&path, "totally not json or jsonc !!!").unwrap();
let err = load_jsonc_file_strict(&path).unwrap_err();
assert!(err.to_string().contains("cannot parse"));
}
#[test]
fn safe_write_creates_file_from_scratch() {
let dir = tmpdir();
let path = dir.path().join("new.json");
let value = serde_json::json!({"created": true});
safe_write_json_file(&path, &value, None).unwrap();
let written = fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
assert_eq!(parsed["created"], true);
}
#[test]
fn safe_write_replaces_existing_file_atomically() {
let dir = tmpdir();
let path = dir.path().join("existing.json");
fs::write(&path, r#"{"old": true}"#).unwrap();
let value = serde_json::json!({"new": true});
safe_write_json_file(&path, &value, None).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["new"], true);
assert!(parsed.get("old").is_none());
}
#[test]
fn safe_write_cleans_up_new_file_on_success() {
let dir = tmpdir();
let path = dir.path().join("config.json");
safe_write_json_file(&path, &serde_json::json!({}), None).unwrap();
let new_path = dir.path().join("config.json.new");
assert!(!new_path.exists(), ".new staging file should be removed");
}
#[test]
fn safe_write_creates_parent_dirs() {
let dir = tmpdir();
let path = dir.path().join("deep").join("nested").join("config.json");
safe_write_json_file(&path, &serde_json::json!({"deep": true}), None).unwrap();
assert!(path.exists());
}
#[test]
#[cfg(unix)]
fn safe_write_through_symlink_preserves_link_and_updates_target() {
use std::os::unix::fs::symlink;
let dir = tmpdir();
let target = dir.path().join("real_target.json");
fs::write(&target, r#"{"old": true}"#).unwrap();
let link = dir.path().join("settings.json");
symlink(&target, &link).unwrap();
safe_write_json_file(&link, &serde_json::json!({"new": true}), None).unwrap();
let meta = fs::symlink_metadata(&link).unwrap();
assert!(
meta.file_type().is_symlink(),
"link.json should remain a symlink"
);
assert_eq!(fs::read_link(&link).unwrap(), target);
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&target).unwrap()).unwrap();
assert_eq!(parsed["new"], true);
}
#[test]
#[cfg(unix)]
fn safe_write_through_symlink_target_in_other_dir() {
use std::os::unix::fs::symlink;
let link_dir = tmpdir();
let target_dir = tmpdir();
let target = target_dir.path().join("dotfiles_settings.json");
fs::write(&target, r#"{"old": true}"#).unwrap();
let link = link_dir.path().join("settings.json");
symlink(&target, &link).unwrap();
safe_write_json_file(&link, &serde_json::json!({"new": true}), None).unwrap();
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&target).unwrap()).unwrap();
assert_eq!(parsed["new"], true);
assert!(!target_dir
.path()
.join("dotfiles_settings.json.new")
.exists());
}
#[test]
#[cfg(unix)]
fn safe_write_through_broken_symlink_creates_target() {
use std::os::unix::fs::symlink;
let dir = tmpdir();
let target = dir.path().join("not_yet_created.json");
let link = dir.path().join("settings.json");
symlink(&target, &link).unwrap();
safe_write_json_file(&link, &serde_json::json!({"created": true}), None).unwrap();
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&target).unwrap()).unwrap();
assert_eq!(parsed["created"], true);
}
#[test]
#[cfg(unix)]
fn safe_write_through_multi_hop_dangling_chain_preserves_every_hop() {
use std::os::unix::fs::symlink;
let dir = tmpdir();
let final_target = dir.path().join("final_target.json"); let intermediate = dir.path().join("intermediate.json");
let config = dir.path().join("config.json");
symlink(&final_target, &intermediate).unwrap(); symlink(&intermediate, &config).unwrap();
safe_write_json_file(&config, &serde_json::json!({"created": true}), None).unwrap();
assert!(
fs::symlink_metadata(&config)
.unwrap()
.file_type()
.is_symlink(),
"config.json should remain a symlink"
);
assert_eq!(fs::read_link(&config).unwrap(), intermediate);
assert!(
fs::symlink_metadata(&intermediate)
.unwrap()
.file_type()
.is_symlink(),
"intermediate.json should remain a symlink, not be replaced by a regular file"
);
assert_eq!(fs::read_link(&intermediate).unwrap(), final_target);
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&final_target).unwrap()).unwrap();
assert_eq!(parsed["created"], true);
}
#[test]
#[cfg(unix)]
fn safe_write_through_cyclic_symlink_fails_safely_without_touching_links() {
use std::os::unix::fs::symlink;
let dir = tmpdir();
let a = dir.path().join("a.json");
let b = dir.path().join("b.json");
symlink(&b, &a).unwrap(); symlink(&a, &b).unwrap();
let result = safe_write_json_file(&a, &serde_json::json!({"x": true}), None);
assert!(result.is_err(), "a cyclic symlink must be rejected");
assert!(
fs::symlink_metadata(&a).unwrap().file_type().is_symlink(),
"a.json must remain untouched after a failed resolution"
);
assert!(
fs::symlink_metadata(&b).unwrap().file_type().is_symlink(),
"b.json must remain untouched after a failed resolution"
);
assert_eq!(fs::read_link(&a).unwrap(), b);
assert_eq!(fs::read_link(&b).unwrap(), a);
}
#[cfg(unix)]
fn build_dangling_chain(dir: &Path, hops: usize) -> (PathBuf, PathBuf) {
use std::os::unix::fs::symlink;
let final_target = dir.join("hop_final_missing.json");
let mut prev = final_target.clone();
for i in (0..hops).rev() {
let link = dir.join(format!("hop_{i}.json"));
symlink(&prev, &link).unwrap();
prev = link;
}
(prev, final_target)
}
#[test]
#[cfg(unix)]
fn safe_write_through_chain_of_exactly_max_hops_succeeds() {
let dir = tmpdir();
let (entry, final_target) = build_dangling_chain(dir.path(), MAX_SYMLINK_HOPS);
safe_write_json_file(&entry, &serde_json::json!({"x": true}), None).unwrap();
assert!(
fs::symlink_metadata(&entry)
.unwrap()
.file_type()
.is_symlink(),
"entry point must remain a symlink"
);
let parsed: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&final_target).unwrap()).unwrap();
assert_eq!(parsed["x"], true);
}
#[test]
#[cfg(unix)]
fn safe_write_through_chain_one_hop_past_max_fails_safely() {
let dir = tmpdir();
let (entry, _final_target) = build_dangling_chain(dir.path(), MAX_SYMLINK_HOPS + 1);
let result = safe_write_json_file(&entry, &serde_json::json!({"x": true}), None);
assert!(
result.is_err(),
"a chain one hop past the budget must be rejected"
);
assert!(
fs::symlink_metadata(&entry)
.unwrap()
.file_type()
.is_symlink(),
"entry point must remain untouched after a failed resolution"
);
}
#[test]
#[cfg(unix)]
fn safe_write_through_excessively_long_dangling_chain_fails_safely() {
let dir = tmpdir();
let (entry, _final_target) = build_dangling_chain(dir.path(), 50);
let result = safe_write_json_file(&entry, &serde_json::json!({"x": true}), None);
assert!(
result.is_err(),
"an overlong dangling chain must be rejected"
);
assert!(
fs::symlink_metadata(&entry)
.unwrap()
.file_type()
.is_symlink(),
"entry point must remain untouched after a failed resolution"
);
}
#[test]
fn write_json_file_creates_backup_automatically() {
let dir = tmpdir();
let path = dir.path().join("auto.json");
fs::write(&path, r#"{"original": true}"#).unwrap();
write_json_file(&path, &serde_json::json!({"updated": true})).unwrap();
let bak = dir.path().join("auto.json.bak");
assert!(bak.exists());
let backup_content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&bak).unwrap()).unwrap();
assert_eq!(backup_content["original"], true);
}
#[test]
fn invalid_json_is_never_silently_replaced() {
let dir = tmpdir();
let path = dir.path().join("opencode.json");
let corrupted =
r#"{"mcp": {"other_server": {"url": "http://example.com"},}, "theme": "dark",}"#;
fs::write(&path, corrupted).unwrap();
let err = load_json_file_strict(&path);
assert!(err.is_err(), "strict loader must reject invalid JSON");
assert_eq!(fs::read_to_string(&path).unwrap(), corrupted);
let old_style = load_json_file(&path);
assert_eq!(
old_style,
serde_json::json!({}),
"non-strict loader returns empty"
);
}
#[test]
fn full_install_cycle_preserves_existing_config() {
let dir = tmpdir();
let path = dir.path().join("config.json");
let original = serde_json::json!({
"theme": "dark",
"mcp": {
"existing_server": {"url": "http://localhost:8080"}
},
"other_setting": [1, 2, 3]
});
fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap();
let backup = backup_config_file(&path).unwrap();
let mut config = load_json_file_strict(&path).unwrap();
config["mcp"]["tokensave"] = serde_json::json!({
"type": "local",
"command": ["tokensave", "serve"]
});
safe_write_json_file(&path, &config, backup.as_deref()).unwrap();
let result: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(result["mcp"]["tokensave"].is_object());
assert_eq!(result["theme"], "dark");
assert_eq!(
result["mcp"]["existing_server"]["url"],
"http://localhost:8080"
);
assert_eq!(result["other_setting"], serde_json::json!([1, 2, 3]));
let bak_content: serde_json::Value =
serde_json::from_str(&fs::read_to_string(backup.unwrap()).unwrap()).unwrap();
assert!(bak_content.get("tokensave").is_none());
assert_eq!(bak_content["theme"], "dark");
}
#[test]
fn full_install_cycle_aborts_on_corrupt_file() {
let dir = tmpdir();
let path = dir.path().join("config.json");
let corrupt_content = "{ this is not valid json at all }}}";
fs::write(&path, corrupt_content).unwrap();
let backup = backup_config_file(&path).unwrap();
assert!(backup.is_some());
let err = load_json_file_strict(&path);
assert!(err.is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), corrupt_content);
assert_eq!(
fs::read_to_string(backup.unwrap()).unwrap(),
corrupt_content
);
}
#[test]
fn safe_write_output_is_valid_json() {
let dir = tmpdir();
let path = dir.path().join("roundtrip.json");
let value = serde_json::json!({
"unicode": "héllo wörld 🦀",
"nested": {"deep": {"array": [1, null, true, "str"]}},
"empty_obj": {},
"empty_arr": []
});
safe_write_json_file(&path, &value, None).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let reparsed: serde_json::Value =
serde_json::from_str(&raw).expect("written file must be valid JSON");
assert_eq!(reparsed, value);
}
#[test]
#[cfg(unix)]
fn remove_managed_rules_file_preserves_symlink_removes_target() {
use std::os::unix::fs::symlink;
let link_dir = tmpdir();
let target_dir = tmpdir();
let target = target_dir.path().join("dotfiles_tokensave.md");
fs::write(&target, "old rules").unwrap();
let link = link_dir.path().join("tokensave.md");
symlink(&target, &link).unwrap();
remove_managed_rules_file(&link);
let meta = fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "symlink must be preserved");
assert_eq!(fs::read_link(&link).unwrap(), target);
assert!(!target.exists(), "target content must be removed");
}
#[test]
fn remove_managed_rules_file_removes_plain_file_and_prunes_empty_dir() {
let dir = tmpdir();
let rules_dir = dir.path().join("rules");
fs::create_dir_all(&rules_dir).unwrap();
let path = rules_dir.join("tokensave.md");
fs::write(&path, "rules").unwrap();
remove_managed_rules_file(&path);
assert!(!path.exists());
assert!(!rules_dir.exists(), "now-empty rules/ dir should be pruned");
}
const CLAUDE_MARKER: &str = "## MANDATORY: No Explore Agents When Tokensave Is Available";
const CLAUDE_SUBHEADING: &str =
"## When you spawn an Explore agent in a tokensave-enabled project";
#[test]
fn remove_legacy_rules_block_noop_when_file_missing() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
assert!(!path.exists());
}
#[test]
fn remove_legacy_rules_block_noop_when_marker_absent() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
fs::write(&path, "# My notes\n\nNothing to do with tokensave here.\n").unwrap();
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# My notes\n\nNothing to do with tokensave here.\n"
);
}
#[test]
fn remove_legacy_rules_block_preserves_adjacent_user_heading_mentioning_tokensave() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
fs::write(
&path,
format!(
"# My notes\n\n\
Some custom content.\n\n\
{CLAUDE_MARKER}\n\n\
legacy body text\n\n\
{CLAUDE_SUBHEADING}\n\n\
legacy sub-body text\n\n\
## My tokensave workflow notes\n\n\
This is MY content about tokensave, not the installed block.\n"
),
)
.unwrap();
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
let result = fs::read_to_string(&path).unwrap();
assert!(!result.contains(CLAUDE_MARKER));
assert!(!result.contains(CLAUDE_SUBHEADING));
assert!(result.contains("Some custom content."));
assert!(
result.contains("## My tokensave workflow notes"),
"adjacent user heading must survive: {result}"
);
assert!(result.contains("This is MY content about tokensave, not the installed block."));
}
#[test]
fn remove_legacy_rules_block_leaves_only_custom_content_when_block_is_appended_at_eof() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
fs::write(
&path,
format!(
"# My notes\n\nSome custom content.\n\n{CLAUDE_MARKER}\n\nlegacy body\n\n{CLAUDE_SUBHEADING}\n\nlegacy sub-body\n"
),
)
.unwrap();
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# My notes\n\nSome custom content.\n"
);
}
#[test]
fn remove_legacy_rules_block_removes_file_when_migration_leaves_it_empty() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
fs::write(
&path,
format!("{CLAUDE_MARKER}\n\nlegacy body\n\n{CLAUDE_SUBHEADING}\n\nlegacy sub-body\n"),
)
.unwrap();
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
assert!(!path.exists(), "file with nothing left should be removed");
}
#[test]
#[cfg(unix)]
fn remove_legacy_rules_block_preserves_symlink_when_migration_empties_file() {
use std::os::unix::fs::symlink;
let link_dir = tmpdir();
let target_dir = tmpdir();
let target = target_dir.path().join("dotfiles_claude_md");
fs::write(&target, format!("{CLAUDE_MARKER}\n\nlegacy body\n")).unwrap();
let link = link_dir.path().join("CLAUDE.md");
symlink(&target, &link).unwrap();
remove_legacy_rules_block(&link, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
let meta = fs::symlink_metadata(&link).unwrap();
assert!(meta.file_type().is_symlink(), "symlink must be preserved");
assert_eq!(fs::read_link(&link).unwrap(), target);
assert!(!target.exists(), "legacy target content must be removed");
}
#[test]
fn remove_legacy_rules_block_creates_backup() {
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
let original = format!("Custom.\n\n{CLAUDE_MARKER}\n\nlegacy body\n");
fs::write(&path, &original).unwrap();
remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]).unwrap();
let backup = dir.path().join("CLAUDE.md.bak");
assert!(backup.exists(), "migration must leave a recoverable backup");
assert_eq!(fs::read_to_string(&backup).unwrap(), original);
}
#[test]
#[cfg(unix)]
fn remove_legacy_rules_block_write_failure_returns_err_and_leaves_file_untouched() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir();
let path = dir.path().join("CLAUDE.md");
let original = format!("Custom.\n\n{CLAUDE_MARKER}\n\nlegacy body\n");
fs::write(&path, &original).unwrap();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
let result = remove_legacy_rules_block(&path, CLAUDE_MARKER, &[CLAUDE_SUBHEADING]);
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_err(), "write failure must surface as Err");
assert_eq!(
fs::read_to_string(&path).unwrap(),
original,
"CLAUDE.md must be left untouched when migration fails partway"
);
}
#[test]
fn safe_write_text_file_rename_failure_leaves_original_untouched() {
let dir = tmpdir();
let path = dir.path().join("target");
fs::create_dir(&path).unwrap();
fs::write(path.join("existing.txt"), "keep me").unwrap();
let result = safe_write_text_file(&path, "new content");
assert!(result.is_err(), "rename onto a directory must fail");
assert!(
path.is_dir(),
"original directory must survive a failed rename"
);
assert_eq!(
fs::read_to_string(path.join("existing.txt")).unwrap(),
"keep me"
);
assert!(
!dir.path().join("target.new").exists(),
"the failed .new staging file must be cleaned up"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod path_normalize_tests {
use crate::agents::normalize_path_separators;
#[test]
fn normalizes_windows_backslashes() {
assert_eq!(
normalize_path_separators(r"C:\Users\dev\scoop\shims\tokensave.exe"),
"C:/Users/dev/scoop/shims/tokensave.exe"
);
}
#[test]
fn leaves_unix_paths_unchanged() {
assert_eq!(
normalize_path_separators("/usr/local/bin/tokensave"),
"/usr/local/bin/tokensave"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod install_scope_tests {
use crate::agents::InstallContext;
use crate::agents::InstallScope;
use std::path::PathBuf;
#[test]
fn install_context_base_dir_follows_scope() {
let home = PathBuf::from("/home/user");
let proj = PathBuf::from("/work/proj");
let global = InstallContext {
home: home.clone(),
tokensave_bin: "tokensave".into(),
tool_permissions: vec![],
scope: InstallScope::Global,
force_permission_style: false,
};
assert_eq!(global.base_dir(), home.as_path());
assert!(!global.is_local());
let local = InstallContext {
home: home.clone(),
tokensave_bin: "tokensave".into(),
tool_permissions: vec![],
scope: InstallScope::Local {
project_path: proj.clone(),
},
force_permission_style: false,
};
assert_eq!(local.base_dir(), proj.as_path());
assert!(local.is_local());
}
}