use axum::{
extract::{Path as AxumPath, Query, State},
http::StatusCode,
Json,
};
use wsb::prelude::*;
use wsb::refactor::cli::{Args, OutputFormat, ProgressMode};
use wsb::refactor::patterns_file::PatternPair;
use wsb::refactor::preview::Plan;
use wsb::refactor::rename_engine::RenameEngine;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use uuid::Uuid;
const DEFAULT_LIMIT: usize = 200;
const MAX_LIMIT: usize = 1000;
fn plans() -> &'static Mutex<HashMap<Uuid, Arc<Held>>> {
static PLANS: OnceLock<Mutex<HashMap<Uuid, Arc<Held>>>> = OnceLock::new();
PLANS.get_or_init(|| Mutex::new(HashMap::new()))
}
#[derive(Debug)]
struct Held {
plan: Plan,
request: RefactorRequest,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RefactorRequest {
pub root: String,
pub pairs: Vec<PatternPairRequest>,
#[serde(default)]
pub files_only: bool,
#[serde(default)]
pub dirs_only: bool,
#[serde(default)]
pub names_only: bool,
#[serde(default)]
pub content_only: bool,
#[serde(default)]
pub ignore_case: bool,
#[serde(default)]
pub use_regex: bool,
#[serde(default)]
pub include_hidden: bool,
#[serde(default)]
pub binary_names: bool,
#[serde(default)]
pub follow_symlinks: bool,
#[serde(default)]
pub backup: bool,
#[serde(default)]
pub gitignore: bool,
#[serde(default)]
pub max_depth: usize,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PatternPairRequest {
pub pattern: String,
pub substitute: String,
}
impl RefactorRequest {
fn pairs(&self) -> Vec<PatternPair> {
self.pairs
.iter()
.map(|pair| PatternPair {
pattern: pair.pattern.clone(),
substitute: pair.substitute.clone(),
})
.collect()
}
fn to_args(&self, workspace_root: &Path) -> Result<Args, String> {
let root = Path::new(&self.root);
let root_dir = if root.is_absolute() {
root.to_path_buf()
} else {
workspace_root.join(root)
};
let first = self
.pairs
.first()
.ok_or_else(|| "a refactor needs at least one pattern to replace".to_string())?;
let args = Args {
root_dir,
pattern: Some(first.pattern.clone()),
substitute: Some(first.substitute.clone()),
patterns_file: None,
assume_yes: true,
verbose: false,
follow_symlinks: self.follow_symlinks,
backup: self.backup,
files_only: self.files_only,
dirs_only: self.dirs_only,
names_only: self.names_only,
content_only: self.content_only,
max_depth: self.max_depth,
exclude_patterns: self.exclude.clone(),
include_patterns: self.include.clone(),
format: OutputFormat::Json,
threads: 0,
progress: ProgressMode::Never,
ignore_case: self.ignore_case,
use_regex: self.use_regex,
include_hidden: self.include_hidden,
binary_names: self.binary_names,
gitignore: self.gitignore,
};
args.validate()?;
Ok(args)
}
}
#[derive(Debug, Serialize)]
pub struct PlanSummary {
pub id: Uuid,
pub root: String,
pub total: usize,
pub files_edited: usize,
pub files_renamed: usize,
pub directories_renamed: usize,
pub replacements: usize,
pub unreadable: Vec<wsb::refactor::preview::Unreadable>,
}
fn summary_of(id: Uuid, plan: &Plan) -> PlanSummary {
PlanSummary {
id,
root: plan.root.display().to_string(),
total: plan.total(),
files_edited: plan.files_edited,
files_renamed: plan.files_renamed,
directories_renamed: plan.directories_renamed,
replacements: plan.replacements,
unreadable: plan.unreadable.clone(),
}
}
fn bad_request(error: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error.to_string() })),
)
}
pub async fn preview(
State(state): State<crate::web::state::WebState>,
Json(request): Json<RefactorRequest>,
) -> Result<Json<PlanSummary>, (StatusCode, Json<serde_json::Value>)> {
let args = request.to_args(&state.workspace_root).map_err(bad_request)?;
let pairs = request.pairs();
let planned = tokio::task::spawn_blocking(move || {
let engine =
RenameEngine::new_with_pairs(args, pairs).map_err(|error| error.to_string())?;
engine
.plan()
.map(|(plan, _, _)| plan)
.map_err(|error| error.to_string())
})
.await
.map_err(|error| bad_request(format!("the preview panicked: {error}")))?
.map_err(bad_request)?;
let id = Uuid::new_v4();
let summary = summary_of(id, &planned);
plans().lock().expect("plans lock").insert(
id,
Arc::new(Held {
plan: planned,
request,
}),
);
Ok(Json(summary))
}
#[derive(Debug, Deserialize)]
pub struct PageQuery {
#[serde(default)]
pub offset: usize,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct ChangePage {
pub offset: usize,
pub total: usize,
pub changes: Vec<wsb::refactor::preview::Change>,
}
pub async fn changes(
AxumPath(id): AxumPath<Uuid>,
Query(page): Query<PageQuery>,
) -> Result<Json<ChangePage>, (StatusCode, Json<serde_json::Value>)> {
let held = held(id)?;
let limit = page.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
Ok(Json(ChangePage {
offset: page.offset,
total: held.plan.total(),
changes: held.plan.page(page.offset, limit).to_vec(),
}))
}
#[derive(Debug, Deserialize)]
pub struct DiffQuery {
pub path: String,
pub context: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct FileDiff {
pub path: String,
pub hunks: Vec<wsb::refactor::textdiff::Hunk>,
}
const MAX_CONTEXT: usize = 25;
pub async fn diff(
State(state): State<crate::web::state::WebState>,
AxumPath(id): AxumPath<Uuid>,
Query(query): Query<DiffQuery>,
) -> Result<Json<FileDiff>, (StatusCode, Json<serde_json::Value>)> {
let held = held(id)?;
let listed = held.plan.changes.iter().any(|change| {
matches!(change, wsb::refactor::preview::Change::Content { path, .. } if path == &query.path)
});
if !listed {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("{} is not a content change in this plan", query.path),
})),
));
}
let args = held
.request
.to_args(&state.workspace_root)
.map_err(bad_request)?;
let pairs = held.request.pairs();
let context = query
.context
.unwrap_or(wsb::refactor::textdiff::DEFAULT_CONTEXT)
.min(MAX_CONTEXT);
let file = held.plan.root.join(&query.path);
let path = query.path.clone();
let hunks = tokio::task::spawn_blocking(move || {
let engine =
RenameEngine::new_with_pairs(args, pairs).map_err(|error| error.to_string())?;
engine
.hunks_for(&file, context)
.map_err(|error| error.to_string())
})
.await
.map_err(|error| bad_request(format!("the diff panicked: {error}")))?
.map_err(bad_request)?;
Ok(Json(FileDiff { path, hunks }))
}
pub async fn apply(
State(state): State<crate::web::state::WebState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let held = held(id)?;
let args = held
.request
.to_args(&state.workspace_root)
.map_err(bad_request)?;
let pairs = held.request.pairs();
let reviewed = held.plan.clone();
let applied = tokio::task::spawn_blocking(move || {
let engine =
RenameEngine::new_with_pairs(args, pairs).map_err(|error| error.to_string())?;
engine.apply(&reviewed).map_err(|error| error.to_string())
})
.await
.map_err(|error| bad_request(format!("the refactor panicked: {error}")))?;
match applied {
Ok(()) => {
plans().lock().expect("plans lock").remove(&id);
Ok(Json(serde_json::json!({
"applied": true,
"changes": held.plan.total(),
})))
}
Err(error) => Err((
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": error })),
)),
}
}
pub async fn discard(
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let removed = plans().lock().expect("plans lock").remove(&id).is_some();
Ok(Json(serde_json::json!({ "discarded": removed })))
}
fn held(id: Uuid) -> Result<Arc<Held>, (StatusCode, Json<serde_json::Value>)> {
plans()
.lock()
.expect("plans lock")
.get(&id)
.cloned()
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!(
"no refactor plan {id} — plans do not survive a daemon restart, \
and one that was applied or discarded is gone. Preview it again."
),
})),
)
})
}
#[cfg(test)]
#[path = "refactor_tests.rs"]
mod refactor_tests;