Skip to main content

tix/app/
repair.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Detect a concurrent write and decide what a finished round trip does
6    /// (tix #1510).
7    ///
8    /// The comparison is on the file's **raw bytes**: anything that rewrote the
9    /// file — an agent editing through MCP, another session, a watcher's repair —
10    /// leaves different bytes, and at this point nothing has been written yet.
11    /// Identical bytes mean the file is the one the editor was handed, so the edit
12    /// is committed exactly as it was before #1510 existed.
13    pub(in crate::app) fn settle_pending_edit(&mut self, pending: PendingEdit) {
14        let disk_path = self.store.issues_dir.join(&pending.filename);
15        let edited = match std::fs::read_to_string(&pending.temp) {
16            Ok(text) => text,
17            Err(e) => {
18                self.status_msg = Some(format!("editor: {e}"));
19                return;
20            }
21        };
22        // A read failure counts as a change: the bytes cannot be shown to be the
23        // ones the editor saw, and assuming "unchanged" would write over a file
24        // this process cannot even read.
25        let unchanged = std::fs::read(&disk_path)
26            .map(|bytes| bytes == pending.snapshot)
27            .unwrap_or(false);
28        if !unchanged {
29            // Nothing is written and the temp copy is deliberately kept: it *is*
30            // the editor's version, and the prompt may still write it or reopen it.
31            self.mode = Mode::EditConflict {
32                filename: pending.filename,
33                temp: pending.temp,
34                disk_path,
35            };
36            self.status_msg =
37                Some("the issue changed on disk while you were editing it".to_string());
38            return;
39        }
40        let issue = match Issue::parse(&edited) {
41            Ok(issue) => issue,
42            Err(e) => {
43                // The reported bug (tix #1506). The old flow returned the generic
44                // `file not valid after edit` from `run_editor` and then deleted
45                // the temp copy, destroying the user's work. Now the real
46                // diagnostic is classified, nothing is written, the round trip
47                // stays in flight, and the retained copy is kept for the repair
48                // prompt to reopen.
49                let filename = pending.filename.clone();
50                let previous = self.previous_issue(&filename);
51                let problems =
52                    edit_problems::for_parse_error(&error_message(&e), &edited, previous.as_ref());
53                let temp = pending.temp.clone();
54                self.pending_edit = Some(pending);
55                self.enter_repair(&filename, Some(temp), false, problems);
56                return;
57            }
58        };
59        let temp = pending.temp.clone();
60        self.commit_edited_issue(&pending.filename, issue, "Edited", Some(temp));
61    }
62
63    /// Persist an issue an editor round trip produced and settle it (#1506/#1510).
64    ///
65    /// The one write both entry points and the conflict prompt's override share,
66    /// so an override cannot land somewhere an ordinary save would not: it refuses
67    /// a titleless issue (no slug, no stable filename), reassigns the segment when
68    /// the edit changed the assignee (FR-031), updates the detail pane when it is
69    /// showing this issue, rebuilds the board, and reports through
70    /// [`App::report_or_repair`].
71    ///
72    /// `temp` is the retained edited copy when there is one. It is **not** removed
73    /// here: `report_or_repair` keeps it if the written file still has problems,
74    /// so the repair prompt has something to reopen, and removes it only once the
75    /// edit is clean.
76    pub(in crate::app) fn commit_edited_issue(
77        &mut self,
78        original_filename: &str,
79        issue: Issue,
80        message: &str,
81        temp: Option<PathBuf>,
82    ) {
83        if issue.title.trim().is_empty() {
84            let previous = self.previous_issue(original_filename);
85            // A titleless issue has no slug and no stable filename. Nothing is
86            // written; the prompt offers the pre-edit title back.
87            let problems = vec![edit_problems::missing_title(previous.as_ref())];
88            self.enter_repair(original_filename, temp, false, problems);
89            return;
90        }
91        // Back up the version this write is about to replace, so even a clean
92        // save leaves the previous content recoverable and names where.
93        let backup = self.backup_previous_content(original_filename);
94        match self.persist_issue(original_filename, issue) {
95            Ok(location) => self.report_or_repair(&location, temp, message, backup),
96            Err(e) => {
97                self.status_msg = Some(format!("editor: {e}"));
98            }
99        }
100    }
101
102    /// Write `issue` over `original_filename`, reassigning the segment when the
103    /// edit changed the assignee, and refresh what the shell shows.
104    ///
105    /// Returns the relative path actually written (the assignee segment may have
106    /// changed, FR-031).
107    pub(in crate::app) fn persist_issue(
108        &mut self,
109        original_filename: &str,
110        mut issue: Issue,
111    ) -> Result<String> {
112        issue.bump_updated();
113        // FR-031: when the editor assigned the issue, physically move it into the
114        // new assignee segment before writing content. An unassigned edit keeps the
115        // current file location.
116        let mut location = match &issue.assignee {
117            Some(a) => self
118                .store
119                .reassign(original_filename, Some(a))?
120                .to_string_lossy()
121                .to_string(),
122            None => original_filename.to_string(),
123        };
124        location = self.store.update(&location, &issue)?;
125        issue.filename = location.clone();
126        if let Some(d) = &mut self.detail
127            && (d.issue.filename == original_filename || d.issue.filename == location)
128        {
129            d.issue = issue;
130            d.scroll_offset.set(0);
131        }
132        let _ = self.refresh_corpus();
133        Ok(location)
134    }
135
136    /// Lint the written file and either report `message` or enter the repair
137    /// prompt (#1506).
138    ///
139    /// The edit is already on disk here, so a finding is a *report*: the custom
140    /// keys the edit added are preserved either way ([`App::report_or_repair`]
141    /// never deletes the file). `backup` is the pre-write copy, named in the
142    /// status so the previous version stays findable.
143    pub(in crate::app) fn report_or_repair(
144        &mut self,
145        filename: &str,
146        temp: Option<PathBuf>,
147        message: &str,
148        backup: Option<PathBuf>,
149    ) {
150        match self.classify_file_problems(filename) {
151            Ok(problems) if !problems.is_empty() => {
152                // The edit is on disk now, so the round trip is over: only the
153                // *unwritten* path keeps `pending_edit` in flight.
154                self.pending_edit = None;
155                self.enter_repair(filename, temp, true, problems);
156            }
157            Ok(_) => {
158                if let Some(temp) = &temp {
159                    let _ = std::fs::remove_file(temp);
160                }
161                // The round trip is resolved: `reedit_repair` arrives here with
162                // the repair prompt still up, so the board is set explicitly
163                // rather than assumed.
164                self.mode = Mode::Board;
165                self.pending_edit = None;
166                self.status_msg = Some(names_backup(message, backup.as_ref()));
167            }
168            Err(e) => {
169                if let Some(temp) = &temp {
170                    let _ = std::fs::remove_file(temp);
171                }
172                self.mode = Mode::Board;
173                self.pending_edit = None;
174                self.status_msg = Some(format!("editor: {e}"));
175            }
176        }
177    }
178
179    /// Every problem `tix lint` reports for `filename`, classified.
180    pub(in crate::app) fn classify_file_problems(
181        &self,
182        filename: &str,
183    ) -> Result<Vec<EditProblem>> {
184        let findings =
185            crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
186        if findings.is_empty() {
187            return Ok(Vec::new());
188        }
189        let previous = self.previous_issue(filename);
190        Ok(edit_problems::for_lint_findings(
191            &findings,
192            previous.as_ref(),
193        ))
194    }
195
196    /// The issue as it is on disk, used for the pre-edit title and as the
197    /// fallback previous version. `None` when it cannot be read or parsed.
198    pub(in crate::app) fn previous_issue(&self, filename: &str) -> Option<Issue> {
199        let path = self.store.issues_dir.join(filename);
200        let contents = std::fs::read_to_string(path).ok()?;
201        Issue::parse(&contents).ok()
202    }
203
204    /// Write a recoverable copy of `filename`'s current content into the
205    /// centralized backup directory (#1581).
206    ///
207    /// Every write that replaces the on-disk issue from the editor, repair, or
208    /// conflict path goes through this first, so a change tix made is always
209    /// recoverable. The copy lands in [`crate::backup::dir`] beside the issues
210    /// directory (never beside the issue), and the path is returned so the
211    /// status line can name it.
212    pub(in crate::app) fn backup_previous_content(&self, filename: &str) -> Option<PathBuf> {
213        crate::backup::save(&self.store.issues_dir, filename)
214    }
215
216    /// Enter the repair prompt with `problems` (#1506).
217    ///
218    /// Does nothing when there is nothing to report — every caller guards on a
219    /// non-empty list, but an empty prompt would be worse than no prompt.
220    pub(in crate::app) fn enter_repair(
221        &mut self,
222        filename: &str,
223        temp: Option<PathBuf>,
224        written: bool,
225        problems: Vec<EditProblem>,
226    ) {
227        let Some(first) = problems.first() else {
228            return;
229        };
230        let key = problems.iter().find_map(|problem| match problem.fix() {
231            Some(EditFix::DeclareCustomAttribute { key }) => Some(key.clone()),
232            _ => None,
233        });
234        let message = first.summary();
235        self.mode = Mode::EditRepair {
236            filename: filename.to_string(),
237            message: message.clone(),
238            key,
239            problems,
240            temp,
241            written,
242            discard_armed: false,
243        };
244        self.status_msg = Some(message);
245    }
246
247    /// Override the newer on-disk version with the editor's version (#1510).
248    ///
249    /// The **only** conflict outcome that writes, and it writes the temp copy's
250    /// content rather than the on-disk one: the prompt has already said which file
251    /// changed and the user chose their own edit. A temp copy that cannot be read
252    /// or parsed leaves the prompt in place instead of closing it, so the user
253    /// still has the abandon and compare keys.
254    pub(in crate::app) fn override_conflicting_edit(
255        &mut self,
256        filename: &str,
257        temp: &std::path::Path,
258    ) {
259        let edited = match std::fs::read_to_string(temp) {
260            Ok(edited) => edited,
261            Err(e) => {
262                self.status_msg = Some(format!("editor: {e}"));
263                return;
264            }
265        };
266        let issue = match Issue::parse(&edited) {
267            Ok(issue) => issue,
268            Err(e) => {
269                // The temp copy cannot be parsed, so nothing can be written over
270                // the newer file. The prompt stays up with both versions intact.
271                self.status_msg = Some(format!("editor: {e}"));
272                return;
273            }
274        };
275        self.mode = Mode::Board;
276        // The temp copy is handed to the commit, which keeps it if the written
277        // file still has problems and removes it once the edit is clean.
278        self.commit_edited_issue(
279            filename,
280            issue,
281            "Overrode the version that changed on disk with your edit",
282            Some(temp.to_path_buf()),
283        );
284    }
285
286    /// Abandon the editor's version and keep the newer on-disk one (#1510).
287    ///
288    /// The safe default, and the reason `Esc` is bound to it: the temp copy is
289    /// removed and nothing is written, so a write that arrived while the editor was
290    /// open is never clobbered by a keypress the user did not mean as a decision.
291    pub(in crate::app) fn abandon_conflicting_edit(&mut self, temp: &std::path::Path) {
292        let _ = std::fs::remove_file(temp);
293        self.mode = Mode::Board;
294        self.status_msg =
295            Some("kept the version that changed on disk; your edit was discarded".to_string());
296    }
297
298    /// Re-open the editor on the conflict prompt's temp copy so the user can
299    /// compare the two versions (#1510), then land back on the prompt.
300    ///
301    /// The disk file's bytes are read **before** and **after** the second editor
302    /// run, so a change made while the user compares is detected too — that is the
303    /// "re-reading the disk state" the prompt promises. It needs the child and the
304    /// child needs the terminal, which is why the keypress returns
305    /// [`Action::CompareEdit`] instead of doing this where the key is handled.
306    /// Nothing is written either way: the editor opens the temp copy, never the
307    /// issue file.
308    pub fn compare_conflicting_edit(&mut self) -> Result<()> {
309        let (filename, temp, disk_path) = match &self.mode {
310            Mode::EditConflict {
311                filename,
312                temp,
313                disk_path,
314            } => (filename.clone(), temp.clone(), disk_path.clone()),
315            _ => return Ok(()),
316        };
317        let name = editor::resolve_editor(self.store.config.editor.as_deref())?;
318        let before = std::fs::read(&disk_path).map_err(TixError::Io)?;
319        editor::run_editor(&name, &temp, &self.store.config)?;
320        let after = std::fs::read(&disk_path).map_err(TixError::Io)?;
321        // Back to the same prompt: a second look is not a decision, and the three
322        // choices have to still be there once the user has looked.
323        self.mode = Mode::EditConflict {
324            filename,
325            temp,
326            disk_path,
327        };
328        self.status_msg = Some(if after == before {
329            "the issue changed on disk while you were editing it".to_string()
330        } else {
331            "the issue changed on disk again while you were comparing it".to_string()
332        });
333        Ok(())
334    }
335
336    /// The edit-conflict prompt's keys (tix #1510).
337    ///
338    /// The arms read the keys' **canonical** events, like every other modal: the
339    /// prompt binds `o`/`y` to `confirm` (canonical `y`), `a`/`Esc` to `ignore`
340    /// (canonical `Enter`), and `c`/`r` to `reedit` (canonical `r`), so what arrives
341    /// here is `y`, `Enter` and `r` whatever chords the keymap names. `Esc`
342    /// therefore abandons exactly as the prompt promises, and no key that is not one
343    /// of the three does anything.
344    pub(in crate::app) fn handle_edit_conflict_key(&mut self, key: KeyEvent) -> Result<Action> {
345        let (filename, temp) = match &self.mode {
346            Mode::EditConflict { filename, temp, .. } => (filename.clone(), temp.clone()),
347            _ => return Ok(Action::None),
348        };
349        match key.code {
350            KeyCode::Char('y') => {
351                self.override_conflicting_edit(&filename, &temp);
352                Ok(Action::None)
353            }
354            // The child needs the terminal, so the event loop runs this and not
355            // the handler (see `compare_conflicting_edit`).
356            KeyCode::Char('r') => Ok(Action::CompareEdit),
357            KeyCode::Enter | KeyCode::Esc => {
358                self.abandon_conflicting_edit(&temp);
359                Ok(Action::None)
360            }
361            _ => Ok(Action::None),
362        }
363    }
364
365    /// Declare `key` in `tix.toml`'s `[custom_attributes]` (#1506) — the one fix
366    /// the repair prompt applies by writing config rather than the issue, and the
367    /// reason the prompt is not just a nag.
368    ///
369    /// The entry is appended and `tix.toml` is written through
370    /// [`Config::save`](crate::config::Config::save), the same atomic temp-file
371    /// path the Config mode uses. An entry that is already declared — compared
372    /// case-insensitively, the rule [`Config::custom_attributes`] itself applies —
373    /// is not appended again: a second `a` on an open warning is an idempotent
374    /// re-run, not a duplicate row.
375    ///
376    /// `key == None` is a finding that is not an undeclared key, so `a` has
377    /// nothing to declare and says so rather than writing an entry that would
378    /// silence nothing.
379    pub(in crate::app) fn declare_custom_attribute(&mut self, key: Option<&str>) {
380        let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) else {
381            self.status_msg = Some(
382                "nothing to declare: this finding is not an undeclared frontmatter key".into(),
383            );
384            return;
385        };
386        let declared = self.store.config.custom_attributes();
387        let already_declared = declared.iter().any(|d| d == &key.to_ascii_lowercase());
388        if !already_declared {
389            self.store
390                .config
391                .custom_attributes
392                .keys
393                .push(key.to_string());
394        }
395        match self
396            .store
397            .config_path()
398            .and_then(|path| self.store.config.save(&path))
399        {
400            Ok(()) => {
401                self.status_msg = Some(format!(
402                    "declared `{key}` in [custom_attributes] in tix.toml"
403                ));
404            }
405            Err(e) => {
406                // The warning closed either way, so the status line is the only
407                // place this can be reported. Roll the in-memory entry back, so
408                // it never claims a declaration that is not on disk.
409                if !already_declared {
410                    self.store.config.custom_attributes.keys.pop();
411                }
412                self.status_msg = Some(format!("declare `{key}`: {e}"));
413            }
414        }
415    }
416
417    /// The repair prompt's keys (tix #1506).
418    ///
419    /// The arms read the keys' **canonical** events, like every other modal:
420    /// `handle_key` rewrites a bound key to its action's canonical event before
421    /// this runs, so `a`/`y` arrive as `y` (`confirm`), `d` as `d`
422    /// (`declare_key`), `r` as `r` (`reedit`), `i`/`Esc` as `Enter` (`ignore`),
423    /// and `x` as `x` (`discard`).
424    ///
425    /// `reedit` hands back [`Action::ReeditRepair`] because the child needs the
426    /// terminal; everything else is decided here. **No key discards silently**:
427    /// `Esc` acknowledges and keeps the copy, and only a second `x` removes it.
428    pub(in crate::app) fn handle_edit_repair_key(&mut self, key: KeyEvent) -> Result<Action> {
429        match key.code {
430            KeyCode::Char('y') => {
431                self.apply_repair_fix();
432                Ok(Action::None)
433            }
434            KeyCode::Char('d') => {
435                self.declare_repair_key();
436                Ok(Action::None)
437            }
438            KeyCode::Char('r') => Ok(Action::ReeditRepair),
439            KeyCode::Char('x') => {
440                self.arm_or_discard_repair();
441                Ok(Action::None)
442            }
443            KeyCode::Enter | KeyCode::Esc => {
444                self.acknowledge_repair();
445                Ok(Action::None)
446            }
447            _ => Ok(Action::None),
448        }
449    }
450
451    /// Apply the first fix the prompt proposes (#1506).
452    ///
453    /// An undeclared key's fix *is* the declaration, so `confirm` keeps #1506's
454    /// behaviour there. The fix operates on the retained copy (or the issue file
455    /// when the edit is already written) and the result is re-parsed: a fix that
456    /// does not make the file valid leaves it untouched and re-reports, and one
457    /// that does is written with the previous version backed up.
458    pub(in crate::app) fn apply_repair_fix(&mut self) {
459        let (filename, temp, problems) = match &self.mode {
460            Mode::EditRepair {
461                filename,
462                temp,
463                problems,
464                ..
465            } => (filename.clone(), temp.clone(), problems.clone()),
466            _ => return,
467        };
468        let Some(problem) = problems.iter().find(|problem| problem.fix().is_some()) else {
469            // #1506: `confirm` on a prompt whose problem is the undeclared key is
470            // the declaration itself, whether or not the key was carried as a fix.
471            if let Mode::EditRepair { key: Some(key), .. } = &self.mode {
472                let key = key.clone();
473                self.declare_custom_attribute(Some(&key));
474                self.mode = Mode::Board;
475                self.pending_edit = None;
476                return;
477            }
478            // A finding that is not an undeclared key has nothing to declare, and
479            // the wording is the one the old warning used. `confirm` closes the
480            // prompt, exactly as the old warning did (the reported problem stays
481            // on disk and in `tix lint`; there is nothing to decide here).
482            self.status_msg = Some(
483                "nothing to declare: this finding is not an undeclared frontmatter key".into(),
484            );
485            self.mode = Mode::Board;
486            self.pending_edit = None;
487            return;
488        };
489        let Some(fix) = problem.fix().cloned() else {
490            return;
491        };
492        if let EditFix::DeclareCustomAttribute { key } = &fix {
493            self.declare_custom_attribute(Some(key));
494            self.mode = Mode::Board;
495            self.pending_edit = None;
496            return;
497        }
498        let source = match &temp {
499            Some(temp) => temp.clone(),
500            None => self.store.issues_dir.join(&filename),
501        };
502        let contents = match std::fs::read_to_string(&source) {
503            Ok(contents) => contents,
504            Err(e) => {
505                self.status_msg = Some(format!("editor: {e}"));
506                return;
507            }
508        };
509        let Some(fixed) = fix.apply(&contents) else {
510            self.status_msg = Some(format!(
511                "could not apply `{}`; the file is untouched",
512                fix.label()
513            ));
514            return;
515        };
516        let label = fix.label();
517        match Issue::parse(&fixed) {
518            Ok(issue) => {
519                let backup = self.backup_previous_content(&filename);
520                match self.persist_issue(&filename, issue) {
521                    Ok(location) => {
522                        if let Some(temp) = &temp {
523                            let _ = std::fs::remove_file(temp);
524                        }
525                        self.pending_edit = None;
526                        self.mode = Mode::Board;
527                        self.status_msg = Some(names_backup(
528                            &format!("applied `{label}` to {location}"),
529                            backup.as_ref(),
530                        ));
531                    }
532                    Err(e) => self.status_msg = Some(format!("editor: {e}")),
533                }
534            }
535            Err(e) => {
536                // The fix did not make the file valid. Nothing was written — the
537                // repaired text only ever existed as a local string — so the
538                // retained copy is untouched and the remaining problems are what
539                // the prompt now lists.
540                let previous = self.previous_issue(&filename);
541                let problems =
542                    edit_problems::for_parse_error(&error_message(&e), &fixed, previous.as_ref());
543                if let Mode::EditRepair {
544                    problems: current, ..
545                } = &mut self.mode
546                {
547                    *current = problems;
548                }
549                self.status_msg = Some(format!(
550                    "the fix did not make the file valid: {e}; the file is untouched"
551                ));
552            }
553        }
554    }
555
556    /// Declare the prompt's undeclared key (#1506), or say there is none.
557    ///
558    /// Closes to the board either way, as the old warning did: the declaration is
559    /// what silences the finding, and the custom key is already on disk.
560    pub(in crate::app) fn declare_repair_key(&mut self) {
561        let key = match &self.mode {
562            Mode::EditRepair { key, .. } => key.clone(),
563            _ => None,
564        };
565        self.declare_custom_attribute(key.as_deref());
566        self.mode = Mode::Board;
567        self.pending_edit = None;
568    }
569
570    /// Acknowledge the prompt and **keep** the retained copy (#1506).
571    ///
572    /// `Esc` lands here, which is why it can never destroy an edit: the worst a
573    /// stray key does is leave the copy where the status line names it.
574    pub(in crate::app) fn acknowledge_repair(&mut self) {
575        let temp = match &self.mode {
576            Mode::EditRepair { temp, .. } => temp.clone(),
577            _ => None,
578        };
579        self.mode = Mode::Board;
580        self.pending_edit = None;
581        self.status_msg = Some(match &temp {
582            Some(temp) => format!("kept your edited copy at {}", temp.display()),
583            None => "kept your edit".to_string(),
584        });
585    }
586
587    /// Arm a discard, or perform it on the second press (#1506).
588    ///
589    /// Two presses because "I saw it" and "throw it away" are two decisions, and
590    /// this is the **only** action in the whole flow that removes the retained
591    /// copy.
592    pub(in crate::app) fn arm_or_discard_repair(&mut self) {
593        if let Mode::EditRepair {
594            discard_armed,
595            temp,
596            ..
597        } = &mut self.mode
598        {
599            if !*discard_armed {
600                *discard_armed = true;
601                self.status_msg = Some(
602                    "press the discard key again to remove the retained copy — this cannot be \
603                     undone"
604                        .to_string(),
605                );
606                return;
607            }
608            let temp = temp.clone();
609            self.mode = Mode::Board;
610            self.pending_edit = None;
611            match temp {
612                Some(temp) => {
613                    let _ = std::fs::remove_file(&temp);
614                    self.status_msg = Some(format!("discarded your edit ({})", temp.display()));
615                }
616                None => {
617                    self.status_msg =
618                        Some("nothing to discard: the edit is already on disk".to_string())
619                }
620            }
621        }
622    }
623
624    /// Re-open the editor on the repair prompt's retained copy (#1506).
625    ///
626    /// Called from the event loop because the child needs the terminal, exactly
627    /// as [`App::compare_conflicting_edit`] is. The edited copy is re-parsed when
628    /// it comes back: a valid file is committed (with the previous version backed
629    /// up), an invalid one re-enters the same prompt with fresh problems, and
630    /// either way the copy survives.
631    pub fn reedit_repair(&mut self) -> Result<()> {
632        let (filename, temp) = match &self.mode {
633            Mode::EditRepair { filename, temp, .. } => (filename.clone(), temp.clone()),
634            _ => return Ok(()),
635        };
636        let name = editor::resolve_editor(self.store.config.editor.as_deref())?;
637        // No retained copy means the edit is already on disk (the #1506
638        // warn-after-write path): materialise one so the re-edit has a copy to
639        // work on and the prompt keeps the guarantee that its copy only goes away
640        // by discard.
641        let temp = match temp {
642            Some(temp) => temp,
643            None => {
644                let contents = std::fs::read_to_string(self.store.issues_dir.join(&filename))
645                    .map_err(TixError::Io)?;
646                let temp = std::env::temp_dir().join(format!(
647                    "tix-edit-repair-{}-{}.md",
648                    std::process::id(),
649                    filename.replace('/', "-")
650                ));
651                std::fs::write(&temp, contents).map_err(TixError::Io)?;
652                temp
653            }
654        };
655        editor::run_editor(&name, &temp, &self.store.config)?;
656        let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
657        match Issue::parse(&edited) {
658            Ok(issue) => {
659                self.commit_edited_issue(&filename, issue, "Edited", Some(temp));
660            }
661            Err(e) => {
662                let previous = self.previous_issue(&filename);
663                let problems =
664                    edit_problems::for_parse_error(&error_message(&e), &edited, previous.as_ref());
665                let message = problems
666                    .first()
667                    .map(EditProblem::summary)
668                    .unwrap_or_else(|| error_message(&e));
669                if let Mode::EditRepair {
670                    problems: current,
671                    temp: kept,
672                    written,
673                    discard_armed,
674                    ..
675                } = &mut self.mode
676                {
677                    *current = problems;
678                    *kept = Some(temp);
679                    // The revised copy is not on disk, whatever the prompt said
680                    // about the version that was.
681                    *written = false;
682                    *discard_armed = false;
683                }
684                self.status_msg = Some(message);
685            }
686        }
687        Ok(())
688    }
689}