wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A plan is what a reader agrees to, so it has to say exactly what will
//! happen and say the same thing every time it is asked.

use super::*;
use crate::refactor::cli::Args;
use crate::refactor::rename_engine::RenameEngine;
use std::fs;

fn args(root: &Path, pattern: &str, substitute: &str) -> Args {
    Args {
        root_dir: root.to_path_buf(),
        pattern: Some(pattern.to_string()),
        substitute: Some(substitute.to_string()),
        patterns_file: None,
        assume_yes: true,
        verbose: false,
        follow_symlinks: false,
        backup: false,
        files_only: false,
        dirs_only: false,
        names_only: false,
        content_only: false,
        max_depth: 0,
        exclude_patterns: Vec::new(),
        include_patterns: Vec::new(),
        format: crate::refactor::cli::OutputFormat::Json,
        threads: 1,
        progress: crate::refactor::cli::ProgressMode::Never,
        ignore_case: false,
        use_regex: false,
        include_hidden: false,
        binary_names: false,
        gitignore: false,
    }
}

/// A tree with one content match, one file to rename and one directory.
fn tree() -> tempfile::TempDir {
    let dir = tempfile::TempDir::new().expect("temp dir");
    let root = dir.path();
    fs::create_dir_all(root.join("widget_parts")).expect("dir");
    fs::write(root.join("widget_parts/widget_core.txt"), "widget and widget\n").expect("file");
    fs::write(root.join("untouched.txt"), "nothing here\n").expect("other");
    dir
}

#[test]
fn test11072_a_plan_names_every_change_and_writes_nothing() {
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    assert_eq!(plan.files_edited, 1);
    assert_eq!(plan.replacements, 2, "both occurrences in the one file");
    assert_eq!(plan.files_renamed, 1);
    assert_eq!(plan.directories_renamed, 1);

    // Nothing on disk moved or changed.
    assert!(dir.path().join("widget_parts/widget_core.txt").exists());
    assert_eq!(
        fs::read_to_string(dir.path().join("widget_parts/widget_core.txt")).expect("read"),
        "widget and widget\n"
    );
}

#[test]
fn test11073_a_plan_is_paginated_without_running_off_the_end() {
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    let total = plan.total();
    assert!(total >= 3, "content edit, file rename, directory rename");

    // A reader scrolling to the bottom asks for one page past the end every
    // single time. That is an empty page, not a failure.
    assert!(plan.page(total, 50).is_empty());
    assert!(plan.page(total + 500, 50).is_empty());

    // Pages tile the whole list exactly once, in order.
    let mut walked = Vec::new();
    let mut offset = 0;
    while !plan.page(offset, 2).is_empty() {
        walked.extend(plan.page(offset, 2).iter().cloned());
        offset += 2;
    }
    assert_eq!(walked, plan.changes, "paging loses and repeats nothing");
}

#[test]
fn test11074_the_same_tree_plans_the_same_way_twice() {
    // A page renders rows by index and asks for them across several requests.
    // An order that shifted between calls would show a reader one change and
    // apply another.
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (first, _, _) = engine.plan().expect("a plan");
    let (second, _, _) = engine.plan().expect("a plan");
    assert_eq!(first.changes, second.changes);
}

#[test]
fn test11075_applying_a_stale_plan_is_refused_and_writes_nothing() {
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (reviewed, _, _) = engine.plan().expect("a plan");

    // The tree moves under the reviewed plan — a build lands, a branch is
    // checked out, another tool runs.
    fs::write(dir.path().join("widget_extra.txt"), "widget\n").expect("new file");

    let refused = engine.apply(&reviewed).expect_err("a stale plan is refused");
    let message = refused.to_string();
    assert!(
        message.contains("changed since"),
        "the refusal says why: {message}"
    );

    // And nothing was written: the original file is untouched, not half-done.
    assert_eq!(
        fs::read_to_string(dir.path().join("widget_parts/widget_core.txt")).expect("read"),
        "widget and widget\n"
    );
}

#[test]
fn test11076_applying_a_current_plan_carries_it_out() {
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (reviewed, _, _) = engine.plan().expect("a plan");

    engine.apply(&reviewed).expect("a current plan applies");

    assert!(dir.path().join("gadget_parts/gadget_core.txt").exists());
    assert_eq!(
        fs::read_to_string(dir.path().join("gadget_parts/gadget_core.txt")).expect("read"),
        "gadget and gadget\n"
    );
    assert!(!dir.path().join("widget_parts").exists());
}

#[test]
fn test11077_paths_are_reported_as_the_reader_knows_them() {
    let dir = tree();
    let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    // Relative to the chosen root. An absolute path tells a reader where the
    // temp directory is, not which of their files is about to change.
    for change in &plan.changes {
        assert!(
            !change.path().starts_with('/'),
            "change path is relative: {}",
            change.path()
        );
    }
}

/// What the walk could not examine, and whether the reader can do anything.
mod barriers {
    use super::*;
    use crate::refactor::preview::Barrier;

    #[test]
    fn test11103_a_binary_is_reported_not_silently_skipped() {
        // A refactor that quietly ignored what it could not read would report
        // every occurrence replaced while files it never searched kept theirs.
        let dir = tempfile::TempDir::new().expect("temp dir");
        fs::write(dir.path().join("plain.txt"), "widget\n").expect("text");
        // Bytes that are not text in any encoding.
        fs::write(dir.path().join("blob.bin"), [0xff, 0xfe, 0x00, 0x01, 0xc3, 0x28]).expect("blob");

        let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
        let (plan, _, _) = engine.plan().expect("a plan");

        // The text file is still found. One unreadable file does not end the run.
        assert_eq!(plan.files_edited, 1);
        for barrier in &plan.unreadable {
            assert_ne!(barrier.barrier, Barrier::PermissionDenied, "{barrier:?}");
        }
    }

    #[cfg(unix)]
    #[test]
    fn test11104_a_directory_that_cannot_be_read_is_named_and_classified() {
        use std::os::unix::fs::PermissionsExt;

        // Running as root defeats the premise: everything is readable, so
        // there is no barrier to classify.
        if unsafe { libc::geteuid() } == 0 {
            return;
        }

        let dir = tempfile::TempDir::new().expect("temp dir");
        fs::write(dir.path().join("visible.txt"), "widget\n").expect("text");
        let closed = dir.path().join("closed");
        fs::create_dir(&closed).expect("dir");
        fs::write(closed.join("hidden.txt"), "widget\n").expect("inner");
        fs::set_permissions(&closed, fs::Permissions::from_mode(0o000)).expect("chmod");

        let engine = RenameEngine::new(args(dir.path(), "widget", "gadget")).expect("engine");
        let planned = engine.plan();

        // Restored before any assertion, so a failure cannot leave an
        // undeletable directory behind for the next run.
        fs::set_permissions(&closed, fs::Permissions::from_mode(0o755)).expect("restore");

        let (plan, _, _) = planned.expect("one closed directory does not end the refactor");
        assert_eq!(plan.files_edited, 1, "the readable file is still found");

        let denied: Vec<_> = plan
            .unreadable
            .iter()
            .filter(|u| u.barrier == Barrier::PermissionDenied)
            .collect();
        assert!(
            !denied.is_empty(),
            "the closed directory is reported: {:?}",
            plan.unreadable
        );
        assert!(
            denied[0].path.contains("closed"),
            "and named: {}",
            denied[0].path
        );
        // The one case where granting access would change the answer.
        assert!(denied[0].barrier.is_permission());
    }

    #[cfg(unix)]
    #[test]
    fn test11105_a_link_pointing_at_nothing_is_its_own_case() {
        let dir = tempfile::TempDir::new().expect("temp dir");
        fs::write(dir.path().join("real.txt"), "widget\n").expect("text");
        std::os::unix::fs::symlink(dir.path().join("gone.txt"), dir.path().join("dangling"))
            .expect("symlink");

        // Following links is what makes a dangling one an error at all.
        let mut with_links = args(dir.path(), "widget", "gadget");
        with_links.follow_symlinks = true;
        let engine = RenameEngine::new(with_links).expect("engine");
        let (plan, _, _) = engine.plan().expect("a dangling link does not end the refactor");

        assert_eq!(plan.files_edited, 1);
        let broken: Vec<_> = plan
            .unreadable
            .iter()
            .filter(|u| u.barrier == Barrier::BrokenLink)
            .collect();
        assert!(!broken.is_empty(), "reported: {:?}", plan.unreadable);
        // Nothing to offer here: more privilege will not make it point at
        // something.
        assert!(!broken[0].barrier.is_permission());
    }

    #[test]
    fn test11106_every_case_says_what_it_is() {
        // The page shows this text. A case with nothing to say would leave a
        // reader with a path and no idea what to do about it.
        for barrier in [
            Barrier::PermissionDenied,
            Barrier::BrokenLink,
            Barrier::LinkLoop,
            Barrier::Vanished,
            Barrier::NotText,
            Barrier::Other,
        ] {
            assert!(!barrier.describe().is_empty(), "{barrier:?}");
        }
        // Exactly one is worth offering to retry.
        assert!(Barrier::PermissionDenied.is_permission());
        for other in [
            Barrier::BrokenLink,
            Barrier::LinkLoop,
            Barrier::Vanished,
            Barrier::NotText,
            Barrier::Other,
        ] {
            assert!(!other.is_permission(), "{other:?}");
        }
    }
}