wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Refactoring as a reviewed change, not a yes/no question at a terminal.
//!
//! A rename across a workspace touches thousands of files, and the command
//! line's answer to that is to print the first five diffs and ask. This serves
//! the whole plan instead: computed once, held while it is read, applied only
//! on the reader's word — and refused if the tree moved in between.
//!
//! Plans are held in memory and keyed by id. They are cheap to recompute and
//! worthless once the tree changes, so nothing is persisted: a daemon restart
//! correctly loses every plan rather than offering one nobody can trust.

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;

/// How many changes a page returns when the caller does not say.
const DEFAULT_LIMIT: usize = 200;

/// The most a single page will return however large a limit is asked for.
///
/// The list is read by scrolling, so a page only has to be bigger than a
/// screen. Without a ceiling one request could ask for four thousand rows and
/// undo the reason the endpoint is paginated.
const MAX_LIMIT: usize = 1000;

/// Plans being reviewed, by id.
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()))
}

/// A plan and the request that produced it.
#[derive(Debug)]
struct Held {
    plan: Plan,
    request: RefactorRequest,
}

/// Every option the command line takes, as the page sends them.
///
/// Named as the flags are, so what a reader ticks and what a reader would type
/// are the same thing under two spellings rather than two features.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RefactorRequest {
    /// Directory to refactor, absolute or relative to the workspace.
    pub root: String,
    /// What to replace, in order. Applied one after another to the same text,
    /// exactly as a patterns file is — so a later pair sees what the earlier
    /// ones produced, and the count a reader is shown is what will happen.
    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>,
}

/// One find-and-replace.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PatternPairRequest {
    pub pattern: String,
    pub substitute: String,
}

impl RefactorRequest {
    /// The pairs, as the engine takes them.
    fn pairs(&self) -> Vec<PatternPair> {
        self.pairs
            .iter()
            .map(|pair| PatternPair {
                pattern: pair.pattern.clone(),
                substitute: pair.substitute.clone(),
            })
            .collect()
    }

    /// The command-line arguments this request is.
    ///
    /// Built through the same `Args` the CLI parses into, so the page cannot
    /// express a combination the command line would refuse — `validate` is the
    /// one place those rules live, and it runs for both.
    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,
            // The first pair also goes in the positional slots, which is what
            // `Args::validate` inspects. Every pair is checked again by the
            // engine, so the ones beyond the first are not taken on trust.
            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(),
            // JSON and never a progress bar: nothing here writes to a
            // terminal, and a progress tracker would draw to the daemon's log.
            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)
    }
}

/// What a plan says, without its changes.
#[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,
    /// What the walk could not examine, grouped by why.
    ///
    /// Sent with the summary rather than buried in the change pages: it is
    /// the part a reader has to decide about BEFORE agreeing to anything, and
    /// a page that scrolled to it would never show it at all.
    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() })),
    )
}

/// Compute what a refactor would do.
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();

    // Off the runtime: this walks a directory tree and reads every candidate
    // file, which is not something to do on a thread that is also serving
    // every other request.
    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>,
}

/// One window of a plan's changes.
#[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 {
    /// The change to show, exactly as the plan listed it.
    pub path: String,
    /// Unchanged lines either side of each change.
    pub context: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct FileDiff {
    pub path: String,
    pub hunks: Vec<wsb::refactor::textdiff::Hunk>,
}

/// The most context a caller may ask for.
///
/// Context is what makes a hunk readable; enough of it turns the diff back
/// into the whole file, one request per changed file.
const MAX_CONTEXT: usize = 25;

/// One file's change, line by line.
///
/// Computed on demand rather than with the plan: a rename across a workspace
/// touches thousands of files, and diffing all of them to show the two a
/// reader opens would make the preview itself the slow part.
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)?;

    // The path must be one this plan actually listed as a content change. A
    // path from a browser is otherwise a request to read any file on the
    // machine, and "it is under the root" would still cover the whole tree.
    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 }))
}

/// Carry out a reviewed plan.
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(()) => {
            // The plan described a tree that no longer exists, because it was
            // just carried out. Keeping it would offer a second apply that
            // could only be refused.
            plans().lock().expect("plans lock").remove(&id);
            Ok(Json(serde_json::json!({
                "applied": true,
                "changes": held.plan.total(),
            })))
        }
        // A refusal is the plan being stale, which is a CONFLICT rather than a
        // bad request: the caller did nothing wrong and re-previewing fixes it.
        Err(error) => Err((
            StatusCode::CONFLICT,
            Json(serde_json::json!({ "error": error })),
        )),
    }
}

/// Discard a plan the reader decided against.
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;