use crate::errors::Error;
use crate::sqlite::Database;
use crate::sqlite::export_scan::ExportRow;
use std::io::Write;
use std::path::Path;
use std::process::ExitCode;
pub const EXPORT_FORMAT_VERSION: u32 = 1;
const EMBEDDING_DIMS: u32 = 384;
pub(crate) fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 63] as char
} else {
'='
});
}
out
}
#[allow(dead_code)] pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, Error> {
fn value(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
fn decode_value(c: u8) -> Result<u32, Error> {
value(c).ok_or_else(|| {
Error::InvalidInput(format!("invalid base64 character: {:?}", c as char))
})
}
let bytes: Vec<u8> = input.bytes().collect();
if bytes.len() % 4 != 0 {
return Err(Error::InvalidInput(format!(
"base64 input length {} is not a multiple of 4",
bytes.len()
)));
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let v0 = decode_value(chunk[0])?;
let v1 = decode_value(chunk[1])?;
if chunk[1] == b'=' {
return Err(Error::InvalidInput("misplaced base64 padding".to_string()));
}
out.push(((v0 << 2) | (v1 >> 4)) as u8);
if chunk[2] == b'=' {
if chunk[3] != b'=' {
return Err(Error::InvalidInput("misplaced base64 padding".to_string()));
}
continue;
}
let v2 = decode_value(chunk[2])?;
out.push(((v1 << 4) | (v2 >> 2)) as u8);
if chunk[3] == b'=' {
continue;
}
let v3 = decode_value(chunk[3])?;
out.push(((v2 << 6) | v3) as u8);
}
Ok(out)
}
struct ExportJsonlRow {
id: String,
project_id: String,
content: String,
metadata: Option<String>,
embedding: String,
created_at: String,
updated_at: String,
memory_type: String,
status: String,
superseded_by: Option<String>,
retrieval_count: i64,
last_retrieved_at: Option<String>,
}
impl ExportJsonlRow {
fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"id": self.id,
"project_id": self.project_id,
"content": self.content,
"metadata": self.metadata,
"embedding": self.embedding,
"created_at": self.created_at,
"updated_at": self.updated_at,
"memory_type": self.memory_type,
"status": self.status,
"superseded_by": self.superseded_by,
"retrieval_count": self.retrieval_count,
"last_retrieved_at": self.last_retrieved_at,
})
}
}
impl From<&ExportRow> for ExportJsonlRow {
fn from(row: &ExportRow) -> Self {
Self {
id: row.id.clone(),
project_id: row.project_id.clone(),
content: row.content.clone(),
metadata: row.metadata.clone(),
embedding: base64_encode(&row.embedding_blob),
created_at: row.created_at.clone(),
updated_at: row.updated_at.clone(),
memory_type: row.memory_type.clone(),
status: row.status.clone(),
superseded_by: row.superseded_by.clone(),
retrieval_count: row.retrieval_count,
last_retrieved_at: row.last_retrieved_at.clone(),
}
}
}
#[derive(serde::Serialize)]
pub struct ExportResponse {
pub rows: usize,
pub format_version: u32,
pub path: String,
}
pub fn write_jsonl<W: Write>(
out: &mut W,
rows: &[ExportRow],
exported_at: &str,
) -> std::result::Result<usize, Error> {
let header = serde_json::json!({
"type": "export",
"format_version": EXPORT_FORMAT_VERSION,
"embedding_dims": EMBEDDING_DIMS,
"exported_at": exported_at,
"rows": rows.len(),
});
writeln!(
out,
"{}",
serde_json::to_string(&header).map_err(Error::from)?
)?;
for row in rows {
let line =
serde_json::to_string(&ExportJsonlRow::from(row).to_json()).map_err(Error::from)?;
writeln!(out, "{line}")?;
}
Ok(rows.len())
}
pub fn handle_export(
db_path: &Path,
output_path: &Path,
project: Option<&str>,
json: bool,
) -> Result<ExitCode, Error> {
if let Some(project) = project {
eprintln!(
"Note: --project '{}' is ignored for export — export covers ALL projects.",
project
);
}
let db = Database::open(db_path).map_err(|e| {
let msg = e.to_string();
if msg.contains("database is locked") {
return Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
);
}
Error::Config(msg)
})?;
let rows = db
.scan_all_rows()
.map_err(|e| Error::InvalidInput(format!("export scan failed: {e}")))?;
let exported_at = chrono::Utc::now().to_rfc3339();
let mut file = std::fs::File::create(output_path)
.map_err(|e| Error::Config(format!("cannot create {}: {e}", output_path.display())))?;
let count = write_jsonl(&mut file, &rows, &exported_at)?;
let response = ExportResponse {
rows: count,
format_version: EXPORT_FORMAT_VERSION,
path: output_path.display().to_string(),
};
if json {
crate::output::print_json(&response);
} else {
println!("Exported {} row(s) to {}", count, output_path.display());
}
Ok(ExitCode::SUCCESS)
}