use std::fs;
use std::ops::Range;
use std::path::Path;
use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority};
use crate::storage::Store;
#[derive(Debug)]
pub struct TixIssueFinding {
pub file: String,
pub line: usize,
pub column: usize,
pub value: String,
pub kind: String,
pub message: String,
}
pub fn run(config: &Config, store: &Store) -> Result<bool> {
let findings = collect(config, store)?;
if findings.is_empty() {
println!("Lint OK: {} issues checked.", issue_files(store)?.len());
return Ok(false);
}
for finding in &findings {
render_diagnostic(&store.issues_dir, finding);
}
Ok(true)
}
pub fn collect(config: &Config, store: &Store) -> Result<Vec<TixIssueFinding>> {
let files = issue_files(store)?;
let mut findings: Vec<TixIssueFinding> = Vec::new();
for rel in &files {
collect_for_file(config, store, rel, &mut findings)?;
}
Ok(findings)
}
fn issue_files(store: &Store) -> Result<Vec<String>> {
let mut files: Vec<String> = Vec::new();
for entry in fs::read_dir(&store.issues_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
if path.is_dir() {
if name.starts_with(".tix-tmp-") {
continue;
}
for child in fs::read_dir(&path)? {
let child = child?;
if !child.path().is_file() {
continue;
}
let child_name = child.file_name().to_string_lossy().to_string();
if !child_name.ends_with(".md") {
continue;
}
files.push(format!("{name}/{child_name}"));
}
} else if path.is_file() && name.ends_with(".md") {
files.push(name);
}
}
files.sort();
Ok(files)
}
fn collect_for_file(
config: &Config,
store: &Store,
rel: &str,
findings: &mut Vec<TixIssueFinding>,
) -> Result<()> {
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 {
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(());
}
Err(err) => return Err(TixError::Io(err)),
};
let issue = match Issue::parse(&text) {
Ok(issue) => issue,
Err(TixError::Parse(msg)) => {
findings.push(TixIssueFinding {
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(());
}
Err(other) => return Err(other),
};
validate_issue(config, rel, &text, &issue, findings);
Ok(())
}
fn validate_issue(
config: &Config,
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 let Priority::Other(unknown) = &issue.priority {
findings.push(value_finding(
rel,
text,
"priority",
unknown,
"valid options: low, medium, high".to_string(),
));
}
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
&& estimate.parse::<f64>().is_err()
{
findings.push(value_finding(
rel,
text,
"estimate",
estimate,
"estimate must be numeric".to_string(),
));
}
}
fn value_finding(
rel: &str,
text: &str,
kind: &str,
value: &str,
message: String,
) -> TixIssueFinding {
let (line, column) = locate_value(text, kind)
.map(|l| (l.line, l.column))
.unwrap_or((0, 0));
TixIssueFinding {
file: rel.to_string(),
line,
column,
value: value.to_string(),
kind: kind.to_string(),
message,
}
}
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,
start,
end: line_start + content.len(),
});
}
line_start += line.len();
}
None
}
struct Located {
line: usize,
column: usize,
start: usize,
end: usize,
}
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)
}
fn render_diagnostic(issues_dir: &Path, f: &TixIssueFinding) {
let renderer = Renderer::styled();
let title = match f.kind.as_str() {
"missing" => "missing frontmatter".to_string(),
"parse" => "parse error".to_string(),
_ => format!("invalid {} value '{}'", f.kind, f.value),
};
let text = fs::read_to_string(issues_dir.join(&f.file)).ok();
let group = match text {
Some(text) => Level::ERROR.primary_title(title).id("E-tix-lint").element(
Snippet::source(text.clone())
.line_start(1)
.path(&f.file)
.annotation(
AnnotationKind::Primary
.span(span_for(f, &text))
.label(&f.message),
),
),
None => Group::with_title(
Level::ERROR
.primary_title(format!("{}: {title}", f.file))
.id("E-tix-lint"),
),
};
eprintln!("{}", renderer.render(&[group]));
}
fn span_for(f: &TixIssueFinding, text: &str) -> Range<usize> {
if f.line > 0 {
if let Some(loc) = locate_value(text, &f.kind) {
return loc.start..loc.end;
}
return 0..text.len();
}
let first = text.lines().next().map(str::len).unwrap_or(0);
0..if first > 0 { first } else { text.len() }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn write(store: &Store, name: &str, contents: &str) {
if let Some(dir) = name.rsplit_once('/').map(|(d, _)| d) {
fs::create_dir_all(store.issues_dir.join(dir)).unwrap();
}
fs::write(store.issues_dir.join(name), contents).unwrap();
}
const CLEAN: &str = "---\nnumber: 1\ntitle: \"A clean issue\"\nstatus: todo\npriority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n";
#[test]
fn collect_is_empty_on_a_clean_store() {
let (tmp, store) = make_store();
let _ = tmp;
write(&store, "alice/2026-01-01-clean.md", CLEAN);
let findings = collect(&store.config, &store).unwrap();
assert!(findings.is_empty());
}
#[test]
fn collect_flags_invalid_status_and_priority() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-broken.md",
"---\nnumber: 2\ntitle: \"Broken\"\nstatus: shipped\npriority: critical\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
);
let findings = collect(&store.config, &store).unwrap();
let kinds: Vec<&str> = findings.iter().map(|f| f.kind.as_str()).collect();
assert!(
kinds.contains(&"status"),
"unknown status must be flagged, got {kinds:?}"
);
assert!(
kinds.contains(&"priority"),
"unknown priority must be flagged, got {kinds:?}"
);
assert!(
findings.iter().any(|f| f.kind == "status"
&& f.message
.contains("valid options: todo, in progress, in review, done")),
"status finding must name the valid set, got {findings:?}"
);
}
#[test]
fn collect_reports_unparseable_file_as_missing_finding() {
let (tmp, store) = make_store();
let _ = tmp;
write(
&store,
"alice/2026-01-01-not-frontmatter.md",
"Just prose, no frontmatter.\n",
);
let findings = collect(&store.config, &store).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, "missing");
assert_eq!(findings[0].file, "alice/2026-01-01-not-frontmatter.md");
}
}