claude_storage 1.0.0

CLI tool for exploring Claude Code filesystem storage
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
//! Tests for `.projects` command — output format.
//!
//! ## Coverage
//!
//! IT-17 through IT-29: output format behaviour — `verbosity::1` grouping,
//! agent session collapse, entry counts, filter interaction with collapse,
//! v1 entry count, limit truncation, and zero-byte session exclusion.
//!
//! IT-50 through IT-53: project-centric redesign (task 016) — "Active project"
//! summary header, session count aggregate, recency-sorted list, and v0 paths.
//!
//! ## Test Case Index
//!
//! | ID | Test Name | Category |
//! |----|-----------|----------|
//! | IT-17 | v1 output groups sessions under project path headers | Output Format |
//! | IT-18 | path header always present at v1 for scope::local single project | Output Format |
//! | IT-19 | agent sessions collapsed to count line at v1 without agent:: filter | Output Format |
//! | IT-20 | agent sessions shown individually at v2+ | Output Format |
//! | IT-21 | entry count shown per session at v2+ | Output Format |
//! | IT-22 | agent::1 explicit filter disables collapse at v1 | Output Format |
//! | IT-27 | entry count shown per session at v1 | Output Format |
//! | IT-28 | limit::N truncates main sessions shown at v1 | Output Format |
//! | IT-29 | zero-byte sessions excluded from v1 display | Output Format |
//! | IT-50 | summary mode shows "Active project" not "Active session" | Project-Centric |
//! | IT-51 | summary mode shows session count aggregate "(N sessions," | Project-Centric |
//! | IT-52 | list mode shows projects sorted by recency (most recently active first) | Project-Centric |
//! | IT-53 | verbosity::0 shows project paths only — no session IDs | Project-Centric |

mod common;

use tempfile::TempDir;

fn stdout( out : &std::process::Output ) -> String
{
  String::from_utf8_lossy( &out.stdout ).into_owned()
}

fn stderr( out : &std::process::Output ) -> String
{
  String::from_utf8_lossy( &out.stderr ).into_owned()
}

fn assert_exit( out : &std::process::Output, code : i32 )
{
  assert_eq!(
    out.status.code().unwrap_or( -1 ),
    code,
    "expected exit {code}, got {:?}; stderr: {}",
    out.status.code(),
    stderr( out )
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// Output Format Redesign (plan-004)
//
// Root Cause: projects_routine collected sessions into a flat Vec<(label, id)>
// and formatted project labels as format!("{:?}", project.id()) — opaque encoded
// strings. At scope::global with 60+ sessions there was no grouping, no readable
// paths, and no way to tell which sessions belonged to which project.
//
// Why Not Caught: All existing format tests only checked for presence of session
// IDs or "Found N" header. No test asserted path-group headers or agent collapse.
//
// Fix Applied: projects_routine redesigned to collect into BTreeMap<String,
// Vec<Session>> keyed by decoded project path. Output loop emits path headers at
// v1+, collapses agent sessions at v1 when no agent:: filter, shows entry counts
// at v2+.
//
// Prevention: Always add format assertions (path header, agent collapse, entry
// count) when testing commands with structured grouped output.
//
// Pitfall: decode_path() requires input starting with '-'. UUID project dirs
// don't start with '-' — guard with starts_with('-') before calling decode.
// ─────────────────────────────────────────────────────────────────────────────

// IT-17: v1 output groups sessions under project path headers
#[test]
fn it_17_v1_groups_sessions_by_project_path()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project_a = root.path().join( "proj_alpha" );
  let project_b = root.path().join( "proj_beta" );
  common::write_path_project_session( &storage_root, &project_a, "session-alpha-001", 2 );
  common::write_path_project_session( &storage_root, &project_b, "session-beta-001", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Must have at least one path header line: ends with ':' and contains '/' or '~'
  assert!(
    s.lines().any( | l | l.contains( ':' ) && ( l.contains( '/' ) || l.contains( '~' ) ) ),
    "v1 must show project path headers ending with ':'; got:\n{s}"
  );
  assert!( s.contains( "session-alpha-001" ), "must contain session-alpha-001; got:\n{s}" );
  assert!( s.contains( "session-beta-001" ), "must contain session-beta-001; got:\n{s}" );
}

// IT-18: path header always present at v1 for scope::local single project
#[test]
fn it_18_path_header_present_at_v1_single_project()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "my_proj" );
  common::write_path_project_session( &storage_root, &project, "session-path-test", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::local" )
    .arg( format!( "path::{}", project.display() ) )
    .arg( "verbosity::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  assert!(
    s.lines().any( | l | l.contains( ':' ) && ( l.contains( '/' ) || l.contains( '~' ) ) ),
    "path header must be shown at v1 even for single-project local scope; got:\n{s}"
  );
}

// IT-19: agent sessions grouped in family display at v1 without agent:: filter
//
// Updated for family display (TSK-002): agents are shown as family brackets
// `[N agents: breakdown]` per root, not as a flat `+ N agent sessions` collapse.
// Flat agents without valid parent linkage become orphan families with `?` marker.
#[test]
fn it_19_agent_sessions_collapsed_at_v1_no_filter()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "mixed_project" );
  // 2 main sessions
  common::write_path_project_session( &storage_root, &project, "session-main-a", 2 );
  common::write_path_project_session( &storage_root, &project, "session-main-b", 2 );
  // 3 agent sessions (IDs start with "agent-" for is_agent_session() to return true)
  common::write_path_project_session( &storage_root, &project, "agent-task-001", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-002", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-003", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Agent IDs must NOT appear individually at v1
  assert!( !s.contains( "agent-task-001" ), "agent-task-001 must NOT appear individually at v1; got:\n{s}" );
  assert!( !s.contains( "agent-task-002" ), "agent-task-002 must NOT appear individually at v1; got:\n{s}" );
  assert!( !s.contains( "agent-task-003" ), "agent-task-003 must NOT appear individually at v1; got:\n{s}" );
  // Family display: agents must appear as count in brackets or as orphan marker
  assert!(
    s.contains( "agent" ),
    "must show agent info in family display at v1; got:\n{s}"
  );
  // Old collapse format must NOT appear
  assert!(
    !s.contains( "+ " ),
    "must NOT show old '+ N agent' collapse line; got:\n{s}"
  );
  // Main sessions must still appear individually
  assert!( s.contains( "session-main-a" ), "session-main-a must appear; got:\n{s}" );
  assert!( s.contains( "session-main-b" ), "session-main-b must appear; got:\n{s}" );
}

// IT-20: agent sessions shown individually at v2+
#[test]
fn it_20_agent_sessions_shown_individually_at_v2()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "mixed_project_v2" );
  common::write_path_project_session( &storage_root, &project, "session-main-a", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-001", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-002", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-003", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::2" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // All agent sessions must appear individually (tree-indented at v2+)
  assert!( s.contains( "agent-task-001" ), "agent-task-001 must appear individually at v2; got:\n{s}" );
  assert!( s.contains( "agent-task-002" ), "agent-task-002 must appear individually at v2; got:\n{s}" );
  assert!( s.contains( "agent-task-003" ), "agent-task-003 must appear individually at v2; got:\n{s}" );
  // No old-format collapse line
  assert!(
    !s.contains( "+ " ),
    "must NOT show old '+ N agent' collapse at v2; got:\n{s}"
  );
  // Must have path header (grouped output)
  assert!(
    s.lines().any( | l | l.contains( ':' ) && ( l.contains( '/' ) || l.contains( '~' ) ) ),
    "v2 must show project path header ending with ':'; got:\n{s}"
  );
}

// IT-21: entry count shown per session at v2+
#[test]
fn it_21_entry_count_shown_at_v2()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "count_proj" );
  // Exactly 4 entries
  common::write_path_project_session( &storage_root, &project, "session-count-test", 4 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::2" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  assert!(
    s.contains( "(4 entries)" ),
    "v2 must show '(4 entries)' for a 4-entry session; got:\n{s}"
  );
}

// IT-22: agent::1 explicit filter disables collapse at v1
#[test]
fn it_22_agent_filter_disables_collapse_at_v1()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "agent_filter_proj" );
  common::write_path_project_session( &storage_root, &project, "session-main-z", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-001", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-002", 2 );
  common::write_path_project_session( &storage_root, &project, "agent-task-003", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .arg( "agent::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // agent::1 shows only agent sessions individually, no collapse
  assert!( s.contains( "agent-task-001" ), "agent-task-001 must appear individually with agent::1; got:\n{s}" );
  assert!( s.contains( "agent-task-002" ), "agent-task-002 must appear individually with agent::1; got:\n{s}" );
  assert!( s.contains( "agent-task-003" ), "agent-task-003 must appear individually with agent::1; got:\n{s}" );
  assert!(
    !s.contains( "3 agent" ),
    "must NOT show collapse line when agent::1 is set; got:\n{s}"
  );
  // Must have path header (grouped output)
  assert!(
    s.lines().any( | l | l.contains( ':' ) && ( l.contains( '/' ) || l.contains( '~' ) ) ),
    "agent::1 v1 must show project path header ending with ':'; got:\n{s}"
  );
}

// IT-27: entry count shown per session at v1
//
// Root Cause: Entry counts were only shown at v2+; v1 used bare session IDs
// with no metadata. Users needed v2 just to see how many entries a session had.
//
// Why Not Caught: No test verified v1 output contained entry counts.
//
// Fix Applied: v1 now shows `  - {short_id}  {mtime}  ({n} entries)` for each
// main session (mirrors v2 info density at the default verbosity).
//
// Prevention: Always add a v1 entry-count assertion when adding entry count to
// lower verbosity levels.
//
// Pitfall: Synthetic test IDs are not UUID-format (len != 36), so short_id
// returns them intact — assertions against full IDs still pass at v1.
#[test]
fn it_27_entry_count_shown_at_v1()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "count_v1_proj" );
  // Exactly 4 entries
  common::write_path_project_session( &storage_root, &project, "session-v1-count", 4 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  assert!(
    s.contains( "(4 entries)" ),
    "v1 must show '(4 entries)' for a 4-entry session; got:\n{s}"
  );
}

// IT-28: limit::N truncates main sessions shown at v1
//
// Root Cause: No per-project display limit existed; large projects (100+ sessions)
// flooded output at default verbosity.
//
// Why Not Caught: No test exercised the limit:: parameter before it was added.
//
// Fix Applied: limit:: parameter caps the number of main sessions displayed per
// project at v1; a trailing "... and N more" hint points to verbosity::0.
//
// Prevention: Always add a truncation assertion when adding a limit parameter
// to any list command.
//
// Pitfall: limit::0 means unlimited (not zero sessions). Only positive values
// activate truncation.
#[test]
fn it_28_limit_truncates_display()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "limit_proj" );
  // 5 main sessions
  common::write_path_project_session( &storage_root, &project, "session-limit-a", 2 );
  common::write_path_project_session( &storage_root, &project, "session-limit-b", 2 );
  common::write_path_project_session( &storage_root, &project, "session-limit-c", 2 );
  common::write_path_project_session( &storage_root, &project, "session-limit-d", 2 );
  common::write_path_project_session( &storage_root, &project, "session-limit-e", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .arg( "limit::2" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Truncation hint must appear
  assert!(
    s.contains( "and 3 more" ),
    "limit::2 with 5 sessions must show '... and 3 more'; got:\n{s}"
  );
}

// IT-29: zero-byte sessions excluded from v1 display
//
// Root Cause: Claude Code creates zero-byte JSONL files as startup placeholders
// (B8). These aren't real sessions but appeared in v1 output as empty entries.
//
// Why Not Caught: No test created zero-byte session files to verify exclusion.
//
// Fix Applied: v1 loop filters out zero-byte main sessions before display.
// Zero-byte files remain visible at v0 (pipe-safe) and v2+ (where showing
// "(0 entries)" can be informative).
//
// Prevention: Any test that cares about displayed session count must account for
// whether zero-byte sessions exist in the storage fixture.
//
// Pitfall: fs::metadata().len() == 0 is the check — do not rely on entry count
// because count_entries() reads the file and returns Ok(0) for a valid empty
// file, same as a zero-byte file.
#[test]
fn it_29_zero_byte_sessions_excluded_at_v1()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project = root.path().join( "zero_byte_proj" );
  common::write_path_project_session( &storage_root, &project, "session-real", 2 );

  // Create a zero-byte placeholder session (B8 behaviour)
  {
    let encoded = claude_storage_core::encode_path( &project ).unwrap();
    let dir = storage_root.join( "projects" ).join( &encoded );
    std::fs::create_dir_all( &dir ).unwrap();
    // File::create with no writes leaves the file at zero bytes
    let _ = std::fs::File::create( dir.join( "session-placeholder.jsonl" ) ).unwrap();
  }

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::1" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Zero-byte placeholder must NOT appear at v1
  assert!(
    !s.contains( "session-placeholder" ),
    "zero-byte placeholder must NOT appear at v1; got:\n{s}"
  );
  // Real session must still appear
  assert!(
    s.contains( "session-real" ),
    "real session must still appear when zero-byte is excluded; got:\n{s}"
  );
}

// ── IT-50..IT-53: Project-centric redesign (Task 016) ────────────────────────

// IT-50: summary mode shows "Active project" not "Active session"
//
// Root Cause: render_active_summary labelled the output "Active session" — a
// session-level view. The redesigned render_active_project_summary must say
// "Active project" to reflect the project-centric mental model.
//
// Why Not Caught: No test asserted on "Active project" vs "Active session"
// before task 016.
//
// Fix Applied: render_active_summary replaced by render_active_project_summary
// which aggregates sessions per project and labels the output "Active project".
//
// Prevention: Assert the exact prefix of the summary header whenever replacing
// a summary output function.
//
// Pitfall: The summary aggregates across ALL sessions in a project to find
// last_mtime. A project with 3 old sessions and 1 new session has
// last_mtime = max(all session mtimes).
#[test]
fn it_summary_mode_shows_active_project_header()
{
  let root = TempDir::new().unwrap();
  let project_path = root.path().join( "summary_proj" );
  std::fs::create_dir_all( &project_path ).unwrap();

  common::write_path_project_session( root.path(), &project_path, "session-sp-001", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", root.path().to_str().unwrap() )
    .current_dir( &project_path )
    .arg( ".projects" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  assert!(
    s.contains( "Active project" ),
    "summary must say 'Active project'; got:\n{s}"
  );
  assert!(
    !s.contains( "Active session" ),
    "summary must NOT say 'Active session'; got:\n{s}"
  );
}

// IT-51: summary mode shows session count aggregate "(N sessions,"
//
// Root Cause: render_active_summary showed entry count ("N entries") not
// session count ("N sessions"). Project-centric summary must aggregate
// session count across all sessions in the project.
//
// Why Not Caught: No test checked for "sessions," in summary output.
//
// Fix Applied: render_active_project_summary outputs
// "(N sessions, last active Xago)" where N is the session count.
//
// Prevention: Assert for "sessions," (with comma) in summary output to verify
// the project-level session count appears in the expected format.
//
// Pitfall: 1 session renders "1 session," (singular, no 's'). Use 3+ sessions
// to test the "sessions," plural form.
#[test]
fn it_summary_mode_shows_session_count()
{
  let root = TempDir::new().unwrap();
  let project_path = root.path().join( "session_count_proj" );
  std::fs::create_dir_all( &project_path ).unwrap();

  // 3 sessions in the same project → plural "sessions"
  common::write_path_project_session( root.path(), &project_path, "session-sc-001", 2 );
  common::write_path_project_session( root.path(), &project_path, "session-sc-002", 2 );
  common::write_path_project_session( root.path(), &project_path, "session-sc-003", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", root.path().to_str().unwrap() )
    .current_dir( &project_path )
    .arg( ".projects" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Summary must show "(3 sessions, last active ...)" — "sessions," is the key marker
  assert!(
    s.contains( "sessions," ),
    "summary must contain 'sessions,' aggregate count; got:\n{s}"
  );
}

// IT-52: list mode shows projects sorted by recency (most recently active first)
//
// Root Cause: projects_routine iterated groups: BTreeMap<String, Vec<Session>>
// directly, yielding keys in alphabetical order. "proj_beta" always appeared
// after "proj_alpha" regardless of which was more recently active.
//
// Why Not Caught: No test created projects where alphabetical order differs from
// recency order to expose the sort mismatch.
//
// Fix Applied: list mode now calls aggregate_projects() which returns
// Vec<ProjectSummary> sorted by last_mtime descending (most recently active first).
//
// Prevention: Multi-project list tests must assert ORDER (find positions), not
// just presence — contains() is insufficient for verifying sort order.
//
// Pitfall: FileTimes must be set AFTER writing the file. File::create() and
// write() set mtime to "now"; explicit set_times() must be the last step.
#[test]
fn it_list_mode_shows_projects_sorted_by_recency()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  // proj_alpha: alphabetically FIRST but OLDER → must appear second in time-sorted output
  let project_alpha = root.path().join( "proj_alpha" );
  // proj_beta: alphabetically SECOND but NEWER → must appear first in time-sorted output
  let project_beta = root.path().join( "proj_beta" );
  // Create directories so decode_project_display filesystem walk succeeds for
  // paths containing underscores (e.g. proj_alpha, proj_beta).
  std::fs::create_dir_all( &project_alpha ).unwrap();
  std::fs::create_dir_all( &project_beta ).unwrap();

  let enc_alpha = common::write_path_project_session(
    &storage_root, &project_alpha, "session-alpha", 2
  );
  let enc_beta  = common::write_path_project_session(
    &storage_root, &project_beta, "session-beta", 2
  );

  // Explicitly set mtimes for deterministic ordering
  let old_t = std::time::SystemTime::UNIX_EPOCH + core::time::Duration::from_secs( 1_000 );
  let new_t = std::time::SystemTime::UNIX_EPOCH + core::time::Duration::from_secs( 2_000 );

  {
    let p = storage_root.join( "projects" ).join( &enc_alpha ).join( "session-alpha.jsonl" );
    let f = std::fs::OpenOptions::new().write( true ).open( &p ).unwrap();
    f.set_times( std::fs::FileTimes::new().set_modified( old_t ) ).unwrap();
  }
  {
    let p = storage_root.join( "projects" ).join( &enc_beta ).join( "session-beta.jsonl" );
    let f = std::fs::OpenOptions::new().write( true ).open( &p ).unwrap();
    f.set_times( std::fs::FileTimes::new().set_modified( new_t ) ).unwrap();
  }

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );

  let pos_beta  = s.find( "proj_beta" ).expect( "proj_beta must appear in output" );
  let pos_alpha = s.find( "proj_alpha" ).expect( "proj_alpha must appear in output" );
  assert!(
    pos_beta < pos_alpha,
    "proj_beta (newer, t=2000) must appear before proj_alpha (older, t=1000); got:\n{s}"
  );
}

// IT-53: verbosity::0 shows project paths only — no session IDs
//
// Root Cause: At v0, projects_routine emitted one SESSION ID per line.
// Project-centric output at v0 must emit one PROJECT PATH per line for
// machine-readable scripting (e.g. iterating distinct working directories).
//
// Why Not Caught: No test checked v0 output for project-path format.
//
// Fix Applied: v0 list mode now outputs one decoded project path per line
// from aggregate_projects(), replacing the per-session ID enumeration.
//
// Prevention: Any v0 output change needs a test asserting the exact per-line
// format (project path present, session IDs absent).
//
// Pitfall: The output contains DECODED paths (e.g. /tmp/.../proj_v0), not
// encoded storage dir names. Assert for the directory name component, not the
// full encoded form.
#[test]
fn it_verbosity_0_shows_paths_only()
{
  let root = TempDir::new().unwrap();
  let storage_root = root.path().join( ".claude" );

  let project_path = root.path().join( "proj_v0" );
  // Create directory so decode_project_display filesystem walk succeeds.
  std::fs::create_dir_all( &project_path ).unwrap();
  common::write_path_project_session( &storage_root, &project_path, "session-v0-001", 2 );
  common::write_path_project_session( &storage_root, &project_path, "session-v0-002", 2 );

  let out = common::clg_cmd()
    .env( "HOME", root.path().to_str().unwrap() )
    .env( "CLAUDE_STORAGE_ROOT", storage_root.to_str().unwrap() )
    .arg( ".projects" )
    .arg( "scope::global" )
    .arg( "verbosity::0" )
    .output()
    .unwrap();

  assert_exit( &out, 0 );
  let s = stdout( &out );
  // Must contain the project directory name (decoded path)
  assert!(
    s.contains( "proj_v0" ),
    "v0 must output project path containing 'proj_v0'; got:\n{s}"
  );
  // Must NOT contain session IDs
  assert!(
    !s.contains( "session-v0" ),
    "v0 must NOT output session IDs; got:\n{s}"
  );
}