Skip to main content

track/use_cases/
todo_workspace.rs

1use 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/// Options for creating or showing a TODO workspace.
8#[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/// Result of resolving or creating TODO workspaces.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct TodoWorkspaceOutcome {
18    pub paths: Vec<String>,
19    pub warnings: Vec<String>,
20}
21
22/// Creates, recreates, or lists jj workspaces for a TODO.
23pub 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}