dissolve-python 0.3.0

A tool to dissolve deprecated calls in Python codebases
Documentation
//! Integration tests for dissolve commands on example package.
//!
//! These tests verify that the dissolve commands (info, check, migrate, cleanup) work correctly
//! by running them on a copy of the example directory and comparing the results to expected output.

use std::fs;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;

/// Helper to copy a directory recursively
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
    if !dst.exists() {
        fs::create_dir_all(dst)?;
    }

    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());

        if src_path.is_dir() {
            copy_dir_recursive(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }

    Ok(())
}

/// Helper to run a dissolve command and capture output
fn run_dissolve_command(args: &[&str], cwd: Option<&std::path::Path>) -> (String, String, bool) {
    // Use cargo run instead of the binary path for integration tests
    let mut cmd = Command::new("cargo");
    cmd.args(["run", "--"]);
    cmd.args(args);

    if let Some(dir) = cwd {
        cmd.current_dir(dir);
    }

    let output = cmd.output().expect("Failed to execute dissolve command");

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    let success = output.status.success();

    (stdout, stderr, success)
}

/// Create a temporary copy of the example directory for testing
fn create_example_copy() -> (TempDir, PathBuf) {
    let temp_dir = TempDir::new().expect("Failed to create temp dir");
    let example_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("example");
    let example_dst = temp_dir.path().join("example");

    copy_dir_recursive(&example_src, &example_dst).expect("Failed to copy example directory");

    (temp_dir, example_dst)
}

/// Helper to normalize output for comparison (remove file paths, line numbers, etc.)
fn normalize_output(output: &str, temp_path: &std::path::Path) -> String {
    // Replace the temporary path with a placeholder
    let temp_str = temp_path.to_string_lossy();
    let normalized = output.replace(&*temp_str, "<TEMP>");

    // Remove any other path-specific information that might vary
    let normalized = normalized.replace("\\", "/"); // Normalize path separators

    // Sort lines for consistent comparison (some outputs may vary in order)
    let mut lines: Vec<&str> = normalized.lines().collect();
    if lines.len() > 5 && lines[0].contains("deprecated function") {
        // For info command, sort the function listings but keep summary at end
        let mut summary_start = lines.len();
        for (i, line) in lines.iter().enumerate() {
            if line.contains("=== Summary ===") {
                summary_start = i;
                break;
            }
        }

        if summary_start < lines.len() {
            lines[..summary_start].sort();
        }
    }

    lines.join("\n")
}

#[test]
fn test_dissolve_info_command() {
    let (_temp_dir, example_path) = create_example_copy();

    let (stdout, stderr, success) =
        run_dissolve_command(&["info", &example_path.to_string_lossy()], None);

    assert!(success, "dissolve info command failed. stderr: {}", stderr);

    // Verify basic structure of info output
    assert!(stdout.contains("deprecated function(s)"));
    assert!(stdout.contains("=== Summary ==="));
    assert!(stdout.contains("Total files analyzed:"));
    assert!(stdout.contains("Total deprecated functions found:"));

    // Verify it found the expected files
    assert!(stdout.contains("library/utils.py"));
    assert!(stdout.contains("library/models.py"));
    assert!(stdout.contains("library/async_ops.py"));
    assert!(stdout.contains("library/config.py"));
    assert!(stdout.contains("library/containers.py"));
    assert!(stdout.contains("library/processors.py"));

    // Verify it found some expected deprecated functions
    assert!(stdout.contains("old_add"));
    assert!(stdout.contains("old_multiply"));
    assert!(stdout.contains("OldDataProcessor"));
    assert!(stdout.contains("LegacyUser"));

    // Store the normalized output for comparison
    let expected_info_output = normalize_output(&stdout, &example_path);

    // Write expected output to a reference file (for debugging/maintenance)
    let expected_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("expected")
        .join("info_output.txt");

    if let Some(parent) = expected_file.parent() {
        fs::create_dir_all(parent).ok();
    }
    fs::write(&expected_file, &expected_info_output).ok();

    println!("Info command output length: {} characters", stdout.len());
    println!(
        "Info command found {} files",
        stdout.matches(".py:").count()
    );
}

#[test]
fn test_dissolve_check_command() {
    let (_temp_dir, example_path) = create_example_copy();

    let (stdout, stderr, _success) =
        run_dissolve_command(&["check", &example_path.to_string_lossy()], None);

    // check command may return non-zero exit code if there are issues, that's expected

    // Verify basic structure of check output
    assert!(
        stdout.contains("@replace_me function(s) can be replaced")
            || stdout.contains("ERRORS found")
    );

    // Verify it checked the expected files
    assert!(stdout.contains("library/utils.py"));
    assert!(stdout.contains("library/models.py"));

    println!("Check command output: {}", stdout);
    if !stderr.is_empty() {
        println!("Check command stderr: {}", stderr);
    }

    // Store the normalized output
    let expected_check_output = normalize_output(&stdout, &example_path);

    let expected_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("expected")
        .join("check_output.txt");

    if let Some(parent) = expected_file.parent() {
        fs::create_dir_all(parent).ok();
    }
    fs::write(&expected_file, &expected_check_output).ok();
}

#[test]
fn test_dissolve_migrate_command() {
    let (_temp_dir, example_path) = create_example_copy();
    let consumer_path = example_path.join("consumer");

    // First run migrate without --write to see what would be changed
    let (stdout, stderr, success) =
        run_dissolve_command(&["migrate", &consumer_path.to_string_lossy()], None);

    assert!(
        success,
        "dissolve migrate command failed. stderr: {}",
        stderr
    );

    println!("Migrate command (dry run) output: {}", stdout);

    // Then run migrate with --write to actually make changes
    let (stdout_write, stderr_write, success_write) = run_dissolve_command(
        &["migrate", &consumer_path.to_string_lossy(), "--write"],
        None,
    );

    assert!(
        success_write,
        "dissolve migrate --write command failed. stderr: {}",
        stderr_write
    );

    println!("Migrate command (--write) output: {}", stdout_write);

    // Store the outputs
    let expected_file_dry = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("expected")
        .join("migrate_dry_output.txt");

    let expected_file_write = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("expected")
        .join("migrate_write_output.txt");

    if let Some(parent) = expected_file_dry.parent() {
        fs::create_dir_all(parent).ok();
    }

    fs::write(&expected_file_dry, normalize_output(&stdout, &example_path)).ok();
    fs::write(
        &expected_file_write,
        normalize_output(&stdout_write, &example_path),
    )
    .ok();
}

#[test]
fn test_dissolve_cleanup_command() {
    let (_temp_dir, example_path) = create_example_copy();
    let library_path = example_path.join("library");

    // Run cleanup command
    let (stdout, stderr, _success) =
        run_dissolve_command(&["cleanup", &library_path.to_string_lossy()], None);

    // cleanup command may return non-zero if there are functions that can't be cleaned up

    println!("Cleanup command output: {}", stdout);
    if !stderr.is_empty() {
        println!("Cleanup command stderr: {}", stderr);
    }

    // Store the output
    let expected_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("expected")
        .join("cleanup_output.txt");

    if let Some(parent) = expected_file.parent() {
        fs::create_dir_all(parent).ok();
    }

    fs::write(&expected_file, normalize_output(&stdout, &example_path)).ok();
}

#[test]
fn test_full_workflow_integration() {
    let (_temp_dir, example_path) = create_example_copy();

    // Test the full workflow: info -> check -> migrate -> cleanup

    // 1. Info command
    let (info_out, _, info_success) =
        run_dissolve_command(&["info", &example_path.to_string_lossy()], None);
    assert!(info_success);
    assert!(info_out.contains("Total deprecated functions found:"));

    // 2. Check command
    let (check_out, _, _check_success) =
        run_dissolve_command(&["check", &example_path.to_string_lossy()], None);
    assert!(check_out.contains("@replace_me function(s)") || check_out.contains("ERRORS found"));

    // 3. Migrate command on consumer
    let consumer_path = example_path.join("consumer");
    let (migrate_out, _, migrate_success) = run_dissolve_command(
        &["migrate", &consumer_path.to_string_lossy(), "--write"],
        None,
    );
    assert!(migrate_success);

    // 4. Cleanup command on library
    let library_path = example_path.join("library");
    let (cleanup_out, _, _cleanup_success) =
        run_dissolve_command(&["cleanup", &library_path.to_string_lossy()], None);

    // Verify that the workflow completed without major errors
    println!("Full workflow completed successfully");
    println!("Info found {} files", info_out.matches(".py:").count());
    println!("Migration output: {}", migrate_out);
    println!("Cleanup output: {}", cleanup_out);
}

#[test]
fn test_module_qualification_fix() {
    // This test verifies that function names in replacements are correctly qualified
    // with the source module when they're not available in the current scope

    use crate::core::{ConstructType, ParameterInfo, ReplaceInfo};
    use crate::migrate_ruff::migrate_file;
    use crate::tests::test_utils::TestContext;
    use crate::type_introspection_context::TypeIntrospectionContext;
    use crate::types::TypeIntrospectionMethod;
    use std::collections::HashMap;
    use std::path::Path;

    let source = r#"
# Test source that uses deprecated function
from library import utils

result = utils.old_function(42, extra="test")
"#;

    // Create replacement info that replaces utils.old_function with new_function
    let mut replacements = HashMap::new();
    let mut replace_info = ReplaceInfo::new(
        "library.utils.old_function",
        "new_function({value}, extra={extra})",
        ConstructType::Function,
    );

    // Add parameters
    replace_info.parameters = vec![
        ParameterInfo::from_name("value"),
        ParameterInfo::from_name("extra"),
    ];

    // Create AST for the replacement by parsing a simple function
    if let Ok(rustpython_ast::Mod::Module(module)) = rustpython_parser::parse(
        "def old_function(value, extra): return new_function(value, extra=extra)",
        rustpython_parser::Mode::Module,
        "<test>",
    ) {
        if let Some(rustpython_ast::Stmt::FunctionDef(func)) = module.body.first() {
            if let Some(rustpython_ast::Stmt::Return(ret)) = func.body.first() {
                replace_info.replacement_ast = ret.value.clone();
            }
        }
    }

    replacements.insert("library.utils.old_function".to_string(), replace_info);

    // Perform migration
    let test_ctx = TestContext::new(source);
    let mut type_context =
        TypeIntrospectionContext::new(TypeIntrospectionMethod::PyrightLsp).unwrap();
    let result = migrate_file(
        source,
        "test_module",
        Path::new(&test_ctx.file_path),
        &mut type_context,
        replacements,
        HashMap::new(),
    )
    .unwrap();
    type_context.shutdown().unwrap();

    // Verify that new_function is qualified with the source module name
    // Since new_function is not imported or defined in the current module,
    // it should be qualified as library.new_function
    assert!(
        result.contains("library.new_function(42, extra=\"test\")"),
        "Expected 'library.new_function(42, extra=\"test\")', got:\n{}",
        result
    );

    println!("Module qualification fix test passed!");
}