Skip to main content

tix/
edit_problems.rs

1//! Every problem an editor round trip can leave behind, and the repair tix can
2//! apply to each one (tix #1506).
3//!
4//! The old flow ended with a `run_editor` that re-parsed the temp copy and threw
5//! the diagnostic away, so the caller could only report `file not valid after
6//! edit` and the real parse error never reached the user. This module is the
7//! replacement: `run_editor` no longer validates (its job is to run the editor),
8//! and the callers classify what came back here, where each problem carries the
9//! diagnostic **and** — where tix can do so safely — a machine-applicable fix.
10//!
11//! The taxonomy is deliberately small and closed:
12//!
13//! | kind | source | automatic fix |
14//! |---|---|---|
15//! | `history_not_first` | a `tix lint` `history` finding | move the section to the top of the body |
16//! | `unknown_key` | a `tix lint` `unknown_key` finding | declare the key in `[custom_attributes]` |
17//! | `duplicate_key` | a YAML duplicate-field parse error | drop the later duplicate, keep the first |
18//! | `retired_links_key` | a `tix lint` `retired_links_key` finding | remove the key |
19//! | `missing_title` | an empty `title:` after the edit | restore the pre-edit title |
20//! | `history_entry` | a hand-edited `## History` line | none — reopen the editor |
21//! | `invalid_value` | any other `tix lint` finding | none — reopen the editor |
22//! | `unparsable` | a frontmatter that will not parse | none — reopen the editor |
23//!
24//! Every problem carries a [`proposal`](EditProblem::proposal), even when the
25//! only honest proposal is "reopen the editor on the retained copy and fix it by
26//! hand". A problem with no proposal would be a dead end, which is exactly what
27//! this ticket exists to remove.
28
29use crate::commands::lint::TixIssueFinding;
30use crate::model::{HISTORY_HEADING, Issue, is_history_heading};
31
32/// What kind of post-edit problem this is.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum EditProblemKind {
35    /// A `## History` section that is not the first thing in the body.
36    HistoryNotFirst,
37    /// A top-level frontmatter key tix does not know.
38    UnknownKey,
39    /// The same frontmatter key declared twice.
40    DuplicateKey,
41    /// The retired `links:` key, whose value tix no longer reads.
42    RetiredLinksKey,
43    /// The issue has no title, so it has no stable slug or filename.
44    MissingTitle,
45    /// A `## History` line that could not be parsed, or that looks like an entry
46    /// without being one.
47    HistoryEntry,
48    /// Any other value `tix lint` rejects.
49    InvalidValue,
50    /// The frontmatter does not parse at all (bad YAML, a missing `---` fence,
51    /// or anything else the parser refused).
52    Unparsable,
53}
54
55impl EditProblemKind {
56    /// The stable name, used in the prompt heading and in tests.
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::HistoryNotFirst => "history_not_first",
60            Self::UnknownKey => "unknown_key",
61            Self::DuplicateKey => "duplicate_key",
62            Self::RetiredLinksKey => "retired_links_key",
63            Self::MissingTitle => "missing_title",
64            Self::HistoryEntry => "history_entry",
65            Self::InvalidValue => "invalid_value",
66            Self::Unparsable => "unparsable",
67        }
68    }
69}
70
71/// A machine-applicable repair.
72///
73/// "Machine-applicable" is the whole bar: a fix is only offered when tix can
74/// perform it without guessing at what the author meant. Prose problems get no
75/// fix, only a proposal, because a tool that rewrites a user's words is worse
76/// than one that says "here is the file, fix it".
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum EditFix {
79    /// Move the misplaced `## History` heading and its whole entry block to the
80    /// top of the body, byte-for-byte.
81    MoveHistoryToFront,
82    /// Declare `key` in `tix.toml`'s `[custom_attributes]`. Applied by the app
83    /// (it writes `tix.toml`), not by rewriting the issue file.
84    DeclareCustomAttribute { key: String },
85    /// Drop the later occurrence of `key`, keeping the first.
86    DropDuplicateKey { key: String, kept: String },
87    /// Remove the retired `links:` key and its block.
88    RemoveRetiredLinksKey { key: String },
89    /// Put the pre-edit title back.
90    RestoreTitle { title: String },
91}
92
93impl EditFix {
94    /// The human description of what applying the fix does.
95    pub fn label(&self) -> String {
96        match self {
97            Self::MoveHistoryToFront => {
98                "move the `## History` section to the top of the body".to_string()
99            }
100            Self::DeclareCustomAttribute { key } => {
101                format!("declare `{key}` in `[custom_attributes]` in tix.toml")
102            }
103            Self::DropDuplicateKey { key, .. } => {
104                format!("drop the later duplicate `{key}:`")
105            }
106            Self::RemoveRetiredLinksKey { key } => {
107                format!("remove the retired `{key}:` key")
108            }
109            Self::RestoreTitle { title } => format!("restore the title `{title}`"),
110        }
111    }
112
113    /// Whether the fix is a rewrite of the issue text, as opposed to an action
114    /// the app performs elsewhere (declaring a key writes `tix.toml`).
115    pub const fn is_text_fix(&self) -> bool {
116        !matches!(self, Self::DeclareCustomAttribute { .. })
117    }
118
119    /// Apply the fix to `contents`, returning the repaired text.
120    ///
121    /// `None` when the fix cannot be applied — the declared-key fix is not a
122    /// text edit at all, and the text fixes refuse rather than guess when the
123    /// shape they expect is absent. A `None` means the caller must leave the
124    /// file untouched.
125    pub fn apply(&self, contents: &str) -> Option<String> {
126        match self {
127            Self::DeclareCustomAttribute { .. } => None,
128            Self::MoveHistoryToFront => move_history_to_front(contents),
129            Self::DropDuplicateKey { key, .. } => remove_key_occurrence(contents, key, 1),
130            Self::RemoveRetiredLinksKey { key } => remove_key_occurrence(contents, key, 0),
131            Self::RestoreTitle { title } => set_title(contents, title),
132        }
133    }
134}
135
136/// One classified problem, with the diagnostic and the way out.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct EditProblem {
139    /// The taxonomy entry.
140    pub kind: EditProblemKind,
141    /// The real diagnostic. For a parse failure this is the parser's own
142    /// message, verbatim — never the old `file not valid after edit`.
143    pub message: String,
144    /// What to do about it. Always populated; the least a problem can propose is
145    /// reopening the editor on the retained copy.
146    pub proposal: Option<String>,
147    /// The repair tix can perform, when there is one it can perform safely.
148    pub fix: Option<EditFix>,
149}
150
151impl EditProblem {
152    /// `kind: message`, the one-line form the prompt and status line use.
153    pub fn summary(&self) -> String {
154        format!("{}: {}", self.kind.as_str(), self.message)
155    }
156
157    /// The proposal text, or a plain "reopen the editor" fallback so a problem
158    /// can never render as a dead end.
159    pub fn proposal_text(&self) -> String {
160        self.proposal.clone().unwrap_or_else(|| {
161            "reopen the editor on the retained copy and fix it by hand".to_string()
162        })
163    }
164
165    /// The fix tix can apply, if any.
166    pub fn fix(&self) -> Option<&EditFix> {
167        self.fix.as_ref()
168    }
169}
170
171/// Classify a failed `Issue::parse`.
172///
173/// `contents` is the text that would not parse — needed to locate a duplicate
174/// key's later occurrence. `previous` is the issue as it was before the edit,
175/// used only for the (rare) restore-title case; a parse failure never has a
176/// title to restore, so it stays unused here and exists for symmetry with
177/// [`for_lint_findings`].
178pub fn for_parse_error(
179    message: &str,
180    contents: &str,
181    _previous: Option<&Issue>,
182) -> Vec<EditProblem> {
183    if let Some(key) = duplicate_field_key(message)
184        && let Some(problem) = duplicate_key_problem(contents, &key)
185    {
186        return vec![problem];
187    }
188    vec![EditProblem {
189        kind: EditProblemKind::Unparsable,
190        message: message.to_string(),
191        proposal: Some(
192            "reopen the editor on the retained copy and fix it by hand — nothing has \
193             been written"
194                .to_string(),
195        ),
196        fix: None,
197    }]
198}
199
200/// Classify the findings of a `tix lint` pass over the edited file.
201pub fn for_lint_findings(
202    findings: &[TixIssueFinding],
203    previous: Option<&Issue>,
204) -> Vec<EditProblem> {
205    findings
206        .iter()
207        .map(|finding| for_lint_finding(finding, previous))
208        .collect()
209}
210
211/// A titleless issue's problem, with the pre-edit title as the fix when there is
212/// one to restore.
213pub fn missing_title(previous: Option<&Issue>) -> EditProblem {
214    missing_title_problem(previous, None)
215}
216
217fn for_lint_finding(finding: &TixIssueFinding, previous: Option<&Issue>) -> EditProblem {
218    match finding.kind.as_str() {
219        "unknown_key" => {
220            let key = finding.value.trim().to_string();
221            EditProblem {
222                kind: EditProblemKind::UnknownKey,
223                message: finding.message.clone(),
224                proposal: Some(format!(
225                    "declare `{key}` in `[custom_attributes]` in tix.toml"
226                )),
227                fix: (!key.is_empty()).then_some(EditFix::DeclareCustomAttribute { key }),
228            }
229        }
230        "retired_links_key" => EditProblem {
231            kind: EditProblemKind::RetiredLinksKey,
232            message: finding.message.clone(),
233            proposal: Some(
234                "remove the retired `links:` key — its value is no longer read".to_string(),
235            ),
236            fix: Some(EditFix::RemoveRetiredLinksKey {
237                key: if finding.value.trim().is_empty() {
238                    "links".to_string()
239                } else {
240                    finding.value.trim().to_string()
241                },
242            }),
243        },
244        // The misplaced-section finding is the one `history` finding with a
245        // mechanical fix; the others are hand-edited lines that must not be
246        // rewritten.
247        "history" if finding.value.trim() == HISTORY_HEADING => EditProblem {
248            kind: EditProblemKind::HistoryNotFirst,
249            message: finding.message.clone(),
250            proposal: Some(
251                "move the `## History` section to the top of the body, preserving every \
252                 entry"
253                    .to_string(),
254            ),
255            fix: Some(EditFix::MoveHistoryToFront),
256        },
257        "history" => hand_fix_problem(
258            EditProblemKind::HistoryEntry,
259            finding.message.clone(),
260            "reopen the editor on the retained copy and correct the entry by hand — its \
261             bytes are preserved",
262        ),
263        "title" => missing_title_problem(previous, Some(finding.message.clone())),
264        _ => hand_fix_problem(
265            EditProblemKind::InvalidValue,
266            format!("{}: {}", finding.kind, finding.message),
267            "reopen the editor on the retained copy and correct the value by hand",
268        ),
269    }
270}
271
272fn hand_fix_problem(kind: EditProblemKind, message: String, proposal: &str) -> EditProblem {
273    EditProblem {
274        kind,
275        message,
276        proposal: Some(proposal.to_string()),
277        fix: None,
278    }
279}
280
281fn missing_title_problem(previous: Option<&Issue>, message: Option<String>) -> EditProblem {
282    let restored = previous
283        .map(|issue| issue.title.clone())
284        .filter(|title| !title.trim().is_empty());
285    EditProblem {
286        kind: EditProblemKind::MissingTitle,
287        message: message.unwrap_or_else(|| "title is required".to_string()),
288        proposal: Some(match &restored {
289            Some(title) => format!("restore the pre-edit title `{title}`"),
290            None => "reopen the editor on the retained copy and add a title".to_string(),
291        }),
292        fix: restored.map(|title| EditFix::RestoreTitle { title }),
293    }
294}
295
296/// The field name from serde's duplicate-field diagnostic: `duplicate field
297/// \`title\``.
298fn duplicate_field_key(message: &str) -> Option<String> {
299    let rest = message.strip_prefix("duplicate field `")?;
300    let (key, _) = rest.split_once('`')?;
301    (!key.is_empty()).then(|| key.to_string())
302}
303
304fn duplicate_key_problem(contents: &str, key: &str) -> Option<EditProblem> {
305    if count_key_occurrences(contents, key) < 2 {
306        return None;
307    }
308    let kept = key_value(contents, key, 0).unwrap_or_default();
309    Some(EditProblem {
310        kind: EditProblemKind::DuplicateKey,
311        message: format!("duplicate field `{key}`"),
312        proposal: Some(format!(
313            "drop the later `{key}:` entry, keeping the first value (`{kept}`)"
314        )),
315        fix: Some(EditFix::DropDuplicateKey {
316            key: key.to_string(),
317            kept,
318        }),
319    })
320}
321
322/// The byte range of the frontmatter **content** (between the opening and
323/// closing `---` lines). `None` when the fences are missing or unterminated.
324fn frontmatter_region(contents: &str) -> Option<(usize, usize)> {
325    let rest = contents.strip_prefix("---\n")?;
326    let start = contents.len() - rest.len();
327    let mut offset = start;
328    for line in rest.split_inclusive('\n') {
329        if line.trim_end_matches('\n') == "---" {
330            return Some((start, offset));
331        }
332        offset += line.len();
333    }
334    None
335}
336
337/// The top-level key a frontmatter line declares, or `None` for a line that is
338/// not a top-level key (indented, blank, a comment, or without a colon).
339fn top_level_key(line: &str) -> Option<String> {
340    if line.is_empty() || line.starts_with([' ', '\t', '#']) {
341        return None;
342    }
343    let (head, _) = line.split_once(':')?;
344    let key = strip_quotes(head.trim());
345    (!key.is_empty()).then(|| key.to_string())
346}
347
348fn strip_quotes(value: &str) -> &str {
349    if let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
350        return inner;
351    }
352    if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
353        return inner;
354    }
355    value
356}
357
358/// Every `offset..line_end` span inside `contents`' frontmatter whose line
359/// declares `key` at the top level, in file order.
360fn key_spans(contents: &str, key: &str) -> Vec<(usize, usize)> {
361    let Some((start, end)) = frontmatter_region(contents) else {
362        return Vec::new();
363    };
364    let mut spans = Vec::new();
365    let mut offset = start;
366    while offset < end {
367        let line_end = contents[offset..]
368            .find('\n')
369            .map_or(end, |index| offset + index + 1);
370        let line = &contents[offset..line_end];
371        if top_level_key(line).as_deref() == Some(key) {
372            // Extend over the value's indented continuation lines (a block
373            // sequence or mapping), never over a blank line or the next key.
374            let mut block_end = line_end;
375            while block_end < end {
376                let next_end = contents[block_end..]
377                    .find('\n')
378                    .map_or(end, |index| block_end + index + 1);
379                let next = &contents[block_end..next_end];
380                // A value's block is either indented (a nested mapping or an
381                // indented sequence) or a column-0 `- ` sequence, which YAML
382                // allows at the key's own indentation. A top-level key line can
383                // never start with `- `, so this cannot swallow the next key.
384                if next.starts_with(' ') || next.starts_with('\t') || next.starts_with("- ") {
385                    block_end = next_end;
386                } else {
387                    break;
388                }
389            }
390            spans.push((offset, block_end));
391        }
392        offset = line_end;
393    }
394    spans
395}
396
397fn count_key_occurrences(contents: &str, key: &str) -> usize {
398    key_spans(contents, key).len()
399}
400
401/// The inline value of the `occurrence`-th `key:` entry, or the first non-empty
402/// line of its block. Used only for the message that says which value was kept.
403fn key_value(contents: &str, key: &str, occurrence: usize) -> Option<String> {
404    let spans = key_spans(contents, key);
405    let (start, end) = *spans.get(occurrence)?;
406    let block = &contents[start..end];
407    let mut lines = block.lines();
408    let head = lines.next()?;
409    let inline = strip_quotes(head.split_once(':').map_or("", |(_, v)| v).trim());
410    if !inline.is_empty() {
411        return Some(inline.to_string());
412    }
413    lines
414        .map(str::trim)
415        .find(|line| !line.is_empty())
416        .map(str::to_string)
417}
418
419/// Remove the `occurrence`-th top-level `key:` entry and its indented block.
420fn remove_key_occurrence(contents: &str, key: &str, occurrence: usize) -> Option<String> {
421    let (start, end) = *key_spans(contents, key).get(occurrence)?;
422    let mut out = String::with_capacity(contents.len());
423    out.push_str(&contents[..start]);
424    out.push_str(&contents[end..]);
425    Some(out)
426}
427
428/// Replace the first `title:` value with `title`.
429fn set_title(contents: &str, title: &str) -> Option<String> {
430    let (start, end) = *key_spans(contents, "title").first()?;
431    let line = &contents[start..end];
432    let colon = line.find(':')?;
433    let rendered = serde_yaml::to_string(title)
434        .map(|yaml| yaml.trim_end().to_string())
435        .unwrap_or_else(|_| format!("{title:?}"));
436    let mut out = String::with_capacity(contents.len());
437    out.push_str(&contents[..=start + colon]);
438    out.push(' ');
439    out.push_str(&rendered);
440    out.push('\n');
441    out.push_str(&contents[end..]);
442    Some(out)
443}
444
445/// Move the first misplaced `## History` section, with its whole entry block, to
446/// the top of the body.
447///
448/// Every line of the section is copied across byte-for-byte: the heading, the
449/// entry lines (parsed or hand-edited), and the blank lines between them. The
450/// block ends at the last line that is blank or a `- ` list item, so body prose
451/// that follows the section stays in the body.
452fn move_history_to_front(contents: &str) -> Option<String> {
453    let fence_end = closing_fence_end(contents)?;
454    let head = &contents[..fence_end];
455    let mut body = &contents[fence_end..];
456    if let Some(rest) = body.strip_prefix('\n') {
457        body = rest;
458    }
459    let lines: Vec<&str> = body.split('\n').collect();
460    let heading = lines.iter().position(|line| is_history_heading(line))?;
461    if heading == 0 {
462        // Already the first thing in the body; nothing to move.
463        return None;
464    }
465    let mut block_end = heading + 1;
466    while block_end < lines.len()
467        && (lines[block_end].trim().is_empty() || lines[block_end].trim_start().starts_with("- "))
468    {
469        block_end += 1;
470    }
471    // Trim trailing blank lines from the captured block so the section does not
472    // carry the separator that belonged to the prose it sat above.
473    while block_end > heading + 1 && lines[block_end - 1].trim().is_empty() {
474        block_end -= 1;
475    }
476    let block: Vec<&str> = lines[heading..block_end].to_vec();
477    let mut remaining: Vec<&str> = Vec::new();
478    remaining.extend_from_slice(&lines[..heading]);
479    remaining.extend_from_slice(&lines[block_end..]);
480    let remaining = remaining.join("\n");
481    let remaining = remaining.trim_matches('\n');
482    let mut new_body = block.join("\n");
483    new_body.push('\n');
484    if !remaining.is_empty() {
485        new_body.push('\n');
486        new_body.push_str(remaining);
487        new_body.push('\n');
488    }
489    Some(format!("{head}\n{new_body}"))
490}
491
492/// The end offset of the closing `---` fence line, including its newline.
493fn closing_fence_end(contents: &str) -> Option<usize> {
494    let rest = contents.strip_prefix("---\n")?;
495    let mut offset = contents.len() - rest.len();
496    for line in rest.split_inclusive('\n') {
497        if line.trim_end_matches('\n') == "---" {
498            return Some(offset + line.len());
499        }
500        offset += line.len();
501    }
502    None
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::model::Issue;
509
510    fn issue_with_history(body: &str) -> String {
511        format!(
512            "---\nnumber: 1\ntitle: \"Hist\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{body}"
513        )
514    }
515
516    #[test]
517    fn parse_error_for_a_duplicate_key_offers_the_drop_fix() {
518        let contents = "---\nnumber: 1\ntitle: \"First\"\ntitle: \"Second\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
519        let problem = &for_parse_error("duplicate field `title`", contents, None)[0];
520        assert_eq!(problem.kind, EditProblemKind::DuplicateKey);
521        assert!(problem.proposal.as_deref().unwrap().contains("First"));
522        let fixed = problem.fix().unwrap().apply(contents).unwrap();
523        let issue = Issue::parse(&fixed).unwrap();
524        assert_eq!(
525            issue.title, "First",
526            "the first value must be kept:\n{fixed}"
527        );
528        assert_eq!(fixed.matches("title:").count(), 1);
529    }
530
531    #[test]
532    fn parse_error_for_garbage_has_no_fix_and_says_nothing_was_written() {
533        for message in [
534            "priority: invalid type: sequence, expected a string at line 4 column 11",
535            "file must start with a `---` delimiter",
536            "frontmatter is not terminated by a closing `---`",
537        ] {
538            let problems = for_parse_error(message, "garbage", None);
539            assert_eq!(problems.len(), 1);
540            assert_eq!(problems[0].kind, EditProblemKind::Unparsable);
541            assert_eq!(problems[0].message, message, "the real diagnostic is kept");
542            assert!(problems[0].fix.is_none());
543            assert!(
544                problems[0]
545                    .proposal_text()
546                    .contains("nothing has been written")
547            );
548            assert_ne!(problems[0].message, "file not valid after edit");
549        }
550    }
551
552    #[test]
553    fn history_move_preserves_every_entry_and_the_file_then_parses() {
554        let contents = issue_with_history(
555            "Some prose.\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} created \u{2014} did it\n- 2026-01-02T00:00:00Z \u{2014} bob \u{2014} edited \u{2014} then this\n\nMore prose.\n",
556        );
557        let before = Issue::parse(&contents).unwrap();
558        assert!(before.history.is_empty(), "the section is body text before");
559
560        let fixed = EditFix::MoveHistoryToFront.apply(&contents).unwrap();
561        let after = Issue::parse(&fixed).unwrap();
562        assert_eq!(
563            after.history.entries.len(),
564            2,
565            "both entries are recognised: {fixed}"
566        );
567        for line in contents.lines().filter(|l| l.starts_with("- 20")) {
568            assert!(
569                fixed.contains(line),
570                "entry `{line}` must survive byte-for-byte"
571            );
572        }
573        assert!(after.body.contains("Some prose"));
574        assert!(after.body.contains("More prose"));
575    }
576
577    #[test]
578    fn retired_links_fix_removes_the_key_and_its_block() {
579        let contents =
580            issue_with_history("Body.").replace("created:", "links:\n- \"12\"\n- task-7\ncreated:");
581        let fixed = EditFix::RemoveRetiredLinksKey {
582            key: "links".to_string(),
583        }
584        .apply(&contents)
585        .unwrap();
586        assert!(!fixed.contains("links:"), "{fixed}");
587        assert!(Issue::parse(&fixed).is_ok());
588    }
589
590    #[test]
591    fn restore_title_writes_the_previous_title_back() {
592        let contents = "---\nnumber: 1\ntitle: \"\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
593        let fixed = EditFix::RestoreTitle {
594            title: "Original title".to_string(),
595        }
596        .apply(contents)
597        .unwrap();
598        assert_eq!(Issue::parse(&fixed).unwrap().title, "Original title");
599    }
600
601    fn finding(kind: &str, value: &str, message: &str) -> TixIssueFinding {
602        TixIssueFinding {
603            severity: crate::commands::lint::Severity::Warning,
604            file: "2026-01-01-a.md".to_string(),
605            line: 1,
606            column: 1,
607            value: value.to_string(),
608            kind: kind.to_string(),
609            message: message.to_string(),
610        }
611    }
612
613    #[test]
614    fn every_problem_carries_a_proposal() {
615        let previous = Issue::parse(&issue_with_history("Body.")).unwrap();
616        let findings = vec![
617            finding(
618                "unknown_key",
619                "build_id",
620                "unknown top-level frontmatter key",
621            ),
622            finding(
623                "retired_links_key",
624                "links",
625                "still carries the retired key",
626            ),
627            finding("history", HISTORY_HEADING, "must be the first thing"),
628            finding("history", "- broken", "looks like an entry"),
629            finding("title", "", "title is required"),
630            finding("priority", "nope", "valid options: low, medium, high"),
631        ];
632        let classified = for_lint_findings(&findings, Some(&previous));
633        let parse = for_parse_error("duplicate field `title`", "---\n", None);
634        for problem in classified.iter().chain(parse.iter()) {
635            assert!(
636                problem.proposal.is_some(),
637                "{} must propose something: {problem:?}",
638                problem.kind.as_str()
639            );
640            assert!(!problem.message.is_empty());
641        }
642        // The misplaced-section finding keeps the lint message verbatim and gets
643        // the move fix; the unparsed-entry finding gets a proposal but no fix.
644        assert_eq!(classified[2].kind, EditProblemKind::HistoryNotFirst);
645        assert_eq!(classified[2].message, "must be the first thing");
646        assert_eq!(classified[2].fix, Some(EditFix::MoveHistoryToFront));
647        assert_eq!(classified[3].kind, EditProblemKind::HistoryEntry);
648        assert!(classified[3].fix.is_none());
649        assert_eq!(classified[4].kind, EditProblemKind::MissingTitle);
650        assert_eq!(
651            classified[4].fix,
652            Some(EditFix::RestoreTitle {
653                title: "Hist".to_string()
654            })
655        );
656    }
657
658    #[test]
659    fn a_fix_that_cannot_find_its_shape_refuses_instead_of_guessing() {
660        let contents = issue_with_history("Body.");
661        assert!(
662            EditFix::DropDuplicateKey {
663                key: "title".to_string(),
664                kept: String::new(),
665            }
666            .apply(&contents)
667            .is_none()
668        );
669        assert!(
670            EditFix::RemoveRetiredLinksKey {
671                key: "links".to_string(),
672            }
673            .apply(&contents)
674            .is_none()
675        );
676        assert!(
677            EditFix::RestoreTitle {
678                title: "T".to_string(),
679            }
680            .apply("---\nnumber: 1\n---\n")
681            .is_none()
682        );
683        assert!(
684            EditFix::MoveHistoryToFront
685                .apply("not a document")
686                .is_none()
687        );
688    }
689}