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