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