Skip to content

Commit f1acaa7

Browse files
committed
Add ticket format validation to doctor
Validates ticket files for common formatting issues: - Missing or malformed frontmatter delimiters - Malformed array fields (comma-separated strings instead of arrays) - Empty or invalid ID/title - Invalid parent/blocking reference formats - Parse errors with detailed messages This catches manual editing mistakes and data corruption.
1 parent e9ad6ee commit f1acaa7

File tree

3 files changed

+182
-3
lines changed

3 files changed

+182
-3
lines changed

.peas/config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
[peas]
44
prefix = "peas-"
55
id_length = 5
6+
id_mode = "random"
67
default_status = "todo"
78
default_type = "task"
89
frontmatter = "toml"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
+++
2+
id = "peas-39uq2"
3+
title = "Add ticket format validation to doctor"
4+
type = "feature"
5+
status = "completed"
6+
priority = "normal"
7+
created = "2026-02-02T23:46:57.775134400Z"
8+
updated = "2026-02-02T23:48:02.331533600Z"
9+
+++
10+
11+
Add validation checks for ticket files in peas doctor:
12+
13+
- Frontmatter parsing errors (already exists but could be more detailed)
14+
- Malformed array fields (e.g., blocking field with comma-separated string instead of array)
15+
- Invalid enum values (status, type, priority)
16+
- ID format validation (matches prefix + expected length)
17+
- Required fields present (id, title, type, status)
18+
- Timestamp format validation
19+
- Parent/blocking references format (should be valid ticket IDs)
20+
21+
This helps catch manual editing mistakes and data corruption.

src/cli/handlers/doctor.rs

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,16 @@ pub fn handle_doctor(fix: bool) -> Result<()> {
5252
// Check 3: Config content
5353
check_config_content(&cwd, &mut results, fix)?;
5454

55-
// Check 4: Ticket integrity
55+
// Check 4: Ticket format validation
56+
check_ticket_format(&cwd, &mut results)?;
57+
58+
// Check 5: Ticket integrity
5659
check_ticket_integrity(&cwd, &mut results)?;
5760

58-
// Check 5: Mixed ID styles
61+
// Check 6: Mixed ID styles
5962
check_mixed_id_styles(&cwd, &mut results)?;
6063

61-
// Check 6: Sequential ID counter (if applicable)
64+
// Check 7: Sequential ID counter (if applicable)
6265
check_sequential_counter(&cwd, &mut results, fix)?;
6366

6467
// Summary
@@ -252,6 +255,160 @@ fn check_config_content(cwd: &Path, results: &mut DiagnosticResults, fix: bool)
252255
Ok(())
253256
}
254257

258+
fn check_ticket_format(cwd: &Path, results: &mut DiagnosticResults) -> Result<()> {
259+
println!("{}", "Ticket Format Validation".bold());
260+
261+
let data_dir = cwd.join(DATA_DIR);
262+
if !data_dir.exists() {
263+
results.warn("No data directory to check");
264+
println!();
265+
return Ok(());
266+
}
267+
268+
let mut total_tickets = 0;
269+
let mut format_issues: Vec<(String, String)> = Vec::new();
270+
271+
for entry in std::fs::read_dir(&data_dir)? {
272+
let entry = entry?;
273+
let path = entry.path();
274+
275+
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
276+
total_tickets += 1;
277+
let filename = path
278+
.file_name()
279+
.and_then(|f| f.to_str())
280+
.unwrap_or("unknown");
281+
let content = std::fs::read_to_string(&path)?;
282+
283+
// Check for frontmatter delimiters
284+
if !content.starts_with("+++") && !content.starts_with("---") {
285+
format_issues.push((
286+
filename.to_string(),
287+
"Missing frontmatter delimiters".to_string(),
288+
));
289+
continue;
290+
}
291+
292+
// Extract frontmatter for raw validation
293+
let delimiter = if content.starts_with("+++") {
294+
"+++"
295+
} else {
296+
"---"
297+
};
298+
let parts: Vec<&str> = content.splitn(3, delimiter).collect();
299+
if parts.len() < 3 {
300+
format_issues.push((
301+
filename.to_string(),
302+
"Malformed frontmatter structure".to_string(),
303+
));
304+
continue;
305+
}
306+
307+
let frontmatter = parts[1].trim();
308+
309+
// Check for malformed array fields (comma-separated strings instead of arrays)
310+
// This catches: blocking = ["a,b,c"] instead of blocking = ["a", "b", "c"]
311+
for field in ["blocking", "tags", "assets"] {
312+
let pattern = format!("{} = [\"", field);
313+
if let Some(start) = frontmatter.find(&pattern) {
314+
let after_bracket = start + pattern.len();
315+
if let Some(end) = frontmatter[after_bracket..].find("\"]") {
316+
let value = &frontmatter[after_bracket..after_bracket + end];
317+
// If the value contains commas but no quotes, it's likely malformed
318+
if value.contains(',') && !value.contains("\", \"") {
319+
format_issues.push((
320+
filename.to_string(),
321+
format!(
322+
"Malformed {} array: contains comma-separated string instead of array elements",
323+
field
324+
),
325+
));
326+
}
327+
}
328+
}
329+
}
330+
331+
// Try to parse and check for additional issues
332+
match crate::storage::parse_markdown(&content) {
333+
Ok(pea) => {
334+
// Check ID format - should start with a prefix and have reasonable length
335+
if pea.id.is_empty() {
336+
format_issues.push((filename.to_string(), "Empty ticket ID".to_string()));
337+
} else if !pea.id.contains('-') {
338+
format_issues.push((
339+
filename.to_string(),
340+
format!("ID '{}' missing prefix separator", pea.id),
341+
));
342+
}
343+
344+
// Check title
345+
if pea.title.is_empty() {
346+
format_issues
347+
.push((filename.to_string(), "Empty ticket title".to_string()));
348+
}
349+
350+
// Check parent format if present
351+
if let Some(ref parent) = pea.parent {
352+
if parent.is_empty() {
353+
format_issues
354+
.push((filename.to_string(), "Empty parent reference".to_string()));
355+
} else if !parent.contains('-') {
356+
format_issues.push((
357+
filename.to_string(),
358+
format!("Parent '{}' has invalid ID format", parent),
359+
));
360+
}
361+
}
362+
363+
// Check blocking references format
364+
for blocked in &pea.blocking {
365+
if blocked.is_empty() {
366+
format_issues.push((
367+
filename.to_string(),
368+
"Empty blocking reference".to_string(),
369+
));
370+
} else if !blocked.contains('-') && blocked.contains(',') {
371+
// Likely a comma-separated string that wasn't caught above
372+
format_issues.push((
373+
filename.to_string(),
374+
format!(
375+
"Blocking '{}' appears to be comma-separated (should be array)",
376+
blocked
377+
),
378+
));
379+
}
380+
}
381+
}
382+
Err(e) => {
383+
format_issues.push((filename.to_string(), format!("Parse error: {}", e)));
384+
}
385+
}
386+
}
387+
}
388+
389+
if total_tickets == 0 {
390+
results.pass("No tickets to validate");
391+
println!();
392+
return Ok(());
393+
}
394+
395+
if format_issues.is_empty() {
396+
results.pass(&format!("All {} tickets are well-formed", total_tickets));
397+
} else {
398+
results.error(&format!(
399+
"{} format issues in {} tickets",
400+
format_issues.len(),
401+
total_tickets
402+
));
403+
for (filename, issue) in &format_issues {
404+
println!(" - {}: {}", filename, issue);
405+
}
406+
}
407+
408+
println!();
409+
Ok(())
410+
}
411+
255412
fn check_ticket_integrity(cwd: &Path, results: &mut DiagnosticResults) -> Result<()> {
256413
println!("{}", "Ticket Integrity".bold());
257414

0 commit comments

Comments
 (0)