repo-flatten 0.2.1

A utility to flatten all files in the repository into a single file, consumed by LLMs. Will ignore .gitignore and hidden files.
//! Path filtering and resolution module.

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tracing::{info, info_span, warn};

/// Resolve and normalize filter paths based on user input and repository root.
#[tracing::instrument(level = "info", skip(user_paths), fields(user_path_count=user_paths.len(), repo_root=%repo_root.display()), err)]
pub fn resolve_filter_paths(user_paths: &[PathBuf], repo_root: &Path) -> Result<Vec<PathBuf>> {
    let span = info_span!("path_filter.resolve");
    let _guard = span.enter();

    info!("resolving filter paths");

    let paths_to_flatten = if !user_paths.is_empty() {
        // User provided one or more paths
        resolve_user_provided_paths(user_paths, repo_root)?
    } else {
        // No paths specified, auto-detect based on current directory
        auto_detect_paths(repo_root)?
    };

    // Normalize the filter paths if provided
    let normalized_filter_paths = normalize_paths(paths_to_flatten);

    info!(
        normalized_path_count = normalized_filter_paths.len(),
        "path resolution completed"
    );
    Ok(normalized_filter_paths)
}

/// Resolve user-provided paths relative to the repository root.
#[tracing::instrument(level = "info", skip(user_paths), fields(user_path_count=user_paths.len(), repo_root=%repo_root.display()), err)]
fn resolve_user_provided_paths(user_paths: &[PathBuf], repo_root: &Path) -> Result<Vec<PathBuf>> {
    let span = info_span!("path_filter.resolve_user_paths");
    let _guard = span.enter();

    let current_dir = std::env::current_dir().context("Failed to get current working directory")?;

    let mut resolved_paths = Vec::new();

    for user_path in user_paths {
        let path_span =
            info_span!("path_filter.resolve_single_path", user_path=%user_path.display());
        let _path_guard = path_span.enter();

        let resolved_path = if user_path.is_absolute() {
            // User provided absolute path, use as-is
            user_path.clone()
        } else {
            // User provided relative path, resolve relative to current working directory
            current_dir.join(user_path)
        };

        // Convert the resolved path to be relative to repository root
        match resolved_path.strip_prefix(repo_root) {
            Ok(relative_to_repo) => {
                let path = relative_to_repo.to_path_buf();
                if !path.as_os_str().is_empty() {
                    info!(resolved_path=%path.display(), "path resolved successfully");
                    resolved_paths.push(path);
                } else {
                    info!("path resolved to repository root, including all files");
                }
            }
            Err(_) => {
                warn!(user_path=%user_path.display(), repo_root=%repo_root.display(),
                      "path is outside repository, skipping");
            }
        }
    }

    Ok(resolved_paths)
}

/// Auto-detect paths based on current directory relative to repository root.
#[tracing::instrument(level = "info", fields(repo_root=%repo_root.display()), err)]
fn auto_detect_paths(repo_root: &Path) -> Result<Vec<PathBuf>> {
    let span = info_span!("path_filter.auto_detect");
    let _guard = span.enter();

    let current_dir = std::env::current_dir().context("Failed to get current working directory")?;

    // Try to get the relative path from repo root to current directory
    match current_dir.strip_prefix(repo_root) {
        Ok(relative_path) => {
            let path = relative_path.to_path_buf();
            if path.as_os_str().is_empty() {
                // We're at the repository root, flatten everything
                info!("current directory is repository root, including all files");
                Ok(vec![])
            } else {
                // We're in a subdirectory, only flatten this subdirectory
                info!(detected_path=%path.display(), "auto-detected subdirectory filter");
                Ok(vec![path])
            }
        }
        Err(_) => {
            // Current directory is not inside the repository, flatten everything
            info!("current directory is outside repository, including all files");
            Ok(vec![])
        }
    }
}

/// Normalize paths by removing current directory components and empty paths.
#[tracing::instrument(level = "info", skip(paths), fields(input_count=paths.len()))]
fn normalize_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
    let span = info_span!("path_filter.normalize");
    let _guard = span.enter();

    let normalized: Vec<PathBuf> = paths
        .into_iter()
        .map(|p| {
            // Use Path methods to properly handle the path
            let mut components = p.components().collect::<Vec<_>>();

            // Remove current directory component if present
            if let Some(std::path::Component::CurDir) = components.first() {
                components.remove(0);
            }

            // Reconstruct the path from components
            let mut result = PathBuf::new();
            for component in components {
                result.push(component);
            }
            result
        })
        .filter(|p| !p.as_os_str().is_empty())
        .collect();

    info!(
        output_count = normalized.len(),
        "path normalization completed"
    );
    normalized
}