understatus 0.5.0

A calm, unobtrusive macOS statusline addon for AI coding CLIs (Claude Code): CPU/memory/disk/network + session info with a quiet glyph theme.
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
662
663
664
//! `render --source lterm --oneline`의 출력 계약 통합 테스트(spec §6.3, §10).
//!
//! 코어 1행을 stdout으로 쓰는 경로는 단위 테스트로 직접 관측하기 어렵다(직접 출력).
//! 따라서 빌드된 바이너리를 실제로 실행해 stdout 바이트를 검증한다:
//! - 정확히 1행(개행 0개), 후행 개행 없음.
//! - chain 미수행(체인 HUD seam "│"가 없음).
//! - cols 힌트가 강제 절단을 하지 않음(최종 폭 권위는 lterm).

use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};

/// chain 실행 여부를 stdout으로 직접 관측하기 위한 센티널. chain_command가 도는 경우에만
/// 자식 stdout에 합성되어 self 세그먼트와 함께 한 줄에 나타난다.
const CHAIN_SENTINEL: &str = "CHAINSENTINEL";

/// 병렬 테스트 스레드 간 임시 경로 충돌을 막는 프로세스 전역 단조 카운터.
///
/// pid+nanos만으로 임시 경로를 만들면 두 테스트 스레드가 같은 나노초 틱(부하 시 시계 해상도가
/// 거칠어짐)에 진입할 때 경로가 충돌해, 한 테스트의 `fs::write`(truncate+write)가 다른 테스트의
/// 읽기와 경합하며 torn read(부분 판독)를 유발한다(E2E flaky 근본 원인). 매 호출마다 증가하는
/// 전역 카운터를 경로에 섞어 충돌을 원천 차단한다.
static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0);

/// 프로세스 내에서 절대 충돌하지 않는 고유 토큰(`<pid>-<nanos>-<counter>`)을 만든다.
fn unique_token() -> String {
    let counter = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    format!("{}-{nanos}-{counter}", std::process::id())
}

/// 빌드된 understatus 바이너리에 stdin/인자를 주어 실행하고 stdout 바이트를 반환한다.
///
/// # 인자
/// - `args`: render 서브커맨드 뒤 플래그(예: `["render", "--source", "lterm", "--oneline"]`).
/// - `stdin`: 자식 stdin으로 전달할 JSON 본문.
///
/// # 반환
/// 자식 stdout 바이트 전체. NO_COLOR=1로 색을 끄고, 설정은 부재 경로로 기본값을 강제한다.
fn run_understatus(args: &[&str], stdin: &str) -> Vec<u8> {
    // 존재하지 않는 설정 경로 → 전 항목 기본값(테스트 격리, chain_command 없음).
    run_understatus_with_config(args, stdin, "/nonexistent/understatus-test-config.toml")
}

/// [`run_understatus`]와 동일하되 `UNDERSTATUS_CONFIG` 경로를 명시 주입한다.
///
/// chain_command가 설정된 임시 config를 주입해 chain 실행 여부를 stdout으로 직접 관측하기 위함이다.
///
/// # 인자
/// - `args`: render 서브커맨드 뒤 플래그.
/// - `stdin`: 자식 stdin으로 전달할 JSON 본문.
/// - `config_path`: `UNDERSTATUS_CONFIG`로 주입할 config.toml 경로.
fn run_understatus_with_config(args: &[&str], stdin: &str, config_path: &str) -> Vec<u8> {
    let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
        .args(args)
        .env("NO_COLOR", "1")
        .env("UNDERSTATUS_CONFIG", config_path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("understatus 바이너리 실행 실패");

    child
        .stdin
        .take()
        .expect("stdin 핸들 없음")
        .write_all(stdin.as_bytes())
        .expect("stdin 쓰기 실패");

    let output = child.wait_with_output().expect("자식 종료 대기 실패");
    assert!(
        output.status.success(),
        "종료 코드 비정상: {:?}",
        output.status
    );
    output.stdout
}

/// chain_command가 센티널을 출력하도록 설정한 임시 config.toml을 만들고 그 경로를 반환한다.
///
/// chain 자식은 `sh -c <command>`로 실행되므로 `printf CHAINSENTINEL`이 도는지로 chain
/// 실행 여부를 직접 검증한다. 테스트마다 고유 경로를 써서 캐시/병렬 간섭을 피한다.
///
/// # 인자
/// - `tag`: 파일명 고유화 태그(테스트별 충돌/캐시 격리용).
///
/// # 반환
/// 작성된 config.toml의 절대 경로 문자열.
fn write_chain_config(tag: &str) -> String {
    let path = std::env::temp_dir().join(format!(
        "understatus-chain-cfg-{}-{}.toml",
        std::process::id(),
        tag
    ));
    // [chain] chain_command가 sh -c로 실행된다. 센티널만 출력하는 최소 명령.
    let toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n");
    std::fs::write(&path, toml).expect("임시 config 작성 실패");
    path.to_string_lossy().into_owned()
}

/// --oneline은 정확히 1행을 후행 개행 없이 출력해야 한다(spec §6.3).
#[test]
fn oneline_emits_single_line_without_trailing_newline() {
    let stdout = run_understatus(
        &["render", "--source", "lterm", "--oneline"],
        r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj"}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    // 후행 개행 0개.
    assert!(!text.ends_with('\n'), "후행 개행이 있으면 안 됨: {text:?}");
    // 내부 개행 0개(정확히 1행).
    assert_eq!(
        text.matches('\n').count(),
        0,
        "정확히 1행이어야 함(개행 0): {text:?}"
    );
    // 코어 세그먼트(CPU% 등)가 비어 있지 않아야 한다.
    assert!(!text.is_empty(), "코어 출력이 비면 안 됨");
    assert!(text.contains('%'), "시스템 지표(%)가 있어야 함: {text:?}");
}

/// 기본 render(--oneline 없음)는 후행 개행이 붙는다(기존 동작 보존, 대조군).
#[test]
fn default_render_has_trailing_newline() {
    let stdout = run_understatus(&["render"], "{}");
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    assert!(
        text.ends_with('\n'),
        "기본 render는 println!으로 후행 개행이 있어야 함: {text:?}"
    );
}

/// --oneline은 chain을 수행하지 않는다(실제 chain_command 설정 상태에서 직접 증명).
///
/// 기본 config 대신 chain_command(센티널 출력)가 설정된 임시 config를 주입해 chain 실행
/// 여부를 stdout 센티널로 직접 관측한다. 세 분기를 한 테스트에서 대조 검증한다:
/// - `--oneline`(claude source): chain 미수행 → 센티널 **없음**(수정 #2).
/// - 동일 config로 `--oneline` 없이(claude source): chain 수행 → 센티널 **있음**(대조군).
/// - `--source lterm`(--oneline 없이): chain 비활성 → 센티널 **없음**(수정 #3).
///
/// 각 분기는 서로 다른 session/pane(=session_key)을 써서 chain 캐시 교차 오염을 피한다.
#[test]
fn oneline_does_not_run_chain() {
    let config = write_chain_config("oneline-chain-skip");

    // (1) --oneline(claude source): chain 미수행 → 센티널 없음.
    let oneline_out = run_understatus_with_config(
        &["render", "--oneline"],
        r#"{"session_id":"oneline-skip-a"}"#,
        &config,
    );
    let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
    assert!(
        !oneline_text.contains(CHAIN_SENTINEL),
        "--oneline은 chain을 수행하면 안 됨(센티널 부재): {oneline_text:?}"
    );

    // (2) 대조군: 동일 config로 --oneline 없이(claude source) → chain 수행 → 센티널 있음.
    //   chain이 실제로 도는지 증명해 (1)의 부재가 chain-skip 때문임을 분리 검증한다.
    let control_out = run_understatus_with_config(
        &["render"],
        r#"{"session_id":"oneline-skip-control"}"#,
        &config,
    );
    let control_text = String::from_utf8(control_out).expect("stdout는 UTF-8이어야 함");
    assert!(
        control_text.contains(CHAIN_SENTINEL),
        "대조군(--oneline 없음, claude)은 chain이 실제로 돌아 센티널이 있어야 함: {control_text:?}"
    );

    // (3) --source lterm(--oneline 없이): chain 비활성 → 센티널 없음(수정 #3).
    let lterm_out = run_understatus_with_config(
        &["render", "--source", "lterm"],
        r#"{"source":"lterm","session":"oneline-skip-lterm","pane":"%1"}"#,
        &config,
    );
    let lterm_text = String::from_utf8(lterm_out).expect("stdout는 UTF-8이어야 함");
    assert!(
        !lterm_text.contains(CHAIN_SENTINEL),
        "--source lterm은 chain 기본 off여야 함(센티널 부재): {lterm_text:?}"
    );

    let _ = std::fs::remove_file(&config);
}

/// 작은 cols 힌트가 와도 강제 절단하지 않는다(최종 폭 권위는 lterm, spec §6.3).
///
/// cols=10처럼 작은 값을 주어도 출력이 그 폭으로 잘리지 않고 정상 세그먼트가 유지되어야 한다.
#[test]
fn oneline_cols_hint_does_not_force_truncation() {
    let stdout = run_understatus(
        &["render", "--source", "lterm", "--oneline"],
        r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","cols":10,"rows":2}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    // cols=10보다 길게 나올 수 있어야 한다(강제 절단 금지). 기본 max_width(80)만 적용된다.
    // 최소한 cwd 디렉터리명("proj")이 살아 있어야 한다(좁은 cols로 인한 손실이 없음).
    assert!(
        text.contains("proj"),
        "cols 힌트가 cwd를 잘라내면 안 됨(폭 권위는 lterm): {text:?}"
    );
}

/// non-git cwd인 lterm 세션은 git 세그먼트가 부재해야 한다(조건부 — 절대 비활성이 아님).
///
/// parse_lterm_input은 cwd가 유효 git repo일 때만 git_branch를 채운다(cwd-only). 확실히 non-git인
/// 격리 임시 경로를 cwd로 주어(우연한 git repo 회피) git 마커(⎇)가 출력에 없음을 검증한다.
/// (유효 git cwd의 positive 케이스는 oneline_lterm_git_cwd_shows_branch가 담당.)
#[test]
fn oneline_lterm_non_git_cwd_has_no_git_segment() {
    // 확실히 non-git인 격리 경로(생성하지 않음 → 존재하지 않는 경로라 `.git`도 없음).
    let non_git_cwd = std::env::temp_dir().join(format!("understatus-nogit-{}", unique_token()));
    let stdin = format!(
        r#"{{"source":"lterm","session":"codex","pane":"%3","cwd":{:?}}}"#,
        non_git_cwd.to_string_lossy()
    );
    let stdout = run_understatus(&["render", "--source", "lterm", "--oneline"], &stdin);
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    assert!(
        !text.contains(''),
        "non-git cwd lterm은 git 세그먼트(⎇)가 없어야 함: {text:?}"
    );
}

/// 유효 git cwd인 lterm 세션은 oneline에 `⎇ <branch>` + cmux-status에 "git" pill을 노출해야 한다.
///
/// run_understatus는 서브프로세스이므로 stdin의 cwd가 실재해야 한다 → hermetic temp `.git`을
/// **spawn 전에 디스크에 생성**한 뒤 두 표면(oneline/cmux-status)을 동일 cwd로 검증한다.
#[test]
fn oneline_lterm_git_cwd_shows_branch() {
    use std::io::Write;
    // create-before-spawn: 서브프로세스가 읽을 수 있도록 spawn 전에 `.git/HEAD`를 만든다.
    let tmp = std::env::temp_dir().join(format!("understatus-lterm-git-{}", unique_token()));
    let git_dir = tmp.join(".git");
    std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
    let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
    writeln!(file, "ref: refs/heads/main").expect("HEAD 쓰기 실패");

    let cwd = tmp.to_string_lossy().into_owned();
    let stdin = format!(
        r#"{{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":{cwd:?}}}"#
    );

    // (1) oneline 표면: ⎇ <branch> 표시.
    let oneline_out = run_understatus(&["render", "--source", "lterm", "--oneline"], &stdin);
    let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
    assert!(
        oneline_text.contains("⎇ main"),
        "유효 git cwd는 oneline에 ⎇ <branch>를 표시해야 함: {oneline_text:?}"
    );

    // (2) cmux-status 표면: "git" pill(value=="main") 존재.
    let cmux_out = run_understatus(
        &[
            "render",
            "--source",
            "lterm",
            "--surface-format",
            "cmux-status",
        ],
        &stdin,
    );
    let cmux_text = String::from_utf8(cmux_out).expect("stdout는 UTF-8이어야 함");
    let value: serde_json::Value = serde_json::from_str(&cmux_text).expect("valid JSON");
    let pills = value["pills"].as_array().expect("pills 배열");
    let git_pill = pills
        .iter()
        .find(|p| p["key"] == "git")
        .expect("git pill이 있어야 함");
    assert_eq!(
        git_pill["value"], "main",
        "git pill 값은 bare 브랜치명: {cmux_text:?}"
    );

    let _ = std::fs::remove_dir_all(&tmp);
}

/// lterm 출력에 세션/페인 라벨("session/pane")이 cwd 앞에 표시되어야 한다(--source lterm).
#[test]
fn oneline_lterm_shows_session_pane_label() {
    let stdout = run_understatus(
        &["render", "--source", "lterm", "--oneline"],
        r#"{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":"/x/ios_cleaner"}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    // 세션/페인 라벨 + cwd basename이 함께 표시되어야 한다.
    assert!(
        text.contains("codex/%3"),
        "lterm 출력에 session/pane 라벨이 있어야 함: {text:?}"
    );
    assert!(
        text.contains("codex/%3 · ios_cleaner"),
        "session/pane은 cwd 바로 앞에 표시되어야 함: {text:?}"
    );
}

// ===== cmux 네이티브 status pills(C1/C2 surface-format, 설계 §3.3) =====

/// 빌드된 바이너리를 실행하되 종료 코드도 함께 반환한다(에러 경로 검증용).
fn run_understatus_status(args: &[&str], stdin: &str) -> (Vec<u8>, std::process::ExitStatus) {
    let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
        .args(args)
        .env("NO_COLOR", "1")
        .env(
            "UNDERSTATUS_CONFIG",
            "/nonexistent/understatus-test-config.toml",
        )
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("understatus 바이너리 실행 실패");
    child
        .stdin
        .take()
        .expect("stdin 핸들 없음")
        .write_all(stdin.as_bytes())
        .expect("stdin 쓰기 실패");
    let output = child.wait_with_output().expect("자식 종료 대기 실패");
    (output.stdout, output.status)
}

/// AC6: `--surface-format cmux-status` → 단일 줄 valid JSON(개행 0, schema/version 봉투).
#[test]
fn surface_format_cmux_status_emits_single_line_json() {
    let stdout = run_understatus(
        &[
            "render",
            "--source",
            "lterm",
            "--surface-format",
            "cmux-status",
        ],
        r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","agent":"codex"}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    // 단일 줄(내부/후행 개행 0).
    assert_eq!(
        text.matches('\n').count(),
        0,
        "cmux-status는 단일 줄 JSON이어야 함: {text:?}"
    );
    // valid JSON으로 파싱 가능 + 스키마 봉투.
    let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON이어야 함");
    assert_eq!(value["schema"], "cmux-status");
    assert_eq!(value["version"], 1);
    assert!(value["pills"].is_array(), "pills 배열 필요: {text:?}");
}

/// AC6: `--surface-format bogus` → Err exit(비정상 종료 코드).
#[test]
fn surface_format_bogus_exits_failure() {
    let (_stdout, status) = run_understatus_status(&["render", "--surface-format", "bogus"], "{}");
    assert!(
        !status.success(),
        "미지 surface-format은 실패 종료해야 함: {status:?}"
    );
}

/// AC6: 미지정 → oneline(기존 기본 render = 후행 개행 + JSON 아님).
#[test]
fn surface_format_unspecified_defaults_to_oneline() {
    let stdout = run_understatus(
        &["render", "--source", "lterm", "--oneline"],
        r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/tmp/proj","agent":"codex"}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    // oneline 표면은 cmux JSON이 아니다(schema 키 없음).
    assert!(
        !text.contains("\"schema\""),
        "미지정 표면은 oneline이어야 함(JSON 아님): {text:?}"
    );
    assert!(text.contains('%'), "oneline 코어 세그먼트 유지: {text:?}");
}

/// 비결정 P2 세그먼트(network/disk/battery)를 모두 끈 격리 config를 만들고 경로를 반환한다.
///
/// network throughput은 직전 샘플이 캐시되어야 노출되므로 별도 프로세스 호출 간에 노출 여부가
/// 흔들린다(첫 샘플 None → 둘째 Some). disk/battery도 호스트 상태 의존이라 비결정적이다. 이들을
/// 꺼서 CPU 글리프 + cpu% + mem%만 남기면 세그먼트 골격이 호출 간 안정된다(수치만 라이브로 변동).
fn write_deterministic_core_config(tag: &str) -> String {
    let path = std::env::temp_dir().join(format!(
        "understatus-core-cfg-{}-{}.toml",
        std::process::id(),
        tag
    ));
    let toml = "[display]\nshow_network = false\nshow_disk = false\nshow_battery = false\n";
    std::fs::write(&path, toml).expect("임시 config 작성 실패");
    path.to_string_lossy().into_owned()
}

/// 직교성 고정: `--surface-format oneline`(--oneline 없이)은 플래그 전혀 없는 기본 render와
/// **동일 골격**이어야 한다(예상치 못한 terse 동작 없음). 라이브 시스템 지표(cpu/mem 숫자·밴드
/// 글리프) 때문에 리터럴 byte-identical 단언은 불가하므로, 그 라이브 산출물만 제거한 골격이
/// byte-identical임을 단언한다(skeleton equivalence).
///
/// `--surface-format`은 표면 선택일 뿐 terse 여부는 `--oneline`가 별도로 정하므로, `oneline`
/// 표면을 명시해도 chain/compose + 후행 개행을 거치는 기존 경로를 그대로 타야 한다. 비결정 P2
/// 세그먼트(network/disk/battery)는 config로 꺼서 세그먼트 골격을 호출 간 고정하고, 라이브 CPU
/// 의존 산출물(cpu% 숫자 + 밴드 글리프)과 mem% 숫자만 제거로 무력화한 뒤 두 출력 골격이
/// **byte-identical**임을 단언한다.
#[test]
fn surface_format_oneline_matches_default_render_skeleton() {
    // claude source(기본) + chain_command 없음 + P2 세그먼트 off → 두 경로가 동일 골격을 낸다.
    let config = write_deterministic_core_config("surface-oneline-identical");
    let stdin = r#"{"session_id":"surface-oneline-identical"}"#;
    let default_out = run_understatus_with_config(&["render"], stdin, &config);
    let explicit_out =
        run_understatus_with_config(&["render", "--surface-format", "oneline"], stdin, &config);
    let default_text = String::from_utf8(default_out).expect("stdout는 UTF-8이어야 함");
    let explicit_text = String::from_utf8(explicit_out).expect("stdout는 UTF-8이어야 함");
    let _ = std::fs::remove_file(&config);

    // 후행 개행 유지(terse가 아님 = 기존 일반 render 동작).
    assert!(
        explicit_text.ends_with('\n'),
        "--surface-format oneline은 terse가 아니라 후행 개행을 유지해야 함: {explicit_text:?}"
    );
    // %를 포함한 코어 세그먼트가 양쪽 모두 존재.
    assert!(
        default_text.contains('%'),
        "기본 코어 세그먼트: {default_text:?}"
    );
    assert!(
        explicit_text.contains('%'),
        "명시 oneline 코어 세그먼트: {explicit_text:?}"
    );
    // 핵심: 라이브 CPU 의존 산출물(cpu% 숫자 + CPU 밴드 글리프) + mem% 숫자를 제거하면, 두 출력
    // 골격이 byte-identical이어야 한다(추가/누락 세그먼트·후행 개행·구분자 차이가 전혀 없음 =
    // 직교성 고정). 밴드 글리프(○▁▄▆◆)는 라이브 CPU%에 따라 별도 프로세스 호출 간 달라진다.
    let strip_live = |text: &str| -> String {
        text.chars()
            .filter(|c| {
                !c.is_ascii_digit() && *c != '.' && !matches!(c, '' | '' | '' | '' | '')
            })
            .collect()
    };
    assert_eq!(
        strip_live(&default_text),
        strip_live(&explicit_text),
        "라이브 산출물을 제외한 세그먼트 골격은 byte-identical이어야 함:\n  default={default_text:?}\n  explicit={explicit_text:?}"
    );
}

/// enrich-실패(bare codex) cmux-status → pill key 집합 {model,cpu,mem}(3), ctx/progress 부재.
#[test]
fn surface_format_cmux_status_unenriched_three_pills() {
    let stdout = run_understatus(
        &[
            "render",
            "--source",
            "lterm",
            "--surface-format",
            "cmux-status",
        ],
        // 존재하지 않는 cwd → codex enrich 후보 0 → ctx None, model bare "codex".
        r#"{"source":"lterm","session":"codex","pane":"%3","cwd":"/nonexistent-cmux-pill-cwd","agent":"codex"}"#,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");
    let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
    let pills = value["pills"].as_array().expect("pills 배열");
    let mut keys: Vec<&str> = pills.iter().filter_map(|p| p["key"].as_str()).collect();
    keys.sort_unstable();
    // **2가 아니라 3**: ctx만 부재, model(bare codex)+cpu+mem 가용.
    assert_eq!(
        keys,
        vec!["cpu", "mem", "model"],
        "enrich-실패 3 pill: {text:?}"
    );
    // progress 필드는 더 이상 직렬화되지 않는다(set-progress 워크스페이스 전역 누수 회피).
    assert!(
        value.get("progress").is_none(),
        "progress 필드는 직렬화에서 제거되어야 함: {text:?}"
    );
    // 색은 #RRGGBB(model 등).
    let model = pills.iter().find(|p| p["key"] == "model").unwrap();
    let color = model["color"].as_str().expect("model 색");
    assert!(
        color.len() == 7 && color.starts_with('#'),
        "model 색 #RRGGBB: {color:?}"
    );
    assert_eq!(model["value"], "codex", "bare agent 토큰 표시");
}

// ===== E2E: Codex 세션 심층판독(spec §11 E2E, AC1/AC2) =====

/// 넓은 max_width(codex 풀 프로필이 폭 트림으로 잘리지 않게)를 가진 임시 config를 만든다.
///
/// codex enabled 기본은 true이고 chain_command는 미설정(chain 없음)이다. 폭 권위는 lterm이지만
/// `render()`는 여전히 `display.max_width`를 적용하므로, 6개 세그먼트가 모두 보이도록 넓힌다.
fn write_wide_config() -> String {
    let path =
        std::env::temp_dir().join(format!("understatus-codex-e2e-cfg-{}.toml", unique_token()));
    std::fs::write(&path, "[display]\nmax_width = 200\n").expect("임시 config 작성 실패");
    path.to_string_lossy().into_owned()
}

/// CODEX_HOME/HOME을 주입해 understatus를 실행하고 stdout 바이트를 반환한다.
///
/// codex enrich는 `CODEX_HOME`(세션 경로)와 `HOME`(캐시 루트)에 의존하므로, 합성 세션을
/// 격리 디렉터리에 두고 두 env를 주입한다. `config_path`로 표시 폭 등을 주입한다.
fn run_with_codex_env(
    args: &[&str],
    stdin: &str,
    codex_home: &str,
    home: &str,
    config_path: &str,
) -> Vec<u8> {
    let mut child = Command::new(env!("CARGO_BIN_EXE_understatus"))
        .args(args)
        .env("NO_COLOR", "1")
        .env("UNDERSTATUS_CONFIG", config_path)
        .env("CODEX_HOME", codex_home)
        .env("HOME", home)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .expect("understatus 바이너리 실행 실패");
    child
        .stdin
        .take()
        .expect("stdin 핸들 없음")
        .write_all(stdin.as_bytes())
        .expect("stdin 쓰기 실패");
    let output = child.wait_with_output().expect("자식 종료 대기 실패");
    assert!(
        output.status.success(),
        "종료 코드 비정상: {:?}",
        output.status
    );
    output.stdout
}

/// 합성 Codex 세션(session_meta + turn_context + token_count)을 임시 CODEX_HOME에 작성한다.
///
/// # 반환
/// `(codex_home, cache_home)` 임시 디렉터리 경로. 호출자가 정리한다.
fn write_synthetic_codex_session(cwd: &str) -> (std::path::PathBuf, std::path::PathBuf) {
    let unique = unique_token();
    let codex_home = std::env::temp_dir().join(format!("understatus-e2e-codex-{unique}"));
    let cache_home = std::env::temp_dir().join(format!("understatus-e2e-home-{unique}"));
    let day_dir = codex_home
        .join("sessions")
        .join("2026")
        .join("06")
        .join("05");
    std::fs::create_dir_all(&day_dir).expect("일자 디렉터리 생성 실패");
    std::fs::create_dir_all(&cache_home).expect("캐시 홈 생성 실패");

    // 275/1000 = 27.5% ctx, 5h=3%, wk=21%, plan=pro, effort=xhigh, model=gpt-5.5.
    let session_meta = format!(
        r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"codex-tui","cli_version":"0.137.0"}}}}"#
    );
    let turn_context = r#"{"type":"turn_context","payload":{"model":"gpt-5.5","effort":"xhigh","summary":"auto"}}"#;
    let token_count = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":9999999},"last_token_usage":{"total_tokens":275},"model_context_window":1000},"rate_limits":{"limit_id":"codex","primary":{"used_percent":3.0,"window_minutes":300},"secondary":{"used_percent":21.0,"window_minutes":10080},"plan_type":"pro"}}}"#;

    let path = day_dir.join("rollout-2026-06-05T20-40-45-e2e.jsonl");
    let body = format!("{session_meta}\n{turn_context}\n{token_count}\n");
    std::fs::write(&path, body).expect("합성 세션 쓰기 실패");
    (codex_home, cache_home)
}

/// AC1 E2E: 합성 단일 Codex 세션 → 1행에 풀 프로필(model·ctx·5h·wk·plan·effort).
#[test]
fn e2e_codex_single_session_full_profile() {
    let cwd = "/Users/me/e2e-codex-proj";
    let (codex_home, cache_home) = write_synthetic_codex_session(cwd);
    let config = write_wide_config();
    let stdin = format!(
        r#"{{"source":"lterm","session":"codex","pane":"%9","cwd":"{cwd}","agent":"codex"}}"#
    );
    let stdout = run_with_codex_env(
        &["render", "--source", "lterm", "--oneline"],
        &stdin,
        &codex_home.to_string_lossy(),
        &cache_home.to_string_lossy(),
        &config,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");

    // 정확히 1행(개행 0).
    assert_eq!(
        text.matches('\n').count(),
        0,
        "정확히 1행이어야 함: {text:?}"
    );
    // 풀 프로필: 실모델 + ctx% + 5h% + wk% + plan + effort.
    assert!(text.contains("gpt-5.5"), "실모델 표시: {text:?}");
    assert!(
        text.contains("ctx 28%") || text.contains("ctx 27%"),
        "ctx% 표시: {text:?}"
    );
    assert!(text.contains("5h 3%"), "5h 한도 표시: {text:?}");
    assert!(text.contains("wk 21%"), "주간 한도 표시: {text:?}");
    assert!(text.contains("pro"), "plan(bare value) 표시: {text:?}");
    assert!(text.contains("xhigh"), "effort(bare value) 표시: {text:?}");
    // 저하 시 보이는 bare "codex"가 실모델로 대체되었어야 한다.
    assert!(
        !text.contains(" codex "),
        "model 슬롯이 실모델로 enrich되어야 함: {text:?}"
    );

    let _ = std::fs::remove_dir_all(&codex_home);
    let _ = std::fs::remove_dir_all(&cache_home);
    let _ = std::fs::remove_file(&config);
}

/// AC2 E2E: 미매칭(cwd 불일치) → enrich 생략 → 기존 lterm 출력으로 정직하게 저하.
///
/// 합성 세션의 cwd와 다른 cwd로 호출하면 후보 0개 → enrich 없음. codex 한도 세그먼트가
/// 일절 없고 model 슬롯이 bare "codex"로 남아 기존 lterm 출력과 동형이어야 한다.
///
/// 주의: 두 라이브 프로세스 stdout의 정확한 바이트 동일 비교는 CPU/mem 등 라이브 샘플이
/// 매 실행마다 달라 비결정적이다. 따라서 "enrich 미발동(codex 세그먼트 부재 + bare codex
/// 유지)"이라는 관측 가능한 저하 계약으로 검증한다(세그먼트 단위 byte 동일은 단위 테스트가 담당).
#[test]
fn e2e_codex_unmatched_degrades_to_bare_lterm() {
    let session_cwd = "/Users/me/e2e-codex-has-session";
    let (codex_home, cache_home) = write_synthetic_codex_session(session_cwd);
    let config = write_wide_config();
    // 세션과 다른 cwd → 후보 0 → enrich 생략.
    let stdin = r#"{"source":"lterm","session":"codex","pane":"%8","cwd":"/Users/me/e2e-no-match","agent":"codex"}"#;

    let stdout = run_with_codex_env(
        &["render", "--source", "lterm", "--oneline"],
        stdin,
        &codex_home.to_string_lossy(),
        &cache_home.to_string_lossy(),
        &config,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");

    // 정확히 1행.
    assert_eq!(
        text.matches('\n').count(),
        0,
        "정확히 1행이어야 함: {text:?}"
    );
    // enrich 미발동: codex 한도/실모델/ctx 세그먼트가 없어야 한다.
    assert!(!text.contains("5h "), "미매칭은 5h 세그먼트 없음: {text:?}");
    assert!(!text.contains("wk "), "미매칭은 wk 세그먼트 없음: {text:?}");
    assert!(
        !text.contains("gpt-5.5"),
        "미매칭은 실모델 enrich 없음: {text:?}"
    );
    assert!(
        !text.contains("ctx "),
        "미매칭은 ctx 세그먼트 없음: {text:?}"
    );
    // model 슬롯은 bare "codex"로 남는다(기존 lterm 저하).
    assert!(
        text.contains("codex"),
        "미매칭은 bare codex로 정직하게 저하해야 함: {text:?}"
    );

    let _ = std::fs::remove_dir_all(&codex_home);
    let _ = std::fs::remove_dir_all(&cache_home);
    let _ = std::fs::remove_file(&config);
}