tempo_cli/services/
project_service.rs

1use 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
19/// Service layer for project-related business logic
20pub struct ProjectService;
21
22impl ProjectService {
23    /// Create a new project with validation and auto-detection
24    pub async fn create_project(
25        name: Option<String>, 
26        path: Option<PathBuf>, 
27        description: Option<String>
28    ) -> Result<Project> {
29        // Validate inputs early
30        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        // Auto-detect project name if not provided
55        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        // Perform database operations in a blocking task
61        let mut project = Project::new(project_name, canonical_path.clone());
62        project = project.with_description(validated_description);
63        
64        // Add Git metadata if available
65        let git_hash = get_git_hash(&canonical_path);
66        project = project.with_git_hash(git_hash);
67
68        // Set description based on project type
69        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        // Database operations in blocking task
81        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            // Check if project already exists
88            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            // Save to database
96            ProjectQueries::create(&db.connection, &project_clone)
97        }).await??;
98        
99        project.id = Some(project_id);
100
101        Ok(project)
102    }
103
104    /// List projects with optional filtering
105    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            // TODO: Add tag filtering logic when tag system is implemented
110            let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
111            
112            Ok(projects)
113        }).await?
114    }
115
116    /// Get a project by ID
117    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    /// Get a project by path
128    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    /// Update project metadata
137    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    /// Archive a project
181    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    /// Unarchive a project
196    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    // Private helper to get database connection (synchronous for use in blocking tasks)
211    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, // Auto-detect name
231            Some(project_path.clone()),
232            None
233        ).await;
234
235        assert!(result.is_ok());
236        let project = result.unwrap();
237        // Compare canonicalized paths since temp paths can be symlinked
238        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        // Test invalid path
246        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        // Test with specific directory name
260        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        // Create .git directory
274        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        // Test Git repository detection
279        assert!(is_git_repository(&git_dir));
280        
281        // Test project creation with Git repo
282        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        // Create .tempo marker
300        fs::write(tempo_dir.join(".tempo"), "").unwrap();
301        
302        // Test Tempo marker detection
303        assert!(has_tempo_marker(&tempo_dir));
304        
305        // Test project creation with Tempo marker
306        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        // Test list projects with no tag filter
320        let result = ProjectService::list_projects(false, None).await;
321        assert!(result.is_ok());
322        
323        // Test list projects including archived
324        let result_archived = ProjectService::list_projects(true, None).await;
325        assert!(result_archived.is_ok());
326        
327        // Test tag filtering (placeholder for future implementation)
328        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        // Test get project by non-existent ID
335        let result = ProjectService::get_project_by_id(99999).await;
336        assert!(result.is_ok());
337        assert!(result.unwrap().is_none());
338        
339        // Test get project by non-existent path
340        // Use a temp directory that exists but has no project
341        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        // Test updating non-existent project (should now fail with better error)
353        let result = ProjectService::update_project(
354            99999,
355            Some("New Name".to_string()),
356            Some("New Description".to_string())
357        ).await;
358        // Should fail with "Project not found" error
359        assert!(result.is_err());
360        assert!(result.unwrap_err().to_string().contains("Project with ID 99999 not found"));
361        
362        // Test archiving non-existent project
363        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        // Test unarchiving non-existent project
368        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}