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        if self.apply_filesystem_review() {
108            return;
109        }
110        if self.is_filesystem_report(self.current()) {
111            self.message = "this filesystem report has no pending proposal to apply".into();
112            return;
113        }
114        let Some(proposal) = self.review.pending.take() else {
115            self.message = "no change proposal awaiting review".into();
116            return;
117        };
118        if self
119            .docs
120            .get(proposal.buffer)
121            .is_none_or(|doc| doc.buf.revision() != proposal.view_revision)
122        {
123            self.review.pending = Some(proposal);
124            self.message = "review changed; prepare a new proposal before applying".into();
125            return;
126        }
127        if proposal
128            .search
129            .is_some_and(|stamp| self.search_stamp(stamp.session) != Some(stamp))
130        {
131            self.review.pending = Some(proposal);
132            self.message = "Search changed; prepare a new review".into();
133            return;
134        }
135        let producer = proposal.plan.producer.label().to_string();
136        let mut receipt = ChangeReceipt {
137            producer: producer.clone(),
138            applied: Vec::new(),
139            applied_positions: Vec::new(),
140            refused: proposal.plan.refused.clone(),
141            redo_positions: None,
142        };
143        let mut lines: Vec<String> = proposal
144            .plan
145            .refused
146            .iter()
147            .map(|(location, reason)| format!("refused: {} — {reason}", location.label()))
148            .collect();
149        for target in proposal.plan.documents {
150            let label = target.location.label();
151            let current = self.docs.get(target.document).map(|doc| doc.buf.revision());
152            match current {
153                None => {
154                    let reason = "document closed since the proposal".to_string();
155                    receipt.refused.push((target.location, reason.clone()));
156                    lines.push(format!("refused: {label} — {reason}"));
157                }
158                Some(_)
159                    if proposal.search.is_some()
160                        && !self.doc(target.document).matches_target(
161                            &crate::files::FileTarget::Local(target.location.path.clone()),
162                        ) =>
163                {
164                    let reason = "source binding changed since the proposal".to_string();
165                    receipt.refused.push((target.location, reason.clone()));
166                    lines.push(format!("refused: {label} — {reason}"));
167                }
168                Some(revision) if revision != target.base => {
169                    let reason = format!(
170                        "edited since the proposal (base revision {}, now {revision}) — re-run the {producer}",
171                        target.base
172                    );
173                    receipt.refused.push((target.location, reason.clone()));
174                    lines.push(format!("refused: {label} — {reason}"));
175                }
176                Some(_) => {
177                    let changes = ChangeSet {
178                        edits: target.edits,
179                        undo_open: false,
180                    };
181                    match self.apply(target.document, target.base, changes) {
182                        Ok(committed) => {
183                            receipt.applied.push((
184                                target.document,
185                                target.base,
186                                committed.revision,
187                            ));
188                            if let Some(position) = self
189                                .docs
190                                .get(target.document)
191                                .and_then(|doc| doc.buf.history().committed_position())
192                            {
193                                receipt.applied_positions.push(position);
194                            }
195                            lines.push(format!(
196                                "applied: {label} (revision {} -> {})",
197                                target.base, committed.revision
198                            ));
199                        }
200                        Err(error) => {
201                            let reason = format!("changed since plan: {error}");
202                            receipt.refused.push((target.location, reason.clone()));
203                            lines.push(format!("refused: {label} — {reason}"));
204                        }
205                    }
206                }
207            }
208        }
209        let status = if receipt.refused.is_empty() {
210            "APPLIED"
211        } else if receipt.applied.is_empty() {
212            "REFUSED"
213        } else {
214            "PARTIAL"
215        };
216        let mut text = format!(
217            "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",
218            proposal.id,
219            receipt.applied.len(),
220            receipt.refused.len()
221        );
222        for line in &lines {
223            text.push_str(line);
224            text.push('\n');
225        }
226        let publish = self.replace_system(proposal.buffer, &text);
227        self.review.rows.insert(
228            proposal.buffer,
229            text.lines()
230                .enumerate()
231                .map(|(line, _)| {
232                    if line == 0 && !receipt.refused.is_empty() {
233                        ReviewRow::Warning
234                    } else if line == 0 {
235                        ReviewRow::Heading
236                    } else {
237                        ReviewRow::Context
238                    }
239                })
240                .collect(),
241        );
242        if self.current() == proposal.buffer {
243            self.set_head(0);
244            self.view_mut().view_top = 0;
245        }
246        self.message = match (receipt.applied.len(), receipt.refused.len()) {
247            (applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
248            (applied, refused) => {
249                format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
250            }
251        };
252        if let Err(error) = publish {
253            self.message = format!("change receipt publish failed: {error}");
254        }
255        self.changes.record(receipt);
256    }
257
258    /// Cancel the reviewed proposal: nothing is applied, ever. The review
259    /// buffer becomes a cancelled receipt and stays open.
260    pub(crate) fn review_cancel_pub(&mut self) {
261        if self.cancel_filesystem_review() {
262            return;
263        }
264        if self.is_filesystem_report(self.current()) {
265            self.message = "this filesystem report has no pending proposal to cancel".into();
266            return;
267        }
268        if let Some(stamp) = self
269            .review
270            .preparing
271            .as_ref()
272            .map(|pending| pending.ticket.key.stamp)
273        {
274            self.cancel_review_preparation();
275            self.resume_search_after_review(stamp);
276            self.message = "replacement review cancelled; nothing applied".into();
277            return;
278        }
279        let Some(proposal) = self.review.pending.take() else {
280            self.message = "no change proposal awaiting review".into();
281            return;
282        };
283        let search = proposal.search;
284        let producer = proposal.plan.producer.label();
285        let mut text = format!(
286            "strop change proposal {}: {producer} — CANCELLED\nnothing applied; {} file(s) had been proposed\n",
287            proposal.id,
288            proposal.plan.documents.len()
289        );
290        if !proposal.plan.refused.is_empty() {
291            text.push_str("\nrefused targets (would have been skipped):\n");
292            for (location, reason) in &proposal.plan.refused {
293                text.push_str(&format!("  {} — {reason}\n", location.label()));
294            }
295        }
296        let publish = self.replace_system(proposal.buffer, &text);
297        self.review.rows.insert(
298            proposal.buffer,
299            text.lines()
300                .enumerate()
301                .map(|(line, _)| {
302                    if line == 0 {
303                        ReviewRow::Heading
304                    } else {
305                        ReviewRow::Context
306                    }
307                })
308                .collect(),
309        );
310        if self.current() == proposal.buffer {
311            self.set_head(0);
312            self.view_mut().view_top = 0;
313        }
314        self.message = format!(
315            "{producer}: proposal {} cancelled — nothing applied",
316            proposal.id
317        );
318        if let Err(error) = publish {
319            self.message = format!("change receipt publish failed: {error}");
320        }
321        if let Some(stamp) = search {
322            self.resume_search_after_review(stamp);
323        }
324    }
325
326    /// Render the review buffer: header, per-file unified diffs computed
327    /// from each target's pinned base, and every refused target named with
328    /// its reason.
329    fn render_proposal(&self, id: usize, plan: &ChangePlan) -> ReviewBuffer {
330        let mut view = Self::review_heading(id, plan);
331        for target in &plan.documents {
332            view.line("", ReviewRow::Context);
333            let label = match target.location.filesystem {
334                strop_workspace::Filesystem::Local => target
335                    .location
336                    .path
337                    .strip_prefix(&self.cwd)
338                    .unwrap_or(&target.location.path)
339                    .display()
340                    .to_string(),
341                _ => target.location.label(),
342            };
343            match self.docs.get(target.document) {
344                Some(document) => {
345                    match render::file_diff(&label, &document.buf, target.base, &target.edits) {
346                        Ok(diff) => view.append(diff),
347                        Err(error) => {
348                            view.line(&format!("refused: {label} — {error}"), ReviewRow::Warning)
349                        }
350                    }
351                }
352                None => view.line(
353                    &format!("refused: {label} — document closed"),
354                    ReviewRow::Warning,
355                ),
356            }
357        }
358        Self::review_refusals(&mut view, plan);
359        view
360    }
361}
362impl Editor {
363    /// Present a prepared plan as a review ALWAYS (0051 §6 R04): a
364    /// global replace never applies straight from the picker's text
365    /// fields, even when the plan is a single file. Same review shape
366    /// as `present_change_plan` — kept separate so that method's
367    /// direct-apply fast path stays untouched for LSP producers.
368    pub(crate) fn review_change_plan(&mut self, plan: ChangePlan) {
369        let Some(id) = self.next_review_id() else {
370            return;
371        };
372        let text = self.render_proposal(id, &plan);
373        self.publish_review(id, plan, text, None, true);
374    }
375
376    fn next_review_id(&mut self) -> Option<usize> {
377        match self.review.seq.checked_add(1) {
378            Some(id) => Some(id),
379            None => {
380                self.message = "review identity exhausted".into();
381                None
382            }
383        }
384    }
385
386    fn review_heading(id: usize, plan: &ChangePlan) -> ReviewBuffer {
387        let mut view = ReviewBuffer::default();
388        view.line(
389            &format!("strop change proposal {id}: {}", plan.producer.label()),
390            ReviewRow::Heading,
391        );
392        view.line(
393            &format!(
394                "{} file(s) to change, {} target(s) refused",
395                plan.documents.len(),
396                plan.refused.len()
397            ),
398            ReviewRow::Context,
399        );
400        view.line(
401            &format!("{APPLY_COMMAND} applies exactly what is shown; {CANCEL_COMMAND} discards it"),
402            ReviewRow::Context,
403        );
404        view.line(
405            "bases are pinned — editing a source invalidates that file at apply",
406            ReviewRow::Context,
407        );
408        view
409    }
410
411    fn review_refusals(view: &mut ReviewBuffer, plan: &ChangePlan) {
412        if !plan.refused.is_empty() {
413            view.line("", ReviewRow::Context);
414            view.line("refused targets:", ReviewRow::Warning);
415            for (location, reason) in &plan.refused {
416                view.line(
417                    &format!("  {} — {reason}", location.label()),
418                    ReviewRow::Warning,
419                );
420            }
421        }
422    }
423
424    fn present_prepared_search_review(
425        &mut self,
426        plan: ChangePlan,
427        body: ReviewBuffer,
428        stamp: crate::editor::picker::search::SearchStamp,
429        focus: bool,
430    ) {
431        let Some(id) = self.next_review_id() else {
432            return;
433        };
434        let mut text = Self::review_heading(id, &plan);
435        text.append(body);
436        Self::review_refusals(&mut text, &plan);
437        self.publish_review(id, plan, text, Some(stamp), focus);
438    }
439
440    fn publish_review(
441        &mut self,
442        id: usize,
443        plan: ChangePlan,
444        text: ReviewBuffer,
445        search: Option<crate::editor::picker::search::SearchStamp>,
446        focus: bool,
447    ) {
448        self.retire_filesystem_review("superseded by a text review");
449        if let Some(old) = self.review.pending.take() {
450            let note = format!(
451                "strop change proposal {}: {} — SUPERSEDED by a newer proposal\n",
452                old.id,
453                old.plan.producer.label()
454            );
455            if let Err(error) = self.replace_system(old.buffer, &note) {
456                self.message = format!("could not retire proposal {}: {error}", old.id);
457            }
458            self.review
459                .rows
460                .insert(old.buffer, vec![ReviewRow::Heading]);
461        }
462        self.review.seq = id;
463        let producer = plan.producer.label().to_owned();
464        let files = plan.documents.len();
465        let refused = plan.refused.len();
466        let mut buf = Buffer::from_text(&text.text);
467        buf.name = Some(format!("change proposal {id}"));
468        let view_revision = buf.revision();
469        let buffer = if focus {
470            self.open_temporary_output(buf)
471        } else {
472            let mut document = crate::editor::Document::output(buf);
473            if let Some(origin) = self
474                .retained_search
475                .as_ref()
476                .and_then(|glue| glue.search.as_ref())
477                .map(|context| context.origin.clone())
478            {
479                document.set_return_point(origin);
480            }
481            let id = self.docs.insert(document);
482            self.mru.push(id);
483            id
484        };
485        self.review.rows.insert(buffer, text.rows);
486        self.review.pending = Some(ChangeProposal {
487            id,
488            plan,
489            buffer,
490            view_revision,
491            search,
492        });
493        self.message = format!(
494            "{producer}: proposal {id} reviews {files} file(s), {refused} refused — {}",
495            if focus {
496                ":apply-change or :cancel-change"
497            } else {
498                "ready in buffers; focus unchanged"
499            }
500        );
501    }
502
503    pub(crate) fn invalidate_search_review(&mut self, session: strop_core::worker::WorkerId) {
504        if self
505            .review
506            .preparing
507            .as_ref()
508            .is_some_and(|pending| pending.ticket.key.stamp.session == session)
509        {
510            self.cancel_review_preparation();
511        }
512        if !self.review.pending.as_ref().is_some_and(|proposal| {
513            proposal
514                .search
515                .is_some_and(|stamp| stamp.session == session)
516        }) {
517            return;
518        }
519        if let Some(proposal) = self.review.pending.take() {
520            let note = format!("strop change proposal {} — STALE\nSearch changed; prepare a new review. Nothing applied.\n", proposal.id);
521            if let Err(error) = self.replace_system(proposal.buffer, &note) {
522                self.message = format!("could not retire stale review: {error}");
523            }
524            self.review.rows.insert(
525                proposal.buffer,
526                vec![ReviewRow::Warning, ReviewRow::Context],
527            );
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests;
534
535impl Editor {
536    pub(crate) fn set_filesystem_review_rows(
537        &mut self,
538        document: DocumentId,
539        rows: Vec<ReviewRow>,
540    ) {
541        self.filesystem_report_opened(document);
542        self.review.rows.insert(document, rows);
543    }
544
545    pub(crate) fn retire_text_review_for_filesystem(&mut self) {
546        if let Some(proposal) = self.review.pending.take() {
547            if self.docs.get(proposal.buffer).is_some() {
548                if let Err(error) = self.replace_system(
549                    proposal.buffer,
550                    "text review superseded by filesystem review; nothing applied\n",
551                ) {
552                    self.message = error.to_string();
553                }
554            }
555        }
556    }
557
558    pub(crate) fn invalidate_filesystem_text_review(&mut self, documents: &[DocumentId]) {
559        self.cancel_review_preparation();
560        if self.review.pending.as_ref().is_some_and(|proposal| {
561            proposal
562                .plan
563                .documents
564                .iter()
565                .any(|target| documents.contains(&target.document))
566        }) {
567            self.retire_text_review_for_filesystem();
568        }
569    }
570    pub fn review_row(&self, document: DocumentId, row: usize) -> Option<ReviewRow> {
571        self.review.rows.get(&document)?.get(row).copied()
572    }
573}