ryo-analysis 0.1.0

Code graph and discovery engine for the RYO project
Documentation
//! Test utilities for creating AnalysisContext instances.
//!
//! This module is only available with the `testing` feature enabled.
//!
//! # Example
//!
//! ```rust,ignore
//! use ryo_analysis::testing::ContextBuilder;
//!
//! let ctx = ContextBuilder::new()
//!     .with_file("src/lib.rs", "pub struct Foo {}")
//!     .build();
//!
//! // Access file by path
//! let file = ctx.test_file("src/lib.rs").unwrap();
//! ```

use crate::{AnalysisContext, WorkspaceFilePath};
use im::HashMap as ImHashMap;
use ryo_source::pure::PureFile;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};

/// Returns a shared test workspace root with a valid Cargo.toml.
///
/// Creates a minimal Cargo project at a deterministic temp directory
/// so that `CargoMetadataProvider::from_directory()` succeeds in test contexts.
/// The directory is created once per process and reused across all tests.
fn test_workspace_root() -> &'static Path {
    static WORKSPACE: OnceLock<PathBuf> = OnceLock::new();
    WORKSPACE.get_or_init(|| {
        let dir = std::env::temp_dir().join("ryo-test-workspace");
        std::fs::create_dir_all(dir.join("src"))
            .expect("Failed to create test workspace src directory");
        std::fs::write(
            dir.join("Cargo.toml"),
            "[package]\nname = \"test_crate\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
        )
        .expect("Failed to write test Cargo.toml");
        std::fs::write(dir.join("src").join("lib.rs"), "").expect("Failed to write test lib.rs");
        dir
    })
}

/// Builder for creating test AnalysisContext instances.
///
/// Provides a fluent API for constructing contexts with test files.
///
/// # Example
///
/// ```rust,ignore
/// // Simple usage (crate_name defaults to "crate")
/// let ctx = ContextBuilder::new()
///     .with_file("src/lib.rs", "pub struct Foo {}")
///     .build();
///
/// // With explicit crate name
/// let ctx = ContextBuilder::new()
///     .with_crate_name("mylib")
///     .with_file("src/lib.rs", "pub struct Foo {}")
///     .build();
///
/// // With explicit workspace root
/// let ctx = ContextBuilder::new()
///     .with_workspace_root("/my/project")
///     .with_crate_name("mylib")
///     .with_file("src/lib.rs", "pub struct Foo {}")
///     .build();
/// ```
#[derive(Debug)]
pub struct ContextBuilder {
    files: HashMap<PathBuf, PureFile>,
    workspace_root: PathBuf,
    crate_name: String,
}

impl Default for ContextBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl ContextBuilder {
    /// Create a new builder with default settings.
    ///
    /// Defaults:
    /// - workspace_root: temp directory with valid Cargo.toml
    /// - crate_name: "test_crate"
    pub fn new() -> Self {
        Self {
            files: HashMap::new(),
            workspace_root: test_workspace_root().to_path_buf(),
            crate_name: "test_crate".to_string(),
        }
    }

    /// Set the workspace root path.
    pub fn with_workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
        self.workspace_root = root.into();
        self
    }

    /// Set the crate name.
    pub fn with_crate_name(mut self, name: &str) -> Self {
        self.crate_name = name.to_string();
        self
    }

    /// Add a file with the given path and source code.
    ///
    /// # Panics
    /// Panics if the source code cannot be parsed.
    pub fn with_file(mut self, path: &str, source: &str) -> Self {
        let file = PureFile::from_source(source).expect("invalid source code");
        self.files.insert(PathBuf::from(path), file);
        self
    }

    /// Add a file with the given path and pre-parsed PureFile.
    pub fn with_pure_file(mut self, path: &str, file: PureFile) -> Self {
        self.files.insert(PathBuf::from(path), file);
        self
    }

    /// Build the AnalysisContext with full symbol analysis.
    ///
    /// Creates WorkspaceFilePath for each file with the configured
    /// workspace_root and crate_name, then performs full symbol analysis
    /// to populate the SymbolRegistry and CodeGraphV2.
    pub fn build(self) -> AnalysisContext {
        let workspace_files: HashMap<WorkspaceFilePath, PureFile> = self
            .files
            .into_iter()
            .map(|(path, file)| {
                let wp =
                    WorkspaceFilePath::new_for_test(path, &self.workspace_root, &self.crate_name);
                (wp, file)
            })
            .collect();

        AnalysisContext::from_workspace_files(workspace_files)
    }

    /// Build a minimal AnalysisContext (no symbol analysis).
    ///
    /// Creates a context with an empty SymbolRegistry and CodeGraphV2.
    /// Useful for tests that only need AST manipulation and don't require
    /// symbol-based features like pre-checks or impact analysis.
    pub fn build_minimal(self) -> AnalysisContext {
        let workspace_files: ImHashMap<WorkspaceFilePath, Arc<PureFile>> = self
            .files
            .into_iter()
            .map(|(path, file)| {
                let wp =
                    WorkspaceFilePath::new_for_test(path, &self.workspace_root, &self.crate_name);
                (wp, Arc::new(file))
            })
            .collect();

        AnalysisContext::from_im_files(workspace_files)
    }
}

/// Extension trait for AnalysisContext test utilities.
///
/// Provides convenient methods for accessing files by path string in tests.
pub trait ContextTestExt {
    /// Get a file by path string.
    ///
    /// # Example
    /// ```rust,ignore
    /// let file = ctx.test_file("src/lib.rs").unwrap();
    /// ```
    fn test_file(&self, path: &str) -> Option<&PureFile>;

    /// Get WorkspaceFilePath by path string.
    fn test_file_path(&self, path: &str) -> Option<WorkspaceFilePath>;

    /// Check if a file exists by path string.
    fn test_has_file(&self, path: &str) -> bool;
}

impl ContextTestExt for AnalysisContext {
    fn test_file(&self, path: &str) -> Option<&PureFile> {
        let file_path = self.test_file_path(path)?;
        self.file(&file_path)
    }

    fn test_file_path(&self, path: &str) -> Option<WorkspaceFilePath> {
        // Find file by matching relative path suffix
        let path = Path::new(path);
        self.files()
            .keys()
            .find(|wp| wp.as_relative() == path)
            .cloned()
    }

    fn test_has_file(&self, path: &str) -> bool {
        self.test_file_path(path).is_some()
    }
}

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

    #[test]
    fn test_context_builder_basic() {
        let ctx = ContextBuilder::new()
            .with_file("src/lib.rs", "pub struct Foo {}")
            .build();

        assert_eq!(ctx.file_count(), 1);
        assert!(ctx.test_has_file("src/lib.rs"));
    }

    #[test]
    fn test_context_builder_multiple_files() {
        let ctx = ContextBuilder::new()
            .with_file("src/lib.rs", "mod foo;")
            .with_file("src/foo.rs", "pub fn bar() {}")
            .build();

        assert_eq!(ctx.file_count(), 2);
        assert!(ctx.test_has_file("src/lib.rs"));
        assert!(ctx.test_has_file("src/foo.rs"));
    }

    #[test]
    fn test_context_builder_with_crate_name() {
        let ctx = ContextBuilder::new()
            .with_crate_name("mylib")
            .with_file("src/lib.rs", "pub struct Foo {}")
            .build();

        let file_path = ctx.test_file_path("src/lib.rs").unwrap();
        assert_eq!(file_path.crate_name().as_str(), "mylib");
    }

    #[test]
    fn test_context_test_ext() {
        let ctx = ContextBuilder::new()
            .with_file("src/lib.rs", "pub fn test() {}")
            .build();

        let file = ctx.test_file("src/lib.rs").unwrap();
        assert!(file.to_source().unwrap().contains("test"));
    }
}