init: initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
// application use cases
|
||||
@@ -0,0 +1,160 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
use crate::domain::error::{AppError, ErrorCode};
|
||||
use crate::infrastructure::filesystem;
|
||||
use crate::infrastructure::git;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeRequest {
|
||||
pub project_id: String,
|
||||
pub directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileTreeNode {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_directory: bool,
|
||||
pub size: u64,
|
||||
pub modified_at: String,
|
||||
pub children: Option<Vec<FileTreeNode>>,
|
||||
pub git_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadFileRequest {
|
||||
pub project_id: String,
|
||||
pub file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileContent {
|
||||
pub content: String,
|
||||
pub file_path: String,
|
||||
pub size: usize,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadFileRangeRequest {
|
||||
pub project_id: String,
|
||||
pub file_path: String,
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileContentRange {
|
||||
pub content: String,
|
||||
pub file_path: String,
|
||||
pub start_line: usize,
|
||||
pub end_line: usize,
|
||||
pub total_lines: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_file_tree(
|
||||
state: State<'_, AppState>,
|
||||
request: FileTreeRequest,
|
||||
) -> Result<Vec<FileTreeNode>, AppError> {
|
||||
let project = get_project_root(&state, &request.project_id).await?;
|
||||
let dir = match &request.directory {
|
||||
Some(d) => project.join(d),
|
||||
None => project.clone(),
|
||||
};
|
||||
|
||||
if !filesystem::is_path_within_workspace(&project, &dir) {
|
||||
return Err(AppError::new(ErrorCode::PathOutsideWorkspace, "路径不在工作区内"));
|
||||
}
|
||||
|
||||
let git_statuses = git::get_file_git_statuses(&project);
|
||||
let entries = filesystem::list_directory(&dir, &git_statuses)?;
|
||||
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| FileTreeNode {
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
is_directory: e.is_directory,
|
||||
size: e.size,
|
||||
modified_at: e.modified_at,
|
||||
children: None,
|
||||
git_status: e.git_status,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_workspace_file(
|
||||
state: State<'_, AppState>,
|
||||
request: ReadFileRequest,
|
||||
) -> Result<FileContent, AppError> {
|
||||
let project = get_project_root(&state, &request.project_id).await?;
|
||||
let file_path = project.join(&request.file_path);
|
||||
|
||||
if !filesystem::is_path_within_workspace(&project, &file_path) {
|
||||
return Err(AppError::new(ErrorCode::PathOutsideWorkspace, "路径不在工作区内"));
|
||||
}
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(AppError::new(ErrorCode::IoError, "文件不存在"));
|
||||
}
|
||||
|
||||
let max_size = 512 * 1024; // 512KB limit
|
||||
let content = filesystem::read_file_content(&file_path, max_size)?;
|
||||
let content_len = content.len();
|
||||
let hash = filesystem::compute_file_hash(&file_path)?;
|
||||
|
||||
Ok(FileContent {
|
||||
content,
|
||||
file_path: request.file_path,
|
||||
size: content_len,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file_range(
|
||||
state: State<'_, AppState>,
|
||||
request: ReadFileRangeRequest,
|
||||
) -> Result<FileContentRange, AppError> {
|
||||
let project = get_project_root(&state, &request.project_id).await?;
|
||||
let file_path = project.join(&request.file_path);
|
||||
|
||||
if !filesystem::is_path_within_workspace(&project, &file_path) {
|
||||
return Err(AppError::new(ErrorCode::PathOutsideWorkspace, "路径不在工作区内"));
|
||||
}
|
||||
|
||||
let full_content = std::fs::read_to_string(&file_path)?;
|
||||
let total_lines = full_content.lines().count();
|
||||
let range_content = filesystem::read_file_range(&file_path, request.start_line, request.end_line)?;
|
||||
|
||||
Ok(FileContentRange {
|
||||
content: range_content,
|
||||
file_path: request.file_path,
|
||||
start_line: request.start_line,
|
||||
end_line: request.end_line,
|
||||
total_lines,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_project_root(state: &AppState, project_id: &str) -> Result<PathBuf, AppError> {
|
||||
let (root_path,): (String,) = sqlx::query_as(
|
||||
"SELECT root_path FROM projects WHERE id = ?",
|
||||
)
|
||||
.bind(project_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::ProjectNotFound, "项目未找到"))?;
|
||||
|
||||
Ok(PathBuf::from(root_path))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod projects;
|
||||
pub mod files;
|
||||
@@ -0,0 +1,180 @@
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, State};
|
||||
use tracing::info;
|
||||
|
||||
use crate::domain::error::{AppError, ErrorCode};
|
||||
use crate::infrastructure::git;
|
||||
use crate::services::project_detector;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectOpenResult {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub canonical_path: String,
|
||||
pub is_git_repository: bool,
|
||||
pub trust_level: String,
|
||||
pub detected_stack: project_detector::DetectedStack,
|
||||
pub git_status: Option<git::GitStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub trust_level: String,
|
||||
pub last_opened_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectDetail {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub canonical_path: String,
|
||||
pub is_git_repository: bool,
|
||||
pub trust_level: String,
|
||||
pub detected_stack: serde_json::Value,
|
||||
pub created_at: String,
|
||||
pub last_opened_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_project(
|
||||
_app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
) -> Result<ProjectOpenResult, AppError> {
|
||||
info!("Opening project at path: {}", path);
|
||||
|
||||
let root_path = PathBuf::from(&path);
|
||||
if !root_path.exists() {
|
||||
return Err(AppError::new(ErrorCode::ProjectNotFound, "目录不存在"));
|
||||
}
|
||||
|
||||
let canonical_path = root_path
|
||||
.canonicalize()
|
||||
.map_err(|e| AppError::new(ErrorCode::IoError, format!("无法解析路径: {}", e)))?;
|
||||
|
||||
let canonical_str = canonical_path.to_string_lossy().to_string();
|
||||
let name = canonical_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "未知项目".to_string());
|
||||
|
||||
let detected_stack = project_detector::detect_project(&canonical_path);
|
||||
|
||||
let git_status = if detected_stack.is_git_repository {
|
||||
Some(git::get_git_status(&canonical_path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let project_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT OR REPLACE INTO projects (id, name, root_path, canonical_path, is_git_repository, trust_level, detected_stack_json, created_at, last_opened_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'readonly', ?, ?, ?)"#,
|
||||
)
|
||||
.bind(&project_id)
|
||||
.bind(&name)
|
||||
.bind(&path)
|
||||
.bind(&canonical_str)
|
||||
.bind(detected_stack.is_git_repository as i32)
|
||||
.bind(serde_json::to_string(&detected_stack).unwrap_or_default())
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(ProjectOpenResult {
|
||||
project_id,
|
||||
name,
|
||||
root_path: path,
|
||||
canonical_path: canonical_str,
|
||||
is_git_repository: detected_stack.is_git_repository,
|
||||
trust_level: "readonly".to_string(),
|
||||
detected_stack,
|
||||
git_status,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_projects(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ProjectSummary>, AppError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, String, String, String)>(
|
||||
"SELECT id, name, root_path, trust_level, last_opened_at FROM projects ORDER BY last_opened_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, root_path, trust_level, last_opened_at)| ProjectSummary {
|
||||
id,
|
||||
name,
|
||||
root_path,
|
||||
trust_level,
|
||||
last_opened_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_project(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<ProjectDetail, AppError> {
|
||||
let row = sqlx::query_as::<_, (String, String, String, String, i32, String, String, String, String)>(
|
||||
"SELECT id, name, root_path, canonical_path, is_git_repository, trust_level, detected_stack_json, created_at, last_opened_at FROM projects WHERE id = ?",
|
||||
)
|
||||
.bind(&project_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::ProjectNotFound, "项目未找到"))?;
|
||||
|
||||
Ok(ProjectDetail {
|
||||
id: row.0,
|
||||
name: row.1,
|
||||
root_path: row.2,
|
||||
canonical_path: row.3,
|
||||
is_git_repository: row.4 != 0,
|
||||
trust_level: row.5,
|
||||
detected_stack: serde_json::from_str(&row.6).unwrap_or_default(),
|
||||
created_at: row.7,
|
||||
last_opened_at: row.8,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_project(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
delete_local_data: bool,
|
||||
) -> Result<(), AppError> {
|
||||
info!("Removing project: {}, delete_local_data: {}", project_id, delete_local_data);
|
||||
|
||||
sqlx::query("DELETE FROM projects WHERE id = ?")
|
||||
.bind(&project_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
if delete_local_data {
|
||||
// Clean up indexes, snapshots etc.
|
||||
let app_data = state.app_data_dir.clone();
|
||||
let project_index_dir = app_data.join("indexes").join(&project_id);
|
||||
let project_snapshot_dir = app_data.join("workspaces").join(&project_id);
|
||||
let _ = std::fs::remove_dir_all(&project_index_dir);
|
||||
let _ = std::fs::remove_dir_all(&project_snapshot_dir);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// app config
|
||||
@@ -0,0 +1 @@
|
||||
// agent module
|
||||
@@ -0,0 +1,148 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub enum ErrorCode {
|
||||
ProjectNotFound,
|
||||
ProjectNotTrusted,
|
||||
PathOutsideWorkspace,
|
||||
SymlinkEscapeDetected,
|
||||
SensitiveFileAccessDenied,
|
||||
TaskNotFound,
|
||||
InvalidTaskState,
|
||||
PlanNotApproved,
|
||||
ApprovalRequired,
|
||||
ToolNotAllowed,
|
||||
CommandNotAllowed,
|
||||
CommandTimeout,
|
||||
ProcessStartFailed,
|
||||
ModelUnavailable,
|
||||
ModelResponseInvalid,
|
||||
ModelContextOverflow,
|
||||
McpServerUnavailable,
|
||||
McpToolDenied,
|
||||
IndexNotReady,
|
||||
PatchConflict,
|
||||
PatchValidationFailed,
|
||||
GitDirtyWorktree,
|
||||
GitOperationFailed,
|
||||
SnapshotFailed,
|
||||
RollbackFailed,
|
||||
DatabaseError,
|
||||
IoError,
|
||||
Cancelled,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ErrorCode::ProjectNotFound => "PROJECT_NOT_FOUND",
|
||||
ErrorCode::ProjectNotTrusted => "PROJECT_NOT_TRUSTED",
|
||||
ErrorCode::PathOutsideWorkspace => "PATH_OUTSIDE_WORKSPACE",
|
||||
ErrorCode::SymlinkEscapeDetected => "SYMLINK_ESCAPE_DETECTED",
|
||||
ErrorCode::SensitiveFileAccessDenied => "SENSITIVE_FILE_ACCESS_DENIED",
|
||||
ErrorCode::TaskNotFound => "TASK_NOT_FOUND",
|
||||
ErrorCode::InvalidTaskState => "INVALID_TASK_STATE",
|
||||
ErrorCode::PlanNotApproved => "PLAN_NOT_APPROVED",
|
||||
ErrorCode::ApprovalRequired => "APPROVAL_REQUIRED",
|
||||
ErrorCode::ToolNotAllowed => "TOOL_NOT_ALLOWED",
|
||||
ErrorCode::CommandNotAllowed => "COMMAND_NOT_ALLOWED",
|
||||
ErrorCode::CommandTimeout => "COMMAND_TIMEOUT",
|
||||
ErrorCode::ProcessStartFailed => "PROCESS_START_FAILED",
|
||||
ErrorCode::ModelUnavailable => "MODEL_UNAVAILABLE",
|
||||
ErrorCode::ModelResponseInvalid => "MODEL_RESPONSE_INVALID",
|
||||
ErrorCode::ModelContextOverflow => "MODEL_CONTEXT_OVERFLOW",
|
||||
ErrorCode::McpServerUnavailable => "MCP_SERVER_UNAVAILABLE",
|
||||
ErrorCode::McpToolDenied => "MCP_TOOL_DENIED",
|
||||
ErrorCode::IndexNotReady => "INDEX_NOT_READY",
|
||||
ErrorCode::PatchConflict => "PATCH_CONFLICT",
|
||||
ErrorCode::PatchValidationFailed => "PATCH_VALIDATION_FAILED",
|
||||
ErrorCode::GitDirtyWorktree => "GIT_DIRTY_WORKTREE",
|
||||
ErrorCode::GitOperationFailed => "GIT_OPERATION_FAILED",
|
||||
ErrorCode::SnapshotFailed => "SNAPSHOT_FAILED",
|
||||
ErrorCode::RollbackFailed => "ROLLBACK_FAILED",
|
||||
ErrorCode::DatabaseError => "DATABASE_ERROR",
|
||||
ErrorCode::IoError => "IO_ERROR",
|
||||
ErrorCode::Cancelled => "CANCELLED",
|
||||
ErrorCode::Internal => "INTERNAL",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErrorCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub detail: Option<String>,
|
||||
pub recoverable: bool,
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.as_str().to_string(),
|
||||
message: message.into(),
|
||||
detail: None,
|
||||
recoverable: false,
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recoverable(mut self) -> Self {
|
||||
self.recoverable = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
|
||||
self.detail = Some(detail.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_context(mut self, context: serde_json::Value) -> Self {
|
||||
self.context = Some(context);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"[{}] {}",
|
||||
self.code,
|
||||
self.detail.as_deref().unwrap_or(&self.message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
AppError::new(ErrorCode::DatabaseError, "Database operation failed")
|
||||
.with_detail(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::migrate::MigrateError> for AppError {
|
||||
fn from(err: sqlx::migrate::MigrateError) -> Self {
|
||||
AppError::new(ErrorCode::DatabaseError, "Database migration failed")
|
||||
.with_detail(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
AppError::new(ErrorCode::IoError, "I/O operation failed")
|
||||
.with_detail(err.to_string())
|
||||
.recoverable()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -0,0 +1,5 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
pub mod error;
|
||||
|
||||
pub use error::{AppError, AppResult, ErrorCode};
|
||||
@@ -0,0 +1 @@
|
||||
// patch module
|
||||
@@ -0,0 +1 @@
|
||||
// permission module
|
||||
@@ -0,0 +1 @@
|
||||
// plan module
|
||||
@@ -0,0 +1 @@
|
||||
// project module
|
||||
@@ -0,0 +1 @@
|
||||
// task module
|
||||
@@ -0,0 +1 @@
|
||||
// tool module
|
||||
@@ -0,0 +1,35 @@
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::SqlitePool;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
use crate::domain::AppResult;
|
||||
|
||||
pub async fn init_database(app_data_dir: &PathBuf) -> AppResult<SqlitePool> {
|
||||
std::fs::create_dir_all(app_data_dir)?;
|
||||
|
||||
let db_path = app_data_dir.join("forgepilot.db");
|
||||
let db_path_str = db_path.to_string_lossy().to_string();
|
||||
|
||||
info!("Initializing database at {}", db_path_str);
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(&db_path_str)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await?;
|
||||
|
||||
info!("Database initialized successfully");
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use ignore::WalkBuilder;
|
||||
use std::path::{Path, PathBuf};
|
||||
use blake3::Hasher;
|
||||
|
||||
use crate::domain::AppResult;
|
||||
|
||||
pub fn is_path_within_workspace(workspace_root: &Path, target: &Path) -> bool {
|
||||
let Ok(canonical_workspace) = workspace_root.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(canonical_target) = target.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
canonical_target.starts_with(&canonical_workspace)
|
||||
}
|
||||
|
||||
pub fn compute_file_hash(path: &Path) -> AppResult<String> {
|
||||
let content = std::fs::read(path)?;
|
||||
let hash = Hasher::new()
|
||||
.update(&content)
|
||||
.finalize();
|
||||
Ok(hash.to_hex().to_string())
|
||||
}
|
||||
|
||||
pub fn read_file_content(path: &Path, max_size: usize) -> AppResult<String> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() > max_size as u64 {
|
||||
return Err(crate::domain::AppError::new(
|
||||
crate::domain::ErrorCode::Internal,
|
||||
format!("文件过大,超过 {} 字节限制", max_size),
|
||||
));
|
||||
}
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub fn read_file_range(
|
||||
path: &Path,
|
||||
start_line: usize,
|
||||
end_line: usize,
|
||||
) -> AppResult<String> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = start_line.saturating_sub(1).min(lines.len());
|
||||
let end = end_line.min(lines.len());
|
||||
Ok(lines[start..end].join("\n"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_directory: bool,
|
||||
pub size: u64,
|
||||
pub modified_at: String,
|
||||
pub git_status: Option<String>,
|
||||
}
|
||||
|
||||
pub fn list_directory(dir: &Path, git_statuses: &std::collections::HashMap<String, String>) -> AppResult<Vec<FileEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = entry.metadata()?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let relative_path = path.to_string_lossy().to_string();
|
||||
|
||||
let git_status = git_statuses
|
||||
.get(&relative_path)
|
||||
.or_else(|| {
|
||||
git_statuses
|
||||
.iter()
|
||||
.find(|(k, _)| path.ends_with(k))
|
||||
.map(|(_, v)| v)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
entries.push(FileEntry {
|
||||
name,
|
||||
path: relative_path,
|
||||
is_directory: metadata.is_dir(),
|
||||
size: metadata.len(),
|
||||
modified_at: format_system_time(metadata.modified().ok()),
|
||||
git_status,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_directory
|
||||
.cmp(&a.is_directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn walk_project_files(
|
||||
root: &Path,
|
||||
max_files: usize,
|
||||
) -> AppResult<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.standard_filters(true)
|
||||
.hidden(false)
|
||||
.build();
|
||||
|
||||
for result in walker {
|
||||
if files.len() >= max_files {
|
||||
break;
|
||||
}
|
||||
match result {
|
||||
Ok(entry) => {
|
||||
if entry.file_type().map_or(false, |ft| ft.is_file()) {
|
||||
files.push(entry.into_path());
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn format_system_time(time: Option<std::time::SystemTime>) -> String {
|
||||
time.map(|t| {
|
||||
let datetime: chrono::DateTime<chrono::Utc> = t.into();
|
||||
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStatus {
|
||||
pub branch: String,
|
||||
pub is_clean: bool,
|
||||
pub changed_files: Vec<String>,
|
||||
pub staged_files: Vec<String>,
|
||||
pub untracked_files: Vec<String>,
|
||||
pub ahead: usize,
|
||||
pub behind: usize,
|
||||
}
|
||||
|
||||
pub fn get_git_status(repo_path: &Path) -> GitStatus {
|
||||
let branch = get_current_branch(repo_path);
|
||||
let changed_files = get_changed_files(repo_path);
|
||||
let staged_files = get_staged_files(repo_path);
|
||||
let untracked_files = get_untracked_files(repo_path);
|
||||
let (ahead, behind) = get_ahead_behind(repo_path);
|
||||
|
||||
GitStatus {
|
||||
is_clean: changed_files.is_empty() && staged_files.is_empty() && untracked_files.is_empty(),
|
||||
branch,
|
||||
changed_files,
|
||||
staged_files,
|
||||
untracked_files,
|
||||
ahead,
|
||||
behind,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(repo_path: &Path, args: &[&str]) -> Option<String> {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn get_current_branch(repo_path: &Path) -> String {
|
||||
run_git(repo_path, &["branch", "--show-current"]).unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn get_changed_files(repo_path: &Path) -> Vec<String> {
|
||||
run_git(repo_path, &["diff", "--name-only"])
|
||||
.map(|s| s.lines().map(|l| l.to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_staged_files(repo_path: &Path) -> Vec<String> {
|
||||
run_git(repo_path, &["diff", "--cached", "--name-only"])
|
||||
.map(|s| s.lines().map(|l| l.to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_untracked_files(repo_path: &Path) -> Vec<String> {
|
||||
run_git(repo_path, &["ls-files", "--others", "--exclude-standard"])
|
||||
.map(|s| s.lines().map(|l| l.to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_ahead_behind(repo_path: &Path) -> (usize, usize) {
|
||||
let output = run_git(repo_path, &["rev-list", "--count", "--left-right", "@{upstream}...HEAD"]);
|
||||
match output {
|
||||
Some(s) => {
|
||||
let parts: Vec<&str> = s.split_whitespace().collect();
|
||||
if parts.len() == 2 {
|
||||
(parts[0].parse().unwrap_or(0), parts[1].parse().unwrap_or(0))
|
||||
} else {
|
||||
(0, 0)
|
||||
}
|
||||
}
|
||||
None => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_file_git_statuses(repo_path: &Path) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
|
||||
for f in get_changed_files(repo_path) {
|
||||
map.insert(f, "modified".to_string());
|
||||
}
|
||||
for f in get_staged_files(repo_path) {
|
||||
map.insert(f, "staged".to_string());
|
||||
}
|
||||
for f in get_untracked_files(repo_path) {
|
||||
map.insert(f, "untracked".to_string());
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// logging
|
||||
@@ -0,0 +1 @@
|
||||
// mcp
|
||||
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod database;
|
||||
pub mod filesystem;
|
||||
pub mod git;
|
||||
pub mod search;
|
||||
pub mod parser;
|
||||
pub mod process;
|
||||
pub mod models;
|
||||
pub mod mcp;
|
||||
pub mod secrets;
|
||||
pub mod logging;
|
||||
@@ -0,0 +1 @@
|
||||
// models
|
||||
@@ -0,0 +1 @@
|
||||
// parser
|
||||
@@ -0,0 +1 @@
|
||||
// process
|
||||
@@ -0,0 +1 @@
|
||||
// search
|
||||
@@ -0,0 +1 @@
|
||||
// secrets
|
||||
@@ -0,0 +1,54 @@
|
||||
mod commands;
|
||||
mod application;
|
||||
mod domain;
|
||||
mod services;
|
||||
mod infrastructure;
|
||||
mod state;
|
||||
mod config;
|
||||
|
||||
use state::AppState;
|
||||
use tauri::Manager;
|
||||
use tracing::info;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
info!("Starting ForgePilot application");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve app data dir");
|
||||
|
||||
std::fs::create_dir_all(&app_data_dir)?;
|
||||
|
||||
let db_path = app_data_dir.join("forgepilot.db");
|
||||
let db_path_str = db_path.to_string_lossy().to_string();
|
||||
|
||||
info!("Initializing database at {}", db_path_str);
|
||||
|
||||
tauri::async_runtime::block_on(async {
|
||||
let pool = crate::infrastructure::database::init_database(&app_data_dir)
|
||||
.await
|
||||
.expect("failed to initialize database");
|
||||
|
||||
app.manage(AppState::new(pool, app_data_dir));
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crate::commands::projects::open_project,
|
||||
crate::commands::projects::list_projects,
|
||||
crate::commands::projects::get_project,
|
||||
crate::commands::projects::remove_project,
|
||||
crate::commands::files::get_file_tree,
|
||||
crate::commands::files::read_workspace_file,
|
||||
crate::commands::files::read_file_range,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
fn init_tracing() {
|
||||
let file_appender = tracing_appender::rolling::daily(
|
||||
std::env::temp_dir().join("forgepilot"),
|
||||
"forgepilot.log",
|
||||
);
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
let terminal_layer = fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(false);
|
||||
|
||||
let file_layer = fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.with_writer(file_appender);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(terminal_layer)
|
||||
.with(file_layer)
|
||||
.init();
|
||||
|
||||
info!("ForgePilot tracing initialized");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
init_tracing();
|
||||
forgepilot_lib::run()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod project_detector;
|
||||
@@ -0,0 +1,177 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedStack {
|
||||
pub languages: Vec<String>,
|
||||
pub frameworks: Vec<String>,
|
||||
pub build_tool: Option<String>,
|
||||
pub test_framework: Option<String>,
|
||||
pub is_git_repository: bool,
|
||||
}
|
||||
|
||||
pub fn detect_project(root: &Path) -> DetectedStack {
|
||||
let mut languages = Vec::new();
|
||||
let mut frameworks = Vec::new();
|
||||
let mut build_tool = None;
|
||||
let mut test_framework = None;
|
||||
|
||||
detect_languages(root, &mut languages);
|
||||
detect_frameworks(root, &mut frameworks);
|
||||
detect_build_tool_and_test(root, &mut build_tool, &mut test_framework);
|
||||
|
||||
let is_git_repository = root.join(".git").exists();
|
||||
|
||||
DetectedStack {
|
||||
languages,
|
||||
frameworks,
|
||||
build_tool,
|
||||
test_framework,
|
||||
is_git_repository,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_languages(root: &Path, languages: &mut Vec<String>) {
|
||||
let detectors: &[(&str, &[&str])] = &[
|
||||
("Rust", &["Cargo.toml", "Cargo.lock"]),
|
||||
("TypeScript", &["tsconfig.json"]),
|
||||
("JavaScript", &["package.json"]),
|
||||
("Python", &["requirements.txt", "setup.py", "pyproject.toml"]),
|
||||
("Go", &["go.mod", "go.sum"]),
|
||||
("Java", &["pom.xml", "build.gradle", "build.gradle.kts"]),
|
||||
("Kotlin", &["build.gradle.kts"]),
|
||||
("C", &["Makefile"]),
|
||||
("C++", &["CMakeLists.txt"]),
|
||||
("C#", &["*.csproj"]),
|
||||
];
|
||||
|
||||
for (lang, markers) in detectors {
|
||||
let mut found = false;
|
||||
for marker in *markers {
|
||||
if marker.starts_with("*.") {
|
||||
// Check by extension pattern
|
||||
let ext = &marker[1..]; // ".csproj" etc.
|
||||
if let Ok(entries) = std::fs::read_dir(root) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.ends_with(ext) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if root.join(marker).exists() {
|
||||
found = true;
|
||||
}
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if found && !languages.contains(&lang.to_string()) {
|
||||
languages.push(lang.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Detect by extension prevalence
|
||||
let ext_map: &[(&str, &str)] = &[
|
||||
(".rs", "Rust"),
|
||||
(".ts", "TypeScript"),
|
||||
(".tsx", "TypeScript"),
|
||||
(".js", "JavaScript"),
|
||||
(".jsx", "JavaScript"),
|
||||
(".py", "Python"),
|
||||
(".go", "Go"),
|
||||
(".java", "Java"),
|
||||
];
|
||||
|
||||
let mut ext_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
|
||||
for scan_dir in &[root.to_path_buf(), root.join("src")] {
|
||||
if let Ok(entries) = std::fs::read_dir(scan_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
let dot_ext = format!(".{}", ext);
|
||||
if let Some(lang) = ext_map.iter().find(|(e, _)| *e == dot_ext) {
|
||||
*ext_counts.entry(lang.1).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (lang, count) in ext_counts.iter() {
|
||||
if *count >= 3 && !languages.contains(&lang.to_string()) {
|
||||
languages.push(lang.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_frameworks(root: &Path, frameworks: &mut Vec<String>) {
|
||||
if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let deps = pkg["dependencies"].as_object();
|
||||
let dev_deps = pkg["devDependencies"].as_object();
|
||||
|
||||
let check_dep = |name: &str| -> bool {
|
||||
deps.map_or(false, |d| d.contains_key(name))
|
||||
|| dev_deps.map_or(false, |d| d.contains_key(name))
|
||||
};
|
||||
|
||||
if check_dep("@tauri-apps/api") || check_dep("@tauri-apps/cli") {
|
||||
frameworks.push("Tauri".to_string());
|
||||
}
|
||||
if check_dep("react") {
|
||||
frameworks.push("React".to_string());
|
||||
}
|
||||
if check_dep("vue") {
|
||||
frameworks.push("Vue".to_string());
|
||||
}
|
||||
if check_dep("next") {
|
||||
frameworks.push("Next.js".to_string());
|
||||
}
|
||||
if check_dep("vite") {
|
||||
frameworks.push("Vite".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_build_tool_and_test(
|
||||
root: &Path,
|
||||
build_tool: &mut Option<String>,
|
||||
test_framework: &mut Option<String>,
|
||||
) {
|
||||
if root.join("Cargo.toml").exists() {
|
||||
*build_tool = Some("Cargo".to_string());
|
||||
*test_framework = Some("cargo test".to_string());
|
||||
} else if root.join("package.json").exists() {
|
||||
if root.join("bun.lock").exists() || root.join("bun.lockb").exists() {
|
||||
*build_tool = Some("Bun".to_string());
|
||||
} else if root.join("pnpm-lock.yaml").exists() {
|
||||
*build_tool = Some("pnpm".to_string());
|
||||
} else {
|
||||
*build_tool = Some("npm".to_string());
|
||||
}
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(root.join("package.json")) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let dev_deps = pkg["devDependencies"].as_object();
|
||||
if dev_deps.map_or(false, |d| d.contains_key("vitest")) {
|
||||
*test_framework = Some("Vitest".to_string());
|
||||
} else if dev_deps.map_or(false, |d| d.contains_key("jest")) {
|
||||
*test_framework = Some("Jest".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if root.join("go.mod").exists() {
|
||||
*build_tool = Some("go".to_string());
|
||||
*test_framework = Some("go test".to_string());
|
||||
} else if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
|
||||
*build_tool = Some("pip/poetry".to_string());
|
||||
*test_framework = Some("pytest".to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use sqlx::SqlitePool;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub app_data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: SqlitePool, app_data_dir: PathBuf) -> Self {
|
||||
Self { db, app_data_dir }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user