Skip to main content

fallow_types/
discover.rs

1//! File discovery types: discovered files, file IDs, and entry points.
2
3use std::path::{Path, PathBuf};
4
5/// A discovered source file on disk.
6///
7/// # Examples
8///
9/// ```
10/// use fallow_types::discover::{DiscoveredFile, FileId};
11/// use std::path::PathBuf;
12///
13/// let file = DiscoveredFile {
14///     id: FileId(0),
15///     path: PathBuf::from("/project/src/index.ts"),
16///     size_bytes: 2048,
17/// };
18/// assert_eq!(file.id, FileId(0));
19/// assert_eq!(file.size_bytes, 2048);
20/// ```
21#[derive(Debug, Clone)]
22pub struct DiscoveredFile {
23    /// Unique file index.
24    pub id: FileId,
25    /// Absolute path.
26    pub path: PathBuf,
27    /// File size in bytes (for sorting largest-first).
28    pub size_bytes: u64,
29}
30
31/// Compact file identifier.
32///
33/// A newtype wrapper around `u32` used as a stable index into file arrays.
34/// `FileId`s are path-sorted (not insertion order) for stable cross-run identity.
35///
36/// # Examples
37///
38/// ```
39/// use fallow_types::discover::FileId;
40///
41/// let id = FileId(42);
42/// assert_eq!(id.0, 42);
43///
44/// // Implements Copy
45/// let copy = id;
46/// assert_eq!(id, copy);
47/// ```
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
49pub struct FileId(pub u32);
50
51const _: () = assert!(std::mem::size_of::<FileId>() == 4);
52#[cfg(all(target_pointer_width = "64", unix))]
53const _: () = assert!(std::mem::size_of::<DiscoveredFile>() == 40);
54
55/// Persistable file identity for cache entries that need to survive `FileId`
56/// churn across runs.
57///
58/// `FileId` remains a dense in-memory index. This key is path-derived, root
59/// relative where possible, and uses `/` separators so graph-cache metadata can
60/// compare file identity without relying on platform path display quirks.
61#[derive(
62    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
63)]
64pub struct StableFileKey(String);
65
66impl StableFileKey {
67    /// Build a stable key from an absolute path and the analysis root.
68    #[must_use]
69    pub fn from_root_relative(root: &Path, path: &Path) -> Self {
70        let relative = path.strip_prefix(root).unwrap_or(path);
71        Self(normalize_path(relative))
72    }
73
74    /// Build a stable key from an already-root-relative path.
75    #[must_use]
76    pub fn from_relative(path: &Path) -> Self {
77        Self(normalize_path(path))
78    }
79
80    /// Stable string used in persisted cache manifests.
81    #[must_use]
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85}
86
87fn normalize_path(path: &Path) -> String {
88    path.to_string_lossy().replace('\\', "/")
89}
90
91/// An entry point into the module graph.
92#[derive(Debug, Clone)]
93pub struct EntryPoint {
94    /// Absolute path to the entry point file.
95    pub path: PathBuf,
96    /// How this entry point was discovered.
97    pub source: EntryPointSource,
98}
99
100impl std::fmt::Display for EntryPointSource {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::PackageJsonMain => f.write_str("package.json main"),
104            Self::PackageJsonModule => f.write_str("package.json module"),
105            Self::PackageJsonExports => f.write_str("package.json exports"),
106            Self::PackageJsonBin => f.write_str("package.json bin"),
107            Self::PackageJsonScript => f.write_str("package.json script"),
108            Self::Plugin { name } => write!(f, "{name}"),
109            Self::TestFile => f.write_str("test file"),
110            Self::DefaultIndex => f.write_str("default index"),
111            Self::ManualEntry => f.write_str("manual entry"),
112            Self::InfrastructureConfig => f.write_str("infrastructure config"),
113            Self::DynamicallyLoaded => f.write_str("dynamically loaded"),
114        }
115    }
116}
117
118/// Where an entry point was discovered from.
119#[derive(Debug, Clone)]
120pub enum EntryPointSource {
121    /// The `main` field in package.json.
122    PackageJsonMain,
123    /// The `module` field in package.json.
124    PackageJsonModule,
125    /// The `exports` field in package.json.
126    PackageJsonExports,
127    /// The `bin` field in package.json.
128    PackageJsonBin,
129    /// A script command in package.json.
130    PackageJsonScript,
131    /// Detected by a framework plugin.
132    Plugin {
133        /// Name of the plugin that detected this entry point.
134        name: String,
135    },
136    /// A test file (e.g., `*.test.ts`, `*.spec.ts`).
137    TestFile,
138    /// A default index file (e.g., `src/index.ts`).
139    DefaultIndex,
140    /// Manually configured in fallow config.
141    ManualEntry,
142    /// Discovered from infrastructure config files (Dockerfile, Procfile, fly.toml).
143    InfrastructureConfig,
144    /// Declared in `dynamicallyLoaded` config as a runtime-loaded file.
145    DynamicallyLoaded,
146}
147
148#[cfg(test)]
149mod stable_file_key_tests {
150    use super::*;
151
152    #[test]
153    fn stable_file_key_strips_root_prefix() {
154        let key = StableFileKey::from_root_relative(
155            Path::new("/project"),
156            Path::new("/project/src/index.ts"),
157        );
158
159        assert_eq!(key.as_str(), "src/index.ts");
160    }
161
162    #[test]
163    fn stable_file_key_keeps_path_when_outside_root() {
164        let key =
165            StableFileKey::from_root_relative(Path::new("/project"), Path::new("/other/file.ts"));
166
167        assert_eq!(key.as_str(), "/other/file.ts");
168    }
169
170    #[test]
171    fn stable_file_key_normalizes_windows_separators() {
172        let key = StableFileKey::from_relative(Path::new(r"src\feature\file.ts"));
173
174        assert_eq!(key.as_str(), "src/feature/file.ts");
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::collections::hash_map::DefaultHasher;
182    use std::hash::{Hash, Hasher};
183
184    #[test]
185    fn file_id_equality() {
186        assert_eq!(FileId(0), FileId(0));
187        assert_eq!(FileId(42), FileId(42));
188        assert_ne!(FileId(0), FileId(1));
189    }
190
191    #[test]
192    fn file_id_copy_semantics() {
193        let a = FileId(5);
194        let b = a; // Copy, not move
195        assert_eq!(a, b);
196    }
197
198    #[test]
199    fn file_id_hash_consistent() {
200        let id = FileId(99);
201        let hash1 = {
202            let mut h = DefaultHasher::new();
203            id.hash(&mut h);
204            h.finish()
205        };
206        let hash2 = {
207            let mut h = DefaultHasher::new();
208            id.hash(&mut h);
209            h.finish()
210        };
211        assert_eq!(hash1, hash2);
212    }
213
214    #[test]
215    fn file_id_equal_values_same_hash() {
216        let a = FileId(7);
217        let b = FileId(7);
218        let hash_a = {
219            let mut h = DefaultHasher::new();
220            a.hash(&mut h);
221            h.finish()
222        };
223        let hash_b = {
224            let mut h = DefaultHasher::new();
225            b.hash(&mut h);
226            h.finish()
227        };
228        assert_eq!(hash_a, hash_b);
229    }
230
231    #[test]
232    fn file_id_inner_value_accessible() {
233        let id = FileId(123);
234        assert_eq!(id.0, 123);
235    }
236
237    #[test]
238    fn file_id_debug_format() {
239        let id = FileId(42);
240        let debug = format!("{id:?}");
241        assert!(
242            debug.contains("42"),
243            "Debug should show inner value: {debug}"
244        );
245    }
246
247    #[test]
248    fn discovered_file_clone() {
249        let original = DiscoveredFile {
250            id: FileId(0),
251            path: PathBuf::from("/project/src/index.ts"),
252            size_bytes: 1024,
253        };
254        let cloned = original.clone();
255        assert_eq!(cloned.id, original.id);
256        assert_eq!(cloned.path, original.path);
257        assert_eq!(cloned.size_bytes, original.size_bytes);
258    }
259
260    #[test]
261    fn discovered_file_zero_size() {
262        let file = DiscoveredFile {
263            id: FileId(0),
264            path: PathBuf::from("/empty.ts"),
265            size_bytes: 0,
266        };
267        assert_eq!(file.size_bytes, 0);
268    }
269
270    #[test]
271    fn discovered_file_large_size() {
272        let file = DiscoveredFile {
273            id: FileId(0),
274            path: PathBuf::from("/large.ts"),
275            size_bytes: u64::MAX,
276        };
277        assert_eq!(file.size_bytes, u64::MAX);
278    }
279
280    #[test]
281    fn entry_point_clone() {
282        let ep = EntryPoint {
283            path: PathBuf::from("/project/src/main.ts"),
284            source: EntryPointSource::PackageJsonMain,
285        };
286        let cloned = ep.clone();
287        assert_eq!(cloned.path, ep.path);
288        assert!(matches!(cloned.source, EntryPointSource::PackageJsonMain));
289    }
290
291    #[test]
292    fn entry_point_source_all_variants_constructible() {
293        let _ = EntryPointSource::PackageJsonMain;
294        let _ = EntryPointSource::PackageJsonModule;
295        let _ = EntryPointSource::PackageJsonExports;
296        let _ = EntryPointSource::PackageJsonBin;
297        let _ = EntryPointSource::PackageJsonScript;
298        let _ = EntryPointSource::Plugin {
299            name: "next".to_string(),
300        };
301        let _ = EntryPointSource::TestFile;
302        let _ = EntryPointSource::DefaultIndex;
303        let _ = EntryPointSource::ManualEntry;
304        let _ = EntryPointSource::InfrastructureConfig;
305        let _ = EntryPointSource::DynamicallyLoaded;
306    }
307
308    #[test]
309    fn entry_point_source_plugin_preserves_name() {
310        let source = EntryPointSource::Plugin {
311            name: "vitest".to_string(),
312        };
313        match source {
314            EntryPointSource::Plugin { name } => assert_eq!(name, "vitest"),
315            _ => panic!("expected Plugin variant"),
316        }
317    }
318
319    #[test]
320    fn entry_point_source_plugin_clone_preserves_name() {
321        let source = EntryPointSource::Plugin {
322            name: "storybook".to_string(),
323        };
324        let cloned = source.clone();
325        assert!(matches!(&source, EntryPointSource::Plugin { name } if name == "storybook"));
326        match cloned {
327            EntryPointSource::Plugin { name } => assert_eq!(name, "storybook"),
328            _ => panic!("expected Plugin variant after clone"),
329        }
330    }
331
332    #[test]
333    fn entry_point_source_debug_format() {
334        let source = EntryPointSource::PackageJsonMain;
335        let debug = format!("{source:?}");
336        assert!(
337            debug.contains("PackageJsonMain"),
338            "Debug should name the variant: {debug}"
339        );
340
341        let plugin = EntryPointSource::Plugin {
342            name: "remix".to_string(),
343        };
344        let debug = format!("{plugin:?}");
345        assert!(
346            debug.contains("remix"),
347            "Debug should show plugin name: {debug}"
348        );
349    }
350
351    #[test]
352    fn entry_point_source_display_all_variants() {
353        assert_eq!(
354            EntryPointSource::PackageJsonMain.to_string(),
355            "package.json main"
356        );
357        assert_eq!(
358            EntryPointSource::PackageJsonModule.to_string(),
359            "package.json module"
360        );
361        assert_eq!(
362            EntryPointSource::PackageJsonExports.to_string(),
363            "package.json exports"
364        );
365        assert_eq!(
366            EntryPointSource::PackageJsonBin.to_string(),
367            "package.json bin"
368        );
369        assert_eq!(
370            EntryPointSource::PackageJsonScript.to_string(),
371            "package.json script"
372        );
373        assert_eq!(
374            EntryPointSource::Plugin {
375                name: "vitest".to_string()
376            }
377            .to_string(),
378            "vitest"
379        );
380        assert_eq!(EntryPointSource::TestFile.to_string(), "test file");
381        assert_eq!(EntryPointSource::DefaultIndex.to_string(), "default index");
382        assert_eq!(EntryPointSource::ManualEntry.to_string(), "manual entry");
383        assert_eq!(
384            EntryPointSource::InfrastructureConfig.to_string(),
385            "infrastructure config"
386        );
387        assert_eq!(
388            EntryPointSource::DynamicallyLoaded.to_string(),
389            "dynamically loaded"
390        );
391    }
392}