init: initial commit

This commit is contained in:
Blizzard
2026-08-02 21:15:48 +08:00
commit 48119ef99b
83 changed files with 13349 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5753
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "forgepilot"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "forgepilot_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.53.1", features = ["full"] }
tokio-util = "0.7.19"
thiserror = "2.0.19"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-appender = "0.2.5"
uuid = { version = "1.24.0", features = ["v4", "serde"] }
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite", "migrate"] }
chrono = { version = "0.4.45", features = ["serde"] }
tauri-plugin-dialog = "2.7.2"
ignore = "0.4.31"
blake3 = "1.8.5"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+186
View File
@@ -0,0 +1,186 @@
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
root_path TEXT NOT NULL UNIQUE,
canonical_path TEXT NOT NULL,
is_git_repository INTEGER NOT NULL DEFAULT 0,
trust_level TEXT NOT NULL DEFAULT 'readonly',
detected_stack_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_opened_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_settings (
project_id TEXT PRIMARY KEY,
model_profile_id TEXT,
approval_mode TEXT NOT NULL DEFAULT 'balanced',
git_isolation_mode TEXT NOT NULL DEFAULT 'worktree',
network_policy TEXT NOT NULL DEFAULT 'local_only',
read_roots_json TEXT NOT NULL DEFAULT '[]',
write_roots_json TEXT NOT NULL DEFAULT '[]',
ignored_paths_json TEXT NOT NULL DEFAULT '[]',
commands_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'planAndExecute',
status TEXT NOT NULL DEFAULT 'draft',
base_revision TEXT,
worktree_path TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
failure_code TEXT,
failure_message TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_events (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
event_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(task_id, sequence),
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
content_markdown TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'draft',
content_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
approved_at TEXT,
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tool_calls (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
arguments_json TEXT NOT NULL DEFAULT '{}',
reason TEXT NOT NULL DEFAULT '',
risk_level TEXT NOT NULL DEFAULT 'low',
approval_status TEXT NOT NULL DEFAULT 'pending',
status TEXT NOT NULL DEFAULT 'pending',
output_summary TEXT,
error_json TEXT,
started_at TEXT,
completed_at TEXT,
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS patches (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'draft',
base_revision TEXT,
summary TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
applied_at TEXT,
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS patch_files (
id TEXT PRIMARY KEY,
patch_id TEXT NOT NULL,
file_path TEXT NOT NULL,
operation TEXT NOT NULL,
before_hash TEXT,
after_hash TEXT,
diff_text TEXT NOT NULL DEFAULT '',
review_status TEXT NOT NULL DEFAULT 'pending',
FOREIGN KEY(patch_id) REFERENCES patches(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS command_runs (
id TEXT PRIMARY KEY,
task_id TEXT,
program TEXT NOT NULL,
args_json TEXT NOT NULL DEFAULT '[]',
cwd TEXT NOT NULL,
risk_level TEXT NOT NULL DEFAULT 'low',
exit_code INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
output_log_path TEXT,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS model_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'planner',
provider TEXT NOT NULL,
base_url TEXT NOT NULL,
model_id TEXT NOT NULL,
api_key_ref TEXT,
context_window INTEGER,
max_output_tokens INTEGER,
temperature REAL,
capabilities_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
transport TEXT NOT NULL DEFAULT 'stdio',
command TEXT,
args_json TEXT NOT NULL DEFAULT '[]',
url TEXT,
status TEXT NOT NULL DEFAULT 'stopped',
auto_start INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS index_files (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
modified_at TEXT NOT NULL,
file_hash TEXT,
language TEXT,
is_ignored INTEGER NOT NULL DEFAULT 0,
index_status TEXT NOT NULL DEFAULT 'pending',
UNIQUE(project_id, file_path),
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS symbols (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
file_path TEXT NOT NULL,
symbol_name TEXT NOT NULL,
symbol_kind TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER,
parent_symbol_id TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+1
View File
@@ -0,0 +1 @@
// application use cases
+160
View File
@@ -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))
}
+2
View File
@@ -0,0 +1,2 @@
pub mod projects;
pub mod files;
+180
View File
@@ -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(())
}
+1
View File
@@ -0,0 +1 @@
// app config
+1
View File
@@ -0,0 +1 @@
// agent module
+148
View File
@@ -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>;
+5
View File
@@ -0,0 +1,5 @@
#![allow(dead_code, unused_imports)]
pub mod error;
pub use error::{AppError, AppResult, ErrorCode};
+1
View File
@@ -0,0 +1 @@
// patch module
+1
View File
@@ -0,0 +1 @@
// permission module
+1
View File
@@ -0,0 +1 @@
// plan module
+1
View File
@@ -0,0 +1 @@
// project module
+1
View File
@@ -0,0 +1 @@
// task module
+1
View File
@@ -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()
}
+96
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
// mcp
+12
View File
@@ -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
+54
View File
@@ -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");
}
+36
View File
@@ -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()
}
+1
View File
@@ -0,0 +1 @@
pub mod project_detector;
+177
View File
@@ -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());
}
}
+13
View File
@@ -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 }
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ForgePilot",
"version": "0.1.0",
"identifier": "com.sundynix.forgepilot",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "ForgePilot",
"width": 1400,
"height": 900,
"minWidth": 900,
"minHeight": 600
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:* https://localhost:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}