track/use_cases/
todo_workspace.rs1use crate::db::Database;
2use crate::models::TaskRepo;
3use crate::services::{RepoService, TaskService, TodoService, WorktreeService};
4use crate::utils::{Result, TrackError};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct TodoWorkspaceRequest {
10 pub recreate: bool,
11 pub force: bool,
12 pub all_repos: bool,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TodoWorkspaceOutcome {
18 pub paths: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22pub struct TodoWorkspaceUseCase<'a> {
24 db: &'a Database,
25}
26
27impl<'a> TodoWorkspaceUseCase<'a> {
28 pub fn new(db: &'a Database) -> Self {
29 Self { db }
30 }
31
32 pub fn execute(
33 &self,
34 task_id: i64,
35 todo_index: i64,
36 request: TodoWorkspaceRequest,
37 ) -> Result<TodoWorkspaceOutcome> {
38 let todo_service = TodoService::new(self.db);
39 let worktree_service = WorktreeService::new(self.db);
40 let repo_service = RepoService::new(self.db);
41 let task_service = TaskService::new(self.db);
42
43 let todo = todo_service.get_todo_by_index(task_id, todo_index)?;
44 let repos = repo_service.list_repos(task_id)?;
45
46 if repos.is_empty() {
47 return Err(TrackError::NoRepositoriesRegistered);
48 }
49
50 let target_repos = if request.all_repos {
51 repos
52 } else {
53 vec![resolve_current_repo(&repos)?]
54 };
55
56 let worktrees = worktree_service.list_worktrees(task_id)?;
57 let task = task_service.get_task(task_id)?;
58 let branch_name = worktree_service.get_todo_branch_name(
59 task_id,
60 task.ticket_id.as_deref(),
61 todo.task_index,
62 )?;
63
64 let mut paths = Vec::new();
65 let mut warnings = Vec::new();
66
67 for repo in target_repos {
68 let mut todo_worktrees: Vec<_> = worktrees
69 .iter()
70 .filter(|wt| {
71 wt.todo_id == Some(todo.id)
72 && wt.base_repo.as_deref() == Some(repo.repo_path.as_str())
73 })
74 .cloned()
75 .collect();
76
77 if !todo_worktrees.is_empty() {
78 if !request.recreate {
79 paths.extend(todo_worktrees.iter().map(|worktree| worktree.path.clone()));
80 continue;
81 }
82
83 if !request.force {
84 for worktree in &todo_worktrees {
85 if Path::new(&worktree.path).exists()
86 && worktree_service.has_uncommitted_changes(&worktree.path)?
87 {
88 return Err(TrackError::WorkspaceHasUncommittedChanges {
89 path: worktree.path.clone(),
90 });
91 }
92 }
93 }
94
95 for worktree in todo_worktrees.drain(..) {
96 if !worktree_service
97 .bookmark_exists_in_repo(repo.repo_path.as_str(), &worktree.branch)?
98 {
99 if Path::new(&worktree.path).exists() {
100 paths.push(worktree.path.clone());
101 }
102 if request.all_repos {
103 warnings.push(format!(
104 "Skipping recreate for {} (missing branch/bookmark).",
105 worktree.path
106 ));
107 continue;
108 }
109 return Err(TrackError::BookmarkNotFound {
110 bookmark: worktree.branch.clone(),
111 repo_path: repo.repo_path.clone(),
112 });
113 }
114
115 let recreated = worktree_service.recreate_worktree(&worktree, request.force)?;
116 paths.push(recreated.path);
117 }
118 } else {
119 if request.recreate
120 && !worktree_service
121 .bookmark_exists_in_repo(repo.repo_path.as_str(), &branch_name)?
122 {
123 if request.all_repos {
124 warnings.push(format!(
125 "Skipping create for {} (missing branch/bookmark).",
126 repo.repo_path
127 ));
128 continue;
129 }
130 return Err(TrackError::BookmarkNotFound {
131 bookmark: branch_name.clone(),
132 repo_path: repo.repo_path.clone(),
133 });
134 }
135
136 let created = match worktree_service.add_worktree(
137 task_id,
138 &repo.repo_path,
139 None,
140 task.ticket_id.as_deref(),
141 Some(todo.id),
142 false,
143 ) {
144 Ok(worktree) => worktree,
145 Err(TrackError::BookmarkExists(_)) => worktree_service.add_existing_worktree(
146 task_id,
147 &repo.repo_path,
148 &branch_name,
149 Some(todo.id),
150 false,
151 None,
152 )?,
153 Err(err) => return Err(err),
154 };
155
156 paths.push(created.path);
157 }
158 }
159
160 if paths.is_empty() {
161 return Err(TrackError::NoWorkspacePathsAvailable);
162 }
163
164 Ok(TodoWorkspaceOutcome { paths, warnings })
165 }
166}
167
168fn resolve_current_repo(repos: &[TaskRepo]) -> Result<TaskRepo> {
169 let current_path = std::env::current_dir()
170 .and_then(|path| path.canonicalize())
171 .map_err(|e| TrackError::PathResolutionFailed(e.to_string()))?;
172
173 let mut matching: Vec<(TaskRepo, PathBuf)> = repos
174 .iter()
175 .cloned()
176 .filter_map(|repo| {
177 let repo_path = PathBuf::from(&repo.repo_path);
178 let repo_path = repo_path.canonicalize().ok()?;
179 if current_path.starts_with(&repo_path) {
180 Some((repo, repo_path))
181 } else {
182 None
183 }
184 })
185 .collect();
186
187 matching.sort_by_key(|(_, repo_path)| repo_path.to_string_lossy().len());
188 matching
189 .pop()
190 .map(|(repo, _)| repo)
191 .ok_or(TrackError::CurrentDirectoryNotRegistered)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::services::TaskService;
198
199 #[test]
200 fn workspace_requires_registered_repos() {
201 let db = Database::new_in_memory().unwrap();
202 let task = TaskService::new(&db)
203 .create_task("Task", None, None, None)
204 .unwrap();
205 TodoService::new(&db)
206 .add_todo(task.id, "Todo", false)
207 .unwrap();
208
209 let result = TodoWorkspaceUseCase::new(&db).execute(
210 task.id,
211 1,
212 TodoWorkspaceRequest {
213 recreate: false,
214 force: false,
215 all_repos: false,
216 },
217 );
218 assert!(matches!(result, Err(TrackError::NoRepositoriesRegistered)));
219 }
220}