Skip to main content

strop_engine/editor/changes/
review.rs

1//! Reviewable prepared changes (0049 §8): rename/code-action replies that
2//! touch more than one document — or name refused targets — open a
3//! real-buffer unified-diff review with explicit Apply/Cancel instead of
4//! mutating immediately. Single-document, nothing-refused plans (the
5//! `:format` case) keep applying directly with the existing receipt.
6//!
7//! A proposal is immutable once presented: identity, provenance, the
8//! complete target inventory (planned documents with pinned base revisions
9//! plus every refused target with its reason), and the rendered diff. Apply
10//! re-checks each base revision and refuses moved targets BY NAME — it
11//! never recomputes from newer text. After Apply/Cancel the review buffer
12//! stays around as the receipt: outcomes per target, refusals with reasons.
13//!
14use strop_core::id::DocumentId;
15use strop_core::Buffer;
16
17use super::{ChangePlan, ChangeReceipt, PlannedDocument};
18use crate::editor::transact::ChangeSet;
19use crate::editor::Editor;
20mod render;
21mod save;
22use render::ReviewBuffer;
23
24#[derive(Debug, Clone, Copy)]
25pub enum ReviewRow {
26    Heading,
27    File,
28    Hunk,
29    Context,
30    Removed,
31    Added,
32    Warning,
33}
34
35/// The command names the review buffer advertises. Integration wires these
36/// exact names to `review_apply_pub` / `review_cancel_pub`.
37pub(crate) const APPLY_COMMAND: &str = ":apply-change";
38pub(crate) const CANCEL_COMMAND: &str = ":cancel-change";
39
40/// Unified-diff context lines around each changed span.
41const CONTEXT: usize = 3;
42
43/// Pending-proposal state. One proposal is reviewable at a time; a newer
44/// proposal supersedes the older one's buffer with a visible note.
45#[derive(Default)]
46pub(crate) struct ReviewState {
47    /// Monotone proposal identity; receipt headers name it.
48    seq: usize,
49    /// The proposal awaiting Apply/Cancel, if any.
50    pending: Option<ChangeProposal>,
51    /// A global replace assembling while its unopened targets load
52    /// (0051 §6 R04); the review presents when the last target resolves.
53    pub(crate) replace: Option<PendingReplace>,
54    /// Monotone replace identity: a newer Enter supersedes, and opens
55    /// issued for the older one merge nowhere.
56    replace_seq: usize,
57    pub(crate) replace_context: Option<ReplaceContext>,
58    rows: std::collections::HashMap<DocumentId, Vec<ReviewRow>>,
59    saves: std::collections::HashMap<DocumentId, save::PendingChangeSave>,
60}
61
62impl ReviewState {
63    pub(crate) fn forget(&mut self, document: DocumentId) {
64        self.rows.remove(&document);
65        self.saves.retain(|_, pending| pending.report != document);
66        if self
67            .pending
68            .as_ref()
69            .is_some_and(|proposal| proposal.buffer == document)
70        {
71            self.pending = None;
72            if self.replace.is_none() {
73                self.replace_context = None;
74            }
75        }
76    }
77}
78
79pub(crate) struct ReplaceContext {
80    pub picker: strop_picker::Picker,
81    pub origin: crate::editor::jumps::JumpRecord,
82}
83
84/// A global replace (Space R Enter) assembling its prepared review:
85/// open buffers planned at Enter, unopened targets joining as their
86/// owned open jobs land. Nothing here mutates a buffer.
87#[derive(Debug)]
88pub(crate) struct PendingReplace {
89    /// Generation from `replace_seq`; late opens for a superseded
90    /// replace are ignored.
91    pub id: usize,
92    /// Targets prepared so far, each with its pinned base revision.
93    pub documents: Vec<PlannedDocument>,
94    /// Every target that will not change, named with its reason:
95    /// stale witnesses, read-only buffers, failed opens.
96    pub refused: Vec<(strop_workspace::ResourceLocation, String)>,
97    /// Owned opens still in flight; the review presents at zero.
98    pub pending_opens: usize,
99}
100
101/// An immutable prepared proposal (0049 §8): identity, provenance, the
102/// complete intended target inventory with pinned base revisions, and the
103/// review buffer presenting it. Nothing here recomputes at apply time.
104#[derive(Debug)]
105pub(crate) struct ChangeProposal {
106    /// Proposal number, stable across the review buffer and its receipt.
107    pub id: usize,
108    /// The prepared plan: `documents` carry their base revisions, `refused`
109    /// names every target that will not change, with its reason.
110    pub plan: ChangePlan,
111    /// The real (read-only) buffer showing the diff review; it becomes the
112    /// receipt after Apply/Cancel.
113    pub buffer: DocumentId,
114    pub view_revision: strop_core::id::BufferRevision,
115}
116
117impl Editor {
118    /// Present a prepared plan: apply single-document, nothing-refused
119    /// plans directly (the existing receipt path), otherwise open the
120    /// diff-review buffer and hold the proposal for Apply/Cancel.
121    pub(crate) fn present_change_plan(&mut self, plan: ChangePlan) {
122        if plan.documents.len() <= 1 && plan.refused.is_empty() {
123            self.apply_change_plan(plan);
124            return;
125        }
126        self.review_change_plan(plan);
127    }
128
129    /// Apply the reviewed proposal. Each target's base revision is checked
130    /// first: a source edited since the proposal (or closed) is refused by
131    /// name — never recomputed from newer text. Applied targets go through
132    /// the revision-checked gateway exactly as prepared. The review buffer
133    /// becomes the receipt and stays open; the receipt is recorded for
134    /// grouped undo (`:undo-change`).
135    pub(crate) fn review_apply_pub(&mut self) {
136        let Some(proposal) = self.review.pending.take() else {
137            self.message = "no change proposal awaiting review".into();
138            return;
139        };
140        if self
141            .docs
142            .get(proposal.buffer)
143            .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
144        {
145            self.review.pending = Some(proposal);
146            self.message = "review changed; prepare a new proposal before applying".into();
147            return;
148        }
149        self.review.replace_context = None;
150        let producer = proposal.plan.producer.label().to_string();
151        let mut receipt = ChangeReceipt {
152            producer: producer.clone(),
153            applied: Vec::new(),
154            applied_positions: Vec::new(),
155            refused: proposal.plan.refused.clone(),
156            redo_positions: None,
157        };
158        let mut lines: Vec<String> = proposal
159            .plan
160            .refused
161            .iter()
162            .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
163            .collect();
164        for target in proposal.plan.documents {
165            let label = target.location.label();
166            let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
167            match current {
168                None => {
169                    let reason = "document closed since the proposal".to_string();
170                    receipt.refused.push((target.location, reason.clone()));
171                    lines.push(format!("refused: {label} — {reason}"));
172                }
173                Some(revision) if revision != target.base => {
174                    let reason = format!(
175                        "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
176                        target.base
177                    );
178                    receipt.refused.push((target.location, reason.clone()));
179                    lines.push(format!("refused: {label} — {reason}"));
180                }
181                Some(_) => {
182                    let changes = ChangeSet {
183                        edits: target.edits,
184                        undo_open: false,
185                    };
186                    match self.apply(target.document, target.base, changes) {
187                        Ok(committed) => {
188                            receipt.applied.push((
189                                target.document,
190                                target.base,
191                                committed.revision,
192                            ));
193                            if let Some(position) = self
194                                .docs
195                                .get(target.document)
196                                .and_then(|doc| doc.buf.history().committed_position())
197                            {
198                                receipt.applied_positions.push(position);
199                            }
200                            lines.push(format!(
201                                "applied: {label} (revision {} -> {})",
202                                target.base, committed.revision
203                            ));
204                        }
205                        Err(error) => {
206                            let reason = format!("changed since plan: {error}");
207                            receipt.refused.push((target.location, reason.clone()));
208                            lines.push(format!("refused: {label} — {reason}"));
209                        }
210                    }
211                }
212            }
213        }
214        let status = if receipt.refused.is_empty() {
215            "APPLIED"
216        } else if receipt.applied.is_empty() {
217            "REFUSED"
218        } else {
219            "PARTIAL"
220        };
221        let mut text = format!(
222            "strop change proposal {}: {producer} — {status}\n{} buffer(s) applied, {} target(s) refused\n:undo-change reverts the applied group; :save-change saves changed files\n\n",
223            proposal.id,
224            receipt.applied.len(),
225            receipt.refused.len()
226        );
227        for line in &lines {
228            text.push_str(line);
229            text.push('\n');
230        }
231        let publish = self.replace_system(proposal.buffer, &text);
232        self.review.rows.insert(
233            proposal.buffer,
234            text.lines()
235                .enumerate()
236                .map(|(line, _)| {
237                    if line == 0 && !receipt.refused.is_empty() {
238                        ReviewRow::Warning
239                    } else if line == 0 {
240                        ReviewRow::Heading
241                    } else {
242                        ReviewRow::Context
243                    }
244                })
245                .collect(),
246        );
247        if self.current() == proposal.buffer {
248            self.set_head(0);
249            self.view_mut().view_top = 0;
250        }
251        self.message = match (receipt.applied.len(), receipt.refused.len()) {
252            (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
253            (applied, refused) => {
254                format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
255            }
256        };
257        if let Err(error) = publish {
258            self.message = format!("change receipt publish failed: {error}");
259        }
260        self.changes.record(receipt);
261    }
262
263    /// Cancel the reviewed proposal: nothing is applied, ever. The review
264    /// buffer becomes a cancelled receipt and stays open.
265    pub(crate) fn review_cancel_pub(&mut self) {
266        if self.review.replace.take().is_some() {
267            self.restore_replace_context();
268            self.message = "replace cancelled while loading; nothing applied".into();
269            return;
270        }
271        let Some(proposal) = self.review.pending.take() else {
272            self.message = "no change proposal awaiting review".into();
273            return;
274        };
275        let producer = proposal.plan.producer.label();
276        let mut text = format!(
277            "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
278            proposal.id,
279            proposal.plan.documents.len()
280        );
281        if !proposal.plan.refused.is_empty() {
282            text.push_str("\nrefused targets (would have been skipped):\n");
283            for (location, reason) in &proposal.plan.refused {
284                text.push_str(&format!("  {} — {reason}\n", location.label()));
285            }
286        }
287        let publish = self.replace_system(proposal.buffer, &text);
288        self.review.rows.insert(
289            proposal.buffer,
290            text.lines()
291                .enumerate()
292                .map(|(line, _)| {
293                    if line == 0 {
294                        ReviewRow::Heading
295                    } else {
296                        ReviewRow::Context
297                    }
298                })
299                .collect(),
300        );
301        if self.current() == proposal.buffer {
302            self.set_head(0);
303            self.view_mut().view_top = 0;
304        }
305        self.message = format!(
306            "{producer}: proposal {} cancelled — nothing applied",
307            proposal.id
308        );
309        if let Err(error) = publish {
310            self.message = format!("change receipt publish failed: {error}");
311        }
312        self.restore_replace_context();
313    }
314
315    /// Render the review buffer: header, per-file unified diffs computed
316    /// from each target's pinned base, and every refused target named with
317    /// its reason.
318    fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
319        let producer = plan.producer.label();
320        let mut view = ReviewBuffer::default();
321        view.line(
322            &format!("strop change proposal {id}: {producer}"),
323            ReviewRow::Heading,
324        );
325        view.line(
326            &format!(
327                "{} file(s) to change, {} target(s) refused",
328                plan.documents.len(),
329                plan.refused.len()
330            ),
331            ReviewRow::Context,
332        );
333        view.line(
334            &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
335            ReviewRow::Context,
336        );
337        view.line(
338            "bases are pinned — editing a source invalidates that file at apply",
339            ReviewRow::Context,
340        );
341        for target in &plan.documents {
342            view.line("", ReviewRow::Context);
343            let label = match target.location.filesystem {
344                strop_workspace::Filesystem::Local => target
345                    .location
346                    .path
347                    .strip_prefix(&self.cwd)
348                    .unwrap_or(&target.location.path)
349                    .display()
350                    .to_string(),
351                _ => target.location.label(),
352            };
353            match self.docs.get(target.document) {
354                Some(document) => {
355                    match render::file_diff(&label, &document.buf, target.base, &target.edits) {
356                        Ok(diff) => view.append(diff),
357                        Err(error) => {
358                            view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
359                        }
360                    }
361                }
362                None => view.line(
363                    &format!("refused: {label} — document closed"),
364                    ReviewRow::Warning,
365                ),
366            }
367        }
368        if !plan.refused.is_empty() {
369            view.line("", ReviewRow::Context);
370            view.line("refused targets:", ReviewRow::Warning);
371            for (location, reason) in &plan.refused {
372                view.line(
373                    &format!("  {} — {reason}", location.label()),
374                    ReviewRow::Warning,
375                );
376            }
377        }
378        view
379    }
380}
381impl Editor {
382    /// Begin a replace review assembly (0051 §6 R04); returns the
383    /// generation the owned opens carry. A newer call supersedes the
384    /// old assembly — its late opens merge nowhere.
385    pub(crate) fn begin_replace(
386        &mut self,
387        documents: Vec<PlannedDocument>,
388        refused: Vec<(strop_workspace::ResourceLocation, String)>,
389        pending_opens: usize,
390    ) -> usize {
391        self.review.replace_seq += 1;
392        let id = self.review.replace_seq;
393        self.review.replace = Some(PendingReplace {
394            id,
395            documents,
396            refused,
397            pending_opens,
398        });
399        id
400    }
401
402    /// Present a prepared plan as a review ALWAYS (0051 §6 R04): a
403    /// global replace never applies straight from the picker's text
404    /// fields, even when the plan is a single file. Same review shape
405    /// as `present_change_plan` — kept separate so that method's
406    /// direct-apply fast path stays untouched for LSP producers.
407    pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
408        if let Some(old) = self.review.pending.take() {
409            let note = format!(
410                "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
411                old.id,
412                old.plan.producer.label()
413            );
414            if let Err(error) = self.replace_system(old.buffer, &note) {
415                self.message = format!("could not retire proposal {}: {error}", old.id);
416            }
417            self.review
418                .rows
419                .insert(old.buffer, vec![ReviewRow::Heading]);
420        }
421        self.review.seq += 1;
422        let id = self.review.seq;
423        let text = self.render_proposal(id, &plan);
424        let producer = plan.producer.label().to_string();
425        let files = plan.documents.len();
426        let refused = plan.refused.len();
427        let mut buf = Buffer::from_text(&text.text);
428        buf.name = Some(format!("change proposal {id}"));
429        let view_revision = buf.revision();
430        let buffer = self.open_temporary_output(buf);
431        self.review.rows.insert(buffer, text.rows);
432        self.review.pending = Some(ChangeProposal {
433            id,
434            plan,
435            buffer,
436            view_revision,
437        });
438        self.message = match refused {
439            0 => format!(
440                "{producer}: proposal {id} reviews {files} file(s) — {APPLY_COMMAND} or {CANCEL_COMMAND}"
441            ),
442            _ => format!(
443                "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {APPLY_COMMAND} or {CANCEL_COMMAND}"
444            ),
445        };
446    }
447}
448
449#[cfg(test)]
450mod tests;
451
452impl Editor {
453    pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
454        self.review.rows.get(&document)?.get(row).copied()
455    }
456}