use domain::error::{CodeGraphError, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub(super) fn resolve_settings_path(project_root: Option<&Path>, global: bool) -> Result<PathBuf> {
if global {
let home =
std::env::var("HOME").map_err(|_| CodeGraphError::Other("$HOME is not set".into()))?;
let home = std::fs::canonicalize(&home).map_err(|e| {
CodeGraphError::Other(format!("failed to canonicalize $HOME '{home}': {e}"))
})?;
Ok(home.join(".claude").join("settings.json"))
} else {
match project_root {
Some(root) => Ok(root.join(".claude").join("settings.json")),
None => Err(CodeGraphError::Other(
"project root is required for non-global settings".into(),
)),
}
}
}
pub(super) fn ensure_gitignore_entry(project_root: &Path) -> Result<bool> {
let gitignore = project_root.join(".gitignore");
let content = if gitignore.exists() {
fs::read_to_string(&gitignore).map_err(|e| CodeGraphError::FileSystem {
path: gitignore.clone(),
source: e,
})?
} else {
String::new()
};
let already_present = content.lines().any(|line| line.trim() == ".code-graph/");
if already_present {
return Ok(false);
}
let mut new_content = content.clone();
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str("# Code Graph data\n.code-graph/\n");
fs::write(&gitignore, new_content).map_err(|e| CodeGraphError::FileSystem {
path: gitignore,
source: e,
})?;
Ok(true)
}
pub(super) fn remove_gitignore_entry(project_root: &Path) -> Result<bool> {
let gitignore = project_root.join(".gitignore");
if !gitignore.exists() {
return Ok(false);
}
let content = fs::read_to_string(&gitignore).map_err(|e| CodeGraphError::FileSystem {
path: gitignore.clone(),
source: e,
})?;
let original_line_count = content.lines().count();
let filtered: Vec<&str> = content
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed != ".code-graph/" && trimmed != "# Code Graph data"
})
.collect();
let removed = filtered.len() < original_line_count;
if removed {
let mut new_content = filtered.join("\n");
if content.ends_with('\n') {
new_content.push('\n');
}
fs::write(&gitignore, new_content).map_err(|e| CodeGraphError::FileSystem {
path: gitignore,
source: e,
})?;
}
Ok(removed)
}
pub(super) fn find_on_path(binary: &str) -> Option<PathBuf> {
which::which(binary).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn resolve_settings_path_local() {
let dir = tempdir().unwrap();
let root = dir.path();
let path = resolve_settings_path(Some(root), false).unwrap();
assert_eq!(path, root.join(".claude").join("settings.json"));
}
#[test]
fn resolve_settings_path_global() {
let path = resolve_settings_path(None, true).unwrap();
let home = std::env::var("HOME").unwrap();
assert_eq!(
path,
PathBuf::from(home).join(".claude").join("settings.json")
);
}
#[test]
fn resolve_settings_path_local_without_root_errors() {
let result = resolve_settings_path(None, false);
assert!(result.is_err(), "expected error when project_root is None");
}
#[test]
fn ensure_gitignore_creates_file() {
let dir = tempdir().unwrap();
let root = dir.path();
let result = ensure_gitignore_entry(root).unwrap();
assert!(result, "expected true when entry was added");
let content = fs::read_to_string(root.join(".gitignore")).unwrap();
assert!(content.contains(".code-graph/"));
}
#[test]
fn ensure_gitignore_appends() {
let dir = tempdir().unwrap();
let root = dir.path();
let gitignore = root.join(".gitignore");
fs::write(&gitignore, "target/\n").unwrap();
let result = ensure_gitignore_entry(root).unwrap();
assert!(result, "expected true when entry was appended");
let content = fs::read_to_string(&gitignore).unwrap();
assert!(content.contains("target/"));
assert!(content.contains(".code-graph/"));
}
#[test]
fn ensure_gitignore_idempotent() {
let dir = tempdir().unwrap();
let root = dir.path();
let gitignore = root.join(".gitignore");
fs::write(&gitignore, "# Code Graph data\n.code-graph/\n").unwrap();
let result = ensure_gitignore_entry(root).unwrap();
assert!(!result, "expected false when entry already present");
let content = fs::read_to_string(&gitignore).unwrap();
assert_eq!(content.matches(".code-graph/").count(), 1);
}
#[test]
fn ensure_gitignore_handles_no_trailing_newline() {
let dir = tempdir().unwrap();
let root = dir.path();
let gitignore = root.join(".gitignore");
fs::write(&gitignore, "target/").unwrap();
let result = ensure_gitignore_entry(root).unwrap();
assert!(result, "expected true when entry was appended");
let content = fs::read_to_string(&gitignore).unwrap();
assert!(content.contains("target/\n"));
assert!(content.contains(".code-graph/"));
}
#[test]
fn remove_gitignore_entry_removes_line_and_comment() {
let dir = tempdir().unwrap();
let root = dir.path();
let gitignore = root.join(".gitignore");
fs::write(&gitignore, "target/\n# Code Graph data\n.code-graph/\n").unwrap();
let result = remove_gitignore_entry(root).unwrap();
assert!(result, "expected true when lines were removed");
let content = fs::read_to_string(&gitignore).unwrap();
assert!(!content.contains(".code-graph/"));
assert!(!content.contains("# Code Graph data"));
assert!(content.contains("target/"));
}
#[test]
fn remove_gitignore_entry_noop_when_absent() {
let dir = tempdir().unwrap();
let root = dir.path();
let gitignore = root.join(".gitignore");
fs::write(&gitignore, "target/\n").unwrap();
let result = remove_gitignore_entry(root).unwrap();
assert!(!result, "expected false when no entry found");
let content = fs::read_to_string(&gitignore).unwrap();
assert_eq!(content, "target/\n");
}
#[test]
fn remove_gitignore_noop_no_file() {
let dir = tempdir().unwrap();
let root = dir.path();
let result = remove_gitignore_entry(root).unwrap();
assert!(!result, "expected false when .gitignore does not exist");
}
#[test]
fn find_on_path_returns_some_for_existing_binary() {
let result = find_on_path("ls");
assert!(result.is_some(), "expected Some for 'ls'");
}
#[test]
fn find_on_path_returns_none_for_nonexistent() {
let result = find_on_path("nonexistent_binary_xyz_123");
assert!(result.is_none(), "expected None for nonexistent binary");
}
}