crawk 0.5.2

Dependency crawler for Rust. It crawls so you don't have to untangle
Documentation
use crate::reference::TypeReference;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

/// Options for dependency analysis.
///
/// Controls how the analyzer processes modules and formats output.
///
/// # Examples
///
/// ```
/// use crawk::AnalysisOptions;
///
/// // Default options: exclude tests, don't expand groups, don't resolve globs
/// let options = AnalysisOptions::default();
///
/// // Include test modules and expand grouped imports
/// let options = AnalysisOptions {
///     include_tests: true,
///     expand_groups: true,
///     ..Default::default()
/// };
///
/// // Resolve glob imports to explicit items
/// let options = AnalysisOptions {
///     resolve_globs: true,
///     ..Default::default()
/// };
///
/// // Fully flatten all imports: groups first, then globs.
/// // `use crate::foo::{Bar, *}` → `foo::Bar`, `foo::Baz`, `foo::Qux`, …
/// let options = AnalysisOptions {
///     expand_groups: true,
///     resolve_globs: true,
///     ..Default::default()
/// };
/// ```
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Default)]
pub struct AnalysisOptions {
    /// Recursively analyze all submodules of the specified module.
    ///
    /// When `false` (default), only the specified module is analyzed.
    /// When `true`, all nested submodules are also analyzed. For example,
    /// if analyzing `foo` with `recursive = true`, it will analyze
    /// `foo`, `foo::bar`, `foo::baz`, etc.
    pub recursive: bool,

    /// Include test modules (`#[cfg(test)]`) in analysis.
    ///
    /// When `false` (default), dependencies from test modules are excluded.
    pub include_tests: bool,

    /// Expand grouped imports into individual paths.
    ///
    /// When `true`, `use crate::foo::{Bar, Baz}` becomes two separate entries:
    /// `foo::Bar` and `foo::Baz`.
    ///
    /// This transformation runs **before** glob resolution (see [`resolve_globs`](Self::resolve_globs)).
    /// A grouped glob `use crate::foo::{Bar, *}` is first split into `foo::Bar` and `foo::*`,
    /// and then — if `resolve_globs` is also `true` — the glob is expanded further into
    /// the individual items exported by `foo`.
    pub expand_groups: bool,

    /// Resolve glob imports to explicit items.
    ///
    /// When `true`, `use crate::foo::*` is expanded into the individual public
    /// items exported by module `foo` (e.g., `foo::Bar`, `foo::Baz`).
    ///
    /// Glob resolution runs **after** group expansion (see [`expand_groups`](Self::expand_groups)).
    /// If `expand_groups` is `false`, a grouped glob such as `use crate::foo::{Bar, *}` is
    /// kept as a single group entry and the glob inside it is **not** resolved, because the
    /// resolver only processes top-level glob references. Enable both options together to
    /// fully flatten all grouped and glob imports.
    pub resolve_globs: bool,
}

/// Result of analyzing a module's dependencies.
///
/// Contains the set of internal crate dependencies found in the analyzed module
/// and all its submodules. Created by [`Analyzer::analyze_module`](crate::Analyzer::analyze_module)
/// using the options specified in [`AnalysisOptions`].
#[derive(Debug, Clone)]
pub struct AnalysisResult {
    /// The analyzed module path (e.g., `"utils::parser"`).
    module_path: String,

    /// Set of internal dependencies found for the analyzed modules.
    dependencies: HashMap<String, HashSet<TypeReference>>,

    /// Path to the source file that was analyzed.
    source_file: PathBuf,
}

impl AnalysisResult {
    /// Creates a new analysis result.
    #[must_use]
    pub(crate) fn new(
        module_path: impl Into<String>,
        dependencies: HashMap<String, HashSet<TypeReference>>,
        source_file: PathBuf,
    ) -> Self {
        Self {
            module_path: module_path.into(),
            dependencies,
            source_file,
        }
    }

    /// Returns the analyzed module path.
    #[must_use]
    pub fn module_path(&self) -> &str {
        &self.module_path
    }

    /// Returns the internal crate references found, grouped by module path.
    ///
    /// The map key is the **module path** (e.g., `"utils::parser"`). The value is the set of
    /// [`TypeReference`] items found in that module's source.
    ///
    /// With [`AnalysisOptions::recursive`] set to `false` (default), the map contains exactly
    /// one entry — for the module passed to [`Analyzer::analyze_module`](crate::Analyzer::analyze_module).
    /// With `recursive: true`, the map contains one entry per discovered submodule (e.g.,
    /// `"utils"`, `"utils::parser"`, `"utils::lexer"`, …).
    ///
    /// To get a flat, deduplicated list across all modules, use
    /// [`into_sorted_vec`](Self::into_sorted_vec) instead.
    #[must_use]
    pub const fn dependencies(&self) -> &HashMap<String, HashSet<TypeReference>> {
        &self.dependencies
    }

    /// Returns the path to the analyzed source file.
    #[must_use]
    pub fn source_file(&self) -> &Path {
        &self.source_file
    }

    /// Returns `true` if no dependencies were found.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.dependencies.is_empty()
    }

    /// Returns the number of dependencies found.
    #[must_use]
    pub fn len(&self) -> usize {
        self.dependencies.len()
    }

    /// Consumes the result and returns the dependencies as a sorted vector.
    #[must_use]
    pub fn into_sorted_vec(self) -> Vec<TypeReference> {
        let all_deps_unique: HashSet<_> = self.dependencies.values().flatten().cloned().collect();
        let mut all_deps_unique: Vec<TypeReference> = all_deps_unique.into_iter().collect();
        all_deps_unique.sort_by_key(TypeReference::to_path_string);
        all_deps_unique
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_analysis_options_default() {
        let options = AnalysisOptions::default();
        assert!(!options.include_tests);
        assert!(!options.expand_groups);
    }

    #[test]
    fn test_analysis_result_source_file() {
        let result = AnalysisResult::new(
            "foo::bar".to_owned(),
            HashMap::new(),
            PathBuf::from("/tmp/test.rs"),
        );
        assert_eq!(result.source_file(), Path::new("/tmp/test.rs"));
    }

    #[test]
    fn test_analysis_result_len_and_is_empty() {
        let empty_result = AnalysisResult::new("empty".to_owned(), HashMap::new(), PathBuf::new());
        assert_eq!(empty_result.len(), 0);
        assert!(empty_result.is_empty());

        let mut deps = HashMap::new();
        deps.insert("mod_a".to_owned(), HashSet::new());
        deps.insert("mod_b".to_owned(), HashSet::new());
        let non_empty_result = AnalysisResult::new("root".to_owned(), deps, PathBuf::new());
        assert_eq!(non_empty_result.len(), 2);
        assert!(!non_empty_result.is_empty());
    }

    #[test]
    fn test_analysis_result_module_path() {
        let result =
            AnalysisResult::new("foo::bar::baz".to_owned(), HashMap::new(), PathBuf::new());
        assert_eq!(result.module_path(), "foo::bar::baz");
    }

    #[test]
    fn test_into_sorted_vec_deduplicates_and_sorts() {
        use crate::reference::TypeReference;

        let r1 = TypeReference::new(["z_module", "Type"]);
        let r2 = TypeReference::new(["a_module", "Type"]);
        let r3 = TypeReference::new(["z_module", "Type"]); // duplicate of r1

        let mut deps = HashMap::new();
        deps.insert("mod_a".to_owned(), HashSet::from([r1.clone(), r2.clone()]));
        deps.insert("mod_b".to_owned(), HashSet::from([r3]));

        let result = AnalysisResult::new("root".to_owned(), deps, PathBuf::new());
        let sorted = result.into_sorted_vec();

        assert_eq!(sorted.len(), 2);
        assert_eq!(sorted[0], r2); // a_module before z_module
        assert_eq!(sorted[1], r1);
    }
}