use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde::Deserialize;
use super::KuzuImportError;
pub const EXPORT_SCRIPT: &str = include_str!("export.py");
pub const EXPORT_FORMAT: &str = "trusty-kuzu-export/1";
pub const REQUIRED_MEMORY_COLUMNS: &[&str] = &["id", "content"];
#[derive(Debug, Clone)]
pub struct RunOutput {
pub success: bool,
pub code: Option<i32>,
pub stderr: String,
}
pub trait CommandRunner {
fn run(&self, program: &Path, args: &[OsString]) -> std::io::Result<RunOutput>;
}
#[derive(Debug, Clone, Copy)]
pub struct SystemRunner {
pub timeout: Duration,
}
impl SystemRunner {
pub fn new(timeout: Duration) -> Self {
Self { timeout }
}
}
impl Default for SystemRunner {
fn default() -> Self {
Self::new(Duration::from_secs(super::DEFAULT_BRIDGE_TIMEOUT_SECS))
}
}
const POLL: Duration = Duration::from_millis(50);
impl CommandRunner for SystemRunner {
fn run(&self, program: &Path, args: &[OsString]) -> std::io::Result<RunOutput> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let mut pipe = child.stderr.take();
let reader = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
String::from_utf8_lossy(&buf).into_owned()
});
let deadline = Instant::now() + self.timeout;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("killed after {}s", self.timeout.as_secs()),
));
}
std::thread::sleep(POLL);
};
Ok(RunOutput {
success: status.success(),
code: status.code(),
stderr: reader.join().unwrap_or_default(),
})
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct KuzuMemoryRow {
pub id: Option<String>,
pub content: Option<String>,
pub content_hash: Option<String>,
pub created_at: Option<String>,
pub memory_type: Option<String>,
pub knowledge_type: Option<String>,
pub importance: Option<f64>,
pub source_type: Option<String>,
pub project_tag: Option<String>,
pub agent_id: Option<String>,
pub user_id: Option<String>,
pub session_id: Option<String>,
pub metadata: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KuzuEntityRow {
pub id: Option<String>,
pub name: Option<String>,
pub entity_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KuzuMention {
pub memory_id: Option<String>,
pub entity_id: Option<String>,
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KuzuRelatesTo {
pub from_id: Option<String>,
pub to_id: Option<String>,
pub relationship_type: Option<String>,
pub strength: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KuzuExport {
pub format: String,
#[serde(default)]
pub memory_columns: Vec<String>,
pub memories: Vec<KuzuMemoryRow>,
#[serde(default)]
pub entities: Vec<KuzuEntityRow>,
#[serde(default)]
pub mentions: Vec<KuzuMention>,
#[serde(default)]
pub relates_to: Vec<KuzuRelatesTo>,
#[serde(default)]
pub other_edges: BTreeMap<String, usize>,
#[serde(default)]
pub other_edges_error: Option<String>,
}
impl KuzuExport {
pub fn edge_count(&self) -> usize {
self.mentions.len() + self.relates_to.len()
}
}
pub fn export_store(
runner: &dyn CommandRunner,
python: &Path,
db: &Path,
) -> Result<KuzuExport, KuzuImportError> {
let tmp = tempfile::TempDir::with_prefix("trusty-kuzu-import-")
.map_err(|e| KuzuImportError::Io(format!("create temp dir: {e}")))?;
let script = tmp.path().join("export.py");
let out = tmp.path().join("export.json");
std::fs::write(&script, EXPORT_SCRIPT)
.map_err(|e| KuzuImportError::Io(format!("write export script: {e}")))?;
let args = [
script.into_os_string(),
db.as_os_str().to_os_string(),
out.clone().into_os_string(),
];
let run = runner.run(python, &args).map_err(|e| match e.kind() {
std::io::ErrorKind::TimedOut => KuzuImportError::BridgeTimedOut(e.to_string()),
_ => {
KuzuImportError::InterpreterNotFound(format!("could not run {}: {e}", python.display()))
}
})?;
if !run.success {
return Err(KuzuImportError::BridgeFailed {
code: run.code,
stderr: tail(&run.stderr, 400),
});
}
let raw = std::fs::read(&out).map_err(|e| {
KuzuImportError::MalformedExport(format!("bridge exited 0 but wrote no output: {e}"))
})?;
let export: KuzuExport = serde_json::from_slice(&raw)
.map_err(|e| KuzuImportError::MalformedExport(e.to_string()))?;
validate_export(&export)?;
Ok(export)
}
pub fn validate_export(export: &KuzuExport) -> Result<(), KuzuImportError> {
if export.format != EXPORT_FORMAT {
return Err(KuzuImportError::MalformedExport(format!(
"unknown export format {:?}",
export.format
)));
}
let missing: Vec<String> = REQUIRED_MEMORY_COLUMNS
.iter()
.filter(|c| !export.memory_columns.iter().any(|have| have == *c))
.map(|c| (*c).to_string())
.collect();
if !missing.is_empty() {
return Err(KuzuImportError::SchemaColumnsMissing(missing));
}
if export
.memories
.iter()
.any(|m| m.id.as_deref().is_none_or(str::is_empty))
{
return Err(KuzuImportError::MalformedExport(
"a Memory row has no id".to_string(),
));
}
Ok(())
}
pub fn resolve_python(
explicit: Option<&Path>,
path_var: Option<&OsStr>,
) -> Result<PathBuf, KuzuImportError> {
if let Some(p) = explicit {
if p.exists() {
return Ok(p.to_path_buf());
}
return Err(KuzuImportError::InterpreterNotFound(format!(
"--python {} does not exist",
p.display()
)));
}
let launcher = which("kuzu-memory", path_var).ok_or_else(|| {
KuzuImportError::InterpreterNotFound(
"kuzu-memory is not on PATH; install it or pass --python <interpreter \
that can `import kuzu`>"
.to_string(),
)
})?;
let text = std::fs::read_to_string(&launcher).map_err(|e| {
KuzuImportError::InterpreterNotFound(format!("read {}: {e}", launcher.display()))
})?;
let shebang = text
.lines()
.next()
.and_then(|l| l.strip_prefix("#!"))
.map(str::trim)
.ok_or_else(|| {
KuzuImportError::InterpreterNotFound(format!(
"{} has no shebang; pass --python",
launcher.display()
))
})?;
let mut parts = shebang.split_whitespace();
let first = parts.next().unwrap_or_default();
let interpreter = if first.ends_with("/env") {
let name = parts.find(|p| !p.starts_with('-')).unwrap_or("python3");
which(name, path_var).ok_or_else(|| {
KuzuImportError::InterpreterNotFound(format!("{name} (from kuzu-memory) not on PATH"))
})?
} else {
PathBuf::from(first)
};
let is_python = interpreter
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|n| n.starts_with("python"));
if !is_python {
return Err(KuzuImportError::InterpreterNotFound(format!(
"{}'s shebang names {}, which is not a python interpreter; pass --python \
<the interpreter kuzu-memory runs under>",
launcher.display(),
interpreter.display()
)));
}
if interpreter.exists() {
Ok(interpreter)
} else {
Err(KuzuImportError::InterpreterNotFound(format!(
"kuzu-memory's interpreter {} does not exist",
interpreter.display()
)))
}
}
fn which(name: &str, path_var: Option<&OsStr>) -> Option<PathBuf> {
std::env::split_paths(path_var?)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}
fn tail(s: &str, max: usize) -> String {
let trimmed = s.trim();
let count = trimmed.chars().count();
trimmed.chars().skip(count.saturating_sub(max)).collect()
}