Skip to main content

fallow_types/
discover.rs

1//! File discovery types: discovered files, file IDs, and entry points.
2
3use std::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)]
49pub struct FileId(pub u32);
50
51// Size assertions to prevent memory regressions in hot-path types.
52// These types are stored in large Vecs (one per project file) and iterated
53// in tight loops during discovery, parsing, and graph construction.
54const _: () = assert!(std::mem::size_of::<FileId>() == 4);
55#[cfg(all(target_pointer_width = "64", unix))]
56const _: () = assert!(std::mem::size_of::<DiscoveredFile>() == 40);
57
58/// An entry point into the module graph.
59#[derive(Debug, Clone)]
60pub struct EntryPoint {
61    /// Absolute path to the entry point file.
62    pub path: PathBuf,
63    /// How this entry point was discovered.
64    pub source: EntryPointSource,
65}
66
67impl std::fmt::Display for EntryPointSource {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::PackageJsonMain => f.write_str("package.json main"),
71            Self::PackageJsonModule => f.write_str("package.json module"),
72            Self::PackageJsonExports => f.write_str("package.json exports"),
73            Self::PackageJsonBin => f.write_str("package.json bin"),
74            Self::PackageJsonScript => f.write_str("package.json script"),
75            Self::Plugin { name } => write!(f, "{name}"),
76            Self::TestFile => f.write_str("test file"),
77            Self::DefaultIndex => f.write_str("default index"),
78            Self::ManualEntry => f.write_str("manual entry"),
79            Self::InfrastructureConfig => f.write_str("infrastructure config"),
80            Self::DynamicallyLoaded => f.write_str("dynamically loaded"),
81        }
82    }
83}
84
85/// Where an entry point was discovered from.
86#[derive(Debug, Clone)]
87pub enum EntryPointSource {
88    /// The `main` field in package.json.
89    PackageJsonMain,
90    /// The `module` field in package.json.
91    PackageJsonModule,
92    /// The `exports` field in package.json.
93    PackageJsonExports,
94    /// The `bin` field in package.json.
95    PackageJsonBin,
96    /// A script command in package.json.
97    PackageJsonScript,
98    /// Detected by a framework plugin.
99    Plugin {
100        /// Name of the plugin that detected this entry point.
101        name: String,
102    },
103    /// A test file (e.g., `*.test.ts`, `*.spec.ts`).
104    TestFile,
105    /// A default index file (e.g., `src/index.ts`).
106    DefaultIndex,
107    /// Manually configured in fallow config.
108    ManualEntry,
109    /// Discovered from infrastructure config files (Dockerfile, Procfile, fly.toml).
110    InfrastructureConfig,
111    /// Declared in `dynamicallyLoaded` config as a runtime-loaded file.
112    DynamicallyLoaded,
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::collections::hash_map::DefaultHasher;
119    use std::hash::{Hash, Hasher};
120
121    // ── FileId ──────────────────────────────────────────────────────
122
123    #[test]
124    fn file_id_equality() {
125        assert_eq!(FileId(0), FileId(0));
126        assert_eq!(FileId(42), FileId(42));
127        assert_ne!(FileId(0), FileId(1));
128    }
129
130    #[test]
131    fn file_id_copy_semantics() {
132        let a = FileId(5);
133        let b = a; // Copy, not move
134        assert_eq!(a, b);
135    }
136
137    #[test]
138    fn file_id_hash_consistent() {
139        let id = FileId(99);
140        let hash1 = {
141            let mut h = DefaultHasher::new();
142            id.hash(&mut h);
143            h.finish()
144        };
145        let hash2 = {
146            let mut h = DefaultHasher::new();
147            id.hash(&mut h);
148            h.finish()
149        };
150        assert_eq!(hash1, hash2);
151    }
152
153    #[test]
154    fn file_id_equal_values_same_hash() {
155        let a = FileId(7);
156        let b = FileId(7);
157        let hash_a = {
158            let mut h = DefaultHasher::new();
159            a.hash(&mut h);
160            h.finish()
161        };
162        let hash_b = {
163            let mut h = DefaultHasher::new();
164            b.hash(&mut h);
165            h.finish()
166        };
167        assert_eq!(hash_a, hash_b);
168    }
169
170    #[test]
171    fn file_id_inner_value_accessible() {
172        let id = FileId(123);
173        assert_eq!(id.0, 123);
174    }
175
176    #[test]
177    fn file_id_debug_format() {
178        let id = FileId(42);
179        let debug = format!("{id:?}");
180        assert!(
181            debug.contains("42"),
182            "Debug should show inner value: {debug}"
183        );
184    }
185
186    // ── DiscoveredFile ──────────────────────────────────────────────
187
188    #[test]
189    fn discovered_file_clone() {
190        let original = DiscoveredFile {
191            id: FileId(0),
192            path: PathBuf::from("/project/src/index.ts"),
193            size_bytes: 1024,
194        };
195        let cloned = original.clone();
196        assert_eq!(cloned.id, original.id);
197        assert_eq!(cloned.path, original.path);
198        assert_eq!(cloned.size_bytes, original.size_bytes);
199    }
200
201    #[test]
202    fn discovered_file_zero_size() {
203        let file = DiscoveredFile {
204            id: FileId(0),
205            path: PathBuf::from("/empty.ts"),
206            size_bytes: 0,
207        };
208        assert_eq!(file.size_bytes, 0);
209    }
210
211    #[test]
212    fn discovered_file_large_size() {
213        let file = DiscoveredFile {
214            id: FileId(0),
215            path: PathBuf::from("/large.ts"),
216            size_bytes: u64::MAX,
217        };
218        assert_eq!(file.size_bytes, u64::MAX);
219    }
220
221    // ── EntryPoint ──────────────────────────────────────────────────
222
223    #[test]
224    fn entry_point_clone() {
225        let ep = EntryPoint {
226            path: PathBuf::from("/project/src/main.ts"),
227            source: EntryPointSource::PackageJsonMain,
228        };
229        let cloned = ep.clone();
230        assert_eq!(cloned.path, ep.path);
231        assert!(matches!(cloned.source, EntryPointSource::PackageJsonMain));
232    }
233
234    // ── EntryPointSource ────────────────────────────────────────────
235
236    #[test]
237    fn entry_point_source_all_variants_constructible() {
238        // Verify all variants can be constructed (compile-time coverage)
239        let _ = EntryPointSource::PackageJsonMain;
240        let _ = EntryPointSource::PackageJsonModule;
241        let _ = EntryPointSource::PackageJsonExports;
242        let _ = EntryPointSource::PackageJsonBin;
243        let _ = EntryPointSource::PackageJsonScript;
244        let _ = EntryPointSource::Plugin {
245            name: "next".to_string(),
246        };
247        let _ = EntryPointSource::TestFile;
248        let _ = EntryPointSource::DefaultIndex;
249        let _ = EntryPointSource::ManualEntry;
250        let _ = EntryPointSource::InfrastructureConfig;
251        let _ = EntryPointSource::DynamicallyLoaded;
252    }
253
254    #[test]
255    fn entry_point_source_plugin_preserves_name() {
256        let source = EntryPointSource::Plugin {
257            name: "vitest".to_string(),
258        };
259        match source {
260            EntryPointSource::Plugin { name } => assert_eq!(name, "vitest"),
261            _ => panic!("expected Plugin variant"),
262        }
263    }
264
265    #[test]
266    fn entry_point_source_plugin_clone_preserves_name() {
267        let source = EntryPointSource::Plugin {
268            name: "storybook".to_string(),
269        };
270        // Use source after clone to verify both copies are valid
271        let cloned = source.clone();
272        // Verify original is still usable
273        assert!(matches!(&source, EntryPointSource::Plugin { name } if name == "storybook"));
274        // Verify clone has the same data
275        match cloned {
276            EntryPointSource::Plugin { name } => assert_eq!(name, "storybook"),
277            _ => panic!("expected Plugin variant after clone"),
278        }
279    }
280
281    #[test]
282    fn entry_point_source_debug_format() {
283        let source = EntryPointSource::PackageJsonMain;
284        let debug = format!("{source:?}");
285        assert!(
286            debug.contains("PackageJsonMain"),
287            "Debug should name the variant: {debug}"
288        );
289
290        let plugin = EntryPointSource::Plugin {
291            name: "remix".to_string(),
292        };
293        let debug = format!("{plugin:?}");
294        assert!(
295            debug.contains("remix"),
296            "Debug should show plugin name: {debug}"
297        );
298    }
299
300    #[test]
301    fn entry_point_source_display_all_variants() {
302        assert_eq!(
303            EntryPointSource::PackageJsonMain.to_string(),
304            "package.json main"
305        );
306        assert_eq!(
307            EntryPointSource::PackageJsonModule.to_string(),
308            "package.json module"
309        );
310        assert_eq!(
311            EntryPointSource::PackageJsonExports.to_string(),
312            "package.json exports"
313        );
314        assert_eq!(
315            EntryPointSource::PackageJsonBin.to_string(),
316            "package.json bin"
317        );
318        assert_eq!(
319            EntryPointSource::PackageJsonScript.to_string(),
320            "package.json script"
321        );
322        assert_eq!(
323            EntryPointSource::Plugin {
324                name: "vitest".to_string()
325            }
326            .to_string(),
327            "vitest"
328        );
329        assert_eq!(EntryPointSource::TestFile.to_string(), "test file");
330        assert_eq!(EntryPointSource::DefaultIndex.to_string(), "default index");
331        assert_eq!(EntryPointSource::ManualEntry.to_string(), "manual entry");
332        assert_eq!(
333            EntryPointSource::InfrastructureConfig.to_string(),
334            "infrastructure config"
335        );
336        assert_eq!(
337            EntryPointSource::DynamicallyLoaded.to_string(),
338            "dynamically loaded"
339        );
340    }
341}