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
13pub 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
23fn 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
37async 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
61async 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 {task_id} not found")))?;
71 let repo_path = resolve_task_repo_path(state, &task).await?;
72 Ok((task, repo_path))
73}
74
75pub 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 {id} not found")))?;
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
175pub 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
220pub 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
244pub 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}