use anyhow::{Context, Result};
use std::path::PathBuf;
use crate::db::queries::ProjectQueries;
use crate::db::{get_database_path, Database};
use crate::models::Project;
use crate::utils::paths::{
canonicalize_path, detect_project_name, get_git_hash, has_tempo_marker, is_git_repository,
};
use crate::utils::validation::{
validate_project_description, validate_project_id, validate_project_name,
validate_project_path_enhanced,
};
pub struct ProjectService;
impl ProjectService {
pub async fn create_project(
name: Option<String>,
path: Option<PathBuf>,
description: Option<String>,
) -> Result<Project> {
let validated_name = if let Some(n) = name {
Some(validate_project_name(&n).context("Invalid project name provided")?)
} else {
None
};
let validated_description = if let Some(d) = description {
Some(validate_project_description(&d).context("Invalid project description provided")?)
} else {
None
};
let project_path = if let Some(path) = path {
validate_project_path_enhanced(&path).context("Invalid project path provided")?
} else {
std::env::current_dir().context("Failed to get current directory")?
};
let canonical_path = canonicalize_path(&project_path)?;
let project_name = validated_name.unwrap_or_else(|| {
let detected = detect_project_name(&canonical_path);
validate_project_name(&detected).unwrap_or_else(|_| "project".to_string())
});
let mut project = Project::new(project_name, canonical_path.clone());
project = project.with_description(validated_description);
let git_hash = get_git_hash(&canonical_path);
project = project.with_git_hash(git_hash);
if project.description.is_none() {
let auto_description = if is_git_repository(&canonical_path) {
Some("Git repository".to_string())
} else if has_tempo_marker(&canonical_path) {
Some("Tempo tracked project".to_string())
} else {
None
};
project = project.with_description(auto_description);
}
let canonical_path_clone = canonical_path.clone();
let project_clone = project.clone();
let project_id = tokio::task::spawn_blocking(move || -> Result<i64> {
let db = Self::get_database_sync()?;
if let Some(existing) = ProjectQueries::find_by_path(&db.connection, &canonical_path_clone)? {
return Err(anyhow::anyhow!(
"A project named '{}' already exists at this path. Use 'tempo list' to see existing projects.",
existing.name
));
}
ProjectQueries::create(&db.connection, &project_clone)
}).await??;
project.id = Some(project_id);
Ok(project)
}
pub async fn list_projects(
include_archived: bool,
_tag_filter: Option<String>,
) -> Result<Vec<Project>> {
tokio::task::spawn_blocking(move || -> Result<Vec<Project>> {
let db = Self::get_database_sync()?;
let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
Ok(projects)
})
.await?
}
pub async fn get_project_by_id(project_id: i64) -> Result<Option<Project>> {
let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
let db = Self::get_database_sync()?;
ProjectQueries::find_by_id(&db.connection, validated_id)
})
.await?
}
pub async fn get_project_by_path(path: &PathBuf) -> Result<Option<Project>> {
let canonical_path = canonicalize_path(path)?;
tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
let db = Self::get_database_sync()?;
ProjectQueries::find_by_path(&db.connection, &canonical_path)
})
.await?
}
pub async fn update_project(
project_id: i64,
name: Option<String>,
description: Option<String>,
) -> Result<bool> {
let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
let validated_name = if let Some(n) = name {
Some(validate_project_name(&n).context("Invalid project name")?)
} else {
None
};
let validated_description = if let Some(d) = description {
Some(validate_project_description(&d).context("Invalid project description")?)
} else {
None
};
tokio::task::spawn_blocking(move || -> Result<bool> {
let db = Self::get_database_sync()?;
let mut updated = false;
if let Some(name) = validated_name {
let result = ProjectQueries::update_name(&db.connection, validated_id, name)?;
if !result {
return Err(anyhow::anyhow!(
"Project with ID {} not found",
validated_id
));
}
updated = true;
}
if let Some(description) = validated_description {
let result = ProjectQueries::update_project_description(
&db.connection,
validated_id,
Some(description),
)?;
if !result {
return Err(anyhow::anyhow!(
"Project with ID {} not found",
validated_id
));
}
updated = true;
}
Ok(updated)
})
.await?
}
pub async fn archive_project(project_id: i64) -> Result<bool> {
let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
tokio::task::spawn_blocking(move || -> Result<bool> {
let db = Self::get_database_sync()?;
let result = ProjectQueries::update_archived(&db.connection, validated_id, true)?;
if !result {
return Err(anyhow::anyhow!(
"Project with ID {} not found",
validated_id
));
}
Ok(result)
})
.await?
}
pub async fn unarchive_project(project_id: i64) -> Result<bool> {
let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
tokio::task::spawn_blocking(move || -> Result<bool> {
let db = Self::get_database_sync()?;
let result = ProjectQueries::update_archived(&db.connection, validated_id, false)?;
if !result {
return Err(anyhow::anyhow!(
"Project with ID {} not found",
validated_id
));
}
Ok(result)
})
.await?
}
fn get_database_sync() -> Result<Database> {
let db_path = get_database_path()?;
Database::new(&db_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn test_create_project_with_auto_detection() {
let temp_dir = tempdir().unwrap();
let project_path = temp_dir.path().to_path_buf();
let result = ProjectService::create_project(
None, Some(project_path.clone()),
None,
)
.await;
assert!(result.is_ok());
let project = result.unwrap();
let expected_canonical = canonicalize_path(&project_path).unwrap();
assert_eq!(project.path, expected_canonical);
assert!(!project.name.is_empty());
}
#[tokio::test]
async fn test_path_validation() {
let invalid_path = PathBuf::from("/nonexistent/path/that/should/not/exist");
let result = ProjectService::create_project(
Some("Test Project".to_string()),
Some(invalid_path),
None,
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_project_name_detection() {
let temp_dir = tempdir().unwrap();
let project_dir = temp_dir.path().join("my-awesome-project");
fs::create_dir_all(&project_dir).unwrap();
let detected_name = detect_project_name(&project_dir);
assert_eq!(detected_name, "my-awesome-project");
}
#[tokio::test]
async fn test_git_repository_detection() {
let temp_dir = tempdir().unwrap();
let git_dir = temp_dir.path().join("git_project");
fs::create_dir_all(&git_dir).unwrap();
let git_meta = git_dir.join(".git");
fs::create_dir_all(&git_meta).unwrap();
fs::write(git_meta.join("HEAD"), "ref: refs/heads/main\n").unwrap();
assert!(is_git_repository(&git_dir));
let result = ProjectService::create_project(
Some("Git Test".to_string()),
Some(git_dir.clone()),
None,
)
.await;
if let Ok(project) = result {
assert_eq!(project.description, Some("Git repository".to_string()));
}
}
#[tokio::test]
async fn test_tempo_marker_detection() {
let temp_dir = tempdir().unwrap();
let tempo_dir = temp_dir.path().join("tempo_project");
fs::create_dir_all(&tempo_dir).unwrap();
fs::write(tempo_dir.join(".tempo"), "").unwrap();
assert!(has_tempo_marker(&tempo_dir));
let result = ProjectService::create_project(
Some("Tempo Test".to_string()),
Some(tempo_dir.clone()),
None,
)
.await;
if let Ok(project) = result {
assert_eq!(
project.description,
Some("Tempo tracked project".to_string())
);
}
}
#[tokio::test]
async fn test_project_filtering() {
let result = ProjectService::list_projects(false, None).await;
assert!(result.is_ok());
let result_archived = ProjectService::list_projects(true, None).await;
assert!(result_archived.is_ok());
let result_filtered = ProjectService::list_projects(false, Some("work".to_string())).await;
assert!(result_filtered.is_ok());
}
#[tokio::test]
async fn test_project_retrieval_edge_cases() {
let result = ProjectService::get_project_by_id(99999).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
let temp_dir = tempdir().unwrap();
let nonexistent_project_path = temp_dir.path().join("nonexistent_project");
std::fs::create_dir_all(&nonexistent_project_path).unwrap();
let result = ProjectService::get_project_by_path(&nonexistent_project_path).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_project_update_operations() {
let result = ProjectService::update_project(
99999,
Some("New Name".to_string()),
Some("New Description".to_string()),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Project with ID 99999 not found"));
let archive_result = ProjectService::archive_project(99999).await;
assert!(archive_result.is_err());
assert!(archive_result
.unwrap_err()
.to_string()
.contains("Project with ID 99999 not found"));
let unarchive_result = ProjectService::unarchive_project(99999).await;
assert!(unarchive_result.is_err());
assert!(unarchive_result
.unwrap_err()
.to_string()
.contains("Project with ID 99999 not found"));
}
}