use std::fs;
use std::io::BufReader;
use ipld_core::ipld::Ipld;
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 {
pub url: String,
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())?;
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()
);
}
fs::create_dir_all(&target_dir)
.with_context(|| format!("creating {}", target_dir.display()))?;
let (bs, ohs) = repo::create_or_open_stores(&data_dir)?;
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()
)
})?;
let r_repo = ReadonlyRepo::init(bs.clone(), ohs.clone())?;
let head_commit = find_head_commit(&bs, &stats.roots)?;
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(§ion).context("serialising remote section")?;
fs::write(data_dir.join(config::CONFIG_FILE), config_text)
.context("writing .mnem/config.toml")?;
if let Some(head_cid) = &head_commit {
let cfg = config::load(&data_dir)?;
let _ = r_repo.update_ref(
"refs/remotes/origin/main",
None,
Some(RefTarget::normal(head_cid.clone())),
&config::author_string(&cfg),
)?;
}
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>"),
}
Ok(())
}
fn parse_clone_source(url: &str) -> Result<std::path::PathBuf> {
let has_scheme = url.contains("://");
if !has_scheme {
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);
}
if let Some(rest) = url.strip_prefix("file://") {
let trimmed = if rest.starts_with('/') && rest.len() >= 3 && rest.as_bytes()[2] == b':' {
&rest[1..]
} else {
rest
};
let normalized = super::normalize_cli_path(trimmed);
return Ok(std::path::PathBuf::from(normalized));
}
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 ."
);
}
fn resolve_target_dir(url: &str, explicit: Option<&Path>) -> Result<std::path::PathBuf> {
if let Some(d) = explicit {
return Ok(d.to_path_buf());
}
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))
}
fn find_head_commit(
bs: &std::sync::Arc<dyn mnem_core::store::Blockstore>,
roots: &[mnem_core::id::Cid],
) -> Result<Option<mnem_core::id::Cid>> {
let mut best: Option<(u64, mnem_core::id::Cid)> = None;
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,
};
best = Some(match best {
None => (time, root_cid.clone()),
Some((t, _)) if time > t => (time, root_cid.clone()),
Some(prev) => prev,
});
}
Ok(best.map(|(_, c)| c))
}
#[cfg(test)]
mod parse_clone_source_tests {
use super::parse_clone_source;
#[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}");
}
}