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);
    }
}