mnem-cli 0.1.7

Command-line interface for mnem - Git for AI Agent Knowledge.
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
//! `mnem clone <url> [<dir>]` - initialise a repo from an archive.
//!
//! Q2 scope (partial, ):
//!
//! - `file:///absolute/path/to/archive.car` - supported
//! - bare path to a `.car` file (convenience) - supported
//! - `https://...`, `mnem+ssh://...`, `mnem://...` - rejected with an
//!   actionable message pointing at PR 3 / Q2-of-PR-3
//!
//! Behaviour on a supported URL:
//!
//! 1. Validate `<dir>` - either missing (we create it) or existing
//!    and empty of `.mnem/`. Refuse to clone into a directory that
//!    already carries a mnem repo.
//! 2. Create `.mnem/` and open the redb blockstore.
//! 3. Stream the CAR through
//!    `mnem_transport::import` (which enforces the default size
//!    limit; malformed archives error out cleanly PR 1).
//! 4. Scan the imported blocks for the `_kind: "commit"` block with
//!    the largest `time` field - the clone's head. This is a simple
//!    heuristic; a follow-up can carry the head CID in the CAR's
//!    root list.
//! 5. Write `[remote.origin] = { url = "<url>" }` to
//!    `.mnem/config.toml`.
//! 6. Record an `origin/main` tracking ref on the View via
//!    `View::with_tracking_ref`. (Empty when no commit is found.)
//!
//! Tests assert both the happy path (round-trip: export CAR from A,
//! clone into B, `mnem log` shows same head) and the dirty-dir
//! refusal path.
//!
//! # Examples
//!
//! ```text
//! mnem clone file:///tmp/alice.car /tmp/alice-mirror
//! mnem clone ./alice.car ./alice-mirror
//! ```

use std::fs;
use std::io::BufReader;

use ipld_core::ipld::Ipld;
use mnem_core::HEADS_PREFIX;
use mnem_transport::remote::{RemoteConfigFile, RemoteSection, serialize_config};

use super::*;

#[derive(clap::Args, Debug)]
#[command(after_long_help = "\
Examples:
  mnem clone file:///tmp/alice.car /tmp/mirror
  mnem clone ./alice.car ./mirror       # bare path OK when it ends in .car
  mnem clone file:///tmp/alice.car      # clones into $PWD/alice (derived from url)
")]
pub(crate) struct Args {
    /// URL to clone from. Q2 supports `file://` and bare `.car` paths
    /// only. Remote schemes (`https://`, `mnem+ssh://`, ...) are
    /// deferred to PR 3.
    pub url: String,
    /// Target directory. Must not already contain `.mnem/`. Defaults
    /// to a directory derived from the URL stem.
    pub dir: Option<std::path::PathBuf>,
}

pub(crate) fn run(_override: Option<&Path>, args: Args) -> Result<()> {
    let local_path = parse_clone_source(&args.url)?;
    let target_dir = resolve_target_dir(&args.url, args.dir.as_deref())?;

    // Refuse to clone on top of an existing mnem repo.
    let data_dir = target_dir.join(repo::MNEM_DIR);
    if data_dir.exists() {
        bail!(
            "target directory already contains a mnem repository at {}; refusing to clone",
            data_dir.display()
        );
    }

    // Run the full clone body inside a closure so that any failure can
    // be caught uniformly and the partially-created `.mnem/` directory
    // cleaned up before returning the error to the caller (BUG-29).
    let result = (|| -> Result<()> {
        // Create the workspace + blockstore.
        fs::create_dir_all(&target_dir)
            .with_context(|| format!("creating {}", target_dir.display()))?;
        let (bs, ohs) = repo::create_or_open_stores(&data_dir)?;

        // Stream the CAR. `import` enforces the built-in size limit.
        let file = fs::File::open(&local_path)
            .with_context(|| format!("opening {}", local_path.display()))?;
        let mut r = BufReader::new(file);
        let stats = mnem_transport::import(&mut r, &*bs).with_context(|| {
            format!(
                "importing CAR from {}\n\
                 hint: see docs/RUNBOOK.md#5-car-import-rejected for the error-variant \
                 taxonomy (malformed CAR, CID mismatch, size cap, missing root, ...).",
                local_path.display()
            )
        })?;

        // Initialise the repo state. The import only wrote blocks; an
        // empty ReadonlyRepo still needs an op-head to be consistent.
        let r_repo = ReadonlyRepo::init(bs.clone(), ohs.clone())?;

        // Find the clone's head: the commit block with the greatest
        // `time`. Falls back to `None` if no commit survived (e.g. the
        // CAR carried only leaves).
        let head_commit = find_head_commit(&bs, &stats.roots)?;

        // Write `[remote.origin]` pointing at the URL.
        let mut section = RemoteSection::default();
        section.remote.insert(
            "origin".into(),
            RemoteConfigFile {
                url: args.url.clone(),
                capabilities: None,
                token_env: None,
            },
        );
        let config_text = serialize_config(&section).context("serialising remote section")?;
        fs::write(data_dir.join(config::CONFIG_FILE), config_text)
            .context("writing .mnem/config.toml")?;

        // Record an origin/main tracking ref if we found a head, then
        // advance the local branch (`refs/heads/main`) and `view.heads`
        // so the cloned data is immediately visible to `mnem retrieve`
        // and `mnem status` (fix for bug J6).
        if let Some(head_cid) = &head_commit {
            // Use the repo's author string (empty config gives the
            // default agent-id), then update via an Operation so `mnem
            // log` sees the clone.
            let cfg = config::load(&data_dir)?;
            let author = config::author_string(&cfg);

            // Step 1: remote tracking ref (informational only; does not
            // advance view.heads). For Q2 simplicity: store a normal
            // local ref named `refs/remotes/origin/main` so the plumbing
            // is uniform. SPEC reserves `refs/remotes/<name>/*` for
            // exactly this convention.
            let after_remote = r_repo.update_ref(
                "refs/remotes/origin/main",
                None,
                Some(RefTarget::normal(head_cid.clone())),
                &author,
            )?;

            // Step 2: create a local branch `refs/heads/main` pointing
            // at the cloned commit so `mnem branch list` and named-ref
            // resolution work identically to a freshly-committed repo.
            let after_local = after_remote.update_ref(
                &format!("{HEADS_PREFIX}main"),
                None,
                Some(RefTarget::normal(head_cid.clone())),
                &author,
            )?;

            // Step 3: advance view.heads so `head_commit()` is non-None
            // and the Prolly tree is reachable by `mnem retrieve`.
            // Without this step the blockstore has all the data but the
            // repo's working position stays at the genesis op (empty
            // heads), making every retrieval return 0 results.
            after_local.update_heads(head_cid.clone(), &author)?;
        }

        // If the clone source is a local mnem repo directory (not a CAR
        // file), copy the [embed] section from its config so `mnem retrieve`
        // works immediately in the clone without manual re-configuration.
        // When the source is a CAR archive we cannot recover the embedder
        // settings from the file itself, so we print an actionable note
        // instead.
        let embed_copied = try_copy_embed_config(&local_path, &data_dir);

        println!(
            "cloned {} blocks ({} bytes) from {} into {}",
            stats.blocks,
            stats.bytes,
            args.url,
            target_dir.display()
        );
        match &head_commit {
            Some(c) => println!("  origin/main -> {c}"),
            None => println!("  origin/main -> <no commit found in CAR>"),
        }
        if !embed_copied {
            println!(
                "note: embedder config was not copied. Run `mnem config set embed.provider <provider>` \
                 to configure embeddings, or copy [embed] from the source repo's .mnem/config.toml manually."
            );
        }
        Ok(())
    })();

    // BUG-29: on any failure after `.mnem/` was (or may have been)
    // created, remove it so the user can retry without a manual
    // `rm -rf .mnem/` step.  We only clean up the `.mnem/` data
    // directory, not the parent `target_dir`, because that directory
    // may have been supplied by the user and pre-existed the clone.
    if result.is_err() {
        eprintln!("clone failed; removing partial .mnem/ directory");
        let _ = fs::remove_dir_all(&data_dir);
    }

    result
}

/// Try to copy the `[embed]` section from a source repo's
/// `.mnem/config.toml` into the clone's config. Returns `true` if an
/// embed config was successfully copied, `false` otherwise (source is
/// a CAR file, source has no embed config, or the read fails).
///
/// When the source path is a directory we attempt to read
/// `<source>/.mnem/config.toml` directly. When it is a file (e.g. a
/// `.car` archive) there is no config to read, so we return `false`
/// without emitting any error - the caller is expected to print a
/// user-facing note instead.
///
/// The copy is done by reading the already-written clone config as a
/// raw `toml::Value`, injecting the `[embed]` table from the source,
/// and writing back. This preserves the `[remote.origin]` entry (and
/// any other sections) that were already stored.
fn try_copy_embed_config(source: &std::path::Path, dest_data_dir: &std::path::Path) -> bool {
    // A CAR file (or any regular file) has no adjacent `.mnem/` dir
    // we can reach. Only directories are valid source repos.
    if !source.is_dir() {
        return false;
    }
    let src_config_path = source.join(repo::MNEM_DIR).join(config::CONFIG_FILE);
    let Ok(src_text) = fs::read_to_string(&src_config_path) else {
        return false;
    };
    let Ok(src_cfg) = toml::from_str::<config::Config>(&src_text) else {
        return false;
    };
    let Some(embed) = src_cfg.embed else {
        return false;
    };

    // Serialize just the embed section by building a temporary Config
    // containing only the embed field, then extracting the `embed`
    // TOML table from the serialised output.
    let embed_only = config::Config {
        embed: Some(embed),
        ..Default::default()
    };
    let Ok(embed_toml) = toml::to_string_pretty(&embed_only) else {
        return false;
    };
    let Ok(embed_value) = toml::from_str::<toml::Value>(&embed_toml) else {
        return false;
    };
    let Some(embed_table) = embed_value.get("embed").cloned() else {
        return false;
    };

    // Read the clone's config.toml as a raw Value so we can inject the
    // embed section without disturbing [remote.origin] or any other
    // top-level tables that already exist.
    let dest_config_path = dest_data_dir.join(config::CONFIG_FILE);
    let dest_text = fs::read_to_string(&dest_config_path).unwrap_or_default();
    let Ok(mut dest_root) = toml::from_str::<toml::Value>(&dest_text) else {
        return false;
    };
    let Some(dest_table) = dest_root.as_table_mut() else {
        return false;
    };
    dest_table.insert("embed".into(), embed_table);

    let Ok(out) = toml::to_string_pretty(&dest_root) else {
        return false;
    };
    fs::write(&dest_config_path, out).is_ok()
}

/// Reject unsupported URL schemes with an actionable message, and
/// resolve the supported ones to a local filesystem path.
fn parse_clone_source(url: &str) -> Result<std::path::PathBuf> {
    // bare path convenience: treat any non-URL-looking path that ends
    // in `.car` as a file URL. This saves POSIX users the triple-slash
    // escape dance.
    let has_scheme = url.contains("://");
    if !has_scheme {
        // audit-2026-04-25 P1-5: rewrite git-bash-style `/c/...` to
        // `c:/...` on Windows so MSYS2 / Git Bash users do not hit a
        // "system cannot find the path" error.
        let normalized = super::normalize_cli_path(url);
        let p = std::path::PathBuf::from(&normalized);
        if !p.extension().is_some_and(|e| e.eq_ignore_ascii_case("car")) {
            bail!(
                "`{url}` does not look like a URL or a `.car` path. \
                 Pass file:///abs/path/archive.car or a bare *.car path."
            );
        }
        return Ok(p);
    }
    // URL path. Only file:// is shipped in Q2.
    if let Some(rest) = url.strip_prefix("file://") {
        // `file:///abs/path` -> rest begins with `/` on POSIX. On
        // Windows, `file:///C:/path` -> rest = `/C:/path`; trim the
        // leading slash so the PathBuf is drive-letter addressable.
        let trimmed = if rest.starts_with('/') && rest.len() >= 3 && rest.as_bytes()[2] == b':' {
            &rest[1..]
        } else {
            rest
        };
        // audit-2026-04-25 C3-4 (Cycle-3): mirror the P1-5 fix from
        // `mnem export`: rewrite git-bash-style `/c/...` paths to
        // `c:/...` so `file:///c/tmp/repo.car` (lowercase, no colon)
        // works on Windows the same way `file:///C:/tmp/repo.car`
        // does. Without this, MSYS2 / Git Bash users hit a "system
        // cannot find the path" error after the URL strip.
        let normalized = super::normalize_cli_path(trimmed);
        return Ok(std::path::PathBuf::from(normalized));
    }
    // Any other scheme -> deferred.
    let scheme = url.split("://").next().unwrap_or("<unknown>");
    bail!(
        "clone over the `{scheme}` scheme is not yet implemented. \
         mnem 0.3 ships `file://` clone only; remote schemes land in PR 3 \
         (Q2-of-PR-3). See docs/ROADMAP.md and ."
    );
}

/// Resolve the target directory:
/// - explicit `<dir>` wins
/// - else derive from the URL: `.../alice.car` -> `./alice`
fn resolve_target_dir(url: &str, explicit: Option<&Path>) -> Result<std::path::PathBuf> {
    if let Some(d) = explicit {
        return Ok(d.to_path_buf());
    }
    // Strip scheme, take the final path component sans `.car`.
    let tail = url.rsplit('/').next().unwrap_or(url);
    let stem = tail.trim_end_matches(".car");
    if stem.is_empty() {
        bail!("could not derive a target dir from `{url}`; pass <dir> explicitly");
    }
    let cwd = std::env::current_dir().context("cwd unreadable")?;
    Ok(cwd.join(stem))
}

/// Determine the HEAD commit CID from the CAR's designated roots list.
///
/// The CAR header's `roots[0]` is the authoritative HEAD pointer: the
/// exporter always writes the HEAD commit CID as the single root (see
/// `mnem_transport::export`). We read `roots[0]` directly, verify it
/// decodes as a commit block, and return it.
///
/// Fallback (multi-root or non-commit root): if `roots[0]` does not
/// decode as a `_kind: "commit"` block (e.g. a future multi-root CAR
/// or a CAR carrying only leaf data), we apply the improved heuristic:
/// find all commit blocks that are never referenced as a `parent` by
/// another commit (i.e. true tips), then pick the one with the largest
/// `time`. This is strictly better than the old approach because it
/// ignores commits buried in the middle of a chain.
///
/// Returns `None` only if no commit block was found in the store at all.
fn find_head_commit(
    bs: &std::sync::Arc<dyn mnem_core::store::Blockstore>,
    roots: &[mnem_core::id::Cid],
) -> Result<Option<mnem_core::id::Cid>> {
    // --- Primary path: use roots[0] as the authoritative HEAD. ---
    if let Some(root_cid) = roots.first() {
        let Some(bytes) = bs.get(root_cid)? else {
            // roots[0] block missing - fall through to heuristic.
            return find_head_commit_heuristic(bs, roots);
        };
        if let Ok(Ipld::Map(m)) = from_canonical_bytes::<Ipld>(&bytes) {
            if matches!(m.get("_kind"), Some(Ipld::String(k)) if k == "commit") {
                // roots[0] is a commit: this is the designated HEAD.
                return Ok(Some(root_cid.clone()));
            }
        }
        // roots[0] is not a commit block (e.g. leaf-only CAR); fall through.
    }

    // --- Fallback: improved tip-finding heuristic. ---
    find_head_commit_heuristic(bs, roots)
}

/// Heuristic HEAD selection used when the CAR header root is absent or
/// not a commit. Scans `roots` for commit blocks that are not
/// referenced as `parent` by any other commit (true tips) and returns
/// the one with the greatest `time`. Falls back to `None` when no
/// commit block is present at all.
fn find_head_commit_heuristic(
    bs: &std::sync::Arc<dyn mnem_core::store::Blockstore>,
    roots: &[mnem_core::id::Cid],
) -> Result<Option<mnem_core::id::Cid>> {
    use std::collections::HashSet;

    // Collect all commit blocks reachable from the provided roots,
    // along with their `time` and `parent` CIDs.
    let mut commits: Vec<(mnem_core::id::Cid, u64)> = Vec::new();
    let mut referenced_as_parent: HashSet<mnem_core::id::Cid> = HashSet::new();

    for root_cid in roots {
        let Some(bytes) = bs.get(root_cid)? else {
            continue;
        };
        let Ok(Ipld::Map(m)) = from_canonical_bytes::<Ipld>(&bytes) else {
            continue;
        };
        let Some(Ipld::String(kind)) = m.get("_kind") else {
            continue;
        };
        if kind != "commit" {
            continue;
        }
        let time = match m.get("time") {
            Some(Ipld::Integer(n)) => u64::try_from(*n).unwrap_or(0),
            _ => 0,
        };
        commits.push((root_cid.clone(), time));
        // Track any parent CIDs so we can exclude non-tip commits.
        if let Some(Ipld::Link(parent_cid)) = m.get("parent") {
            if let Ok(p) = mnem_core::id::Cid::from_bytes(&parent_cid.to_bytes()) {
                referenced_as_parent.insert(p);
            }
        }
    }

    if commits.is_empty() {
        return Ok(None);
    }

    // Prefer tip commits (not referenced as a parent by anyone else).
    // Among those (or among all commits if none qualify), pick the
    // greatest `time`; break ties by CID byte string for determinism.
    let best = {
        let tips: Vec<&(mnem_core::id::Cid, u64)> = commits
            .iter()
            .filter(|(cid, _)| !referenced_as_parent.contains(cid))
            .collect();
        let candidates: &[&(mnem_core::id::Cid, u64)] = if tips.is_empty() {
            // No true tips found; use all commits as candidates.
            &commits.iter().collect::<Vec<_>>()
        } else {
            &tips
        };
        candidates
            .iter()
            .max_by(|(a_cid, a_time), (b_cid, b_time)| {
                a_time
                    .cmp(b_time)
                    .then_with(|| a_cid.to_bytes().cmp(&b_cid.to_bytes()))
            })
            .map(|(cid, _)| (*cid).clone())
    };

    Ok(best)
}

#[cfg(test)]
mod find_head_commit_tests {
    use std::collections::BTreeMap;
    use std::sync::Arc;

    use ipld_core::ipld::Ipld;
    use mnem_core::codec::{hash_to_cid, to_canonical_bytes};
    use mnem_core::store::{Blockstore, MemoryBlockstore};

    use super::find_head_commit;

    /// Build a minimal `_kind: "commit"` block with the given `time`,
    /// insert it into `bs`, and return its CID.
    fn make_commit(bs: &MemoryBlockstore, time: u64) -> mnem_core::id::Cid {
        let mut m = BTreeMap::new();
        m.insert("_kind".to_string(), Ipld::String("commit".to_string()));
        m.insert("time".to_string(), Ipld::Integer(i128::from(time)));
        let ipld = Ipld::Map(m);
        let bytes = to_canonical_bytes(&ipld).unwrap();
        let (_, cid) = hash_to_cid(&ipld).unwrap();
        bs.put(cid.clone(), bytes).unwrap();
        cid
    }

    /// BUG-30: `find_head_commit` must use `roots[0]` as the
    /// authoritative HEAD, not the commit with the largest `time`.
    ///
    /// Arrange two commits: one with a smaller time (the real HEAD,
    /// placed at roots[0]) and one with a larger time (a stale branch
    /// tip). The function must return roots[0], not the high-time one.
    #[test]
    fn uses_roots0_not_largest_time() {
        let inner = MemoryBlockstore::new();

        // HEAD commit has time=1 (smaller).
        let head_cid = make_commit(&inner, 1);
        // A second commit has time=9999 (larger) - it would win the old
        // largest-time heuristic, but it is NOT the designated head.
        let _stale_cid = make_commit(&inner, 9999);

        let bs: Arc<dyn Blockstore> = Arc::new(inner);

        // roots[0] designates the real HEAD.
        let roots = vec![head_cid.clone()];
        let result = find_head_commit(&bs, &roots).unwrap();
        assert_eq!(
            result,
            Some(head_cid),
            "find_head_commit must return roots[0], not the commit with the largest time"
        );
    }

    /// When roots[0] is absent from the blockstore (malformed CAR),
    /// the heuristic fallback must still return a commit if any are
    /// present in roots.
    #[test]
    fn fallback_when_root_missing_from_blockstore() {
        let inner = MemoryBlockstore::new();
        let commit_cid = make_commit(&inner, 42);

        let bs: Arc<dyn Blockstore> = Arc::new(inner);

        // roots[0] is a random CID not in the store.
        use mnem_core::id::{CODEC_DAG_CBOR, Cid, Multihash};
        let phantom = Cid::new(CODEC_DAG_CBOR, Multihash::sha2_256(b"not-stored"));

        let roots = vec![phantom, commit_cid.clone()];
        let result = find_head_commit(&bs, &roots).unwrap();
        assert_eq!(
            result,
            Some(commit_cid),
            "heuristic fallback must return the available commit when roots[0] is missing"
        );
    }
}

#[cfg(test)]
mod parse_clone_source_tests {
    use super::parse_clone_source;

    // audit-2026-04-25 C3-4: lock the git-bash-style file:/// URL
    // normalization so MSYS2 / Git Bash users on Windows can paste
    // the lowercase `file:///c/tmp/x.car` form they get from
    // `realpath` without hitting "system cannot find the path".
    #[test]
    #[cfg(windows)]
    fn file_uri_with_git_bash_drive_letter_normalizes() {
        let p = parse_clone_source("file:///c/tmp/repo.car").expect("parse ok");
        let s = p.to_string_lossy().replace('\\', "/");
        assert!(
            s.starts_with("c:/") || s.starts_with("C:/"),
            "expected drive-letter path, got {s:?}"
        );
        assert!(s.ends_with("/tmp/repo.car"), "got {s:?}");
    }

    #[test]
    #[cfg(windows)]
    fn file_uri_with_uppercase_drive_letter_unchanged() {
        let p = parse_clone_source("file:///C:/tmp/repo.car").expect("parse ok");
        let s = p.to_string_lossy().replace('\\', "/");
        assert!(s.starts_with("C:/"), "got {s:?}");
        assert!(s.ends_with("/tmp/repo.car"), "got {s:?}");
    }

    #[test]
    fn bare_car_path_still_accepted() {
        let p = parse_clone_source("./alice.car").expect("parse ok");
        assert!(p.to_string_lossy().ends_with("alice.car"));
    }

    #[test]
    fn unsupported_scheme_rejected() {
        let err = parse_clone_source("https://example.com/repo.car").unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("not yet implemented"), "got {msg}");
    }
}