#[allow(clippy::wildcard_imports)]
use super::*;
pub(super) struct ParsedFile {
pub(super) rel: String,
pub(super) text: String,
pub(super) issue: Issue,
}
pub(super) fn collect_for_file(
config: &Config,
store: &Store,
planner: &Planner,
milestone_names: &[String],
rel: &str,
findings: &mut Vec<TixIssueFinding>,
) -> Result<Option<ParsedFile>> {
let text = match fs::read_to_string(store.issues_dir.join(rel)) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
findings.push(TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line: 0,
column: 0,
value: String::new(),
kind: "parse".to_string(),
message: "file is not valid UTF-8".to_string(),
});
return Ok(None);
}
Err(err) => return Err(TixError::Io(err)),
};
let issue = match Issue::parse(&text) {
Ok(issue) => issue,
Err(TixError::Parse(msg)) => {
findings.push(TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line: 0,
column: 0,
value: String::new(),
kind: if text.starts_with("---\n") {
"parse".to_string()
} else {
"missing".to_string()
},
message: msg,
});
return Ok(None);
}
Err(other) => return Err(other),
};
validate_issue(
config,
planner,
milestone_names,
rel,
&text,
&issue,
findings,
);
unknown_frontmatter_keys(config, rel, &text, &issue, findings);
Ok(Some(ParsedFile {
rel: rel.to_string(),
text,
issue,
}))
}
pub(super) fn unknown_frontmatter_keys(
config: &Config,
rel: &str,
text: &str,
issue: &Issue,
findings: &mut Vec<TixIssueFinding>,
) {
let declared = config.custom_attributes();
for key in issue.extra_frontmatter.keys() {
if declared.iter().any(|d| d == key) {
continue;
}
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: rel.to_string(),
line: locate_key_line(text, key),
column: 1,
value: key.clone(),
kind: "unknown_key".to_string(),
message: format!(
"unknown top-level frontmatter key `{key}`: it is preserved on write but \
nothing reads it. If that is deliberate, declare it in \
`[custom_attributes]` in tix.toml; if not, it is probably a typo."
),
});
}
}
pub(super) fn locate_key_line(text: &str, key: &str) -> usize {
let frontmatter = match text.strip_prefix("---\n") {
Some(rest) => rest,
None => return 0,
};
for (offset, line) in frontmatter.lines().enumerate() {
if line == key
|| line.starts_with(&format!("{key}:"))
|| line.starts_with(&format!("\"{key}\":"))
{
return offset + 2; }
if line == "---" {
break;
}
}
0
}
#[allow(clippy::too_many_lines)]
pub(super) fn validate_issue(
config: &Config,
planner: &Planner,
milestone_names: &[String],
rel: &str,
text: &str,
issue: &Issue,
findings: &mut Vec<TixIssueFinding>,
) {
let status = issue.status.display();
let columns = &config.default_config().columns;
if !columns.iter().any(|c| c == status) {
findings.push(value_finding(
rel,
text,
"status",
status,
format!("valid options: {}", columns.join(", ")),
));
}
if issue.title.trim().is_empty() {
findings.push(value_finding(
rel,
text,
"title",
&issue.title,
"title is required".to_string(),
));
}
if let Priority::Other(unknown) = &issue.priority
&& !issue.priority.is_undefined()
{
findings.push(value_finding(
rel,
text,
"priority",
unknown,
"valid options: '' (unset), low, medium, high".to_string(),
));
}
if let Some(t) = issue
.type_
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
{
let allowed = config.custom_types();
if !allowed.iter().any(|a| a.eq_ignore_ascii_case(t)) {
let located = locate_value(text, "type");
findings.push(TixIssueFinding {
severity: Severity::Warning,
file: rel.to_string(),
line: located.as_ref().map_or(0, |l| l.line),
column: located.as_ref().map_or(0, |l| l.column),
value: t.to_string(),
kind: "unknown_type".to_string(),
message: format!(
"unknown issue type `{t}`; valid types: {}. Add it to \
`types` in tix.toml to accept it.",
allowed.join(", ")
),
});
}
}
if !is_iso_date(&issue.created) {
findings.push(value_finding(
rel,
text,
"created",
&issue.created,
"expected a date in `YYYY-MM-DD` format".to_string(),
));
}
if !is_iso_date(&issue.updated) {
findings.push(value_finding(
rel,
text,
"updated",
&issue.updated,
"expected a date in `YYYY-MM-DD` format".to_string(),
));
}
if let Some(estimate) = &issue.estimate
&& crate::wip::Estimate::parse_terms(estimate).is_err()
{
findings.push(value_finding(
rel,
text,
"estimate",
estimate,
"estimate must be a duration such as `3`, `3d`, `1 day` or `0.5-1 day`".to_string(),
));
}
let fenced = fenced_lines(&issue.body);
let body_line = body_start_line(text, &issue.body);
for (index, line) in issue.body.lines().enumerate() {
if fenced.get(index).copied().unwrap_or(false) {
continue;
}
if crate::model::is_history_heading(line) {
findings.push(finding_at_line(
rel,
body_line + index,
1,
"history",
line.trim(),
"a `## History` section must be the first thing in the body; \
this one is treated as body text, so its entries are not in the \
audit trail and the next `tix edit --body` deletes them"
.to_string(),
));
}
}
for (index, line) in issue.body.lines().enumerate() {
if fenced.get(index).copied().unwrap_or(false) {
continue;
}
if crate::model::looks_like_an_unrecognised_entry(line) {
findings.push(finding_at_line(
rel,
body_line + index,
1,
"history",
line.trim(),
"looks like a `## History` entry but was not recognised \u{2014} \
entries need U+2014 em-dash separators, not ASCII hyphens"
.to_string(),
));
}
}
for line in issue.history.unparsed() {
let finding = value_finding(
rel,
text,
"history",
line,
"unparseable `## History` entry \u{2014} kept verbatim, never repaired; \
expected `- <timestamp> \u{2014} <actor> \u{2014} <kind> \u{2014} <detail>` \
with U+2014 em-dash separators"
.to_string(),
);
findings.push(finding);
}
if let Some(cycles) = &issue.cycles {
for name in cycles {
if planner.cycle(name).is_none() {
let known = planner.cycle_names();
let list = if known.is_empty() {
"none configured".to_string()
} else {
known.join(", ")
};
findings.push(value_finding(
rel,
text,
"cycles",
name,
format!("unknown cycle — valid cycles: {list}"),
));
}
}
}
if let Some(milestones) = &issue.milestones {
for name in milestones {
if planner.milestone(name).is_err() || !milestone_names.iter().any(|m| m == name) {
let list = if milestone_names.is_empty() {
"none configured".to_string()
} else {
milestone_names.join(", ")
};
findings.push(value_finding(
rel,
text,
"milestones",
name,
format!("unknown milestone — valid milestones: {list}"),
));
}
}
}
if let Err(TixError::Config(msg)) =
crate::model::validate_issue_dates(issue.start_date.as_deref(), issue.due_date.as_deref())
{
let (kind, value) = if msg.starts_with("start_date") {
("start_date", issue.start_date.as_deref().unwrap_or(""))
} else {
("due_date", issue.due_date.as_deref().unwrap_or(""))
};
findings.push(value_finding(rel, text, kind, value, msg));
}
}
pub(super) fn value_finding(
rel: &str,
text: &str,
kind: &str,
value: &str,
message: String,
) -> TixIssueFinding {
finding_at(rel, text, kind, kind, value, message)
}
pub(super) fn finding_at(
rel: &str,
text: &str,
kind: &str,
locate_key: &str,
value: &str,
message: String,
) -> TixIssueFinding {
let (line, column) = locate_value(text, locate_key)
.map(|l| (l.line, l.column))
.unwrap_or((0, 0));
TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line,
column,
value: value.to_string(),
kind: kind.to_string(),
message,
}
}
pub(super) fn finding_at_line(
rel: &str,
line: usize,
column: usize,
kind: &str,
value: &str,
message: String,
) -> TixIssueFinding {
TixIssueFinding {
severity: Severity::Error,
file: rel.to_string(),
line,
column,
value: value.to_string(),
kind: kind.to_string(),
message,
}
}
pub(super) fn fenced_lines(body: &str) -> Vec<bool> {
let mut flags = Vec::new();
let mut fence: Option<(char, usize)> = None;
for line in body.lines() {
let trimmed = line.trim_start();
let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~');
let run = marker.map_or(0, |c| trimmed.chars().take_while(|x| *x == c).count());
flags.push(fence.is_some());
match fence {
Some((c, len)) => {
if marker == Some(c) && run >= len && trimmed[run..].trim().is_empty() {
fence = None;
}
}
None if run >= 3 => fence = marker.map(|c| (c, run)),
None => {}
}
}
flags
}
pub(super) fn body_start_line(text: &str, body: &str) -> usize {
let start = text.len().saturating_sub(body.len());
text[..start].matches('\n').count() + 1
}
pub(super) fn locate_value(text: &str, key: &str) -> Option<Located> {
let prefix = format!("{key}:");
let fm_end = text[4..].find("\n---").map(|i| i + 4).unwrap_or(text.len());
let fm = &text[..fm_end];
let mut line_start = 0usize;
for (idx, line) in fm.split_inclusive('\n').enumerate() {
let content = line.trim_end_matches(['\n', '\r']);
if let Some(rest) = content.trim_start().strip_prefix(&prefix) {
let pad = content.len() - content.trim_start().len();
let value_skip = rest.len() - rest.trim_start().len();
let start = line_start + pad + prefix.len() + value_skip;
return Some(Located {
line: idx + 1,
column: start - line_start + 1,
});
}
line_start += line.len();
}
None
}
pub(super) struct Located {
pub(super) line: usize,
pub(super) column: usize,
}
pub(super) fn is_iso_date(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
for &c in &b[0..4] {
if !c.is_ascii_digit() {
return false;
}
}
for &c in &[b[5], b[6], b[8], b[9]] {
if !c.is_ascii_digit() {
return false;
}
}
let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
(1..=12).contains(&month) && (1..=31).contains(&day)
}
pub(super) fn render_diagnostic(issues_dir: &Path, f: &TixIssueFinding) -> String {
let title = match f.kind.as_str() {
"missing" => "missing frontmatter".to_string(),
"parse" => "parse error".to_string(),
"over_limit" => "work-in-progress limit exceeded".to_string(),
"duplicate_number" => "duplicate issue number".to_string(),
"malformed_estimate" | "missing_conversion" | "ambiguous_conversion" => {
"estimate cannot be counted".to_string()
}
_ => format!("invalid {} value '{}'", f.kind, f.value),
};
let id = match f.severity {
Severity::Error => "E-tix-lint",
Severity::Warning => "W-tix-lint",
};
let mut diagnostic = match f.severity {
Severity::Error => Diagnostic::error(id, title),
Severity::Warning => Diagnostic::warning(id, title),
};
let path = issues_dir.join(&f.file);
match fs::read_to_string(&path) {
Ok(text) if !f.file.is_empty() => {
diagnostic = diagnostic
.at(&path, span_for(f, &text))
.labelled(&f.message);
}
_ if !f.message.is_empty() => {
let note = if f.file.is_empty() {
f.message.clone()
} else {
format!("{}: {}", f.file, f.message)
};
diagnostic = diagnostic.with_note(note);
}
_ => {}
}
if let Some(help) = diagnostic_help(&f.kind) {
diagnostic = diagnostic.with_help(help);
}
diagnostic.render_auto()
}
pub(super) fn diagnostic_help(kind: &str) -> Option<&'static str> {
Some(match kind {
"status" => "set `status:` to one of the board's configured columns",
"priority" => "use `''` (unset), `low`, `medium`, or `high`",
"title" => "give the issue a non-empty `title:`",
"unknown_type" => "add the type to `types` in tix.toml, or fix the spelling",
"created" | "updated" => "use `YYYY-MM-DD`, e.g. `2026-10-06`",
"estimate" => {
"use a duration, e.g. `estimate: 3`, `estimate: 1 day`, or `estimate: 0.5-1 day`"
}
"unknown_key" => "declare it in `[custom_attributes]` in tix.toml, or remove it",
"cycles" => "create it with `tix cycle create`, or fix the name",
"milestones" => "create it with `tix milestone create`, or fix the name",
"start_date" | "due_date" => {
"use `YYYY-MM-DD`, and keep `due_date` on or after `start_date`"
}
"history" => {
"keep one `## History` section as the first block of the body, with U+2014 \
em-dash separators between the fields"
}
"related_issue_empty" => "remove the empty entry, or name an issue",
"related_issue_unresolved" => {
"point at an issue in this store; paths and URLs are accepted verbatim"
}
"missing" => "add frontmatter delimited by `---` at the top of the file",
"parse" => "fix the parse error named on the annotation",
"layout_mismatch" => "run `tix layout migrate`",
"duplicate_number" => {
"renumber one file above the highest number and update every `parent:`, \
relationship edge and link that named it; lint does not renumber"
}
"foreign_repo_not_linted" => "check the path in `related_issues`",
"retired_links_key" => "recreate the links with `tix link`, then delete the `links:` key",
"self_link" => "a relationship cannot point at its own issue",
"missing_target" => "name an issue that exists",
"ambiguous_target" => "replace the numeric alias with one canonical issue id",
"duplicate_identity" => "give each issue a distinct 8-character canonical id",
"conflicting_edge" => "keep one edge per pair, or use `related` for a plain link",
"unknown_kind" => "use one of: `blocks`, `duplicates`, `related`",
"unknown_relation" => "run `tix link list` to see the stored relationship ids",
"self_parent" => "clear `parent:`, or point it at a different issue",
"unresolved_parent" | "unknown_parent" => {
"point `parent:` at an issue that exists, or clear it with `tix edit --parent ''`"
}
"missing_project_reference" => {
"point the association at a live project, or remove the dangling id with \
`tix project remove <id> <issue>` / `tix epic edit`"
}
"missing_epic_reference" => {
"recreate the epic, drop the reference with `tix epic remove <id> <sel>`, \
or clear the parent with `tix epic edit <id> --parent ''`"
}
"missing_issue_reference" => {
"remove the malformed entry from `epics:`, or replace it with an epic id"
}
"missing_milestone_reference" => {
"create it with `tix milestone create`, or clear the epic's milestone"
}
"missing_cycle_reference" => "create it with `tix cycle create`, or clear the epic's cycle",
"unmapped_column_default" => {
"optional: add a `[kanban.<board>.category_map]` table to silence this"
}
"direct_edit_policy_violation" => {
"resolve the contradiction yourself — `tix lint` never edits the file"
}
"unknown_column" => "use a column the board configures",
"invalid_transition" => "run `tix workflow show` to see the permitted transitions",
"missing_completion_category" => {
"map a column to `completed` in the board's `category_map`"
}
"unknown_category" => "use a category from `tix workflow categories`",
"mapping_for_unknown_column" => "map only columns the board configures",
"cycle" => "break the loop by removing one edge",
_ => return None,
})
}
pub(super) fn span_for(f: &TixIssueFinding, text: &str) -> Range<usize> {
if f.line > 0 {
let mut line_start = 0usize;
for (idx, line) in text.split_inclusive('\n').enumerate() {
if idx + 1 == f.line {
let start = line_start + (f.column - 1).min(line.len());
let end = line_start + line.trim_end_matches(['\n', '\r']).len();
return start..end.max(start);
}
line_start += line.len();
}
let first = text.lines().next().map(str::len).unwrap_or(0);
return 0..if first > 0 { first } else { text.len() };
}
let first = text.lines().next().map(str::len).unwrap_or(0);
0..if first > 0 { first } else { text.len() }
}