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}