use crate::config::xdg;
use crate::error::ApiError;
use crate::types::NodeID;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const BUILTIN_DEFAULTS: &[&str] = &[".git", "target", "node_modules", ".cargo"];
const GITIGNORE_ENTRY: &str = ".gitignore";
const GITIGNORE_BLOCK_START: &str = "# .gitignore";
const GITIGNORE_BLOCK_END: &str = "# end .gitignore";
pub fn ignore_list_path(workspace_root: &Path) -> Result<PathBuf, ApiError> {
let data_dir = xdg::workspace_data_dir(workspace_root)?;
Ok(data_dir.join("ignore_list"))
}
fn gitignore_hash_path(workspace_root: &Path) -> Result<PathBuf, ApiError> {
let data_dir = xdg::workspace_data_dir(workspace_root)?;
Ok(data_dir.join("._gitignore_hash"))
}
pub fn read_gitignore_patterns(workspace_root: &Path) -> Vec<String> {
let gitignore_path = workspace_root.join(".gitignore");
if !gitignore_path.exists() || !gitignore_path.is_file() {
return Vec::new();
}
let Ok(contents) = fs::read_to_string(&gitignore_path) else {
return Vec::new();
};
let mut out = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
out.push(line.to_string());
}
out
}
pub fn sync_gitignore_to_ignore_list(workspace_root: &Path) -> Result<(), ApiError> {
let list_path = ignore_list_path(workspace_root)?;
let gitignore_patterns = read_gitignore_patterns(workspace_root);
let (before, after) = if list_path.exists() && list_path.is_file() {
let contents = fs::read_to_string(&list_path)
.map_err(|e| ApiError::ConfigError(format!("Failed to read ignore list: {}", e)))?;
let mut before = Vec::new();
let mut after = Vec::new();
let mut in_block = false;
let mut found_block = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed == GITIGNORE_BLOCK_START {
in_block = true;
found_block = true;
continue;
}
if trimmed == GITIGNORE_BLOCK_END {
in_block = false;
continue;
}
if in_block {
continue;
}
if found_block {
after.push(line.to_string());
} else {
before.push(line.to_string());
}
}
(before, after)
} else {
if let Some(parent) = list_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
ApiError::ConfigError(format!(
"Failed to create directory {}: {}",
parent.display(),
e
))
})?;
}
}
(Vec::new(), Vec::new())
};
let mut f = fs::File::create(&list_path)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
for line in before {
writeln!(f, "{}", line)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
}
writeln!(f, "{}", GITIGNORE_BLOCK_START)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
for p in &gitignore_patterns {
writeln!(f, "{}", p)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
}
writeln!(f, "{}", GITIGNORE_BLOCK_END)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
for line in after {
writeln!(f, "{}", line)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
}
Ok(())
}
fn read_stored_gitignore_hash(workspace_root: &Path) -> Result<Option<NodeID>, ApiError> {
let hash_path = gitignore_hash_path(workspace_root)?;
if !hash_path.exists() || !hash_path.is_file() {
return Ok(None);
}
let hex_str = fs::read_to_string(&hash_path).map_err(|e| {
ApiError::ConfigError(format!("Failed to read .gitignore hash file: {}", e))
})?;
let hex_str = hex_str.trim();
if hex_str.len() != 64 {
return Ok(None);
}
let mut arr = [0u8; 32];
for (i, chunk) in hex_str.as_bytes().chunks(2).enumerate() {
if i >= 32 || chunk.len() != 2 {
return Ok(None);
}
let s = std::str::from_utf8(chunk)
.map_err(|_| ApiError::ConfigError("Invalid hex in .gitignore hash".to_string()))?;
arr[i] = u8::from_str_radix(s, 16)
.map_err(|_| ApiError::ConfigError("Invalid hex in .gitignore hash".to_string()))?;
}
Ok(Some(arr))
}
fn write_stored_gitignore_hash(workspace_root: &Path, node_id: &NodeID) -> Result<(), ApiError> {
let hash_path = gitignore_hash_path(workspace_root)?;
if let Some(parent) = hash_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| ApiError::ConfigError(format!("Failed to create directory: {}", e)))?;
}
}
let hex_str = hex::encode(node_id);
fs::write(&hash_path, hex_str)
.map_err(|e| ApiError::ConfigError(format!("Failed to write .gitignore hash: {}", e)))?;
Ok(())
}
pub fn maybe_sync_gitignore_after_tree(
workspace_root: &Path,
current_gitignore_node_id: Option<&NodeID>,
) -> Result<(), ApiError> {
let stored = read_stored_gitignore_hash(workspace_root)?;
let current = current_gitignore_node_id.copied();
let changed = match (stored, current) {
(None, None) => false,
(Some(a), Some(b)) => a != b,
_ => true,
};
if !changed {
return Ok(());
}
sync_gitignore_to_ignore_list(workspace_root)?;
if let Some(id) = current {
write_stored_gitignore_hash(workspace_root, &id)?;
} else {
let hash_path = gitignore_hash_path(workspace_root)?;
if hash_path.exists() {
let _ = fs::remove_file(&hash_path);
}
}
Ok(())
}
pub fn load_ignore_patterns(workspace_root: &Path) -> Result<Vec<String>, ApiError> {
let mut patterns: Vec<String> = BUILTIN_DEFAULTS.iter().map(|s| (*s).to_string()).collect();
let list_path = ignore_list_path(workspace_root)?;
if !list_path.exists() || !list_path.is_file() {
patterns.extend(read_gitignore_patterns(workspace_root));
return Ok(patterns);
}
let contents = fs::read_to_string(&list_path).unwrap_or_default();
let mut in_block = false;
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == GITIGNORE_BLOCK_START {
in_block = true;
continue;
}
if line == GITIGNORE_BLOCK_END {
in_block = false;
continue;
}
if line.starts_with('#') && !in_block {
continue;
}
if in_block {
patterns.push(line.to_string());
continue;
}
if line == GITIGNORE_ENTRY {
patterns.extend(read_gitignore_patterns(workspace_root));
} else {
patterns.push(line.to_string());
}
}
Ok(patterns)
}
pub fn read_ignore_list(workspace_root: &Path) -> Result<Vec<String>, ApiError> {
let list_path = ignore_list_path(workspace_root)?;
if !list_path.exists() || !list_path.is_file() {
return Ok(Vec::new());
}
let contents = fs::read_to_string(&list_path)
.map_err(|e| ApiError::ConfigError(format!("Failed to read ignore list: {}", e)))?;
let mut out = Vec::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
out.push(line.to_string());
}
Ok(out)
}
pub fn remove_from_ignore_list(workspace_root: &Path, path: &Path) -> Result<(), ApiError> {
let list_path = ignore_list_path(workspace_root)?;
if !list_path.exists() || !list_path.is_file() {
return Ok(());
}
let path_norm = normalize_workspace_relative(workspace_root, path)?;
let contents = fs::read_to_string(&list_path)
.map_err(|e| ApiError::ConfigError(format!("Failed to read ignore list: {}", e)))?;
let mut in_block = false;
let mut out = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed == GITIGNORE_BLOCK_START {
in_block = true;
out.push(line.to_string());
continue;
}
if trimmed == GITIGNORE_BLOCK_END {
in_block = false;
out.push(line.to_string());
continue;
}
if in_block {
out.push(line.to_string());
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
out.push(line.to_string());
continue;
}
if trimmed == GITIGNORE_ENTRY {
out.push(line.to_string());
continue;
}
if trimmed == path_norm {
continue;
}
out.push(line.to_string());
}
let new_contents = out.join("\n");
let has_final_newline = contents.ends_with('\n');
let to_write = if has_final_newline && !new_contents.is_empty() && !new_contents.ends_with('\n')
{
format!("{}\n", new_contents)
} else {
new_contents
};
fs::write(&list_path, to_write)
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
Ok(())
}
pub fn append_to_ignore_list(workspace_root: &Path, path: &str) -> Result<(), ApiError> {
let list_path = ignore_list_path(workspace_root)?;
if let Some(parent) = list_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
ApiError::ConfigError(format!(
"Failed to create directory {}: {}",
parent.display(),
e
))
})?;
}
}
let line = format!("{}\n", path);
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&list_path)
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()))
.map_err(|e| ApiError::ConfigError(format!("Failed to write ignore list: {}", e)))?;
Ok(())
}
pub fn normalize_workspace_relative(
workspace_root: &Path,
path: &Path,
) -> Result<String, ApiError> {
let workspace_canon = workspace_root
.canonicalize()
.map_err(|e| ApiError::ConfigError(format!("Failed to canonicalize workspace: {}", e)))?;
let path_resolved = if path.is_absolute() {
path.to_path_buf()
} else {
workspace_root.join(path)
};
let path_canon = if path_resolved.exists() {
path_resolved
.canonicalize()
.map_err(|e| ApiError::ConfigError(format!("Failed to canonicalize path: {}", e)))?
} else {
if path.is_absolute() {
let path_str = path.to_string_lossy();
let ws_str = workspace_canon.to_string_lossy();
if !path_str.starts_with(ws_str.as_ref()) {
return Err(ApiError::ConfigError(
"Path is outside workspace".to_string(),
));
}
let suffix = path_str.strip_prefix(ws_str.as_ref()).unwrap_or(&path_str);
let suffix = suffix.trim_start_matches(std::path::MAIN_SEPARATOR);
return Ok(suffix.to_string());
}
return Ok(path.to_string_lossy().trim_start_matches("./").to_string());
};
let path_str = path_canon.to_string_lossy();
let ws_str = workspace_canon.to_string_lossy();
if !path_str.starts_with(ws_str.as_ref()) {
return Err(ApiError::ConfigError(
"Path is outside workspace".to_string(),
));
}
let suffix = path_str.strip_prefix(ws_str.as_ref()).unwrap_or(&path_str);
let suffix = suffix.trim_start_matches(std::path::MAIN_SEPARATOR);
Ok(suffix.to_string())
}