| use std::collections::HashSet; |
| use std::path::{Path, PathBuf}; |
| use std::sync::{Arc, LazyLock}; |
|
|
| use async_trait::async_trait; |
| use forge_app::{CommandInfra, WalkerInfra}; |
| use forge_domain::WorkspaceId; |
| use tracing::{info, warn}; |
|
|
| use crate::error::Error as ServiceError; |
| use crate::fd_git::FsGit; |
| use crate::fd_walker::FdWalker; |
|
|
| pub(crate) static ALLOWED_EXTENSIONS: LazyLock<HashSet<String>> = LazyLock::new(|| { |
| let extensions_str = include_str!("allowed_extensions.txt"); |
| extensions_str |
| .lines() |
| .map(|line| line.trim().to_lowercase()) |
| .filter(|line| !line.is_empty()) |
| .collect() |
| }); |
|
|
| |
| |
| pub(crate) fn has_allowed_extension(path: &Path) -> bool { |
| if let Some(ext) = path.extension() { |
| ALLOWED_EXTENSIONS.contains(&ext.to_string_lossy().to_lowercase() as &str) |
| } else { |
| false |
| } |
| } |
|
|
| |
| |
| |
| fn is_ignored_by_name(path: &Path) -> bool { |
| let Some(name) = path.file_name().and_then(|n| n.to_str()) else { |
| return false; |
| }; |
| let name_lower = name.to_lowercase(); |
|
|
| |
| if name_lower.ends_with(".lock") |
| || name_lower.ends_with(".lockb") |
| || name_lower.ends_with("-lock.json") |
| || name_lower.ends_with("-lock.yaml") |
| || name_lower.ends_with("-lock.yml") |
| || name_lower.ends_with(".lock.json") |
| || name_lower.ends_with(".lockfile") |
| || name == "Package.resolved" |
| { |
| return true; |
| } |
|
|
| false |
| } |
|
|
| |
| fn is_symlink(path: &Path) -> bool { |
| path.symlink_metadata() |
| .map(|m| m.file_type().is_symlink()) |
| .unwrap_or(false) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| pub(crate) fn filter_and_resolve( |
| dir_path: &Path, |
| paths: impl IntoIterator<Item = String>, |
| ) -> anyhow::Result<Vec<PathBuf>> { |
| let filtered: Vec<PathBuf> = paths |
| .into_iter() |
| .map(|p| dir_path.join(&p)) |
| .filter(|p| !is_symlink(p)) |
| .filter(|p| !is_ignored_by_name(p)) |
| .filter(|p| has_allowed_extension(p)) |
| .collect(); |
|
|
| if filtered.is_empty() { |
| return Err(ServiceError::NoSourceFilesFound.into()); |
| } |
|
|
| Ok(filtered) |
| } |
|
|
| |
| |
| |
| |
| |
| #[async_trait] |
| pub trait FileDiscovery: Send + Sync { |
| |
| |
| |
| |
| |
| |
| async fn discover(&self, dir_path: &Path) -> anyhow::Result<Vec<PathBuf>>; |
| } |
|
|
| |
| |
| pub async fn discover_sync_file_paths( |
| discovery: &impl FileDiscovery, |
| dir_path: &Path, |
| workspace_id: &WorkspaceId, |
| ) -> anyhow::Result<Vec<PathBuf>> { |
| info!(workspace_id = %workspace_id, "Discovering files for sync"); |
| let files = discovery.discover(dir_path).await?; |
| info!( |
| workspace_id = %workspace_id, |
| count = files.len(), |
| "Files discovered and filtered for sync" |
| ); |
| Ok(files) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| pub struct FdDefault<F> { |
| git: FsGit<F>, |
| walker: FdWalker<F>, |
| } |
|
|
| impl<F> FdDefault<F> { |
| |
| |
| pub fn new(infra: Arc<F>) -> Self { |
| Self { git: FsGit::new(infra.clone()), walker: FdWalker::new(infra) } |
| } |
| } |
|
|
| #[async_trait] |
| impl<F: CommandInfra + WalkerInfra + 'static> FileDiscovery for FdDefault<F> { |
| async fn discover(&self, dir_path: &Path) -> anyhow::Result<Vec<PathBuf>> { |
| match self.git.discover(dir_path).await { |
| Ok(files) => Ok(files), |
| Err(err) => { |
| warn!(error = ?err, "git-based file discovery failed, falling back to walker"); |
| self.walker.discover(dir_path).await |
| } |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::fs::{self, File}; |
| use std::io::Write; |
|
|
| use pretty_assertions::assert_eq; |
| use tempfile::tempdir; |
|
|
| use super::*; |
|
|
| #[test] |
| fn test_filter_and_resolve_excludes_symlinks() { |
| let dir = tempdir().unwrap(); |
| let base = dir.path(); |
|
|
| |
| let real_path = base.join("main.rs"); |
| File::create(&real_path) |
| .unwrap() |
| .write_all(b"fn main() {}") |
| .unwrap(); |
|
|
| |
| let link_path = base.join("link.rs"); |
| std::os::unix::fs::symlink(&real_path, &link_path).unwrap(); |
|
|
| let paths = vec!["main.rs".to_string(), "link.rs".to_string()]; |
| let actual = filter_and_resolve(base, paths).unwrap(); |
|
|
| let expected = vec![base.join("main.rs")]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_filter_and_resolve_excludes_dangling_symlinks() { |
| let dir = tempdir().unwrap(); |
| let base = dir.path(); |
|
|
| |
| let real_path = base.join("lib.rs"); |
| File::create(&real_path).unwrap().write_all(b"").unwrap(); |
|
|
| |
| let dangling = base.join("missing.rs"); |
| std::os::unix::fs::symlink(base.join("nonexistent.rs"), &dangling).unwrap(); |
|
|
| let paths = vec!["lib.rs".to_string(), "missing.rs".to_string()]; |
| let actual = filter_and_resolve(base, paths).unwrap(); |
|
|
| let expected = vec![base.join("lib.rs")]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_filter_and_resolve_excludes_symlinks_to_directories() { |
| let dir = tempdir().unwrap(); |
| let base = dir.path(); |
|
|
| |
| let real_path = base.join("src").join("main.rs"); |
| fs::create_dir_all(real_path.parent().unwrap()).unwrap(); |
| File::create(&real_path).unwrap().write_all(b"").unwrap(); |
|
|
| |
| |
| let link_dir = base.join("src_link"); |
| std::os::unix::fs::symlink(base.join("src"), &link_dir).unwrap(); |
|
|
| let paths = vec!["src/main.rs".to_string(), "src_link".to_string()]; |
| let actual = filter_and_resolve(base, paths).unwrap(); |
|
|
| |
| |
| |
| let expected = vec![base.join("src/main.rs")]; |
| assert_eq!(actual, expected); |
| } |
| } |
|
|