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