1use crate::commands::lint::TixIssueFinding;
30use crate::diagnostics::Diagnostic;
31use crate::model::{HISTORY_HEADING, Issue, is_history_heading};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum EditProblemKind {
36 HistoryNotFirst,
38 UnknownKey,
40 DuplicateKey,
42 RetiredLinksKey,
44 MissingTitle,
46 HistoryEntry,
49 InvalidValue,
51 Unparsable,
54}
55
56impl EditProblemKind {
57 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#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum EditFix {
80 MoveHistoryToFront,
83 DeclareCustomAttribute { key: String },
86 DropDuplicateKey { key: String, kept: String },
88 RemoveRetiredLinksKey { key: String },
90 RestoreTitle { title: String },
92}
93
94impl EditFix {
95 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 pub const fn is_text_fix(&self) -> bool {
117 !matches!(self, Self::DeclareCustomAttribute { .. })
118 }
119
120 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#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct EditProblem {
140 pub kind: EditProblemKind,
142 pub message: String,
145 pub proposal: Option<String>,
148 pub fix: Option<EditFix>,
150}
151
152impl EditProblem {
153 pub fn summary(&self) -> String {
155 format!("{}: {}", self.kind.as_str(), self.message)
156 }
157
158 #[must_use]
167 pub fn diagnostic(&self) -> Diagnostic {
168 Diagnostic::error("E-tix-edit", self.summary()).with_help(self.proposal_text())
169 }
170
171 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 pub fn fix(&self) -> Option<&EditFix> {
181 self.fix.as_ref()
182 }
183}
184
185pub 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
214pub 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
225pub 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 "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
310fn 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
336fn 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
351fn 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
372fn 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 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 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
415fn 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
433fn 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
442fn 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
459fn 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 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 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
506fn 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 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}