use crate::error::IndexError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[cfg(test)]
const GLOBAL_DIR_NAME: &str = ".codanna-test";
#[cfg(test)]
const LOCAL_DIR_NAME: &str = ".codanna-test-local";
#[cfg(test)]
const FASTEMBED_CACHE_NAME: &str = ".fastembed_cache";
#[cfg(not(test))]
const GLOBAL_DIR_NAME: &str = ".codanna";
#[cfg(not(test))]
const LOCAL_DIR_NAME: &str = ".codanna";
#[cfg(not(test))]
const FASTEMBED_CACHE_NAME: &str = ".fastembed_cache";
static GLOBAL_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn global_dir() -> PathBuf {
GLOBAL_DIR
.get_or_init(|| {
dirs::home_dir()
.expect("Failed to determine home directory")
.join(GLOBAL_DIR_NAME)
})
.clone()
}
pub fn models_dir() -> PathBuf {
global_dir().join("models")
}
pub fn projects_file() -> PathBuf {
global_dir().join("projects.json")
}
pub fn local_dir_name() -> &'static str {
LOCAL_DIR_NAME
}
pub fn fastembed_cache_name() -> &'static str {
FASTEMBED_CACHE_NAME
}
pub fn init_global_dirs() -> Result<(), std::io::Error> {
let global = global_dir();
let models = models_dir();
if !global.exists() {
std::fs::create_dir(&global)?;
println!("Created global directory: {}", global.display());
} else {
println!("Using existing global directory: {}", global.display());
}
if !models.exists() {
std::fs::create_dir(&models)?;
println!("Created models directory: {}", models.display());
} else {
println!("Using existing models directory: {}", models.display());
}
init_profile_infrastructure()?;
Ok(())
}
pub fn init_profile_infrastructure() -> Result<(), std::io::Error> {
let providers_file = global_dir().join("providers.json");
if !providers_file.exists() {
let empty_registry = serde_json::json!({
"version": 1,
"providers": {}
});
let content =
serde_json::to_string_pretty(&empty_registry).map_err(std::io::Error::other)?;
std::fs::write(&providers_file, content)?;
println!("Created provider registry: {}", providers_file.display());
}
Ok(())
}
pub fn create_fastembed_symlink() -> Result<(), std::io::Error> {
let local_cache = PathBuf::from(fastembed_cache_name());
let global_models = models_dir();
if local_cache.exists() {
if local_cache.is_symlink() {
let target = std::fs::read_link(&local_cache)?;
if target == global_models {
let model_dir = global_models.join("models--Qdrant--all-MiniLM-L6-v2-onnx");
if model_dir.exists() && model_dir.is_dir() {
println!(
"Symlink already exists: {} -> {} (model verified)",
local_cache.display(),
global_models.display()
);
return Ok(());
} else {
println!(
"Symlink exists but model not found. Model will be downloaded on first use."
);
return Ok(());
}
}
std::fs::remove_file(&local_cache)?;
} else {
eprintln!(
"Warning: {} exists and is not a symlink",
local_cache.display()
);
eprintln!(" Models will be downloaded locally");
return Ok(());
}
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&global_models, &local_cache)?;
println!(
"Created symlink: {} -> {}",
local_cache.display(),
global_models.display()
);
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(&global_models, &local_cache)?;
println!(
"Created symlink: {} -> {}",
local_cache.display(),
global_models.display()
);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ProjectId(String);
impl Default for ProjectId {
fn default() -> Self {
Self::new()
}
}
impl ProjectId {
pub fn new() -> Self {
use rand::RngExt;
use sha2::{Digest, Sha256};
let mut rng = rand::rng();
let random_bytes: [u8; 16] = rng.random();
let mut hasher = Sha256::new();
hasher.update(random_bytes);
let result = hasher.finalize();
Self(format!("{result:x}")[..32].to_string())
}
pub fn from_string(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfo {
pub path: PathBuf,
pub name: String,
pub symbol_count: u32,
pub file_count: u32,
pub last_modified: u64,
pub doc_count: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectRegistry {
version: u32,
projects: HashMap<String, ProjectInfo>,
default_project: Option<String>,
}
impl ProjectRegistry {
pub fn new() -> Self {
Self {
version: 1,
projects: HashMap::new(),
default_project: None,
}
}
pub fn load() -> Result<Self, IndexError> {
let path = projects_file();
if !path.exists() {
return Ok(Self::new());
}
let content = std::fs::read_to_string(&path).map_err(|e| IndexError::FileRead {
path: path.clone(),
source: e,
})?;
serde_json::from_str(&content).map_err(|e| {
IndexError::General(format!(
"Failed to parse project registry: {e}\nSuggestion: Back up and delete {}",
path.display()
))
})
}
pub fn save(&self) -> Result<(), IndexError> {
let path = projects_file();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| IndexError::FileWrite {
path: parent.to_path_buf(),
source: e,
})?;
}
let content = serde_json::to_string_pretty(&self).map_err(|e| {
IndexError::General(format!("Failed to serialize project registry: {e}"))
})?;
std::fs::write(&path, content).map_err(|e| IndexError::FileWrite { path, source: e })
}
pub fn register_project(project_path: &Path) -> Result<String, IndexError> {
let project_id = ProjectId::new();
let project_info = Self::create_project_info(project_path);
let mut registry = Self::load().unwrap_or_else(|_| Self::new());
registry.add_project(project_id.as_str(), project_info);
registry.save()?;
Ok(project_id.to_string())
}
pub fn register_or_update_project(project_path: &Path) -> Result<String, IndexError> {
let mut registry = Self::load().unwrap_or_else(|_| Self::new());
let canonical_input = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
if let Some((existing_id, _)) = registry.find_project_by_path(&canonical_input) {
let updated_info = Self::create_project_info(project_path);
registry.add_project(&existing_id, updated_info);
registry.save()?;
Ok(existing_id)
} else {
let project_id = ProjectId::new();
let project_info = Self::create_project_info(project_path);
registry.add_project(project_id.as_str(), project_info);
registry.save()?;
Ok(project_id.to_string())
}
}
fn create_project_info(project_path: &Path) -> ProjectInfo {
let canonical_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string();
ProjectInfo {
path: canonical_path,
name,
symbol_count: 0,
file_count: 0,
last_modified: 0,
doc_count: 0,
}
}
fn add_project(&mut self, project_id: &str, project_info: ProjectInfo) {
self.projects.insert(project_id.to_string(), project_info);
}
fn find_project_by_path(&self, path: &Path) -> Option<(String, &ProjectInfo)> {
let search_path = path.canonicalize().ok()?;
self.projects.iter().find_map(|(id, info)| {
if let Ok(project_path) = info.path.canonicalize() {
if project_path == search_path {
return Some((id.clone(), info));
}
}
None
})
}
pub fn find_project_by_id(&self, project_id: &str) -> Option<&ProjectInfo> {
self.projects.get(project_id)
}
pub fn find_project_by_id_mut(&mut self, project_id: &str) -> Option<&mut ProjectInfo> {
self.projects.get_mut(project_id)
}
pub fn update_project_path(
&mut self,
project_id: &str,
new_path: &Path,
) -> Result<(), IndexError> {
let project = self.projects.get_mut(project_id).ok_or_else(|| {
IndexError::General(format!(
"Project {project_id} not found\nSuggestion: Run 'codanna init' in the project directory"
))
})?;
project.path = new_path.to_path_buf();
project.name = new_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string();
self.save()
}
}
impl Default for ProjectRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn resolve_index_path(
settings: &crate::config::Settings,
config_path: Option<&Path>,
) -> PathBuf {
if settings.index_path.is_absolute() {
return settings.index_path.clone();
}
if let Some(cfg_path) = config_path {
if let Some(parent) = cfg_path.parent() {
let local_dir = local_dir_name();
if parent.file_name() == Some(std::ffi::OsStr::new(local_dir)) {
if let Some(workspace) = parent.parent() {
return workspace.join(&settings.index_path);
}
}
return parent.join(&settings.index_path);
}
}
if let Some(workspace_root) = &settings.workspace_root {
return workspace_root.join(&settings.index_path);
}
settings.index_path.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_directory_names() {
assert_eq!(GLOBAL_DIR_NAME, ".codanna-test");
assert_eq!(LOCAL_DIR_NAME, ".codanna-test-local");
assert_eq!(FASTEMBED_CACHE_NAME, ".fastembed_cache"); }
#[test]
fn test_global_paths() {
let global = global_dir();
assert!(global.ends_with(GLOBAL_DIR_NAME));
let models = models_dir();
assert!(models.ends_with(format!("{GLOBAL_DIR_NAME}/models")));
let projects = projects_file();
assert!(projects.ends_with(format!("{GLOBAL_DIR_NAME}/projects.json")));
}
}