wsb 0.291.8591

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A project renamed: what wsb keeps under the project's name follows it.
//!
//! wsb keeps a worktree's local state by its project's name
//! ([`crate::local_state`]): the folder `$PRJ/.state/workspaces/<project>/`,
//! and the database rows keyed `<project>/<worktree>` or `<project>:<worktree>`.
//! `prj rename-project` moves the project's folder and the agents' records;
//! this moves the rest, so the worktrees find what they had under the new
//! name and nothing is left under the old one, and reconnects every
//! worktree's submodules with main's repositories at their new paths.
//!
//! Run after the folder has moved. Every step can run again: a rename that
//! stopped part way is finished by running it once more.

use std::path::Path;

use anyhow::{bail, Context, Result};
use rusqlite::{params, Transaction};

/// Move everything wsb keeps under `old` to `new`, and say what was moved.
///
/// Refused while a daemon runs, which holds the rows, and while anything is
/// at work in one of the old name's worktrees.
pub fn rename(workshop: &wsb_prj::Workshop, old: &str, new: &str) -> Result<Vec<String>> {
    for name in [old, new] {
        if !wsb_prj::is_valid_name(name) {
            bail!("'{name}' is not a project name");
        }
    }
    if old == new {
        bail!("the old and new names are the same");
    }
    if workshop.workspace(old).is_ok() {
        bail!(
            "{} is still a project; move its folder to {} first (`prj rename-project {old} {new}` does)",
            workshop.root().join(old).display(),
            workshop.root().join(new).display()
        );
    }
    let project = workshop.workspace(new).with_context(|| format!("renaming {old} to {new}"))?;
    let host_dir = crate::daemon::host_dir().map_err(anyhow::Error::msg)?;
    crate::migrate::refuse_while_a_daemon_runs_for(&host_dir, &format!("wsb project rename {old} {new}"))?;

    let from = workshop.state_dir().join(wsb_prj::WORKSPACES_DIR).join(old);
    refuse_while_at_work(&from)?;

    let mut done = Vec::new();
    if from.exists() {
        merge_into(&from, &project.state_dir())?;
        done.push(format!("moved {} to {}", from.display(), project.state_dir().display()));
    }
    done.extend(rekey_rows(old, new, workshop.root())?);
    done.extend(crate::worktree::repair_links(&project).map_err(anyhow::Error::msg)?);
    if done.is_empty() {
        done.push(format!("nothing is kept under {old}"));
    }
    Ok(done)
}

fn refuse_while_at_work(state: &Path) -> Result<()> {
    let worktrees = state.join(wsb_prj::WORKTREES_DIR);
    let entries = match std::fs::read_dir(&worktrees) {
        Ok(entries) => entries,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e).with_context(|| format!("reading {}", worktrees.display())),
    };
    for entry in entries {
        let dir = entry.with_context(|| format!("reading {}", worktrees.display()))?.path();
        if let Some(holder) = crate::worktree_lock::holders_in(&dir).map_err(anyhow::Error::msg)?.first() {
            bail!(
                "{} (pid {} on {}) is at work in {}; let it finish and run this again",
                holder.command,
                holder.pid,
                holder.host,
                dir.display()
            );
        }
    }
    Ok(())
}

/// Move `from` into `to`: whole when `to` is not there, entry by entry when
/// it is (something ran under the new name before this did). A file both
/// hold is refused, naming it, and nothing more is moved.
fn merge_into(from: &Path, to: &Path) -> Result<()> {
    if !to.exists() {
        if let Some(parent) = to.parent() {
            std::fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
        }
        return std::fs::rename(from, to).with_context(|| format!("moving {} to {}", from.display(), to.display()));
    }
    if !(from.is_dir() && to.is_dir()) {
        bail!("{} and {} are both there; keep one, remove the other, and run this again", from.display(), to.display());
    }
    for entry in std::fs::read_dir(from).with_context(|| format!("reading {}", from.display()))? {
        let name = entry.with_context(|| format!("reading {}", from.display()))?.file_name();
        merge_into(&from.join(&name), &to.join(&name))?;
    }
    std::fs::remove_dir(from).with_context(|| format!("removing {}", from.display()))
}

/// Re-key every row named for the old project, in one transaction. A row the
/// new name already has gives way to the old name's: it can only have been
/// made after the folder moved, starting from nothing.
fn rekey_rows(old: &str, new: &str, root: &Path) -> Result<Vec<String>> {
    let store = crate::store::open()?;
    let mut connection = store.get_connection()?;
    let tx = connection.transaction()?;
    let mut done = Vec::new();

    // `<project>/<worktree>`: the test catalogue and each worktree's open folders.
    let (old_key, new_key) = (format!("{old}/"), format!("{new}/"));
    for table in ["tests", "workbench_state"] {
        let renamed = rekey_prefix(&tx, table, "worktree", &old_key, &new_key)?;
        if renamed > 0 {
            done.push(format!("renamed {renamed} row(s) of {table}"));
        }
    }

    // `<project>:<worktree>`, with the project and the folder beside it.
    let (old_id, new_id) = (format!("{old}:"), format!("{new}:"));
    tx.execute(
        "DELETE FROM worktrees WHERE worktree_id IN (
             SELECT ?2 || substr(worktree_id, length(?1) + 1) FROM worktrees WHERE substr(worktree_id, 1, length(?1)) = ?1)",
        params![old_id, new_id],
    )?;
    let (old_dir, new_dir) = (folder_prefix(root, old), folder_prefix(root, new));
    let worktrees = tx.execute(
        "UPDATE worktrees SET
             worktree_id = ?2 || substr(worktree_id, length(?1) + 1),
             super_root = CASE WHEN substr(super_root, 1, length(?3)) = ?3
                               THEN ?4 || substr(super_root, length(?3) + 1) ELSE super_root END
         WHERE substr(worktree_id, 1, length(?1)) = ?1",
        params![old_id, new_id, old_dir, new_dir],
    )?;
    tx.execute("UPDATE worktrees SET project = ?2 WHERE project = ?1", params![old, new])?;
    if worktrees > 0 {
        done.push(format!("renamed {worktrees} worktree(s)"));
    }

    let sessions = tx.execute(
        "UPDATE sessions SET
             project = ?2,
             cwd = CASE WHEN substr(cwd, 1, length(?3)) = ?3 THEN ?4 || substr(cwd, length(?3) + 1) ELSE cwd END
         WHERE project = ?1",
        params![old, new, old_dir, new_dir],
    )?;
    if sessions > 0 {
        done.push(format!("filed {sessions} session(s) under {new}"));
    }

    if rename_in_layout(&tx, old, new)? {
        done.push("kept the workbench's tabs of its worktrees open".to_string());
    }
    tx.commit()?;
    Ok(done)
}

/// `$PRJ/<project>/`, as a prefix of the paths kept for its worktrees.
fn folder_prefix(root: &Path, project: &str) -> String {
    format!("{}{}", root.join(project).to_string_lossy(), std::path::MAIN_SEPARATOR)
}

fn rekey_prefix(tx: &Transaction, table: &str, column: &str, old: &str, new: &str) -> Result<usize> {
    let moved = format!("?2 || substr({column}, length(?1) + 1)");
    let under_old = format!("substr({column}, 1, length(?1)) = ?1");
    tx.execute(
        &format!("DELETE FROM {table} WHERE {column} IN (SELECT {moved} FROM {table} WHERE {under_old})"),
        params![old, new],
    )?;
    Ok(tx.execute(&format!("UPDATE {table} SET {column} = {moved} WHERE {under_old}"), params![old, new])?)
}

/// The workshop's workbench layout: its tabs name worktrees by id and projects
/// by name, and a tab naming a worktree that is not there is dropped when the
/// layout is read back. Whether anything was renamed.
fn rename_in_layout(tx: &Transaction, old: &str, new: &str) -> Result<bool> {
    let saved: Option<String> = match tx.query_row("SELECT layout_json FROM workbench_layout WHERE id = 1", [], |row| row.get(0)) {
        Ok(text) => Some(text),
        Err(rusqlite::Error::QueryReturnedNoRows) => None,
        Err(e) => return Err(e.into()),
    };
    let Some(saved) = saved else {
        return Ok(false);
    };
    let mut layout: serde_json::Value = serde_json::from_str(&saved).context("reading the workbench layout")?;
    if !rename_tabs(&mut layout, old, new) {
        return Ok(false);
    }
    tx.execute(
        "UPDATE workbench_layout SET layout_json = ?1, updated_at = datetime('now') WHERE id = 1",
        params![layout.to_string()],
    )?;
    Ok(true)
}

/// Rename the project in every tab of a layout: the worktree or project its
/// input names, the key made from it, and the group's active tab when it is
/// that one. Whether any tab changed.
pub(crate) fn rename_tabs(layout: &mut serde_json::Value, old: &str, new: &str) -> bool {
    let Some(groups) = layout.pointer_mut("/workbench/groups").and_then(|groups| groups.as_array_mut()) else {
        return false;
    };
    let mut changed = false;
    for group in groups {
        let mut keys = Vec::new();
        for tab in group.get_mut("tabs").and_then(|tabs| tabs.as_array_mut()).into_iter().flatten() {
            let Some((field, from, to)) = tab.get("input").and_then(|input| renamed_input(input, old, new)) else {
                continue;
            };
            tab["input"][field] = serde_json::Value::String(to.clone());
            // The key is `<kind>:<worktree or project>`, then whatever follows a `|`.
            if let Some(key) = tab.get("key").and_then(|k| k.as_str()).map(str::to_string) {
                if let Some((kind, rest)) = key.split_once(':') {
                    if rest == from || rest.strip_prefix(from.as_str()).is_some_and(|after| after.starts_with('|')) {
                        let renamed = format!("{kind}:{to}{}", &rest[from.len()..]);
                        tab["key"] = serde_json::Value::String(renamed.clone());
                        keys.push((key, renamed));
                    }
                }
            }
            changed = true;
        }
        if let Some(active) = group.get("active").and_then(|a| a.as_str()) {
            if let Some((_, renamed)) = keys.iter().find(|(key, _)| key == active) {
                group["active"] = serde_json::Value::String(renamed.clone());
            }
        }
    }
    changed
}

/// The field of a tab's input that names the old project, what it says, and
/// what it says once renamed.
fn renamed_input(input: &serde_json::Value, old: &str, new: &str) -> Option<(&'static str, String, String)> {
    if let Some(worktree) = input.get("worktree").and_then(|w| w.as_str()) {
        let name = worktree.strip_prefix(old)?.strip_prefix(':')?;
        return Some(("worktree", worktree.to_string(), format!("{new}:{name}")));
    }
    let is_the_project = input.get("kind").and_then(|k| k.as_str()) == Some("project")
        && input.get("project").and_then(|p| p.as_str()) == Some(old);
    is_the_project.then(|| ("project", old.to_string(), new.to_string()))
}

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