1use glob::Pattern;
5use std::collections::HashSet;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::Instant;
10
11use crate::utils::file::is_path_excluded;
12
13pub struct CollectedPaths {
14 pub files: Vec<(PathBuf, fs::Metadata)>,
15 pub directories: Vec<(PathBuf, fs::Metadata)>,
16 pub excluded_count: usize,
17 pub total_file_bytes: u64,
18 pub collection_errors: Vec<(PathBuf, String)>,
19 pub limit_reached: bool,
22}
23
24#[derive(Debug, Clone, Default)]
32pub struct CollectionLimits {
33 pub max_file_count: Option<usize>,
35 pub max_total_bytes: Option<u64>,
37 pub deadline: Option<Instant>,
39 pub symlink_root_guard: Option<PathBuf>,
42}
43
44impl CollectionLimits {
45 pub fn unbounded() -> Self {
47 Self::default()
48 }
49
50 fn resolved_for_walk(&self) -> Self {
58 let symlink_root_guard = self
59 .symlink_root_guard
60 .as_ref()
61 .map(|root| fs::canonicalize(root).unwrap_or_else(|_| root.clone()));
62 Self {
63 max_file_count: self.max_file_count,
64 max_total_bytes: self.max_total_bytes,
65 deadline: self.deadline,
66 symlink_root_guard,
67 }
68 }
69
70 fn deadline_exceeded(&self) -> bool {
71 self.deadline
72 .is_some_and(|deadline| Instant::now() >= deadline)
73 }
74
75 fn file_count_reached(&self, collected_files: usize) -> bool {
76 self.max_file_count
77 .is_some_and(|limit| collected_files >= limit)
78 }
79
80 fn total_bytes_exceeded(&self, collected_bytes: u64, next_file_bytes: u64) -> bool {
81 self.max_total_bytes
82 .is_some_and(|limit| collected_bytes.saturating_add(next_file_bytes) > limit)
83 }
84}
85
86enum WalkBudget {
88 Continue,
89 Stop(&'static str),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CollectionFrontier {
94 pub path: PathBuf,
95 pub recurse: bool,
96}
97
98struct CollectionAccumulator {
99 files: Vec<(PathBuf, fs::Metadata)>,
100 directories: Vec<(PathBuf, fs::Metadata)>,
101 file_seen: HashSet<PathBuf>,
102 dir_seen: HashSet<PathBuf>,
103 excluded_count: usize,
104 total_file_bytes: u64,
105 collection_errors: Vec<(PathBuf, String)>,
106 limit_reached: bool,
107}
108
109enum TraversalMetadata {
110 File(fs::Metadata),
111 Directory {
112 metadata: fs::Metadata,
113 can_recurse: bool,
114 },
115 Other,
116}
117
118impl CollectedPaths {
119 pub fn file_count(&self) -> usize {
120 self.files.len()
121 }
122
123 pub fn directory_count(&self) -> usize {
124 self.directories.len()
125 }
126
127 pub fn scan_root(&self) -> Option<&Path> {
128 self.directories
129 .first()
130 .map(|(path, _)| path.as_path())
131 .or_else(|| {
132 self.files
133 .first()
134 .and_then(|(path, _)| path.parent().or(Some(path.as_path())))
135 })
136 }
137}
138
139pub fn collect_paths<P: AsRef<Path>>(
140 root: P,
141 max_depth: usize,
142 exclude_patterns: &[Pattern],
143) -> CollectedPaths {
144 collect_paths_with_limits(
145 root,
146 max_depth,
147 exclude_patterns,
148 &CollectionLimits::unbounded(),
149 )
150}
151
152pub fn collect_paths_with_limits<P: AsRef<Path>>(
153 root: P,
154 max_depth: usize,
155 exclude_patterns: &[Pattern],
156 limits: &CollectionLimits,
157) -> CollectedPaths {
158 let limits = &limits.resolved_for_walk();
159 let depth_limit = depth_limit_from_cli(max_depth);
160 let root = root.as_ref();
161
162 if is_path_excluded(root, exclude_patterns) {
163 return CollectedPaths {
164 files: Vec::new(),
165 directories: Vec::new(),
166 excluded_count: 1,
167 total_file_bytes: 0,
168 collection_errors: Vec::new(),
169 limit_reached: false,
170 };
171 }
172
173 let traversal_metadata = match classify_for_traversal(root, true, limits) {
174 Ok(traversal_metadata) => traversal_metadata,
175 Err(error) => {
176 return CollectedPaths {
177 files: Vec::new(),
178 directories: Vec::new(),
179 excluded_count: 0,
180 total_file_bytes: 0,
181 collection_errors: vec![(root.to_path_buf(), error.to_string())],
182 limit_reached: false,
183 };
184 }
185 };
186
187 match traversal_metadata {
188 TraversalMetadata::File(metadata) => CollectedPaths {
189 total_file_bytes: metadata.len(),
190 files: vec![(root.to_path_buf(), metadata)],
191 directories: Vec::new(),
192 excluded_count: 0,
193 collection_errors: Vec::new(),
194 limit_reached: false,
195 },
196 TraversalMetadata::Directory {
197 metadata,
198 can_recurse,
199 } if can_recurse => {
200 collect_all_paths(root, &metadata, depth_limit, exclude_patterns, limits)
201 }
202 TraversalMetadata::Directory { metadata, .. } => CollectedPaths {
203 files: Vec::new(),
204 directories: vec![(root.to_path_buf(), metadata)],
205 excluded_count: 0,
206 total_file_bytes: 0,
207 collection_errors: Vec::new(),
208 limit_reached: false,
209 },
210 TraversalMetadata::Other => CollectedPaths {
211 files: Vec::new(),
212 directories: Vec::new(),
213 excluded_count: 0,
214 total_file_bytes: 0,
215 collection_errors: Vec::new(),
216 limit_reached: false,
217 },
218 }
219}
220
221pub fn collect_selected_paths(
222 root: &Path,
223 selected: &[CollectionFrontier],
224 max_depth: usize,
225 exclude_patterns: &[Pattern],
226) -> CollectedPaths {
227 collect_selected_paths_with_limits(
228 root,
229 selected,
230 max_depth,
231 exclude_patterns,
232 &CollectionLimits::unbounded(),
233 )
234}
235
236pub fn collect_selected_paths_with_limits(
237 root: &Path,
238 selected: &[CollectionFrontier],
239 max_depth: usize,
240 exclude_patterns: &[Pattern],
241 limits: &CollectionLimits,
242) -> CollectedPaths {
243 let limits = &limits.resolved_for_walk();
244 let depth_limit = depth_limit_from_cli(max_depth);
245
246 if is_path_excluded(root, exclude_patterns) {
247 return CollectedPaths {
248 files: Vec::new(),
249 directories: Vec::new(),
250 excluded_count: 1,
251 total_file_bytes: 0,
252 collection_errors: Vec::new(),
253 limit_reached: false,
254 };
255 }
256
257 let root_metadata = match classify_for_traversal(root, true, limits) {
258 Ok(TraversalMetadata::Directory { metadata, .. }) => metadata,
259 Ok(TraversalMetadata::File(metadata)) => metadata,
260 Ok(TraversalMetadata::Other) => {
261 return CollectedPaths {
262 files: Vec::new(),
263 directories: Vec::new(),
264 excluded_count: 0,
265 total_file_bytes: 0,
266 collection_errors: Vec::new(),
267 limit_reached: false,
268 };
269 }
270 Err(error) => {
271 return CollectedPaths {
272 files: Vec::new(),
273 directories: Vec::new(),
274 excluded_count: 0,
275 total_file_bytes: 0,
276 collection_errors: vec![(root.to_path_buf(), error.to_string())],
277 limit_reached: false,
278 };
279 }
280 };
281
282 let mut accumulator = CollectionAccumulator {
283 files: Vec::new(),
284 directories: vec![(root.to_path_buf(), root_metadata)],
285 file_seen: HashSet::new(),
286 dir_seen: HashSet::from([root.to_path_buf()]),
287 excluded_count: 0,
288 total_file_bytes: 0,
289 collection_errors: Vec::new(),
290 limit_reached: false,
291 };
292
293 for frontier in minimize_frontier(selected) {
294 if accumulator.limit_reached {
295 break;
296 }
297 let relative_depth = frontier.path.components().count();
298 if depth_limit.is_some_and(|limit| relative_depth > limit) {
299 continue;
300 }
301
302 let absolute = root.join(&frontier.path);
303 if is_path_or_any_ancestor_excluded(root, &absolute, exclude_patterns) {
304 accumulator.excluded_count += 1;
305 continue;
306 }
307
308 let traversal_metadata = match classify_for_traversal(&absolute, false, limits) {
309 Ok(traversal_metadata) => traversal_metadata,
310 Err(error) => {
311 accumulator
312 .collection_errors
313 .push((absolute, error.to_string()));
314 continue;
315 }
316 };
317
318 add_ancestor_directories(root, &absolute, &mut accumulator, limits);
319
320 let collected = match traversal_metadata {
321 TraversalMetadata::File(metadata) => {
322 insert_file(&mut accumulator, absolute, metadata, limits);
323 continue;
324 }
325 TraversalMetadata::Directory {
326 metadata,
327 can_recurse,
328 } if frontier.recurse && can_recurse => {
329 let subtree_depth_limit =
330 depth_limit.map(|limit| limit.saturating_sub(relative_depth));
331 collect_all_paths(
332 &absolute,
333 &metadata,
334 subtree_depth_limit,
335 exclude_patterns,
336 &subtree_limits(limits, &accumulator),
337 )
338 }
339 TraversalMetadata::Directory { metadata, .. } => CollectedPaths {
340 files: Vec::new(),
341 directories: vec![(absolute, metadata)],
342 excluded_count: 0,
343 total_file_bytes: 0,
344 collection_errors: Vec::new(),
345 limit_reached: false,
346 },
347 TraversalMetadata::Other => continue,
348 };
349 merge_collected(&mut accumulator, collected, limits);
350 }
351
352 CollectedPaths {
353 files: accumulator.files,
354 directories: accumulator.directories,
355 excluded_count: accumulator.excluded_count,
356 total_file_bytes: accumulator.total_file_bytes,
357 collection_errors: accumulator.collection_errors,
358 limit_reached: accumulator.limit_reached,
359 }
360}
361
362fn subtree_limits(
365 limits: &CollectionLimits,
366 accumulator: &CollectionAccumulator,
367) -> CollectionLimits {
368 CollectionLimits {
369 max_file_count: limits
370 .max_file_count
371 .map(|limit| limit.saturating_sub(accumulator.files.len())),
372 max_total_bytes: limits
373 .max_total_bytes
374 .map(|limit| limit.saturating_sub(accumulator.total_file_bytes)),
375 deadline: limits.deadline,
376 symlink_root_guard: limits.symlink_root_guard.clone(),
377 }
378}
379
380fn collect_all_paths(
381 root: &Path,
382 root_metadata: &fs::Metadata,
383 depth_limit: Option<usize>,
384 exclude_patterns: &[Pattern],
385 limits: &CollectionLimits,
386) -> CollectedPaths {
387 let mut files = Vec::new();
388 let mut directories = vec![(root.to_path_buf(), root_metadata.clone())];
389 let mut excluded_count = 0;
390 let mut total_file_bytes = 0_u64;
391 let mut collection_errors = Vec::new();
392 let mut limit_reached = false;
393
394 let mut pending_dirs: Vec<(PathBuf, Option<usize>)> = vec![(root.to_path_buf(), depth_limit)];
395
396 'walk: while let Some((dir_path, current_depth)) = pending_dirs.pop() {
397 let entries: Vec<_> = match fs::read_dir(&dir_path) {
398 Ok(entries) => entries.filter_map(Result::ok).collect(),
399 Err(e) => {
400 collection_errors.push((dir_path.clone(), e.to_string()));
401 continue;
402 }
403 };
404
405 for entry in entries {
406 let path = entry.path();
407
408 if is_path_excluded(&path, exclude_patterns) {
409 excluded_count += 1;
410 continue;
411 }
412
413 match classify_for_traversal(&path, false, limits) {
414 Ok(TraversalMetadata::File(metadata)) => {
415 if let WalkBudget::Stop(reason) =
416 enforce_file_budget(limits, files.len(), total_file_bytes, metadata.len())
417 {
418 collection_errors.push((path, reason.to_string()));
419 limit_reached = true;
420 break 'walk;
421 }
422 total_file_bytes += metadata.len();
423 files.push((path, metadata));
424 }
425 Ok(TraversalMetadata::Directory {
426 metadata,
427 can_recurse,
428 }) => {
429 directories.push((path.clone(), metadata));
430 let should_recurse = can_recurse && current_depth.is_none_or(|d| d > 0);
431 if should_recurse {
432 let next_depth = current_depth.map(|d| d - 1);
433 pending_dirs.push((path, next_depth));
434 }
435 }
436 _ => continue,
437 }
438 }
439 }
440
441 CollectedPaths {
442 files,
443 directories,
444 excluded_count,
445 total_file_bytes,
446 collection_errors,
447 limit_reached,
448 }
449}
450
451fn enforce_file_budget(
453 limits: &CollectionLimits,
454 collected_files: usize,
455 collected_bytes: u64,
456 next_file_bytes: u64,
457) -> WalkBudget {
458 if limits.deadline_exceeded() {
459 return WalkBudget::Stop("scan exceeded its overall time budget");
460 }
461 if limits.file_count_reached(collected_files) {
462 return WalkBudget::Stop("scan exceeded its maximum file count");
463 }
464 if limits.total_bytes_exceeded(collected_bytes, next_file_bytes) {
465 return WalkBudget::Stop("scan exceeded its maximum total byte size");
466 }
467 WalkBudget::Continue
468}
469
470fn classify_for_traversal(
471 path: &Path,
472 recurse_into_symlinked_directories: bool,
473 limits: &CollectionLimits,
474) -> io::Result<TraversalMetadata> {
475 let link_metadata = fs::symlink_metadata(path)?;
476 if link_metadata.file_type().is_symlink() {
477 if let Some(canonical_root) = &limits.symlink_root_guard
478 && symlink_target_escapes_root(path, canonical_root)
479 {
480 return Ok(TraversalMetadata::Other);
484 }
485 let target_metadata = fs::metadata(path)?;
486 return Ok(classify_resolved_metadata(
487 target_metadata,
488 recurse_into_symlinked_directories,
489 ));
490 }
491
492 Ok(classify_resolved_metadata(link_metadata, true))
493}
494
495fn symlink_target_escapes_root(path: &Path, canonical_root: &Path) -> bool {
499 match fs::canonicalize(path) {
500 Ok(canonical_target) => !canonical_target.starts_with(canonical_root),
501 Err(_) => true,
502 }
503}
504
505fn classify_resolved_metadata(metadata: fs::Metadata, can_recurse: bool) -> TraversalMetadata {
506 if metadata.is_file() {
507 TraversalMetadata::File(metadata)
508 } else if metadata.is_dir() {
509 TraversalMetadata::Directory {
510 metadata,
511 can_recurse,
512 }
513 } else {
514 TraversalMetadata::Other
515 }
516}
517
518fn depth_limit_from_cli(max_depth: usize) -> Option<usize> {
519 if max_depth == 0 {
520 None
521 } else {
522 Some(max_depth)
523 }
524}
525
526fn is_path_or_any_ancestor_excluded(
527 path_root: &Path,
528 path: &Path,
529 exclude_patterns: &[Pattern],
530) -> bool {
531 let mut current = Some(path);
532 while let Some(candidate) = current {
533 if is_path_excluded(candidate, exclude_patterns) {
534 return true;
535 }
536 if candidate == path_root {
537 break;
538 }
539 current = candidate.parent();
540 }
541 false
542}
543
544fn minimize_frontier(selected: &[CollectionFrontier]) -> Vec<CollectionFrontier> {
545 let mut ordered = selected.to_vec();
546 ordered.sort_by_key(|entry| (entry.path.components().count(), !entry.recurse));
547
548 let mut minimized = Vec::new();
549 for entry in ordered {
550 let covered = minimized.iter().any(|existing: &CollectionFrontier| {
551 existing.recurse
552 && (entry.path == existing.path || entry.path.starts_with(&existing.path))
553 });
554 if !covered {
555 minimized.push(entry);
556 }
557 }
558 minimized
559}
560
561fn add_ancestor_directories(
562 root: &Path,
563 path: &Path,
564 accumulator: &mut CollectionAccumulator,
565 limits: &CollectionLimits,
566) {
567 let mut current = path.parent();
568 while let Some(dir) = current {
569 if dir == root {
570 break;
571 }
572 if accumulator.dir_seen.insert(dir.to_path_buf()) {
573 match classify_for_traversal(dir, false, limits) {
574 Ok(TraversalMetadata::Directory { metadata, .. }) => {
575 accumulator.directories.push((dir.to_path_buf(), metadata))
576 }
577 Ok(_) => {}
578 Err(error) => accumulator
579 .collection_errors
580 .push((dir.to_path_buf(), error.to_string())),
581 }
582 }
583 current = dir.parent();
584 }
585}
586
587fn insert_file(
588 accumulator: &mut CollectionAccumulator,
589 path: PathBuf,
590 metadata: fs::Metadata,
591 limits: &CollectionLimits,
592) {
593 if accumulator.limit_reached || accumulator.file_seen.contains(&path) {
594 return;
595 }
596 if let WalkBudget::Stop(reason) = enforce_file_budget(
597 limits,
598 accumulator.files.len(),
599 accumulator.total_file_bytes,
600 metadata.len(),
601 ) {
602 accumulator
603 .collection_errors
604 .push((path, reason.to_string()));
605 accumulator.limit_reached = true;
606 return;
607 }
608 accumulator.file_seen.insert(path.clone());
609 accumulator.total_file_bytes += metadata.len();
610 accumulator.files.push((path, metadata));
611}
612
613fn merge_collected(
614 accumulator: &mut CollectionAccumulator,
615 collected: CollectedPaths,
616 limits: &CollectionLimits,
617) {
618 accumulator.excluded_count += collected.excluded_count;
619 accumulator
620 .collection_errors
621 .extend(collected.collection_errors);
622
623 for (path, metadata) in collected.files {
628 insert_file(accumulator, path, metadata, limits);
629 }
630 accumulator.limit_reached |= collected.limit_reached;
631
632 for (path, metadata) in collected.directories {
633 if accumulator.dir_seen.insert(path.clone()) {
634 accumulator.directories.push((path, metadata));
635 }
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::{
642 CollectionFrontier, CollectionLimits, collect_paths, collect_paths_with_limits,
643 collect_selected_paths, collect_selected_paths_with_limits,
644 };
645 use std::fs;
646 use std::path::PathBuf;
647 use std::time::{Duration, Instant};
648
649 #[test]
650 fn file_scan_root_uses_parent_directory() {
651 let temp_dir = tempfile::tempdir().expect("temp dir");
652 let file_path = temp_dir.path().join("Directory.Packages.props");
653 fs::write(&file_path, "<Project />").expect("write props file");
654
655 let collected = collect_paths(&file_path, 0, &[]);
656 assert_eq!(collected.file_count(), 1);
657 assert_eq!(collected.directory_count(), 0);
658 assert_eq!(collected.scan_root(), Some(temp_dir.path()));
659 }
660
661 #[test]
662 fn collect_paths_recurses_regular_directories() {
663 let temp_dir = tempfile::tempdir().expect("temp dir");
664 let nested = temp_dir.path().join("src/bin");
665 fs::create_dir_all(&nested).expect("create nested directory");
666 fs::write(nested.join("main.rs"), "fn main() {}\n").expect("write nested file");
667
668 let collected = collect_paths(temp_dir.path(), 0, &[]);
669
670 assert!(
671 collected
672 .files
673 .iter()
674 .any(|(path, _)| path == &temp_dir.path().join("src/bin/main.rs"))
675 );
676 }
677
678 #[cfg(unix)]
679 #[test]
680 fn collect_paths_does_not_recurse_into_symlinked_directory_cycle() {
681 use std::os::unix::fs::symlink;
682
683 let temp_dir = tempfile::tempdir().expect("temp dir");
684 let root = temp_dir.path();
685 let real = root.join("real");
686 fs::create_dir_all(&real).expect("create real directory");
687 fs::write(real.join("file.txt"), "content\n").expect("write file");
688 symlink(root, real.join("back")).expect("create symlink cycle");
689
690 let collected = collect_paths(root, 0, &[]);
691
692 assert!(collected.collection_errors.is_empty());
693 assert_eq!(collected.file_count(), 1);
694 assert!(
695 collected
696 .files
697 .iter()
698 .all(|(path, _)| !path.starts_with(real.join("back")))
699 );
700 }
701
702 #[cfg(unix)]
703 #[test]
704 fn collect_paths_recurses_explicit_symlinked_scan_root() {
705 use std::os::unix::fs::symlink;
706
707 let temp_dir = tempfile::tempdir().expect("temp dir");
708 let target = temp_dir.path().join("target");
709 fs::create_dir_all(&target).expect("create target directory");
710 fs::write(target.join("inside.txt"), "content\n").expect("write file");
711 let root_link = temp_dir.path().join("root-link");
712 symlink(&target, &root_link).expect("create root symlink");
713
714 let collected = collect_paths(&root_link, 0, &[]);
715
716 assert!(collected.collection_errors.is_empty());
717 assert!(
718 collected
719 .files
720 .iter()
721 .any(|(path, _)| path == &root_link.join("inside.txt"))
722 );
723 }
724
725 #[cfg(unix)]
726 #[test]
727 fn collect_paths_keeps_symlinked_regular_files_scannable() {
728 use std::os::unix::fs::symlink;
729
730 let temp_dir = tempfile::tempdir().expect("temp dir");
731 let root = temp_dir.path();
732 let target = root.join("target.txt");
733 fs::write(&target, "content\n").expect("write target file");
734 let link = root.join("link.txt");
735 symlink(&target, &link).expect("create file symlink");
736
737 let collected = collect_paths(root, 0, &[]);
738
739 assert!(collected.collection_errors.is_empty());
740 assert!(
741 collected
742 .files
743 .iter()
744 .any(|(path, _)| path == &root.join("link.txt"))
745 );
746 }
747
748 #[cfg(unix)]
749 #[test]
750 fn collect_selected_paths_does_not_recurse_into_symlinked_directory() {
751 use std::os::unix::fs::symlink;
752
753 let temp_dir = tempfile::tempdir().expect("temp dir");
754 let root = temp_dir.path();
755 let target = root.join("target");
756 fs::create_dir_all(&target).expect("create target directory");
757 fs::write(target.join("inside.txt"), "content\n").expect("write file");
758 symlink(&target, root.join("link")).expect("create directory symlink");
759
760 let collected = collect_selected_paths(
761 root,
762 &[CollectionFrontier {
763 path: PathBuf::from("link"),
764 recurse: true,
765 }],
766 0,
767 &[],
768 );
769
770 assert!(collected.collection_errors.is_empty());
771 assert!(collected.files.is_empty());
772 assert!(
773 collected
774 .directories
775 .iter()
776 .any(|(path, _)| path == &root.join("link"))
777 );
778 }
779
780 #[cfg(unix)]
781 #[test]
782 fn symlink_root_guard_skips_file_symlink_pointing_outside_root() {
783 use std::os::unix::fs::symlink;
784
785 let outside_dir = tempfile::tempdir().expect("outside temp dir");
786 let secret = outside_dir.path().join("secret.txt");
787 fs::write(&secret, "TOP SECRET\n").expect("write out-of-tree secret");
788
789 let scan_dir = tempfile::tempdir().expect("scan temp dir");
790 let root = scan_dir.path();
791 fs::write(root.join("inside.txt"), "ordinary\n").expect("write inside file");
792 symlink(&secret, root.join("creds")).expect("create escaping symlink");
793
794 let limits = CollectionLimits {
795 symlink_root_guard: Some(root.to_path_buf()),
796 ..CollectionLimits::unbounded()
797 };
798 let collected = collect_paths_with_limits(root, 0, &[], &limits);
799
800 assert!(
802 collected
803 .files
804 .iter()
805 .all(|(path, _)| path != &root.join("creds"))
806 );
807 assert!(
809 collected
810 .files
811 .iter()
812 .any(|(path, _)| path == &root.join("inside.txt"))
813 );
814 }
815
816 #[cfg(unix)]
817 #[test]
818 fn symlink_root_guard_keeps_in_tree_file_symlinks() {
819 use std::os::unix::fs::symlink;
820
821 let scan_dir = tempfile::tempdir().expect("scan temp dir");
822 let root = scan_dir.path();
823 let target = root.join("target.txt");
824 fs::write(&target, "content\n").expect("write target file");
825 symlink(&target, root.join("link.txt")).expect("create in-tree symlink");
826
827 let limits = CollectionLimits {
828 symlink_root_guard: Some(root.to_path_buf()),
829 ..CollectionLimits::unbounded()
830 };
831 let collected = collect_paths_with_limits(root, 0, &[], &limits);
832
833 assert!(
834 collected
835 .files
836 .iter()
837 .any(|(path, _)| path == &root.join("link.txt"))
838 );
839 }
840
841 #[test]
842 fn max_file_count_limit_stops_collection() {
843 let temp_dir = tempfile::tempdir().expect("temp dir");
844 let root = temp_dir.path();
845 for index in 0..5 {
846 fs::write(root.join(format!("file{index}.txt")), "x\n").expect("write file");
847 }
848
849 let limits = CollectionLimits {
850 max_file_count: Some(2),
851 ..CollectionLimits::unbounded()
852 };
853 let collected = collect_paths_with_limits(root, 0, &[], &limits);
854
855 assert!(collected.limit_reached);
856 assert!(collected.file_count() <= 2);
857 assert!(
858 collected
859 .collection_errors
860 .iter()
861 .any(|(_, reason)| reason.contains("maximum file count"))
862 );
863 }
864
865 #[test]
866 fn max_total_bytes_limit_stops_collection() {
867 let temp_dir = tempfile::tempdir().expect("temp dir");
868 let root = temp_dir.path();
869 for index in 0..5 {
870 fs::write(root.join(format!("file{index}.txt")), vec![b'x'; 100]).expect("write file");
871 }
872
873 let limits = CollectionLimits {
874 max_total_bytes: Some(150),
875 ..CollectionLimits::unbounded()
876 };
877 let collected = collect_paths_with_limits(root, 0, &[], &limits);
878
879 assert!(collected.limit_reached);
880 assert!(collected.total_file_bytes <= 150);
881 assert!(
882 collected
883 .collection_errors
884 .iter()
885 .any(|(_, reason)| reason.contains("maximum total byte size"))
886 );
887 }
888
889 #[test]
890 fn deadline_already_passed_stops_collection() {
891 let temp_dir = tempfile::tempdir().expect("temp dir");
892 let root = temp_dir.path();
893 fs::write(root.join("file.txt"), "x\n").expect("write file");
894
895 let limits = CollectionLimits {
896 deadline: Some(Instant::now() - Duration::from_secs(1)),
897 ..CollectionLimits::unbounded()
898 };
899 let collected = collect_paths_with_limits(root, 0, &[], &limits);
900
901 assert!(collected.limit_reached);
902 assert!(
903 collected
904 .collection_errors
905 .iter()
906 .any(|(_, reason)| reason.contains("overall time budget"))
907 );
908 }
909
910 #[test]
911 fn unbounded_limits_preserve_full_collection() {
912 let temp_dir = tempfile::tempdir().expect("temp dir");
913 let root = temp_dir.path();
914 for index in 0..5 {
915 fs::write(root.join(format!("file{index}.txt")), "x\n").expect("write file");
916 }
917
918 let collected = collect_paths_with_limits(root, 0, &[], &CollectionLimits::unbounded());
919
920 assert!(!collected.limit_reached);
921 assert_eq!(collected.file_count(), 5);
922 }
923
924 #[test]
925 fn truncated_subtree_keeps_files_collected_within_its_budget() {
926 let temp_dir = tempfile::tempdir().expect("temp dir");
930 let root = temp_dir.path();
931
932 let first = root.join("first");
933 fs::create_dir_all(&first).expect("create first dir");
934 for index in 0..3 {
935 fs::write(first.join(format!("f{index}.txt")), "x\n").expect("write file");
936 }
937
938 let second = root.join("second");
939 fs::create_dir_all(&second).expect("create second dir");
940 for index in 0..5 {
941 fs::write(second.join(format!("s{index}.txt")), "x\n").expect("write file");
942 }
943
944 let limits = CollectionLimits {
945 max_file_count: Some(5),
946 ..CollectionLimits::unbounded()
947 };
948 let collected = collect_selected_paths_with_limits(
949 root,
950 &[
951 CollectionFrontier {
952 path: PathBuf::from("first"),
953 recurse: true,
954 },
955 CollectionFrontier {
956 path: PathBuf::from("second"),
957 recurse: true,
958 },
959 ],
960 0,
961 &[],
962 &limits,
963 );
964
965 assert!(collected.limit_reached);
967 assert_eq!(collected.file_count(), 5);
968 assert_eq!(
969 collected
970 .files
971 .iter()
972 .filter(|(path, _)| path.starts_with(&first))
973 .count(),
974 3
975 );
976 assert_eq!(
977 collected
978 .files
979 .iter()
980 .filter(|(path, _)| path.starts_with(&second))
981 .count(),
982 2
983 );
984 }
985}