Files
vibe-kanban/crates/server/src/routes/projects.rs
Louis Knight-Webb e5aa72e907 Re-rank file search (vibe-kanban) (#514)
* Cleanup script changes for task attempt f7987e81-2e4a-47e7-9d54-cc27930b694b

* Commit changes from coding agent for task attempt f7987e81-2e4a-47e7-9d54-cc27930b694b

* Cleanup script changes for task attempt f7987e81-2e4a-47e7-9d54-cc27930b694b

* fmt

* Perfect! ** Refactoring Complete**

## Summary

Successfully refactored the FileRanker to remove all direct git2 dependencies and consolidated all git operations in GitService:

### **Key Changes Made:**

1. **Enhanced GitService** (`crates/services/src/services/git.rs`):
   -  Added `HeadInfo` struct with `branch: String` and `oid: String`
   -  Added `get_head_info()` method that returns both branch name and commit SHA
   -  Updated `get_current_branch()` to be a thin wrapper maintaining backward compatibility

2. **Refactored FileRanker** (`crates/services/src/services/file_ranker.rs`):
   - �� **Removed all `git2` imports** - now purely focused on ranking logic
   -  Changed cache to store `head_sha: String` instead of `head_oid: git2::Oid`
   -  Updated cache validation to use `GitService.get_head_info()`
   -  Updated cache storage to use `GitService.get_head_info()`

### **Benefits Achieved:**

- **🎯 Clean Separation**: FileRanker = pure ranking logic, GitService = all git I/O
- **🧪 Better Testability**: FileRanker no longer depends on git2, easier to mock/test
- **🔄 Future-Proof**: All git backend changes centralized in GitService  
- ** Zero Performance Impact**: Same caching behavior and efficiency
- **🔄 Backward Compatibility**: Existing `get_current_branch()` callers unaffected

### **Architecture Now:**
```
FileRanker (ranking logic only)
    ↓ delegates to
GitService (single git facade)
    ↓ encapsulates  
git2 (implementation detail)
```

The refactoring successfully achieved proper separation of concerns while maintaining all existing functionality and performance characteristics.

* Cleanup script changes for task attempt f7987e81-2e4a-47e7-9d54-cc27930b694b
2025-08-19 11:23:14 +01:00

411 lines
14 KiB
Rust

use std::{collections::HashMap, path::Path};
use axum::{
extract::{Query, State},
http::StatusCode,
middleware::from_fn_with_state,
response::Json as ResponseJson,
routing::{get, post},
Extension, Json, Router,
};
use db::models::project::{
CreateProject, Project, ProjectError, SearchMatchType, SearchResult, UpdateProject,
};
use deployment::Deployment;
use ignore::WalkBuilder;
use services::services::{file_ranker::FileRanker, git::GitBranch};
use utils::response::ApiResponse;
use uuid::Uuid;
use crate::{error::ApiError, middleware::load_project_middleware, DeploymentImpl};
pub async fn get_projects(
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<Vec<Project>>>, ApiError> {
let projects = Project::find_all(&deployment.db().pool).await?;
Ok(ResponseJson(ApiResponse::success(projects)))
}
pub async fn get_project(
Extension(project): Extension<Project>,
) -> Result<ResponseJson<ApiResponse<Project>>, ApiError> {
Ok(ResponseJson(ApiResponse::success(project)))
}
pub async fn get_project_branches(
Extension(project): Extension<Project>,
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<Vec<GitBranch>>>, ApiError> {
let branches = deployment.git().get_all_branches(&project.git_repo_path)?;
Ok(ResponseJson(ApiResponse::success(branches)))
}
pub async fn create_project(
State(deployment): State<DeploymentImpl>,
Json(payload): Json<CreateProject>,
) -> Result<ResponseJson<ApiResponse<Project>>, ApiError> {
let id = Uuid::new_v4();
tracing::debug!("Creating project '{}'", payload.name);
// Check if git repo path is already used by another project
match Project::find_by_git_repo_path(&deployment.db().pool, &payload.git_repo_path).await {
Ok(Some(_)) => {
return Ok(ResponseJson(ApiResponse::error(
"A project with this git repository path already exists",
)));
}
Ok(None) => {
// Path is available, continue
}
Err(e) => {
return Err(ProjectError::GitRepoCheckFailed(e.to_string()).into());
}
}
// Validate and setup git repository
let path = std::path::Path::new(&payload.git_repo_path);
if payload.use_existing_repo {
// For existing repos, validate that the path exists and is a git repository
if !path.exists() {
return Ok(ResponseJson(ApiResponse::error(
"The specified path does not exist",
)));
}
if !path.is_dir() {
return Ok(ResponseJson(ApiResponse::error(
"The specified path is not a directory",
)));
}
if !path.join(".git").exists() {
return Ok(ResponseJson(ApiResponse::error(
"The specified directory is not a git repository",
)));
}
// Ensure existing repo has a main branch if it's empty
if let Err(e) = deployment.git().ensure_main_branch_exists(path) {
tracing::error!("Failed to ensure main branch exists: {}", e);
return Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to ensure main branch exists: {}",
e
))));
}
} else {
// For new repos, create directory and initialize git
// Create directory if it doesn't exist
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(path) {
tracing::error!("Failed to create directory: {}", e);
return Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to create directory: {}",
e
))));
}
}
// Check if it's already a git repo, if not initialize it
if !path.join(".git").exists() {
if let Err(e) = deployment.git().initialize_repo_with_main_branch(path) {
tracing::error!("Failed to initialize git repository: {}", e);
return Ok(ResponseJson(ApiResponse::error(&format!(
"Failed to initialize git repository: {}",
e
))));
}
}
}
match Project::create(&deployment.db().pool, &payload, id).await {
Ok(project) => {
// Track project creation event
deployment
.track_if_analytics_allowed(
"project_created",
serde_json::json!({
"project_id": project.id.to_string(),
"use_existing_repo": payload.use_existing_repo,
"has_setup_script": payload.setup_script.is_some(),
"has_dev_script": payload.dev_script.is_some(),
}),
)
.await;
Ok(ResponseJson(ApiResponse::success(project)))
}
Err(e) => Err(ProjectError::CreateFailed(e.to_string()).into()),
}
}
pub async fn update_project(
Extension(existing_project): Extension<Project>,
State(deployment): State<DeploymentImpl>,
Json(payload): Json<UpdateProject>,
) -> Result<ResponseJson<ApiResponse<Project>>, StatusCode> {
// If git_repo_path is being changed, check if the new path is already used by another project
if let Some(new_git_repo_path) = &payload.git_repo_path {
if new_git_repo_path != &existing_project.git_repo_path.to_string_lossy() {
match Project::find_by_git_repo_path_excluding_id(
&deployment.db().pool,
new_git_repo_path,
existing_project.id,
)
.await
{
Ok(Some(_)) => {
return Ok(ResponseJson(ApiResponse::error(
"A project with this git repository path already exists",
)));
}
Ok(None) => {
// Path is available, continue
}
Err(e) => {
tracing::error!("Failed to check for existing git repo path: {}", e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
}
}
// Destructure payload to handle field updates.
// This allows us to treat `None` from the payload as an explicit `null` to clear a field,
// as the frontend currently sends all fields on update.
let UpdateProject {
name,
git_repo_path,
setup_script,
dev_script,
cleanup_script,
copy_files,
} = payload;
let name = name.unwrap_or(existing_project.name);
let git_repo_path =
git_repo_path.unwrap_or(existing_project.git_repo_path.to_string_lossy().to_string());
match Project::update(
&deployment.db().pool,
existing_project.id,
name,
git_repo_path,
setup_script,
dev_script,
cleanup_script,
copy_files,
)
.await
{
Ok(project) => Ok(ResponseJson(ApiResponse::success(project))),
Err(e) => {
tracing::error!("Failed to update project: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn delete_project(
Extension(project): Extension<Project>,
State(deployment): State<DeploymentImpl>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
match Project::delete(&deployment.db().pool, project.id).await {
Ok(rows_affected) => {
if rows_affected == 0 {
Err(StatusCode::NOT_FOUND)
} else {
Ok(ResponseJson(ApiResponse::success(())))
}
}
Err(e) => {
tracing::error!("Failed to delete project: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[derive(serde::Deserialize)]
pub struct OpenEditorRequest {
editor_type: Option<String>,
}
pub async fn open_project_in_editor(
Extension(project): Extension<Project>,
State(deployment): State<DeploymentImpl>,
Json(payload): Json<Option<OpenEditorRequest>>,
) -> Result<ResponseJson<ApiResponse<()>>, StatusCode> {
let path = project.git_repo_path.to_string_lossy();
let editor_config = {
let config = deployment.config().read().await;
let editor_type_str = payload.as_ref().and_then(|req| req.editor_type.as_deref());
config.editor.with_override(editor_type_str)
};
match editor_config.open_file(&path) {
Ok(_) => {
tracing::info!("Opened editor for project {} at path: {}", project.id, path);
Ok(ResponseJson(ApiResponse::success(())))
}
Err(e) => {
tracing::error!("Failed to open editor for project {}: {}", project.id, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn search_project_files(
Extension(project): Extension<Project>,
Query(params): Query<HashMap<String, String>>,
) -> Result<ResponseJson<ApiResponse<Vec<SearchResult>>>, StatusCode> {
let query = match params.get("q") {
Some(q) if !q.trim().is_empty() => q.trim(),
_ => {
return Ok(ResponseJson(ApiResponse::error(
"Query parameter 'q' is required and cannot be empty",
)));
}
};
// Search files in the project repository
match search_files_in_repo(&project.git_repo_path.to_string_lossy(), query).await {
Ok(results) => Ok(ResponseJson(ApiResponse::success(results))),
Err(e) => {
tracing::error!("Failed to search files: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn search_files_in_repo(
repo_path: &str,
query: &str,
) -> Result<Vec<SearchResult>, Box<dyn std::error::Error + Send + Sync>> {
let repo_path = Path::new(repo_path);
if !repo_path.exists() {
return Err("Repository path does not exist".into());
}
let mut results = Vec::new();
let query_lower = query.to_lowercase();
// Use ignore::WalkBuilder to respect gitignore files
let walker = WalkBuilder::new(repo_path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(false)
.build();
for result in walker {
let entry = result?;
let path = entry.path();
// Skip the root directory itself
if path == repo_path {
continue;
}
let relative_path = path.strip_prefix(repo_path)?;
// Skip .git directory and its contents
if relative_path
.components()
.any(|component| component.as_os_str() == ".git")
{
continue;
}
let relative_path_str = relative_path.to_string_lossy().to_lowercase();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default();
// Check for matches
if file_name.contains(&query_lower) {
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type: SearchMatchType::FileName,
});
} else if relative_path_str.contains(&query_lower) {
// Check if it's a directory name match or full path match
let match_type = if path
.parent()
.and_then(|p| p.file_name())
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default()
.contains(&query_lower)
{
SearchMatchType::DirectoryName
} else {
SearchMatchType::FullPath
};
results.push(SearchResult {
path: relative_path.to_string_lossy().to_string(),
is_file: path.is_file(),
match_type,
});
}
}
// Apply git history-based ranking
let file_ranker = FileRanker::new();
match file_ranker.get_stats(repo_path).await {
Ok(stats) => {
// Re-rank results using git history
file_ranker.rerank(&mut results, &stats);
}
Err(e) => {
tracing::warn!(
"Failed to get git stats for ranking, using basic sort: {}",
e
);
// Fallback to basic priority sorting
results.sort_by(|a, b| {
let priority = |match_type: &SearchMatchType| match match_type {
SearchMatchType::FileName => 0,
SearchMatchType::DirectoryName => 1,
SearchMatchType::FullPath => 2,
};
priority(&a.match_type)
.cmp(&priority(&b.match_type))
.then_with(|| a.path.cmp(&b.path))
});
}
}
// Limit to top 10 results
results.truncate(10);
Ok(results)
}
pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
let project_id_router = Router::new()
.route(
"/",
get(get_project).put(update_project).delete(delete_project),
)
.route("/branches", get(get_project_branches))
.route("/search", get(search_project_files))
.route("/open-editor", post(open_project_in_editor))
.layer(from_fn_with_state(
deployment.clone(),
load_project_middleware,
));
let projects_router = Router::new()
.route("/", get(get_projects).post(create_project))
.nest("/{id}", project_id_router);
Router::new().nest("/projects", projects_router)
}