@@ -239,6 +239,13 @@ fn output_json_report(report: &lash_db::IndexReport, error_reporter: &ErrorRepor
239239 "files_deleted" : report. files_deleted,
240240 "files_unchanged" : report. files_unchanged,
241241 "has_changes" : report. has_changes,
242+ "id_derivation_rebuild" : report. id_derivation_rebuild,
243+ "id_renames" : report. id_renames. iter( ) . map( |r| json!( {
244+ "file" : r. file_path. display( ) . to_string( ) ,
245+ "old_id" : r. old_full_id( ) ,
246+ "new_id" : r. new_full_id( ) ,
247+ "title" : r. title,
248+ } ) ) . collect:: <Vec <_>>( ) ,
242249 "errors" : {
243250 "count" : summary. error_count,
244251 "files_affected" : summary. files_affected. len( ) ,
@@ -253,6 +260,12 @@ fn output_json_report(report: &lash_db::IndexReport, error_reporter: &ErrorRepor
253260 Ok ( ( ) )
254261}
255262
263+ /// How many renamed IDs `lash index` spells out before summarising the rest
264+ ///
265+ /// Past a handful the list stops being readable in a terminal, and
266+ /// `lash migrate-ids` shows the whole thing anyway.
267+ const MAX_LISTED_RENAMES : usize = 10 ;
268+
256269/// Output indexing report as human-readable text
257270fn output_text_report (
258271 report : & lash_db:: IndexReport ,
@@ -308,6 +321,46 @@ fn output_text_report(
308321 println ! ( " Unchanged: {}" , report. files_unchanged) ;
309322 }
310323
324+ // A re-derive that actually moved IDs is not a routine reindex: every
325+ // reference written against one of the old IDs stopped resolving in this
326+ // same moment. Say so here rather than letting `lash lint` be the first
327+ // thing that mentions it, several commands later, without the context.
328+ //
329+ // Keyed on renames rather than on the re-derive itself, because a rule
330+ // change need not affect any title in a given project, and warning about
331+ // a repair that changed nothing would train people to ignore it.
332+ if !report. id_renames . is_empty ( ) {
333+ println ! ( ) ;
334+ let notice = format ! (
335+ "{} task ID{} changed: this index was built under older ID rules." ,
336+ report. id_renames. len( ) ,
337+ if report. id_renames. len( ) == 1 {
338+ ""
339+ } else {
340+ "s"
341+ }
342+ ) ;
343+ if let Some ( t) = theme {
344+ println ! ( "{}" , t. style_warning( & notice) ) ;
345+ } else {
346+ println ! ( "{notice}" ) ;
347+ }
348+ for rename in report. id_renames . iter ( ) . take ( MAX_LISTED_RENAMES ) {
349+ println ! ( " {} → {}" , rename. old_full_id( ) , rename. new_full_id( ) ) ;
350+ }
351+ if report. id_renames . len ( ) > MAX_LISTED_RENAMES {
352+ println ! (
353+ " … and {} more" ,
354+ report. id_renames. len( ) - MAX_LISTED_RENAMES
355+ ) ;
356+ }
357+ println ! ( ) ;
358+ println ! ( "Stored IDs now match what lash derives today. References written" ) ;
359+ println ! ( "against the old IDs will not resolve until they are updated:" ) ;
360+ println ! ( " lash migrate-ids # show what would change" ) ;
361+ println ! ( " lash migrate-ids --write # rewrite the references" ) ;
362+ }
363+
311364 // Print error summary
312365 let summary = error_reporter. summary ( ) ;
313366 if summary. error_count > 0 {
@@ -433,6 +486,8 @@ mod tests {
433486 error: "Parse error" . to_string( ) ,
434487 } ] ,
435488 has_changes : true ,
489+ id_derivation_rebuild : false ,
490+ id_renames : vec ! [ ] ,
436491 profile : None ,
437492 } ;
438493
@@ -1023,6 +1078,8 @@ mod tests {
10231078 error: "Unexpected token" . to_string( ) ,
10241079 } ] ,
10251080 has_changes : false ,
1081+ id_derivation_rebuild : false ,
1082+ id_renames : vec ! [ ] ,
10261083 profile : None ,
10271084 } ;
10281085
@@ -1528,6 +1585,8 @@ mod tests {
15281585 files_skipped : 0 ,
15291586 errors : vec ! [ ] ,
15301587 has_changes : false ,
1588+ id_derivation_rebuild : false ,
1589+ id_renames : vec ! [ ] ,
15311590 profile : None ,
15321591 } ;
15331592 output_text_report ( & report_zero, false , & reporter, None ) ;
@@ -1546,9 +1605,72 @@ mod tests {
15461605 files_skipped : 0 ,
15471606 errors : vec ! [ ] ,
15481607 has_changes : added > 0 || updated > 0 || deleted > 0 ,
1608+ id_derivation_rebuild : false ,
1609+ id_renames : vec ! [ ] ,
15491610 profile : None ,
15501611 } ;
15511612 output_text_report ( & r, false , & reporter, None ) ;
15521613 }
15531614 }
1615+
1616+ /// Build a report carrying `count` renamed IDs.
1617+ fn report_with_renames ( count : usize ) -> lash_db:: IndexReport {
1618+ let id_renames = ( 0 ..count)
1619+ . map ( |i| lash_db:: TaskIdRename {
1620+ file_path : PathBuf :: from ( "tasks.md" ) ,
1621+ file_id : "tasks" . to_string ( ) ,
1622+ old_local_id : format ! ( "old-{i}" ) ,
1623+ new_local_id : format ! ( "new-{i}" ) ,
1624+ title : format ! ( "Task {i}" ) ,
1625+ } )
1626+ . collect ( ) ;
1627+
1628+ lash_db:: IndexReport {
1629+ files_processed : 1 ,
1630+ files_added : 0 ,
1631+ files_updated : 1 ,
1632+ files_deleted : 0 ,
1633+ files_unchanged : 0 ,
1634+ files_skipped : 0 ,
1635+ errors : vec ! [ ] ,
1636+ has_changes : true ,
1637+ id_derivation_rebuild : true ,
1638+ id_renames,
1639+ profile : None ,
1640+ }
1641+ }
1642+
1643+ #[ test]
1644+ fn test_json_report_carries_the_renamed_ids ( ) {
1645+ // Machine consumers need the mapping, not just the count: it is the
1646+ // only record of what a reference used to mean.
1647+ let report = report_with_renames ( 2 ) ;
1648+ let reporter = ErrorReporter :: new ( ErrorReporterConfig {
1649+ verbosity : Verbosity :: Normal ,
1650+ output_format : OutputFormat :: JsonPretty ,
1651+ display_mode : ErrorDisplayMode :: Batch ,
1652+ theme : None ,
1653+ show_summary : false ,
1654+ } ) ;
1655+
1656+ assert ! ( output_json_report( & report, & reporter) . is_ok( ) ) ;
1657+ }
1658+
1659+ #[ test]
1660+ fn test_a_long_rename_list_is_truncated_not_dropped ( ) {
1661+ // Silently printing only the first ten would read as "that was all".
1662+ let report = report_with_renames ( MAX_LISTED_RENAMES + 3 ) ;
1663+ assert ! ( report. id_renames. len( ) > MAX_LISTED_RENAMES ) ;
1664+
1665+ // Exercised for panics and for the truncation arithmetic; the text
1666+ // itself is asserted end-to-end in the integration tests.
1667+ output_text_report ( & report, false , & text_reporter ( ) , None ) ;
1668+ }
1669+
1670+ #[ test]
1671+ fn test_report_with_no_renames_is_the_quiet_path ( ) {
1672+ let report = report_with_renames ( 0 ) ;
1673+ assert ! ( report. id_renames. is_empty( ) ) ;
1674+ output_text_report ( & report, false , & text_reporter ( ) , None ) ;
1675+ }
15541676}
0 commit comments