use axum::{
extract::{Query, State},
Json,
};
use routa_core::git::{FileChangeStatus, GitFileChange};
use routa_core::models::task::Task;
use crate::error::ServerError;
use crate::state::AppState;
use super::dto::{TaskChangeCommitQuery, TaskChangeFileQuery, TaskChangeStatsQuery};
pub fn repo_label_from_path(repo_path: &str) -> String {
repo_path
.trim_end_matches(std::path::MAIN_SEPARATOR)
.rsplit(std::path::MAIN_SEPARATOR)
.find(|segment| !segment.is_empty())
.unwrap_or(repo_path)
.to_string()
}
fn parse_file_change_status(status: &str) -> FileChangeStatus {
match status.trim().to_ascii_lowercase().as_str() {
"added" => FileChangeStatus::Added,
"deleted" => FileChangeStatus::Deleted,
"renamed" => FileChangeStatus::Renamed,
"copied" => FileChangeStatus::Copied,
"untracked" => FileChangeStatus::Untracked,
"typechange" => FileChangeStatus::Typechange,
"conflicted" => FileChangeStatus::Conflicted,
_ => FileChangeStatus::Modified,
}
}
async fn resolve_task_repo_path(state: &AppState, task: &Task) -> Result<String, ServerError> {
let worktree = match task.worktree_id.as_ref() {
Some(worktree_id) => state.worktree_store.get(worktree_id).await?,
None => None,
};
let codebase_id = worktree
.as_ref()
.map(|item| item.codebase_id.clone())
.or_else(|| task.codebase_ids.first().cloned())
.unwrap_or_default();
let codebase = if codebase_id.is_empty() {
None
} else {
state.codebase_store.get(&codebase_id).await?
};
Ok(worktree
.as_ref()
.map(|item| item.worktree_path.clone())
.or_else(|| codebase.as_ref().map(|item| item.repo_path.clone()))
.unwrap_or_default())
}
async fn load_task_and_repo_path(
state: &AppState,
task_id: &str,
) -> Result<(Task, String), ServerError> {
let task = state
.task_store
.get(task_id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Task {} not found", task_id)))?;
let repo_path = resolve_task_repo_path(state, &task).await?;
Ok((task, repo_path))
}
pub async fn get_task_changes(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<serde_json::Value>, ServerError> {
let task = state
.task_store
.get(&id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Task {} not found", id)))?;
let worktree = match task.worktree_id.as_ref() {
Some(worktree_id) => state.worktree_store.get(worktree_id).await?,
None => None,
};
let codebase_id = worktree
.as_ref()
.map(|item| item.codebase_id.clone())
.or_else(|| task.codebase_ids.first().cloned())
.unwrap_or_default();
let codebase = if codebase_id.is_empty() {
None
} else {
state.codebase_store.get(&codebase_id).await?
};
let repo_path = worktree
.as_ref()
.map(|item| item.worktree_path.clone())
.or_else(|| codebase.as_ref().map(|item| item.repo_path.clone()))
.unwrap_or_default();
let label = codebase
.as_ref()
.and_then(|item| item.label.clone())
.unwrap_or_else(|| {
repo_label_from_path(if repo_path.is_empty() {
"repo"
} else {
&repo_path
})
});
let branch = codebase
.as_ref()
.and_then(|item| item.branch.clone())
.unwrap_or_else(|| "unknown".to_string());
let source = if worktree.is_some() {
"worktree"
} else {
"repo"
};
if repo_path.is_empty() {
return Ok(Json(serde_json::json!({
"changes": {
"codebaseId": codebase_id,
"repoPath": "",
"label": label,
"branch": branch,
"status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
"files": [],
"source": source,
"worktreeId": worktree.as_ref().map(|item| item.id.clone()),
"worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
"error": "No repository or worktree linked to this task",
}
})));
}
if !crate::git::is_git_repository(&repo_path) {
return Ok(Json(serde_json::json!({
"changes": {
"codebaseId": codebase_id,
"repoPath": repo_path,
"label": label,
"branch": branch,
"status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
"files": [],
"source": source,
"worktreeId": worktree.as_ref().map(|item| item.id.clone()),
"worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
"error": "Repository is missing or not a git repository",
}
})));
}
let changes = crate::git::get_repo_changes(&repo_path);
Ok(Json(serde_json::json!({
"changes": {
"codebaseId": codebase_id,
"repoPath": repo_path,
"label": label,
"branch": changes.branch,
"status": changes.status,
"files": changes.files,
"source": source,
"worktreeId": worktree.as_ref().map(|item| item.id.clone()),
"worktreePath": worktree.as_ref().map(|item| item.worktree_path.clone()),
}
})))
}
pub async fn get_task_change_file(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
Query(query): Query<TaskChangeFileQuery>,
) -> Result<Json<serde_json::Value>, ServerError> {
let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
let path = query
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ServerError::BadRequest("Missing file path or status".to_string()))?;
let status = query
.status
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ServerError::BadRequest("Missing file path or status".to_string()))?;
if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
return Err(ServerError::BadRequest(
"Repository is missing or not a git repository".to_string(),
));
}
let diff = crate::git::get_repo_file_diff(
&repo_path,
&GitFileChange {
path: path.to_string(),
status: parse_file_change_status(status),
previous_path: query.previous_path.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}),
},
);
Ok(Json(serde_json::json!({ "diff": diff })))
}
pub async fn get_task_change_commit(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
Query(query): Query<TaskChangeCommitQuery>,
) -> Result<Json<serde_json::Value>, ServerError> {
let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
let sha = query
.sha
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ServerError::BadRequest("Missing commit sha".to_string()))?;
if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
return Err(ServerError::BadRequest(
"Repository is missing or not a git repository".to_string(),
));
}
let diff = crate::git::get_repo_commit_diff(&repo_path, sha);
Ok(Json(serde_json::json!({ "diff": diff })))
}
pub async fn get_task_change_stats(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
Query(query): Query<TaskChangeStatsQuery>,
) -> Result<Json<serde_json::Value>, ServerError> {
let (_task, repo_path) = load_task_and_repo_path(&state, &id).await?;
let paths_param = query
.paths
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| ServerError::BadRequest("Missing 'paths' query parameter".to_string()))?;
let requested_paths: Vec<String> = paths_param
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect();
if requested_paths.is_empty() {
return Err(ServerError::BadRequest(
"No valid paths provided".to_string(),
));
}
if requested_paths.len() > 100 {
return Err(ServerError::BadRequest(
"Too many paths requested. Maximum 100 per request.".to_string(),
));
}
if repo_path.is_empty() || !crate::git::is_git_repository(&repo_path) {
return Err(ServerError::BadRequest(
"Repository is missing or not a git repository".to_string(),
));
}
let statuses: Vec<String> = query
.statuses
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect();
let stats: Vec<serde_json::Value> = requested_paths
.iter()
.enumerate()
.map(|(index, path)| {
let diff = crate::git::get_repo_file_diff(
&repo_path,
&GitFileChange {
path: path.clone(),
status: parse_file_change_status(
statuses
.get(index)
.map(String::as_str)
.unwrap_or("modified"),
),
previous_path: None,
},
);
serde_json::json!({
"path": path,
"additions": diff.additions,
"deletions": diff.deletions,
})
})
.collect();
let successful = stats
.iter()
.filter(|entry| entry.get("error").is_none())
.count();
Ok(Json(serde_json::json!({
"stats": stats,
"requested": requested_paths.len(),
"successful": successful,
})))
}