1use crate::commands::lint::TixIssueFinding;
30use crate::model::{HISTORY_HEADING, Issue, is_history_heading};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum EditProblemKind {
35 HistoryNotFirst,
37 UnknownKey,
39 DuplicateKey,
41 RetiredLinksKey,
43 MissingTitle,
45 HistoryEntry,
48 InvalidValue,
50 Unparsable,
53}
54
55impl EditProblemKind {
56 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#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum EditFix {
79 MoveHistoryToFront,
82 DeclareCustomAttribute { key: String },
85 DropDuplicateKey { key: String, kept: String },
87 RemoveRetiredLinksKey { key: String },
89 RestoreTitle { title: String },
91}
92
93impl EditFix {
94 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 pub const fn is_text_fix(&self) -> bool {
116 !matches!(self, Self::DeclareCustomAttribute { .. })
117 }
118
119 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#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct EditProblem {
139 pub kind: EditProblemKind,
141 pub message: String,
144 pub proposal: Option<String>,
147 pub fix: Option<EditFix>,
149}
150
151impl EditProblem {
152 pub fn summary(&self) -> String {
154 format!("{}: {}", self.kind.as_str(), self.message)
155 }
156
157 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 pub fn fix(&self) -> Option<&EditFix> {
167 self.fix.as_ref()
168 }
169}
170
171pub 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
200pub 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
211pub 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 "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
296fn 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
322fn 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
337fn 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
358fn 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 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 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
401fn 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
419fn 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
428fn 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
445fn 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 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 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
492fn 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 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}