vipune 0.10.0

A minimal memory layer for AI agents
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
//! Tests for project detection.
//!
//! All tests use [`detect_project_at_test`] — a hermetic wrapper that injects
//! an empty `env_project` sentinel into [`detect_project_at_internal`] — so the
//! real `VIPUNE_PROJECT` env var is never consulted. This prevents test failures
//! when developers run the suite with that variable set in their shell.
//! No process-global state is mutated (`set_current_dir`, `set_var`).

use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

use super::*;
use tempfile::TempDir;

/// Hermetic test wrapper that never reads the real VIPUNE_PROJECT env var.
///
/// Passes an empty string as `env_project` so the control flow enters the
/// `if let Some(project) = env_project` branch in `detect_project_at_internal`,
/// finds the value empty after trimming, and falls through to git detection.
///
/// This is the canonical way to call project detection from tests — production
/// behaviour of `detect_project()` and `detect_project_at()` is unchanged.
fn detect_project_at_test(root: &Path, explicit: Option<&str>) -> String {
    detect_project_at_internal(root, explicit, Some(String::new()))
}

// ── Git fixture harness ──────────────────────────────────────────────────────

/// Create a bare git repo in a temp directory.
///
/// Configures local user.name/user.email and uses an explicit initial branch
/// so it works on any machine and in CI regardless of global git config.
fn create_git_repo() -> TempDir {
    let dir = TempDir::new().expect("create temp dir for git repo");
    init_git_repo(dir.path());
    dir
}

/// Initialize a git repo at the given path.
fn init_git_repo(path: &Path) {
    Command::new("git")
        .args(["-C", path.to_str().unwrap(), "init", "-b", "main"])
        .env("GIT_TERMINAL_PROMPT", "0")
        .output()
        .expect("git init failed");

    Command::new("git")
        .args([
            "-C",
            path.to_str().unwrap(),
            "config",
            "user.name",
            "Test User",
        ])
        .output()
        .expect("git config user.name failed");

    Command::new("git")
        .args([
            "-C",
            path.to_str().unwrap(),
            "config",
            "user.email",
            "test@example.com",
        ])
        .output()
        .expect("git config user.email failed");
}

/// Add a git remote to the repo at the given path.
fn add_remote(path: &Path, name: &str, url: &str) {
    Command::new("git")
        .args(["-C", path.to_str().unwrap(), "remote", "add", name, url])
        .output()
        .expect("git remote add failed");
}

/// Create a nested subdirectory inside a git repo and return its path.
fn create_subdirectory(repo_path: &Path) -> PathBuf {
    let sub = repo_path.join("src").join("deep");
    std::fs::create_dir_all(&sub).expect("create subdirectory");
    sub
}

// ── parse_git_remote golden table ─────────────────────────────────────────────

#[test]
fn test_parse_ssh_remote() {
    assert_eq!(
        parse_git_remote("git@github.com:owner/repo.git"),
        "owner/repo"
    );
    assert_eq!(parse_git_remote("git@github.com:owner/repo"), "owner/repo");
}

#[test]
fn test_parse_https_remote() {
    assert_eq!(
        parse_git_remote("https://github.com/owner/repo.git"),
        "owner/repo"
    );
    assert_eq!(
        parse_git_remote("https://github.com/owner/repo"),
        "owner/repo"
    );
}

#[test]
fn test_parse_ssh_url_with_protocol() {
    assert_eq!(
        parse_git_remote("ssh://git@github.com/owner/repo.git"),
        "owner/repo"
    );
}

#[test]
fn test_git_suffix_stripping() {
    assert_eq!(parse_git_remote("owner/repo.git"), "owner/repo");
}

#[test]
fn test_fallback_when_no_domain() {
    assert_eq!(parse_git_remote("just-name"), "just-name");
}

#[test]
fn test_parse_mixed_case_host_and_owner() {
    // Case is preserved — Owner/Repo and owner/repo are distinct ids.
    assert_eq!(
        parse_git_remote("git@GitHub.com:Owner/Repo.git"),
        "Owner/Repo"
    );
    assert_eq!(
        parse_git_remote("https://GitHub.com/Owner/Repo.git"),
        "Owner/Repo"
    );
}

// ── explicit override tests ──────────────────────────────────────────────────

#[test]
fn test_explicit_override() {
    let dir = TempDir::new().expect("temp dir");
    assert_eq!(
        detect_project_at(dir.path(), Some("my-project")),
        "my-project"
    );
}

#[test]
fn test_explicit_override_empty() {
    // Empty explicit string should fall through to automatic detection.
    // Using a temp dir with no git repo, the fallback is the dir name.
    let dir = create_git_repo();
    let result = detect_project_at_test(dir.path(), Some(""));
    // Falls through to git root dir name since there's no origin.
    assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}

#[test]
fn test_explicit_override_whitespace() {
    // Whitespace-only explicit string should fall through to automatic detection.
    let dir = create_git_repo();
    let result = detect_project_at_test(dir.path(), Some("   \t  "));
    // Falls through to git root dir name since there's no origin.
    assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}

// ── env var tests ─────────────────────────────────────────────────────────────

#[test]
fn test_env_var_whitespace() {
    // Whitespace-only VIPUNE_PROJECT should fall through to git detection.
    // Inject the value as a parameter rather than mutating process-global env.
    let dir = create_git_repo();
    let result = detect_project_at_internal(dir.path(), None, Some("   ".to_string()));
    // Falls through to git root dir name since there's no origin.
    assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}

#[test]
fn test_env_var_override_with_no_git() {
    // VIPUNE_PROJECT set with no git repo — should use env var value.
    let dir = TempDir::new().expect("temp dir");
    let result = detect_project_at_internal(dir.path(), None, Some("env-project".to_string()));
    assert_eq!(result, "env-project");
}

#[test]
fn test_env_var_trimmed() {
    // VIPUNE_PROJECT with leading/trailing whitespace is trimmed.
    let dir = TempDir::new().expect("temp dir");
    let result = detect_project_at_internal(dir.path(), None, Some("  trimmed  ".to_string()));
    assert_eq!(result, "trimmed");
}

// ── detection with git remotes ───────────────────────────────────────────────

#[test]
fn test_detect_https_remote() {
    let dir = create_git_repo();
    add_remote(
        dir.path(),
        "origin",
        "https://github.com/randomm/vipune.git",
    );
    assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}

#[test]
fn test_detect_ssh_remote() {
    let dir = create_git_repo();
    add_remote(dir.path(), "origin", "git@github.com:randomm/vipune.git");
    assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}

#[test]
fn test_detect_ssh_url_with_protocol() {
    let dir = create_git_repo();
    add_remote(
        dir.path(),
        "origin",
        "ssh://git@github.com/randomm/vipune.git",
    );
    assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}

#[test]
fn test_detect_remote_without_git_suffix() {
    let dir = create_git_repo();
    add_remote(dir.path(), "origin", "https://github.com/randomm/vipune");
    assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}

#[test]
fn test_detect_only_upstream_remote_uses_dir_name() {
    // When only an 'upstream' remote exists (no 'origin'), should NOT adopt
    // the upstream remote — fall back to directory name.
    let dir = create_git_repo();
    add_remote(
        dir.path(),
        "upstream",
        "https://github.com/canonical/project.git",
    );
    let result = detect_project_at_test(dir.path(), None);
    assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
    // Ensure we did NOT pick up the upstream remote.
    assert_ne!(result, "canonical/project");
}

#[test]
fn test_detect_no_git_repo() {
    // No git repo at all — fallback to directory name.
    let dir = TempDir::new().expect("temp dir");
    let result = detect_project_at_test(dir.path(), None);
    // Falls back to dir name (or "unknown" if dir has no file_name).
    assert!(!result.is_empty());
}

// ── determinism: root vs subdirectory ────────────────────────────────────────

#[test]
fn test_detect_same_id_from_root_and_subdirectory() {
    let dir = create_git_repo();
    add_remote(dir.path(), "origin", "https://github.com/owner/repo.git");

    let sub = create_subdirectory(dir.path());

    // Both root and subdirectory must yield the same project_id.
    let from_root = detect_project_at_test(dir.path(), None);
    let from_sub = detect_project_at_test(&sub, None);

    assert_eq!(from_root, "owner/repo");
    assert_eq!(from_sub, "owner/repo");
    assert_eq!(from_root, from_sub);
}

#[test]
fn test_detect_fallback_same_from_root_and_subdirectory() {
    // No remotes: both root and subdirectory yield the git root dir name.
    let dir = create_git_repo();
    let sub = create_subdirectory(dir.path());

    let from_root = detect_project_at_test(dir.path(), None);
    let from_sub = detect_project_at_test(&sub, None);

    let expected = dir.path().file_name().unwrap().to_str().unwrap();
    assert_eq!(from_root, expected);
    assert_eq!(from_sub, expected);
}

// ── fallback warning tests ───────────────────────────────────────────────────

#[test]
fn test_fallback_no_remotes_yields_dir_name() {
    let dir = create_git_repo();
    let result = detect_project_at_test(dir.path(), None);
    assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}

#[test]
fn test_fallback_warning_message_includes_project_id() {
    let dir = create_git_repo();
    let msg = build_fallback_warning_message(dir.path().file_name().unwrap().to_str().unwrap());
    assert!(msg.contains("using directory name as project_id"));
    assert!(msg.contains(dir.path().file_name().unwrap().to_str().unwrap()));
}

#[test]
fn test_fallback_warning_message_no_other_remotes() {
    let dir = create_git_repo();
    let msg = build_fallback_warning_message(dir.path().file_name().unwrap().to_str().unwrap());
    // The other-remotes lookup was removed (issue #163 finding 2): the warning
    // must not spawn a third git subprocess on the degraded path, so it never
    // mentions remotes regardless of what remotes exist.
    assert!(!msg.contains("other remotes"));
    assert!(msg.contains("This project_id may differ"));
}

// ── integration: remote-derived ids unchanged ────────────────────────────────

#[test]
fn test_remote_derived_ids_unchanged() {
    // Verify that repos resolving via remote produce the same ids as before.
    // This is the key invariant: the fix must not change existing project_ids.
    let cases = [
        ("https://github.com/randomm/vipune.git", "randomm/vipune"),
        ("https://github.com/randomm/vipune", "randomm/vipune"),
        ("git@github.com:randomm/vipune.git", "randomm/vipune"),
        ("git@github.com:randomm/vipune", "randomm/vipune"),
        ("ssh://git@github.com/randomm/vipune.git", "randomm/vipune"),
        // #164 (fixed): these two entries are the SAME repo referenced via
        // HTTPS and SSH. Previously `parse_git_remote` produced DIFFERENT
        // project_ids for the two forms (`subgroup/project` vs
        // `group/subgroup/project`). The fix applies the last-two-segment
        // rule uniformly to the SSH-shorthand branch as well, so both forms
        // now resolve to `subgroup/project` — one repo, one id.
        (
            "https://gitlab.example.com/group/subgroup/project.git",
            "subgroup/project",
        ),
        (
            "git@gitlab.example.com:group/subgroup/project.git",
            "subgroup/project",
        ),
    ];

    for (remote_url, expected_id) in cases {
        let dir = create_git_repo();
        add_remote(dir.path(), "origin", remote_url);
        let result = detect_project_at_test(dir.path(), None);
        assert_eq!(
            result, expected_id,
            "remote '{}' should produce project_id '{}'",
            remote_url, expected_id
        );
    }
}

// ── detect_project backward compatibility ────────────────────────────────────

#[test]
fn test_detect_project_delegates_to_current_dir() {
    // detect_project(None) must return a non-empty string when called from
    // the current directory (the vipune repo itself has a git remote).
    let project = detect_project(None);
    assert!(!project.is_empty());
}

#[test]
fn test_detect_project_explicit_override() {
    assert_eq!(detect_project(Some("custom-id")), "custom-id");
}

// ── run_git: timeout, typed errors, stdout capture ───────────────────────────

/// A stub `git` that sleeps on any invocation — stands in for a wedged real
/// git process (frozen network fs, hung credential helper) without needing an
/// actual hung git (issue #163 acceptance: "without relying on a real hung
/// git process"). Writes a sleep script at `dir/git` and marks it executable.
fn make_git_sleep_stub(dir: &Path) {
    let stub = dir.join("git");
    // Use /bin/sleep so the stub does not depend on `sleep` being on the CI
    // runner's PATH (Linux CI pools have shipped minimal sh where a bare
    // `sleep` resolves to exit 127). `/bin/sleep` exists on macOS and Linux.
    std::fs::write(&stub, "#!/bin/sh\n/bin/sleep 5\n").expect("write sleep stub");
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(&stub).expect("stat stub").permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(&stub, perms).expect("chmod stub");
}

/// Like `run_git` but with an injectable PATH dir and timeout so tests can
/// use a stub `git` and a short deadline without touching process env.
fn run_git_in_env(
    root: &Path,
    args: &[&str],
    timeout: Duration,
    path_dir: Option<&Path>,
) -> Result<String, GitError> {
    let root_str = root
        .to_str()
        .map(|s| s.to_string())
        .ok_or_else(|| GitError::Spawn(format!("non-UTF-8 path: {:?}", root)))?;

    let mut cmd = Command::new("git");
    cmd.arg("-C").arg(&root_str);
    cmd.args(args);
    cmd.env("GIT_TERMINAL_PROMPT", "0");
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());
    if let Some(dir) = path_dir {
        cmd.env("PATH", dir);
    }

    let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;

    let (tx, rx) = mpsc::channel();
    {
        let stdout = child.stdout.take().expect("stdout piped");
        let stderr = child.stderr.take().expect("stderr piped");
        thread::spawn(move || {
            let stdout_buf = read_pipe(stdout);
            let stderr_buf = read_pipe(stderr);
            let _ = tx.send((stdout_buf, stderr_buf));
        });
    }

    match rx.recv_timeout(timeout) {
        Ok((stdout_buf, stderr_buf)) => match child.wait() {
            Ok(status) => {
                if status.success() {
                    Ok(String::from_utf8_lossy(&stdout_buf).trim().to_string())
                } else {
                    Err(GitError::NonZeroExit {
                        code: status.code(),
                        stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
                    })
                }
            }
            Err(e) => Err(GitError::Spawn(format!("wait failure: {e}"))),
        },
        Err(_) => {
            let _ = child.kill();
            let _ = child.wait();
            Err(GitError::Timeout(timeout))
        }
    }
}

#[test]
fn test_run_git_timeout_kills_stub() {
    // A stub `git` that sleeps 5 s stands in for a wedged real git. With a
    // 100 ms deadline the kill path must fire and return the typed Timeout
    // error — no real hung git process needed (issue #163 acceptance).
    let stub_dir = TempDir::new().expect("stub bin dir");
    make_git_sleep_stub(stub_dir.path());

    let result = run_git_in_env(
        Path::new("/"),
        &["--version"],
        Duration::from_millis(100),
        Some(stub_dir.path()),
    );
    match result {
        Err(GitError::Timeout(d)) => {
            // The timeout must be the requested 100 ms, not the stub's 5 s.
            assert!(d.as_millis() <= 100);
        }
        other => panic!("expected Err(GitError::Timeout), got {:?}", other),
    }
}

#[test]
fn test_run_git_captures_stdout_on_success() {
    // `git -C <repo> rev-parse --show-toplevel` exits 0 with the repo root on
    // stdout. The captured value must match, proving stdout piping works
    // end-to-end through run_git (not just the empty case).
    let dir = create_git_repo();
    let out = run_git(dir.path(), &["rev-parse", "--show-toplevel"], GIT_TIMEOUT);
    let root = out.expect("expected Ok");
    // git prints the canonical (symlinks-resolved) path; resolve the temp dir
    // the same way so the comparison holds on macOS where /tmp is a symlink.
    let canonical = std::fs::canonicalize(dir.path()).expect("canonicalize temp dir");
    let expected = canonical.to_str().expect("path is valid UTF-8");
    assert_eq!(root, expected);
}

#[test]
fn test_run_git_nonzero_exit_is_typed() {
    // `git remote get-url origin` in a repo with no origin remote exits non-zero.
    // The error must carry the failure cause, not collapse to None.
    let dir = create_git_repo();
    let result = run_git(dir.path(), &["remote", "get-url", "origin"], GIT_TIMEOUT);
    match result {
        Err(GitError::NonZeroExit { code, stderr }) => {
            // git exits 2 (not 1) when the remote does not exist; the point of
            // the test is that the non-zero exit is typed and diagnosable.
            assert!(
                code == Some(1) || code == Some(2),
                "expected non-zero exit, got {code:?}"
            );
            assert!(
                !stderr.is_empty(),
                "stderr should be captured for diagnostics"
            );
        }
        other => panic!("expected Err(GitError::NonZeroExit), got {:?}", other),
    }
}

#[test]
fn test_run_git_spawn_failure_is_typed() {
    // A PATH that contains no `git` makes spawn fail; the error must name the
    // cause (issue #163 finding 3) rather than collapse to None.
    let empty_bin = TempDir::new().expect("empty bin dir");
    let result = run_git_in_env(
        Path::new("/"),
        &["--version"],
        GIT_TIMEOUT,
        Some(empty_bin.path()),
    );
    match result {
        Err(GitError::Spawn(msg)) => {
            assert!(!msg.is_empty(), "spawn error should carry a reason");
        }
        other => panic!("expected Err(GitError::Spawn), got {:?}", other),
    }
}