use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectServerOptions {
pub watch: Option<bool>,
pub watch_debounce_ms: Option<u64>,
}
#[cfg(unix)]
pub fn is_process_alive(pid: u32) -> bool {
use std::process::Command;
Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(unix))]
pub fn is_process_alive(_pid: u32) -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMeta {
pub project_id: String,
pub name: String,
pub path: PathBuf,
pub imported_at: String,
pub last_accessed: String,
pub file_count: usize,
pub total_lines: usize,
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub has_config: bool,
#[serde(default)]
pub socket: Option<PathBuf>,
#[serde(default)]
pub server_pid: Option<u32>,
#[serde(default)]
pub server_options: ProjectServerOptions,
}
impl ProjectMeta {
pub fn new(
project_id: String,
name: String,
path: PathBuf,
file_count: usize,
total_lines: usize,
) -> Self {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let socket = Self::generate_socket_path(&project_id);
let canonical_path = path.canonicalize().unwrap_or(path);
Self {
project_id,
name,
path: canonical_path,
imported_at: now.clone(),
last_accessed: now,
file_count,
total_lines,
description: None,
tags: Vec::new(),
has_config: false,
socket: Some(socket),
server_pid: None,
server_options: ProjectServerOptions::default(),
}
}
pub fn generate_socket_path(project_id: &str) -> PathBuf {
let short_id = &project_id[..8.min(project_id.len())];
PathBuf::from(format!("/tmp/ryo-{}.sock", short_id))
}
pub fn socket_path(&self) -> PathBuf {
self.socket
.clone()
.unwrap_or_else(|| Self::generate_socket_path(&self.project_id))
}
pub fn is_server_running(&self) -> bool {
if let Some(pid) = self.server_pid {
is_process_alive(pid)
} else {
false
}
}
pub fn set_server_pid(&mut self, pid: Option<u32>) {
self.server_pid = pid;
}
pub fn cleanup_dead_server(&mut self) -> bool {
if let Some(pid) = self.server_pid {
if !is_process_alive(pid) {
self.server_pid = None;
return true;
}
}
false
}
pub fn touch(&mut self) {
self.last_accessed = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_config(mut self, has_config: bool) -> Self {
self.has_config = has_config;
self
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ProjectIndex {
projects: HashMap<String, ProjectMeta>,
#[serde(skip)]
by_path: HashMap<PathBuf, String>,
#[serde(default = "default_version")]
version: u32,
}
fn default_version() -> u32 {
1
}
impl ProjectIndex {
pub fn new() -> Self {
Self {
projects: HashMap::new(),
by_path: HashMap::new(),
version: 1,
}
}
pub fn add(&mut self, meta: ProjectMeta) {
let project_id = meta.project_id.clone();
let path = meta.path.clone();
self.projects.insert(project_id.clone(), meta);
self.by_path.insert(path, project_id);
}
pub fn remove(&mut self, project_id: &str) -> Option<ProjectMeta> {
if let Some(meta) = self.projects.remove(project_id) {
self.by_path.remove(&meta.path);
Some(meta)
} else {
None
}
}
pub fn get(&self, project_id: &str) -> Option<&ProjectMeta> {
if let Some(meta) = self.projects.get(project_id) {
return Some(meta);
}
if project_id.len() >= 4 {
let matches: Vec<_> = self
.projects
.iter()
.filter(|(id, _)| id.starts_with(project_id))
.collect();
if matches.len() == 1 {
return Some(matches[0].1);
}
}
None
}
pub fn get_mut(&mut self, project_id: &str) -> Option<&mut ProjectMeta> {
if self.projects.contains_key(project_id) {
return self.projects.get_mut(project_id);
}
if project_id.len() >= 4 {
let matching_id = self
.projects
.keys()
.find(|id| id.starts_with(project_id))
.cloned();
if let Some(id) = matching_id {
let count = self
.projects
.keys()
.filter(|k| k.starts_with(project_id))
.count();
if count == 1 {
return self.projects.get_mut(&id);
}
}
}
None
}
pub fn get_by_path(&self, path: &Path) -> Option<&ProjectMeta> {
if let Ok(canonical) = path.canonicalize() {
if let Some(id) = self.by_path.get(&canonical) {
return self.projects.get(id);
}
}
self.by_path.get(path).and_then(|id| self.projects.get(id))
}
pub fn contains_path(&self, path: &Path) -> bool {
if let Ok(canonical) = path.canonicalize() {
if self.by_path.contains_key(&canonical) {
return true;
}
}
self.by_path.contains_key(path)
}
pub fn list(&self) -> Vec<&ProjectMeta> {
let mut projects: Vec<_> = self.projects.values().collect();
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
projects
}
pub fn list_by_import_date(&self) -> Vec<&ProjectMeta> {
let mut projects: Vec<_> = self.projects.values().collect();
projects.sort_by(|a, b| b.imported_at.cmp(&a.imported_at));
projects
}
pub fn search_by_name(&self, pattern: &str) -> Vec<&ProjectMeta> {
let pattern_lower = pattern.to_lowercase();
self.projects
.values()
.filter(|p| p.name.to_lowercase().contains(&pattern_lower))
.collect()
}
pub fn search_by_tags(&self, tags: &[String]) -> Vec<&ProjectMeta> {
self.projects
.values()
.filter(|p| tags.iter().any(|t| p.tags.contains(t)))
.collect()
}
pub fn count(&self) -> usize {
self.projects.len()
}
pub fn rebuild_path_index(&mut self) {
self.by_path.clear();
for (project_id, meta) in &self.projects {
self.by_path.insert(meta.path.clone(), project_id.clone());
}
}
pub fn cleanup_dead_servers(&mut self) -> usize {
let mut cleaned = 0;
for meta in self.projects.values_mut() {
if meta.cleanup_dead_server() {
cleaned += 1;
}
}
cleaned
}
pub fn total_lines(&self) -> usize {
self.projects.values().map(|p| p.total_lines).sum()
}
pub fn total_files(&self) -> usize {
self.projects.values().map(|p| p.file_count).sum()
}
}
impl<'de> serde::de::Deserialize<'de> for ProjectIndex {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
#[derive(Deserialize)]
struct IndexData {
projects: HashMap<String, ProjectMeta>,
#[serde(default = "default_version")]
version: u32,
}
let data = IndexData::deserialize(deserializer)?;
let mut index = ProjectIndex {
projects: data.projects,
by_path: HashMap::new(),
version: data.version,
};
index.rebuild_path_index();
Ok(index)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_meta(id: &str, name: &str, path: &str) -> ProjectMeta {
ProjectMeta {
project_id: id.to_string(),
name: name.to_string(),
path: PathBuf::from(path),
imported_at: "2024-01-01T10:00:00Z".to_string(),
last_accessed: "2024-01-01T10:00:00Z".to_string(),
file_count: 10,
total_lines: 500,
description: None,
tags: Vec::new(),
has_config: false,
socket: Some(ProjectMeta::generate_socket_path(id)),
server_pid: None,
server_options: ProjectServerOptions::default(),
}
}
#[test]
fn test_add_and_get() {
let mut index = ProjectIndex::new();
let meta = create_test_meta("p1", "MyProject", "/projects/my-project");
index.add(meta);
assert!(index.get("p1").is_some());
assert!(index.get("p2").is_none());
}
#[test]
fn test_get_by_path() {
let mut index = ProjectIndex::new();
index.add(create_test_meta("p1", "MyProject", "/projects/my-project"));
assert!(index
.get_by_path(Path::new("/projects/my-project"))
.is_some());
assert!(index.get_by_path(Path::new("/other/path")).is_none());
}
#[test]
fn test_remove() {
let mut index = ProjectIndex::new();
index.add(create_test_meta("p1", "MyProject", "/projects/my-project"));
let removed = index.remove("p1");
assert!(removed.is_some());
assert!(index.get("p1").is_none());
assert!(!index.contains_path(Path::new("/projects/my-project")));
}
#[test]
fn test_search_by_name() {
let mut index = ProjectIndex::new();
index.add(create_test_meta("p1", "TodoApp", "/projects/todo"));
index.add(create_test_meta("p2", "WebServer", "/projects/web"));
index.add(create_test_meta("p3", "TodoBackend", "/projects/todo-be"));
let results = index.search_by_name("todo");
assert_eq!(results.len(), 2);
}
#[test]
fn test_serialization_roundtrip() {
let mut index = ProjectIndex::new();
index.add(create_test_meta("p1", "Project1", "/path/1"));
index.add(create_test_meta("p2", "Project2", "/path/2"));
let json = serde_json::to_string(&index).unwrap();
let restored: ProjectIndex = serde_json::from_str(&json).unwrap();
assert_eq!(restored.count(), 2);
assert!(restored.get("p1").is_some());
assert!(restored.get_by_path(Path::new("/path/2")).is_some());
}
}