herolib-code 0.3.13

Code analysis and parsing utilities for Rust source files
Documentation
//! Directory walker for discovering Rust source files.
//!
//! This module provides functionality to walk through a directory tree
//! and discover all Rust source files (`.rs` files).

use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use super::error::{ParseError, ParseResult};

/// Configuration options for the directory walker.
#[derive(Debug, Clone)]
pub struct WalkerConfig {
    /// Whether to follow symbolic links.
    pub follow_symlinks: bool,
    /// Maximum depth to recurse (None for unlimited).
    pub max_depth: Option<usize>,
    /// Directories to skip (e.g., "target", ".git").
    pub skip_dirs: Vec<String>,
    /// File patterns to include (default: ["*.rs"]).
    pub include_patterns: Vec<String>,
}

impl Default for WalkerConfig {
    fn default() -> Self {
        Self {
            follow_symlinks: false,
            max_depth: None,
            skip_dirs: vec![
                "target".to_string(),
                ".git".to_string(),
                "node_modules".to_string(),
                ".cargo".to_string(),
            ],
            include_patterns: vec!["*.rs".to_string()],
        }
    }
}

impl WalkerConfig {
    /// Creates a new WalkerConfig with default settings.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets whether to follow symbolic links.
    pub fn follow_symlinks(mut self, follow: bool) -> Self {
        self.follow_symlinks = follow;
        self
    }

    /// Sets the maximum depth to recurse.
    pub fn max_depth(mut self, depth: Option<usize>) -> Self {
        self.max_depth = depth;
        self
    }

    /// Adds a directory to skip.
    pub fn skip_dir(mut self, dir: impl Into<String>) -> Self {
        self.skip_dirs.push(dir.into());
        self
    }

    /// Sets the directories to skip.
    pub fn skip_dirs(mut self, dirs: Vec<String>) -> Self {
        self.skip_dirs = dirs;
        self
    }
}

/// Walks a directory tree and discovers Rust source files.
pub struct DirectoryWalker {
    /// Configuration for the walker.
    config: WalkerConfig,
}

impl DirectoryWalker {
    /// Creates a new DirectoryWalker with the given configuration.
    pub fn new(config: WalkerConfig) -> Self {
        Self { config }
    }

    /// Creates a new DirectoryWalker with default configuration.
    pub fn with_defaults() -> Self {
        Self::new(WalkerConfig::default())
    }

    /// Discovers all Rust source files in the given directory.
    ///
    /// # Arguments
    ///
    /// * `root` - The root directory to start walking from.
    ///
    /// # Returns
    ///
    /// A vector of paths to all discovered Rust source files.
    pub fn discover_rust_files<P: AsRef<Path>>(&self, root: P) -> ParseResult<Vec<PathBuf>> {
        let root = root.as_ref();

        if !root.exists() {
            return Err(ParseError::DirectoryNotFound(root.to_path_buf()));
        }

        let mut walker = WalkDir::new(root).follow_links(self.config.follow_symlinks);

        if let Some(depth) = self.config.max_depth {
            walker = walker.max_depth(depth);
        }

        let mut rust_files = Vec::new();
        let skip_dirs = &self.config.skip_dirs;

        let walker_iter = walker.into_iter().filter_entry(move |entry| {
            // Don't filter out the root directory
            if entry.depth() == 0 {
                return true;
            }
            if let Some(name) = entry.file_name().to_str() {
                // Skip files and directories starting with . or _
                if name.starts_with('.') || name.starts_with('_') {
                    return false;
                }
                // Filter out directories that should be skipped
                if entry.file_type().is_dir() && skip_dirs.contains(&name.to_string()) {
                    return false;
                }
            }
            true
        });

        for entry in walker_iter {
            let entry = entry.map_err(|e| ParseError::WalkError {
                path: root.to_path_buf(),
                source: e,
            })?;

            let path = entry.path();

            // Check if this is a Rust file
            if entry.file_type().is_file() {
                if let Some(extension) = path.extension() {
                    if extension == "rs" {
                        rust_files.push(path.to_path_buf());
                    }
                }
            }
        }

        // Sort files for consistent ordering
        rust_files.sort();

        Ok(rust_files)
    }

    /// Returns a reference to the walker configuration.
    pub fn config(&self) -> &WalkerConfig {
        &self.config
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn test_walker_config_default() {
        let config = WalkerConfig::default();
        assert!(!config.follow_symlinks);
        assert!(config.max_depth.is_none());
        assert!(config.skip_dirs.contains(&"target".to_string()));
    }

    #[test]
    fn test_walker_config_builder() {
        let config = WalkerConfig::new()
            .follow_symlinks(true)
            .max_depth(Some(5))
            .skip_dir("custom_dir");

        assert!(config.follow_symlinks);
        assert_eq!(config.max_depth, Some(5));
        assert!(config.skip_dirs.contains(&"custom_dir".to_string()));
    }

    #[test]
    fn test_discover_rust_files() {
        let dir = tempdir().unwrap();
        let dir_path = dir.path();

        // Create some Rust files
        fs::write(dir_path.join("main.rs"), "fn main() {}").unwrap();
        fs::write(dir_path.join("lib.rs"), "pub fn lib() {}").unwrap();

        // Create a subdirectory with more Rust files
        let subdir = dir_path.join("src");
        fs::create_dir(&subdir).unwrap();
        fs::write(subdir.join("module.rs"), "pub mod module;").unwrap();

        // Create a non-Rust file
        fs::write(dir_path.join("readme.md"), "# Readme").unwrap();

        let walker = DirectoryWalker::with_defaults();
        let files = walker.discover_rust_files(dir_path).unwrap();

        assert_eq!(files.len(), 3);
        assert!(files.iter().all(|f| f.extension().unwrap() == "rs"));
    }

    #[test]
    fn test_skip_directories() {
        let dir = tempdir().unwrap();
        let dir_path = dir.path();

        // Create a target directory (should be skipped)
        let target_dir = dir_path.join("target");
        fs::create_dir(&target_dir).unwrap();
        fs::write(target_dir.join("generated.rs"), "// generated").unwrap();

        // Create a regular file
        fs::write(dir_path.join("main.rs"), "fn main() {}").unwrap();

        let walker = DirectoryWalker::with_defaults();
        let files = walker.discover_rust_files(dir_path).unwrap();

        // Only main.rs should be found, not target/generated.rs
        assert_eq!(files.len(), 1);
        assert!(files[0].ends_with("main.rs"));
    }

    #[test]
    fn test_directory_not_found() {
        let walker = DirectoryWalker::with_defaults();
        let result = walker.discover_rust_files("/nonexistent/path");

        assert!(matches!(result, Err(ParseError::DirectoryNotFound(_))));
    }
}