use std::path::Path;
use anyhow::Result;
use crate::core::TicketFile;
const CANONICAL_ORDER: &[&str] = &[
"id",
"title",
"status",
"blocked_by",
"priority",
"env",
"spec",
"validation_criteria",
];
pub fn run(check: bool, ids: &[String]) -> Result<i32> {
let tickets_dir = crate::commands::common::tickets_dir()?;
let files = collect_files(&tickets_dir, ids)?;
if files.is_empty() {
if !ids.is_empty() {
eprintln!("tkt: no matching tickets found");
return Ok(1);
}
eprintln!("tkt: no tickets in .tickets/");
return Ok(0);
}
let mut changed = 0u32;
let mut errors = 0u32;
for path in &files {
let tf = match TicketFile::parse(path) {
Ok(tf) => tf,
Err(e) => {
eprintln!(" skip {}: {}", path.display(), e);
errors += 1;
continue;
}
};
let original = std::fs::read_to_string(path)?;
let canonical = render_canonical(&tf);
if original != canonical {
changed += 1;
let name = path.file_name().unwrap_or_default().to_string_lossy();
if check {
eprintln!(" would change: {}", name);
} else {
std::fs::write(path, &canonical)?;
eprintln!(" ✓ {}", name);
}
}
}
if check {
if changed > 0 {
eprintln!("\ntkt lint: {} file(s) would change", changed);
Ok(1)
} else {
eprintln!("tkt lint: all files canonical");
Ok(0)
}
} else {
if changed > 0 {
eprintln!("\ntkt lint: {} file(s) normalized", changed);
} else {
eprintln!("tkt lint: all files already canonical");
}
if errors > 0 {
eprintln!("tkt lint: {} file(s) skipped (parse errors)", errors);
}
Ok(0)
}
}
fn render_canonical(tf: &TicketFile) -> String {
let mut ordered: Vec<(String, String)> = Vec::new();
for &field in CANONICAL_ORDER {
if let Some(entry) = tf.fm.iter().find(|(k, _)| k == field) {
ordered.push((entry.0.clone(), normalize_value(field, &entry.1)));
}
}
for (k, v) in &tf.fm {
if k.is_empty() {
continue; }
if !CANONICAL_ORDER.contains(&k.as_str()) {
ordered.push((k.clone(), normalize_value(k, v)));
}
}
let mut parts = vec!["---".to_string()];
for (k, v) in &ordered {
if v.contains('\n') {
parts.push(format!("{}:{}", k, v));
} else {
parts.push(format!("{}: {}", k, v.trim_start()));
}
}
parts.push("---".to_string());
let header = parts.join("\n");
let body = tf.body.trim_start_matches('\n');
if body.is_empty() {
format!("{}\n", header)
} else {
format!("{}\n\n{}", header, body)
}
}
fn normalize_value(key: &str, raw: &str) -> String {
if raw.contains('\n') {
return raw.to_string();
}
let trimmed = raw.trim();
match key {
"id" => {
let unquoted = trimmed.trim_matches('"').trim_matches('\'');
format!("\"{}\"", unquoted)
}
"blocked_by" => normalize_blocked_by(trimmed),
_ => trimmed.to_string(),
}
}
fn normalize_blocked_by(raw: &str) -> String {
let trimmed = raw.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return "[]".to_string();
}
let inner = &trimmed[1..trimmed.len() - 1];
if inner.trim().is_empty() {
return "[]".to_string();
}
let items: Vec<String> = inner
.split(',')
.map(|s| {
let clean = s.trim().trim_matches('"').trim_matches('\'');
format!("\"{}\"", clean)
})
.collect();
format!("[{}]", items.join(", "))
}
fn collect_files(tickets_dir: &Path, ids: &[String]) -> Result<Vec<std::path::PathBuf>> {
let mut files = Vec::new();
for entry in std::fs::read_dir(tickets_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false)
&& path.file_name().map(|n| n != "config.toml").unwrap_or(true)
{
if ids.is_empty() {
files.push(path);
} else {
let name = path.file_name().unwrap_or_default().to_string_lossy();
for id in ids {
if name.starts_with(&format!("{}-", id)) {
files.push(path.clone());
break;
}
}
}
}
}
files.sort();
Ok(files)
}