understatus 0.7.1

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
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
//! `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";
/// 프로덕션 `MAX_RENDER_STDIN_BYTES`(src/main.rs)와 동기화된 통합 테스트 상한.
const RENDER_STDIN_LIMIT_BYTES: usize = 1024 * 1024;
/// 프로덕션 `MAX_CONFIG_BYTES`(src/config.rs)와 동기화된 통합 테스트 상한.
const CONFIG_LIMIT_BYTES: usize = 256 * 1024;

/// 병렬 테스트 스레드 간 임시 경로 충돌을 막는 프로세스 전역 단조 카운터.
///
/// 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
}

/// HOME 캐시 루트까지 격리해 understatus를 실행한다.
fn run_understatus_isolated_home(
    args: &[&str],
    stdin: &str,
    config_path: &str,
    home: &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("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
}

/// 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()
}

/// chain 자식이 전달받은 stdin byte 수를 `LEN:<n>`으로 출력하는 임시 config를 만든다.
fn write_stdin_len_chain_config(tag: &str) -> String {
    let path = std::env::temp_dir().join(format!(
        "understatus-chain-len-cfg-{}-{tag}.toml",
        unique_token()
    ));
    let command = "bytes=$(wc -c | tr -d ' '); printf 'LEN:%s' \"$bytes\"";
    let toml = format!(
        "[chain]\nchain_command = {command:?}\nchain_cache_ttl_seconds = 0\n[display]\nmax_width = 200\nshow_network = false\nshow_disk = false\nshow_battery = false\n"
    );
    std::fs::write(&path, toml).expect("stdin 길이 chain config 작성 실패");
    path.to_string_lossy().into_owned()
}

/// 테스트별 HOME 캐시 루트를 만든다(chain/pulse/net 캐시 간섭 방지).
fn make_isolated_home(tag: &str) -> String {
    let path = std::env::temp_dir().join(format!("understatus-home-{}-{tag}", unique_token()));
    std::fs::create_dir_all(&path).expect("격리 HOME 생성 실패");
    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:?}"
    );
}

/// oversized stdin은 실제 render 공개 경로에서 빈 입력으로 안전 저하하며, chain 자식에 원문을
/// 전달하지 않아야 한다. helper 단위 테스트를 넘어 `read_stdin()` 배선 회귀를 잡는다.
#[test]
fn oversized_stdin_is_not_forwarded_to_chain() {
    let config = write_stdin_len_chain_config("oversized-stdin");
    let home = make_isolated_home("oversized-stdin");
    let oversized = "x".repeat(RENDER_STDIN_LIMIT_BYTES + 1);

    let stdout = run_understatus_isolated_home(&["render"], &oversized, &config, &home);
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");

    assert!(
        text.contains("LEN:0"),
        "oversized stdin은 빈 raw_stdin으로 chain에 전달되어야 함: {text:?}"
    );
    assert!(
        !text.contains(&format!("LEN:{}", RENDER_STDIN_LIMIT_BYTES + 1)),
        "oversized 원문 길이가 chain에 노출되면 안 됨: {text:?}"
    );

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

/// oversized config는 `UNDERSTATUS_CONFIG`를 통한 실제 render 공개 경로에서도 파싱되지 않고 기본값으로
/// 저하해야 한다. 만약 unbounded read로 회귀하면 chain sentinel이 출력되어 이 테스트가 실패한다.
#[test]
fn oversized_config_is_ignored_by_render_path() {
    let path = std::env::temp_dir().join(format!(
        "understatus-oversized-render-cfg-{}.toml",
        unique_token()
    ));
    let mut toml = format!("[chain]\nchain_command = \"printf {CHAIN_SENTINEL}\"\n#");
    toml.push_str(&"x".repeat(CONFIG_LIMIT_BYTES + 1));
    std::fs::write(&path, toml).expect("oversized config 작성 실패");
    let config = path.to_string_lossy().into_owned();
    let home = make_isolated_home("oversized-config");

    let stdout = run_understatus_isolated_home(
        &["render"],
        r#"{"session_id":"oversized-config"}"#,
        &config,
        &home,
    );
    let text = String::from_utf8(stdout).expect("stdout는 UTF-8이어야 함");

    assert!(
        !text.contains(CHAIN_SENTINEL),
        "oversized config는 파싱되지 않아 chain_command가 실행되면 안 됨: {text:?}"
    );
    assert!(
        text.contains('%'),
        "기본 설정 렌더는 계속 성공해야 함: {text:?}"
    );

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

/// --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);
}

/// repo 하위 dir(`<root>/src`)을 cwd로 준 lterm 세션은 부모 walk-up으로 branch를 표시해야 한다(W-A v2).
///
/// run_understatus는 서브프로세스이므로 stdin의 cwd가 실재해야 한다 → hermetic temp `<root>/.git/HEAD`와
/// `<root>/src`를 **spawn 전에 디스크에 생성**한 뒤(create-before-spawn), cwd=`<root>/src`로 호출해
/// oneline 출력에 git 세그먼트(⎇ <branch>)가 포함됨을 검증한다(oneline_lterm_git_cwd_shows_branch와 동형).
#[test]
fn oneline_lterm_git_subdir_shows_branch() {
    use std::io::Write;
    // create-before-spawn: 서브프로세스가 읽을 수 있도록 spawn 전에 `<root>/.git/HEAD` + `<root>/src`를 만든다.
    let token = unique_token();
    let root = std::env::temp_dir().join(format!("understatus-lterm-subdir-{token}"));
    let git_dir = root.join(".git");
    std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
    // 흔한 `main` 대신 고유 branch명(`wt-<short>`)을 써서 다른 소스의 `main` 누출로 인한 false-green을
    // 차단한다(mutation 저항 강화). token(`<pid>-<nanos>-<counter>`)에서 파생한 짧은 8자리 hex 해시
    // (`wt-<hash>`)를 branch명으로 쓴다(영숫자/하이픈만 포함 = git ref 규칙 적합).
    let short = {
        // 간단한 FNV-1a 64bit 해시(외부 의존 없이 token을 짧은 고유 hex로 압축).
        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
        for byte in token.as_bytes() {
            hash ^= u64::from(*byte);
            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
        }
        format!("{:08x}", hash as u32)
    };
    let branch = format!("wt-{short}");
    let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
    writeln!(file, "ref: refs/heads/{branch}").expect("HEAD 쓰기 실패");
    let subdir = root.join("src");
    std::fs::create_dir_all(&subdir).expect("하위 dir 생성 실패");

    // cwd를 repo 루트가 아닌 하위 `<root>/src`로 준다(부모 walk-up이 도출해야 정상).
    let cwd = subdir.to_string_lossy().into_owned();
    let stdin = format!(
        r#"{{"source":"lterm","session":"codex","pane":"%3","agent":"codex","cwd":{cwd:?}}}"#
    );

    // 결정성 확보: 기본 max_width(80)에선 가변 네트워크 세그먼트(↓↑KB/s~MB/s) 폭에 따라 저우선 git
    // 세그먼트가 enforce_width로 잘려 flaky해진다(특히 하위 dir의 추가 `· src` 세그먼트로 폭이 더 빡빡함).
    // 넉넉한 max_width를 주입해 throughput 변동과 무관하게 git 세그먼트가 항상 유지되도록 고정한다.
    let cfg_path = std::env::temp_dir()
        .join(format!("understatus-subdir-cfg-{token}.toml"))
        .to_string_lossy()
        .into_owned();
    std::fs::write(&cfg_path, "[display]\nmax_width = 500\n").expect("임시 config 작성 실패");

    let oneline_out = run_understatus_with_config(
        &["render", "--source", "lterm", "--oneline"],
        &stdin,
        &cfg_path,
    );
    let oneline_text = String::from_utf8(oneline_out).expect("stdout는 UTF-8이어야 함");
    assert!(
        oneline_text.contains(&format!("{branch}")),
        "repo 하위 dir cwd는 부모 walk-up으로 oneline에 고유 branch(⎇ {branch})를 표시해야 함: {oneline_text:?}"
    );

    let _ = std::fs::remove_file(&cfg_path);
    let _ = std::fs::remove_dir_all(&root);
}