pub mod conversation_buffer;
pub mod detection;
#[cfg(feature = "sqlite-memory")]
pub mod manager;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub use conversation_buffer::{ConversationBuffer, ConversationTurn};
pub use detection::{detect_project, extract_path, find_by_id, find_by_name, DetectionResult};
#[cfg(feature = "sqlite-memory")]
pub use manager::{ProjectManager, ProjectManagerError};
pub type ProjectId = Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectSource {
Manual,
AutoDetected,
}
impl std::fmt::Display for ProjectSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectSource::Manual => write!(f, "manual"),
ProjectSource::AutoDetected => write!(f, "auto_detected"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: ProjectId,
pub name: String,
pub description: String,
pub paths: Vec<PathBuf>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_emoji")]
pub emoji: String,
pub source: ProjectSource,
#[serde(default = "default_true")]
pub memory_visible: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_active_at: DateTime<Utc>,
}
fn default_emoji() -> String {
"📦".to_string()
}
fn default_true() -> bool {
true
}
impl Project {
pub fn new(name: impl Into<String>, source: ProjectSource) -> Self {
let now = Utc::now();
Self {
id: ProjectId::new_v4(),
name: name.into(),
description: String::new(),
paths: Vec::new(),
tags: Vec::new(),
emoji: default_emoji(),
source,
memory_visible: true,
created_at: now,
updated_at: now,
last_active_at: now,
}
}
pub fn from_path(path: &Path) -> Self {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut project = Self::new(&name, ProjectSource::AutoDetected);
project.paths.push(path.to_path_buf());
project
}
pub fn touch(&mut self) {
self.last_active_at = Utc::now();
self.updated_at = Utc::now();
}
pub fn add_path(&mut self, path: PathBuf) {
if !self.paths.contains(&path) {
self.paths.push(path.clone());
self.updated_at = Utc::now();
}
}
pub fn remove_path(&mut self, path: &PathBuf) -> bool {
if let Some(pos) = self.paths.iter().position(|p| p == path) {
self.paths.remove(pos);
self.updated_at = Utc::now();
true
} else {
false
}
}
pub fn add_tag(&mut self, tag: impl Into<String>) {
let tag = tag.into();
if !self.tags.contains(&tag) {
self.tags.push(tag);
self.updated_at = Utc::now();
}
}
pub fn has_paths(&self) -> bool {
!self.paths.is_empty()
}
pub fn primary_path(&self) -> Option<&PathBuf> {
self.paths.first()
}
pub fn tag(&self) -> String {
format!("[{} {}]", self.emoji, self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_new() {
let p = Project::new("oxios", ProjectSource::Manual);
assert_eq!(p.name, "oxios");
assert_eq!(p.source, ProjectSource::Manual);
assert!(p.paths.is_empty());
assert_eq!(p.emoji, "📦");
}
#[test]
fn test_project_from_path() {
let path = PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios");
let p = Project::from_path(&path);
assert_eq!(p.name, "oxios");
assert_eq!(p.source, ProjectSource::AutoDetected);
assert_eq!(p.paths, vec![path]);
}
#[test]
fn test_project_add_path() {
let mut p = Project::new("oxios", ProjectSource::Manual);
assert!(!p.has_paths());
p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
assert!(p.has_paths());
assert_eq!(
p.primary_path(),
Some(&PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"))
);
p.add_path(PathBuf::from("/Volumes/MERCURY/PROJECTS/oxios"));
assert_eq!(p.paths.len(), 1);
}
#[test]
fn test_project_tag() {
let mut p = Project::new("oxios", ProjectSource::Manual);
p.emoji = "🔧".to_string();
assert_eq!(p.tag(), "[🔧 oxios]");
}
}