tempo_cli/services/
project_service.rs

1use anyhow::{Context, Result};
2use std::path::PathBuf;
3
4use crate::db::queries::ProjectQueries;
5use crate::db::{get_database_path, Database};
6use crate::models::Project;
7use crate::utils::paths::{
8    canonicalize_path, detect_project_name, get_git_hash, has_tempo_marker, is_git_repository,
9};
10use crate::utils::validation::{
11    validate_project_description, validate_project_id, validate_project_name,
12    validate_project_path_enhanced,
13};
14
15/// Service layer for project-related business logic
16pub struct ProjectService;
17
18impl ProjectService {
19    /// Create a new project with validation and auto-detection
20    pub async fn create_project(
21        name: Option<String>,
22        path: Option<PathBuf>,
23        description: Option<String>,
24    ) -> Result<Project> {
25        // Validate inputs early
26        let validated_name = if let Some(n) = name {
27            Some(validate_project_name(&n).context("Invalid project name provided")?)
28        } else {
29            None
30        };
31
32        let validated_description = if let Some(d) = description {
33            Some(validate_project_description(&d).context("Invalid project description provided")?)
34        } else {
35            None
36        };
37
38        let project_path = if let Some(path) = path {
39            validate_project_path_enhanced(&path).context("Invalid project path provided")?
40        } else {
41            std::env::current_dir().context("Failed to get current directory")?
42        };
43
44        let canonical_path = canonicalize_path(&project_path)?;
45
46        // Auto-detect project name if not provided
47        let project_name = validated_name.unwrap_or_else(|| {
48            let detected = detect_project_name(&canonical_path);
49            validate_project_name(&detected).unwrap_or_else(|_| "project".to_string())
50        });
51
52        // Perform database operations in a blocking task
53        let mut project = Project::new(project_name, canonical_path.clone());
54        project = project.with_description(validated_description);
55
56        // Add Git metadata if available
57        let git_hash = get_git_hash(&canonical_path);
58        project = project.with_git_hash(git_hash);
59
60        // Set description based on project type
61        if project.description.is_none() {
62            let auto_description = if is_git_repository(&canonical_path) {
63                Some("Git repository".to_string())
64            } else if has_tempo_marker(&canonical_path) {
65                Some("Tempo tracked project".to_string())
66            } else {
67                None
68            };
69            project = project.with_description(auto_description);
70        }
71
72        // Database operations in blocking task
73        let canonical_path_clone = canonical_path.clone();
74        let project_clone = project.clone();
75
76        let project_id = tokio::task::spawn_blocking(move || -> Result<i64> {
77            let db = Self::get_database_sync()?;
78
79            // Check if project already exists
80            if let Some(existing) = ProjectQueries::find_by_path(&db.connection, &canonical_path_clone)? {
81                return Err(anyhow::anyhow!(
82                    "A project named '{}' already exists at this path. Use 'tempo list' to see existing projects.",
83                    existing.name
84                ));
85            }
86
87            // Save to database
88            ProjectQueries::create(&db.connection, &project_clone)
89        }).await??;
90
91        project.id = Some(project_id);
92
93        Ok(project)
94    }
95
96    /// List projects with optional filtering
97    pub async fn list_projects(
98        include_archived: bool,
99        _tag_filter: Option<String>,
100    ) -> Result<Vec<Project>> {
101        tokio::task::spawn_blocking(move || -> Result<Vec<Project>> {
102            let db = Self::get_database_sync()?;
103
104            // TODO: Add tag filtering logic when tag system is implemented
105            let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
106
107            Ok(projects)
108        })
109        .await?
110    }
111
112    /// Get a project by ID
113    pub async fn get_project_by_id(project_id: i64) -> Result<Option<Project>> {
114        let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
115
116        tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
117            let db = Self::get_database_sync()?;
118            ProjectQueries::find_by_id(&db.connection, validated_id)
119        })
120        .await?
121    }
122
123    /// Get a project by path
124    pub async fn get_project_by_path(path: &PathBuf) -> Result<Option<Project>> {
125        let canonical_path = canonicalize_path(path)?;
126        tokio::task::spawn_blocking(move || -> Result<Option<Project>> {
127            let db = Self::get_database_sync()?;
128            ProjectQueries::find_by_path(&db.connection, &canonical_path)
129        })
130        .await?
131    }
132
133    /// Update project metadata
134    pub async fn update_project(
135        project_id: i64,
136        name: Option<String>,
137        description: Option<String>,
138    ) -> Result<bool> {
139        let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
140
141        let validated_name = if let Some(n) = name {
142            Some(validate_project_name(&n).context("Invalid project name")?)
143        } else {
144            None
145        };
146
147        let validated_description = if let Some(d) = description {
148            Some(validate_project_description(&d).context("Invalid project description")?)
149        } else {
150            None
151        };
152
153        tokio::task::spawn_blocking(move || -> Result<bool> {
154            let db = Self::get_database_sync()?;
155
156            let mut updated = false;
157
158            if let Some(name) = validated_name {
159                let result = ProjectQueries::update_name(&db.connection, validated_id, name)?;
160                if !result {
161                    return Err(anyhow::anyhow!(
162                        "Project with ID {} not found",
163                        validated_id
164                    ));
165                }
166                updated = true;
167            }
168
169            if let Some(description) = validated_description {
170                let result = ProjectQueries::update_project_description(
171                    &db.connection,
172                    validated_id,
173                    Some(description),
174                )?;
175                if !result {
176                    return Err(anyhow::anyhow!(
177                        "Project with ID {} not found",
178                        validated_id
179                    ));
180                }
181                updated = true;
182            }
183
184            Ok(updated)
185        })
186        .await?
187    }
188
189    /// Archive a project
190    pub async fn archive_project(project_id: i64) -> Result<bool> {
191        let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
192
193        tokio::task::spawn_blocking(move || -> Result<bool> {
194            let db = Self::get_database_sync()?;
195            let result = ProjectQueries::update_archived(&db.connection, validated_id, true)?;
196            if !result {
197                return Err(anyhow::anyhow!(
198                    "Project with ID {} not found",
199                    validated_id
200                ));
201            }
202            Ok(result)
203        })
204        .await?
205    }
206
207    /// Unarchive a project
208    pub async fn unarchive_project(project_id: i64) -> Result<bool> {
209        let validated_id = validate_project_id(project_id).context("Invalid project ID")?;
210
211        tokio::task::spawn_blocking(move || -> Result<bool> {
212            let db = Self::get_database_sync()?;
213            let result = ProjectQueries::update_archived(&db.connection, validated_id, false)?;
214            if !result {
215                return Err(anyhow::anyhow!(
216                    "Project with ID {} not found",
217                    validated_id
218                ));
219            }
220            Ok(result)
221        })
222        .await?
223    }
224
225    // Private helper to get database connection (synchronous for use in blocking tasks)
226    fn get_database_sync() -> Result<Database> {
227        let db_path = get_database_path()?;
228        Database::new(&db_path)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::fs;
236    use tempfile::tempdir;
237
238    #[tokio::test]
239    async fn test_create_project_with_auto_detection() {
240        let temp_dir = tempdir().unwrap();
241        let project_path = temp_dir.path().to_path_buf();
242
243        let result = ProjectService::create_project(
244            None, // Auto-detect name
245            Some(project_path.clone()),
246            None,
247        )
248        .await;
249
250        assert!(result.is_ok());
251        let project = result.unwrap();
252        // Compare canonicalized paths since temp paths can be symlinked
253        let expected_canonical = canonicalize_path(&project_path).unwrap();
254        assert_eq!(project.path, expected_canonical);
255        assert!(!project.name.is_empty());
256    }
257
258    #[tokio::test]
259    async fn test_path_validation() {
260        // Test invalid path
261        let invalid_path = PathBuf::from("/nonexistent/path/that/should/not/exist");
262        let result = ProjectService::create_project(
263            Some("Test Project".to_string()),
264            Some(invalid_path),
265            None,
266        )
267        .await;
268        assert!(result.is_err());
269    }
270
271    #[tokio::test]
272    async fn test_project_name_detection() {
273        let temp_dir = tempdir().unwrap();
274
275        // Test with specific directory name
276        let project_dir = temp_dir.path().join("my-awesome-project");
277        fs::create_dir_all(&project_dir).unwrap();
278
279        let detected_name = detect_project_name(&project_dir);
280        assert_eq!(detected_name, "my-awesome-project");
281    }
282
283    #[tokio::test]
284    async fn test_git_repository_detection() {
285        let temp_dir = tempdir().unwrap();
286        let git_dir = temp_dir.path().join("git_project");
287        fs::create_dir_all(&git_dir).unwrap();
288
289        // Create .git directory
290        let git_meta = git_dir.join(".git");
291        fs::create_dir_all(&git_meta).unwrap();
292        fs::write(git_meta.join("HEAD"), "ref: refs/heads/main\n").unwrap();
293
294        // Test Git repository detection
295        assert!(is_git_repository(&git_dir));
296
297        // Test project creation with Git repo
298        let result = ProjectService::create_project(
299            Some("Git Test".to_string()),
300            Some(git_dir.clone()),
301            None,
302        )
303        .await;
304
305        if let Ok(project) = result {
306            assert_eq!(project.description, Some("Git repository".to_string()));
307        }
308    }
309
310    #[tokio::test]
311    async fn test_tempo_marker_detection() {
312        let temp_dir = tempdir().unwrap();
313        let tempo_dir = temp_dir.path().join("tempo_project");
314        fs::create_dir_all(&tempo_dir).unwrap();
315
316        // Create .tempo marker
317        fs::write(tempo_dir.join(".tempo"), "").unwrap();
318
319        // Test Tempo marker detection
320        assert!(has_tempo_marker(&tempo_dir));
321
322        // Test project creation with Tempo marker
323        let result = ProjectService::create_project(
324            Some("Tempo Test".to_string()),
325            Some(tempo_dir.clone()),
326            None,
327        )
328        .await;
329
330        if let Ok(project) = result {
331            assert_eq!(
332                project.description,
333                Some("Tempo tracked project".to_string())
334            );
335        }
336    }
337
338    #[tokio::test]
339    async fn test_project_filtering() {
340        // Test list projects with no tag filter
341        let result = ProjectService::list_projects(false, None).await;
342        assert!(result.is_ok());
343
344        // Test list projects including archived
345        let result_archived = ProjectService::list_projects(true, None).await;
346        assert!(result_archived.is_ok());
347
348        // Test tag filtering (placeholder for future implementation)
349        let result_filtered = ProjectService::list_projects(false, Some("work".to_string())).await;
350        assert!(result_filtered.is_ok());
351    }
352
353    #[tokio::test]
354    async fn test_project_retrieval_edge_cases() {
355        // Test get project by non-existent ID
356        let result = ProjectService::get_project_by_id(99999).await;
357        assert!(result.is_ok());
358        assert!(result.unwrap().is_none());
359
360        // Test get project by non-existent path
361        // Use a temp directory that exists but has no project
362        let temp_dir = tempdir().unwrap();
363        let nonexistent_project_path = temp_dir.path().join("nonexistent_project");
364        std::fs::create_dir_all(&nonexistent_project_path).unwrap();
365
366        let result = ProjectService::get_project_by_path(&nonexistent_project_path).await;
367        assert!(result.is_ok());
368        assert!(result.unwrap().is_none());
369    }
370
371    #[tokio::test]
372    async fn test_project_update_operations() {
373        // Test updating non-existent project (should now fail with better error)
374        let result = ProjectService::update_project(
375            99999,
376            Some("New Name".to_string()),
377            Some("New Description".to_string()),
378        )
379        .await;
380        // Should fail with "Project not found" error
381        assert!(result.is_err());
382        assert!(result
383            .unwrap_err()
384            .to_string()
385            .contains("Project with ID 99999 not found"));
386
387        // Test archiving non-existent project
388        let archive_result = ProjectService::archive_project(99999).await;
389        assert!(archive_result.is_err());
390        assert!(archive_result
391            .unwrap_err()
392            .to_string()
393            .contains("Project with ID 99999 not found"));
394
395        // Test unarchiving non-existent project
396        let unarchive_result = ProjectService::unarchive_project(99999).await;
397        assert!(unarchive_result.is_err());
398        assert!(unarchive_result
399            .unwrap_err()
400            .to_string()
401            .contains("Project with ID 99999 not found"));
402    }
403}