tempo_cli/services/
project_service.rs1use anyhow::{Result, Context};
2use std::path::PathBuf;
3
4use crate::db::{Database, get_database_path};
5use crate::db::queries::ProjectQueries;
6use crate::models::Project;
7use crate::utils::paths::{
8 canonicalize_path, detect_project_name, get_git_hash,
9 is_git_repository, has_tempo_marker
10};
11use crate::utils::validation::{
12 validate_project_name, validate_project_description, validate_project_id,
13 validate_project_path_enhanced
14};
15
16#[cfg(test)]
17use crate::test_utils::{TestContext, with_test_db_async};
18
19pub struct ProjectService;
21
22impl ProjectService {
23 pub async fn create_project(
25 name: Option<String>,
26 path: Option<PathBuf>,
27 description: Option<String>
28 ) -> Result<Project> {
29 let validated_name = if let Some(n) = name {
31 Some(validate_project_name(&n)
32 .context("Invalid project name provided")?)
33 } else {
34 None
35 };
36
37 let validated_description = if let Some(d) = description {
38 Some(validate_project_description(&d)
39 .context("Invalid project description provided")?)
40 } else {
41 None
42 };
43
44 let project_path = if let Some(path) = path {
45 validate_project_path_enhanced(&path)
46 .context("Invalid project path provided")?
47 } else {
48 std::env::current_dir()
49 .context("Failed to get current directory")?
50 };
51
52 let canonical_path = canonicalize_path(&project_path)?;
53
54 let project_name = validated_name.unwrap_or_else(|| {
56 let detected = detect_project_name(&canonical_path);
57 validate_project_name(&detected).unwrap_or_else(|_| "project".to_string())
58 });
59
60 let mut project = Project::new(project_name, canonical_path.clone());
62 project = project.with_description(validated_description);
63
64 let git_hash = get_git_hash(&canonical_path);
66 project = project.with_git_hash(git_hash);
67
68 if project.description.is_none() {
70 let auto_description = if is_git_repository(&canonical_path) {
71 Some("Git repository".to_string())
72 } else if has_tempo_marker(&canonical_path) {
73 Some("Tempo tracked project".to_string())
74 } else {
75 None
76 };
77 project = project.with_description(auto_description);
78 }
79
80 let canonical_path_clone = canonical_path.clone();
82 let project_clone = project.clone();
83
84 let project_id = tokio::task::spawn_blocking(move || -> Result<i64> {
85 let db = Self::get_database_sync()?;
86
87 if let Some(existing) = ProjectQueries::find_by_path(&db.connection, &canonical_path_clone)? {
89 return Err(anyhow::anyhow!(
90 "A project named '{}' already exists at this path. Use 'tempo list' to see existing projects.",
91 existing.name
92 ));
93 }
94
95 ProjectQueries::create(&db.connection, &project_clone)
97 }).await??;
98
99 project.id = Some(project_id);
100
101 Ok(project)
102 }
103
104 pub async fn list_projects(include_archived: bool, _tag_filter: Option<String>) -> Result<Vec<Project>> {
106 tokio::task::spawn_blocking(move || -> Result<Vec<Project>> {
107 let db = Self::get_database_sync()?;
108
109 let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
111
112 Ok(projects)
113 }).await?
114 }
115
116 pub async fn get_project_by_id(project_id: i64) -> Result<Option<Project>> {
118 let validated_id = validate_project_id(project_id)
119 .context("Invalid project ID")?;
120
121 tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
122 let db = Self::get_database_sync()?;
123 ProjectQueries::find_by_id(&db.connection, validated_id)
124 }).await?
125 }
126
127 pub async fn get_project_by_path(path: &PathBuf) -> Result<Option<Project>> {
129 let canonical_path = canonicalize_path(path)?;
130 tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
131 let db = Self::get_database_sync()?;
132 ProjectQueries::find_by_path(&db.connection, &canonical_path)
133 }).await?
134 }
135
136 pub async fn update_project(project_id: i64, name: Option<String>, description: Option<String>) -> Result<bool> {
138 let validated_id = validate_project_id(project_id)
139 .context("Invalid project ID")?;
140
141 let validated_name = if let Some(n) = name {
142 Some(validate_project_name(&n)
143 .context("Invalid project name")?)
144 } else {
145 None
146 };
147
148 let validated_description = if let Some(d) = description {
149 Some(validate_project_description(&d)
150 .context("Invalid project description")?)
151 } else {
152 None
153 };
154
155 tokio::task::spawn_blocking(move || -> Result<bool> {
156 let db = Self::get_database_sync()?;
157
158 let mut updated = false;
159
160 if let Some(name) = validated_name {
161 let result = ProjectQueries::update_name(&db.connection, validated_id, name)?;
162 if !result {
163 return Err(anyhow::anyhow!("Project with ID {} not found", validated_id));
164 }
165 updated = true;
166 }
167
168 if let Some(description) = validated_description {
169 let result = ProjectQueries::update_project_description(&db.connection, validated_id, Some(description))?;
170 if !result {
171 return Err(anyhow::anyhow!("Project with ID {} not found", validated_id));
172 }
173 updated = true;
174 }
175
176 Ok(updated)
177 }).await?
178 }
179
180 pub async fn archive_project(project_id: i64) -> Result<bool> {
182 let validated_id = validate_project_id(project_id)
183 .context("Invalid project ID")?;
184
185 tokio::task::spawn_blocking(move || -> Result<bool> {
186 let db = Self::get_database_sync()?;
187 let result = ProjectQueries::update_archived(&db.connection, validated_id, true)?;
188 if !result {
189 return Err(anyhow::anyhow!("Project with ID {} not found", validated_id));
190 }
191 Ok(result)
192 }).await?
193 }
194
195 pub async fn unarchive_project(project_id: i64) -> Result<bool> {
197 let validated_id = validate_project_id(project_id)
198 .context("Invalid project ID")?;
199
200 tokio::task::spawn_blocking(move || -> Result<bool> {
201 let db = Self::get_database_sync()?;
202 let result = ProjectQueries::update_archived(&db.connection, validated_id, false)?;
203 if !result {
204 return Err(anyhow::anyhow!("Project with ID {} not found", validated_id));
205 }
206 Ok(result)
207 }).await?
208 }
209
210 fn get_database_sync() -> Result<Database> {
212 let db_path = get_database_path()?;
213 Database::new(&db_path)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::test_utils::with_test_db;
221 use tempfile::tempdir;
222 use std::fs;
223
224 #[tokio::test]
225 async fn test_create_project_with_auto_detection() {
226 let temp_dir = tempdir().unwrap();
227 let project_path = temp_dir.path().to_path_buf();
228
229 let result = ProjectService::create_project(
230 None, Some(project_path.clone()),
232 None
233 ).await;
234
235 assert!(result.is_ok());
236 let project = result.unwrap();
237 let expected_canonical = canonicalize_path(&project_path).unwrap();
239 assert_eq!(project.path, expected_canonical);
240 assert!(!project.name.is_empty());
241 }
242
243 #[tokio::test]
244 async fn test_path_validation() {
245 let invalid_path = PathBuf::from("/nonexistent/path/that/should/not/exist");
247 let result = ProjectService::create_project(
248 Some("Test Project".to_string()),
249 Some(invalid_path),
250 None
251 ).await;
252 assert!(result.is_err());
253 }
254
255 #[tokio::test]
256 async fn test_project_name_detection() {
257 let temp_dir = tempdir().unwrap();
258
259 let project_dir = temp_dir.path().join("my-awesome-project");
261 fs::create_dir_all(&project_dir).unwrap();
262
263 let detected_name = detect_project_name(&project_dir);
264 assert_eq!(detected_name, "my-awesome-project");
265 }
266
267 #[tokio::test]
268 async fn test_git_repository_detection() {
269 let temp_dir = tempdir().unwrap();
270 let git_dir = temp_dir.path().join("git_project");
271 fs::create_dir_all(&git_dir).unwrap();
272
273 let git_meta = git_dir.join(".git");
275 fs::create_dir_all(&git_meta).unwrap();
276 fs::write(git_meta.join("HEAD"), "ref: refs/heads/main\n").unwrap();
277
278 assert!(is_git_repository(&git_dir));
280
281 let result = ProjectService::create_project(
283 Some("Git Test".to_string()),
284 Some(git_dir.clone()),
285 None
286 ).await;
287
288 if let Ok(project) = result {
289 assert_eq!(project.description, Some("Git repository".to_string()));
290 }
291 }
292
293 #[tokio::test]
294 async fn test_tempo_marker_detection() {
295 let temp_dir = tempdir().unwrap();
296 let tempo_dir = temp_dir.path().join("tempo_project");
297 fs::create_dir_all(&tempo_dir).unwrap();
298
299 fs::write(tempo_dir.join(".tempo"), "").unwrap();
301
302 assert!(has_tempo_marker(&tempo_dir));
304
305 let result = ProjectService::create_project(
307 Some("Tempo Test".to_string()),
308 Some(tempo_dir.clone()),
309 None
310 ).await;
311
312 if let Ok(project) = result {
313 assert_eq!(project.description, Some("Tempo tracked project".to_string()));
314 }
315 }
316
317 #[tokio::test]
318 async fn test_project_filtering() {
319 let result = ProjectService::list_projects(false, None).await;
321 assert!(result.is_ok());
322
323 let result_archived = ProjectService::list_projects(true, None).await;
325 assert!(result_archived.is_ok());
326
327 let result_filtered = ProjectService::list_projects(false, Some("work".to_string())).await;
329 assert!(result_filtered.is_ok());
330 }
331
332 #[tokio::test]
333 async fn test_project_retrieval_edge_cases() {
334 let result = ProjectService::get_project_by_id(99999).await;
336 assert!(result.is_ok());
337 assert!(result.unwrap().is_none());
338
339 let temp_dir = tempdir().unwrap();
342 let nonexistent_project_path = temp_dir.path().join("nonexistent_project");
343 std::fs::create_dir_all(&nonexistent_project_path).unwrap();
344
345 let result = ProjectService::get_project_by_path(&nonexistent_project_path).await;
346 assert!(result.is_ok());
347 assert!(result.unwrap().is_none());
348 }
349
350 #[tokio::test]
351 async fn test_project_update_operations() {
352 let result = ProjectService::update_project(
354 99999,
355 Some("New Name".to_string()),
356 Some("New Description".to_string())
357 ).await;
358 assert!(result.is_err());
360 assert!(result.unwrap_err().to_string().contains("Project with ID 99999 not found"));
361
362 let archive_result = ProjectService::archive_project(99999).await;
364 assert!(archive_result.is_err());
365 assert!(archive_result.unwrap_err().to_string().contains("Project with ID 99999 not found"));
366
367 let unarchive_result = ProjectService::unarchive_project(99999).await;
369 assert!(unarchive_result.is_err());
370 assert!(unarchive_result.unwrap_err().to_string().contains("Project with ID 99999 not found"));
371 }
372}