@@ -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+
255412fn check_ticket_integrity ( cwd : & Path , results : & mut DiagnosticResults ) -> Result < ( ) > {
256413 println ! ( "{}" , "Ticket Integrity" . bold( ) ) ;
257414
0 commit comments