File size: 8,025 Bytes
d90101d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | 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()
});
/// Returns `true` if `path` carries an extension present in the allowed
/// extensions list.
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
}
}
/// Returns `true` if the file at `path` should be excluded based on its name,
/// regardless of extension. This covers lock files and other generated
/// dependency manifest files that are not useful to index.
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();
// Lock files: *-lock.json, *.lock, *.lockb, *.lock.json, etc.
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
}
/// Returns `true` if `path` is a symlink (does not follow the link).
fn is_symlink(path: &Path) -> bool {
path.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
/// Filters relative path strings down to those with an allowed extension,
/// resolves each against `dir_path`, and returns them as absolute `PathBuf`s.
///
/// Symlinks are always excluded regardless of their target or extension, so
/// that the sync pipeline only ever processes real files.
///
/// Returns an error when the filtered list is empty, indicating no indexable
/// source files exist in the workspace.
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)
}
/// Trait for discovering the list of files in a workspace directory that
/// should be considered for synchronisation.
///
/// Implementations may use different strategies (e.g. `git ls-files` or a
/// plain filesystem walk) to enumerate files. The returned paths are absolute.
#[async_trait]
pub trait FileDiscovery: Send + Sync {
/// Returns the absolute paths of all files to be indexed under `dir_path`.
///
/// # Errors
///
/// Returns an error if the discovery strategy fails and no files can be
/// enumerated.
async fn discover(&self, dir_path: &Path) -> anyhow::Result<Vec<PathBuf>>;
}
/// Discovers workspace files using a `FileDiscovery` implementation and logs
/// progress associated with `workspace_id`.
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)
}
/// A `FileDiscovery` implementation that routes between `GitFileDiscovery` and
/// `WalkerFileDiscovery`.
///
/// It first attempts git-based discovery. If git is unavailable, returns no
/// files, or fails for any reason it transparently falls back to the filesystem
/// walker so that workspaces without git history are still indexed correctly.
pub struct FdDefault<F> {
git: FsGit<F>,
walker: FdWalker<F>,
}
impl<F> FdDefault<F> {
/// Creates a new `RoutingFileDiscovery` using the provided infrastructure
/// for both the git and walker strategies.
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();
// Real file with an allowed extension.
let real_path = base.join("main.rs");
File::create(&real_path)
.unwrap()
.write_all(b"fn main() {}")
.unwrap();
// Symlink pointing to the real file (also carries an allowed extension).
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();
// Real file with an allowed extension (keeps the result non-empty).
let real_path = base.join("lib.rs");
File::create(&real_path).unwrap().write_all(b"").unwrap();
// Dangling symlink — target does not exist.
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();
// Real file with an allowed extension.
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();
// Symlink to a directory — even if it appears as a file path it should
// be excluded.
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();
// src_link has no allowed extension so it is dropped by the extension
// filter before symlink detection could be needed, but the real file
// must always be present.
let expected = vec![base.join("src/main.rs")];
assert_eq!(actual, expected);
}
}
|