1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
2
3mod config;
5mod errors;
6mod listing;
7mod processing;
8mod tree;
9mod utils;
10
11use log::{debug, error, info, warn};
13use std::io; use std::ops::Range;
15use std::path::{Path, PathBuf};
16
17pub use config::GrabConfig;
19pub use errors::{GrabError, GrabResult};
20
21#[derive(Debug, Clone)]
22pub struct GrabbedFile {
23 pub display_path: String,
24 pub full_range: Range<usize>,
25 pub header_range: Option<Range<usize>>,
26 pub body_range: Range<usize>,
27}
28
29#[derive(Debug, Clone)]
30pub struct GrabOutput {
31 pub content: String,
32 pub files: Vec<GrabbedFile>,
33}
34
35pub fn grab_contents(config: &GrabConfig) -> GrabResult<String> {
39 grab_contents_detailed(config).map(|output| output.content)
40}
41
42pub fn grab_contents_detailed(config: &GrabConfig) -> GrabResult<GrabOutput> {
44 info!("Starting dirgrab operation with config: {:?}", config);
45
46 let target_path = config.target_path.canonicalize().map_err(|e| {
48 if e.kind() == io::ErrorKind::NotFound {
49 GrabError::TargetPathNotFound(config.target_path.clone())
50 } else {
51 GrabError::IoError {
52 path: config.target_path.clone(),
53 source: e,
54 }
55 }
56 })?;
57 debug!("Canonical target path: {:?}", target_path);
58
59 let (files_to_process, maybe_repo_root) = if config.no_git {
61 info!("Ignoring Git context due to --no-git flag.");
62 let files = listing::list_files_walkdir(&target_path, config)?;
63 (files, None)
64 } else {
65 let git_repo_root = listing::detect_git_repo(&target_path)?;
66 let scope_subdir = git_repo_root
67 .as_ref()
68 .and_then(|root| derive_scope_subdir(root, &target_path, config));
69
70 let files = match &git_repo_root {
71 Some(root) => {
72 info!("Operating in Git mode. Repo root: {:?}", root);
73 if let Some(scope) = scope_subdir.as_deref() {
74 info!("Limiting Git file listing to sub-path: {:?}", scope);
75 } else if !config.all_repo {
76 debug!(
77 "Scope calculation yielded full repository; processing entire repo contents."
78 );
79 }
80 listing::list_files_git(root, config, scope_subdir.as_deref())?
81 }
82 None => {
83 info!("Operating in Non-Git mode. Target path: {:?}", target_path);
84 listing::list_files_walkdir(&target_path, config)?
85 }
86 };
87 (files, git_repo_root)
88 };
89
90 info!("Found {} files to process.", files_to_process.len());
91
92 let mut output_buffer = String::new();
94 let mut file_segments = Vec::new();
95
96 if config.include_tree {
98 if files_to_process.is_empty() {
99 warn!("--include-tree specified, but no files were selected for processing. Tree will be empty.");
100 output_buffer.push_str("---\nDIRECTORY STRUCTURE (No files selected)\n---\n\n");
102 return Ok(GrabOutput {
103 content: output_buffer,
104 files: Vec::new(),
105 });
106 } else {
107 let base_path_for_tree = if !config.no_git && maybe_repo_root.is_some() {
109 maybe_repo_root.as_deref().unwrap() } else {
111 &target_path
112 };
113 debug!(
114 "Generating directory tree relative to: {:?}",
115 base_path_for_tree
116 );
117
118 match tree::generate_indented_tree(&files_to_process, base_path_for_tree) {
119 Ok(tree_str) => {
120 output_buffer.push_str("---\nDIRECTORY STRUCTURE\n---\n");
121 output_buffer.push_str(&tree_str);
122 output_buffer.push_str("\n---\nFILE CONTENTS\n---\n\n");
123 }
124 Err(e) => {
125 error!("Failed to generate directory tree: {}", e);
126 output_buffer.push_str("---\nERROR GENERATING DIRECTORY STRUCTURE\n---\n\n");
128 }
129 }
130 }
131 }
132
133 if !files_to_process.is_empty() {
135 let processed = processing::process_files(
137 &files_to_process,
138 config, maybe_repo_root.as_deref(),
140 &target_path,
141 )?;
142 let base_offset = output_buffer.len();
143 output_buffer.push_str(&processed.content);
144 for segment in processed.files {
145 file_segments.push(GrabbedFile {
146 display_path: segment.display_path,
147 full_range: offset_range(&segment.full_range, base_offset),
148 header_range: segment
149 .header_range
150 .map(|range| offset_range(&range, base_offset)),
151 body_range: offset_range(&segment.body_range, base_offset),
152 });
153 }
154 } else if !config.include_tree {
155 warn!("No files selected for processing based on current configuration.");
157 return Ok(GrabOutput {
159 content: String::new(),
160 files: Vec::new(),
161 });
162 }
163
164 Ok(GrabOutput {
166 content: output_buffer,
167 files: file_segments,
168 })
169}
170
171fn derive_scope_subdir(
172 repo_root: &Path,
173 target_path: &Path,
174 config: &GrabConfig,
175) -> Option<PathBuf> {
176 if config.all_repo {
177 return None;
178 }
179
180 match target_path.strip_prefix(repo_root) {
181 Ok(rel) => {
182 if rel.as_os_str().is_empty() {
183 None
184 } else {
185 Some(rel.to_path_buf())
186 }
187 }
188 Err(_) => None,
189 }
190}
191
192fn offset_range(range: &Range<usize>, offset: usize) -> Range<usize> {
193 (range.start + offset)..(range.end + offset)
194}
195
196#[cfg(test)]
203mod tests {
204 use super::*;
207 use anyhow::{Context, Result}; use std::collections::HashSet;
210 use std::fs::{self}; use std::path::{Path, PathBuf}; use std::process::Command;
213 use tempfile::{tempdir, TempDir};
214
215 fn setup_test_dir() -> Result<(TempDir, PathBuf)> {
217 let dir = tempdir()?;
218 let path = dir.path().to_path_buf();
219
220 fs::write(path.join("file1.txt"), "Content of file 1.")?;
221 fs::write(path.join("file2.rs"), "fn main() {}")?;
222 fs::create_dir_all(path.join("subdir"))?; fs::write(path.join("subdir").join("file3.log"), "Log message.")?;
224 fs::write(
225 path.join("subdir").join("another.txt"),
226 "Another text file.",
227 )?;
228 fs::write(path.join("binary.dat"), [0x80, 0x81, 0x82])?;
229 fs::write(path.join("dirgrab.txt"), "Previous dirgrab output.")?;
230 Ok((dir, path))
231 }
232
233 fn setup_git_repo(path: &Path) -> Result<bool> {
234 if Command::new("git").arg("--version").output().is_err() {
235 eprintln!("WARN: 'git' command not found, skipping Git-related test setup.");
236 return Ok(false);
237 }
238 crate::utils::run_command("git", &["init", "-b", "main"], path)?;
240 crate::utils::run_command("git", &["config", "user.email", "test@example.com"], path)?;
241 crate::utils::run_command("git", &["config", "user.name", "Test User"], path)?;
242 crate::utils::run_command("git", &["config", "core.autocrlf", "false"], path)?;
244
245 fs::write(path.join(".gitignore"), "*.log\nbinary.dat\nfile1.txt")?;
246 crate::utils::run_command(
247 "git",
248 &["add", ".gitignore", "file2.rs", "subdir/another.txt"],
249 path,
250 )?;
251 crate::utils::run_command("git", &["commit", "-m", "Initial commit"], path)?;
252
253 fs::write(path.join("untracked.txt"), "This file is not tracked.")?;
254 fs::write(path.join("ignored.log"), "This should be ignored by git.")?;
255 fs::create_dir_all(path.join("deep/sub"))?;
256 fs::write(path.join("deep/sub/nested.txt"), "Nested content")?;
257 crate::utils::run_command("git", &["add", "deep/sub/nested.txt"], path)?;
258 crate::utils::run_command("git", &["commit", "-m", "Add nested file"], path)?;
259 Ok(true)
260 }
261
262 fn run_test_command(
263 cmd: &str,
264 args: &[&str],
265 current_dir: &Path,
266 ) -> Result<std::process::Output> {
267 let output = crate::utils::run_command(cmd, args, current_dir)?;
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 let stdout = String::from_utf8_lossy(&output.stdout);
271 anyhow::bail!(
272 "Command failed: {} {:?}\nStatus: {}\nStdout: {}\nStderr: {}",
273 cmd,
274 args,
275 output.status,
276 stdout,
277 stderr
278 );
279 }
280 Ok(output)
281 }
282
283 fn get_expected_set(base_path: &Path, relative_paths: &[&str]) -> HashSet<PathBuf> {
284 relative_paths.iter().map(|p| base_path.join(p)).collect()
285 }
286
287 fn assert_paths_eq(actual: Vec<PathBuf>, expected: HashSet<PathBuf>) {
288 let actual_set: HashSet<PathBuf> = actual.into_iter().collect();
289 assert_eq!(
290 actual_set, expected,
291 "Path sets differ.\nActual paths: {:?}\nExpected paths: {:?}",
292 actual_set, expected
293 );
294 }
295
296 #[test]
299 fn test_detect_git_repo_inside() -> Result<()> {
300 let (_dir, path) = setup_test_dir()?;
301 if !setup_git_repo(&path)? {
302 println!("Skipping Git test: git not found or setup failed.");
303 return Ok(());
304 }
305 let maybe_root = crate::listing::detect_git_repo(&path)?; assert!(maybe_root.is_some());
307 assert_eq!(maybe_root.unwrap().canonicalize()?, path.canonicalize()?);
308 let subdir_path = path.join("subdir");
309 let maybe_root_from_subdir = crate::listing::detect_git_repo(&subdir_path)?; assert!(maybe_root_from_subdir.is_some());
311 assert_eq!(
312 maybe_root_from_subdir.unwrap().canonicalize()?,
313 path.canonicalize()?
314 );
315 Ok(())
316 }
317
318 #[test]
319 fn test_detect_git_repo_outside() -> Result<()> {
320 let (_dir, path) = setup_test_dir()?;
321 let maybe_root = crate::listing::detect_git_repo(&path)?; assert!(maybe_root.is_none());
324 Ok(())
325 }
326
327 #[test]
328 fn test_list_files_walkdir_no_exclude_default_excludes_dirgrab_txt() -> Result<()> {
329 let (_dir, path) = setup_test_dir()?;
330 let config = GrabConfig {
331 target_path: path.clone(),
332 add_headers: false,
333 exclude_patterns: vec![],
334 include_untracked: false, include_default_output: false, no_git: true, include_tree: false,
338 convert_pdf: false,
339 all_repo: false,
340 };
341 let files = crate::listing::list_files_walkdir(&path, &config)?; let expected_set = get_expected_set(
343 &path,
344 &[
345 "file1.txt",
346 "file2.rs",
347 "subdir/file3.log",
348 "subdir/another.txt",
349 "binary.dat",
350 ],
352 );
353 assert_paths_eq(files, expected_set);
354 Ok(())
355 }
356
357 #[test]
358 fn test_list_files_walkdir_with_exclude() -> Result<()> {
359 let (_dir, path) = setup_test_dir()?;
360 let config = GrabConfig {
361 target_path: path.clone(),
362 add_headers: false,
363 exclude_patterns: vec!["*.log".to_string(), "subdir/".to_string()], include_untracked: false,
365 include_default_output: false,
366 no_git: true, include_tree: false,
368 convert_pdf: false,
369 all_repo: false,
370 };
371 let files = crate::listing::list_files_walkdir(&path, &config)?; let expected_set = get_expected_set(
373 &path,
374 &[
375 "file1.txt",
376 "file2.rs",
377 "binary.dat",
378 ],
381 );
382 assert_paths_eq(files, expected_set);
383 Ok(())
384 }
385
386 #[test]
387 fn test_list_files_git_tracked_only_default_excludes_dirgrab_txt() -> Result<()> {
388 let (_dir, path) = setup_test_dir()?;
389 if !setup_git_repo(&path)? {
390 println!("Skipping Git test: git not found or setup failed.");
391 return Ok(());
392 }
393 let config = GrabConfig {
394 target_path: path.clone(), add_headers: false,
396 exclude_patterns: vec![],
397 include_untracked: false, include_default_output: false, no_git: false, include_tree: false,
401 convert_pdf: false,
402 all_repo: false,
403 };
404 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
406 &path,
407 &[
408 ".gitignore",
409 "file2.rs",
410 "subdir/another.txt",
411 "deep/sub/nested.txt",
412 ],
419 );
420 assert_paths_eq(files, expected_set);
421 Ok(())
422 }
423
424 #[test]
425 fn test_list_files_git_include_untracked_default_excludes_dirgrab_txt() -> Result<()> {
426 let (_dir, path) = setup_test_dir()?;
427 if !setup_git_repo(&path)? {
428 println!("Skipping Git test: git not found or setup failed.");
429 return Ok(());
430 }
431 let config = GrabConfig {
432 target_path: path.clone(),
433 add_headers: false,
434 exclude_patterns: vec![],
435 include_untracked: true, include_default_output: false, no_git: false, include_tree: false,
439 convert_pdf: false,
440 all_repo: false,
441 };
442 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
444 &path,
445 &[
446 ".gitignore",
447 "file2.rs",
448 "subdir/another.txt",
449 "deep/sub/nested.txt",
450 "untracked.txt", ],
457 );
458 assert_paths_eq(files, expected_set);
459 Ok(())
460 }
461
462 #[test]
463 fn test_list_files_git_with_exclude() -> Result<()> {
464 let (_dir, path) = setup_test_dir()?;
465 if !setup_git_repo(&path)? {
466 println!("Skipping Git test: git not found or setup failed.");
467 return Ok(());
468 }
469 let config = GrabConfig {
470 target_path: path.clone(),
471 add_headers: false,
472 exclude_patterns: vec![
473 "*.rs".to_string(), "subdir/".to_string(), "deep/".to_string(), ],
477 include_untracked: false, include_default_output: false,
479 no_git: false, include_tree: false,
481 convert_pdf: false,
482 all_repo: false,
483 };
484 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(&path, &[".gitignore"]); assert_paths_eq(files, expected_set);
487 Ok(())
488 }
489
490 #[test]
491 fn test_list_files_git_untracked_with_exclude() -> Result<()> {
492 let (_dir, path) = setup_test_dir()?;
493 if !setup_git_repo(&path)? {
494 println!("Skipping Git test: git not found or setup failed.");
495 return Ok(());
496 }
497 let config = GrabConfig {
498 target_path: path.clone(),
499 add_headers: false,
500 exclude_patterns: vec!["*.txt".to_string()], include_untracked: true, include_default_output: false,
503 no_git: false, include_tree: false,
505 convert_pdf: false,
506 all_repo: false,
507 };
508 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
510 &path,
511 &[
512 ".gitignore",
513 "file2.rs",
514 ],
519 );
520 assert_paths_eq(files, expected_set);
521 Ok(())
522 }
523
524 #[test]
525 fn test_list_files_walkdir_include_default_output() -> Result<()> {
526 let (_dir, path) = setup_test_dir()?;
527 let config = GrabConfig {
528 target_path: path.clone(),
529 add_headers: false,
530 exclude_patterns: vec![],
531 include_untracked: false,
532 include_default_output: true, no_git: true, include_tree: false,
535 convert_pdf: false,
536 all_repo: false,
537 };
538 let files = crate::listing::list_files_walkdir(&path, &config)?; let expected_set = get_expected_set(
540 &path,
541 &[
542 "file1.txt",
543 "file2.rs",
544 "subdir/file3.log",
545 "subdir/another.txt",
546 "binary.dat",
547 "dirgrab.txt", ],
549 );
550 assert_paths_eq(files, expected_set);
551 Ok(())
552 }
553
554 #[test]
555 fn test_list_files_git_include_default_output_tracked_only() -> Result<()> {
556 let (_dir, path) = setup_test_dir()?;
557 if !setup_git_repo(&path)? {
558 println!("Skipping Git test: git not found or setup failed.");
559 return Ok(());
560 }
561 fs::write(path.join("dirgrab.txt"), "Tracked dirgrab output.")?;
563 run_test_command("git", &["add", "dirgrab.txt"], &path)?;
564 run_test_command("git", &["commit", "-m", "Add dirgrab.txt"], &path)?;
565
566 let config = GrabConfig {
567 target_path: path.clone(),
568 add_headers: false,
569 exclude_patterns: vec![],
570 include_untracked: false, include_default_output: true, no_git: false, include_tree: false,
574 convert_pdf: false,
575 all_repo: false,
576 };
577 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
579 &path,
580 &[
581 ".gitignore",
582 "file2.rs",
583 "subdir/another.txt",
584 "deep/sub/nested.txt",
585 "dirgrab.txt", ],
587 );
588 assert_paths_eq(files, expected_set);
589 Ok(())
590 }
591
592 #[test]
593 fn test_list_files_git_include_default_output_with_untracked() -> Result<()> {
594 let (_dir, path) = setup_test_dir()?;
595 if !setup_git_repo(&path)? {
596 println!("Skipping Git test: git not found or setup failed.");
597 return Ok(());
598 }
599 let config = GrabConfig {
601 target_path: path.clone(),
602 add_headers: false,
603 exclude_patterns: vec![],
604 include_untracked: true, include_default_output: true, no_git: false, include_tree: false,
608 convert_pdf: false,
609 all_repo: false,
610 };
611 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
613 &path,
614 &[
615 ".gitignore",
616 "file2.rs",
617 "subdir/another.txt",
618 "deep/sub/nested.txt",
619 "untracked.txt", "dirgrab.txt", ],
622 );
623 assert_paths_eq(files, expected_set);
624 Ok(())
625 }
626
627 #[test]
628 fn test_list_files_git_include_default_output_but_excluded_by_user() -> Result<()> {
629 let (_dir, path) = setup_test_dir()?;
630 if !setup_git_repo(&path)? {
631 println!("Skipping Git test: git not found or setup failed.");
632 return Ok(());
633 }
634 let config = GrabConfig {
635 target_path: path.clone(),
636 add_headers: false,
637 exclude_patterns: vec!["dirgrab.txt".to_string()], include_untracked: true,
639 include_default_output: true, no_git: false, include_tree: false,
642 convert_pdf: false,
643 all_repo: false,
644 };
645 let files = crate::listing::list_files_git(&path, &config, None)?; let expected_set = get_expected_set(
647 &path,
648 &[
649 ".gitignore",
650 "file2.rs",
651 "subdir/another.txt",
652 "deep/sub/nested.txt",
653 "untracked.txt",
654 ],
656 );
657 assert_paths_eq(files, expected_set);
658 Ok(())
659 }
660
661 #[test]
662 fn test_list_files_git_scoped_to_subdir() -> Result<()> {
663 let (_dir, path) = setup_test_dir()?;
664 if !setup_git_repo(&path)? {
665 println!("Skipping Git test: git not found or setup failed.");
666 return Ok(());
667 }
668
669 fs::write(path.join("deep/untracked_inside.txt"), "scoped content")?;
670
671 let config = GrabConfig {
672 target_path: path.join("deep"),
673 add_headers: false,
674 exclude_patterns: vec![],
675 include_untracked: true,
676 include_default_output: false,
677 no_git: false,
678 include_tree: false,
679 convert_pdf: false,
680 all_repo: false,
681 };
682 let scope = Path::new("deep");
683 let files = crate::listing::list_files_git(&path, &config, Some(scope))?;
684 let expected_set =
685 get_expected_set(&path, &["deep/sub/nested.txt", "deep/untracked_inside.txt"]);
686 assert_paths_eq(files, expected_set);
687 Ok(())
688 }
689
690 #[test]
691 fn test_no_git_flag_forces_walkdir_in_git_repo() -> Result<()> {
692 let (_dir, path) = setup_test_dir()?;
693 if !setup_git_repo(&path)? {
694 println!("Skipping Git test: git not found or setup failed.");
695 return Ok(());
696 }
697 let config = GrabConfig {
698 target_path: path.clone(),
699 add_headers: false, exclude_patterns: vec![],
701 include_untracked: false, include_default_output: false, no_git: true, include_tree: false, convert_pdf: false,
706 all_repo: false,
707 };
708 let result_string = grab_contents(&config)?;
709
710 assert!(
712 result_string.contains("Content of file 1."),
713 "file1.txt content missing"
714 ); assert!(
716 result_string.contains("Log message."),
717 "file3.log content missing"
718 ); assert!(
720 result_string.contains("fn main() {}"),
721 "file2.rs content missing"
722 ); assert!(
724 result_string.contains("Another text file."),
725 "another.txt content missing"
726 ); assert!(
728 !result_string.contains("Previous dirgrab output."),
729 "dirgrab.txt included unexpectedly"
730 ); Ok(())
738 }
739
740 #[test]
741 fn test_no_git_flag_still_respects_exclude_patterns() -> Result<()> {
742 let (_dir, path) = setup_test_dir()?;
743 if !setup_git_repo(&path)? {
744 println!("Skipping Git test: git not found or setup failed.");
745 return Ok(());
746 }
747 let config = GrabConfig {
748 target_path: path.clone(),
749 add_headers: false,
750 exclude_patterns: vec!["*.txt".to_string(), "*.rs".to_string()], include_untracked: false,
752 include_default_output: false,
753 no_git: true, include_tree: false,
755 convert_pdf: false,
756 all_repo: false,
757 };
758 let result_string = grab_contents(&config)?;
759
760 assert!(result_string.contains("Log message."), "file3.log missing"); assert!(
762 !result_string.contains("Content of file 1."),
763 "file1.txt included unexpectedly"
764 ); assert!(
766 !result_string.contains("fn main() {}"),
767 "file2.rs included unexpectedly"
768 ); assert!(
770 !result_string.contains("Another text file."),
771 "another.txt included unexpectedly"
772 ); assert!(
774 !result_string.contains("Nested content"),
775 "nested.txt included unexpectedly"
776 ); assert!(
778 !result_string.contains("Previous dirgrab output."),
779 "dirgrab.txt included unexpectedly"
780 ); Ok(())
783 }
784
785 #[test]
786 fn test_no_git_flag_with_include_default_output() -> Result<()> {
787 let (_dir, path) = setup_test_dir()?;
788 if !setup_git_repo(&path)? {
789 println!("Skipping Git test: git not found or setup failed.");
790 return Ok(());
791 }
792 let config = GrabConfig {
793 target_path: path.clone(),
794 add_headers: false,
795 exclude_patterns: vec![],
796 include_untracked: false,
797 include_default_output: true, no_git: true, include_tree: false,
800 convert_pdf: false,
801 all_repo: false,
802 };
803 let result_string = grab_contents(&config)?;
804 assert!(
805 result_string.contains("Previous dirgrab output."),
806 "Should include dirgrab.txt due to override"
807 );
808 Ok(())
809 }
810
811 #[test]
812 fn test_no_git_flag_headers_relative_to_target() -> Result<()> {
813 let (_dir, path) = setup_test_dir()?;
814 if !setup_git_repo(&path)? {
815 println!("Skipping Git test: git not found or setup failed.");
816 return Ok(());
817 }
818 let config = GrabConfig {
819 target_path: path.clone(), add_headers: true, exclude_patterns: vec![
822 "*.log".to_string(),
823 "*.dat".to_string(),
824 "dirgrab.txt".to_string(),
825 ], include_untracked: false,
827 include_default_output: false,
828 no_git: true, include_tree: false, convert_pdf: false,
831 all_repo: false,
832 };
833 let result_string = grab_contents(&config)?;
834
835 let expected_header_f1 = format!("--- FILE: {} ---", Path::new("file1.txt").display());
837 assert!(
838 result_string.contains(&expected_header_f1),
839 "Header path should be relative to target_path. Expected '{}' in output:\n{}",
840 expected_header_f1,
841 result_string
842 );
843
844 let expected_header_f2 = format!("--- FILE: {} ---", Path::new("file2.rs").display());
846 assert!(
847 result_string.contains(&expected_header_f2),
848 "Header path should be relative to target_path. Expected '{}' in output:\n{}",
849 expected_header_f2,
850 result_string
851 );
852
853 let expected_nested_header = format!(
854 "--- FILE: {} ---",
855 Path::new("deep/sub/nested.txt").display()
856 );
857 assert!(
858 result_string.contains(&expected_nested_header),
859 "Nested header path relative to target_path. Expected '{}' in output:\n{}",
860 expected_nested_header,
861 result_string
862 );
863 Ok(())
864 }
865
866 #[test]
867 fn test_git_mode_headers_relative_to_repo_root() -> Result<()> {
868 let (_dir, path) = setup_test_dir()?;
869 if !setup_git_repo(&path)? {
870 println!("Skipping Git test: git not found or setup failed.");
871 return Ok(());
872 }
873 let subdir_target = path.join("deep"); fs::create_dir_all(&subdir_target)?; let config = GrabConfig {
877 target_path: subdir_target.clone(), add_headers: true, exclude_patterns: vec![],
880 include_untracked: false, include_default_output: false,
882 no_git: false, include_tree: false, convert_pdf: false,
885 all_repo: false,
886 };
887 let result_string = grab_contents(&config)?; let expected_nested_header = format!(
891 "--- FILE: {} ---",
892 Path::new("deep/sub/nested.txt").display()
893 );
894 assert!(
895 result_string.contains(&expected_nested_header),
896 "Header path should be relative to repo root. Expected '{}' in output:\n{}",
897 expected_nested_header,
898 result_string
899 );
900
901 let unexpected_root_header = format!("--- FILE: {} ---", Path::new(".gitignore").display());
903 assert!(
904 !result_string.contains(&unexpected_root_header),
905 "Scoped results should not include repo-root files. Unexpected '{}' in output:\n{}",
906 unexpected_root_header,
907 result_string
908 );
909 let unexpected_rs_header = format!("--- FILE: {} ---", Path::new("file2.rs").display());
910 assert!(
911 !result_string.contains(&unexpected_rs_header),
912 "Scoped results should not include repo-root files. Unexpected '{}' in output:\n{}",
913 unexpected_rs_header,
914 result_string
915 );
916 Ok(())
917 }
918
919 #[test]
920 fn test_grab_contents_with_tree_no_git() -> Result<()> {
921 let (_dir, path) = setup_test_dir()?;
922 fs::write(path.join(".gitignore"), "*.log\nbinary.dat")?; fs::create_dir_all(path.join("deep/sub"))?;
925 fs::write(path.join("deep/sub/nested.txt"), "Nested content")?;
926 fs::write(path.join("untracked.txt"), "Untracked content")?; let config = GrabConfig {
929 target_path: path.clone(),
930 add_headers: true,
931 exclude_patterns: vec![
932 "*.log".to_string(), "*.dat".to_string(), ".gitignore".to_string(), "dirgrab.txt".to_string(), ],
937 include_untracked: false, include_default_output: false, no_git: true, include_tree: true, convert_pdf: false,
942 all_repo: false,
943 };
944 let result = grab_contents(&config)?;
945
946 let expected_tree_part = "\
949---
950DIRECTORY STRUCTURE
951---
952- deep/
953 - sub/
954 - nested.txt
955- file1.txt
956- file2.rs
957- subdir/
958 - another.txt
959- untracked.txt
960";
961
962 assert!(
963 result.contains(expected_tree_part),
964 "Expected tree structure not found in output:\nTree Section:\n---\n{}\n---",
965 result
966 .split("---\nFILE CONTENTS\n---")
967 .next()
968 .unwrap_or("TREE NOT FOUND")
969 );
970
971 assert!(
972 result.contains("\n---\nFILE CONTENTS\n---\n\n"),
973 "Expected file content separator not found"
974 );
975 assert!(
977 result.contains("--- FILE: file1.txt ---"),
978 "Header for file1.txt missing"
979 );
980 assert!(
981 result.contains("Content of file 1."),
982 "Content of file1.txt missing"
983 );
984 assert!(
985 result.contains("--- FILE: deep/sub/nested.txt ---"),
986 "Header for nested.txt missing"
987 );
988 assert!(
989 result.contains("Nested content"),
990 "Content of nested.txt missing"
991 );
992 assert!(
994 !result.contains("Previous dirgrab output."),
995 "dirgrab.txt content included unexpectedly"
996 );
997 assert!(
998 !result.contains("Log message"),
999 "Log content included unexpectedly"
1000 );
1001
1002 Ok(())
1003 }
1004
1005 #[test]
1006 fn test_grab_contents_with_tree_git_mode() -> Result<()> {
1007 let (_dir, path) = setup_test_dir()?;
1008 if !setup_git_repo(&path)? {
1009 println!("Skipping Git test: git not found or setup failed.");
1010 return Ok(());
1011 }
1012 let config = GrabConfig {
1013 target_path: path.clone(),
1014 add_headers: true,
1015 exclude_patterns: vec![".gitignore".to_string()], include_untracked: true, include_default_output: false, no_git: false, include_tree: true, convert_pdf: false,
1021 all_repo: false,
1022 };
1023 let result = grab_contents(&config)?;
1024
1025 let expected_tree_part = "\
1028---
1029DIRECTORY STRUCTURE
1030---
1031- deep/
1032 - sub/
1033 - nested.txt
1034- file2.rs
1035- subdir/
1036 - another.txt
1037- untracked.txt
1038";
1039 assert!(
1040 result.contains(expected_tree_part),
1041 "Expected tree structure not found in output:\nTree Section:\n---\n{}\n---",
1042 result
1043 .split("---\nFILE CONTENTS\n---")
1044 .next()
1045 .unwrap_or("TREE NOT FOUND")
1046 );
1047 assert!(
1048 result.contains("\n---\nFILE CONTENTS\n---\n\n"),
1049 "Separator missing"
1050 );
1051 assert!(
1053 result.contains("--- FILE: file2.rs ---"),
1054 "file2.rs header missing"
1055 );
1056 assert!(result.contains("fn main() {}"), "file2.rs content missing");
1057 assert!(
1058 result.contains("--- FILE: untracked.txt ---"),
1059 "untracked.txt header missing"
1060 );
1061 assert!(
1062 result.contains("This file is not tracked."),
1063 "untracked.txt content missing"
1064 );
1065 assert!(
1066 !result.contains("--- FILE: .gitignore ---"),
1067 ".gitignore included unexpectedly"
1068 );
1069
1070 Ok(())
1071 }
1072
1073 #[test]
1074 fn test_grab_contents_with_tree_empty() -> Result<()> {
1075 let (_dir, path) = setup_test_dir()?;
1076 let config = GrabConfig {
1078 target_path: path.clone(),
1079 add_headers: true,
1080 exclude_patterns: vec!["*".to_string(), "*/".to_string()], include_untracked: true,
1082 include_default_output: true,
1083 no_git: true, include_tree: true, convert_pdf: false,
1086 all_repo: false,
1087 };
1088 let result = grab_contents(&config)?;
1089 let expected = "---\nDIRECTORY STRUCTURE (No files selected)\n---\n\n";
1091 assert_eq!(result, expected);
1092 Ok(())
1093 }
1094
1095 #[test]
1097 fn test_generate_indented_tree_simple() -> Result<()> {
1098 let tmp_dir = tempdir()?;
1099 let proj_dir = tmp_dir.path().join("project");
1100 fs::create_dir_all(proj_dir.join("src"))?;
1101 fs::create_dir_all(proj_dir.join("tests"))?;
1102 fs::write(proj_dir.join("src/main.rs"), "")?;
1103 fs::write(proj_dir.join("README.md"), "")?;
1104 fs::write(proj_dir.join("src/lib.rs"), "")?;
1105 fs::write(proj_dir.join("tests/basic.rs"), "")?;
1106
1107 let base = PathBuf::from("/project"); let files_logical = [
1110 base.join("src/main.rs"),
1112 base.join("README.md"),
1113 base.join("src/lib.rs"),
1114 base.join("tests/basic.rs"),
1115 ];
1116
1117 let files_in_tmp = files_logical
1119 .iter()
1120 .map(|p| tmp_dir.path().join(p.strip_prefix("/").unwrap()))
1121 .collect::<Vec<_>>();
1122 let base_in_tmp = tmp_dir.path().join("project"); let tree = crate::tree::generate_indented_tree(&files_in_tmp, &base_in_tmp)?; let expected = "\
1126- README.md
1127- src/
1128 - lib.rs
1129 - main.rs
1130- tests/
1131 - basic.rs
1132";
1133 assert_eq!(tree, expected);
1134 Ok(())
1135 }
1136
1137 #[test]
1138 fn test_generate_indented_tree_deeper() -> Result<()> {
1139 let tmp_dir = tempdir()?;
1140 let proj_dir = tmp_dir.path().join("project");
1141 fs::create_dir_all(proj_dir.join("a/b/c"))?;
1142 fs::create_dir_all(proj_dir.join("a/d"))?;
1143 fs::write(proj_dir.join("a/b/c/file1.txt"), "")?;
1144 fs::write(proj_dir.join("a/d/file2.txt"), "")?;
1145 fs::write(proj_dir.join("top.txt"), "")?;
1146 fs::write(proj_dir.join("a/b/file3.txt"), "")?;
1147
1148 let base = PathBuf::from("/project"); let files_logical = [
1150 base.join("a/b/c/file1.txt"),
1151 base.join("a/d/file2.txt"),
1152 base.join("top.txt"),
1153 base.join("a/b/file3.txt"),
1154 ];
1155
1156 let files_in_tmp = files_logical
1157 .iter()
1158 .map(|p| tmp_dir.path().join(p.strip_prefix("/").unwrap()))
1159 .collect::<Vec<_>>();
1160 let base_in_tmp = tmp_dir.path().join("project"); let tree = crate::tree::generate_indented_tree(&files_in_tmp, &base_in_tmp)?; let expected = "\
1164- a/
1165 - b/
1166 - c/
1167 - file1.txt
1168 - file3.txt
1169 - d/
1170 - file2.txt
1171- top.txt
1172";
1173 assert_eq!(tree, expected);
1174 Ok(())
1175 }
1176
1177 #[test]
1179 fn test_process_files_no_headers_skip_binary() -> Result<()> {
1180 let (_dir, path) = setup_test_dir()?;
1181 let files_to_process = vec![
1182 path.join("file1.txt"),
1183 path.join("binary.dat"), path.join("file2.rs"),
1185 ];
1186 let config = GrabConfig {
1187 target_path: path.clone(),
1189 add_headers: false, exclude_patterns: vec![],
1191 include_untracked: false,
1192 include_default_output: false,
1193 no_git: true, include_tree: false,
1195 convert_pdf: false, all_repo: false,
1197 };
1198 let result = crate::processing::process_files(&files_to_process, &config, None, &path)?;
1199 let expected_content = "Content of file 1.\n\nfn main() {}\n\n";
1200 assert_eq!(result.content, expected_content);
1201 assert_eq!(result.files.len(), 2);
1202 assert_eq!(result.files[0].display_path, "file1.txt");
1203 assert!(result.files[0].header_range.is_none());
1204 assert_eq!(
1205 &result.content[result.files[0].body_range.clone()],
1206 "Content of file 1.\n\n"
1207 );
1208 assert_eq!(
1209 &result.content[result.files[1].body_range.clone()],
1210 "fn main() {}\n\n"
1211 );
1212 Ok(())
1213 }
1214
1215 #[test]
1216 fn test_process_files_with_headers_git_mode() -> Result<()> {
1217 let (_dir, path) = setup_test_dir()?;
1218 let files_to_process = vec![path.join("file1.txt"), path.join("file2.rs")];
1220 let repo_root = Some(path.as_path());
1221 let config = GrabConfig {
1222 target_path: path.clone(), add_headers: true, exclude_patterns: vec![],
1225 include_untracked: false,
1226 include_default_output: false,
1227 no_git: false, include_tree: false,
1229 convert_pdf: false,
1230 all_repo: false,
1231 };
1232 let result =
1233 crate::processing::process_files(&files_to_process, &config, repo_root, &path)?;
1234 let expected_content = format!(
1235 "--- FILE: {} ---\nContent of file 1.\n\n--- FILE: {} ---\nfn main() {{}}\n\n",
1236 Path::new("file1.txt").display(), Path::new("file2.rs").display()
1238 );
1239 assert_eq!(result.content, expected_content);
1240 assert_eq!(result.files.len(), 2);
1241 assert!(result.files.iter().all(|seg| seg.header_range.is_some()));
1242 let first = &result.files[0];
1243 assert_eq!(first.display_path, "file1.txt");
1244 assert_eq!(
1245 &result.content[first.header_range.clone().unwrap()],
1246 "--- FILE: file1.txt ---\n"
1247 );
1248 assert_eq!(
1249 &result.content[first.body_range.clone()],
1250 "Content of file 1.\n\n"
1251 );
1252 Ok(())
1253 }
1254
1255 #[test]
1256 fn test_process_files_headers_no_git_mode() -> Result<()> {
1257 let (_dir, path) = setup_test_dir()?;
1258 let files_to_process = vec![path.join("file1.txt"), path.join("subdir/another.txt")];
1259 let config = GrabConfig {
1260 target_path: path.clone(), add_headers: true, exclude_patterns: vec![],
1263 include_untracked: false,
1264 include_default_output: false,
1265 no_git: true, include_tree: false,
1267 convert_pdf: false,
1268 all_repo: false,
1269 };
1270 let result = crate::processing::process_files(&files_to_process, &config, None, &path)?;
1271 let expected_content = format!(
1272 "--- FILE: {} ---\nContent of file 1.\n\n--- FILE: {} ---\nAnother text file.\n\n",
1273 Path::new("file1.txt").display(), Path::new("subdir/another.txt").display()
1275 );
1276 assert_eq!(result.content, expected_content);
1277 assert_eq!(result.files.len(), 2);
1278 Ok(())
1279 }
1280
1281 #[test]
1282 fn test_grab_contents_with_pdf_conversion_enabled() -> Result<()> {
1283 let (_dir, path) = setup_test_dir()?;
1284 let base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1285 let fixtures_dir = base_dir.join("tests/fixtures");
1286 fs::create_dir_all(&fixtures_dir)?;
1287 let fixture_pdf_src = fixtures_dir.join("sample.pdf");
1288
1289 if !fixture_pdf_src.exists() {
1290 anyhow::bail!("Fixture PDF not found at {:?}", fixture_pdf_src);
1291 }
1292
1293 let fixture_pdf_dest = path.join("sample.pdf");
1294 fs::copy(&fixture_pdf_src, &fixture_pdf_dest).with_context(|| {
1295 format!(
1296 "Failed to copy fixture PDF from {:?} to {:?}",
1297 fixture_pdf_src, fixture_pdf_dest
1298 )
1299 })?;
1300
1301 fs::write(path.join("normal.txt"), "Normal text content.")?;
1302
1303 let config = GrabConfig {
1304 target_path: path.clone(),
1305 add_headers: true,
1306 exclude_patterns: vec![
1307 "dirgrab.txt".into(),
1308 "*.log".into(),
1309 "*.dat".into(),
1310 "*.rs".into(),
1311 "subdir/".into(),
1312 ".gitignore".into(),
1313 "deep/".into(),
1314 "untracked.txt".into(),
1315 ],
1316 include_untracked: false,
1317 include_default_output: false,
1318 no_git: true,
1319 include_tree: false,
1320 convert_pdf: true,
1321 all_repo: false,
1322 };
1323
1324 let result_string = grab_contents(&config)?;
1325
1326 let expected_pdf_header = "--- FILE: sample.pdf (extracted text) ---";
1328 assert!(
1329 result_string.contains(expected_pdf_header),
1330 "Missing or incorrect PDF header. Output:\n{}",
1331 result_string
1332 );
1333
1334 let expected_pdf_content = "Pinaceae family"; println!("Searching for: '{}'", expected_pdf_content);
1340 println!("Within: '{}'", result_string);
1341
1342 assert!(
1343 result_string.contains(expected_pdf_content),
1344 "Missing extracted PDF content ('{}'). Output:\n{}",
1345 expected_pdf_content,
1346 result_string
1347 );
1348
1349 let expected_txt_header = "--- FILE: normal.txt ---";
1351 let expected_txt_content = "Normal text content.";
1352 assert!(
1353 result_string.contains(expected_txt_header),
1354 "Missing or incorrect TXT header. Output:\n{}",
1355 result_string
1356 );
1357 assert!(
1358 result_string.contains(expected_txt_content),
1359 "Missing TXT content. Output:\n{}",
1360 result_string
1361 );
1362
1363 let expected_file1_header = "--- FILE: file1.txt ---";
1365 assert!(
1366 result_string.contains(expected_file1_header),
1367 "Missing file1.txt header. Output:\n{}",
1368 result_string
1369 );
1370
1371 Ok(())
1372 }
1373
1374 #[test]
1375 fn test_grab_contents_with_pdf_conversion_disabled() -> Result<()> {
1376 let (_dir, path) = setup_test_dir()?; let base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1378 let fixtures_dir = base_dir.join("tests/fixtures");
1379 fs::create_dir_all(&fixtures_dir)?; let fixture_pdf_src = fixtures_dir.join("sample.pdf");
1381
1382 if !fixture_pdf_src.exists() {
1384 let basic_pdf_content = "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<<>>>>endobj\n4 0 obj<</Length 52>>stream\nBT /F1 12 Tf 72 712 Td (This is sample PDF text content.) Tj ET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000063 00000 n \n0000000117 00000 n \n0000000198 00000 n \ntrailer<</Size 5/Root 1 0 R>>\nstartxref\n315\n%%EOF";
1385 fs::write(&fixture_pdf_src, basic_pdf_content)?;
1386 println!(
1387 "Created dummy sample.pdf for testing at {:?}",
1388 fixture_pdf_src
1389 );
1390 }
1391
1392 let fixture_pdf_dest = path.join("sample.pdf");
1393 fs::copy(&fixture_pdf_src, &fixture_pdf_dest).with_context(|| {
1394 format!(
1395 "Failed to copy fixture PDF from {:?} to {:?}",
1396 fixture_pdf_src, fixture_pdf_dest
1397 )
1398 })?;
1399 fs::write(path.join("normal.txt"), "Normal text content.")?;
1400
1401 let config = GrabConfig {
1402 target_path: path.clone(),
1403 add_headers: true,
1404 exclude_patterns: vec![
1406 "dirgrab.txt".into(),
1407 "*.log".into(),
1408 "*.dat".into(),
1409 "*.rs".into(),
1410 "subdir/".into(),
1411 ".gitignore".into(),
1412 "deep/".into(),
1413 "untracked.txt".into(),
1414 ],
1415 include_untracked: false,
1416 include_default_output: false,
1417 no_git: true,
1418 include_tree: false,
1419 convert_pdf: false, all_repo: false,
1421 };
1422
1423 let result_string = grab_contents(&config)?;
1424
1425 let unexpected_pdf_header_part = "(extracted text)"; let unexpected_pdf_content = "This is sample PDF text content.";
1428 assert!(
1429 !result_string.contains(unexpected_pdf_header_part),
1430 "PDF extracted text header part present unexpectedly. Output:\n{}",
1431 result_string
1432 );
1433 assert!(
1434 !result_string.contains(unexpected_pdf_content),
1435 "Extracted PDF content present unexpectedly. Output:\n{}",
1436 result_string
1437 );
1438
1439 let expected_txt_header = "--- FILE: normal.txt ---";
1441 let expected_txt_content = "Normal text content.";
1442 assert!(
1443 result_string.contains(expected_txt_header),
1444 "Missing or incorrect TXT header. Output:\n{}",
1445 result_string
1446 );
1447 assert!(
1448 result_string.contains(expected_txt_content),
1449 "Missing TXT content. Output:\n{}",
1450 result_string
1451 );
1452
1453 let expected_file1_header = "--- FILE: file1.txt ---";
1455 assert!(
1456 result_string.contains(expected_file1_header),
1457 "Missing file1.txt header. Output:\n{}",
1458 result_string
1459 );
1460
1461 let regular_pdf_header = "--- FILE: sample.pdf ---";
1464 assert!(
1465 !result_string.contains(regular_pdf_header),
1466 "Regular PDF header present when it should have been skipped as non-utf8. Output:\n{}",
1467 result_string
1468 );
1469
1470 Ok(())
1471 }
1472}