wsb 0.248.6265

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A refactor as a reviewable plan: what it WOULD do, before it does it.
//!
//! The command line asks a yes/no question at a terminal after printing the
//! first five diffs. That is not reviewable when a rename touches four
//! thousand files, and it is not something a page can render at all. A plan is
//! the same work split in two — everything computed and nothing written, then
//! carried out unchanged on the reader's word.
//!
//! The plan is the unit of agreement. Applying re-checks that the tree still
//! matches what was reviewed and refuses if it does not, because a plan
//! approved against one tree says nothing about a different one.

use crate::prelude::*;
use crate::{ItemType, RenameItem};
use serde::{Deserialize, Serialize};

/// One thing a refactor would do.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Change {
    /// A file's contents would be rewritten.
    Content {
        /// Relative to the plan's root, which is what a reader recognises.
        path: String,
        /// How many occurrences would be replaced.
        replacements: usize,
    },
    /// A file would be renamed.
    RenameFile { path: String, to: String },
    /// A directory would be renamed, taking everything under it.
    RenameDirectory { path: String, to: String },
}

impl Change {
    /// The path the change is about, for sorting and for finding it again.
    pub fn path(&self) -> &str {
        match self {
            Change::Content { path, .. }
            | Change::RenameFile { path, .. }
            | Change::RenameDirectory { path, .. } => path,
        }
    }
}

/// Why a path could not be examined.
///
/// Kept apart because the ANSWER differs per case, and a single "could not
/// read" leaves a reader with nothing to do. A permission problem can be
/// granted; a symlink pointing at nothing never will be, and neither will a
/// binary. Telling them apart is what lets the page offer the right thing
/// rather than one useless message for all of them.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Barrier {
    /// This process may not read it. Access could be granted.
    PermissionDenied,
    /// A symlink pointing at nothing. Following it is the only way to notice.
    BrokenLink,
    /// A symlink loop: following it would never end.
    LinkLoop,
    /// It went away between being listed and being read.
    Vanished,
    /// Its bytes are not text in any encoding this can decode, so there is no
    /// content to search. Renaming it is still possible.
    NotText,
    /// Something else. The reason carries what the operating system said.
    Other,
}

impl Barrier {
    /// Whether granting access could change the answer.
    ///
    /// The only case worth offering to retry. Re-reading a broken symlink or a
    /// binary with more privilege produces the same result and wastes the
    /// reader's time asking.
    pub fn is_permission(self) -> bool {
        matches!(self, Barrier::PermissionDenied)
    }

    /// What this case is, in a few words.
    pub fn describe(self) -> &'static str {
        match self {
            Barrier::PermissionDenied => "not permitted to read",
            Barrier::BrokenLink => "a link pointing at nothing",
            Barrier::LinkLoop => "a link that points into itself",
            Barrier::Vanished => "went away while the tree was being read",
            Barrier::NotText => "not text in any encoding, so it was not searched",
            Barrier::Other => "could not be read",
        }
    }
}

/// A path the walk could not examine.
///
/// Reported rather than dropped. A refactor that quietly skipped what it could
/// not read would tell a reader every occurrence was replaced while a
/// directory it never opened still held the old name.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Unreadable {
    pub path: String,
    pub barrier: Barrier,
    /// What the operating system actually said.
    pub reason: String,
}

/// Classify a walk error by what the operating system reported.
///
/// From the `io::ErrorKind`, not from the text: messages are localised and
/// reworded between releases, and matching on them is how a classifier
/// silently stops recognising the case it was written for.
pub fn barrier_of(error: &walkdir::Error) -> Barrier {
    if error.loop_ancestor().is_some() {
        return Barrier::LinkLoop;
    }
    match error.io_error().map(std::io::Error::kind) {
        Some(std::io::ErrorKind::PermissionDenied) => Barrier::PermissionDenied,
        Some(std::io::ErrorKind::NotFound) => Barrier::BrokenLink,
        None => Barrier::Other,
        Some(_) => Barrier::Other,
    }
}

/// What a refactor would do, and what it would cost.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
    /// The root the paths are relative to.
    pub root: PathBuf,
    /// Every change, in a stable order so a page can index into it.
    pub changes: Vec<Change>,
    pub files_renamed: usize,
    pub directories_renamed: usize,
    pub files_edited: usize,
    /// Total occurrences that would be replaced across every file.
    pub replacements: usize,
    /// What the walk could not examine, and why.
    pub unreadable: Vec<Unreadable>,
}

impl Plan {
    pub fn total(&self) -> usize {
        self.changes.len()
    }

    /// A window of the changes, for a reader who scrolls rather than one who
    /// receives four thousand rows at once.
    ///
    /// An offset past the end is an empty page, not an error: a reader who
    /// scrolls to the bottom asks for one more page than exists, every time.
    pub fn page(&self, offset: usize, limit: usize) -> &[Change] {
        if offset >= self.changes.len() {
            return &[];
        }
        let end = offset.saturating_add(limit).min(self.changes.len());
        &self.changes[offset..end]
    }
}

/// Build the change list from what discovery found.
///
/// Ordering is deliberate and stable: content edits first, then renames
/// deepest-first exactly as they will be carried out. A page that renders
/// rows by index needs the same answer every time it asks.
pub fn changes_of(
    root: &Path,
    content_files: &[PathBuf],
    counts: &[usize],
    rename_items: &[RenameItem],
) -> Vec<Change> {
    let mut changes = Vec::with_capacity(content_files.len() + rename_items.len());
    for (file, replacements) in content_files.iter().zip(counts.iter()) {
        changes.push(Change::Content {
            path: relative(root, file),
            replacements: *replacements,
        });
    }
    for item in rename_items {
        let path = relative(root, &item.original_path);
        let to = relative(root, &item.new_path);
        changes.push(match item.item_type {
            ItemType::Directory => Change::RenameDirectory { path, to },
            ItemType::File => Change::RenameFile { path, to },
        });
    }
    changes
}

/// How many unchanged lines are shown either side of a change.
pub const CONTEXT: usize = 2;

/// One line of a file, as the preview shows it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DiffLine {
    /// Unchanged, shown for context.
    Context { number: usize, text: String },
    /// The line as it is now.
    Removed { number: usize, text: String },
    /// The line as it would become.
    Added { number: usize, text: String },
}

/// A run of changed lines with the unchanged ones around it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Hunk {
    /// 1-based line number the hunk starts at.
    pub start: usize,
    pub lines: Vec<DiffLine>,
}

/// What one file's content change looks like, line by line.
///
/// No diff algorithm: a replacement rewrites a line in place and never moves
/// one, so line N before is line N after. Matching them up is the whole of it,
/// and an LCS would spend real time rediscovering that nothing moved.
///
/// A substitute containing a newline does split one line into several. The
/// pair is still shown against the line it came from, which is where a reader
/// looks for it — the numbers after it belong to the file as it is NOW, which
/// is the file they are deciding about.
pub fn hunks_of(original: &str, pairs: &[(String, String)]) -> Vec<Hunk> {
    let mut changed: Vec<(usize, String, String)> = Vec::new();
    for (index, line) in original.lines().enumerate() {
        let mut replaced = line.to_string();
        for (pattern, substitute) in pairs {
            if !pattern.is_empty() && replaced.contains(pattern.as_str()) {
                replaced = replaced.replace(pattern.as_str(), substitute);
            }
        }
        if replaced != line {
            changed.push((index + 1, line.to_string(), replaced));
        }
    }
    if changed.is_empty() {
        return Vec::new();
    }

    let lines: Vec<&str> = original.lines().collect();
    let mut hunks: Vec<Hunk> = Vec::new();
    for (number, before, after) in changed {
        let first = number.saturating_sub(CONTEXT).max(1);
        // Extend the previous hunk when their context would overlap or touch,
        // rather than printing the same lines twice under two headings.
        let extend = hunks
            .last()
            .is_some_and(|last| last_line(last) + 1 >= first);
        if !extend {
            hunks.push(Hunk {
                start: first,
                lines: Vec::new(),
            });
        }
        let hunk = hunks.last_mut().expect("just pushed or extended");
        let from = if hunk.lines.is_empty() {
            first
        } else {
            last_line(hunk) + 1
        };
        for n in from..number {
            hunk.lines.push(DiffLine::Context {
                number: n,
                text: lines[n - 1].to_string(),
            });
        }
        hunk.lines.push(DiffLine::Removed {
            number,
            text: before,
        });
        hunk.lines.push(DiffLine::Added {
            number,
            text: after,
        });
        let trailing = (number + 1..=(number + CONTEXT).min(lines.len())).collect::<Vec<_>>();
        for n in trailing {
            hunk.lines.push(DiffLine::Context {
                number: n,
                text: lines[n - 1].to_string(),
            });
        }
    }
    hunks
}

/// The highest line number a hunk has shown so far.
fn last_line(hunk: &Hunk) -> usize {
    hunk.lines
        .last()
        .map(|line| match line {
            DiffLine::Context { number, .. }
            | DiffLine::Removed { number, .. }
            | DiffLine::Added { number, .. } => *number,
        })
        .unwrap_or(hunk.start)
}

/// A path as the reader knows it: relative to the root they chose.
fn relative(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .into_owned()
}

#[cfg(test)]
#[path = "preview_tests.rs"]
mod preview_tests;