dumpfiles 0.3.0

A CLI and library for generating structured YAML representations of directory contents, optimized for efficiently sharing codebases with LLMs.
Documentation
extern crate assert_cmd;
extern crate predicates;
extern crate tempfile;

use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;

/// Helper to create a file with content (mkdir -p parents)
fn create_file(path: &Path, content: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    let mut f = fs::File::create(path).unwrap();
    f.write_all(content.as_bytes()).unwrap();
}

#[test]
fn test_cli_respects_gitignore_dumpignore_and_ignore_patterns() {
    // Arrange: temp project structure
    let dir = tempdir().unwrap();
    let root = dir.path();

    // Real files
    create_file(&root.join("keep.txt"), "keep me");
    create_file(&root.join("notes.tmp"), "should be excluded by --ignore");
    create_file(
        &root.join("secret.txt"),
        "should be excluded by .dumpignore",
    );
    create_file(&root.join("trace.log"), "should be excluded by .gitignore");
    create_file(&root.join("src/main.rs"), "fn main() {}");

    // Default .gitignore excludes *.log
    create_file(&root.join(".gitignore"), "*.log\n");
    // .dumpignore excludes secret.txt
    create_file(&root.join(".dumpignore"), "secret.txt\n");

    // Output path inside the scanned tree (to verify self-exclusion)
    let out_path = root.join("catalog.yaml");

    // Act: run the CLI
    Command::cargo_bin("dumpfiles")
        .unwrap()
        .arg(root) // DIRECTORY
        .arg("--output")
        .arg(&out_path) // write into the scanned dir
        .arg("--ignore")
        .arg("*.tmp") // extra ignore via CLI
        .assert()
        .success()
        .stderr(predicate::str::contains("Starting to process").or(predicate::str::is_empty()));

    // Assert: output file exists and contains expected YAML
    let yaml = fs::read_to_string(&out_path).expect("catalog.yaml should exist");

    // Header
    let project_name = root
        .canonicalize()
        .unwrap()
        .file_name()
        .unwrap()
        .to_string_lossy()
        .into_owned();
    assert!(
        yaml.starts_with(&format!("project: {}\nfiles:\n", project_name)),
        "YAML header should include project name and 'files:'"
    );

    // Included
    assert!(
        yaml.contains("  - path: ") && yaml.contains("keep.txt"),
        "keep.txt should be included"
    );
    assert!(
        yaml.contains("src/main.rs"),
        "src/main.rs should be included"
    );

    // Excluded by various mechanisms
    assert!(
        !yaml.contains("notes.tmp"),
        "notes.tmp should be excluded by --ignore pattern"
    );
    assert!(
        !yaml.contains("trace.log"),
        "trace.log should be excluded by .gitignore"
    );
    assert!(
        !yaml.contains("secret.txt"),
        "secret.txt should be excluded by .dumpignore"
    );

    // Ensure the output file itself is not included
    assert!(
        !yaml.contains("catalog.yaml"),
        "The output file must not be included in its own listing"
    );

    // Basic structure sanity: must contain at least our two included files
    let file_entry_count = yaml.matches("\n  - path: ").count();
    assert!(
        file_entry_count >= 2,
        "Expected at least 2 file entries, got {}",
        file_entry_count
    );
}