tempo_cli/services/
project_service.rs1use 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
15pub struct ProjectService;
17
18impl ProjectService {
19 pub async fn create_project(
21 name: Option<String>,
22 path: Option<PathBuf>,
23 description: Option<String>,
24 ) -> Result<Project> {
25 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 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 let mut project = Project::new(project_name, canonical_path.clone());
54 project = project.with_description(validated_description);
55
56 let git_hash = get_git_hash(&canonical_path);
58 project = project.with_git_hash(git_hash);
59
60 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 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 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 ProjectQueries::create(&db.connection, &project_clone)
89 }).await??;
90
91 project.id = Some(project_id);
92
93 Ok(project)
94 }
95
96 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 let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
106
107 Ok(projects)
108 })
109 .await?
110 }
111
112 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 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 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 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 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 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, Some(project_path.clone()),
246 None,
247 )
248 .await;
249
250 assert!(result.is_ok());
251 let project = result.unwrap();
252 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 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 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 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 assert!(is_git_repository(&git_dir));
296
297 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 fs::write(tempo_dir.join(".tempo"), "").unwrap();
318
319 assert!(has_tempo_marker(&tempo_dir));
321
322 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 let result = ProjectService::list_projects(false, None).await;
342 assert!(result.is_ok());
343
344 let result_archived = ProjectService::list_projects(true, None).await;
346 assert!(result_archived.is_ok());
347
348 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 let result = ProjectService::get_project_by_id(99999).await;
357 assert!(result.is_ok());
358 assert!(result.unwrap().is_none());
359
360 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 let result = ProjectService::update_project(
375 99999,
376 Some("New Name".to_string()),
377 Some("New Description".to_string()),
378 )
379 .await;
380 assert!(result.is_err());
382 assert!(result
383 .unwrap_err()
384 .to_string()
385 .contains("Project with ID 99999 not found"));
386
387 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 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}