use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn canonical_path_str(path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
const GLOBAL_DIR: &str = ".intent-engine";
const PROJECTS_FILE: &str = "projects.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub path: String,
pub last_accessed: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectsRegistry {
pub projects: Vec<ProjectEntry>,
}
impl ProjectsRegistry {
pub fn registry_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(GLOBAL_DIR).join(PROJECTS_FILE))
}
pub fn load() -> Self {
let Some(path) = Self::registry_path() else {
return Self::default();
};
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = Self::registry_path() else {
return Ok(());
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)
}
pub fn register_project(&mut self, project_path: &Path) {
let path_str = canonical_path_str(project_path);
let now = Utc::now();
if let Some(entry) = self.projects.iter_mut().find(|p| p.path == path_str) {
entry.last_accessed = now;
} else {
let name = project_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
self.projects.push(ProjectEntry {
path: path_str,
last_accessed: now,
name,
});
}
}
pub fn remove_project(&mut self, project_path: &str) -> bool {
let canonical = canonical_path_str(Path::new(project_path));
let initial_len = self.projects.len();
self.projects.retain(|p| p.path != canonical);
self.projects.len() < initial_len
}
pub fn get_projects(&self) -> Vec<&ProjectEntry> {
let mut projects: Vec<_> = self.projects.iter().collect();
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
projects
}
pub fn validate_project(path: &str) -> bool {
let project_path = PathBuf::from(path);
let db_path = project_path.join(".intent-engine").join("project.db");
db_path.exists()
}
}
pub fn register_project(project_path: &Path) {
let mut registry = ProjectsRegistry::load();
registry.register_project(project_path);
if let Err(e) = registry.save() {
tracing::warn!(error = %e, "Failed to save global projects registry");
}
}
pub fn remove_project(project_path: &str) -> bool {
let mut registry = ProjectsRegistry::load();
let removed = registry.remove_project(project_path);
if removed {
if let Err(e) = registry.save() {
tracing::warn!(error = %e, "Failed to save global projects registry");
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_project_entry_serialization() {
let entry = ProjectEntry {
path: "/test/project".to_string(),
last_accessed: Utc::now(),
name: Some("project".to_string()),
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: ProjectEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.path, entry.path);
}
#[test]
fn test_registry_register_and_remove() {
let mut registry = ProjectsRegistry::default();
let temp = TempDir::new().unwrap();
registry.register_project(temp.path());
assert_eq!(registry.projects.len(), 1);
registry.register_project(temp.path());
assert_eq!(registry.projects.len(), 1);
let path_str = temp.path().to_string_lossy().to_string();
assert!(registry.remove_project(&path_str));
assert_eq!(registry.projects.len(), 0);
}
#[test]
fn test_registry_canonical_invariant() {
let mut registry = ProjectsRegistry::default();
let temp = TempDir::new().unwrap();
let canonical = temp.path().canonicalize().unwrap();
registry.register_project(&canonical);
assert_eq!(registry.projects.len(), 1);
registry.register_project(temp.path());
assert_eq!(registry.projects.len(), 1, "same project registered twice");
let raw_str = temp.path().to_string_lossy().to_string();
assert!(
registry.remove_project(&raw_str),
"remove via raw path must find canonical entry"
);
assert_eq!(registry.projects.len(), 0);
}
#[test]
fn test_registry_get_projects_sorted() {
let mut registry = ProjectsRegistry::default();
registry.projects.push(ProjectEntry {
path: "/old".to_string(),
last_accessed: Utc::now() - chrono::Duration::hours(2),
name: None,
});
registry.projects.push(ProjectEntry {
path: "/new".to_string(),
last_accessed: Utc::now(),
name: None,
});
let projects = registry.get_projects();
assert_eq!(projects[0].path, "/new");
assert_eq!(projects[1].path, "/old");
}
}