seshat-bin 0.7.1

Seshat binary entry point
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
//! Integration tests for the `seshat scan` command.
//!
//! Tests the CLI binary end-to-end: argument validation, error output,
//! exit codes, and output patterns on real (fixture) project directories.

use std::path::Path;

use assert_cmd::Command;
use chrono::Utc;
use predicates::prelude::*;

/// Helper: get a `Command` for the seshat binary, with HOME isolated to a
/// freshly-leaked tempdir so the test cannot read or write the user's real
/// `~/Library/Application Support/seshat/` (or `~/.local/share/seshat/`).
///
/// `dirs::data_dir()` is built from `$HOME` on every supported platform, so
/// overriding HOME redirects every DB / cache / version-check write produced
/// by the binary into the isolated tempdir. We also clear `XDG_DATA_HOME`
/// because Linux `dirs::data_dir()` honors it ahead of HOME.
///
/// The tempdir intentionally outlives the test (`into_path` leaks it) — the
/// OS cleans `/tmp` later, and this avoids tying the lifetime of HOME to a
/// guard that callers would need to thread through every test.
fn seshat() -> Command {
    let home = tempfile::tempdir().expect("create isolated HOME tempdir");
    // `keep` consumes the TempDir but disables auto-cleanup; the path
    // outlives the test process, so HOME stays valid for the whole binary run.
    let home_path = home.keep();
    let mut cmd = Command::cargo_bin("seshat").expect("binary exists");
    cmd.env("HOME", &home_path);
    cmd.env_remove("XDG_DATA_HOME");
    cmd
}

/// RAII guard that removes the project database created by `seshat scan <dir>`
/// when it goes out of scope (including on panic / assert failure).
///
/// `seshat scan` stores its DB at `$XDG_DATA_HOME/seshat/repos/{dir_name}.db`.
/// Wrapping cleanup in `Drop` ensures orphan `.tmp*.db` files never accumulate
/// even when a test assertion fails.
struct ProjectDbGuard {
    db_file: Option<std::path::PathBuf>,
}

impl ProjectDbGuard {
    fn new(scanned_path: &Path) -> Self {
        let dir_name = scanned_path
            .file_name()
            .expect("scanned path has a file_name component")
            .to_string_lossy()
            .to_string();

        let db_file = dirs::data_dir().map(|d| {
            d.join("seshat")
                .join("repos")
                .join(format!("{dir_name}.db"))
        });
        Self { db_file }
    }
}

impl Drop for ProjectDbGuard {
    fn drop(&mut self) {
        if let Some(ref db_file) = self.db_file {
            // Remove the main DB file and SQLite WAL/SHM sidecars.
            for ext in ["", "-wal", "-shm"] {
                let path = if ext.is_empty() {
                    db_file.clone()
                } else {
                    let name = format!("{}{ext}", db_file.file_name().unwrap().to_string_lossy());
                    db_file.with_file_name(name)
                };
                let _ = std::fs::remove_file(&path);
            }
        }
    }
}

// ── Error cases ──────────────────────────────────────────────────────

#[test]
fn scan_nonexistent_path_exits_with_error() {
    seshat()
        .args(["scan", "/tmp/seshat-test-nonexistent-path-12345"])
        .assert()
        .failure()
        .stderr(predicates::str::contains("error:"))
        .stderr(predicates::str::contains("does not exist"));
}

#[test]
fn scan_file_instead_of_directory_exits_with_error() {
    // Create a temp file (not a directory).
    let tmp = tempfile::NamedTempFile::new().expect("create temp file");
    let path = tmp.path().to_str().expect("valid path");

    seshat()
        .args(["scan", path])
        .assert()
        .failure()
        .stderr(predicates::str::contains("error:"))
        .stderr(predicates::str::contains("not a directory"));
}

// ── Success cases ────────────────────────────────────────────────────

#[test]
fn scan_empty_directory_succeeds_with_warning() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    let _guard = ProjectDbGuard::new(tmp.path());

    seshat()
        .args(["scan", tmp.path().to_str().expect("valid path")])
        .assert()
        .success()
        .stderr(predicates::str::contains("Scanned 0 files"))
        .stderr(predicates::str::contains("no files discovered"));
}

#[test]
fn scan_fixture_project_succeeds() {
    // Use the Rust fixture project from the test fixtures directory.
    let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("parent")
        .parent()
        .expect("workspace root")
        .join("tests/fixtures/rust_project");

    if !fixture.exists() {
        // Skip if fixture not available (CI environments).
        return;
    }

    seshat()
        .env("NO_COLOR", "1")
        .args(["scan", fixture.to_str().expect("valid path")])
        .assert()
        .success()
        .stderr(predicates::str::contains("Scanned"))
        .stderr(predicates::str::contains("Completed in"));
}

#[test]
fn scan_directory_with_no_parseable_files_succeeds() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    let _guard = ProjectDbGuard::new(tmp.path());

    // Create files with unrecognized extensions.
    std::fs::write(tmp.path().join("readme.txt"), "hello").expect("write file");
    std::fs::write(tmp.path().join("data.csv"), "a,b,c").expect("write file");

    seshat()
        .args(["scan", tmp.path().to_str().expect("valid path")])
        .assert()
        .success()
        .stderr(predicates::str::contains("Scanned 0 files"));
}

// ── Verbosity ────────────────────────────────────────────────────────

#[test]
fn scan_quiet_mode_minimal_output() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    let _guard = ProjectDbGuard::new(tmp.path());

    seshat()
        .args(["scan", tmp.path().to_str().expect("valid path"), "--quiet"])
        .assert()
        .success()
        .stderr(predicates::str::contains("Scanned"))
        .stderr(predicates::str::contains("Completed in"))
        // Quiet mode should NOT show the version header.
        .stderr(predicates::str::contains("seshat v").not());
}

#[test]
fn scan_verbose_mode_shows_timing() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    let _guard = ProjectDbGuard::new(tmp.path());

    seshat()
        .args([
            "scan",
            tmp.path().to_str().expect("valid path"),
            "--verbose",
        ])
        .assert()
        .success()
        .stderr(predicates::str::contains("Timing"))
        .stderr(predicates::str::contains("Total:"));
}

// ── Version ──────────────────────────────────────────────────────────

#[test]
fn version_flag_prints_version() {
    seshat()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicates::str::contains("seshat"));
}

// ── No subcommand ────────────────────────────────────────────────────

#[test]
fn no_subcommand_shows_help() {
    seshat().assert().failure();
}

// ── Stubbed commands ─────────────────────────────────────────────────

#[test]
fn serve_starts_and_shows_startup_info() {
    // Without a real MCP client on stdin, the server starts, displays
    // startup info, then exits with a transport error. We verify the
    // startup display is printed correctly.
    //
    // Run from an isolated tempdir with an isolated HOME so the test does
    // not pick up a pre-existing DB under the real
    // `~/Library/Application Support/seshat/repos/` whose migration
    // history may have been advanced by a different working branch.
    let tmp_home = tempfile::tempdir().expect("create home temp dir");
    let tmp_cwd = tempfile::tempdir().expect("create cwd temp dir");

    // The tempdir lives under `/var/folders` (macOS) or `/tmp` (Linux),
    // both of which match the per-OS dangerous-cwd denylist (US-003).
    // Without a nearby `.git`, the auto-scan refusal fires before the
    // startup banner is printed. Initializing a git repo here makes
    // `find_git_root` short-circuit the guard so `serve` proceeds to
    // print "Waiting for MCP client connection".
    std::process::Command::new("git")
        .args(["init", "--quiet", "-b", "main"])
        .current_dir(tmp_cwd.path())
        .output()
        .expect("git init");

    // `serve` does not mkdir its repos directory — it expects scan to have
    // run first. Pre-create the platform-specific data dir so the auto-scan
    // path can write the fresh DB.
    #[cfg(target_os = "macos")]
    let repos_dir = tmp_home
        .path()
        .join("Library")
        .join("Application Support")
        .join("seshat")
        .join("repos");
    #[cfg(not(target_os = "macos"))]
    let repos_dir = tmp_home
        .path()
        .join(".local")
        .join("share")
        .join("seshat")
        .join("repos");
    std::fs::create_dir_all(&repos_dir).expect("create repos dir");

    // Once the dangerous-cwd guard is bypassed (via the git init above)
    // `serve` enters its long-running event loop and does not exit on
    // closed stdin (background scan/GC/watcher tasks keep the runtime
    // alive). Bound the wait via `timeout`: assert_cmd kills the child
    // and collects stderr after the deadline, so we can still check that
    // the startup banner was printed before the kill.
    seshat_with_home(tmp_home.path())
        .env("NO_COLOR", "1")
        .current_dir(tmp_cwd.path())
        .arg("serve")
        .timeout(std::time::Duration::from_secs(25))
        .assert()
        .failure()
        .stderr(predicates::str::contains(
            "Waiting for MCP client connection",
        ));
}

#[test]
fn status_shows_output() {
    seshat().arg("status").assert().success();
}

// ── Review ───────────────────────────────────────────────────────────

#[test]
fn review_in_git_repo_requires_scan_first() {
    // `seshat review` in a git repo with no DB should fail with a
    // "No database found" message — NOT a "not in a git repository" error.
    let tmp = tempfile::tempdir().expect("create temp dir");

    // Initialize a git repo (no commits needed).
    std::process::Command::new("git")
        .args(["init", "-b", "main"])
        .current_dir(tmp.path())
        .output()
        .expect("git init");

    seshat()
        .current_dir(tmp.path())
        .arg("review")
        .assert()
        .failure()
        .stderr(predicates::str::contains("No database found"));
}

#[test]
fn review_without_git_repo_requires_scan_first() {
    // `seshat review` in a non-git directory should NOT fail with
    // "not in a git repository" — it should fallback gracefully and
    // report "No database found" instead.
    let tmp = tempfile::tempdir().expect("create temp dir");

    seshat()
        .current_dir(tmp.path())
        .arg("review")
        .assert()
        .failure()
        .stderr(predicates::str::contains("No database found"));
}

#[test]
fn init_auto_detects_clients() {
    // `seshat init` is now implemented — it should exit successfully.
    // In CI (no AI clients in PATH), it prints a "no clients detected" message.
    // We just verify it exits 0 and doesn't crash.
    seshat().arg("init").assert().success();
}

#[test]
fn init_unknown_client_exits_error() {
    seshat()
        .arg("init")
        .arg("vscode")
        .assert()
        .failure()
        .stderr(predicates::str::contains("Unknown client"));
}

#[test]
fn init_dry_run_flag_accepted() {
    seshat().arg("init").arg("--dry-run").assert().success();
}

// ── US-005: Background update notice ─────────────────────────────────

/// Returns the path where `dirs::data_dir()` would place the version cache
/// when `HOME` is set to `fake_home`.
///
/// - macOS: `<fake_home>/Library/Application Support/seshat/version-check.json`
/// - Linux: `<fake_home>/.local/share/seshat/version-check.json`
fn version_cache_path_for_home(fake_home: &std::path::Path) -> std::path::PathBuf {
    #[cfg(target_os = "macos")]
    let data_dir = fake_home.join("Library").join("Application Support");
    #[cfg(not(target_os = "macos"))]
    let data_dir = fake_home.join(".local").join("share");
    data_dir.join("seshat").join("version-check.json")
}

/// Write a version cache with the given version to the platform-appropriate path
/// inside `fake_home`.
fn write_version_cache_for_home(fake_home: &std::path::Path, version: &str) {
    let cache_file = version_cache_path_for_home(fake_home);
    if let Some(parent) = cache_file.parent() {
        std::fs::create_dir_all(parent).expect("create cache dir");
    }
    let now = Utc::now().to_rfc3339();
    let json = format!(r#"{{"latest_version":"{version}","checked_at":"{now}"}}"#);
    std::fs::write(&cache_file, json).expect("write cache file");
}

/// Helper: run seshat with `HOME` overridden to a specific `fake_home` (used
/// when the test needs to pre-populate or inspect files inside that HOME,
/// e.g. version-cache tests). For the default case where HOME just needs to
/// be isolated, prefer `seshat()` which already does that.
fn seshat_with_home(fake_home: &std::path::Path) -> Command {
    let mut cmd = seshat();
    cmd.env("HOME", fake_home);
    cmd
}

#[test]
fn update_notice_printed_for_status_when_newer_version_cached() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    // Cache says version 9999.0.0 — definitely newer than any real build
    write_version_cache_for_home(tmp.path(), "9999.0.0");

    seshat_with_home(tmp.path())
        .arg("status")
        .assert()
        // status may succeed or fail, but we only care that the notice appears
        .stderr(predicates::str::contains("Seshat v9999.0.0 is available"));
}

#[test]
fn update_notice_printed_for_scan_when_newer_version_cached() {
    let tmp_home = tempfile::tempdir().expect("create home temp dir");
    write_version_cache_for_home(tmp_home.path(), "9999.0.0");

    let tmp_scan = tempfile::tempdir().expect("create scan temp dir");
    let _guard = ProjectDbGuard::new(tmp_scan.path());

    seshat_with_home(tmp_home.path())
        .args(["scan", tmp_scan.path().to_str().expect("valid path")])
        .assert()
        .stderr(predicates::str::contains("Seshat v9999.0.0 is available"));
}

#[test]
fn update_notice_suppressed_for_seshat_update_check() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    // Cache says version 9999.0.0 — would normally trigger background notice
    write_version_cache_for_home(tmp.path(), "9999.0.0");

    // `seshat update --check` should NOT print the background notice.
    // The background notice format is "Seshat vX.Y.Z is available ... Run seshat update to upgrade."
    // That exact phrase must not appear for the update subcommand.
    seshat_with_home(tmp.path())
        .args(["update", "--check"])
        .assert()
        .stderr(predicates::str::contains("Run seshat update to upgrade.").not());
}

#[test]
fn update_notice_suppressed_for_seshat_update() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    write_version_cache_for_home(tmp.path(), "9999.0.0");

    // `seshat update` (without --check) should also not show the background notice.
    seshat_with_home(tmp.path())
        .arg("update")
        .assert()
        .stderr(predicates::str::contains("Run seshat update to upgrade.").not());
}

#[test]
fn update_notice_not_printed_when_up_to_date() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    // Cache says version 0.0.1 — older than any real build, so no notice expected
    write_version_cache_for_home(tmp.path(), "0.0.1");

    seshat_with_home(tmp.path())
        .arg("status")
        .assert()
        .stderr(predicates::str::contains("is available").not());
}

#[test]
fn update_notice_network_failure_silent_skip() {
    let tmp = tempfile::tempdir().expect("create temp dir");
    // No cache file → will try network. Network may succeed or fail.
    // Either way, the background notice must NOT print "Could not check for updates"
    // (that message is only for explicit `seshat update --check`).
    seshat_with_home(tmp.path())
        .arg("status")
        .assert()
        .stderr(predicates::str::contains("Could not check for updates").not());
}