Skip to main content

routa_server/api/tasks/
changes.rs

1use axum::{
2    extract::{Query, State},
3    Json,
4};
5use routa_core::git::{FileChangeStatus, GitFileChange};
6use routa_core::models::task::Task;
7
8use crate::error::ServerError;
9use crate::state::AppState;
10
11use super::dto::{TaskChangeCommitQuery, TaskChangeFileQuery, TaskChangeStatsQuery};
12
13/// Extract repository label from path
14pub fn repo_label_from_path(repo_path: &str) -> String {
15    repo_path
16        .trim_end_matches(std::path::MAIN_SEPARATOR)
17        .rsplit(std::path::MAIN_SEPARATOR)
18        .find(|segment| !segment.is_empty())
19        .unwrap_or(repo_path)
20        .to_string()
21}
22
23/// Parse file change status from string
24fn parse_file_change_status(status: &str) -> FileChangeStatus {
25    match status.trim().to_ascii_lowercase().as_str() {
26        "added" => FileChangeStatus::Added,
27        "deleted" => FileChangeStatus::Deleted,
28        "renamed" => FileChangeStatus::Renamed,
29        "copied" => FileChangeStatus::Copied,
30        "untracked" => FileChangeStatus::Untracked,
31        "typechange" => FileChangeStatus::Typechange,
32        "conflicted" => FileChangeStatus::Conflicted,
33        _ => FileChangeStatus::Modified,
34    }
35}
36
37/// Resolve repository path for a task (worktree or codebase)
38async fn resolve_task_repo_path(state: &AppState, task: &Task) -> Result<String, ServerError> {
39    let worktree = match task.worktree_id.as_ref() {
40        Some(worktree_id) => state.worktree_store.get(worktree_id).await?,
41        None => None,
42    };
43    let codebase_id = worktree
44        .as_ref()
45        .map(|item| item.codebase_id.clone())
46        .or_else(|| task.codebase_ids.first().cloned())
47        .unwrap_or_default();
48    let codebase = if codebase_id.is_empty() {
49        None
50    } else {
51        state.codebase_store.get(&codebase_id).await?
52    };
53
54    Ok(worktree
55        .as_ref()
56        .map(|item| item.worktree_path.clone())
57        .or_else(|| codebase.as_ref().map(|item| item.repo_path.clone()))
58        .unwrap_or_default())
59}
60
61/// Load task and resolve its repository path
62async fn load_task_and_repo_path(
63    state: &AppState,
64    task_id: &str,
65) -> Result<(Task, String), ServerError> {
66    let task = state
67        .task_store
68        .get(task_id)
69        .await?
70        .ok_or_else(|| ServerError::NotFound(format!("Task {} not found", task_id)))?;
71    let repo_path = resolve_task_repo_path(state, &task).await?;
72    Ok((task, repo_path))
73}
74
75/// GET /api/tasks/:id/changes - Get task repository changes
76pub async fn get_task_changes(
77    State(state): State<AppState>,
78    axum::extract::Path(id): axum::extract::Path<String>,
79) -> Result<Json<serde_json::Value>, ServerError> {
80    let task = state
81        .task_store
82        .get(&id)
83        .await?
84        .ok_or_else(|| ServerError::NotFound(format!("Task {} not found", id)))?;
85
86    let worktree = match task.worktree_id.as_ref() {
87        Some(worktree_id) => state.worktree_store.get(worktree_id).await?,
88        None => None,
89    };
90    let codebase_id = worktree
91        .as_ref()
92        .map(|item| item.codebase_id.clone())
93        .or_else(|| task.codebase_ids.first().cloned())
94        .unwrap_or_default();
95    let codebase = if codebase_id.is_empty() {
96        None
97    } else {
98        state.codebase_store.get(&codebase_id).await?
99    };
100    let repo_path = worktree
101        .as_ref()
102        .map(|item| item.worktree_path.clone())
103        .or_else(|| codebase.as_ref().map(|item| item.repo_path.clone()))
104        .unwrap_or_default();
105    let label = codebase
106        .as_ref()
107        .and_then(|item| item.label.clone())
108        .unwrap_or_else(|| {
109            repo_label_from_path(if repo_path.is_empty() {
110                "repo"
111            } else {
112                &repo_path
113            })
114        });
115    let branch = codebase
116        .as_ref()
117        .and_then(|item| item.branch.clone())
118        .unwrap_or_else(|| "unknown".to_string());
119    let source = if worktree.is_some() {
120        "worktree"
121    } else {
122        "repo"
123    };
124
125    if repo_path.is_empty() {
126        return Ok(Json(serde_json::json!({
127            "changes": {
128                "codebaseId": codebase_id,
129                "repoPath": "",
130                "label": label,
131                "branch": branch,
132                "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
133                "files": [],
134                "source": source,
135                "worktreeId": worktree.as_ref().map(|item| item.id.clone()),
136                "worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
137                "error": "No repository or worktree linked to this task",
138            }
139        })));
140    }
141
142    if !crate::git::is_git_repository(&repo_path) {
143        return Ok(Json(serde_json::json!({
144            "changes": {
145                "codebaseId": codebase_id,
146                "repoPath": repo_path,
147                "label": label,
148                "branch": branch,
149                "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
150                "files": [],
151                "source": source,
152                "worktreeId": worktree.as_ref().map(|item| item.id.clone()),
153                "worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
154                "error": "Repository is missing or not a git repository",
155            }
156        })));
157    }
158
159    let changes = crate::git::get_repo_changes(&repo_path);
160    Ok(Json(serde_json::json!({
161        "changes": {
162            "codebaseId": codebase_id,
163            "repoPath": repo_path,
164            "label": label,
165            "branch": changes.branch,
166            "status": changes.status,
167            "files": changes.files,
168            "source": source,
169            "worktreeId": worktree.as_ref().map(|item| item.id.clone()),
170            "worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
171        }
172    })))
173}
174
175/// GET /api/tasks/:id/changes/file - Get file diff for a task change
176pub async fn get_task_change_file(
177    State(state): State<AppState>,
178    axum::extract::Path(id): axum::extract::Path<String>,
179    Query(query): Query<TaskChangeFileQuery>,
180) -> Result<Json<serde_json::Value>, ServerError> {
181    let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
182    let path = query
183        .path
184        .as_deref()
185        .map(str::trim)
186        .filter(|value| !value.is_empty())
187        .ok_or_else(|| ServerError::BadRequest("Missing file path or status".to_string()))?;
188    let status = query
189        .status
190        .as_deref()
191        .map(str::trim)
192        .filter(|value| !value.is_empty())
193        .ok_or_else(|| ServerError::BadRequest("Missing file path or status".to_string()))?;
194
195    if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
196        return Err(ServerError::BadRequest(
197            "Repository is missing or not a git repository".to_string(),
198        ));
199    }
200
201    let diff = crate::git::get_repo_file_diff(
202        &repo_path,
203        &GitFileChange {
204            path: path.to_string(),
205            status: parse_file_change_status(status),
206            previous_path: query.previous_path.and_then(|value| {
207                let trimmed = value.trim().to_string();
208                if trimmed.is_empty() {
209                    None
210                } else {
211                    Some(trimmed)
212                }
213            }),
214        },
215    );
216
217    Ok(Json(serde_json::json!({ "diff": diff })))
218}
219
220/// GET /api/tasks/:id/changes/commit - Get commit diff
221pub async fn get_task_change_commit(
222    State(state): State<AppState>,
223    axum::extract::Path(id): axum::extract::Path<String>,
224    Query(query): Query<TaskChangeCommitQuery>,
225) -> Result<Json<serde_json::Value>, ServerError> {
226    let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
227    let sha = query
228        .sha
229        .as_deref()
230        .map(str::trim)
231        .filter(|value| !value.is_empty())
232        .ok_or_else(|| ServerError::BadRequest("Missing commit sha".to_string()))?;
233
234    if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
235        return Err(ServerError::BadRequest(
236            "Repository is missing or not a git repository".to_string(),
237        ));
238    }
239
240    let diff = crate::git::get_repo_commit_diff(&repo_path, sha);
241    Ok(Json(serde_json::json!({ "diff": diff })))
242}
243
244/// GET /api/tasks/:id/changes/stats - Get change stats for multiple files
245pub async fn get_task_change_stats(
246    State(state): State<AppState>,
247    axum::extract::Path(id): axum::extract::Path<String>,
248    Query(query): Query<TaskChangeStatsQuery>,
249) -> Result<Json<serde_json::Value>, ServerError> {
250    let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
251    let paths_param = query
252        .paths
253        .as_deref()
254        .map(str::trim)
255        .filter(|value| !value.is_empty())
256        .ok_or_else(|| ServerError::BadRequest("Missing 'paths' query parameter".to_string()))?;
257
258    let requested_paths: Vec<String> = paths_param
259        .split(',')
260        .map(str::trim)
261        .filter(|value| !value.is_empty())
262        .map(str::to_string)
263        .collect();
264    if requested_paths.is_empty() {
265        return Err(ServerError::BadRequest(
266            "No valid paths provided".to_string(),
267        ));
268    }
269    if requested_paths.len() > 100 {
270        return Err(ServerError::BadRequest(
271            "Too many paths requested. Maximum 100 per request.".to_string(),
272        ));
273    }
274
275    if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
276        return Err(ServerError::BadRequest(
277            "Repository is missing or not a git repository".to_string(),
278        ));
279    }
280
281    let statuses: Vec<String> = query
282        .statuses
283        .unwrap_or_default()
284        .split(',')
285        .map(str::trim)
286        .filter(|value| !value.is_empty())
287        .map(str::to_string)
288        .collect();
289
290    let stats: Vec<serde_json::Value> = requested_paths
291        .iter()
292        .enumerate()
293        .map(|(index, path)| {
294            let diff = crate::git::get_repo_file_diff(
295                &repo_path,
296                &GitFileChange {
297                    path: path.clone(),
298                    status: parse_file_change_status(
299                        statuses
300                            .get(index)
301                            .map(String::as_str)
302                            .unwrap_or("modified"),
303                    ),
304                    previous_path: None,
305                },
306            );
307            serde_json::json!({
308                "path": path,
309                "additions": diff.additions,
310                "deletions": diff.deletions,
311            })
312        })
313        .collect();
314
315    let successful = stats
316        .iter()
317        .filter(|entry| entry.get("error").is_none())
318        .count();
319
320    Ok(Json(serde_json::json!({
321        "stats": stats,
322        "requested": requested_paths.len(),
323        "successful": successful,
324    })))
325}