use std::path::Path;
use anyhow::{bail, Context, Result};
use rusqlite::{params, Transaction};
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(())
}
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()))
}
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();
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}"));
}
}
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)
}
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])?)
}
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)
}
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());
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
}
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;