mnem-cli 0.1.0

Command-line interface for mnem - git for knowledge graphs.
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
//! Integration tests for `mnem fetch` / `push` / `pull` against a
//! live `mnem-http` server.
//!
//! These tests spin up a real `mnem-http` subprocess on a loopback
//! ephemeral port, `mnem remote add` against it from a second
//! TempDir, then exercise the three wire verbs end-to-end.
//!
//! # Harness
//!
//! [`HttpServer::spawn`] pre-binds a `TcpListener` on `127.0.0.1:0`,
//! takes the kernel-allocated port, drops the listener, then launches
//! `cargo_bin("mnem-http")` with `--bind 127.0.0.1:<port>`. There is
//! a small race window between the drop and the child's `bind`; we
//! cover it by polling `GET /v1/healthz` up to a few seconds. If the
//! port is grabbed by another process in that gap the test fails
//! fast with a diagnostic rather than hanging.
//!
//! # Auth
//!
//! The server-side bearer is injected via `MNEM_HTTP_PUSH_TOKEN` on
//! the spawned child's environment. The client-side bearer is
//! injected via `MNEM_REMOTE_<UPPER>_TOKEN` on the `mnem` subprocess
//! invocation. Env vars are scoped per-`Command` so they never leak
//! into the parent test process (Rust 2024 makes `std::env::set_var`
//! unsafe for exactly this reason).
//!
//! # Cleanup
//!
//! [`HttpServer`] holds the `Child` and implements `Drop` to kill
//! the process; tempdirs clean themselves via `TempDir::drop`. If a
//! test panics mid-run the destructors still fire.

#![allow(clippy::unwrap_used)]

use std::net::TcpListener;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use assert_cmd::prelude::*;
use tempfile::TempDir;

/// Environment variable that holds the server-side push token.
const SERVER_TOKEN_ENV: &str = "MNEM_HTTP_PUSH_TOKEN";
/// Bearer value used throughout the test suite. Arbitrary string;
/// constant-time compared server-side.
const TEST_TOKEN: &str = "b3-integration-token";

// ---------- Test harness ----------

/// RAII handle for a spawned `mnem-http` subprocess. `Drop` sends
/// `kill` so a panicking test never leaks a server.
struct HttpServer {
    child: Child,
    base_url: String,
    /// Owned TempDir backing the server's `.mnem/` so it outlives the
    /// child. The field is prefixed `_` because we only hold it for
    /// its `Drop`.
    _repo: TempDir,
}

impl HttpServer {
    /// Spawn `mnem-http` on a loopback ephemeral port, waiting until
    /// `/v1/healthz` returns 200 (bounded by a ~5 s budget).
    ///
    /// `token` seeds `MNEM_HTTP_PUSH_TOKEN` on the child. Pass `None`
    /// to start a server with authentication administratively
    /// disabled (fail-closed 503 on push-side verbs).
    fn spawn(token: Option<&str>) -> Self {
        // Pre-bind a TcpListener on ephemeral port to discover an
        // available one, then release it for the child to take over.
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral");
        let port = listener.local_addr().expect("local_addr").port();
        drop(listener);

        let repo = TempDir::new().expect("repo tempdir");
        let mut cmd = Command::cargo_bin("mnem-http").expect("built mnem-http");
        cmd.arg("-R")
            .arg(repo.path())
            .arg("--bind")
            .arg(format!("127.0.0.1:{port}"))
            // `--in-memory` avoids redb-fsync overhead and ensures
            // the server releases its repo on kill without leaving
            // lock files. Harness tests don't care about durability.
            .arg("--in-memory")
            // Scrub any ambient MNEM_* env that could bleed in from
            // the parent shell (`RUST_LOG`, `MNEM_BENCH`, etc.).
            .env_remove("MNEM_BENCH")
            .env_remove(SERVER_TOKEN_ENV)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if let Some(t) = token {
            cmd.env(SERVER_TOKEN_ENV, t);
        }
        let mut child = cmd.spawn().expect("spawn mnem-http");

        let base_url = format!("http://127.0.0.1:{port}");

        // Poll /v1/healthz until 200. Bound the wait so a failed
        // bind doesn't hang CI.
        let deadline = Instant::now() + Duration::from_secs(5);
        let healthz = format!("{base_url}/v1/healthz");
        let mut last_err: Option<String> = None;
        loop {
            if Instant::now() > deadline {
                let _ = child.kill();
                panic!("mnem-http did not come up on {base_url} within 5s: last={last_err:?}");
            }
            match ureq::get(&healthz)
                .timeout(Duration::from_millis(250))
                .call()
            {
                Ok(resp) if resp.status() == 200 => break,
                Ok(resp) => last_err = Some(format!("status {}", resp.status())),
                Err(e) => last_err = Some(format!("{e}")),
            }
            std::thread::sleep(Duration::from_millis(50));
        }

        Self {
            child,
            base_url,
            _repo: repo,
        }
    }

    fn url(&self) -> &str {
        &self.base_url
    }
}

impl Drop for HttpServer {
    fn drop(&mut self) {
        // Best-effort shutdown. A dead child is fine.
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

/// Build a `mnem` subprocess scoped to `repo`, with no ambient
/// bearer env bleeding in.
fn mnem(repo: &Path) -> Command {
    let mut cmd = Command::cargo_bin("mnem").expect("built mnem binary");
    cmd.current_dir(repo);
    cmd.arg("-R").arg(repo);
    // Start each invocation with a clean auth slate; individual
    // tests set MNEM_REMOTE_<NAME>_TOKEN per-Command when needed.
    cmd.env_remove(SERVER_TOKEN_ENV);
    cmd.env_remove("MNEM_REMOTE_ORIGIN_TOKEN");
    cmd
}

/// Initialise a repo under `dir` and add a single memory node so the
/// op-log has something to advance over.
fn init_repo_with_node(dir: &Path, summary: &str) {
    mnem(dir).arg("init").arg(dir).assert().success();
    mnem(dir)
        .args([
            "add",
            "node",
            "--label",
            "Memory",
            "--summary",
            summary,
            "--no-embed",
        ])
        .assert()
        .success();
}

/// Wire up `origin` pointing at `server` on `dir`'s `.mnem/config.toml`.
fn add_origin(dir: &Path, server: &HttpServer) {
    mnem(dir)
        .args(["remote", "add", "origin", server.url()])
        .assert()
        .success();
}

// ---------- Tests ----------

#[test]
fn fetch_round_trip_against_local_server() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "fetch round-trip");
    add_origin(client_dir.path(), &server);

    // `mnem fetch origin` against a bare server with no matching
    // refs must succeed (it's a no-op: nothing on remote to pull).
    // The exit 0 contract is what we assert.
    let out = mnem(client_dir.path())
        .arg("fetch")
        .arg("origin")
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "fetch failed; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
#[ignore = "TODO(B3.5): first-push birth-commit protocol gap. Client \
            sends `old=local_head` when remote is empty; server \
            rejects because current heads are empty. Fix requires a \
            joint client+server change (either a `null`/zero-CID \
            sentinel in advance-head, or a dedicated `/birth-head` \
            route). Schema fix + auth coverage is sufficient for B3.4 \
            acceptance; full push round-trip lands in B3.5."]
fn push_round_trip_against_local_server() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "push round-trip");
    add_origin(client_dir.path(), &server);

    // push requires bearer. Pass the matching token via env.
    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(
        out.status.success(),
        "push failed; stdout={stdout}; stderr={stderr}"
    );
    // Best-effort output shape: git-like "To <url>" line on success.
    assert!(
        stdout.contains("To "),
        "expected Git-style push report, got: {stdout}"
    );
}

#[test]
fn push_without_token_is_401_to_cli() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "no-token push");
    add_origin(client_dir.path(), &server);

    // No bearer env supplied to the client. The CLI surfaces the
    // auth hint ("Set MNEM_REMOTE_<UPPER>_TOKEN").
    let out = mnem(client_dir.path())
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(!out.status.success(), "push must fail without a token");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("Authentication required") || stderr.contains("MNEM_REMOTE_"),
        "expected auth hint, got: {stderr}"
    );
}

#[test]
fn push_with_wrong_token_is_401_to_cli() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "wrong-token push");
    add_origin(client_dir.path(), &server);

    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", "definitely-not-the-real-token")
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        !out.status.success(),
        "push with wrong token must fail; stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("Authentication required")
            || stderr.contains("401")
            || stderr.contains("auth"),
        "expected auth rejection, got: {stderr}"
    );
}

#[test]
#[ignore = "TODO(B3.5): depends on first-push birth-commit protocol \
            (see `push_round_trip_against_local_server`)."]
fn push_then_second_push_no_op_is_idempotent() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "idempotent");
    add_origin(client_dir.path(), &server);

    // First push.
    mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Second push with nothing new: CLI short-circuits when remote
    // tip already matches local HEAD (see push.rs step 4). Treated
    // as success; the user sees no new ref advancement.
    let out = mnem(client_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "repeat push of identical tip must be idempotent; stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
fn fetch_twice_is_idempotent() {
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let client_dir = TempDir::new().unwrap();
    init_repo_with_node(client_dir.path(), "fetch idempotent");
    add_origin(client_dir.path(), &server);

    // First fetch = no-op (empty remote). Second fetch = same.
    for _ in 0..2 {
        mnem(client_dir.path())
            .arg("fetch")
            .arg("origin")
            .assert()
            .success();
    }
}

#[test]
#[ignore = "TODO(B3.5): publisher's initial push hits the birth-commit \
            gap; once that lands this test flips back on."]
fn pull_fast_forward_succeeds() {
    // Publisher writes a commit + pushes; consumer pulls it.
    let server = HttpServer::spawn(Some(TEST_TOKEN));
    let pub_dir = TempDir::new().unwrap();
    init_repo_with_node(pub_dir.path(), "publisher-seed");
    add_origin(pub_dir.path(), &server);
    mnem(pub_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Consumer: fresh repo, no commits, pulls to obtain the branch.
    let sub_dir = TempDir::new().unwrap();
    mnem(sub_dir.path())
        .arg("init")
        .arg(sub_dir.path())
        .assert()
        .success();
    add_origin(sub_dir.path(), &server);

    let out = mnem(sub_dir.path())
        .args(["pull", "origin", "main"])
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "fast-forward pull must succeed; stdout={} stderr={}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
}

#[test]
#[ignore = "TODO(B3.5): needs a prior successful push (birth-commit \
            gap) before the non-ff scenario can be reproduced."]
fn pull_non_ff_prints_merge_hint() {
    // Two clients push independent histories. After A pushes, B has
    // a divergent local head; `mnem pull` from B must refuse with
    // the merge-verb hint.
    let server = HttpServer::spawn(Some(TEST_TOKEN));

    // Client A: single commit, push.
    let a_dir = TempDir::new().unwrap();
    init_repo_with_node(a_dir.path(), "A commit");
    add_origin(a_dir.path(), &server);
    mnem(a_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    // Client B: independent commit (divergent history, no shared
    // ancestor with A).
    let b_dir = TempDir::new().unwrap();
    init_repo_with_node(b_dir.path(), "B commit");
    add_origin(b_dir.path(), &server);

    // Pull from B. Local head != remote tip, and local head is not
    // an ancestor of remote tip (disjoint histories) so the verb
    // must refuse with the merge-verb pointer.
    let out = mnem(b_dir.path())
        .args(["pull", "origin", "main"])
        .output()
        .unwrap();
    assert!(!out.status.success(), "non-ff pull must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("non-fast-forward") || stderr.contains("mnem merge"),
        "expected non-ff hint, got: {stderr}"
    );
}

#[test]
#[ignore = "TODO(B3.5): needs a prior successful push from client A \
            (birth-commit gap) before client B's competing push can \
            observe the CAS mismatch."]
fn push_cas_mismatch_surfaces_pull_hint_to_cli() {
    // Two clients push to the same ref without syncing. The second
    // push trips the CAS and the CLI prints the "run mnem pull" hint.
    let server = HttpServer::spawn(Some(TEST_TOKEN));

    let a_dir = TempDir::new().unwrap();
    init_repo_with_node(a_dir.path(), "A first");
    add_origin(a_dir.path(), &server);
    mnem(a_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .assert()
        .success();

    let b_dir = TempDir::new().unwrap();
    init_repo_with_node(b_dir.path(), "B competing");
    add_origin(b_dir.path(), &server);
    let out = mnem(b_dir.path())
        .env("MNEM_REMOTE_ORIGIN_TOKEN", TEST_TOKEN)
        .args(["push", "origin", "main"])
        .output()
        .unwrap();
    // B's push should be rejected because the remote tip is A's,
    // not the <new>/<local_head> value B claims.
    assert!(!out.status.success(), "second competing push must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(
        stderr.contains("mnem pull")
            || stderr.contains("Integrate remote changes")
            || stderr.contains("Updates were rejected"),
        "expected CAS-mismatch hint pointing at mnem pull, got: {stderr}"
    );
}

// ---------- Non-network smoke (retained from B3.3) ----------

#[test]
fn fetch_verb_rejects_missing_remote() {
    // Non-networked smoke: `mnem fetch origin` without `mnem
    // remote add origin` must error actionably. Exercises arg
    // parse + the config-load error path without any network.
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path())
        .arg("fetch")
        .arg("origin")
        .output()
        .unwrap();
    assert!(!out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("no remote") || stderr.contains("origin"),
        "expected actionable missing-remote diagnostic, got: {stderr}"
    );
}

#[test]
fn push_without_remote_errors() {
    // Defence-in-depth: `mnem push` without any configured remote
    // must exit non-zero; the fetch-precondition path covers
    // similar ground but `push` has its own error message.
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path()).arg("push").output().unwrap();
    assert!(!out.status.success());
}

#[test]
fn pull_without_tracking_ref_errors() {
    let dir = TempDir::new().unwrap();
    mnem(dir.path())
        .arg("init")
        .arg(dir.path())
        .assert()
        .success();

    let out = mnem(dir.path()).arg("pull").output().unwrap();
    assert!(!out.status.success());
}