1use crate::error::{PathErrorExt, SsgError};
25use std::ffi::OsString;
26use std::{
27 fs,
28 path::{Path, PathBuf},
29};
30
31pub fn walk_files(
50 dir: &Path,
51 extension: &str,
52) -> Result<Vec<PathBuf>, SsgError> {
53 let mut files = Vec::new();
54 let mut stack = vec![dir.to_path_buf()];
55 while let Some(current) = stack.pop() {
56 if !current.is_dir() {
57 continue;
58 }
59 for entry in fs::read_dir(¤t).with_path(¤t)? {
60 let entry = entry.with_path(¤t)?;
61 let path = entry.path();
62 if path.is_dir() {
63 stack.push(path);
64 } else if path.extension().is_some_and(|e| e == extension) {
65 files.push(path);
66 }
67 }
68 }
69 files.sort();
70 Ok(files)
71}
72
73pub fn walk_files_multi(
93 dir: &Path,
94 extensions: &[&str],
95) -> Result<Vec<PathBuf>, SsgError> {
96 let mut files = Vec::new();
97 let mut stack = vec![dir.to_path_buf()];
98 while let Some(current) = stack.pop() {
99 if !current.is_dir() {
100 continue;
101 }
102 for entry in fs::read_dir(¤t).with_path(¤t)? {
103 let entry = entry.with_path(¤t)?;
104 let path = entry.path();
105 if path.is_dir() {
106 stack.push(path);
107 } else if let Some(ext) = path.extension() {
108 let ext_lower = ext.to_string_lossy().to_lowercase();
109 if extensions.contains(&ext_lower.as_str()) {
110 files.push(path);
111 }
112 }
113 }
114 }
115 files.sort();
116 Ok(files)
117}
118
119pub fn visit_files_bounded_depth<E, F>(
140 dir: &Path,
141 ext: &str,
142 max_depth: usize,
143 mut visit: F,
144) -> Result<(), E>
145where
146 E: From<std::io::Error>,
147 F: FnMut(&Path) -> Result<(), E>,
148{
149 fn recurse<E, F>(
150 dir: &Path,
151 ext: &str,
152 depth_left: usize,
153 visit: &mut F,
154 ) -> Result<(), E>
155 where
156 E: From<std::io::Error>,
157 F: FnMut(&Path) -> Result<(), E>,
158 {
159 let mut names: Vec<(OsString, bool)> = Vec::new();
166 for entry in fs::read_dir(dir)? {
167 let entry = entry?;
168 let is_dir = entry.file_type()?.is_dir();
169 names.push((entry.file_name(), is_dir));
170 }
171 names.sort_by(|a, b| a.0.cmp(&b.0));
172 for (name, is_dir) in names {
173 let path = dir.join(&name);
174 if is_dir {
175 if depth_left > 0 {
176 recurse(&path, ext, depth_left - 1, visit)?;
177 }
178 } else if path.extension().is_some_and(|x| x == ext) {
179 visit(&path)?;
180 }
181 }
182 Ok(())
183 }
184 if !dir.exists() {
189 return Ok(());
190 }
191 recurse(dir, ext, max_depth, &mut visit)
192}
193
194pub fn walk_files_bounded_depth(
213 dir: &Path,
214 extension: &str,
215 max_depth: usize,
216) -> Result<Vec<PathBuf>, SsgError> {
217 let mut files = Vec::new();
218 let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
219 while let Some((current, depth)) = stack.pop() {
220 if depth > max_depth || !current.is_dir() {
221 continue;
222 }
223 for entry in fs::read_dir(¤t).with_path(¤t)? {
224 let entry = entry.with_path(¤t)?;
225 let path = entry.path();
226 if path.is_dir() {
227 stack.push((path, depth + 1));
228 } else if path.extension().is_some_and(|e| e == extension) {
229 files.push(path);
230 }
231 }
232 }
233 files.sort();
234 Ok(files)
235}
236
237pub fn walk_files_bounded_count(
258 dir: &Path,
259 extension: &str,
260 max_files: usize,
261) -> Result<Vec<PathBuf>, SsgError> {
262 let mut files = Vec::new();
263 let mut stack = vec![dir.to_path_buf()];
264
265 while let Some(current) = stack.pop() {
266 if files.len() >= max_files {
267 break;
268 }
269 if !current.is_dir() {
270 continue;
271 }
272 let entries = fs::read_dir(¤t).with_path(¤t)?;
273 for entry in entries {
274 let path = entry.with_path(¤t)?.path();
275 if path.is_dir() {
276 stack.push(path);
277 } else if path.extension().is_some_and(|e| e == extension) {
278 files.push(path);
279 if files.len() >= max_files {
280 break;
281 }
282 }
283 }
284 }
285
286 Ok(files)
287}
288
289#[cfg(test)]
290mod tests {
291 #[test]
296 fn streaming_walk_matches_collected_order() {
297 let dir = tempdir().unwrap();
298 let root = dir.path();
299 for rel in [
300 "zeta.md",
301 "alpha.md",
302 "sub/yak.md",
303 "sub/ant.md",
304 "mid.md",
305 "sub/deep/omega.md",
306 "sub/deep/beta.md",
307 "note.txt",
308 ] {
309 let p = root.join(rel);
310 fs::create_dir_all(p.parent().unwrap()).unwrap();
311 fs::write(&p, "x").unwrap();
312 }
313 let collected = walk_files_bounded_depth(root, "md", 8).unwrap();
314 let mut streamed = Vec::new();
315 visit_files_bounded_depth(
316 root,
317 "md",
318 8,
319 |p| -> Result<(), std::io::Error> {
320 streamed.push(p.to_path_buf());
321 Ok(())
322 },
323 )
324 .unwrap();
325 assert_eq!(streamed, collected);
326 assert_eq!(streamed.len(), 7, "the .txt must be excluded");
327 }
328
329 use super::*;
330 use tempfile::tempdir;
331
332 #[test]
337 fn walk_files_returns_empty_for_missing_directory() {
338 let dir = tempdir().unwrap();
339 let result = walk_files(&dir.path().join("missing"), "html").unwrap();
340 assert!(result.is_empty());
341 }
342
343 #[test]
344 fn walk_files_filters_by_extension() {
345 let dir = tempdir().unwrap();
346 fs::write(dir.path().join("a.html"), "").unwrap();
347 fs::write(dir.path().join("b.css"), "").unwrap();
348 fs::write(dir.path().join("c.js"), "").unwrap();
349
350 let result = walk_files(dir.path(), "html").unwrap();
351 assert_eq!(result.len(), 1);
352 assert!(result[0].ends_with("a.html"));
353 }
354
355 #[test]
356 fn walk_files_recurses_into_subdirectories() {
357 let dir = tempdir().unwrap();
358 let nested = dir.path().join("a").join("b");
359 fs::create_dir_all(&nested).unwrap();
360 fs::write(dir.path().join("top.md"), "").unwrap();
361 fs::write(nested.join("deep.md"), "").unwrap();
362
363 let result = walk_files(dir.path(), "md").unwrap();
364 assert_eq!(result.len(), 2);
365 }
366
367 #[test]
368 fn walk_files_skips_extensionless_files() {
369 let dir = tempdir().unwrap();
374 fs::write(dir.path().join("README"), "").unwrap();
375 fs::write(dir.path().join("a.html"), "").unwrap();
376
377 let result = walk_files(dir.path(), "html").unwrap();
378 assert_eq!(result.len(), 1);
379 assert!(result[0].ends_with("a.html"));
380 }
381
382 #[test]
383 fn walk_files_returns_results_sorted() {
384 let dir = tempdir().unwrap();
385 for name in ["zebra.html", "apple.html", "mango.html"] {
386 fs::write(dir.path().join(name), "").unwrap();
387 }
388 let result = walk_files(dir.path(), "html").unwrap();
389 let names: Vec<_> = result
390 .iter()
391 .map(|p| p.file_name().unwrap().to_str().unwrap())
392 .collect();
393 assert_eq!(names, vec!["apple.html", "mango.html", "zebra.html"]);
394 }
395
396 #[test]
401 fn walk_files_multi_collects_each_supplied_extension() {
402 let dir = tempdir().unwrap();
403 for name in ["a.jpg", "b.jpeg", "c.png", "d.gif", "e.txt"] {
404 fs::write(dir.path().join(name), "").unwrap();
405 }
406 let result =
407 walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
408 assert_eq!(result.len(), 3);
409 }
410
411 #[test]
412 fn walk_files_multi_extension_match_is_case_insensitive() {
413 let dir = tempdir().unwrap();
414 for name in ["A.JPG", "B.PNG", "C.JPEG"] {
415 fs::write(dir.path().join(name), "").unwrap();
416 }
417 let result =
418 walk_files_multi(dir.path(), &["jpg", "jpeg", "png"]).unwrap();
419 assert_eq!(result.len(), 3);
420 }
421
422 #[test]
423 fn walk_files_multi_skips_extensionless_files() {
424 let dir = tempdir().unwrap();
427 fs::write(dir.path().join("README"), "").unwrap();
428 fs::write(dir.path().join("a.jpg"), "").unwrap();
429
430 let result = walk_files_multi(dir.path(), &["jpg"]).unwrap();
431 assert_eq!(result.len(), 1);
432 assert!(result[0].ends_with("a.jpg"));
433 }
434
435 #[test]
436 fn walk_files_multi_returns_empty_for_missing_directory() {
437 let dir = tempdir().unwrap();
438 let result =
439 walk_files_multi(&dir.path().join("missing"), &["jpg"]).unwrap();
440 assert!(result.is_empty());
441 }
442
443 #[test]
448 fn walk_files_bounded_depth_respects_max_depth() {
449 let dir = tempdir().unwrap();
450 let mut current = dir.path().to_path_buf();
451 for i in 0..5 {
452 current = current.join(format!("d{i}"));
453 fs::create_dir_all(¤t).unwrap();
454 fs::write(current.join("p.md"), "").unwrap();
455 }
456 let result = walk_files_bounded_depth(dir.path(), "md", 2).unwrap();
458 assert!(result.len() <= 3);
459 }
460
461 #[test]
462 fn walk_files_bounded_depth_skips_extensionless_files() {
463 let dir = tempdir().unwrap();
464 fs::write(dir.path().join("README"), "").unwrap();
465 fs::write(dir.path().join("a.md"), "").unwrap();
466
467 let result = walk_files_bounded_depth(dir.path(), "md", 4).unwrap();
468 assert_eq!(result.len(), 1);
469 assert!(result[0].ends_with("a.md"));
470 }
471
472 #[test]
473 fn walk_files_bounded_depth_returns_empty_for_missing_directory() {
474 let dir = tempdir().unwrap();
475 let result =
476 walk_files_bounded_depth(&dir.path().join("missing"), "md", 8)
477 .unwrap();
478 assert!(result.is_empty());
479 }
480
481 #[test]
486 fn walk_files_bounded_count_respects_max_files() {
487 let dir = tempdir().unwrap();
488 for i in 0..10 {
489 fs::write(dir.path().join(format!("f{i}.html")), "").unwrap();
490 }
491 let result = walk_files_bounded_count(dir.path(), "html", 5).unwrap();
492 assert_eq!(result.len(), 5);
493 }
494
495 #[test]
496 fn walk_files_bounded_count_skips_extensionless_files() {
497 let dir = tempdir().unwrap();
498 fs::write(dir.path().join("README"), "").unwrap();
499 fs::write(dir.path().join("a.html"), "").unwrap();
500
501 let result = walk_files_bounded_count(dir.path(), "html", 10).unwrap();
502 assert_eq!(result.len(), 1);
503 assert!(result[0].ends_with("a.html"));
504 }
505
506 #[test]
507 fn walk_files_bounded_count_returns_empty_for_missing_directory() {
508 let dir = tempdir().unwrap();
509 let result =
510 walk_files_bounded_count(&dir.path().join("missing"), "html", 100)
511 .unwrap();
512 assert!(result.is_empty());
513 }
514
515 #[test]
516 fn walk_files_bounded_count_outer_loop_breaks_on_saturation() {
517 let dir = tempdir().unwrap();
520 let a = dir.path().join("a");
521 let b = dir.path().join("b");
522 fs::create_dir_all(&a).unwrap();
523 fs::create_dir_all(&b).unwrap();
524 for i in 0..3 {
525 fs::write(a.join(format!("f{i}.html")), "").unwrap();
526 fs::write(b.join(format!("f{i}.html")), "").unwrap();
527 }
528 let result = walk_files_bounded_count(dir.path(), "html", 2).unwrap();
529 assert!(result.len() <= 4);
530 }
531
532 #[cfg(unix)]
537 fn with_unreadable_subdir<F: FnOnce(&Path)>(run: F) {
538 use std::os::unix::fs::PermissionsExt;
539
540 let dir = tempdir().unwrap();
541 let locked = dir.path().join("locked");
542 fs::create_dir_all(&locked).unwrap();
543 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
544 .unwrap();
545
546 run(dir.path());
547
548 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
549 .unwrap();
550 }
551
552 #[cfg(unix)]
553 #[test]
554 fn walk_files_errors_on_unreadable_directory() {
555 with_unreadable_subdir(|root| {
556 let result = walk_files(root, "md");
557 assert!(result.is_err(), "unreadable dir must error");
558 });
559 }
560
561 #[cfg(unix)]
562 #[test]
563 fn walk_files_multi_errors_on_unreadable_directory() {
564 with_unreadable_subdir(|root| {
565 let result = walk_files_multi(root, &["md"]);
566 assert!(result.is_err(), "unreadable dir must error");
567 });
568 }
569
570 #[cfg(unix)]
571 #[test]
572 fn walk_files_bounded_depth_errors_on_unreadable_directory() {
573 with_unreadable_subdir(|root| {
574 let result = walk_files_bounded_depth(root, "md", 8);
575 assert!(result.is_err(), "unreadable dir must error");
576 });
577 }
578
579 #[cfg(unix)]
580 #[test]
581 fn walk_files_bounded_count_errors_on_unreadable_directory() {
582 with_unreadable_subdir(|root| {
583 let result = walk_files_bounded_count(root, "md", 10);
584 assert!(result.is_err(), "unreadable dir must error");
585 });
586 }
587}