use crate::commands::lint::TixIssueFinding;
use crate::model::{HISTORY_HEADING, Issue, is_history_heading};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditProblemKind {
HistoryNotFirst,
UnknownKey,
DuplicateKey,
RetiredLinksKey,
MissingTitle,
HistoryEntry,
InvalidValue,
Unparsable,
}
impl EditProblemKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::HistoryNotFirst => "history_not_first",
Self::UnknownKey => "unknown_key",
Self::DuplicateKey => "duplicate_key",
Self::RetiredLinksKey => "retired_links_key",
Self::MissingTitle => "missing_title",
Self::HistoryEntry => "history_entry",
Self::InvalidValue => "invalid_value",
Self::Unparsable => "unparsable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditFix {
MoveHistoryToFront,
DeclareCustomAttribute { key: String },
DropDuplicateKey { key: String, kept: String },
RemoveRetiredLinksKey { key: String },
RestoreTitle { title: String },
}
impl EditFix {
pub fn label(&self) -> String {
match self {
Self::MoveHistoryToFront => {
"move the `## History` section to the top of the body".to_string()
}
Self::DeclareCustomAttribute { key } => {
format!("declare `{key}` in `[custom_attributes]` in tix.toml")
}
Self::DropDuplicateKey { key, .. } => {
format!("drop the later duplicate `{key}:`")
}
Self::RemoveRetiredLinksKey { key } => {
format!("remove the retired `{key}:` key")
}
Self::RestoreTitle { title } => format!("restore the title `{title}`"),
}
}
pub const fn is_text_fix(&self) -> bool {
!matches!(self, Self::DeclareCustomAttribute { .. })
}
pub fn apply(&self, contents: &str) -> Option<String> {
match self {
Self::DeclareCustomAttribute { .. } => None,
Self::MoveHistoryToFront => move_history_to_front(contents),
Self::DropDuplicateKey { key, .. } => remove_key_occurrence(contents, key, 1),
Self::RemoveRetiredLinksKey { key } => remove_key_occurrence(contents, key, 0),
Self::RestoreTitle { title } => set_title(contents, title),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditProblem {
pub kind: EditProblemKind,
pub message: String,
pub proposal: Option<String>,
pub fix: Option<EditFix>,
}
impl EditProblem {
pub fn summary(&self) -> String {
format!("{}: {}", self.kind.as_str(), self.message)
}
pub fn proposal_text(&self) -> String {
self.proposal.clone().unwrap_or_else(|| {
"reopen the editor on the retained copy and fix it by hand".to_string()
})
}
pub fn fix(&self) -> Option<&EditFix> {
self.fix.as_ref()
}
}
pub fn for_parse_error(
message: &str,
contents: &str,
_previous: Option<&Issue>,
) -> Vec<EditProblem> {
if let Some(key) = duplicate_field_key(message)
&& let Some(problem) = duplicate_key_problem(contents, &key)
{
return vec![problem];
}
vec![EditProblem {
kind: EditProblemKind::Unparsable,
message: message.to_string(),
proposal: Some(
"reopen the editor on the retained copy and fix it by hand — nothing has \
been written"
.to_string(),
),
fix: None,
}]
}
pub fn for_lint_findings(
findings: &[TixIssueFinding],
previous: Option<&Issue>,
) -> Vec<EditProblem> {
findings
.iter()
.map(|finding| for_lint_finding(finding, previous))
.collect()
}
pub fn missing_title(previous: Option<&Issue>) -> EditProblem {
missing_title_problem(previous, None)
}
fn for_lint_finding(finding: &TixIssueFinding, previous: Option<&Issue>) -> EditProblem {
match finding.kind.as_str() {
"unknown_key" => {
let key = finding.value.trim().to_string();
EditProblem {
kind: EditProblemKind::UnknownKey,
message: finding.message.clone(),
proposal: Some(format!(
"declare `{key}` in `[custom_attributes]` in tix.toml"
)),
fix: (!key.is_empty()).then_some(EditFix::DeclareCustomAttribute { key }),
}
}
"retired_links_key" => EditProblem {
kind: EditProblemKind::RetiredLinksKey,
message: finding.message.clone(),
proposal: Some(
"remove the retired `links:` key — its value is no longer read".to_string(),
),
fix: Some(EditFix::RemoveRetiredLinksKey {
key: if finding.value.trim().is_empty() {
"links".to_string()
} else {
finding.value.trim().to_string()
},
}),
},
"history" if finding.value.trim() == HISTORY_HEADING => EditProblem {
kind: EditProblemKind::HistoryNotFirst,
message: finding.message.clone(),
proposal: Some(
"move the `## History` section to the top of the body, preserving every \
entry"
.to_string(),
),
fix: Some(EditFix::MoveHistoryToFront),
},
"history" => hand_fix_problem(
EditProblemKind::HistoryEntry,
finding.message.clone(),
"reopen the editor on the retained copy and correct the entry by hand — its \
bytes are preserved",
),
"title" => missing_title_problem(previous, Some(finding.message.clone())),
_ => hand_fix_problem(
EditProblemKind::InvalidValue,
format!("{}: {}", finding.kind, finding.message),
"reopen the editor on the retained copy and correct the value by hand",
),
}
}
fn hand_fix_problem(kind: EditProblemKind, message: String, proposal: &str) -> EditProblem {
EditProblem {
kind,
message,
proposal: Some(proposal.to_string()),
fix: None,
}
}
fn missing_title_problem(previous: Option<&Issue>, message: Option<String>) -> EditProblem {
let restored = previous
.map(|issue| issue.title.clone())
.filter(|title| !title.trim().is_empty());
EditProblem {
kind: EditProblemKind::MissingTitle,
message: message.unwrap_or_else(|| "title is required".to_string()),
proposal: Some(match &restored {
Some(title) => format!("restore the pre-edit title `{title}`"),
None => "reopen the editor on the retained copy and add a title".to_string(),
}),
fix: restored.map(|title| EditFix::RestoreTitle { title }),
}
}
fn duplicate_field_key(message: &str) -> Option<String> {
let rest = message.strip_prefix("duplicate field `")?;
let (key, _) = rest.split_once('`')?;
(!key.is_empty()).then(|| key.to_string())
}
fn duplicate_key_problem(contents: &str, key: &str) -> Option<EditProblem> {
if count_key_occurrences(contents, key) < 2 {
return None;
}
let kept = key_value(contents, key, 0).unwrap_or_default();
Some(EditProblem {
kind: EditProblemKind::DuplicateKey,
message: format!("duplicate field `{key}`"),
proposal: Some(format!(
"drop the later `{key}:` entry, keeping the first value (`{kept}`)"
)),
fix: Some(EditFix::DropDuplicateKey {
key: key.to_string(),
kept,
}),
})
}
fn frontmatter_region(contents: &str) -> Option<(usize, usize)> {
let rest = contents.strip_prefix("---\n")?;
let start = contents.len() - rest.len();
let mut offset = start;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches('\n') == "---" {
return Some((start, offset));
}
offset += line.len();
}
None
}
fn top_level_key(line: &str) -> Option<String> {
if line.is_empty() || line.starts_with([' ', '\t', '#']) {
return None;
}
let (head, _) = line.split_once(':')?;
let key = strip_quotes(head.trim());
(!key.is_empty()).then(|| key.to_string())
}
fn strip_quotes(value: &str) -> &str {
if let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
return inner;
}
if let Some(inner) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
return inner;
}
value
}
fn key_spans(contents: &str, key: &str) -> Vec<(usize, usize)> {
let Some((start, end)) = frontmatter_region(contents) else {
return Vec::new();
};
let mut spans = Vec::new();
let mut offset = start;
while offset < end {
let line_end = contents[offset..]
.find('\n')
.map_or(end, |index| offset + index + 1);
let line = &contents[offset..line_end];
if top_level_key(line).as_deref() == Some(key) {
let mut block_end = line_end;
while block_end < end {
let next_end = contents[block_end..]
.find('\n')
.map_or(end, |index| block_end + index + 1);
let next = &contents[block_end..next_end];
if next.starts_with(' ') || next.starts_with('\t') || next.starts_with("- ") {
block_end = next_end;
} else {
break;
}
}
spans.push((offset, block_end));
}
offset = line_end;
}
spans
}
fn count_key_occurrences(contents: &str, key: &str) -> usize {
key_spans(contents, key).len()
}
fn key_value(contents: &str, key: &str, occurrence: usize) -> Option<String> {
let spans = key_spans(contents, key);
let (start, end) = *spans.get(occurrence)?;
let block = &contents[start..end];
let mut lines = block.lines();
let head = lines.next()?;
let inline = strip_quotes(head.split_once(':').map_or("", |(_, v)| v).trim());
if !inline.is_empty() {
return Some(inline.to_string());
}
lines
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}
fn remove_key_occurrence(contents: &str, key: &str, occurrence: usize) -> Option<String> {
let (start, end) = *key_spans(contents, key).get(occurrence)?;
let mut out = String::with_capacity(contents.len());
out.push_str(&contents[..start]);
out.push_str(&contents[end..]);
Some(out)
}
fn set_title(contents: &str, title: &str) -> Option<String> {
let (start, end) = *key_spans(contents, "title").first()?;
let line = &contents[start..end];
let colon = line.find(':')?;
let rendered = serde_yaml::to_string(title)
.map(|yaml| yaml.trim_end().to_string())
.unwrap_or_else(|_| format!("{title:?}"));
let mut out = String::with_capacity(contents.len());
out.push_str(&contents[..=start + colon]);
out.push(' ');
out.push_str(&rendered);
out.push('\n');
out.push_str(&contents[end..]);
Some(out)
}
fn move_history_to_front(contents: &str) -> Option<String> {
let fence_end = closing_fence_end(contents)?;
let head = &contents[..fence_end];
let mut body = &contents[fence_end..];
if let Some(rest) = body.strip_prefix('\n') {
body = rest;
}
let lines: Vec<&str> = body.split('\n').collect();
let heading = lines.iter().position(|line| is_history_heading(line))?;
if heading == 0 {
return None;
}
let mut block_end = heading + 1;
while block_end < lines.len()
&& (lines[block_end].trim().is_empty() || lines[block_end].trim_start().starts_with("- "))
{
block_end += 1;
}
while block_end > heading + 1 && lines[block_end - 1].trim().is_empty() {
block_end -= 1;
}
let block: Vec<&str> = lines[heading..block_end].to_vec();
let mut remaining: Vec<&str> = Vec::new();
remaining.extend_from_slice(&lines[..heading]);
remaining.extend_from_slice(&lines[block_end..]);
let remaining = remaining.join("\n");
let remaining = remaining.trim_matches('\n');
let mut new_body = block.join("\n");
new_body.push('\n');
if !remaining.is_empty() {
new_body.push('\n');
new_body.push_str(remaining);
new_body.push('\n');
}
Some(format!("{head}\n{new_body}"))
}
fn closing_fence_end(contents: &str) -> Option<usize> {
let rest = contents.strip_prefix("---\n")?;
let mut offset = contents.len() - rest.len();
for line in rest.split_inclusive('\n') {
if line.trim_end_matches('\n') == "---" {
return Some(offset + line.len());
}
offset += line.len();
}
None
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::Issue;
fn issue_with_history(body: &str) -> String {
format!(
"---\nnumber: 1\ntitle: \"Hist\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{body}"
)
}
#[test]
fn parse_error_for_a_duplicate_key_offers_the_drop_fix() {
let contents = "---\nnumber: 1\ntitle: \"First\"\ntitle: \"Second\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
let problem = &for_parse_error("duplicate field `title`", contents, None)[0];
assert_eq!(problem.kind, EditProblemKind::DuplicateKey);
assert!(problem.proposal.as_deref().unwrap().contains("First"));
let fixed = problem.fix().unwrap().apply(contents).unwrap();
let issue = Issue::parse(&fixed).unwrap();
assert_eq!(
issue.title, "First",
"the first value must be kept:\n{fixed}"
);
assert_eq!(fixed.matches("title:").count(), 1);
}
#[test]
fn parse_error_for_garbage_has_no_fix_and_says_nothing_was_written() {
for message in [
"priority: invalid type: sequence, expected a string at line 4 column 11",
"file must start with a `---` delimiter",
"frontmatter is not terminated by a closing `---`",
] {
let problems = for_parse_error(message, "garbage", None);
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].kind, EditProblemKind::Unparsable);
assert_eq!(problems[0].message, message, "the real diagnostic is kept");
assert!(problems[0].fix.is_none());
assert!(
problems[0]
.proposal_text()
.contains("nothing has been written")
);
assert_ne!(problems[0].message, "file not valid after edit");
}
}
#[test]
fn history_move_preserves_every_entry_and_the_file_then_parses() {
let contents = issue_with_history(
"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",
);
let before = Issue::parse(&contents).unwrap();
assert!(before.history.is_empty(), "the section is body text before");
let fixed = EditFix::MoveHistoryToFront.apply(&contents).unwrap();
let after = Issue::parse(&fixed).unwrap();
assert_eq!(
after.history.entries.len(),
2,
"both entries are recognised: {fixed}"
);
for line in contents.lines().filter(|l| l.starts_with("- 20")) {
assert!(
fixed.contains(line),
"entry `{line}` must survive byte-for-byte"
);
}
assert!(after.body.contains("Some prose"));
assert!(after.body.contains("More prose"));
}
#[test]
fn retired_links_fix_removes_the_key_and_its_block() {
let contents =
issue_with_history("Body.").replace("created:", "links:\n- \"12\"\n- task-7\ncreated:");
let fixed = EditFix::RemoveRetiredLinksKey {
key: "links".to_string(),
}
.apply(&contents)
.unwrap();
assert!(!fixed.contains("links:"), "{fixed}");
assert!(Issue::parse(&fixed).is_ok());
}
#[test]
fn restore_title_writes_the_previous_title_back() {
let contents = "---\nnumber: 1\ntitle: \"\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
let fixed = EditFix::RestoreTitle {
title: "Original title".to_string(),
}
.apply(contents)
.unwrap();
assert_eq!(Issue::parse(&fixed).unwrap().title, "Original title");
}
fn finding(kind: &str, value: &str, message: &str) -> TixIssueFinding {
TixIssueFinding {
severity: crate::commands::lint::Severity::Warning,
file: "2026-01-01-a.md".to_string(),
line: 1,
column: 1,
value: value.to_string(),
kind: kind.to_string(),
message: message.to_string(),
}
}
#[test]
fn every_problem_carries_a_proposal() {
let previous = Issue::parse(&issue_with_history("Body.")).unwrap();
let findings = vec![
finding(
"unknown_key",
"build_id",
"unknown top-level frontmatter key",
),
finding(
"retired_links_key",
"links",
"still carries the retired key",
),
finding("history", HISTORY_HEADING, "must be the first thing"),
finding("history", "- broken", "looks like an entry"),
finding("title", "", "title is required"),
finding("priority", "nope", "valid options: low, medium, high"),
];
let classified = for_lint_findings(&findings, Some(&previous));
let parse = for_parse_error("duplicate field `title`", "---\n", None);
for problem in classified.iter().chain(parse.iter()) {
assert!(
problem.proposal.is_some(),
"{} must propose something: {problem:?}",
problem.kind.as_str()
);
assert_ne!(problem.message, "");
}
assert_eq!(classified[2].kind, EditProblemKind::HistoryNotFirst);
assert_eq!(classified[2].message, "must be the first thing");
assert_eq!(classified[2].fix, Some(EditFix::MoveHistoryToFront));
assert_eq!(classified[3].kind, EditProblemKind::HistoryEntry);
assert!(classified[3].fix.is_none());
assert_eq!(classified[4].kind, EditProblemKind::MissingTitle);
assert_eq!(
classified[4].fix,
Some(EditFix::RestoreTitle {
title: "Hist".to_string()
})
);
}
#[test]
fn a_fix_that_cannot_find_its_shape_refuses_instead_of_guessing() {
let contents = issue_with_history("Body.");
assert!(
EditFix::DropDuplicateKey {
key: "title".to_string(),
kept: String::new(),
}
.apply(&contents)
.is_none()
);
assert!(
EditFix::RemoveRetiredLinksKey {
key: "links".to_string(),
}
.apply(&contents)
.is_none()
);
assert!(
EditFix::RestoreTitle {
title: "T".to_string(),
}
.apply("---\nnumber: 1\n---\n")
.is_none()
);
assert!(
EditFix::MoveHistoryToFront
.apply("not a document")
.is_none()
);
}
}