From 0f53da74178317596633faea1d0bd35257947ecc Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 26 May 2026 16:22:23 +0800 Subject: [PATCH] refactor(services-core): move local filesystem owner Move platform-neutral filesystem operations, listing, tree/search, and service facade into bitfun-services-core while keeping core as the remote-overlay and BitFunError compatibility layer. Add boundary checks and documentation updates for the filesystem owner split. --- docs/architecture/core-decomposition.md | 2 +- docs/plans/core-decomposition-plan.md | 5 +- scripts/check-core-boundaries.mjs | 91 ++++ src/crates/core/AGENTS.md | 5 + .../agentic/tools/implementations/ls_tool.rs | 4 +- .../core/src/infrastructure/filesystem/mod.rs | 19 +- .../core/src/service/filesystem/listing.rs | 345 +------------ .../core/src/service/filesystem/service.rs | 292 +++++------ .../core/src/service/filesystem/types.rs | 63 +-- src/crates/services-core/AGENTS.md | 7 +- src/crates/services-core/Cargo.toml | 3 + .../services-core/src/filesystem/error.rs | 37 ++ .../services-core/src/filesystem/factory.rs | 78 +++ .../services-core/src/filesystem/listing.rs | 376 +++++++++++++++ .../services-core/src/filesystem/mod.rs | 32 ++ .../src/filesystem/operations.rs} | 140 +++--- .../services-core/src/filesystem/service.rs | 455 ++++++++++++++++++ .../src/filesystem/tree.rs} | 122 ++--- .../services-core/src/filesystem/types.rs | 58 +++ src/crates/services-core/src/lib.rs | 1 + 20 files changed, 1427 insertions(+), 708 deletions(-) create mode 100644 src/crates/services-core/src/filesystem/error.rs create mode 100644 src/crates/services-core/src/filesystem/factory.rs create mode 100644 src/crates/services-core/src/filesystem/listing.rs create mode 100644 src/crates/services-core/src/filesystem/mod.rs rename src/crates/{core/src/infrastructure/filesystem/file_operations.rs => services-core/src/filesystem/operations.rs} (80%) create mode 100644 src/crates/services-core/src/filesystem/service.rs rename src/crates/{core/src/infrastructure/filesystem/file_tree.rs => services-core/src/filesystem/tree.rs} (92%) create mode 100644 src/crates/services-core/src/filesystem/types.rs diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index d13d7180a..648350991 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -68,7 +68,7 @@ Rust 编译和链接面。 | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | | `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、host path normalization / runtime artifact URI / remote POSIX path pure contract、provider-neutral tool path resolution / absolute-path check / runtime artifact reference assembly、file guidance marker、file-read freshness comparison、oversized tool-result preview/rendering policy、tool execution result/error/invalid-call presentation policy、allowed-list / collapsed-tool execution gate policy、provider-neutral path policy root matching / denial message、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、session file-read state storage、tool-result filesystem writes、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade、readonly/enabled 过滤规则、provider-neutral tool path resolution / runtime artifact reference assembly、file guidance/freshness policy、oversized result rendering、tool execution presentation 与 path policy 判定已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | -| `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | +| `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;通用本地 filesystem operations/tree/search/listing/service facade 已迁入;config/workspace/runtime/persistence 以及 filesystem 的 remote overlay、product runtime binding 仍在 core | | `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper、remote file command / response assembly、dialog/cancel/execution accepted response helper、workspace/session response assembly helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、persistence/workspace service reads 与 product execution 仍在 core | | `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、MiniApp create/update/draft/apply/import state transition、storage/builtin contract、imported meta timestamp policy、seed meta timestamp policy、runtime detection concrete owner、worker/host/export 纯决策已迁入;filesystem IO、worker process、host dispatch execution、built-in asset seeding、export skeleton 与 Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 25c76e377..2f8bb2417 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -988,10 +988,10 @@ cargo check --workspace **当前安全迁移状态(2026-05-11):** -- 已迁移到 `bitfun-services-core`:`service::system`、`service::diff`、`util::process_manager`、`service::session::types`、`service::session_usage::{types,classifier,redaction,render}`、`service::token_usage::types`。 +- 已迁移到 `bitfun-services-core`:`service::system`、`service::diff`、`util::process_manager`、`service::session::types`、`service::session_usage::{types,classifier,redaction,render}`、`service::token_usage::types`、通用本地 `infrastructure::filesystem` operations/tree/search/listing 和 `service::filesystem` service facade。 - `SessionKind` 已移动到 `bitfun-core-types`,core 的 `agentic::core::SessionKind` 与 `service::session::SessionKind` 继续通过 re-export 兼容。 - 最新主干新增的 Deep Review `deep_review_run_manifest` / `deep_review_cache` 字段已随 `service::session::types` 一起迁移,并保留原有序列化别名与 round-trip 测试;这不是新的 P2 行为变更。 -- `service::config`、`workspace`、`workspace_runtime`、`filesystem`、`runtime`、`i18n`、`bootstrap`、`project_context` 仍保留在 core;继续迁移前需要先确认 `BitFunError`、`PathManager`、workspace/provider ports 的边界方案。 +- `service::config`、`workspace`、`workspace_runtime`、`runtime`、`i18n`、`bootstrap`、`project_context` 仍保留在 core;filesystem 侧的 remote workspace overlay、`BitFunError` 映射、MiniApp filesystem IO、tool-result persistence、PathManager 绑定和产品 runtime 接线仍保留在 core。继续迁移前需要先确认 `PathManager`、workspace/provider ports 和产品持久化边界方案。 **验证:** @@ -1314,6 +1314,7 @@ product-full = ["miniapp", "function-agents"] - 2026-05-21 function-agent response-policy update: Git commit-message and Startchat prompt templates, AI response JSON extraction, JSON repair, JSON-string parsers, and domain error mapping now live in `bitfun-product-domains::function_agents`; core still owns AI service calls, Git service adapters, provider acquisition, AI transport errors, and runtime analysis orchestration. - 2026-05-25 HR-B update: MiniApp create/update/draft prepare/draft sync/permission update/draft apply/import 的纯 manager state transitions 已移入 `bitfun-product-domains::miniapp::lifecycle` / `MiniAppRuntimeFacade`,imported meta 的 id/timestamp 规则也已归入 product-domain helper;内置 MiniApp seed meta 的 id/timestamp/preserved-created-at 规则已移入 `bitfun-product-domains::miniapp::builtin`。 - 2026-05-26 HR-B expansion: MiniApp concrete runtime detector owner 已移入 `bitfun-product-domains::miniapp::runtime`,包含 PATH lookup、version-manager directory scan 与 `--version` process check;core `miniapp::runtime_detect` 只保留兼容 facade。core 仍负责 compile 调度、source/storage/path/marker filesystem IO、customization metadata IO、worker process、host dispatch、built-in asset include/seeding/recompile,以及 function-agent Git/AI concrete service 调用。 +- 2026-05-26 filesystem owner update: 通用本地 filesystem operations、tree/search、directory listing、统一 `FileSystemError` 和 service facade 已迁入 `bitfun-services-core::filesystem`;core `infrastructure::filesystem` / `service::filesystem::{types,listing}` 只保留 re-export,core `service::filesystem::FileSystemService` 只保留 remote workspace overlay、legacy `BitFunError` 映射和旧 API 兼容。MiniApp filesystem IO、tool-result persistence、workspace `PathManager` 绑定、remote SSH runtime 和产品持久化接线仍显式 core-owned。 - boundary check 已补充 product-domain owner anchor:`MiniAppStoragePort` / `MiniAppRuntimePort` 的 core adapter、MiniApp host/customization/builtin 纯 contract、MiniApp manager preflight tests、function-agent Git adapter、prompt/response policy helper 必须存在,防止把 port contract 或 response policy 误读成 storage IO、worker process、host dispatch、customization draft runtime、builtin asset seeding runtime 或 Git/AI service runtime 已完成迁移。 - miniapp runtime/storage/manager/host dispatch/exporter/builtin 与 function-agent 运行逻辑继续迁移前,需要先确认 agent/tool/provider port 和 Git/AI service 边界。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 53db22c53..86c1164d6 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -433,6 +433,21 @@ const ownerCrateFeatureAssemblyRules = [ ]; const facadeOnlyFiles = [ + { + path: 'src/crates/core/src/infrastructure/filesystem/mod.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem infrastructure facade must only re-export the services-core owner crate', + }, + { + path: 'src/crates/core/src/service/filesystem/listing.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem listing facade must only re-export the services-core owner crate', + }, + { + path: 'src/crates/core/src/service/filesystem/types.rs', + importPrefix: 'bitfun_services_core::filesystem', + reason: 'core filesystem DTO facade must only re-export the services-core owner crate', + }, { path: 'src/crates/core/src/service/git/git_service.rs', importPrefix: 'bitfun_services_integrations::git', @@ -511,6 +526,36 @@ const facadeOnlyFiles = [ ]; const forbiddenContentRules = [ + { + path: 'src/crates/core/src/service/filesystem/service.rs', + patterns: [ + { + regex: /\btokio::fs::/, + message: + 'core filesystem service must not own async local filesystem IO; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bstd::fs::/, + message: + 'core filesystem service must not own sync local filesystem IO; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bignore::WalkBuilder\b/, + message: + 'core filesystem service must not own local file walking/search implementation; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bsha2::/, + message: + 'core filesystem service must not own editor-sync hashing implementation; use bitfun-services-core filesystem primitives', + }, + { + regex: /\bbase64::/, + message: + 'core filesystem service must not own binary file encoding implementation; use bitfun-services-core filesystem primitives', + }, + ], + }, { path: 'src/crates/core/src/miniapp/runtime_detect.rs', patterns: [ @@ -1752,6 +1797,52 @@ const forbiddenContentUnderRules = [ ]; const requiredContentRules = [ + { + path: 'src/crates/services-core/src/filesystem/mod.rs', + reason: + 'services-core filesystem owner must expose local filesystem primitives behind a single module boundary', + patterns: [ + { + regex: /mod error;/, + message: 'filesystem owner must expose its error boundary', + }, + { + regex: /mod operations;/, + message: 'filesystem owner must expose local file operation primitives', + }, + { + regex: /mod tree;/, + message: 'filesystem owner must expose local file tree/search primitives', + }, + { + regex: /pub use error::\{FileSystemError, FileSystemResult\};/, + message: 'filesystem owner must re-export the unified filesystem error type', + }, + { + regex: /pub use service::FileSystemService;/, + message: 'filesystem owner must keep the consolidated service facade', + }, + ], + }, + { + path: 'src/crates/core/src/service/filesystem/service.rs', + reason: + 'core filesystem service may keep remote-workspace overlay and BitFunError compatibility, but local filesystem owner must remain services-core', + patterns: [ + { + regex: /lookup_remote_connection_with_hint/, + message: 'core filesystem wrapper must preserve remote workspace connection disambiguation', + }, + { + regex: /get_remote_workspace_manager/, + message: 'core filesystem wrapper must preserve existing remote file service lookup', + }, + { + regex: /map_filesystem_error/, + message: 'core filesystem wrapper must map services-core errors at the compatibility boundary', + }, + ], + }, { path: 'src/crates/core/Cargo.toml', reason: diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 825de1e33..3735013cc 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -103,6 +103,11 @@ SessionManager → Session → DialogTurn → ModelRound - Keep concrete remote SSH runtime code behind `ssh-remote`. No-default builds may keep workspace identity helpers and explicit unsupported stubs, but must not compile russh-backed SSH/SFTP/terminal/search runtime modules. +- Generic local filesystem operations, tree/search, listing, and filesystem DTOs + live in `bitfun-services-core::filesystem`. Core may keep compatibility + re-exports, remote workspace overlay, `BitFunError` mapping, MiniApp + filesystem IO, tool-result persistence, `PathManager` binding, and product + runtime wiring. - Keep no-default `bitfun-core` as a runtime-surface-light facade, not a claimed dependency-light build. Full product runtime modules such as agentic, MiniApp/function-agent, Git/MCP, remote-connect, review-platform, snapshot, diff --git a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs index 407150613..2afea6a2a 100644 --- a/src/crates/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ls_tool.rs @@ -323,8 +323,8 @@ Usage: } // Local: original implementation - let entries = - list_directory_entries(&resolved.resolved_path, limit).map_err(BitFunError::tool)?; + let entries = list_directory_entries(&resolved.resolved_path, limit) + .map_err(|error| BitFunError::tool(error.to_string()))?; let entries_json = entries .iter() diff --git a/src/crates/core/src/infrastructure/filesystem/mod.rs b/src/crates/core/src/infrastructure/filesystem/mod.rs index e1b0f2349..e2e3830e2 100644 --- a/src/crates/core/src/infrastructure/filesystem/mod.rs +++ b/src/crates/core/src/infrastructure/filesystem/mod.rs @@ -1,16 +1,9 @@ -//! Filesystem infrastructure -//! -//! File operations and file tree building. +//! Filesystem infrastructure compatibility facade. -pub mod file_operations; -pub mod file_tree; - -pub use file_operations::{ - normalize_text_for_editor_disk_sync, FileInfo, FileOperationOptions, FileOperationService, - FileReadResult, FileWriteResult, -}; -pub use file_tree::{ - BatchedFileSearchProgressSink, FileContentSearchOptions, FileNameSearchOptions, +pub use bitfun_services_core::filesystem::{ + normalize_text_for_editor_disk_sync, BatchedFileSearchProgressSink, FileContentSearchOptions, + FileInfo, FileNameSearchOptions, FileOperationOptions, FileOperationService, FileReadResult, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, - FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, SearchMatchType, + FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, FileWriteResult, + SearchMatchType, }; diff --git a/src/crates/core/src/service/filesystem/listing.rs b/src/crates/core/src/service/filesystem/listing.rs index fa4a7be74..7021f2e83 100644 --- a/src/crates/core/src/service/filesystem/listing.rs +++ b/src/crates/core/src/service/filesystem/listing.rs @@ -1,341 +1,6 @@ -use crate::util::errors::*; -use ignore::gitignore::Gitignore; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; +//! Filesystem listing compatibility facade. -#[derive(Debug, Clone)] -pub struct DirectoryListingEntry { - pub path: PathBuf, - pub is_dir: bool, - pub depth: usize, - pub modified_time: SystemTime, -} - -#[derive(Debug, Clone)] -pub struct FormattedDirectoryListing { - pub reached_limit: bool, - pub text: String, -} - -#[derive(Debug, Clone)] -struct TreeEntry { - path: String, - is_dir: bool, - modified_time: SystemTime, -} - -pub fn list_directory_entries( - dir_path: &str, - limit: usize, -) -> BitFunResult> { - let path = Path::new(dir_path); - if !path.exists() { - return Err(BitFunError::service(format!( - "Directory does not exist: {}", - dir_path - ))); - } - - let mut result = Vec::new(); - let mut queue = VecDeque::new(); - - if let Ok(metadata) = fs::symlink_metadata(path) { - if !metadata.file_type().is_symlink() && metadata.is_dir() { - if let Ok(entries) = fs::read_dir(path) { - for dir_entry in entries.flatten() { - let entry_path = dir_entry.path(); - if let Ok(entry_metadata) = fs::symlink_metadata(&entry_path) { - if !entry_metadata.file_type().is_symlink() { - queue.push_back(DirectoryListingEntry { - path: entry_path, - is_dir: entry_metadata.is_dir(), - depth: 1, - modified_time: entry_metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH), - }); - } - } - } - } - } - } - - let gitignore = load_gitignore(path); - - let special_folders = [ - Path::new("/"), - Path::new("/home"), - Path::new("/Users"), - Path::new("/System"), - Path::new("/Windows"), - Path::new("/Program Files"), - Path::new("/Program Files (x86)"), - ]; - - let excluded_folders = [ - "node_modules", - "__pycache__", - "env", - "venv", - "target", - "target/dependency", - "build", - "build/dependencies", - "dist", - "out", - "bundle", - "vendor", - "tmp", - "temp", - "deps", - "pkg", - "Pods", - ".git", - "Cargo.lock", - ]; - - while !queue.is_empty() && result.len() < limit { - let current_level_size = queue.len(); - let mut level_complete = true; - - for _ in 0..current_level_size { - if result.len() >= limit { - level_complete = false; - break; - } - - let Some(entry) = queue.pop_front() else { - continue; - }; - let entry_path = &entry.path; - - let is_special = special_folders - .iter() - .any(|special| entry_path == *special || entry_path.starts_with(special)); - - let folder_name = entry_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - - let is_excluded = if entry.depth == 0 { - false - } else { - excluded_folders.contains(&folder_name) - || (folder_name.starts_with('.') && folder_name != "." && folder_name != "..") - }; - - let is_gitignored = if let Some(ref gitignore) = gitignore { - gitignore.matched(entry_path, entry.is_dir).is_ignore() - } else { - false - }; - - let is_symlink = if let Ok(metadata) = fs::symlink_metadata(entry_path) { - metadata.file_type().is_symlink() - } else { - false - }; - - if !is_excluded && !is_gitignored && !is_symlink { - result.push(entry.clone()); - } - - if entry.is_dir && !is_special && !is_excluded && !is_gitignored && !is_symlink { - if let Ok(entries) = fs::read_dir(entry_path) { - for dir_entry in entries.flatten() { - let path = dir_entry.path(); - if let Ok(metadata) = fs::symlink_metadata(&path) { - if !metadata.file_type().is_symlink() { - queue.push_back(DirectoryListingEntry { - path, - is_dir: metadata.is_dir(), - depth: entry.depth + 1, - modified_time: metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH), - }); - } - } - } - } - } - } - - if !level_complete { - let excess = result.len().saturating_sub(limit); - if excess > 0 { - result.truncate(result.len() - excess); - } - break; - } - } - - Ok(result) -} - -pub fn format_directory_listing(entries: &[DirectoryListingEntry], dir_path: &str) -> String { - let base_path = Path::new(dir_path); - let mut result = String::new(); - result.push_str(&format!( - "{}\n", - base_path.display().to_string().replace('\\', "/") - )); - - let mut tree: HashMap> = HashMap::new(); - let mut added_dirs: HashSet = HashSet::new(); - - for entry in entries { - if let Ok(rel_path) = entry.path.strip_prefix(base_path) { - if let Some(rel_str) = rel_path.to_str() { - let normalized = rel_str.replace('\\', "/"); - - if normalized.is_empty() { - continue; - } - - let final_path = if entry.is_dir && !normalized.ends_with('/') { - format!("{}/", normalized) - } else { - normalized.clone() - }; - - let parts: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); - for i in 0..parts.len() { - let is_final_entry = i == parts.len() - 1 && !entry.is_dir; - if is_final_entry { - break; - } - - let ancestor_path = format!("{}/", parts[..=i].join("/")); - let ancestor_parent = if i == 0 { - "/".to_string() - } else { - format!("{}/", parts[..i].join("/")) - }; - - if !added_dirs.contains(&ancestor_path) { - added_dirs.insert(ancestor_path.clone()); - tree.entry(ancestor_parent).or_default().push(TreeEntry { - path: ancestor_path, - is_dir: true, - modified_time: entry.modified_time, - }); - } - } - - if entry.is_dir && added_dirs.contains(&final_path) { - continue; - } - - let parts_for_parent: Vec<&str> = final_path.split('/').collect(); - let parent = if entry.is_dir { - if parts_for_parent.len() > 2 { - format!( - "{}/", - parts_for_parent[..parts_for_parent.len() - 2].join("/") - ) - } else { - "/".to_string() - } - } else if parts_for_parent.len() > 1 { - format!( - "{}/", - parts_for_parent[..parts_for_parent.len() - 1].join("/") - ) - } else { - "/".to_string() - }; - - if entry.is_dir { - added_dirs.insert(final_path.clone()); - } - - tree.entry(parent).or_default().push(TreeEntry { - path: final_path, - is_dir: entry.is_dir, - modified_time: entry.modified_time, - }); - } - } - } - - for children in tree.values_mut() { - children.sort_by(|a, b| match b.modified_time.cmp(&a.modified_time) { - std::cmp::Ordering::Equal => a.path.cmp(&b.path), - other => other, - }); - } - - fn format_tree( - tree: &HashMap>, - parent: &str, - prefix: &str, - result: &mut String, - ) { - if let Some(children) = tree.get(parent) { - let count = children.len(); - for (i, child) in children.iter().enumerate() { - let is_last = i == count - 1; - let name = if child.is_dir { - let dir_name = child.path[..child.path.len() - 1] - .rsplit('/') - .next() - .unwrap_or(""); - format!("{}/", dir_name) - } else { - child.path.rsplit('/').next().unwrap_or("").to_string() - }; - - let connector = if is_last { "└── " } else { "├── " }; - result.push_str(&format!("{}{}{}\n", prefix, connector, name)); - - if child.is_dir { - let child_prefix = if is_last { - format!("{} ", prefix) - } else { - format!("{}│ ", prefix) - }; - format_tree(tree, &child.path, &child_prefix, result); - } - } - } - } - - format_tree(&tree, "/", "", &mut result); - - if result.ends_with('\n') { - result.pop(); - } - - result -} - -pub fn get_formatted_directory_listing( - dir_path: &str, - limit: usize, -) -> BitFunResult { - let entries = list_directory_entries(dir_path, limit)?; - let reached_limit = entries.len() >= limit; - let text = format_directory_listing(&entries, dir_path); - Ok(FormattedDirectoryListing { - reached_limit, - text, - }) -} - -fn load_gitignore(dir_path: &Path) -> Option { - let gitignore_path = dir_path.join(".gitignore"); - - if gitignore_path.exists() { - match Gitignore::new(gitignore_path) { - (gitignore, None) => Some(gitignore), - (_, Some(_)) => None, - } - } else { - None - } -} +pub use bitfun_services_core::filesystem::{ + format_directory_listing, get_formatted_directory_listing, list_directory_entries, + DirectoryListingEntry, FormattedDirectoryListing, +}; diff --git a/src/crates/core/src/service/filesystem/service.rs b/src/crates/core/src/service/filesystem/service.rs index 68236979d..a66ec9566 100644 --- a/src/crates/core/src/service/filesystem/service.rs +++ b/src/crates/core/src/service/filesystem/service.rs @@ -1,11 +1,12 @@ use crate::infrastructure::{ - FileContentSearchOptions, FileInfo, FileNameSearchOptions, FileOperationOptions, - FileOperationService, FileReadResult, FileSearchOutcome, FileSearchProgressSink, - FileSearchResult, FileTreeNode, FileTreeService, FileWriteResult, + FileInfo, FileOperationOptions, FileReadResult, FileSearchOutcome, FileSearchProgressSink, + FileSearchResult, FileTreeNode, FileTreeStatistics, FileWriteResult, }; use crate::util::elapsed_ms_u64; use crate::util::errors::*; +use bitfun_services_core::filesystem::FileSystemService as BaseFileSystemService; use log::debug; +use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::Arc; @@ -13,21 +14,55 @@ use super::types::{DirectoryScanResult, DirectoryStats, FileSearchOptions, FileS const SLOW_FILESYSTEM_OPERATION_LOG_MS: u64 = 500; +fn map_filesystem_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(error.to_string()) +} + +async fn read_remote_directory_contents( + path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Option>> { + let entry = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + path, + preferred_remote_connection_id, + ) + .await?; + + let manager = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager()?; + let file_service = manager.get_file_service().await?; + + Some( + match file_service.read_dir(&entry.connection_id, path).await { + Ok(entries) => Ok(entries + .into_iter() + .filter(|entry| entry.name != "." && entry.name != "..") + .map(|entry| { + FileTreeNode::new( + entry.path.clone(), + entry.name.clone(), + entry.path, + entry.is_dir, + ) + }) + .collect()), + Err(error) => Err(BitFunError::service(format!( + "Failed to read remote directory: {}", + error + ))), + }, + ) +} + /// Unified file system service pub struct FileSystemService { - file_tree_service: Arc, - file_operation_service: Arc, + inner: BaseFileSystemService, } impl FileSystemService { /// Creates a new file system service. pub fn new(config: FileSystemConfig) -> Self { - let file_tree_service = Arc::new(FileTreeService::new(config.tree_options)); - let file_operation_service = Arc::new(FileOperationService::new(config.operation_options)); - Self { - file_tree_service, - file_operation_service, + inner: BaseFileSystemService::new(config), } } @@ -49,11 +84,15 @@ impl FileSystemService { preferred_remote_connection_id: Option<&str>, ) -> BitFunResult> { let started_at = std::time::Instant::now(); - let tree = self - .file_tree_service - .build_tree_with_remote_hint(root_path, preferred_remote_connection_id) - .await - .map_err(BitFunError::service)?; + let tree = if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { + self.get_directory_contents_with_remote_hint(root_path, preferred_remote_connection_id) + .await? + } else { + self.inner + .build_file_tree_with_remote_hint(root_path, preferred_remote_connection_id) + .await + .map_err(map_filesystem_error)? + }; let duration_ms = elapsed_ms_u64(started_at); if duration_ms >= SLOW_FILESYSTEM_OPERATION_LOG_MS { @@ -73,10 +112,30 @@ impl FileSystemService { pub async fn scan_directory(&self, root_path: &str) -> BitFunResult { let start_time = std::time::Instant::now(); - let (files, statistics) = self - .file_tree_service - .build_tree_with_stats(root_path) - .await?; + let (files, statistics) = + if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { + let nodes = self + .get_directory_contents_with_remote_hint(root_path, None) + .await?; + let stats = FileTreeStatistics { + total_files: nodes.iter().filter(|node| !node.is_directory).count(), + total_directories: nodes.iter().filter(|node| node.is_directory).count(), + total_size_bytes: 0, + max_depth_reached: 0, + file_type_counts: HashMap::new(), + large_files: Vec::new(), + symlinks_count: 0, + hidden_files_count: 0, + }; + (nodes, stats) + } else { + let scan_result = self + .inner + .scan_directory(root_path) + .await + .map_err(map_filesystem_error)?; + (scan_result.files, scan_result.statistics) + }; let scan_time_ms = elapsed_ms_u64(start_time); @@ -109,10 +168,16 @@ impl FileSystemService { path: &str, preferred_remote_connection_id: Option<&str>, ) -> BitFunResult> { - self.file_tree_service + if let Some(result) = + read_remote_directory_contents(path, preferred_remote_connection_id).await + { + return result; + } + + self.inner .get_directory_contents_with_remote_hint(path, preferred_remote_connection_id) .await - .map_err(BitFunError::service) + .map_err(map_filesystem_error) } /// Searches files. @@ -122,38 +187,10 @@ impl FileSystemService { pattern: &str, options: FileSearchOptions, ) -> BitFunResult> { - let mut results = self - .file_tree_service - .search_files_with_options( - root_path, - pattern, - options.include_content, - options.case_sensitive, - options.use_regex, - options.whole_word, - ) - .await?; - - if let Some(extensions) = &options.file_extensions { - results.retain(|result| { - if result.is_directory { - true - } else { - let path = std::path::Path::new(&result.path); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - extensions.contains(&ext.to_lowercase()) - } else { - false - } - } - }); - } - - if let Some(max_results) = options.max_results { - results.truncate(max_results); - } - - Ok(results) + self.inner + .search_files(root_path, pattern, options) + .await + .map_err(map_filesystem_error) } pub async fn search_file_names( @@ -175,43 +212,16 @@ impl FileSystemService { cancel_flag: Option>, progress_sink: Option>, ) -> BitFunResult { - let mut outcome = self - .file_tree_service + self.inner .search_file_names_with_progress( root_path, pattern, - FileNameSearchOptions { - case_sensitive: options.case_sensitive, - use_regex: options.use_regex, - whole_word: options.whole_word, - max_results: options.max_results.unwrap_or(10_000), - include_directories: options.include_directories, - cancel_flag, - }, + options, + cancel_flag, progress_sink, ) - .await?; - - if let Some(extensions) = &options.file_extensions { - outcome.results.retain(|result| { - if result.is_directory { - true - } else { - let path = std::path::Path::new(&result.path); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - extensions.contains(&ext.to_lowercase()) - } else { - false - } - } - }); - } - - if let Some(max_results) = options.max_results { - outcome.results.truncate(max_results); - } - - Ok(outcome) + .await + .map_err(map_filesystem_error) } pub async fn search_file_contents( @@ -233,44 +243,24 @@ impl FileSystemService { cancel_flag: Option>, progress_sink: Option>, ) -> BitFunResult { - let mut outcome = self - .file_tree_service + self.inner .search_file_contents_with_progress( root_path, pattern, - FileContentSearchOptions { - case_sensitive: options.case_sensitive, - use_regex: options.use_regex, - whole_word: options.whole_word, - max_results: options.max_results.unwrap_or(10_000), - max_file_size_bytes: 10 * 1024 * 1024, - cancel_flag, - }, + options, + cancel_flag, progress_sink, ) - .await?; - - if let Some(extensions) = &options.file_extensions { - outcome.results.retain(|result| { - let path = std::path::Path::new(&result.path); - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - extensions.contains(&ext.to_lowercase()) - } else { - false - } - }); - } - - if let Some(max_results) = options.max_results { - outcome.results.truncate(max_results); - } - - Ok(outcome) + .await + .map_err(map_filesystem_error) } /// Reads a file. pub async fn read_file(&self, file_path: &str) -> BitFunResult { - self.file_operation_service.read_file(file_path).await + self.inner + .read_file(file_path) + .await + .map_err(map_filesystem_error) } /// Writes a file. @@ -279,10 +269,10 @@ impl FileSystemService { file_path: &str, content: &str, ) -> BitFunResult { - let options = FileOperationOptions::default(); - self.file_operation_service - .write_file(file_path, content, options) + self.inner + .write_file(file_path, content) .await + .map_err(map_filesystem_error) } /// Writes a file with options. @@ -292,79 +282,97 @@ impl FileSystemService { content: &str, options: FileOperationOptions, ) -> BitFunResult { - self.file_operation_service - .write_file(file_path, content, options) + self.inner + .write_file_with_options(file_path, content, options) .await + .map_err(map_filesystem_error) } /// Copies a file. pub async fn copy_file(&self, from: &str, to: &str) -> BitFunResult { - self.file_operation_service.copy_file(from, to).await + self.inner + .copy_file(from, to) + .await + .map_err(map_filesystem_error) } /// Moves a file. pub async fn move_file(&self, from: &str, to: &str) -> BitFunResult<()> { - self.file_operation_service.move_file(from, to).await + self.inner + .move_file(from, to) + .await + .map_err(map_filesystem_error) } /// Deletes a file. pub async fn delete_file(&self, file_path: &str) -> BitFunResult<()> { - self.file_operation_service.delete_file(file_path).await + self.inner + .delete_file(file_path) + .await + .map_err(map_filesystem_error) } /// Gets file info. pub async fn get_file_info(&self, file_path: &str) -> BitFunResult { - self.file_operation_service.get_file_info(file_path).await + self.inner + .get_file_info(file_path) + .await + .map_err(map_filesystem_error) } /// Creates a directory. pub async fn create_directory(&self, dir_path: &str) -> BitFunResult<()> { - self.file_operation_service.create_directory(dir_path).await + self.inner + .create_directory(dir_path) + .await + .map_err(map_filesystem_error) } /// Deletes a directory. pub async fn delete_directory(&self, dir_path: &str, recursive: bool) -> BitFunResult<()> { - self.file_operation_service + self.inner .delete_directory(dir_path, recursive) .await + .map_err(map_filesystem_error) } /// Checks whether the path exists. pub async fn exists(&self, path: &str) -> bool { - std::path::Path::new(path).exists() + self.inner.exists(path).await } /// Checks whether the path is a directory. pub async fn is_directory(&self, path: &str) -> bool { - std::path::Path::new(path).is_dir() + self.inner.is_directory(path).await } /// Checks whether the path is a file. pub async fn is_file(&self, path: &str) -> bool { - std::path::Path::new(path).is_file() + self.inner.is_file(path).await } /// Gets the file size. pub async fn get_file_size(&self, file_path: &str) -> BitFunResult { - let info = self.get_file_info(file_path).await?; - Ok(info.size) + self.inner + .get_file_size(file_path) + .await + .map_err(map_filesystem_error) } /// Reads a text file quickly. pub async fn read_text_file(&self, file_path: &str) -> BitFunResult { - let result = self.read_file(file_path).await?; - if result.is_binary { - Err(BitFunError::service( - "File is binary, cannot read as text".to_string(), - )) - } else { - Ok(result.content) - } + self.inner + .read_text_file(file_path) + .await + .map_err(map_filesystem_error) } /// Writes a text file quickly. pub async fn write_text_file(&self, file_path: &str, content: &str) -> BitFunResult<()> { - self.write_file(file_path, content).await.map(|_| ()) + self.inner + .write_text_file(file_path, content) + .await + .map_err(map_filesystem_error) } /// Lists all files in a directory (recursive). @@ -437,13 +445,13 @@ impl FileSystemService { /// SHA-256 hex of on-disk content after editor-sync normalization (see `FileOperationService`). pub async fn editor_sync_content_sha256_hex(&self, file_path: &str) -> BitFunResult { - self.file_operation_service + self.inner .editor_sync_content_sha256_hex(file_path) .await + .map_err(map_filesystem_error) } pub fn editor_sync_sha256_hex_from_raw_bytes(&self, bytes: &[u8]) -> String { - self.file_operation_service - .editor_sync_sha256_hex_from_raw_bytes(bytes) + self.inner.editor_sync_sha256_hex_from_raw_bytes(bytes) } } diff --git a/src/crates/core/src/service/filesystem/types.rs b/src/crates/core/src/service/filesystem/types.rs index 3ea2e8d31..0b206968d 100644 --- a/src/crates/core/src/service/filesystem/types.rs +++ b/src/crates/core/src/service/filesystem/types.rs @@ -1,60 +1,5 @@ -use crate::infrastructure::{ - FileOperationOptions, FileTreeNode, FileTreeOptions, FileTreeStatistics, -}; -use serde::{Deserialize, Serialize}; - -/// File system service configuration -#[derive(Debug, Clone, Default)] -pub struct FileSystemConfig { - pub tree_options: FileTreeOptions, - pub operation_options: FileOperationOptions, -} - -/// Directory scan result -#[derive(Debug, Serialize, Deserialize)] -pub struct DirectoryScanResult { - pub files: Vec, - pub statistics: FileTreeStatistics, - pub scan_time_ms: u64, -} +//! Filesystem DTO compatibility facade. -/// File search options -#[derive(Debug, Clone)] -pub struct FileSearchOptions { - pub include_content: bool, - pub case_sensitive: bool, - pub use_regex: bool, - pub whole_word: bool, - pub max_results: Option, - pub file_extensions: Option>, - /// Whether to include directories in the search results - pub include_directories: bool, -} - -impl Default for FileSearchOptions { - fn default() -> Self { - Self { - include_content: false, - case_sensitive: false, - use_regex: false, - whole_word: false, - max_results: None, // No limit - file_extensions: None, - include_directories: true, // Include directories by default - } - } -} - -/// Directory statistics -#[derive(Debug, Serialize, Deserialize)] -pub struct DirectoryStats { - pub total_files: usize, - pub total_directories: usize, - pub total_size_bytes: u64, - pub total_size_mb: u64, - pub max_depth: u32, - pub most_common_extensions: Vec<(String, usize)>, - pub large_files_count: usize, - pub hidden_files_count: usize, - pub symlinks_count: usize, -} +pub use bitfun_services_core::filesystem::{ + DirectoryScanResult, DirectoryStats, FileSearchOptions, FileSystemConfig, +}; diff --git a/src/crates/services-core/AGENTS.md b/src/crates/services-core/AGENTS.md index c723b3fe0..9790ccd31 100644 --- a/src/crates/services-core/AGENTS.md +++ b/src/crates/services-core/AGENTS.md @@ -3,7 +3,9 @@ Scope: this guide applies to `src/crates/services-core`. `bitfun-services-core` owns platform-neutral service DTOs and helpers that can -compile without the full product runtime. +compile without the full product runtime. It also owns generic local filesystem +operations/tree/search/listing primitives; product crates may layer remote +workspace routing or legacy error mapping outside this crate. ## Guardrails @@ -17,6 +19,9 @@ compile without the full product runtime. - Runtime call sites that touch agent execution, scheduler state, workspace managers, filesystem orchestration, or product behavior stay in core until a reviewed port/provider design and equivalence tests exist. +- Do not add remote SSH, MiniApp storage, tool-result persistence, `PathManager` + globals, or product runtime bindings to `filesystem`; keep those in core or a + reviewed adapter/provider. - Preserve legacy core imports with facade/re-export code when ownership moves. ## Verification diff --git a/src/crates/services-core/Cargo.toml b/src/crates/services-core/Cargo.toml index 2c8da44ca..18554d4ec 100644 --- a/src/crates/services-core/Cargo.toml +++ b/src/crates/services-core/Cargo.toml @@ -14,9 +14,12 @@ bitfun-core-types = { path = "../core-types" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +base64 = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +ignore = { workspace = true } +sha2 = { workspace = true } which = { workspace = true } similar = { workspace = true } regex = { workspace = true } diff --git a/src/crates/services-core/src/filesystem/error.rs b/src/crates/services-core/src/filesystem/error.rs new file mode 100644 index 000000000..3e0568504 --- /dev/null +++ b/src/crates/services-core/src/filesystem/error.rs @@ -0,0 +1,37 @@ +//! Filesystem error boundary. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileSystemError { + message: String, +} + +pub type FileSystemResult = Result; + +impl FileSystemError { + pub fn service(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for FileSystemError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for FileSystemError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_error_display_preserves_message() { + let error = FileSystemError::service("File does not exist: sample.txt"); + assert_eq!(error.to_string(), "File does not exist: sample.txt"); + } +} diff --git a/src/crates/services-core/src/filesystem/factory.rs b/src/crates/services-core/src/filesystem/factory.rs new file mode 100644 index 000000000..7355df361 --- /dev/null +++ b/src/crates/services-core/src/filesystem/factory.rs @@ -0,0 +1,78 @@ +use super::{FileOperationOptions, FileTreeOptions}; + +use super::service::FileSystemService; +use super::types::FileSystemConfig; + +/// File system service factory +pub struct FileSystemServiceFactory; + +impl FileSystemServiceFactory { + /// Creates the default service. + pub fn create_default() -> FileSystemService { + FileSystemService::default() + } + + /// Creates a fast service (shallow scan). + pub fn create_quick() -> FileSystemService { + let config = FileSystemConfig { + tree_options: FileTreeOptions { + max_depth: Some(3), + include_hidden: false, + include_git_info: false, + include_mime_types: false, + ..Default::default() + }, + operation_options: FileOperationOptions { + max_file_size_mb: 10, + backup_on_overwrite: false, + ..Default::default() + }, + }; + FileSystemService::new(config) + } + + /// Creates a detailed service (deep scan). + pub fn create_detailed() -> FileSystemService { + let config = FileSystemConfig { + tree_options: FileTreeOptions { + max_depth: Some(10), + include_hidden: true, + include_git_info: true, + include_mime_types: true, + ..Default::default() + }, + operation_options: FileOperationOptions { + max_file_size_mb: 100, + backup_on_overwrite: true, + ..Default::default() + }, + }; + FileSystemService::new(config) + } + + /// Creates a restricted service (safe mode). + pub fn create_restricted() -> FileSystemService { + let config = FileSystemConfig { + tree_options: FileTreeOptions { + max_depth: Some(5), + include_hidden: false, + include_git_info: false, + include_mime_types: false, + ..Default::default() + }, + operation_options: FileOperationOptions { + max_file_size_mb: 1, + allowed_extensions: Some(vec![ + "txt".to_string(), + "md".to_string(), + "json".to_string(), + "yaml".to_string(), + "yml".to_string(), + ]), + backup_on_overwrite: true, + ..Default::default() + }, + }; + FileSystemService::new(config) + } +} diff --git a/src/crates/services-core/src/filesystem/listing.rs b/src/crates/services-core/src/filesystem/listing.rs new file mode 100644 index 000000000..683222596 --- /dev/null +++ b/src/crates/services-core/src/filesystem/listing.rs @@ -0,0 +1,376 @@ +use super::error::{FileSystemError, FileSystemResult}; +use ignore::gitignore::Gitignore; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[derive(Debug, Clone)] +pub struct DirectoryListingEntry { + pub path: PathBuf, + pub is_dir: bool, + pub depth: usize, + pub modified_time: SystemTime, +} + +#[derive(Debug, Clone)] +pub struct FormattedDirectoryListing { + pub reached_limit: bool, + pub text: String, +} + +#[derive(Debug, Clone)] +struct TreeEntry { + path: String, + is_dir: bool, + modified_time: SystemTime, +} + +pub fn list_directory_entries( + dir_path: &str, + limit: usize, +) -> FileSystemResult> { + let path = Path::new(dir_path); + if !path.exists() { + return Err(FileSystemError::service(format!( + "Directory does not exist: {}", + dir_path + ))); + } + + let mut result = Vec::new(); + let mut queue = VecDeque::new(); + + if let Ok(metadata) = fs::symlink_metadata(path) { + if !metadata.file_type().is_symlink() && metadata.is_dir() { + if let Ok(entries) = fs::read_dir(path) { + for dir_entry in entries.flatten() { + let entry_path = dir_entry.path(); + if let Ok(entry_metadata) = fs::symlink_metadata(&entry_path) { + if !entry_metadata.file_type().is_symlink() { + queue.push_back(DirectoryListingEntry { + path: entry_path, + is_dir: entry_metadata.is_dir(), + depth: 1, + modified_time: entry_metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH), + }); + } + } + } + } + } + } + + let gitignore = load_gitignore(path); + + let special_folders = [ + Path::new("/"), + Path::new("/home"), + Path::new("/Users"), + Path::new("/System"), + Path::new("/Windows"), + Path::new("/Program Files"), + Path::new("/Program Files (x86)"), + ]; + + let excluded_folders = [ + "node_modules", + "__pycache__", + "env", + "venv", + "target", + "target/dependency", + "build", + "build/dependencies", + "dist", + "out", + "bundle", + "vendor", + "tmp", + "temp", + "deps", + "pkg", + "Pods", + ".git", + "Cargo.lock", + ]; + + while !queue.is_empty() && result.len() < limit { + let current_level_size = queue.len(); + let mut level_complete = true; + + for _ in 0..current_level_size { + if result.len() >= limit { + level_complete = false; + break; + } + + let Some(entry) = queue.pop_front() else { + continue; + }; + let entry_path = &entry.path; + + let is_special = special_folders + .iter() + .any(|special| entry_path == *special || entry_path.starts_with(special)); + + let folder_name = entry_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + let is_excluded = if entry.depth == 0 { + false + } else { + excluded_folders.contains(&folder_name) + || (folder_name.starts_with('.') && folder_name != "." && folder_name != "..") + }; + + let is_gitignored = if let Some(ref gitignore) = gitignore { + gitignore.matched(entry_path, entry.is_dir).is_ignore() + } else { + false + }; + + let is_symlink = if let Ok(metadata) = fs::symlink_metadata(entry_path) { + metadata.file_type().is_symlink() + } else { + false + }; + + if !is_excluded && !is_gitignored && !is_symlink { + result.push(entry.clone()); + } + + if entry.is_dir && !is_special && !is_excluded && !is_gitignored && !is_symlink { + if let Ok(entries) = fs::read_dir(entry_path) { + for dir_entry in entries.flatten() { + let path = dir_entry.path(); + if let Ok(metadata) = fs::symlink_metadata(&path) { + if !metadata.file_type().is_symlink() { + queue.push_back(DirectoryListingEntry { + path, + is_dir: metadata.is_dir(), + depth: entry.depth + 1, + modified_time: metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH), + }); + } + } + } + } + } + } + + if !level_complete { + let excess = result.len().saturating_sub(limit); + if excess > 0 { + result.truncate(result.len() - excess); + } + break; + } + } + + Ok(result) +} + +pub fn format_directory_listing(entries: &[DirectoryListingEntry], dir_path: &str) -> String { + let base_path = Path::new(dir_path); + let mut result = String::new(); + result.push_str(&format!( + "{}\n", + base_path.display().to_string().replace('\\', "/") + )); + + let mut tree: HashMap> = HashMap::new(); + let mut added_dirs: HashSet = HashSet::new(); + + for entry in entries { + if let Ok(rel_path) = entry.path.strip_prefix(base_path) { + if let Some(rel_str) = rel_path.to_str() { + let normalized = rel_str.replace('\\', "/"); + + if normalized.is_empty() { + continue; + } + + let final_path = if entry.is_dir && !normalized.ends_with('/') { + format!("{}/", normalized) + } else { + normalized.clone() + }; + + let parts: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + for i in 0..parts.len() { + let is_final_entry = i == parts.len() - 1 && !entry.is_dir; + if is_final_entry { + break; + } + + let ancestor_path = format!("{}/", parts[..=i].join("/")); + let ancestor_parent = if i == 0 { + "/".to_string() + } else { + format!("{}/", parts[..i].join("/")) + }; + + if !added_dirs.contains(&ancestor_path) { + added_dirs.insert(ancestor_path.clone()); + tree.entry(ancestor_parent).or_default().push(TreeEntry { + path: ancestor_path, + is_dir: true, + modified_time: entry.modified_time, + }); + } + } + + if entry.is_dir && added_dirs.contains(&final_path) { + continue; + } + + let parts_for_parent: Vec<&str> = final_path.split('/').collect(); + let parent = if entry.is_dir { + if parts_for_parent.len() > 2 { + format!( + "{}/", + parts_for_parent[..parts_for_parent.len() - 2].join("/") + ) + } else { + "/".to_string() + } + } else if parts_for_parent.len() > 1 { + format!( + "{}/", + parts_for_parent[..parts_for_parent.len() - 1].join("/") + ) + } else { + "/".to_string() + }; + + if entry.is_dir { + added_dirs.insert(final_path.clone()); + } + + tree.entry(parent).or_default().push(TreeEntry { + path: final_path, + is_dir: entry.is_dir, + modified_time: entry.modified_time, + }); + } + } + } + + for children in tree.values_mut() { + children.sort_by(|a, b| match b.modified_time.cmp(&a.modified_time) { + std::cmp::Ordering::Equal => a.path.cmp(&b.path), + other => other, + }); + } + + fn format_tree( + tree: &HashMap>, + parent: &str, + prefix: &str, + result: &mut String, + ) { + if let Some(children) = tree.get(parent) { + let count = children.len(); + for (i, child) in children.iter().enumerate() { + let is_last = i == count - 1; + let name = if child.is_dir { + let dir_name = child.path[..child.path.len() - 1] + .rsplit('/') + .next() + .unwrap_or(""); + format!("{}/", dir_name) + } else { + child.path.rsplit('/').next().unwrap_or("").to_string() + }; + + let connector = if is_last { + "\u{2514}\u{2500}\u{2500} " + } else { + "\u{251c}\u{2500}\u{2500} " + }; + result.push_str(&format!("{}{}{}\n", prefix, connector, name)); + + if child.is_dir { + let child_prefix = if is_last { + format!("{} ", prefix) + } else { + format!("{}\u{2502} ", prefix) + }; + format_tree(tree, &child.path, &child_prefix, result); + } + } + } + } + + format_tree(&tree, "/", "", &mut result); + + if result.ends_with('\n') { + result.pop(); + } + + result +} + +pub fn get_formatted_directory_listing( + dir_path: &str, + limit: usize, +) -> FileSystemResult { + let entries = list_directory_entries(dir_path, limit)?; + let reached_limit = entries.len() >= limit; + let text = format_directory_listing(&entries, dir_path); + Ok(FormattedDirectoryListing { + reached_limit, + text, + }) +} + +fn load_gitignore(dir_path: &Path) -> Option { + let gitignore_path = dir_path.join(".gitignore"); + + if gitignore_path.exists() { + match Gitignore::new(gitignore_path) { + (gitignore, None) => Some(gitignore), + (_, Some(_)) => None, + } + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_directory_listing_keeps_tree_connectors() { + let base = PathBuf::from("workspace"); + let entries = vec![ + DirectoryListingEntry { + path: base.join("src"), + is_dir: true, + depth: 1, + modified_time: SystemTime::UNIX_EPOCH, + }, + DirectoryListingEntry { + path: base.join("src").join("main.rs"), + is_dir: false, + depth: 2, + modified_time: SystemTime::UNIX_EPOCH, + }, + ]; + + let listing = format_directory_listing(&entries, "workspace"); + + assert_eq!( + listing, + "workspace\n\u{2514}\u{2500}\u{2500} src/\n \u{2514}\u{2500}\u{2500} main.rs" + ); + } +} diff --git a/src/crates/services-core/src/filesystem/mod.rs b/src/crates/services-core/src/filesystem/mod.rs new file mode 100644 index 000000000..155163a70 --- /dev/null +++ b/src/crates/services-core/src/filesystem/mod.rs @@ -0,0 +1,32 @@ +//! Platform-neutral filesystem owner. +//! +//! This module owns local file operations, directory listings, file-tree +//! construction, and search primitives. Product/runtime adapters in +//! `bitfun-core` may still layer remote-workspace routing or legacy error +//! mapping on top of these primitives. + +mod error; +mod factory; +mod listing; +mod operations; +mod service; +mod tree; +mod types; + +pub use error::{FileSystemError, FileSystemResult}; +pub use factory::FileSystemServiceFactory; +pub use listing::{ + format_directory_listing, get_formatted_directory_listing, list_directory_entries, + DirectoryListingEntry, FormattedDirectoryListing, +}; +pub use operations::{ + normalize_text_for_editor_disk_sync, FileInfo, FileOperationOptions, FileOperationService, + FileReadResult, FileWriteResult, +}; +pub use service::FileSystemService; +pub use tree::{ + BatchedFileSearchProgressSink, FileContentSearchOptions, FileNameSearchOptions, + FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, + FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, SearchMatchType, +}; +pub use types::{DirectoryScanResult, DirectoryStats, FileSearchOptions, FileSystemConfig}; diff --git a/src/crates/core/src/infrastructure/filesystem/file_operations.rs b/src/crates/services-core/src/filesystem/operations.rs similarity index 80% rename from src/crates/core/src/infrastructure/filesystem/file_operations.rs rename to src/crates/services-core/src/filesystem/operations.rs index ba514ba92..96f10b42f 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_operations.rs +++ b/src/crates/services-core/src/filesystem/operations.rs @@ -2,20 +2,23 @@ //! //! Provides safe file read/write and operations -use crate::util::errors::*; +use super::error::{FileSystemError, FileSystemResult}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use tokio::fs; -/// Same rules as web `normalizeTextForDiskSyncComparison` (BOM strip, CRLF/CR → LF). +/// Same rules as web `normalizeTextForDiskSyncComparison` (BOM strip, CRLF/CR to LF). pub fn normalize_text_for_editor_disk_sync(text: &str) -> String { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); text.replace("\r\n", "\n").replace('\r', "\n") } fn sha256_hex(data: &[u8]) -> String { - hex::encode(Sha256::digest(data)) + Sha256::digest(data) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } pub struct FileOperationService { @@ -100,32 +103,32 @@ impl FileOperationService { } } - pub async fn read_file(&self, file_path: &str) -> BitFunResult { + pub async fn read_file(&self, file_path: &str) -> FileSystemResult { let path = Path::new(file_path); self.validate_file_access(path, false).await?; if !path.exists() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File does not exist: {}", file_path ))); } if path.is_dir() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Path is a directory: {}", file_path ))); } - let metadata = fs::metadata(path) - .await - .map_err(|e| BitFunError::service(format!("Failed to read file metadata: {}", e)))?; + let metadata = fs::metadata(path).await.map_err(|e| { + FileSystemError::service(format!("Failed to read file metadata: {}", e)) + })?; let file_size = metadata.len(); if file_size > self.max_file_size_mb * 1024 * 1024 { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File too large: {}MB (max: {}MB)", file_size / (1024 * 1024), self.max_file_size_mb @@ -146,7 +149,7 @@ impl FileOperationService { Err(_) => { let bytes = fs::read(path) .await - .map_err(|e| BitFunError::service(format!("Failed to read file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to read file: {}", e)))?; let is_binary = self.is_binary_content(&bytes); @@ -187,32 +190,35 @@ impl FileOperationService { } /// Reads the file from disk and returns the editor-sync hash (see `editor_sync_sha256_hex_from_raw_bytes`). - pub async fn editor_sync_content_sha256_hex(&self, file_path: &str) -> BitFunResult { + pub async fn editor_sync_content_sha256_hex( + &self, + file_path: &str, + ) -> FileSystemResult { let path = Path::new(file_path); self.validate_file_access(path, false).await?; if !path.exists() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File does not exist: {}", file_path ))); } if path.is_dir() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Path is a directory: {}", file_path ))); } - let metadata = fs::metadata(path) - .await - .map_err(|e| BitFunError::service(format!("Failed to read file metadata: {}", e)))?; + let metadata = fs::metadata(path).await.map_err(|e| { + FileSystemError::service(format!("Failed to read file metadata: {}", e)) + })?; let file_size = metadata.len(); if file_size > self.max_file_size_mb * 1024 * 1024 { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File too large: {}MB (max: {}MB)", file_size / (1024 * 1024), self.max_file_size_mb @@ -221,7 +227,7 @@ impl FileOperationService { let bytes = fs::read(path) .await - .map_err(|e| BitFunError::service(format!("Failed to read file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to read file: {}", e)))?; Ok(self.editor_sync_sha256_hex_from_raw_bytes(&bytes)) } @@ -231,7 +237,7 @@ impl FileOperationService { file_path: &str, content: &str, options: FileOperationOptions, - ) -> BitFunResult { + ) -> FileSystemResult { let path = Path::new(file_path); self.validate_file_access(path, true).await?; @@ -247,13 +253,13 @@ impl FileOperationService { if let Some(parent) = path.parent() { fs::create_dir_all(parent).await.map_err(|e| { - BitFunError::service(format!("Failed to create parent directory: {}", e)) + FileSystemError::service(format!("Failed to create parent directory: {}", e)) })?; } fs::write(path, content) .await - .map_err(|e| BitFunError::service(format!("Failed to write file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to write file: {}", e)))?; let bytes_written = content.len() as u64; @@ -269,7 +275,7 @@ impl FileOperationService { file_path: &str, data: &[u8], options: FileOperationOptions, - ) -> BitFunResult { + ) -> FileSystemResult { let path = Path::new(file_path); self.validate_file_access(path, true).await?; @@ -285,13 +291,13 @@ impl FileOperationService { if let Some(parent) = path.parent() { fs::create_dir_all(parent).await.map_err(|e| { - BitFunError::service(format!("Failed to create parent directory: {}", e)) + FileSystemError::service(format!("Failed to create parent directory: {}", e)) })?; } fs::write(path, data) .await - .map_err(|e| BitFunError::service(format!("Failed to write binary file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to write binary file: {}", e)))?; let bytes_written = data.len() as u64; @@ -302,7 +308,7 @@ impl FileOperationService { }) } - pub async fn copy_file(&self, from: &str, to: &str) -> BitFunResult { + pub async fn copy_file(&self, from: &str, to: &str) -> FileSystemResult { let from_trim = from.trim(); let to_trim = to.trim(); let from_path = Path::new(from_trim); @@ -315,32 +321,32 @@ impl FileOperationService { // returns false for broken symlinks and some reparse-point / cloud placeholder edge cases // even though the name is listed in the directory. if fs::symlink_metadata(from_path).await.is_err() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Source file does not exist: {}", from_trim ))); } if from_path.is_dir() { - return Err(BitFunError::service( + return Err(FileSystemError::service( "Cannot copy directory as file".to_string(), )); } if let Some(parent) = to_path.parent() { fs::create_dir_all(parent).await.map_err(|e| { - BitFunError::service(format!("Failed to create target directory: {}", e)) + FileSystemError::service(format!("Failed to create target directory: {}", e)) })?; } let bytes_copied = fs::copy(from_path, to_path) .await - .map_err(|e| BitFunError::service(format!("Failed to copy file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to copy file: {}", e)))?; Ok(bytes_copied) } - pub async fn move_file(&self, from: &str, to: &str) -> BitFunResult<()> { + pub async fn move_file(&self, from: &str, to: &str) -> FileSystemResult<()> { let from_trim = from.trim(); let to_trim = to.trim(); let from_path = Path::new(from_trim); @@ -350,7 +356,7 @@ impl FileOperationService { self.validate_file_access(to_path, true).await?; if fs::symlink_metadata(from_path).await.is_err() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Source file does not exist: {}", from_trim ))); @@ -358,57 +364,57 @@ impl FileOperationService { if let Some(parent) = to_path.parent() { fs::create_dir_all(parent).await.map_err(|e| { - BitFunError::service(format!("Failed to create target directory: {}", e)) + FileSystemError::service(format!("Failed to create target directory: {}", e)) })?; } fs::rename(from_path, to_path) .await - .map_err(|e| BitFunError::service(format!("Failed to move file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to move file: {}", e)))?; Ok(()) } - pub async fn delete_file(&self, file_path: &str) -> BitFunResult<()> { + pub async fn delete_file(&self, file_path: &str) -> FileSystemResult<()> { let path = Path::new(file_path); self.validate_file_access(path, true).await?; if !path.exists() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File does not exist: {}", file_path ))); } if path.is_dir() { - return Err(BitFunError::service( + return Err(FileSystemError::service( "Cannot delete directory as file".to_string(), )); } fs::remove_file(path) .await - .map_err(|e| BitFunError::service(format!("Failed to delete file: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to delete file: {}", e)))?; Ok(()) } - pub async fn get_file_info(&self, file_path: &str) -> BitFunResult { + pub async fn get_file_info(&self, file_path: &str) -> FileSystemResult { let path = Path::new(file_path); self.validate_file_access(path, false).await?; if !path.exists() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File does not exist: {}", file_path ))); } - let metadata = fs::metadata(path) - .await - .map_err(|e| BitFunError::service(format!("Failed to read file metadata: {}", e)))?; + let metadata = fs::metadata(path).await.map_err(|e| { + FileSystemError::service(format!("Failed to read file metadata: {}", e)) + })?; let file_name = path .file_name() @@ -459,51 +465,65 @@ impl FileOperationService { }) } - pub async fn create_directory(&self, dir_path: &str) -> BitFunResult<()> { + pub async fn create_directory(&self, dir_path: &str) -> FileSystemResult<()> { let path = Path::new(dir_path); self.validate_file_access(path, true).await?; fs::create_dir_all(path) .await - .map_err(|e| BitFunError::service(format!("Failed to create directory: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to create directory: {}", e)))?; Ok(()) } - pub async fn delete_directory(&self, dir_path: &str, recursive: bool) -> BitFunResult<()> { + pub async fn delete_directory(&self, dir_path: &str, recursive: bool) -> FileSystemResult<()> { let path = Path::new(dir_path); self.validate_file_access(path, true).await?; if !path.exists() { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Directory does not exist: {}", dir_path ))); } if !path.is_dir() { - return Err(BitFunError::service("Path is not a directory".to_string())); + return Err(FileSystemError::service( + "Path is not a directory".to_string(), + )); } if recursive { fs::remove_dir_all(path).await.map_err(|e| { - BitFunError::service(format!("Failed to delete directory recursively: {}", e)) + FileSystemError::service(format!("Failed to delete directory recursively: {}", e)) })?; } else { - fs::remove_dir(path) - .await - .map_err(|e| BitFunError::service(format!("Failed to delete directory: {}", e)))?; + fs::remove_dir(path).await.map_err(|e| { + FileSystemError::service(format!("Failed to delete directory: {}", e)) + })?; } Ok(()) } - async fn validate_file_access(&self, path: &Path, is_write: bool) -> BitFunResult<()> { + pub async fn exists(&self, path: &str) -> bool { + Path::new(path).exists() + } + + pub async fn is_directory(&self, path: &str) -> bool { + Path::new(path).is_dir() + } + + pub async fn is_file(&self, path: &str) -> bool { + Path::new(path).is_file() + } + + async fn validate_file_access(&self, path: &Path, is_write: bool) -> FileSystemResult<()> { for restricted in &self.restricted_paths { if path.starts_with(restricted) { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "Access denied: path is in restricted list: {:?}", path ))); @@ -513,7 +533,7 @@ impl FileOperationService { if let Some(allowed_extensions) = &self.allowed_extensions { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { if !allowed_extensions.contains(&ext.to_lowercase()) { - return Err(BitFunError::service(format!( + return Err(FileSystemError::service(format!( "File extension not allowed: {}", ext ))); @@ -525,14 +545,14 @@ impl FileOperationService { if let Some(parent) = path.parent() { if parent.exists() { let metadata = fs::metadata(parent).await.map_err(|e| { - BitFunError::service(format!( + FileSystemError::service(format!( "Failed to check parent directory permissions: {}", e )) })?; if metadata.permissions().readonly() { - return Err(BitFunError::service( + return Err(FileSystemError::service( "Parent directory is read-only".to_string(), )); } @@ -543,10 +563,10 @@ impl FileOperationService { Ok(()) } - async fn create_backup(&self, path: &Path) -> BitFunResult { + async fn create_backup(&self, path: &Path) -> FileSystemResult { let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); let file_name = path.file_name().ok_or_else(|| { - BitFunError::service(format!( + FileSystemError::service(format!( "Failed to create backup: path has no file name: {}", path.display() )) @@ -561,7 +581,7 @@ impl FileOperationService { fs::copy(path, &backup_path) .await - .map_err(|e| BitFunError::service(format!("Failed to create backup: {}", e)))?; + .map_err(|e| FileSystemError::service(format!("Failed to create backup: {}", e)))?; Ok(backup_path.to_string_lossy().to_string()) } diff --git a/src/crates/services-core/src/filesystem/service.rs b/src/crates/services-core/src/filesystem/service.rs new file mode 100644 index 000000000..1e2ec9530 --- /dev/null +++ b/src/crates/services-core/src/filesystem/service.rs @@ -0,0 +1,455 @@ +use super::error::{FileSystemError, FileSystemResult}; +use super::{ + FileContentSearchOptions, FileInfo, FileNameSearchOptions, FileOperationOptions, + FileOperationService, FileReadResult, FileSearchOutcome, FileSearchProgressSink, + FileSearchResult, FileTreeNode, FileTreeService, FileWriteResult, +}; +use log::debug; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use super::types::{DirectoryScanResult, DirectoryStats, FileSearchOptions, FileSystemConfig}; + +const SLOW_FILESYSTEM_OPERATION_LOG_MS: u64 = 500; + +fn elapsed_ms_u64(started_at: std::time::Instant) -> u64 { + started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64 +} + +/// Unified file system service +pub struct FileSystemService { + file_tree_service: Arc, + file_operation_service: Arc, +} + +impl FileSystemService { + /// Creates a new file system service. + pub fn new(config: FileSystemConfig) -> Self { + let file_tree_service = Arc::new(FileTreeService::new(config.tree_options)); + let file_operation_service = Arc::new(FileOperationService::new(config.operation_options)); + + Self { + file_tree_service, + file_operation_service, + } + } + + /// Creates the default service. + #[allow(clippy::should_implement_trait)] + pub fn default() -> Self { + Self::new(FileSystemConfig::default()) + } + + /// Builds a file tree. + pub async fn build_file_tree(&self, root_path: &str) -> FileSystemResult> { + self.build_file_tree_with_remote_hint(root_path, None).await + } + + /// Same as [`Self::build_file_tree`], but disambiguates remote roots when `preferred_remote_connection_id` is set. + pub async fn build_file_tree_with_remote_hint( + &self, + root_path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> FileSystemResult> { + let started_at = std::time::Instant::now(); + let tree = self + .file_tree_service + .build_tree_with_remote_hint(root_path, preferred_remote_connection_id) + .await + .map_err(FileSystemError::service)?; + let duration_ms = elapsed_ms_u64(started_at); + + if duration_ms >= SLOW_FILESYSTEM_OPERATION_LOG_MS { + debug!( + "File tree built: root_path={}, preferred_remote_connection_id={}, duration_ms={}, root_entries={}", + root_path, + preferred_remote_connection_id.unwrap_or("local"), + duration_ms, + tree.len() + ); + } + + Ok(tree) + } + + /// Scans a directory and returns a detailed result. + pub async fn scan_directory(&self, root_path: &str) -> FileSystemResult { + let start_time = std::time::Instant::now(); + + let (files, statistics) = self + .file_tree_service + .build_tree_with_stats(root_path) + .await?; + + let scan_time_ms = elapsed_ms_u64(start_time); + + if scan_time_ms >= SLOW_FILESYSTEM_OPERATION_LOG_MS { + debug!( + "Directory scan completed: root_path={}, duration_ms={}, total_files={}, total_directories={}, total_size_bytes={}", + root_path, + scan_time_ms, + statistics.total_files, + statistics.total_directories, + statistics.total_size_bytes + ); + } + + Ok(DirectoryScanResult { + files, + statistics, + scan_time_ms, + }) + } + + /// Gets directory contents (shallow). + pub async fn get_directory_contents(&self, path: &str) -> FileSystemResult> { + self.get_directory_contents_with_remote_hint(path, None) + .await + } + + pub async fn get_directory_contents_with_remote_hint( + &self, + path: &str, + preferred_remote_connection_id: Option<&str>, + ) -> FileSystemResult> { + self.file_tree_service + .get_directory_contents_with_remote_hint(path, preferred_remote_connection_id) + .await + .map_err(FileSystemError::service) + } + + /// Searches files. + pub async fn search_files( + &self, + root_path: &str, + pattern: &str, + options: FileSearchOptions, + ) -> FileSystemResult> { + let mut results = self + .file_tree_service + .search_files_with_options( + root_path, + pattern, + options.include_content, + options.case_sensitive, + options.use_regex, + options.whole_word, + ) + .await?; + + if let Some(extensions) = &options.file_extensions { + results.retain(|result| { + if result.is_directory { + true + } else { + let path = std::path::Path::new(&result.path); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + extensions.contains(&ext.to_lowercase()) + } else { + false + } + } + }); + } + + if let Some(max_results) = options.max_results { + results.truncate(max_results); + } + + Ok(results) + } + + pub async fn search_file_names( + &self, + root_path: &str, + pattern: &str, + options: FileSearchOptions, + cancel_flag: Option>, + ) -> FileSystemResult { + self.search_file_names_with_progress(root_path, pattern, options, cancel_flag, None) + .await + } + + pub async fn search_file_names_with_progress( + &self, + root_path: &str, + pattern: &str, + options: FileSearchOptions, + cancel_flag: Option>, + progress_sink: Option>, + ) -> FileSystemResult { + let mut outcome = self + .file_tree_service + .search_file_names_with_progress( + root_path, + pattern, + FileNameSearchOptions { + case_sensitive: options.case_sensitive, + use_regex: options.use_regex, + whole_word: options.whole_word, + max_results: options.max_results.unwrap_or(10_000), + include_directories: options.include_directories, + cancel_flag, + }, + progress_sink, + ) + .await?; + + if let Some(extensions) = &options.file_extensions { + outcome.results.retain(|result| { + if result.is_directory { + true + } else { + let path = std::path::Path::new(&result.path); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + extensions.contains(&ext.to_lowercase()) + } else { + false + } + } + }); + } + + if let Some(max_results) = options.max_results { + outcome.results.truncate(max_results); + } + + Ok(outcome) + } + + pub async fn search_file_contents( + &self, + root_path: &str, + pattern: &str, + options: FileSearchOptions, + cancel_flag: Option>, + ) -> FileSystemResult { + self.search_file_contents_with_progress(root_path, pattern, options, cancel_flag, None) + .await + } + + pub async fn search_file_contents_with_progress( + &self, + root_path: &str, + pattern: &str, + options: FileSearchOptions, + cancel_flag: Option>, + progress_sink: Option>, + ) -> FileSystemResult { + let mut outcome = self + .file_tree_service + .search_file_contents_with_progress( + root_path, + pattern, + FileContentSearchOptions { + case_sensitive: options.case_sensitive, + use_regex: options.use_regex, + whole_word: options.whole_word, + max_results: options.max_results.unwrap_or(10_000), + max_file_size_bytes: 10 * 1024 * 1024, + cancel_flag, + }, + progress_sink, + ) + .await?; + + if let Some(extensions) = &options.file_extensions { + outcome.results.retain(|result| { + let path = std::path::Path::new(&result.path); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + extensions.contains(&ext.to_lowercase()) + } else { + false + } + }); + } + + if let Some(max_results) = options.max_results { + outcome.results.truncate(max_results); + } + + Ok(outcome) + } + + /// Reads a file. + pub async fn read_file(&self, file_path: &str) -> FileSystemResult { + self.file_operation_service.read_file(file_path).await + } + + /// Writes a file. + pub async fn write_file( + &self, + file_path: &str, + content: &str, + ) -> FileSystemResult { + let options = FileOperationOptions::default(); + self.file_operation_service + .write_file(file_path, content, options) + .await + } + + /// Writes a file with options. + pub async fn write_file_with_options( + &self, + file_path: &str, + content: &str, + options: FileOperationOptions, + ) -> FileSystemResult { + self.file_operation_service + .write_file(file_path, content, options) + .await + } + + /// Copies a file. + pub async fn copy_file(&self, from: &str, to: &str) -> FileSystemResult { + self.file_operation_service.copy_file(from, to).await + } + + /// Moves a file. + pub async fn move_file(&self, from: &str, to: &str) -> FileSystemResult<()> { + self.file_operation_service.move_file(from, to).await + } + + /// Deletes a file. + pub async fn delete_file(&self, file_path: &str) -> FileSystemResult<()> { + self.file_operation_service.delete_file(file_path).await + } + + /// Gets file info. + pub async fn get_file_info(&self, file_path: &str) -> FileSystemResult { + self.file_operation_service.get_file_info(file_path).await + } + + /// Creates a directory. + pub async fn create_directory(&self, dir_path: &str) -> FileSystemResult<()> { + self.file_operation_service.create_directory(dir_path).await + } + + /// Deletes a directory. + pub async fn delete_directory(&self, dir_path: &str, recursive: bool) -> FileSystemResult<()> { + self.file_operation_service + .delete_directory(dir_path, recursive) + .await + } + + /// Checks whether the path exists. + pub async fn exists(&self, path: &str) -> bool { + std::path::Path::new(path).exists() + } + + /// Checks whether the path is a directory. + pub async fn is_directory(&self, path: &str) -> bool { + std::path::Path::new(path).is_dir() + } + + /// Checks whether the path is a file. + pub async fn is_file(&self, path: &str) -> bool { + std::path::Path::new(path).is_file() + } + + /// Gets the file size. + pub async fn get_file_size(&self, file_path: &str) -> FileSystemResult { + let info = self.get_file_info(file_path).await?; + Ok(info.size) + } + + /// Reads a text file quickly. + pub async fn read_text_file(&self, file_path: &str) -> FileSystemResult { + let result = self.read_file(file_path).await?; + if result.is_binary { + Err(FileSystemError::service( + "File is binary, cannot read as text".to_string(), + )) + } else { + Ok(result.content) + } + } + + /// Writes a text file quickly. + pub async fn write_text_file(&self, file_path: &str, content: &str) -> FileSystemResult<()> { + self.write_file(file_path, content).await.map(|_| ()) + } + + /// Lists all files in a directory (recursive). + pub async fn list_all_files(&self, root_path: &str) -> FileSystemResult> { + let tree = self.build_file_tree(root_path).await?; + let mut files = Vec::new(); + + fn collect_files(nodes: &[FileTreeNode], files: &mut Vec) { + for node in nodes { + if !node.is_directory { + files.push(node.path.clone()); + } + if let Some(children) = &node.children { + collect_files(children, files); + } + } + } + + collect_files(&tree, &mut files); + Ok(files) + } + + /// Calculates the directory size. + pub async fn calculate_directory_size(&self, dir_path: &str) -> FileSystemResult { + let scan_result = self.scan_directory(dir_path).await?; + Ok(scan_result.statistics.total_size_bytes) + } + + /// Finds files by extension. + pub async fn find_files_by_extension( + &self, + root_path: &str, + extension: &str, + ) -> FileSystemResult> { + let options = FileSearchOptions { + include_content: false, + file_extensions: Some(vec![extension.to_lowercase()]), + ..Default::default() + }; + + let results = self.search_files(root_path, "", options).await?; + Ok(results + .into_iter() + .filter(|r| !r.is_directory) + .map(|r| r.path) + .collect()) + } + + /// Gets directory statistics. + pub async fn get_directory_stats(&self, dir_path: &str) -> FileSystemResult { + let scan_result = self.scan_directory(dir_path).await?; + let stats = scan_result.statistics; + + Ok(DirectoryStats { + total_files: stats.total_files, + total_directories: stats.total_directories, + total_size_bytes: stats.total_size_bytes, + total_size_mb: stats.total_size_bytes / (1024 * 1024), + max_depth: stats.max_depth_reached, + most_common_extensions: { + let mut ext_vec: Vec<_> = stats.file_type_counts.into_iter().collect(); + ext_vec.sort_by(|a, b| b.1.cmp(&a.1)); + ext_vec.into_iter().take(10).collect() + }, + large_files_count: stats.large_files.len(), + hidden_files_count: stats.hidden_files_count, + symlinks_count: stats.symlinks_count, + }) + } + + /// SHA-256 hex of on-disk content after editor-sync normalization (see `FileOperationService`). + pub async fn editor_sync_content_sha256_hex( + &self, + file_path: &str, + ) -> FileSystemResult { + self.file_operation_service + .editor_sync_content_sha256_hex(file_path) + .await + } + + pub fn editor_sync_sha256_hex_from_raw_bytes(&self, bytes: &[u8]) -> String { + self.file_operation_service + .editor_sync_sha256_hex_from_raw_bytes(bytes) + } +} diff --git a/src/crates/core/src/infrastructure/filesystem/file_tree.rs b/src/crates/services-core/src/filesystem/tree.rs similarity index 92% rename from src/crates/core/src/infrastructure/filesystem/file_tree.rs rename to src/crates/services-core/src/filesystem/tree.rs index 50d2eda51..7d9020c39 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_tree.rs +++ b/src/crates/services-core/src/filesystem/tree.rs @@ -2,7 +2,7 @@ //! //! Provides file tree building, directory scanning, and file search -use crate::util::errors::*; +use super::error::{FileSystemError, FileSystemResult}; use log::warn; use ignore::WalkBuilder; @@ -350,15 +350,8 @@ impl FileTreeService { pub async fn build_tree_with_remote_hint( &self, root_path: &str, - preferred_remote_connection_id: Option<&str>, + _preferred_remote_connection_id: Option<&str>, ) -> Result, String> { - // For remote workspaces, delegate to get_directory_contents which handles SSH - if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { - return self - .get_directory_contents_with_remote_hint(root_path, preferred_remote_connection_id) - .await; - } - let root_path_buf = PathBuf::from(root_path); if !root_path_buf.exists() { @@ -377,34 +370,19 @@ impl FileTreeService { pub async fn build_tree_with_stats( &self, root_path: &str, - ) -> BitFunResult<(Vec, FileTreeStatistics)> { - // For remote workspaces, return simple directory listing with empty stats - if crate::service::remote_ssh::workspace_state::is_remote_path(root_path).await { - let nodes = self - .get_directory_contents_with_remote_hint(root_path, None) - .await - .map_err(BitFunError::service)?; - let stats = FileTreeStatistics { - total_files: nodes.iter().filter(|n| !n.is_directory).count(), - total_directories: nodes.iter().filter(|n| n.is_directory).count(), - total_size_bytes: 0, - max_depth_reached: 0, - file_type_counts: HashMap::new(), - large_files: Vec::new(), - symlinks_count: 0, - hidden_files_count: 0, - }; - return Ok((nodes, stats)); - } - + ) -> FileSystemResult<(Vec, FileTreeStatistics)> { let root_path_buf = PathBuf::from(root_path); if !root_path_buf.exists() { - return Err(BitFunError::service("Directory does not exist".to_string())); + return Err(FileSystemError::service( + "Directory does not exist".to_string(), + )); } if !root_path_buf.is_dir() { - return Err(BitFunError::service("Path is not a directory".to_string())); + return Err(FileSystemError::service( + "Path is not a directory".to_string(), + )); } let mut visited = HashSet::new(); @@ -428,7 +406,7 @@ impl FileTreeService { &mut stats, ) .await - .map_err(BitFunError::service)?; + .map_err(FileSystemError::service)?; Ok((nodes, stats)) } @@ -815,50 +793,13 @@ impl FileTreeService { .await } - /// `preferred_remote_connection_id`: when set (e.g. from workspace/session), resolves SSH file ops - /// without relying on global `active_connection_hint` — required when multiple remotes share the same root path. + /// Keeps the legacy signature; core handles remote routing before delegating + /// local directory reads to this owner crate. pub async fn get_directory_contents_with_remote_hint( &self, path: &str, - preferred_remote_connection_id: Option<&str>, + _preferred_remote_connection_id: Option<&str>, ) -> Result, String> { - // Check if this path belongs to any registered remote workspace - if let Some(entry) = - crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( - path, - preferred_remote_connection_id, - ) - .await - { - if let Some(manager) = - crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() - { - if let Some(file_service) = manager.get_file_service().await { - match file_service.read_dir(&entry.connection_id, path).await { - Ok(entries) => { - let nodes: Vec = entries - .into_iter() - .filter(|e| e.name != "." && e.name != "..") - .map(|e| { - FileTreeNode::new( - e.path.clone(), - e.name.clone(), - e.path.clone(), - e.is_dir, - ) - }) - .collect(); - return Ok(nodes); - } - Err(e) => { - return Err(format!("Failed to read remote directory: {}", e)); - } - } - } - } - } - - // Fall back to local filesystem let path_buf = PathBuf::from(path); if !path_buf.exists() { @@ -993,7 +934,7 @@ impl FileTreeService { root_path: &str, pattern: &str, search_content: bool, - ) -> BitFunResult> { + ) -> FileSystemResult> { self.search_files_with_options(root_path, pattern, search_content, false, false, false) .await } @@ -1006,7 +947,7 @@ impl FileTreeService { case_sensitive: bool, use_regex: bool, whole_word: bool, - ) -> BitFunResult> { + ) -> FileSystemResult> { let filename_outcome = self .search_file_names( root_path, @@ -1050,7 +991,7 @@ impl FileTreeService { root_path: &str, pattern: &str, options: FileNameSearchOptions, - ) -> BitFunResult { + ) -> FileSystemResult { self.search_file_names_with_progress(root_path, pattern, options, None) .await } @@ -1061,11 +1002,13 @@ impl FileTreeService { pattern: &str, options: FileNameSearchOptions, progress_sink: Option>, - ) -> BitFunResult { + ) -> FileSystemResult { let root_path_buf = PathBuf::from(root_path); if !root_path_buf.exists() { - return Err(BitFunError::service("Directory does not exist".to_string())); + return Err(FileSystemError::service( + "Directory does not exist".to_string(), + )); } let matcher = Arc::new(Self::compile_search_regex( @@ -1202,7 +1145,7 @@ impl FileTreeService { root_path: &str, pattern: &str, options: FileContentSearchOptions, - ) -> BitFunResult { + ) -> FileSystemResult { self.search_file_contents_with_progress(root_path, pattern, options, None) .await } @@ -1213,11 +1156,13 @@ impl FileTreeService { pattern: &str, options: FileContentSearchOptions, progress_sink: Option>, - ) -> BitFunResult { + ) -> FileSystemResult { let root_path_buf = PathBuf::from(root_path); if !root_path_buf.exists() { - return Err(BitFunError::service("Directory does not exist".to_string())); + return Err(FileSystemError::service( + "Directory does not exist".to_string(), + )); } let matcher = Arc::new(Self::compile_search_regex( @@ -1351,7 +1296,7 @@ impl FileTreeService { case_sensitive: bool, use_regex: bool, whole_word: bool, - ) -> BitFunResult { + ) -> FileSystemResult { let search_pattern = if use_regex { pattern.to_string() } else if whole_word { @@ -1363,7 +1308,7 @@ impl FileTreeService { RegexBuilder::new(&search_pattern) .case_insensitive(!case_sensitive) .build() - .map_err(|error| BitFunError::service(format!("Invalid regex pattern: {}", error))) + .map_err(|error| FileSystemError::service(format!("Invalid regex pattern: {}", error))) } fn take_first_chars(text: &str, max_chars: usize) -> String { @@ -1391,7 +1336,7 @@ impl FileTreeService { } if max_chars <= 1 { - return "…".to_string(); + return "\u{2026}".to_string(); } let keep_chars = max_chars - 1; @@ -1401,7 +1346,7 @@ impl FileTreeService { .map(|(index, _)| index) .unwrap_or(0); - format!("…{}", &text[start_index..]) + format!("\u{2026}{}", &text[start_index..]) } fn build_content_match_preview( @@ -1514,14 +1459,14 @@ impl FileTreeService { limit_reached: &Arc, cancel_flag: Option<&Arc>, progress_sink: Option<&Arc>, - ) -> BitFunResult<()> { + ) -> FileSystemResult<()> { if should_stop.load(Ordering::Relaxed) || cancellation_requested(cancel_flag) { should_stop.store(true, Ordering::Relaxed); return Ok(()); } let file = File::open(path) - .map_err(|error| BitFunError::service(format!("Failed to open file: {}", error)))?; + .map_err(|error| FileSystemError::service(format!("Failed to open file: {}", error)))?; let reader = BufReader::new(file); let mut matched_results = Vec::new(); @@ -1531,8 +1476,9 @@ impl FileTreeService { return Ok(()); } - let line_bytes = line_result - .map_err(|error| BitFunError::service(format!("Failed to read file: {}", error)))?; + let line_bytes = line_result.map_err(|error| { + FileSystemError::service(format!("Failed to read file: {}", error)) + })?; let line = String::from_utf8_lossy(&line_bytes) .trim_end_matches('\r') .to_string(); diff --git a/src/crates/services-core/src/filesystem/types.rs b/src/crates/services-core/src/filesystem/types.rs new file mode 100644 index 000000000..122b81b86 --- /dev/null +++ b/src/crates/services-core/src/filesystem/types.rs @@ -0,0 +1,58 @@ +use super::{FileOperationOptions, FileTreeNode, FileTreeOptions, FileTreeStatistics}; +use serde::{Deserialize, Serialize}; + +/// File system service configuration +#[derive(Debug, Clone, Default)] +pub struct FileSystemConfig { + pub tree_options: FileTreeOptions, + pub operation_options: FileOperationOptions, +} + +/// Directory scan result +#[derive(Debug, Serialize, Deserialize)] +pub struct DirectoryScanResult { + pub files: Vec, + pub statistics: FileTreeStatistics, + pub scan_time_ms: u64, +} + +/// File search options +#[derive(Debug, Clone)] +pub struct FileSearchOptions { + pub include_content: bool, + pub case_sensitive: bool, + pub use_regex: bool, + pub whole_word: bool, + pub max_results: Option, + pub file_extensions: Option>, + /// Whether to include directories in the search results + pub include_directories: bool, +} + +impl Default for FileSearchOptions { + fn default() -> Self { + Self { + include_content: false, + case_sensitive: false, + use_regex: false, + whole_word: false, + max_results: None, // No limit + file_extensions: None, + include_directories: true, // Include directories by default + } + } +} + +/// Directory statistics +#[derive(Debug, Serialize, Deserialize)] +pub struct DirectoryStats { + pub total_files: usize, + pub total_directories: usize, + pub total_size_bytes: u64, + pub total_size_mb: u64, + pub max_depth: u32, + pub most_common_extensions: Vec<(String, usize)>, + pub large_files_count: usize, + pub hidden_files_count: usize, + pub symlinks_count: usize, +} diff --git a/src/crates/services-core/src/lib.rs b/src/crates/services-core/src/lib.rs index 7bac737df..2618cc9ae 100644 --- a/src/crates/services-core/src/lib.rs +++ b/src/crates/services-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod diagnostics; pub mod diff; +pub mod filesystem; pub mod process_manager; pub mod session; pub mod session_usage;