1use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
22pub struct DiscoveredFile {
23 pub id: FileId,
25 pub path: PathBuf,
27 pub size_bytes: u64,
29}
30
31#[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#[derive(
62 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
63)]
64pub struct StableFileKey(String);
65
66impl StableFileKey {
67 #[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 #[must_use]
76 pub fn from_relative(path: &Path) -> Self {
77 Self(normalize_path(path))
78 }
79
80 #[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#[derive(Debug, Clone)]
93pub struct EntryPoint {
94 pub path: PathBuf,
96 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#[derive(Debug, Clone)]
120pub enum EntryPointSource {
121 PackageJsonMain,
123 PackageJsonModule,
125 PackageJsonExports,
127 PackageJsonBin,
129 PackageJsonScript,
131 Plugin {
133 name: String,
135 },
136 TestFile,
138 DefaultIndex,
140 ManualEntry,
142 InfrastructureConfig,
144 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; 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}