wsb-daemon 0.248.6265

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The plan is what a reader agrees to, so the pieces that decide what they
//! see — the option translation and the paging bounds — are what matter here.

use super::*;
use std::fs;

fn request(root: &str, pattern: &str, substitute: &str) -> RefactorRequest {
    RefactorRequest {
        root: root.to_string(),
        pairs: vec![PatternPairRequest {
            pattern: pattern.to_string(),
            substitute: substitute.to_string(),
        }],
        files_only: false,
        dirs_only: false,
        names_only: false,
        content_only: false,
        ignore_case: false,
        use_regex: false,
        include_hidden: false,
        binary_names: false,
        follow_symlinks: false,
        backup: false,
        gitignore: false,
        max_depth: 0,
        exclude: Vec::new(),
        include: Vec::new(),
    }
}

#[test]
fn test11078_a_relative_root_is_resolved_against_the_workspace() {
    // The tree sends the path it shows, which is relative to the workspace.
    // Resolving it anywhere else would refactor a directory the reader did
    // not click on.
    let workspace = tempfile::TempDir::new().expect("temp dir");
    fs::create_dir_all(workspace.path().join("cartridges/txtcartridge")).expect("nested");

    let args = request("cartridges/txtcartridge", "a", "b")
        .to_args(workspace.path())
        .expect("valid");
    assert_eq!(
        wsb::platform::canonicalize(&args.root_dir).expect("exists"),
        wsb::platform::canonicalize(workspace.path().join("cartridges/txtcartridge")).expect("exists")
    );

    let elsewhere = tempfile::TempDir::new().expect("temp dir");
    let absolute = request(&elsewhere.path().display().to_string(), "a", "b")
        .to_args(workspace.path())
        .expect("valid");
    assert_eq!(absolute.root_dir, elsewhere.path());
}

#[test]
fn test11079_every_option_reaches_the_engine() {
    // A tick the page offers but never passes on is worse than not offering
    // it: the reader believes they narrowed the run and they did not.
    let dir = tempfile::TempDir::new().expect("temp dir");
    let mut req = request(&dir.path().display().to_string(), "a", "b");
    req.files_only = true;
    req.ignore_case = true;
    req.use_regex = true;
    req.include_hidden = true;
    req.binary_names = true;
    req.follow_symlinks = true;
    req.backup = true;
    req.gitignore = true;
    req.max_depth = 3;
    req.exclude = vec!["*.lock".to_string()];
    req.include = vec!["*.rs".to_string()];

    let args = req.to_args(dir.path()).expect("valid");
    assert!(args.files_only);
    assert!(args.ignore_case);
    assert!(args.use_regex);
    assert!(args.include_hidden);
    assert!(args.binary_names);
    assert!(args.follow_symlinks);
    assert!(args.backup);
    assert!(args.gitignore);
    assert_eq!(args.max_depth, 3);
    assert_eq!(args.exclude_patterns, vec!["*.lock".to_string()]);
    assert_eq!(args.include_patterns, vec!["*.rs".to_string()]);
}

#[test]
fn test11080_a_combination_the_command_line_refuses_is_refused_here() {
    // The page must not be able to express a run the CLI would reject. The
    // rules live in `Args::validate`, and this goes through it.
    let dir = tempfile::TempDir::new().expect("temp dir");
    let mut req = request(&dir.path().display().to_string(), "a", "b");
    req.files_only = true;
    req.dirs_only = true;
    let refused = req.to_args(dir.path()).expect_err("mutually exclusive");
    assert!(
        refused.contains("more than one mode"),
        "the refusal says which rule: {refused}"
    );
}

#[test]
fn test11081_a_page_never_runs_off_the_end_of_a_plan() {
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("one.txt"), "widget\n").expect("file");
    let args = request(&dir.path().display().to_string(), "widget", "gadget")
        .to_args(dir.path())
        .expect("valid");
    let engine = RenameEngine::new(args).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    // Infinite scroll asks for the page after the last one every time it
    // reaches the bottom. That has to be an empty page.
    assert!(plan.page(plan.total(), DEFAULT_LIMIT).is_empty());
    assert!(plan.page(usize::MAX / 2, DEFAULT_LIMIT).is_empty());
}

#[test]
fn test11083_an_unknown_plan_says_why_it_is_gone() {
    // Plans live in memory. A reader coming back to a tab after a daemon
    // restart needs to be told to preview again, not shown an empty list as
    // though the refactor had no changes.
    let (status, body) = held(Uuid::new_v4()).expect_err("no such plan");
    assert_eq!(status, StatusCode::NOT_FOUND);
    let text = body.0.to_string();
    assert!(text.contains("Preview it again"), "actionable: {text}");
}

#[test]
fn test11099_several_pairs_are_carried_and_applied_in_order() {
    // A refactor is rarely one rename. The command line takes a patterns file
    // for this; the page had no way to say it at all, so a rename that needed
    // three replacements was three separate reviews.
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("a.txt"), "alpha beta gamma\n").expect("file");

    let mut req = request(&dir.path().display().to_string(), "alpha", "one");
    req.pairs.push(PatternPairRequest {
        pattern: "beta".to_string(),
        substitute: "two".to_string(),
    });
    req.pairs.push(PatternPairRequest {
        pattern: "gamma".to_string(),
        substitute: "three".to_string(),
    });

    let args = req.to_args(dir.path()).expect("valid");
    let engine = RenameEngine::new_with_pairs(args, req.pairs()).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    // Three occurrences, one per pair, counted as they will actually run.
    assert_eq!(plan.replacements, 3);
    assert_eq!(plan.files_edited, 1);

    engine.apply(&plan).expect("applies");
    assert_eq!(
        fs::read_to_string(dir.path().join("a.txt")).expect("read"),
        "one two three\n"
    );
}

#[test]
fn test11100_a_later_pair_cannot_smuggle_a_path_separator() {
    // Only the FIRST pair reached `Args::validate`, so a substitute that turns
    // a rename into a move went unchecked in every pair after it — and in
    // every pair of a patterns file.
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("widget.txt"), "widget\n").expect("file");

    let mut req = request(&dir.path().display().to_string(), "widget", "gadget");
    req.pairs.push(PatternPairRequest {
        pattern: "gadget".to_string(),
        substitute: "../escaped".to_string(),
    });

    let args = req.to_args(dir.path()).expect("the first pair is fine");
    let refused = match RenameEngine::new_with_pairs(args, req.pairs()) {
        Ok(_) => panic!("a later pair with a separator must be refused"),
        Err(error) => error.to_string(),
    };
    assert!(
        refused.contains("path-unsafe"),
        "the refusal names the rule: {refused}"
    );
}

#[test]
fn test11101_a_refactor_with_no_pairs_is_refused() {
    let dir = tempfile::TempDir::new().expect("temp dir");
    let mut req = request(&dir.path().display().to_string(), "a", "b");
    req.pairs.clear();
    let refused = req.to_args(dir.path()).expect_err("nothing to do");
    assert!(refused.contains("at least one pattern"), "{refused}");
}

#[test]
fn test11131_a_diff_shows_the_change_with_line_numbers_on_both_sides() {
    // A substitute containing a newline shifts every line after it. Both
    // numbers are shown because they differ, and neither is inferred.
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("a.txt"), "one\nTARGET\nthree\n").expect("file");

    let req = request(&dir.path().display().to_string(), "TARGET", "A\nB");
    let args = req.to_args(dir.path()).expect("valid");
    let engine = RenameEngine::new_with_pairs(args, req.pairs()).expect("engine");
    let hunks = engine
        .hunks_for(&dir.path().join("a.txt"), 1)
        .expect("a diff");

    let lines: Vec<String> = hunks
        .iter()
        .flat_map(|h| &h.lines)
        .map(|line| match line {
            wsb::refactor::textdiff::DiffLine::Context { old, new, text, .. } => {
                format!("{old}|{new}| |{text}")
            }
            wsb::refactor::textdiff::DiffLine::Removed { old, text, .. } => format!("{old}|-|-|{text}"),
            wsb::refactor::textdiff::DiffLine::Added { new, text, .. } => format!("-|{new}|+|{text}"),
        })
        .collect();

    assert_eq!(
        lines,
        vec!["1|1| |one", "2|-|-|TARGET", "-|2|+|A", "-|3|+|B", "3|4| |three"]
    );
}

#[test]
fn test11132_a_diff_uses_the_same_rules_the_write_will_use() {
    // The preview and the write are the same compiled rules. A regex that
    // reached names but not contents is exactly the divergence this prevents.
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("a.rs"), "fn build(x)\n").expect("file");

    let mut req = request(&dir.path().display().to_string(), r"fn (\w+)\(", "fn new_$1(");
    req.use_regex = true;
    req.content_only = true;
    let args = req.to_args(dir.path()).expect("valid");
    let engine = RenameEngine::new_with_pairs(args, req.pairs()).expect("engine");

    let hunks = engine.hunks_for(&dir.path().join("a.rs"), 0).expect("a diff");
    let shown = format!("{hunks:?}");
    assert!(shown.contains("fn new_build(x)"), "the diff expands the capture: {shown}");

    // And the write produces exactly that.
    let (plan, _, _) = engine.plan().expect("a plan");
    engine.apply(&plan).expect("applies");
    assert_eq!(
        fs::read_to_string(dir.path().join("a.rs")).expect("read"),
        "fn new_build(x)\n"
    );
}

#[test]
fn test11133_a_file_the_plan_never_listed_cannot_be_read_through_the_diff() {
    // The path comes from a browser. Without checking it against the plan,
    // this endpoint would read any file the daemon can reach.
    let dir = tempfile::TempDir::new().expect("temp dir");
    fs::write(dir.path().join("listed.txt"), "widget\n").expect("file");
    fs::write(dir.path().join("secret.txt"), "nothing to match\n").expect("other");

    let req = request(&dir.path().display().to_string(), "widget", "gadget");
    let args = req.to_args(dir.path()).expect("valid");
    let engine = RenameEngine::new_with_pairs(args, req.pairs()).expect("engine");
    let (plan, _, _) = engine.plan().expect("a plan");

    let listed = |path: &str| {
        plan.changes.iter().any(|change| {
            matches!(change, wsb::refactor::preview::Change::Content { path: p, .. } if p == path)
        })
    };
    assert!(listed("listed.txt"));
    assert!(!listed("secret.txt"), "a file with no change is not listed");
    assert!(!listed("../../etc/passwd"));
}