trusty-search 0.28.0

Machine-wide hybrid code search service: BM25 + vector + KG, zero cold-start, MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
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
//! `trusty-search hook install` / `hook uninstall` — git hook management.
//!
//! Why: After a commit or merge, changed files should be re-indexed immediately
//! so search results reflect the latest committed code.  Installing a git
//! `post-commit` / `post-merge` hook automates incremental updates without
//! needing a full reindex. The hook only fires for repos that are already
//! in the opt-in allowlist and silently no-ops when the daemon is down, so
//! it never blocks a commit.
//!
//! What: `handle_hook` writes a marker-delimited block into the target repo's
//! `.git/hooks/{post-commit,post-merge,post-checkout}` files, preserving any
//! existing hook logic via append-safe insertion.  `HookAction::Uninstall`
//! removes only the trusty-search marker block.
//!
//! Index resolution: hooks are installed into the git repo and git always sets
//! CWD to the repo root before invoking any hook. `trusty-search add/remove`
//! without an explicit `--index` flag calls `detect_project(CWD)`, which walks
//! up from the repo root, finds `.git`, and derives the index id from the
//! directory name — exactly the same id that `trusty-search index .` would
//! register. So the hook scripts deliberately omit `--index` and rely on this
//! auto-detection, which requires no baking of paths at install time.
//!
//! Test: `tests` module exercises install → verify block present → idempotent
//! re-install → uninstall → verify block removed, all using a temp `.git/hooks`
//! directory rather than the real allowlist or daemon.

use anyhow::{Context, Result};
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};

// ── Marker strings ────────────────────────────────────────────────────────────

/// Opening sentinel written into every managed hook file.
///
/// Why: must be unique and stable so install/uninstall can locate the block
/// even when other tools have also added lines to the same hook file.
const MARKER_BEGIN: &str = "# >>> trusty-search >>>";
/// Closing sentinel.
const MARKER_END: &str = "# <<< trusty-search <<<";

// ── Public interface ──────────────────────────────────────────────────────────

/// Parameters extracted from the `hook install` / `hook uninstall` clap args.
///
/// Why: bundles the two fields so `handle_hook` has a clean call signature
/// and so `main.rs` dispatches via a single match arm.
/// What: repo is the path to the git repo root (defaults to CWD); action
/// selects install or uninstall.
/// Test: constructed by `main.rs` before calling `handle_hook`.
#[derive(Debug)]
pub struct HookArgs {
    /// Path to the repository root (the directory that contains `.git/`).
    /// Defaults to CWD when `None`.
    pub repo: Option<PathBuf>,
    /// Which sub-action to perform.
    pub action: HookAction,
}

/// The two supported sub-actions for `trusty-search hook`.
///
/// Why: defined once here so `main.rs` re-exports it for the clap `Subcommand`
/// derive rather than maintaining a parallel enum — eliminates the
/// dual-enum mapping pattern noted in review issue #5.
/// What: two variants — `Install` writes the hook files; `Uninstall` removes
/// the trusty-search block.
/// Test: dispatched via `handle_hook`.
#[derive(Debug, Clone, clap::Subcommand)]
pub enum HookAction {
    /// Write post-commit / post-merge / post-checkout hooks (idempotent)
    Install,
    /// Remove the trusty-search block from the hook files
    Uninstall,
}

/// Handle `trusty-search hook install [--repo <path>]`.
///
/// Why: single public entry point so `main.rs` stays a thin dispatcher.
/// What: resolves the git directory, writes all three hook scripts.
/// Test: `tests::handle_hook_install_writes_all_three_hooks`.
pub async fn handle_hook(args: HookArgs) -> Result<()> {
    let repo_root = resolve_repo_root(args.repo.as_deref())?;
    let hooks_dir = repo_root.join(".git").join("hooks");
    anyhow::ensure!(
        hooks_dir.exists(),
        "no .git/hooks directory found at '{}' — is this a git repository?",
        repo_root.display()
    );

    match args.action {
        HookAction::Install => {
            for (name, content) in hook_scripts() {
                let path = hooks_dir.join(name);
                install_block(&path, content)?;
                println!("{} installed hook {}", "".green(), path.display());
            }
            println!(
                "  Run {} inside this repo to register it first.",
                "trusty-search index add .".cyan()
            );
        }
        HookAction::Uninstall => {
            for (name, _) in hook_scripts() {
                let path = hooks_dir.join(name);
                uninstall_block(&path)?;
                println!("{} removed trusty-search block from {}", "".red(), name);
            }
        }
    }
    Ok(())
}

// ── Hook script bodies ────────────────────────────────────────────────────────

/// The three hook scripts managed by this module.
///
/// Why: all three are returned together so install/uninstall loops stay
/// symmetric and a future addition only needs to update this one function.
/// What: returns `(hook_name, script_body)` pairs; each body already includes
/// the shebang and allowlist / daemon-down guards.
/// Test: `tests::hook_scripts_all_exit_zero` confirms each script is valid sh.
fn hook_scripts() -> [(&'static str, &'static str); 3] {
    [
        ("post-commit", POST_COMMIT_SCRIPT),
        ("post-merge", POST_MERGE_SCRIPT),
        ("post-checkout", POST_CHECKOUT_SCRIPT),
    ]
}

/// `post-commit`: index changed/added files, remove deleted files.
///
/// Index resolution: git sets CWD to the repo root before invoking this hook,
/// so `trusty-search add/remove` (no `--index` flag) will call `detect_project`
/// from that root, walk up to find `.git`, and derive the correct index id.
/// The daemon's allowlist then silently rejects repos that are not registered
/// (no-op; exit 0 always).
const POST_COMMIT_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: incremental reindex on commit
# CWD is always the repo root when git invokes this hook.
# `trusty-search add/remove` auto-detects the index from CWD — no --index needed.

BINARY="trusty-search"

# Bail out silently when the binary is not on PATH.
command -v "$BINARY" >/dev/null 2>&1 || exit 0

# No-op when the daemon is down (best-effort health probe).
"$BINARY" health >/dev/null 2>&1 || exit 0

# Determine changed/added vs deleted files in the last commit.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
CHANGED="$(git diff-tree --no-commit-id -r --name-only --diff-filter=ACRMT HEAD 2>/dev/null)" || true
DELETED="$(git diff-tree --no-commit-id -r --name-only --diff-filter=D HEAD 2>/dev/null)" || true

# Index changed/added files.
if [ -n "$CHANGED" ]; then
    echo "$CHANGED" | while IFS= read -r f; do
        [ -f "$REPO_ROOT/$f" ] || continue
        "$BINARY" add "$REPO_ROOT/$f" >/dev/null 2>&1 || true
    done
fi

# Remove deleted files.
if [ -n "$DELETED" ]; then
    echo "$DELETED" | while IFS= read -r f; do
        "$BINARY" remove "$REPO_ROOT/$f" >/dev/null 2>&1 || true
    done
fi

exit 0
"#;

/// `post-merge`: same incremental update after a merge/pull.
///
/// ORIG_HEAD may not exist on fast-forward pulls; we guard for its existence
/// and fall back to HEAD@{1} (the previous tip) so the diff is always correct.
const POST_MERGE_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: incremental reindex on merge
# CWD is always the repo root when git invokes this hook.

BINARY="trusty-search"

command -v "$BINARY" >/dev/null 2>&1 || exit 0
"$BINARY" health >/dev/null 2>&1 || exit 0

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0

# Resolve the pre-merge reference: ORIG_HEAD is reliable for true merges, but
# fast-forward pulls may not leave it. Fall back to HEAD@{1} (reflog), and if
# that also fails (initial clone) skip cleanly.
if git rev-parse --verify -q ORIG_HEAD >/dev/null 2>&1; then
    BASE="ORIG_HEAD"
elif git rev-parse --verify -q "HEAD@{1}" >/dev/null 2>&1; then
    BASE="HEAD@{1}"
else
    exit 0
fi

CHANGED="$(git diff --name-only --diff-filter=ACRMT "$BASE" HEAD 2>/dev/null)" || true
DELETED="$(git diff --name-only --diff-filter=D "$BASE" HEAD 2>/dev/null)" || true

if [ -n "$CHANGED" ]; then
    echo "$CHANGED" | while IFS= read -r f; do
        [ -f "$REPO_ROOT/$f" ] || continue
        "$BINARY" add "$REPO_ROOT/$f" >/dev/null 2>&1 || true
    done
fi

if [ -n "$DELETED" ]; then
    echo "$DELETED" | while IFS= read -r f; do
        "$BINARY" remove "$REPO_ROOT/$f" >/dev/null 2>&1 || true
    done
fi

exit 0
"#;

/// `post-checkout`: when HEAD changes substantially, trigger a background
/// diff-only reindex.  The hook receives three arguments:
///   $1 = previous HEAD, $2 = new HEAD, $3 = branch flag (1 = branch switch)
const POST_CHECKOUT_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: background reindex on branch switch
# CWD is always the repo root when git invokes this hook.

# Only act on branch-switch checkouts ($3 == 1).
[ "$3" = "1" ] || exit 0

# Skip when the HEAD didn't actually change.
[ "$1" != "$2" ] || exit 0

BINARY="trusty-search"
command -v "$BINARY" >/dev/null 2>&1 || exit 0
"$BINARY" health >/dev/null 2>&1 || exit 0

# Fire-and-forget diff-only reindex in the background so the checkout
# returns immediately.  The daemon's SHA-256 hash skip makes this cheap.
"$BINARY" reindex >/dev/null 2>&1 &

exit 0
"#;

// ── Block-level install / uninstall ──────────────────────────────────────────

/// Write (or update) a trusty-search marker block into a hook file.
///
/// Why: many tools share a single `.git/hooks/post-commit`; appending a
/// delimited block lets us coexist without clobbering existing logic. If a
/// prior block exists it is replaced in-place (idempotent).
/// What: reads the current file (or starts empty), splices the new block in,
/// writes atomically (unique tmp sibling + rename), and sets `chmod +x`.
/// Test: `tests::install_creates_and_is_idempotent`.
pub fn install_block(hook_path: &Path, script_body: &str) -> Result<()> {
    let existing = if hook_path.exists() {
        fs::read_to_string(hook_path)
            .with_context(|| format!("could not read {}", hook_path.display()))?
    } else {
        String::new()
    };

    let new_content = splice_block(&existing, script_body);

    atomic_write(hook_path, &new_content)?;
    make_executable(hook_path)?;
    Ok(())
}

/// Remove the trusty-search marker block from a hook file.
///
/// Why: `hook uninstall` must restore the hook file to its pre-install state
/// without touching any other tool's content.
/// What: reads the file, strips the marker block, writes back. No-op when
/// the file does not exist or contains no trusty-search block.
/// Test: `tests::uninstall_removes_block`.
pub fn uninstall_block(hook_path: &Path) -> Result<()> {
    if !hook_path.exists() {
        return Ok(());
    }
    let content = fs::read_to_string(hook_path)
        .with_context(|| format!("could not read {}", hook_path.display()))?;
    let stripped = remove_block(&content);
    if stripped == content {
        // Nothing to remove.
        return Ok(());
    }
    if stripped.trim().is_empty() {
        // File contained only our block — remove the file entirely.
        fs::remove_file(hook_path)
            .with_context(|| format!("could not remove {}", hook_path.display()))?;
    } else {
        atomic_write(hook_path, &stripped)?;
        make_executable(hook_path)?;
    }
    Ok(())
}

// ── String-level block manipulation ──────────────────────────────────────────

/// Insert or replace the trusty-search block in `existing`.
///
/// Why: pure-function text transformation that is easy to unit-test independently
/// of the filesystem.
/// What: finds MARKER_BEGIN..MARKER_END, replaces if present; appends if absent.
/// A shebang in `existing` is preserved as the first line even when the new
/// block is appended.
/// Test: `tests::splice_inserts_and_replaces`.
pub fn splice_block(existing: &str, script_body: &str) -> String {
    let block = format!("{}\n{}{}\n", MARKER_BEGIN, script_body, MARKER_END);

    if let Some(begin) = existing.find(MARKER_BEGIN) {
        // Find the end of the closing marker line.
        if let Some(end_pos) = existing[begin..].find(MARKER_END) {
            let abs_end = begin + end_pos + MARKER_END.len();
            // Consume trailing newline after the marker if present.
            let abs_end = if existing.as_bytes().get(abs_end) == Some(&b'\n') {
                abs_end + 1
            } else {
                abs_end
            };
            let mut result = existing[..begin].to_owned();
            result.push_str(&block);
            result.push_str(&existing[abs_end..]);
            return result;
        }
    }

    // No existing block — append.
    if existing.is_empty() {
        block
    } else {
        // Ensure we start on a new line.
        let sep = if existing.ends_with('\n') { "" } else { "\n" };
        format!("{}{}{}", existing, sep, block)
    }
}

/// Strip the trusty-search marker block from `content`.
///
/// Why: symmetric counterpart to `splice_block`, used by `uninstall_block`.
/// What: returns the content with the MARKER_BEGIN..MARKER_END span removed.
/// Test: `tests::remove_block_is_symmetric`.
pub fn remove_block(content: &str) -> String {
    let Some(begin) = content.find(MARKER_BEGIN) else {
        return content.to_owned();
    };
    let Some(end_pos) = content[begin..].find(MARKER_END) else {
        return content.to_owned();
    };
    let abs_end = begin + end_pos + MARKER_END.len();
    // Consume trailing newline after the marker if present.
    let abs_end = if content.as_bytes().get(abs_end) == Some(&b'\n') {
        abs_end + 1
    } else {
        abs_end
    };
    let mut result = content[..begin].to_owned();
    result.push_str(&content[abs_end..]);
    result
}

// ── Filesystem helpers ────────────────────────────────────────────────────────

/// Write `content` to `path` atomically using a unique temp-file sibling.
///
/// Why: prevents a partial write from leaving the hook file in a broken state,
/// and avoids temp-file name collisions if a previous process was killed.
/// What: builds a unique tmp filename using the current process id (unique per
/// running process) and a nanosecond monotonic counter — no crate dependency
/// required. Writes to the sibling file, then renames (atomic within the same
/// filesystem) to the final path. A killed process leaves at most one `.ts-tmp`
/// file per PID that git ignores (it lives in `.git/hooks/`).
/// Test: covered by `install_block` tests.
fn atomic_write(path: &Path, content: &str) -> Result<()> {
    let parent = path
        .parent()
        .ok_or_else(|| anyhow::anyhow!("hook path '{}' has no parent directory", path.display()))?;
    fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?;

    // Build a collision-resistant tmp path: PID + monotonic nanos.
    let uid = {
        use std::time::{SystemTime, UNIX_EPOCH};
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.subsec_nanos())
            .unwrap_or(0);
        format!("{}-{}", std::process::id(), nanos)
    };
    let file_name = path
        .file_name()
        .unwrap_or_else(|| std::ffi::OsStr::new("hook"));
    let tmp_name = format!(".{}-{}.ts-tmp", file_name.to_string_lossy(), uid);
    let tmp = parent.join(tmp_name);

    fs::write(&tmp, content).with_context(|| format!("could not write {}", tmp.display()))?;
    fs::rename(&tmp, path)
        .with_context(|| format!("could not rename {} to {}", tmp.display(), path.display()))?;
    Ok(())
}

/// Set the hook file to `chmod +x`.
///
/// Why: git will not invoke a hook script unless it is executable.
/// What: reads current permissions, adds 0o111 (user/group/other execute).
///   On non-Unix targets this is a no-op (Windows does not have Unix
///   permission bits, and hook scripts are resolved differently there).
/// Test: covered by `install_block` integration tests on Unix.
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let meta = fs::metadata(path).with_context(|| format!("could not stat {}", path.display()))?;
    let mut perms = meta.permissions();
    let mode = perms.mode() | 0o111;
    perms.set_mode(mode);
    fs::set_permissions(path, perms)
        .with_context(|| format!("could not chmod +x {}", path.display()))?;
    Ok(())
}

/// No-op on non-Unix platforms (Windows manages hook executability differently).
///
/// Why: `git` on Windows invokes hook scripts via the shell regardless of the
/// executable bit; forcing compilation of the Unix chmod path on Windows would
/// break the build with no benefit.
/// What: returns `Ok(())` immediately.
/// Test: compile-time gate only; no runtime test needed.
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
    Ok(())
}

/// Resolve the git repository root.
///
/// Why: users often run `hook install` from a subdirectory of the repo.
/// What: if `explicit` is given, use it; otherwise ask git for the top-level.
/// Test: `tests::resolve_repo_root_falls_back_to_git`.
fn resolve_repo_root(explicit: Option<&Path>) -> Result<PathBuf> {
    if let Some(p) = explicit {
        let canon =
            fs::canonicalize(p).with_context(|| format!("could not resolve '{}'", p.display()))?;
        return Ok(canon);
    }
    // Ask git.
    let out = std::process::Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .context("could not run git")?;
    if !out.status.success() {
        anyhow::bail!("not inside a git repository; pass --repo <path>");
    }
    let raw = std::str::from_utf8(&out.stdout)
        .context("git output was not UTF-8")?
        .trim()
        .to_owned();
    Ok(PathBuf::from(raw))
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    // ── String manipulation ───────────────────────────────────────────────────

    #[test]
    fn splice_inserts_into_empty() {
        // Why: a fresh hook file with no existing content should receive the
        // complete block (including the marker sentinels).
        let result = splice_block("", "echo hello\n");
        assert!(result.contains(MARKER_BEGIN), "missing begin marker");
        assert!(result.contains(MARKER_END), "missing end marker");
        assert!(result.contains("echo hello"), "missing body");
    }

    #[test]
    fn splice_appends_to_existing_content() {
        // Why: any existing hook content (e.g. from another tool) must be
        // preserved before the trusty-search block.
        let existing = "#!/bin/sh\necho other\n";
        let result = splice_block(existing, "echo ts\n");
        assert!(
            result.starts_with("#!/bin/sh\necho other\n"),
            "existing content moved"
        );
        assert!(result.contains(MARKER_BEGIN), "missing begin marker");
        assert!(result.contains("echo ts"), "missing body");
    }

    #[test]
    fn splice_replaces_existing_block() {
        // Why: idempotent re-install must not duplicate the block.
        let first = splice_block("", "echo v1\n");
        let second = splice_block(&first, "echo v2\n");
        // Only one begin/end pair.
        assert_eq!(second.matches(MARKER_BEGIN).count(), 1, "duplicate begin");
        assert_eq!(second.matches(MARKER_END).count(), 1, "duplicate end");
        assert!(second.contains("echo v2"), "new body missing");
        assert!(!second.contains("echo v1"), "old body still present");
    }

    #[test]
    fn remove_block_is_symmetric() {
        // Why: uninstall must restore the file to its pre-install state.
        let original = "#!/bin/sh\necho other\n";
        let installed = splice_block(original, "echo ts\n");
        let restored = remove_block(&installed);
        // The restored content should be equal (modulo trailing newlines) to
        // the original.
        assert_eq!(restored.trim(), original.trim());
    }

    #[test]
    fn remove_block_noop_when_absent() {
        // Why: calling uninstall on a file that was never managed must be safe.
        let content = "#!/bin/sh\necho other\n";
        let result = remove_block(content);
        assert_eq!(result, content);
    }

    // ── Issue 1: hook scripts must NOT contain --index "" ─────────────────────

    #[test]
    fn post_commit_script_does_not_use_empty_index_flag() {
        // Why: `--index ""` overrides auto-detection with an empty string, making
        // every incremental update silently fail against a non-existent index.
        // The correct approach is to omit --index so the CLI auto-detects the
        // index from CWD (which git sets to the repo root before invoking hooks).
        assert!(
            !POST_COMMIT_SCRIPT.contains("--index \"\""),
            "post-commit must not use --index \"\""
        );
        assert!(
            !POST_COMMIT_SCRIPT.contains("--index ''"),
            "post-commit must not use --index ''"
        );
    }

    #[test]
    fn post_merge_script_does_not_use_empty_index_flag() {
        // Why: same as above for the merge hook.
        assert!(
            !POST_MERGE_SCRIPT.contains("--index \"\""),
            "post-merge must not use --index \"\""
        );
        assert!(
            !POST_MERGE_SCRIPT.contains("--index ''"),
            "post-merge must not use --index ''"
        );
    }

    // ── Issue 2: ORIG_HEAD robustness ─────────────────────────────────────────

    #[test]
    fn post_merge_script_guards_orig_head_existence() {
        // Why: on fast-forward pulls ORIG_HEAD may not exist. The hook must
        // guard for its existence and fall back to HEAD@{1} or skip cleanly.
        assert!(
            POST_MERGE_SCRIPT.contains("rev-parse --verify"),
            "post-merge must verify ORIG_HEAD before using it"
        );
        assert!(
            POST_MERGE_SCRIPT.contains("HEAD@{1}"),
            "post-merge must have a HEAD@{{1}} fallback"
        );
    }

    // ── Filesystem operations ─────────────────────────────────────────────────

    fn fake_hooks_dir() -> (TempDir, PathBuf) {
        let tmp = tempfile::tempdir().unwrap();
        let hooks = tmp.path().join(".git").join("hooks");
        fs::create_dir_all(&hooks).unwrap();
        (tmp, hooks)
    }

    #[test]
    fn install_creates_hook_file() {
        // Why: a freshly created hook file must exist after install.
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        assert!(path.exists(), "hook file was not created");
    }

    #[cfg(unix)]
    #[test]
    fn install_creates_hook_file_and_is_executable() {
        // Why: a freshly created hook file must be chmod +x so git invokes it.
        use std::os::unix::fs::PermissionsExt;
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        let mode = fs::metadata(&path).unwrap().permissions().mode();
        assert_ne!(mode & 0o111, 0, "hook is not executable");
    }

    #[test]
    fn install_is_idempotent() {
        // Why: running `hook install` twice must not duplicate the block.
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(content.matches(MARKER_BEGIN).count(), 1, "block duplicated");
    }

    #[test]
    fn install_preserves_existing_shebang() {
        // Why: if another tool already wrote a shebang, we must not clobber it.
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        fs::write(&path, "#!/bin/sh\necho existing\n").unwrap();
        #[cfg(unix)]
        make_executable(&path).unwrap();
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        let content = fs::read_to_string(&path).unwrap();
        assert!(content.starts_with("#!/bin/sh"), "shebang moved");
        assert!(content.contains("echo existing"), "prior content gone");
        assert!(
            content.contains(MARKER_BEGIN),
            "trusty-search block missing"
        );
    }

    #[test]
    fn uninstall_removes_block() {
        // Why: after uninstall the hook file must not contain the marker block.
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        uninstall_block(&path).unwrap();
        if path.exists() {
            let content = fs::read_to_string(&path).unwrap();
            assert!(
                !content.contains(MARKER_BEGIN),
                "marker still present after uninstall"
            );
        }
        // path may not exist (only block → file removed) — that is also correct.
    }

    #[test]
    fn uninstall_preserves_other_content() {
        // Why: `hook uninstall` must not destroy another tool's hook logic.
        let (_tmp, hooks) = fake_hooks_dir();
        let path = hooks.join("post-commit");
        fs::write(&path, "#!/bin/sh\necho other-tool\n").unwrap();
        #[cfg(unix)]
        make_executable(&path).unwrap();
        install_block(&path, POST_COMMIT_SCRIPT).unwrap();
        uninstall_block(&path).unwrap();
        let content = fs::read_to_string(&path).unwrap();
        assert!(
            content.contains("echo other-tool"),
            "other tool's content gone"
        );
        assert!(
            !content.contains(MARKER_BEGIN),
            "trusty-search block still present"
        );
    }

    #[test]
    fn uninstall_noop_when_file_absent() {
        // Why: uninstall on a path that was never created must not error.
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("nonexistent-hook");
        uninstall_block(&path).unwrap(); // must not panic or error
    }

    // ── Full install / uninstall via handle_hook ─────────────────────────────

    fn fake_git_repo() -> TempDir {
        let tmp = tempfile::tempdir().unwrap();
        let hooks_dir = tmp.path().join(".git").join("hooks");
        fs::create_dir_all(&hooks_dir).unwrap();
        tmp
    }

    #[tokio::test]
    async fn handle_hook_install_writes_all_three_hooks() {
        // Why: all three hook scripts must be present after install.
        let repo = fake_git_repo();
        let args = HookArgs {
            repo: Some(repo.path().to_path_buf()),
            action: HookAction::Install,
        };
        handle_hook(args).await.unwrap();
        for name in ["post-commit", "post-merge", "post-checkout"] {
            let p = repo.path().join(".git").join("hooks").join(name);
            assert!(p.exists(), "{name} was not created");
            let content = fs::read_to_string(&p).unwrap();
            assert!(content.contains(MARKER_BEGIN), "{name} missing marker");
        }
    }

    #[tokio::test]
    async fn handle_hook_uninstall_removes_all_three_hooks() {
        // Why: uninstall must clean up every hook, not just post-commit.
        let repo = fake_git_repo();
        let install_args = HookArgs {
            repo: Some(repo.path().to_path_buf()),
            action: HookAction::Install,
        };
        handle_hook(install_args).await.unwrap();
        let uninstall_args = HookArgs {
            repo: Some(repo.path().to_path_buf()),
            action: HookAction::Uninstall,
        };
        handle_hook(uninstall_args).await.unwrap();
        for name in ["post-commit", "post-merge", "post-checkout"] {
            let p = repo.path().join(".git").join("hooks").join(name);
            if p.exists() {
                let content = fs::read_to_string(&p).unwrap();
                assert!(
                    !content.contains(MARKER_BEGIN),
                    "{name} still has trusty-search block"
                );
            }
        }
    }

    #[tokio::test]
    async fn handle_hook_install_errors_outside_git_repo() {
        // Why: we must fail with a clear error rather than silently creating
        // a .git/hooks directory that will never be used.
        let tmp = tempfile::tempdir().unwrap();
        // No .git directory under tmp.
        let args = HookArgs {
            repo: Some(tmp.path().to_path_buf()),
            action: HookAction::Install,
        };
        let result = handle_hook(args).await;
        assert!(result.is_err(), "expected error outside a git repo");
    }
}