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};
18use crate::editor::transact::ChangeSet;
19use crate::editor::Editor;
20pub(crate) mod prepare;
21mod render;
22mod save;
23use render::ReviewBuffer;
24
25#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
26pub enum ReviewRow {
27    Heading,
28    File,
29    Hunk,
30    Context,
31    Removed,
32    Added,
33    Warning,
34}
35
36/// The command names the review buffer advertises. Integration wires these
37/// exact names to `review_apply_pub` / `review_cancel_pub`.
38pub(crate) const APPLY_COMMAND: &str = ":apply-change";
39pub(crate) const CANCEL_COMMAND: &str = ":cancel-change";
40
41/// Unified-diff context lines around each changed span.
42const CONTEXT: usize = 3;
43
44/// Pending-proposal state. One proposal is reviewable at a time; a newer
45/// proposal supersedes the older one's buffer with a visible note.
46#[derive(Default)]
47pub(crate) struct ReviewState {
48    /// Monotone proposal identity; receipt headers name it.
49    seq: usize,
50    /// The proposal awaiting Apply/Cancel, if any.
51    pending: Option<ChangeProposal>,
52    pub(crate) preparing: Option<prepare::Preparation>,
53    rows: std::collections::HashMap<DocumentId, Vec<ReviewRow>>,
54    saves: std::collections::HashMap<DocumentId, save::PendingChangeSave>,
55}
56
57impl ReviewState {
58    pub(crate) fn forget(&mut self, document: DocumentId) {
59        self.rows.remove(&document);
60        self.saves.retain(|_, pending| pending.report != document);
61        if self
62            .pending
63            .as_ref()
64            .is_some_and(|proposal| proposal.buffer == document)
65        {
66            self.pending = None;
67        }
68    }
69}
70
71/// An immutable prepared proposal (0049 §8): identity, provenance, the
72/// complete intended target inventory with pinned base revisions, and the
73/// review buffer presenting it. Nothing here recomputes at apply time.
74#[derive(Debug)]
75pub(crate) struct ChangeProposal {
76    /// Proposal number, stable across the review buffer and its receipt.
77    pub id: usize,
78    /// The prepared plan: `documents` carry their base revisions, `refused`
79    /// names every target that will not change, with its reason.
80    pub plan: ChangePlan,
81    /// The real (read-only) buffer showing the diff review; it becomes the
82    /// receipt after Apply/Cancel.
83    pub buffer: DocumentId,
84    pub view_revision: strop_core::id::BufferRevision,
85    pub search: Option<crate::editor::picker::search::SearchStamp>,
86}
87
88impl Editor {
89    /// Present a prepared plan: apply single-document, nothing-refused
90    /// plans directly (the existing receipt path), otherwise open the
91    /// diff-review buffer and hold the proposal for Apply/Cancel.
92    pub(crate) fn present_change_plan(&mut self, plan: ChangePlan) {
93        if plan.documents.len() <= 1 && plan.refused.is_empty() {
94            self.apply_change_plan(plan);
95            return;
96        }
97        self.review_change_plan(plan);
98    }
99
100    /// Apply the reviewed proposal. Each target's base revision is checked
101    /// first: a source edited since the proposal (or closed) is refused by
102    /// name — never recomputed from newer text. Applied targets go through
103    /// the revision-checked gateway exactly as prepared. The review buffer
104    /// becomes the receipt and stays open; the receipt is recorded for
105    /// grouped undo (`:undo-change`).
106    pub(crate) fn review_apply_pub(&mut self) {
107        let Some(proposal) = self.review.pending.take() else {
108            self.message = "no change proposal awaiting review".into();
109            return;
110        };
111        if self
112            .docs
113            .get(proposal.buffer)
114            .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
115        {
116            self.review.pending = Some(proposal);
117            self.message = "review changed; prepare a new proposal before applying".into();
118            return;
119        }
120        if proposal
121            .search
122            .is_some_and(|stamp| self.search_stamp(stamp.session) != Some(stamp))
123        {
124            self.review.pending = Some(proposal);
125            self.message = "Search changed; prepare a new review".into();
126            return;
127        }
128        let producer = proposal.plan.producer.label().to_string();
129        let mut receipt = ChangeReceipt {
130            producer: producer.clone(),
131            applied: Vec::new(),
132            applied_positions: Vec::new(),
133            refused: proposal.plan.refused.clone(),
134            redo_positions: None,
135        };
136        let mut lines: Vec<String> = proposal
137            .plan
138            .refused
139            .iter()
140            .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
141            .collect();
142        for target in proposal.plan.documents {
143            let label = target.location.label();
144            let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
145            match current {
146                None => {
147                    let reason = "document closed since the proposal".to_string();
148                    receipt.refused.push((target.location, reason.clone()));
149                    lines.push(format!("refused: {label} — {reason}"));
150                }
151                Some(_)
152                    if proposal.search.is_some()
153                        && !self.doc(target.document).matches_target(
154                            &crate::files::FileTarget::Local(target.location.path.clone()),
155                        ) =>
156                {
157                    let reason = "source binding changed since the proposal".to_string();
158                    receipt.refused.push((target.location, reason.clone()));
159                    lines.push(format!("refused: {label} — {reason}"));
160                }
161                Some(revision) if revision != target.base => {
162                    let reason = format!(
163                        "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
164                        target.base
165                    );
166                    receipt.refused.push((target.location, reason.clone()));
167                    lines.push(format!("refused: {label} — {reason}"));
168                }
169                Some(_) => {
170                    let changes = ChangeSet {
171                        edits: target.edits,
172                        undo_open: false,
173                    };
174                    match self.apply(target.document, target.base, changes) {
175                        Ok(committed) => {
176                            receipt.applied.push((
177                                target.document,
178                                target.base,
179                                committed.revision,
180                            ));
181                            if let Some(position) = self
182                                .docs
183                                .get(target.document)
184                                .and_then(|doc| doc.buf.history().committed_position())
185                            {
186                                receipt.applied_positions.push(position);
187                            }
188                            lines.push(format!(
189                                "applied: {label} (revision {} -> {})",
190                                target.base, committed.revision
191                            ));
192                        }
193                        Err(error) => {
194                            let reason = format!("changed since plan: {error}");
195                            receipt.refused.push((target.location, reason.clone()));
196                            lines.push(format!("refused: {label} — {reason}"));
197                        }
198                    }
199                }
200            }
201        }
202        let status = if receipt.refused.is_empty() {
203            "APPLIED"
204        } else if receipt.applied.is_empty() {
205            "REFUSED"
206        } else {
207            "PARTIAL"
208        };
209        let mut text = format!(
210            "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",
211            proposal.id,
212            receipt.applied.len(),
213            receipt.refused.len()
214        );
215        for line in &lines {
216            text.push_str(line);
217            text.push('\n');
218        }
219        let publish = self.replace_system(proposal.buffer, &text);
220        self.review.rows.insert(
221            proposal.buffer,
222            text.lines()
223                .enumerate()
224                .map(|(line, _)| {
225                    if line == 0 && !receipt.refused.is_empty() {
226                        ReviewRow::Warning
227                    } else if line == 0 {
228                        ReviewRow::Heading
229                    } else {
230                        ReviewRow::Context
231                    }
232                })
233                .collect(),
234        );
235        if self.current() == proposal.buffer {
236            self.set_head(0);
237            self.view_mut().view_top = 0;
238        }
239        self.message = match (receipt.applied.len(), receipt.refused.len()) {
240            (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
241            (applied, refused) => {
242                format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
243            }
244        };
245        if let Err(error) = publish {
246            self.message = format!("change receipt publish failed: {error}");
247        }
248        self.changes.record(receipt);
249    }
250
251    /// Cancel the reviewed proposal: nothing is applied, ever. The review
252    /// buffer becomes a cancelled receipt and stays open.
253    pub(crate) fn review_cancel_pub(&mut self) {
254        if let Some(stamp) = self
255            .review
256            .preparing
257            .as_ref()
258            .map(|pending| pending.ticket.key.stamp)
259        {
260            self.cancel_review_preparation();
261            self.resume_search_after_review(stamp);
262            self.message = "replacement review cancelled; nothing applied".into();
263            return;
264        }
265        let Some(proposal) = self.review.pending.take() else {
266            self.message = "no change proposal awaiting review".into();
267            return;
268        };
269        let search = proposal.search;
270        let producer = proposal.plan.producer.label();
271        let mut text = format!(
272            "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
273            proposal.id,
274            proposal.plan.documents.len()
275        );
276        if !proposal.plan.refused.is_empty() {
277            text.push_str("\nrefused targets (would have been skipped):\n");
278            for (location, reason) in &proposal.plan.refused {
279                text.push_str(&format!("  {} — {reason}\n", location.label()));
280            }
281        }
282        let publish = self.replace_system(proposal.buffer, &text);
283        self.review.rows.insert(
284            proposal.buffer,
285            text.lines()
286                .enumerate()
287                .map(|(line, _)| {
288                    if line == 0 {
289                        ReviewRow::Heading
290                    } else {
291                        ReviewRow::Context
292                    }
293                })
294                .collect(),
295        );
296        if self.current() == proposal.buffer {
297            self.set_head(0);
298            self.view_mut().view_top = 0;
299        }
300        self.message = format!(
301            "{producer}: proposal {} cancelled — nothing applied",
302            proposal.id
303        );
304        if let Err(error) = publish {
305            self.message = format!("change receipt publish failed: {error}");
306        }
307        if let Some(stamp) = search {
308            self.resume_search_after_review(stamp);
309        }
310    }
311
312    /// Render the review buffer: header, per-file unified diffs computed
313    /// from each target's pinned base, and every refused target named with
314    /// its reason.
315    fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
316        let mut view = Self::review_heading(id, plan);
317        for target in &plan.documents {
318            view.line("", ReviewRow::Context);
319            let label = match target.location.filesystem {
320                strop_workspace::Filesystem::Local => target
321                    .location
322                    .path
323                    .strip_prefix(&self.cwd)
324                    .unwrap_or(&target.location.path)
325                    .display()
326                    .to_string(),
327                _ => target.location.label(),
328            };
329            match self.docs.get(target.document) {
330                Some(document) => {
331                    match render::file_diff(&label, &document.buf, target.base, &target.edits) {
332                        Ok(diff) => view.append(diff),
333                        Err(error) => {
334                            view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
335                        }
336                    }
337                }
338                None => view.line(
339                    &format!("refused: {label} — document closed"),
340                    ReviewRow::Warning,
341                ),
342            }
343        }
344        Self::review_refusals(&mut view, plan);
345        view
346    }
347}
348impl Editor {
349    /// Present a prepared plan as a review ALWAYS (0051 §6 R04): a
350    /// global replace never applies straight from the picker's text
351    /// fields, even when the plan is a single file. Same review shape
352    /// as `present_change_plan` — kept separate so that method's
353    /// direct-apply fast path stays untouched for LSP producers.
354    pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
355        let Some(id) = self.next_review_id() else {
356            return;
357        };
358        let text = self.render_proposal(id, &plan);
359        self.publish_review(id, plan, text, None, true);
360    }
361
362    fn next_review_id(&mut self) -> Option<usize> {
363        match self.review.seq.checked_add(1) {
364            Some(id) => Some(id),
365            None => {
366                self.message = "review identity exhausted".into();
367                None
368            }
369        }
370    }
371
372    fn review_heading(id: usize, plan: &ChangePlan) -> ReviewBuffer {
373        let mut view = ReviewBuffer::default();
374        view.line(
375            &format!("strop change proposal {id}: {}", plan.producer.label()),
376            ReviewRow::Heading,
377        );
378        view.line(
379            &format!(
380                "{} file(s) to change, {} target(s) refused",
381                plan.documents.len(),
382                plan.refused.len()
383            ),
384            ReviewRow::Context,
385        );
386        view.line(
387            &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
388            ReviewRow::Context,
389        );
390        view.line(
391            "bases are pinned — editing a source invalidates that file at apply",
392            ReviewRow::Context,
393        );
394        view
395    }
396
397    fn review_refusals(view: &mut ReviewBuffer, plan: &ChangePlan) {
398        if !plan.refused.is_empty() {
399            view.line("", ReviewRow::Context);
400            view.line("refused targets:", ReviewRow::Warning);
401            for (location, reason) in &plan.refused {
402                view.line(
403                    &format!("  {} — {reason}", location.label()),
404                    ReviewRow::Warning,
405                );
406            }
407        }
408    }
409
410    fn present_prepared_search_review(
411        &mut self,
412        plan: ChangePlan,
413        body: ReviewBuffer,
414        stamp: crate::editor::picker::search::SearchStamp,
415        focus: bool,
416    ) {
417        let Some(id) = self.next_review_id() else {
418            return;
419        };
420        let mut text = Self::review_heading(id, &plan);
421        text.append(body);
422        Self::review_refusals(&mut text, &plan);
423        self.publish_review(id, plan, text, Some(stamp), focus);
424    }
425
426    fn publish_review(
427        &mut self,
428        id: usize,
429        plan: ChangePlan,
430        text: ReviewBuffer,
431        search: Option<crate::editor::picker::search::SearchStamp>,
432        focus: bool,
433    ) {
434        if let Some(old) = self.review.pending.take() {
435            let note = format!(
436                "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
437                old.id,
438                old.plan.producer.label()
439            );
440            if let Err(error) = self.replace_system(old.buffer, &note) {
441                self.message = format!("could not retire proposal {}: {error}", old.id);
442            }
443            self.review
444                .rows
445                .insert(old.buffer, vec![ReviewRow::Heading]);
446        }
447        self.review.seq = id;
448        let producer = plan.producer.label().to_owned();
449        let files = plan.documents.len();
450        let refused = plan.refused.len();
451        let mut buf = Buffer::from_text(&text.text);
452        buf.name = Some(format!("change proposal {id}"));
453        let view_revision = buf.revision();
454        let buffer = if focus {
455            self.open_temporary_output(buf)
456        } else {
457            let mut document = crate::editor::Document::output(buf);
458            if let Some(origin) = self
459                .retained_search
460                .as_ref()
461                .and_then(|glue| glue.search.as_ref())
462                .map(|context| context.origin.clone())
463            {
464                document.set_return_point(origin);
465            }
466            let id = self.docs.insert(document);
467            self.mru.push(id);
468            id
469        };
470        self.review.rows.insert(buffer, text.rows);
471        self.review.pending = Some(ChangeProposal {
472            id,
473            plan,
474            buffer,
475            view_revision,
476            search,
477        });
478        self.message = format!(
479            "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {}",
480            if focus {
481                ":apply-change or :cancel-change"
482            } else {
483                "ready in buffers; focus unchanged"
484            }
485        );
486    }
487
488    pub(crate) fn invalidate_search_review(&mut self, session: strop_core::worker::WorkerId) {
489        if self
490            .review
491            .preparing
492            .as_ref()
493            .is_some_and(|pending| pending.ticket.key.stamp.session == session)
494        {
495            self.cancel_review_preparation();
496        }
497        if !self.review.pending.as_ref().is_some_and(|proposal| {
498            proposal
499                .search
500                .is_some_and(|stamp| stamp.session == session)
501        }) {
502            return;
503        }
504        if let Some(proposal) = self.review.pending.take() {
505            let note = format!("strop change proposal {} — STALE\nSearch changed; prepare a new review. Nothing applied.\n", proposal.id);
506            if let Err(error) = self.replace_system(proposal.buffer, &note) {
507                self.message = format!("could not retire stale review: {error}");
508            }
509            self.review.rows.insert(
510                proposal.buffer,
511                vec![ReviewRow::Warning, ReviewRow::Context],
512            );
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests;
519
520impl Editor {
521    pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
522        self.review.rows.get(&document)?.get(row).copied()
523    }
524}