use std::path::{Path, PathBuf};
use serde_json::Value;
use tokio_stream::{Stream, StreamExt};
use crate::{
sessions::{IngestEvent, MessageWithParts, SessionWithMessages},
wire::ProviderOptions,
};
mod claude_ai_export;
mod claude_code;
mod claude_desktop_app;
mod codex_cli;
mod discovery;
pub mod extract;
mod grok_build;
mod hermes;
mod jsonl;
mod letta_code;
mod nanoclaw;
mod oh_my_pi;
mod openclaw;
mod opencode;
mod pi_coding_agent;
mod sqlite;
pub use claude_ai_export::{ClaudeAiExportAdapter, ClaudeAiExportFactory};
pub use claude_code::{ClaudeCodeAdapter, ClaudeCodeFactory};
pub use claude_desktop_app::{ClaudeDesktopAppAdapter, ClaudeDesktopAppFactory};
pub use codex_cli::{CodexCliAdapter, CodexCliFactory};
pub use discovery::{
Candidate, apply_to_doc, discover, persist_accept, probe_pathless, probe_unconfigured,
prompt_and_persist, set_adapter_enabled,
};
pub use extract::{
Extracted, Source, extract_bool, extract_compact_repr, extract_raw_record, extract_self_str,
extract_str, extract_value,
};
pub use grok_build::{GrokBuildAdapter, GrokBuildFactory};
pub use hermes::{HermesAdapter, HermesFactory};
pub use letta_code::{LettaCodeAdapter, LettaCodeFactory};
pub use nanoclaw::{NanoclawAdapter, NanoclawFactory};
pub use oh_my_pi::{OhMyPiAdapter, OhMyPiFactory};
pub use openclaw::{
EraseTarget, OpenClawAdapter, OpenClawFactory, PreserveNote, ReconciliationReport,
};
pub use opencode::{OpencodeAdapter, OpencodeFactory};
pub use pi_coding_agent::{PiCodingAgentAdapter, PiCodingAgentFactory};
pub trait AdapterFactory: Send + Sync {
fn name(&self) -> &'static str;
fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError>;
fn probe_default(&self, env: &Env) -> Option<Value>;
fn restore_unsupported(&self) -> Option<&'static str> {
None
}
fn serialize(
&self,
session: &SessionWithMessages,
fidelity: RestoreFidelity,
) -> Result<Vec<RestoredFile>, AdapterError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreFidelity {
Native,
Foreign,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RestoredFile {
pub relative_path: PathBuf,
pub bytes: Vec<u8>,
pub actual_fidelity: RestoreFidelity,
}
impl RestoredFile {
pub(crate) fn new(
relative_path: impl Into<PathBuf>,
bytes: Vec<u8>,
actual_fidelity: RestoreFidelity,
) -> Self {
Self {
relative_path: relative_path.into(),
bytes,
actual_fidelity,
}
}
}
pub trait Adapter: Send + Sync {
fn events(&self) -> EventStream<'_> {
let stream = self.events_with(&NoopOracle);
Box::pin(stream.filter_map(|res| match res {
Ok(AdapterYield::Event(event)) => Some(Ok(event)),
Ok(AdapterYield::Skipped { .. } | AdapterYield::SkippedBatch { .. }) => None,
Err(error) => Some(Err(error)),
}))
}
fn discover(&self) -> DiscoverFuture<'_>;
fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a>;
fn plan<'a>(&'a self, _oracle: &'a dyn SkipOracle) -> PlanFuture<'a> {
Box::pin(async { Ok(None) })
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SyncPlan {
pub sessions: usize,
pub fresh: usize,
pub pending: usize,
}
impl SyncPlan {
pub fn from_heads<'a>(
oracle: &dyn SkipOracle,
heads: impl IntoIterator<Item = (Option<&'a str>, SourceWatermark)>,
) -> Self {
let mut plan = Self::default();
for (session_id, watermark) in heads {
plan.sessions += 1;
if source_in_sync(oracle, session_id, watermark) {
plan.fresh += 1;
} else {
plan.pending += 1;
}
}
plan
}
pub fn all_pending(sessions: usize) -> Self {
Self {
sessions,
pending: sessions,
..Self::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceWatermark {
At(i64),
Empty,
Opaque,
}
pub fn source_in_sync(
oracle: &dyn SkipOracle,
session_id: Option<&str>,
watermark: SourceWatermark,
) -> bool {
match watermark {
SourceWatermark::Empty => true,
SourceWatermark::At(ts) => {
session_id.is_some_and(|id| is_session_fresh(oracle, id, Some(ts)))
}
SourceWatermark::Opaque => false,
}
}
pub type PlanFuture<'a> = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Option<SyncPlan>, AdapterError>> + Send + 'a>,
>;
pub trait SkipOracle: Send + Sync {
fn session_max_ts(&self, session_id: &str) -> Option<i64>;
fn is_empty(&self) -> bool {
false
}
}
pub fn is_session_fresh(
oracle: &dyn SkipOracle,
session_id: &str,
source_last_ts_micros: Option<i64>,
) -> bool {
matches!(
(oracle.session_max_ts(session_id), source_last_ts_micros),
(Some(stored), Some(source)) if source <= stored
)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopOracle;
impl SkipOracle for NoopOracle {
fn session_max_ts(&self, _session_id: &str) -> Option<i64> {
None
}
fn is_empty(&self) -> bool {
true
}
}
#[derive(Debug, Clone)]
pub enum AdapterYield {
Event(IngestEvent),
Skipped {
session_id: Option<String>,
project: Option<String>,
reason: SkipReason,
},
SkippedBatch {
reason: SkipReason,
count: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipReason {
Fresh,
Empty,
Unsupported(String),
Superseded,
}
pub type AdapterYieldStream<'a> =
std::pin::Pin<Box<dyn Stream<Item = Result<AdapterYield, AdapterError>> + Send + 'a>>;
pub type DiscoverFuture<'a> =
std::pin::Pin<Box<dyn std::future::Future<Output = Result<usize, AdapterError>> + Send + 'a>>;
pub struct Env {
pub home: PathBuf,
}
impl Env {
pub fn from_env() -> Option<Self> {
crate::config::home_dir().map(|home| Self { home })
}
pub fn with_home(home: impl Into<PathBuf>) -> Self {
Self { home: home.into() }
}
}
pub type EventStream<'a> =
std::pin::Pin<Box<dyn Stream<Item = Result<IngestEvent, AdapterError>> + Send + 'a>>;
#[derive(Debug)]
pub struct AdapterError {
pub adapter: &'static str,
pub location: String,
pub kind: AdapterErrorKind,
}
#[derive(Debug)]
pub enum AdapterErrorKind {
Io(std::io::Error),
Parse {
line: usize,
source: serde_json::Error,
},
Schema(String),
Config(String),
Transport(String),
Auth(String),
}
impl AdapterError {
pub fn io(adapter: &'static str, location: impl Into<String>, source: std::io::Error) -> Self {
Self {
adapter,
location: location.into(),
kind: AdapterErrorKind::Io(source),
}
}
pub fn parse(
adapter: &'static str,
location: impl Into<String>,
line: usize,
source: serde_json::Error,
) -> Self {
Self {
adapter,
location: location.into(),
kind: AdapterErrorKind::Parse { line, source },
}
}
pub fn schema(
adapter: &'static str,
location: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
adapter,
location: location.into(),
kind: AdapterErrorKind::Schema(message.into()),
}
}
pub fn config(adapter: &'static str, message: impl Into<String>) -> Self {
Self {
adapter,
location: "config".to_owned(),
kind: AdapterErrorKind::Config(message.into()),
}
}
}
impl std::fmt::Display for AdapterError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
AdapterErrorKind::Io(source) => {
write!(
formatter,
"{} io error at {}: {source}",
self.adapter, self.location
)
}
AdapterErrorKind::Parse { line, source } => write!(
formatter,
"{} json parse error at {}:{line}: {source}",
self.adapter, self.location,
),
AdapterErrorKind::Schema(message) => {
write!(
formatter,
"{} schema error at {}: {message}",
self.adapter, self.location
)
}
AdapterErrorKind::Config(message) => {
write!(formatter, "{} config error: {message}", self.adapter)
}
AdapterErrorKind::Transport(message) => write!(
formatter,
"{} transport error at {}: {message}",
self.adapter, self.location,
),
AdapterErrorKind::Auth(message) => {
write!(formatter, "{} auth error: {message}", self.adapter)
}
}
}
}
impl std::error::Error for AdapterError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.kind {
AdapterErrorKind::Io(source) => Some(source),
AdapterErrorKind::Parse { source, .. } => Some(source),
_ => None,
}
}
}
pub fn registry() -> &'static [&'static dyn AdapterFactory] {
&[
&ClaudeCodeFactory,
&ClaudeDesktopAppFactory,
&ClaudeAiExportFactory,
&CodexCliFactory,
&OpencodeFactory,
&OpenClawFactory,
&NanoclawFactory,
&HermesFactory,
&PiCodingAgentFactory,
&OhMyPiFactory,
&LettaCodeFactory,
&GrokBuildFactory,
]
}
pub fn by_name(name: &str) -> Option<&'static dyn AdapterFactory> {
registry().iter().copied().find(|f| f.name() == name)
}
pub fn known_names() -> Vec<&'static str> {
registry().iter().map(|f| f.name()).collect()
}
pub fn probe_all(env: &Env) -> Vec<(&'static str, Value)> {
registry()
.iter()
.filter_map(|factory| factory.probe_default(env).map(|cfg| (factory.name(), cfg)))
.collect()
}
pub(crate) fn part_id(message_id: &str, ordinal: usize) -> String {
format!("{message_id}:{ordinal:04}")
}
pub(crate) fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_default()
}
pub(crate) fn jsonl_bytes(
adapter: &'static str,
records: &[Value],
) -> Result<Vec<u8>, AdapterError> {
let mut bytes = Vec::new();
for record in records {
let line = serde_json::to_vec(record).map_err(|err| {
AdapterError::schema(adapter, "serialize", format!("json encode failed: {err}"))
})?;
bytes.extend(line);
bytes.push(b'\n');
}
Ok(bytes)
}
pub(crate) fn config_path(adapter: &'static str, config: Value) -> Result<PathBuf, AdapterError> {
use serde::Deserialize;
#[derive(Deserialize)]
struct Cfg {
path: PathBuf,
}
let cfg: Cfg = serde_json::from_value(config)
.map_err(|err| AdapterError::config(adapter, format!("bad config blob: {err}")))?;
Ok(expand_home(cfg.path))
}
pub(crate) fn expand_home(path: PathBuf) -> PathBuf {
match crate::config::home_dir() {
Some(home) => crate::config::expand_home_under(&path, &home),
None => path,
}
}
pub(crate) fn raw_record(options: &ProviderOptions) -> Option<Value> {
options
.get("source")
.and_then(|source| source.get("raw_record"))
.cloned()
}
pub(crate) fn source_options(adapter: &'static str, raw: &Value) -> ProviderOptions {
let mut options = ProviderOptions::new();
options.insert(
"source".to_owned(),
serde_json::json!({
"adapter": adapter,
"raw_record": extract_raw_record(raw),
}),
);
options
}
#[inline]
pub(crate) fn part_ordinal(ordinal: usize) -> i32 {
i32::try_from(ordinal).unwrap_or(i32::MAX)
}
pub fn validate_path_id(
adapter: &'static str,
kind: &str,
id: &str,
location: impl Into<String>,
) -> Result<(), AdapterError> {
if id.is_empty()
|| id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| std::path::Path::new(id).is_absolute()
{
return Err(AdapterError::schema(
adapter,
location,
format!("{kind} contains a path separator or traversal marker: {id}"),
));
}
if let Some(reason) = windows_hostile(id) {
return Err(AdapterError::schema(
adapter,
location,
format!("{kind} {reason}: {id}"),
));
}
Ok(())
}
const WINDOWS_DEVICE_NAMES: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn windows_hostile(segment: &str) -> Option<&'static str> {
if segment.contains(':') {
return Some("contains ':', which names an NTFS alternate data stream on Windows");
}
if segment.ends_with('.') || segment.ends_with(' ') {
return Some("ends with a dot or space, which Windows silently strips");
}
let stem = segment
.split_once('.')
.map_or(segment, |(head, _)| head)
.trim_end();
if WINDOWS_DEVICE_NAMES
.iter()
.any(|device| stem.eq_ignore_ascii_case(device))
{
return Some("is a reserved Windows device name");
}
None
}
pub fn restore_destinations(
root: &Path,
files: &[RestoredFile],
) -> Result<Vec<PathBuf>, AdapterError> {
files
.iter()
.map(|file| restore_destination(root, file))
.collect()
}
fn restore_destination(root: &Path, file: &RestoredFile) -> Result<PathBuf, AdapterError> {
let at = || file.relative_path.display().to_string();
for component in file.relative_path.components() {
use std::path::Component;
let segment = match component {
Component::Normal(s) => s,
Component::CurDir => continue,
_ => {
return Err(AdapterError::schema(
"restore",
at(),
"relative_path component is not a normal name",
));
}
};
let Some(text) = segment.to_str() else {
return Err(AdapterError::schema(
"restore",
at(),
"relative_path segment is not UTF-8",
));
};
validate_path_id("restore", "relative_path segment", text, at())?;
}
let dest = root.join(&file.relative_path);
if !dest.starts_with(root) {
return Err(AdapterError::schema(
"restore",
at(),
"relative_path escaped the restore root after join",
));
}
Ok(dest)
}
pub fn write_restored_files(
root: &Path,
files: Vec<RestoredFile>,
) -> Result<Vec<PathBuf>, AdapterError> {
let dests = restore_destinations(root, &files)?;
let existing: Vec<String> = dests
.iter()
.filter(|dest| exists_even_if_dangling(dest))
.map(|dest| dest.display().to_string())
.collect();
if !existing.is_empty() {
return Err(AdapterError::schema(
"restore",
root.display().to_string(),
format!(
"refusing to overwrite existing files: {}",
existing.join(", ")
),
));
}
let io =
|location: String, source: std::io::Error| AdapterError::io("restore", location, source);
let mut written = Vec::with_capacity(dests.len());
let mut created_dirs: Vec<PathBuf> = Vec::new();
let outcome = (|| -> Result<(), AdapterError> {
for (dest, file) in dests.into_iter().zip(files) {
if let Some(parent) = dest.parent() {
let created: Vec<PathBuf> = parent
.ancestors()
.take_while(|dir| !dir.exists())
.map(Path::to_path_buf)
.collect();
let result = std::fs::create_dir_all(parent);
created_dirs.extend(created);
result.map_err(|error| io(parent.display().to_string(), error))?;
}
let mut handle = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&dest)
.map_err(|error| io(dest.display().to_string(), error))?;
written.push(dest);
std::io::Write::write_all(&mut handle, &file.bytes).map_err(|error| {
let location = written
.last()
.map_or_else(String::new, |dest| dest.display().to_string());
io(location, error)
})?;
}
Ok(())
})();
let Err(error) = outcome else {
return Ok(written);
};
for path in &written {
let _ = std::fs::remove_file(path);
}
created_dirs.sort_unstable_by_key(|dir| std::cmp::Reverse(dir.components().count()));
for dir in &created_dirs {
let _ = std::fs::remove_dir(dir);
}
Err(error)
}
pub fn exists_even_if_dangling(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
pub(crate) fn extracted_text(value: &Option<Extracted<String>>) -> &str {
value.as_deref().map(String::as_str).unwrap_or("")
}
pub(crate) fn by_timestamp_then_id(
left: &MessageWithParts,
right: &MessageWithParts,
) -> std::cmp::Ordering {
left.message
.timestamp()
.cmp(&right.message.timestamp())
.then_with(|| left.message.id().cmp(right.message.id()))
}
#[inline]
pub(crate) fn empty_options() -> ProviderOptions {
ProviderOptions::new()
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::{RestoreFidelity, RestoredFile, validate_path_id, write_restored_files};
#[test]
fn validate_path_id_refuses_windows_hostile_segments() {
let ok = |id: &str| validate_path_id("t", "id", id, "loc").is_ok();
assert!(ok("ses_01HXY"));
assert!(ok("msg-1.jsonl"));
assert!(!ok("NUL"));
assert!(!ok("nul.jsonl"));
assert!(!ok("CoM9.txt"));
assert!(ok("console.jsonl"));
assert!(ok("nullable"));
assert!(!ok("session."));
assert!(!ok("session "));
assert!(!ok("C:session"));
assert!(!ok("session:stream"));
}
#[test]
fn a_failed_batch_removes_the_files_and_the_directories_it_created() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path().join("client-home");
std::fs::create_dir_all(&root).expect("root");
std::fs::write(root.join("blocked"), b"in the way").expect("blocker");
let error = write_restored_files(
&root,
vec![
RestoredFile::new(
"sessions/deep/first.jsonl",
b"first".to_vec(),
RestoreFidelity::Foreign,
),
RestoredFile::new(
"blocked/second.jsonl",
b"second".to_vec(),
RestoreFidelity::Foreign,
),
],
)
.expect_err("a destination whose parent is a file must fail the batch");
assert!(error.to_string().contains("io error"), "{error}");
assert!(
!root.join("sessions/deep/first.jsonl").exists(),
"the file written before the failure survived the unwind",
);
assert!(
!root.join("sessions/deep").exists() && !root.join("sessions").exists(),
"directories this batch created survived the unwind",
);
assert!(
root.join("blocked").is_file() && root.is_dir(),
"the unwind removed something it did not create",
);
}
#[test]
fn the_unwind_keeps_a_directory_it_did_not_create() {
let temp = TempDir::new().expect("temp dir");
let root = temp.path().join("client-home");
std::fs::create_dir_all(&root).expect("root");
std::fs::write(root.join("blocked"), b"in the way").expect("blocker");
write_restored_files(
&root,
vec![RestoredFile::new(
"sessions/first.jsonl",
b"first".to_vec(),
RestoreFidelity::Foreign,
)],
)
.expect("the first batch writes cleanly");
let intruder = root.join("sessions/not-ours.txt");
std::fs::write(&intruder, b"someone else's").expect("intruder");
write_restored_files(
&root,
vec![
RestoredFile::new(
"sessions/second.jsonl",
b"second".to_vec(),
RestoreFidelity::Foreign,
),
RestoredFile::new(
"blocked/third.jsonl",
b"third".to_vec(),
RestoreFidelity::Foreign,
),
],
)
.expect_err("the second batch fails on the blocked destination");
assert!(
!root.join("sessions/second.jsonl").exists(),
"the failed batch's own file survived",
);
assert!(intruder.is_file(), "the unwind removed a foreign file");
}
#[test]
fn adapters_never_touch_the_store_or_query_layer() {
const FORBIDDEN_IDENTS: [&str; 10] = [
"Store",
"substrate",
"rowmap",
"handlers",
"lance",
"lancedb",
"arrow",
"datafusion",
"object_store",
"from_stored",
];
const ALLOWED_ROOTS: [&str; 4] = ["adapter", "wire", "config", "sessions"];
const ALLOWED_SESSIONS_ITEMS: [&str; 3] =
["IngestEvent", "SessionWithMessages", "MessageWithParts"];
const EXEMPT: [(&str, &str, usize); 3] = [
("openclaw.rs", "crate::sessions::Store", 1),
("openclaw.rs", "Store", 2),
("extract.rs", "from_stored", 1),
];
const ITEM_KEYWORDS: [&str; 13] = [
"fn",
"pub",
"impl",
"use",
"const",
"static",
"struct",
"enum",
"trait",
"type",
"async",
"unsafe",
"macro_rules!",
];
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).expect("adapter dir is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
out.push(path);
}
}
}
fn identifiers(text: &str) -> impl Iterator<Item = &str> {
text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.filter(|ident| !ident.is_empty())
}
fn forbidden(ident: &str) -> Option<&'static str> {
FORBIDDEN_IDENTS.into_iter().find(|token| {
ident == *token
|| ident
.strip_prefix(token)
.is_some_and(|rest| rest.starts_with('_'))
})
}
fn opens_test_module(lines: &[&str], index: usize) -> bool {
lines[index].trim_start().starts_with("#[cfg(test)]")
&& lines[index + 1..]
.iter()
.map(|line| line.trim_start())
.find(|line| {
!line.is_empty() && !line.starts_with("#[") && !line.starts_with("//")
})
.is_some_and(|line| line.split_whitespace().take(4).any(|word| word == "mod"))
}
fn group_body(text: &str, open: usize) -> Option<&str> {
let mut depth = 0usize;
for (offset, c) in text[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&text[open + 1..open + offset]);
}
}
_ => {}
}
}
None
}
fn split_top_level(body: &str) -> Vec<&str> {
let mut entries = Vec::new();
let mut depth = 0usize;
let mut start = 0usize;
for (offset, c) in body.char_indices() {
match c {
'{' => depth += 1,
'}' => depth -= 1,
',' if depth == 0 => {
entries.push(body[start..offset].trim());
start = offset + 1;
}
_ => {}
}
}
entries.push(body[start..].trim());
entries
.into_iter()
.filter(|entry| !entry.is_empty())
.collect()
}
fn check_root_path(entry: &str) -> Option<String> {
let head = entry.split('{').next().unwrap_or("").trim_end_matches("::");
let mut segments = head.split("::").map(str::trim).filter(|s| !s.is_empty());
let root = segments.next()?;
if !ALLOWED_ROOTS.contains(&root) {
return Some(format!("crate::{head}"));
}
if root != "sessions" {
return None;
}
let group_items = entry
.find('{')
.and_then(|open| group_body(entry, open))
.map(|body| identifiers(body).collect::<Vec<_>>())
.unwrap_or_default();
segments
.chain(group_items)
.find(|item| !ALLOWED_SESSIONS_ITEMS.contains(item))
.map(|item| format!("crate::sessions::{item}"))
}
fn inline_root_paths<'a>(line: &'a str, root_prefixes: &[&str]) -> Vec<&'a str> {
let mut entries = Vec::new();
for prefix in root_prefixes {
for (start, _) in line.match_indices(prefix) {
let rest = &line[start + prefix.len()..];
let head_len = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == ':'))
.unwrap_or(rest.len());
let entry_end = match rest[head_len..].starts_with('{') {
true => group_body(rest, head_len)
.map_or(rest.len(), |body| head_len + body.len() + 2),
false => head_len,
};
entries.push(&rest[..entry_end]);
}
}
entries
}
fn is_item_line(line: &str) -> bool {
let first = line.split(|c: char| c.is_whitespace() || c == '(').next();
first.is_some_and(|word| ITEM_KEYWORDS.contains(&word))
&& !line.split_whitespace().take(4).any(|word| word == "mod")
}
let adapter_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("adapter");
let mut files = Vec::new();
rust_files(&adapter_dir, &mut files);
files.sort();
let mut violations = Vec::new();
let mut exempt_hits: Vec<usize> = vec![0; EXEMPT.len()];
for path in &files {
let name = path
.strip_prefix(&adapter_dir)
.expect("collected under adapter_dir")
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
let root_prefixes: &[&str] = match name.as_str() {
"mod.rs" => &["crate::", "super::"],
_ => &["crate::", "super::super::"],
};
let text = std::fs::read_to_string(path).expect("adapter source is readable");
let lines: Vec<&str> = text.lines().collect();
let mut record = |line_no: usize, token: &str, line: &str| {
let exempt = EXEMPT
.iter()
.position(|(file, tok, _)| *file == name && *tok == token);
match exempt {
Some(slot) => exempt_hits[slot] += 1,
None => violations.push(format!("{name}:{line_no}: `{token}` in: {line}")),
}
};
let mut group_depth = 0usize;
let mut nested_root: Option<String> = None;
let mut in_tests = false;
for (index, raw) in lines.iter().enumerate() {
let line_no = index + 1;
let line = raw.trim_start();
if in_tests {
if raw.len() == line.len() && is_item_line(line) {
record(line_no, "production item after the test module", line);
}
continue;
}
if opens_test_module(&lines, index) {
in_tests = true;
continue;
}
if line.starts_with("//") {
continue;
}
for ident in identifiers(line) {
if let Some(token) = forbidden(ident) {
record(line_no, token, line);
}
}
if group_depth > 0 {
let entry = line.trim_end_matches(';').trim_end_matches(',');
let opens = entry.matches('{').count();
let closes = entry.matches('}').count();
let is_path = entry.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_');
if group_depth == 1 && is_path {
if let Some(bad) = check_root_path(entry) {
record(line_no, &bad, line);
}
if opens > closes {
nested_root = entry.split("::").next().map(str::to_owned);
}
} else if group_depth > 1
&& nested_root.as_deref() == Some("sessions")
&& let Some(item) =
identifiers(entry).find(|item| !ALLOWED_SESSIONS_ITEMS.contains(item))
{
record(line_no, &format!("crate::sessions::{item}"), line);
}
group_depth = (group_depth + opens).saturating_sub(closes);
continue;
}
for entry in inline_root_paths(line, root_prefixes) {
if entry.starts_with('{') {
match group_body(entry, 0) {
Some(body) => {
for sub in split_top_level(body) {
if let Some(bad) = check_root_path(sub) {
record(line_no, &bad, line);
}
}
}
None => {
group_depth = 1;
nested_root = None;
}
}
} else if let Some(bad) = check_root_path(entry) {
record(line_no, &bad, line);
}
}
}
}
for ((file, token, expected), actual) in EXEMPT.iter().zip(exempt_hits) {
if actual != *expected {
violations.push(format!(
"{file}: `{token}` exempt for {expected} lines, found {actual} - a new use \
needs its own reason, a removed one drops the exemption"
));
}
}
violations.sort();
assert!(
violations.is_empty(),
"adapter code reached past the seam into the store/query layer:\n{}\n\
A legitimate read-only use goes into EXEMPT as (file, token, count) with its \
reason; production items belong above the test module.",
violations.join("\n"),
);
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use serde_json::Value;
use tempfile::TempDir;
use super::{Adapter, AdapterFactory, Env, NoopOracle, RestoreFidelity, SkipOracle};
use crate::{handlers::ingest_adapter, sessions::Store};
pub(crate) struct MaxWatermarkOracle;
impl SkipOracle for MaxWatermarkOracle {
fn session_max_ts(&self, _session_id: &str) -> Option<i64> {
Some(i64::MAX)
}
}
pub(crate) fn assert_probe_default(
factory: &dyn AdapterFactory,
expected_subpath: &[&str],
) -> anyhow::Result<()> {
let temp = TempDir::new()?;
let mut expected = temp.path().to_path_buf();
for segment in expected_subpath {
expected.push(segment);
}
std::fs::create_dir_all(&expected)?;
let env = Env::with_home(temp.path());
let probe = factory.probe_default(&env);
let got = probe
.as_ref()
.and_then(|value| value.get("path"))
.and_then(Value::as_str);
anyhow::ensure!(
got == expected.to_str(),
"factory must probe its install path: got {got:?}, expected {expected:?}",
);
std::fs::remove_dir_all(&expected)?;
anyhow::ensure!(
factory.probe_default(&env).is_none(),
"probe_default must be None once the install path disappears",
);
Ok(())
}
pub(crate) async fn assert_native_restore(
factory: &dyn AdapterFactory,
adapter: &dyn Adapter,
source_root: &Path,
) -> anyhow::Result<()> {
let temp = TempDir::new()?;
let store = Store::open_local(temp.path()).await?;
ingest_adapter(&store, adapter, &NoopOracle, |_| {}).await?;
let session_ids = store.session_ids().await?;
assert!(
!session_ids.is_empty(),
"native restore fixture must ingest at least one session",
);
let mut restored_paths = BTreeSet::new();
for session_id in session_ids {
let Some(session) = store.get_session(&session_id).await? else {
anyhow::bail!("session id listed by store was not readable: {session_id}");
};
let restored = factory.serialize(&session, RestoreFidelity::Native)?;
for file in restored {
let expected = source_root.join(&file.relative_path);
let expected_bytes = std::fs::read(&expected)
.map_err(|err| anyhow::anyhow!("read {}: {err}", expected.display()))?;
assert_json_file_equal(&expected, &expected_bytes, &file.bytes)?;
restored_paths.insert(file.relative_path);
}
}
assert_eq!(
restored_paths,
source_json_files(source_root)?,
"native restore must emit exactly the source JSON/JSONL file set",
);
Ok(())
}
fn source_json_files(root: &Path) -> anyhow::Result<BTreeSet<PathBuf>> {
let mut out = BTreeSet::new();
collect_source_json_files(root, root, &mut out)?;
Ok(out)
}
fn collect_source_json_files(
root: &Path,
dir: &Path,
out: &mut BTreeSet<PathBuf>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
collect_source_json_files(root, &path, out)?;
continue;
}
if let Some("json" | "jsonl") = path.extension().and_then(|ext| ext.to_str()) {
out.insert(path.strip_prefix(root)?.to_path_buf());
}
}
Ok(())
}
fn assert_json_file_equal(path: &Path, expected: &[u8], actual: &[u8]) -> anyhow::Result<()> {
if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
let expected_lines = json_lines(expected)?;
let actual_lines = json_lines(actual)?;
assert_eq!(
actual_lines,
expected_lines,
"jsonl mismatch at {}",
path.display()
);
} else {
let expected_value: serde_json::Value = serde_json::from_slice(expected)?;
let actual_value: serde_json::Value = serde_json::from_slice(actual)?;
assert_eq!(
actual_value,
expected_value,
"json mismatch at {}",
path.display()
);
}
Ok(())
}
fn json_lines(bytes: &[u8]) -> anyhow::Result<Vec<serde_json::Value>> {
let text = std::str::from_utf8(bytes)?;
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).map_err(Into::into))
.collect()
}
}