use std::{
collections::BTreeSet,
fmt,
fs::{self, File},
io::{self, BufWriter, Write},
path::{Path, PathBuf},
};
use anyhow::{Result, bail};
use clap::Args;
use io_pimdir::PimdirBlobs;
use io_replica::{collection::ReplicaCollectionId, object::ReplicaHash};
use log::warn;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
use serde_json::json;
use crate::cli::{StoreFlags, bytes, now, report};
const PAGE: usize = 500;
const FORMAT_VERSION: i64 = 1;
#[derive(Debug, Args)]
pub struct ExportCommand {
#[arg(value_name = "DIR")]
pub dir: PathBuf,
#[arg(long)]
pub retained: bool,
#[arg(long)]
pub no_objects: bool,
#[arg(long)]
pub force: bool,
}
impl ExportCommand {
pub fn execute(self, printer: &mut impl Printer, store: &StoreFlags) -> Result<()> {
let read = store.read()?;
let blobs = store.blobs()?;
let manifest_path = self.dir.join("manifest.json");
if manifest_path.exists() && !self.force {
bail!(
"{} already holds a dump: pass --force to overwrite it",
self.dir.display()
);
}
fs::create_dir_all(self.dir.join("items"))?;
let mut collections = Vec::new();
let mut hashes = BTreeSet::new();
let mut items = 0;
for (index, collection) in read
.list_collections()
.map_err(report)?
.into_iter()
.enumerate()
{
let file = format!("items/{:04}.jsonl", index + 1);
let live = self.dump_live(&read, &collection.id, &self.dir.join(&file), &mut hashes)?;
let (retained_file, retained) = if self.retained {
let file = format!("items/{:04}.retained.jsonl", index + 1);
let count =
self.dump_retained(&read, &collection.id, &self.dir.join(&file), &mut hashes)?;
(Some(file), count)
} else {
(None, 0)
};
items += live + retained;
collections.push(json!({
"id": collection.id,
"kind": collection.kind,
"name": collection.name,
"parent": collection.parent,
"color": collection.color,
"description": collection.description,
"sort_order": collection.sort_order,
"generation": collection.generation,
"items": file,
"retained_items": retained_file,
"live": live,
"retained": retained,
}));
}
let (objects, object_bytes, missing) = if self.no_objects {
(0, 0, Vec::new())
} else {
self.dump_objects(&blobs, &hashes)?
};
let collection_count = collections.len();
let manifest = json!({
"format": "pimdir-export",
"format_version": FORMAT_VERSION,
"exported_at": now(),
"store": store.dir().display().to_string(),
"sources": read.distinct_sources().map_err(report)?,
"collections": collections,
"objects": objects,
"object_bytes": object_bytes,
"missing_objects": missing,
});
fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?;
printer.out(ExportOutput {
dir: self.dir,
collections: collection_count,
items,
objects,
bytes: object_bytes,
missing,
})
}
fn dump_live(
&self,
read: &io_pimdir::PimdirReader,
collection: &str,
path: &Path,
hashes: &mut BTreeSet<String>,
) -> Result<u64> {
let mut file = BufWriter::new(File::create(path)?);
let mut after: Option<String> = None;
let mut count = 0;
loop {
let page = read
.list_items(collection, after.as_deref(), PAGE)
.map_err(report)?;
if page.is_empty() {
break;
}
for item in &page {
if let Some(hash) = &item.object {
hashes.insert(hash.0.clone());
}
let line = json!({
"collection": collection,
"seq": item.seq,
"link_id": item.link_id.0,
"flags": item.flags.known(),
"level": level(item.level),
"object": item.object.as_ref().map(|hash| hash.0.clone()),
"meta": item.meta.as_ref().map(|meta| meta.0.clone()),
});
writeln!(file, "{line}")?;
count += 1;
}
after = page.last().map(|item| item.link_id.0.clone());
}
file.flush()?;
Ok(count)
}
fn dump_retained(
&self,
read: &io_pimdir::PimdirReader,
collection: &str,
path: &Path,
hashes: &mut BTreeSet<String>,
) -> Result<u64> {
let id = ReplicaCollectionId(collection.to_string());
let mut file = BufWriter::new(File::create(path)?);
let mut after: Option<i64> = None;
let mut count = 0;
loop {
let page = read.list_retained(&id, after, PAGE).map_err(report)?;
if page.is_empty() {
break;
}
for item in &page {
if let Some(hash) = &item.object {
hashes.insert(hash.0.clone());
}
let retention = item.retention.as_ref();
let line = json!({
"collection": collection,
"seq": item.seq,
"link_id": item.link_id.0,
"flags": item.flags.known(),
"level": level(item.level),
"object": item.object.as_ref().map(|hash| hash.0.clone()),
"meta": item.meta.as_ref().map(|meta| meta.0.clone()),
"retained_at": retention.map(|retention| retention.at.clone()),
"retained_by": retention.and_then(|retention| retention.by.clone()),
});
writeln!(file, "{line}")?;
count += 1;
}
after = page.last().map(|item| item.seq);
}
file.flush()?;
Ok(count)
}
fn dump_objects(
&self,
blobs: &PimdirBlobs,
hashes: &BTreeSet<String>,
) -> Result<(u64, u64, Vec<String>)> {
let mut copied = 0;
let mut total = 0;
let mut missing = Vec::new();
for hash in hashes {
let Some(mut reader) = blobs.reader(&ReplicaHash(hash.clone()))? else {
warn!("the body of object {hash} is missing from the blob store");
missing.push(hash.clone());
continue;
};
let path = self.object_path(hash);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = File::create(&path)?;
total += io::copy(&mut reader, &mut file)?;
file.flush()?;
copied += 1;
}
Ok((copied, total, missing))
}
fn object_path(&self, hash: &str) -> PathBuf {
let objects = self.dir.join("objects");
if hash.len() >= 4 {
objects.join(&hash[0..2]).join(&hash[2..4]).join(hash)
} else {
objects.join(hash)
}
}
}
fn level(level: io_replica::placement::ReplicaLevel) -> &'static str {
match level {
io_replica::placement::ReplicaLevel::Probed => "probed",
io_replica::placement::ReplicaLevel::Meta => "meta",
io_replica::placement::ReplicaLevel::Full => "full",
}
}
#[derive(Debug, Serialize)]
pub struct ExportOutput {
pub dir: PathBuf,
pub collections: usize,
pub items: u64,
pub objects: u64,
pub bytes: u64,
pub missing: Vec<String>,
}
impl fmt::Display for ExportOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"Exported {} collection(s), {} item(s) and {} object(s) ({}) to {}",
self.collections,
self.items,
self.objects,
bytes(self.bytes),
self.dir.display()
)?;
if !self.missing.is_empty() {
writeln!(
f,
"{} object(s) had no body in the blob store and were left out; run `pimdir check`",
self.missing.len()
)?;
}
Ok(())
}
}