shuire 0.2.0

Vim-like TUI git diff viewer
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
//! End-to-end tests that drive the real `shuire` TUI through the
//! [`tuistory`](https://github.com/) terminal harness.
//!
//! Each test spawns a PTY with `tuistory launch`, sends keystrokes, waits for
//! expected UI text, asserts on the terminal snapshot, then closes the session.
//!
//! These tests are `#[ignore]`d by default because they depend on an external
//! binary (`tuistory`) and a built `shuire`. Run them explicitly:
//!
//! ```text
//! cargo build --release --bin shuire   # any profile works; tests use the bin path
//! cargo test --test e2e -- --ignored --test-threads=1
//! ```
//!
//! `--test-threads=1` keeps the shared comment-storage file from colliding
//! between sessions. `--clean` is also passed on every launch so tests do not
//! inherit stale state from a previous run.

use std::path::PathBuf;
use std::process::{Command, Output};

const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
const SHUIRE_BIN: &str = env!("CARGO_BIN_EXE_shuire");

/// Quote a CLI argument so that tuistory's shell-style parser preserves it
/// verbatim — critical for passing JSON blobs through `--comment`.
fn shell_quote(arg: &str) -> String {
    let safe = arg
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || "-_./=:@".contains(c));
    if safe {
        arg.to_string()
    } else {
        format!("'{}'", arg.replace('\'', r"'\''"))
    }
}

fn tuistory_raw(args: &[&str]) -> Option<Output> {
    Command::new("tuistory").args(args).output().ok()
}

fn tuistory_ok(args: &[&str]) -> String {
    let out = tuistory_raw(args).expect("tuistory must be installed and on PATH");
    assert!(
        out.status.success(),
        "`tuistory {}` failed\nstdout: {}\nstderr: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

fn tuistory_available() -> bool {
    Command::new("tuistory")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

struct Session {
    name: String,
}

impl Session {
    fn launch(name: &str, extra_args: &[&str]) -> Self {
        let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
        let mut cmd = format!("{SHUIRE_BIN} --from-file {sample} --theme dark --no-emoji --clean");
        for a in extra_args {
            cmd.push(' ');
            cmd.push_str(&shell_quote(a));
        }
        Self::launch_raw(name, &cmd)
    }

    fn launch_raw(name: &str, cmd: &str) -> Self {
        let _ = tuistory_raw(&["close", "-s", name]);
        tuistory_ok(&["launch", cmd, "-s", name, "--cols", "140", "--rows", "40"]);
        Self { name: name.into() }
    }

    fn wait(&self, pat: &str) {
        tuistory_ok(&["wait", pat, "-s", &self.name, "--timeout", "10000"]);
    }

    fn wait_idle(&self) {
        let _ = tuistory_raw(&["wait-idle", "-s", &self.name]);
    }

    fn press(&self, keys: &[&str]) {
        let mut args: Vec<&str> = vec!["press"];
        args.extend_from_slice(keys);
        args.extend_from_slice(&["-s", &self.name]);
        tuistory_ok(&args);
    }

    fn type_text(&self, text: &str) {
        tuistory_ok(&["type", text, "-s", &self.name]);
    }

    fn snapshot(&self) -> String {
        tuistory_ok(&["snapshot", "-s", &self.name, "--trim"])
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        let _ = tuistory_raw(&["close", "-s", &self.name]);
    }
}

macro_rules! require_tuistory {
    () => {
        if !tuistory_available() {
            eprintln!("skipping: `tuistory` is not installed");
            return;
        }
    };
}

#[test]
#[ignore]
fn scenario_launch_renders_file_list() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-launch", &[]);
    s.wait("FILES");
    s.wait("README.md");

    let snap = s.snapshot();
    for expected in ["README.md", "Cargo.toml", "src"] {
        assert!(
            snap.contains(expected),
            "expected `{expected}` in snapshot:\n{snap}"
        );
    }
}

#[test]
#[ignore]
fn scenario_tab_moves_to_next_file() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-nav", &[]);
    s.wait("README.md");

    s.press(&["tab"]);
    s.wait("Cargo.toml");

    s.press(&["tab"]);
    s.wait("main.rs");

    s.press(&["shift", "tab"]);
    s.wait_idle();
    let snap = s.snapshot();
    assert!(
        snap.contains("Cargo.toml"),
        "expected Cargo.toml after Shift-Tab:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_toggle_split_view() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-split", &["--mode", "unified"]);
    s.wait("README.md");
    s.wait_idle();
    let unified = s.snapshot();

    s.press(&["s"]);
    s.wait_idle();
    let split = s.snapshot();

    assert_ne!(
        unified, split,
        "toggling split view should change the rendered layout"
    );

    s.press(&["s"]);
    s.wait_idle();
    let unified_again = s.snapshot();
    assert_ne!(split, unified_again);
}

#[test]
#[ignore]
fn scenario_help_overlay_toggles() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-help", &[]);
    s.wait("FILES");

    // `?` goes through `type`, not `press`: `press shift slash` yields
    // a chord, but shuire's key handler wants the literal `?` char.
    s.type_text("?");
    s.wait("Navigation");

    let with_help = s.snapshot();
    assert!(
        with_help.contains("朱入レ"),
        "expected help body:\n{with_help}"
    );

    s.press(&["esc"]);
    s.wait_idle();
    let dismissed = s.snapshot();
    assert!(
        !dismissed.contains("Navigation"),
        "help overlay should be dismissed:\n{dismissed}"
    );
}

#[test]
#[ignore]
fn scenario_file_filter() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-filter", &[]);
    s.wait("README.md");

    s.press(&["h"]);
    s.type_text("/");
    s.wait_idle();
    s.type_text("api");
    s.wait_idle();

    let snap = s.snapshot();
    assert!(
        snap.contains("api"),
        "expected /api filter marker in header:\n{snap}"
    );
    assert!(
        snap.contains("handlers.rs") || snap.contains("routes.rs"),
        "expected matching api/* files to remain:\n{snap}"
    );

    s.press(&["esc"]);
    s.wait_idle();
}

fn scratch_dir(tag: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("shuire-e2e-{}-{}", tag, std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create scratch dir");
    dir
}

#[test]
#[ignore]
fn scenario_stdout_emits_final_comments() {
    require_tuistory!();

    let scratch = scratch_dir("stdout");
    let script_path = scratch.join("run.sh");

    let marker = "e2e-stdout-marker-7a3b";
    let comment_json = format!(r#"{{"file":"README.md","line":1,"body":"{marker}"}}"#);

    // Why a wrapper script instead of redirecting stdout to a file:
    // shuire shares one stdout between ratatui's alternate-screen escape
    // sequences and the post-teardown `print!` dump. Redirecting eats the
    // UI; we need it in the PTY. So we let the final `print!` land on the
    // restored main screen, then echo a sentinel so the test can sync on
    // shuire having exited cleanly.
    let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
    let script_body = format!(
        "#!/bin/sh\n\
         {bin} --from-file {sample} --theme dark --no-emoji --clean \
         --comment '{json}'\n\
         echo __E2E_STDOUT_DONE__\n",
        bin = SHUIRE_BIN,
        json = comment_json,
    );
    std::fs::write(&script_path, script_body).expect("write wrapper script");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
            .expect("chmod +x");
    }

    let s = Session::launch_raw(
        "shuire-e2e-stdout",
        &format!("sh {}", script_path.display()),
    );
    s.wait("README.md");
    s.press(&["q"]);
    s.wait("__E2E_STDOUT_DONE__");

    let snap = s.snapshot();
    assert!(
        snap.contains(marker),
        "expected saved comment body on restored main screen:\n{snap}"
    );
    assert!(
        snap.contains("README.md:"),
        "expected location header on restored main screen:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_clipboard_yank_all() {
    require_tuistory!();

    // arboard needs a running X11/Wayland session on Linux; skip gracefully
    // on headless hosts instead of failing the whole suite.
    let mut clipboard = match arboard::Clipboard::new() {
        Ok(cb) => cb,
        Err(e) => {
            eprintln!("skipping: clipboard backend unavailable ({e})");
            return;
        }
    };
    // Seed the clipboard so the post-`Y` assertion proves *this* run wrote
    // to it, not a leftover from a previous session.
    let sentinel = "e2e-clipboard-pre-sentinel";
    let _ = clipboard.set_text(sentinel);

    let marker = "e2e-clipboard-marker-8d4f";
    let comment_json = format!(r#"[{{"file":"README.md","line":1,"body":"{marker}"}}]"#);
    let s = Session::launch("shuire-e2e-clipboard", &["--comment", &comment_json]);
    s.wait("README.md");

    s.press(&["shift", "y"]);
    s.wait("Copied");
    s.wait_idle();

    let got = clipboard.get_text().expect("read clipboard after Y");
    assert_ne!(got, sentinel, "clipboard was never updated by `Y`");
    assert!(
        got.contains(marker),
        "expected marker in clipboard, got: {got:?}"
    );
    assert!(
        got.contains("README.md:"),
        "expected location header in clipboard, got: {got:?}"
    );
}

#[test]
#[ignore]
fn scenario_add_comment_roundtrip() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-comment", &[]);
    s.wait("README.md");

    s.press(&["l"]);
    s.press(&["j"]);
    s.press(&["i"]);
    s.wait("New Comment");

    let body = "e2e-roundtrip-marker";
    s.type_text(body);
    s.wait_idle();
    s.press(&["ctrl", "s"]);
    s.wait_idle();

    let snap = s.snapshot();
    assert!(
        snap.contains(body),
        "saved comment body should appear in the diff pane:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_search_highlights_match() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-search", &[]);
    s.wait("README.md");

    // Search for a unique string that only exists in README.md's new hunk so
    // we can assert the cursor landed on that line.
    s.press(&["l"]); // focus diff pane
    s.type_text("/");
    s.wait_idle();
    s.type_text("enjoy");
    s.press(&["enter"]);
    s.wait_idle();

    let after_search = s.snapshot();
    assert!(
        after_search.contains("enjoy"),
        "match body should still be visible:\n{after_search}"
    );
    assert!(
        after_search.contains("Search: enjoy") || after_search.contains("/enjoy"),
        "expected active-search marker in status bar:\n{after_search}"
    );

    // Esc clears the active search; the marker should be gone.
    s.press(&["esc"]);
    s.wait_idle();
    let cleared = s.snapshot();
    assert!(
        !cleared.contains("Search: enjoy"),
        "Esc should clear the search marker:\n{cleared}"
    );
}

#[test]
#[ignore]
fn scenario_delete_comment_with_dd() {
    require_tuistory!();

    // Seed the comment on the file that ends up at the top of the tree (the
    // initial selection), so we don't have to navigate before the delete.
    let marker = "e2e-delete-marker-5b2c";
    let comment_json =
        format!(r#"{{"file":".github/workflows/ci.yml","line":1,"body":"{marker}"}}"#);
    let s = Session::launch("shuire-e2e-delete-comment", &["--comment", &comment_json]);
    s.wait("ci.yml");

    // Focus the diff pane and focus the comment: `j` from a line with a
    // comment attached lifts `comment_focus` to Some(0) instead of advancing
    // the cursor — which is what `dd` operates on.
    s.press(&["l"]);
    s.press(&["j"]);
    s.wait_idle();
    let before = s.snapshot();
    assert!(
        before.contains(marker),
        "seed comment should be visible before delete:\n{before}"
    );

    // `dd` deletes the focused comment.
    s.press(&["d"]);
    s.press(&["d"]);
    s.wait_idle();

    let after = s.snapshot();
    assert!(
        !after.contains(marker),
        "comment body should be gone after dd:\n{after}"
    );
    assert!(
        after.contains("Comment deleted"),
        "expected flash toast after delete:\n{after}"
    );
}

#[test]
#[ignore]
fn scenario_file_list_toggle_with_shift_f() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-filelist", &[]);
    s.wait("FILES");

    // With file list visible we should see the FILES header.
    let with_list = s.snapshot();
    assert!(with_list.contains("FILES"));

    // Shift-F hides the file list; the header should disappear.
    s.press(&["shift", "f"]);
    s.wait_idle();
    let hidden = s.snapshot();
    assert!(
        !hidden.contains("FILES"),
        "FILES header should be hidden after Shift-F:\n{hidden}"
    );

    // Toggle back.
    s.press(&["shift", "f"]);
    s.wait_idle();
    let shown = s.snapshot();
    assert!(
        shown.contains("FILES"),
        "FILES header should reappear:\n{shown}"
    );
}