dirgrab_lib/
lib.rs

1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
2
3// Declare modules
4mod config;
5mod errors;
6mod listing;
7mod processing;
8mod tree;
9mod utils;
10
11// Necessary imports for lib.rs itself
12use log::{debug, error, info, warn};
13use std::io; // For io::ErrorKind // For logging within grab_contents
14use std::ops::Range;
15use std::path::{Path, PathBuf};
16
17// Re-export public API components
18pub 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
35// --- Main Public Function ---
36
37/// Performs the main `dirgrab` operation based on the provided configuration.
38pub fn grab_contents(config: &GrabConfig) -> GrabResult<String> {
39    grab_contents_detailed(config).map(|output| output.content)
40}
41
42/// Performs the main `dirgrab` operation and returns file-level metadata along with the content.
43pub fn grab_contents_detailed(config: &GrabConfig) -> GrabResult<GrabOutput> {
44    info!("Starting dirgrab operation with config: {:?}", config);
45
46    // Canonicalize cleans the path and checks existence implicitly via OS call
47    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    // Determine file listing mode and potential repo root based on no_git flag
60    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    // Initialize output buffer
93    let mut output_buffer = String::new();
94    let mut file_segments = Vec::new();
95
96    // Generate and prepend tree if requested
97    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            // Keep explicit tree header even if empty
101            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            // Determine base path for tree (repo root if git mode, target path otherwise)
108            let base_path_for_tree = if !config.no_git && maybe_repo_root.is_some() {
109                maybe_repo_root.as_deref().unwrap() // Safe unwrap due to is_some() check
110            } 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                    // Still add header indicating failure
127                    output_buffer.push_str("---\nERROR GENERATING DIRECTORY STRUCTURE\n---\n\n");
128                }
129            }
130        }
131    }
132
133    // Process files and append content (only if files exist)
134    if !files_to_process.is_empty() {
135        // Updated call to process_files to pass the whole config struct
136        let processed = processing::process_files(
137            &files_to_process,
138            config, // Pass config struct
139            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        // If no files AND no tree was requested
156        warn!("No files selected for processing based on current configuration.");
157        // Return empty string only if no files were found AND tree wasn't requested/generated.
158        return Ok(GrabOutput {
159            content: String::new(),
160            files: Vec::new(),
161        });
162    }
163
164    // Return the combined buffer (might contain only tree, or tree + content, or just content)
165    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// --- FILE: dirgrab-lib/src/lib.rs ---
197// (Showing only the tests module and its necessary imports)
198
199// ... (rest of lib.rs code above) ...
200
201// --- Tests ---
202#[cfg(test)]
203mod tests {
204    // Use super::* to bring everything from lib.rs into scope for tests
205    // This now includes GrabConfig, GrabError, GrabResult because they are re-exported.
206    use super::*;
207    // Also need direct imports for helpers/types used *only* in tests
208    use anyhow::{Context, Result}; // Ensure Context and Result are imported from anyhow
209    use std::collections::HashSet;
210    use std::fs::{self}; // Ensure File is imported if needed by helpers
211    use std::path::{Path, PathBuf}; // Need these for helpers defined within tests mod
212    use std::process::Command;
213    use tempfile::{tempdir, TempDir};
214
215    // --- Test Setup Helpers ---
216    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"))?; // Use create_dir_all
223        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        // Use crate:: path now because utils is not in super::* scope
239        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        // Configure Git to handle potential CRLF issues on Windows in tests if needed
243        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    // --- Tests ---
297    // Tests calling listing functions need crate:: prefix
298    #[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)?; // Use crate:: path
306        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)?; // Use crate:: path
310        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        // Ensure no git repo exists here
322        let maybe_root = crate::listing::detect_git_repo(&path)?; // Use crate:: path
323        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,      // No effect in walkdir
335            include_default_output: false, // Exclude dirgrab.txt
336            no_git: true,                  // Force walkdir
337            include_tree: false,
338            convert_pdf: false,
339            all_repo: false,
340        };
341        let files = crate::listing::list_files_walkdir(&path, &config)?; // Use crate:: path
342        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                // "dirgrab.txt" should be excluded by default
351            ],
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()], // User excludes
364            include_untracked: false,
365            include_default_output: false,
366            no_git: true, // Force walkdir
367            include_tree: false,
368            convert_pdf: false,
369            all_repo: false,
370        };
371        let files = crate::listing::list_files_walkdir(&path, &config)?; // Use crate:: path
372        let expected_set = get_expected_set(
373            &path,
374            &[
375                "file1.txt",
376                "file2.rs",
377                "binary.dat",
378                // subdir/* excluded
379                // dirgrab.txt excluded by default
380            ],
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(), // Target doesn't matter as much as root for list_files_git
395            add_headers: false,
396            exclude_patterns: vec![],
397            include_untracked: false,      // Tracked only
398            include_default_output: false, // Exclude dirgrab.txt
399            no_git: false,                 // Use Git
400            include_tree: false,
401            convert_pdf: false,
402            all_repo: false,
403        };
404        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path, pass repo root
405        let expected_set = get_expected_set(
406            &path,
407            &[
408                ".gitignore",
409                "file2.rs",
410                "subdir/another.txt",
411                "deep/sub/nested.txt",
412                // file1.txt ignored by .gitignore
413                // file3.log ignored by .gitignore
414                // binary.dat ignored by .gitignore
415                // dirgrab.txt not tracked and default excluded
416                // untracked.txt not tracked
417                // ignored.log not tracked
418            ],
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 untracked
436            include_default_output: false, // Exclude dirgrab.txt
437            no_git: false,                 // Use Git
438            include_tree: false,
439            convert_pdf: false,
440            all_repo: false,
441        };
442        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
443        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", // Included now
451                                 // file1.txt ignored by .gitignore
452                                 // file3.log ignored by .gitignore
453                                 // binary.dat ignored by .gitignore
454                                 // ignored.log ignored by .gitignore (via --exclude-standard)
455                                 // dirgrab.txt untracked and default excluded
456            ],
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(),    // Exclude rust files
474                "subdir/".to_string(), // Exclude subdir/
475                "deep/".to_string(),   // Exclude deep/
476            ],
477            include_untracked: false, // Tracked only
478            include_default_output: false,
479            no_git: false, // Use Git
480            include_tree: false,
481            convert_pdf: false,
482            all_repo: false,
483        };
484        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
485        let expected_set = get_expected_set(&path, &[".gitignore"]); // Only .gitignore remains
486        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()], // Exclude all .txt files
501            include_untracked: true,                     // Include untracked
502            include_default_output: false,
503            no_git: false, // Use Git
504            include_tree: false,
505            convert_pdf: false,
506            all_repo: false,
507        };
508        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
509        let expected_set = get_expected_set(
510            &path,
511            &[
512                ".gitignore",
513                "file2.rs",
514                // subdir/another.txt excluded by *.txt
515                // deep/sub/nested.txt excluded by *.txt
516                // untracked.txt excluded by *.txt
517                // dirgrab.txt excluded by default
518            ],
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, // Include dirgrab.txt
533            no_git: true,                 // Force walkdir
534            include_tree: false,
535            convert_pdf: false,
536            all_repo: false,
537        };
538        let files = crate::listing::list_files_walkdir(&path, &config)?; // Use crate:: path
539        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", // Included now
548            ],
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        // Make dirgrab.txt tracked
562        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,     // Tracked only
571            include_default_output: true, // Include dirgrab.txt
572            no_git: false,                // Use Git
573            include_tree: false,
574            convert_pdf: false,
575            all_repo: false,
576        };
577        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
578        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", // Included because tracked and override flag set
586            ],
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        // dirgrab.txt is untracked in this setup
600        let config = GrabConfig {
601            target_path: path.clone(),
602            add_headers: false,
603            exclude_patterns: vec![],
604            include_untracked: true,      // Include untracked
605            include_default_output: true, // Include dirgrab.txt
606            no_git: false,                // Use Git
607            include_tree: false,
608            convert_pdf: false,
609            all_repo: false,
610        };
611        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
612        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", // Included
620                "dirgrab.txt",   // Included because untracked and override flag set
621            ],
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()], // User explicitly excludes
638            include_untracked: true,
639            include_default_output: true, // Override default exclusion, but user exclusion takes precedence
640            no_git: false,                // Use Git
641            include_tree: false,
642            convert_pdf: false,
643            all_repo: false,
644        };
645        let files = crate::listing::list_files_git(&path, &config, None)?; // Use crate:: path
646        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                // dirgrab.txt excluded by user pattern
655            ],
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, // No headers for easier content check
700            exclude_patterns: vec![],
701            include_untracked: false,      // No effect
702            include_default_output: false, // Exclude dirgrab.txt
703            no_git: true,                  // Force walkdir
704            include_tree: false,           // No tree for easier content check
705            convert_pdf: false,
706            all_repo: false,
707        };
708        let result_string = grab_contents(&config)?;
709
710        // Check content from files that would be ignored by git but included by walkdir
711        assert!(
712            result_string.contains("Content of file 1."),
713            "file1.txt content missing"
714        ); // Ignored by .gitignore, but walkdir includes
715        assert!(
716            result_string.contains("Log message."),
717            "file3.log content missing"
718        ); // Ignored by .gitignore, but walkdir includes
719        assert!(
720            result_string.contains("fn main() {}"),
721            "file2.rs content missing"
722        ); // Tracked by git, included by walkdir
723        assert!(
724            result_string.contains("Another text file."),
725            "another.txt content missing"
726        ); // Tracked by git, included by walkdir
727        assert!(
728            !result_string.contains("Previous dirgrab output."),
729            "dirgrab.txt included unexpectedly"
730        ); // Excluded by default
731
732        // The binary file binary.dat is skipped because it's not valid UTF-8.
733        // The processing function logs a warning. We don't need to assert its absence
734        // in the final string, as it cannot be represented in a valid Rust String anyway.
735        // The fact that grab_contents completes successfully and includes the text files is sufficient.
736
737        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()], // Exclude .txt and .rs
751            include_untracked: false,
752            include_default_output: false,
753            no_git: true, // Force walkdir
754            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"); // Included
761        assert!(
762            !result_string.contains("Content of file 1."),
763            "file1.txt included unexpectedly"
764        ); // Excluded by *.txt
765        assert!(
766            !result_string.contains("fn main() {}"),
767            "file2.rs included unexpectedly"
768        ); // Excluded by *.rs
769        assert!(
770            !result_string.contains("Another text file."),
771            "another.txt included unexpectedly"
772        ); // Excluded by *.txt
773        assert!(
774            !result_string.contains("Nested content"),
775            "nested.txt included unexpectedly"
776        ); // Excluded by *.txt
777        assert!(
778            !result_string.contains("Previous dirgrab output."),
779            "dirgrab.txt included unexpectedly"
780        ); // Excluded by default & *.txt
781
782        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, // Include dirgrab.txt
798            no_git: true,                 // Force walkdir
799            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(), // Target is repo root
820            add_headers: true,         // Enable headers
821            exclude_patterns: vec![
822                "*.log".to_string(),
823                "*.dat".to_string(),
824                "dirgrab.txt".to_string(),
825            ], // Simplify output
826            include_untracked: false,
827            include_default_output: false,
828            no_git: true,        // Force walkdir
829            include_tree: false, // No tree
830            convert_pdf: false,
831            all_repo: false,
832        };
833        let result_string = grab_contents(&config)?;
834
835        // file1.txt is ignored by .gitignore but included here because no_git=true
836        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        // .gitignore itself is not usually listed by walkdir unless explicitly targeted? Let's check file2.rs
845        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"); // Target is inside the repo
874        fs::create_dir_all(&subdir_target)?; // Ensure target exists
875
876        let config = GrabConfig {
877            target_path: subdir_target.clone(), // Target is 'deep' subdir
878            add_headers: true,                  // Enable headers
879            exclude_patterns: vec![],
880            include_untracked: false, // Tracked only
881            include_default_output: false,
882            no_git: false,       // Use Git mode
883            include_tree: false, // No tree
884            convert_pdf: false,
885            all_repo: false,
886        };
887        let result_string = grab_contents(&config)?; // Should still find files relative to repo root
888
889        // Check headers are relative to repo root (path), not target_path (subdir_target)
890        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        // Check other files outside the target dir are also included and relative to root
902        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        // Don't need git repo setup for no_git test, but keep files consistent
923        fs::write(path.join(".gitignore"), "*.log\nbinary.dat")?; // Create dummy .gitignore
924        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")?; // File exists
927
928        let config = GrabConfig {
929            target_path: path.clone(),
930            add_headers: true,
931            exclude_patterns: vec![
932                "*.log".to_string(),       // Exclude logs
933                "*.dat".to_string(),       // Exclude binary
934                ".gitignore".to_string(),  // Exclude .gitignore itself
935                "dirgrab.txt".to_string(), // Exclude default output file explicitly too
936            ],
937            include_untracked: false,      // No effect
938            include_default_output: false, // Also excluded above
939            no_git: true,                  // Force walkdir
940            include_tree: true,            // THE flag to test
941            convert_pdf: false,
942            all_repo: false,
943        };
944        let result = grab_contents(&config)?;
945
946        // Expected tree for walkdir with excludes applied
947        // file1.txt, file2.rs, another.txt, nested.txt, untracked.txt should remain
948        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        // Check presence of headers and content for included files
976        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        // Check absence of excluded file content
993        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()], // Exclude .gitignore
1016            include_untracked: true,                          // Include untracked
1017            include_default_output: false,                    // Exclude dirgrab.txt (default)
1018            no_git: false,                                    // Use Git
1019            include_tree: true,                               // Include tree
1020            convert_pdf: false,
1021            all_repo: false,
1022        };
1023        let result = grab_contents(&config)?;
1024
1025        // Expected tree for git ls-files -ou --exclude-standard :!.gitignore :!dirgrab.txt
1026        // Should include: file2.rs, another.txt, nested.txt, untracked.txt
1027        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        // Check content
1052        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        // No need for files if we exclude everything
1077        let config = GrabConfig {
1078            target_path: path.clone(),
1079            add_headers: true,
1080            exclude_patterns: vec!["*".to_string(), "*/".to_string()], // Exclude everything
1081            include_untracked: true,
1082            include_default_output: true,
1083            no_git: true,       // Use walkdir
1084            include_tree: true, // Ask for tree
1085            convert_pdf: false,
1086            all_repo: false,
1087        };
1088        let result = grab_contents(&config)?;
1089        // Expect only the empty tree message
1090        let expected = "---\nDIRECTORY STRUCTURE (No files selected)\n---\n\n";
1091        assert_eq!(result, expected);
1092        Ok(())
1093    }
1094
1095    // Tests calling internal helpers need crate:: prefix
1096    #[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        // Simulate paths relative to a base (doesn't have to exist for this test)
1108        let base = PathBuf::from("/project"); // Logical base
1109        let files_logical = [
1110            // Use array for BTreeSet later if needed
1111            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        // Map logical paths to actual paths in temp dir for is_dir() check
1118        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"); // The actual base path
1123
1124        let tree = crate::tree::generate_indented_tree(&files_in_tmp, &base_in_tmp)?; // Use crate:: path
1125        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"); // Logical base
1149        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"); // Actual base
1161
1162        let tree = crate::tree::generate_indented_tree(&files_in_tmp, &base_in_tmp)?; // Use crate:: path
1163        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    // --- Tests for processing.rs (Updated to pass GrabConfig) ---
1178    #[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"), // Should be skipped as non-utf8
1184            path.join("file2.rs"),
1185        ];
1186        let config = GrabConfig {
1187            // Create dummy config
1188            target_path: path.clone(),
1189            add_headers: false, // Key part of this test
1190            exclude_patterns: vec![],
1191            include_untracked: false,
1192            include_default_output: false,
1193            no_git: true, // Assume non-git mode for simplicity here
1194            include_tree: false,
1195            convert_pdf: false, // PDF conversion off
1196            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        // Don't need full git setup if we just provide repo_root
1219        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(), // target can be same as root for this test
1223            add_headers: true,         // Key part of this test
1224            exclude_patterns: vec![],
1225            include_untracked: false,
1226            include_default_output: false,
1227            no_git: false, // Git mode ON
1228            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(), // Paths relative to repo_root (which is path)
1237            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(), // Target path is the base
1261            add_headers: true,         // Key part of this test
1262            exclude_patterns: vec![],
1263            include_untracked: false,
1264            include_default_output: false,
1265            no_git: true, // Git mode OFF
1266            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(), // Paths relative to target_path
1274            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        // Check PDF header
1327        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        // *** Update expected content based on actual PDF text - try a different snippet ***
1335        // let expected_pdf_content = "Pines are the largest and most"; // Original snippet
1336        let expected_pdf_content = "Pinaceae family"; // Try this snippet instead
1337
1338        // Add a println to see exactly what is being searched for and in what
1339        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        // Check normal text file header and content
1350        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        // Check that file1.txt (not excluded) is present
1364        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()?; // Use existing helper
1377        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)?; // Ensure exists
1380        let fixture_pdf_src = fixtures_dir.join("sample.pdf");
1381
1382        // Create dummy if needed
1383        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 many things to simplify output check
1405            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, // Disable PDF conversion
1420            all_repo: false,
1421        };
1422
1423        let result_string = grab_contents(&config)?;
1424
1425        // Check PDF is NOT processed as text
1426        let unexpected_pdf_header_part = "(extracted text)"; // Check for the specific part of the header
1427        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        // Check normal text file is still included
1440        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        // Check that file1.txt (not excluded) is present
1454        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        // With convert_pdf: false, the PDF should be skipped as non-UTF8 by the fallback logic.
1462        // Check that the standard PDF header does NOT appear either.
1463        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} // End of mod tests