1use std::path::{Path, PathBuf};
20
21use serde_json::Value;
22use tokio_stream::{Stream, StreamExt};
23
24use crate::{
25 sessions::{IngestEvent, MessageWithParts, SessionWithMessages},
26 wire::ProviderOptions,
27};
28
29mod claude_ai_export;
30mod claude_code;
31mod claude_desktop_app;
32mod codex_cli;
33mod discovery;
34pub mod extract;
35mod hermes;
36mod jsonl;
37mod nanoclaw;
38mod openclaw;
39mod opencode;
40mod pi_coding_agent;
41mod sqlite;
42
43pub use claude_ai_export::{ClaudeAiExportAdapter, ClaudeAiExportFactory};
44pub use claude_code::{ClaudeCodeAdapter, ClaudeCodeFactory};
45pub use claude_desktop_app::{ClaudeDesktopAppAdapter, ClaudeDesktopAppFactory};
46pub use codex_cli::{CodexCliAdapter, CodexCliFactory};
47pub use discovery::{
48 Candidate, apply_to_doc, discover, persist_accept, probe_unconfigured, prompt_and_persist,
49 set_adapter_enabled,
50};
51pub use extract::{
52 Extracted, Source, extract_bool, extract_compact_repr, extract_raw_record, extract_self_str,
53 extract_str, extract_value,
54};
55pub use hermes::{HermesAdapter, HermesFactory};
56pub use nanoclaw::{NanoclawAdapter, NanoclawFactory};
57pub use openclaw::{
58 EraseTarget, OpenClawAdapter, OpenClawFactory, PreserveNote, ReconciliationReport,
59};
60pub use opencode::{OpencodeAdapter, OpencodeFactory};
61pub use pi_coding_agent::{PiCodingAgentAdapter, PiCodingAgentFactory};
62
63pub trait AdapterFactory: Send + Sync {
67 fn name(&self) -> &'static str;
71
72 fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError>;
78
79 fn probe_default(&self, env: &Env) -> Option<Value>;
85
86 fn serialize(
88 &self,
89 session: &SessionWithMessages,
90 fidelity: RestoreFidelity,
91 ) -> Result<Vec<RestoredFile>, AdapterError>;
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum RestoreFidelity {
96 Native,
97 Foreign,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct RestoredFile {
102 pub relative_path: PathBuf,
103 pub bytes: Vec<u8>,
104 pub actual_fidelity: RestoreFidelity,
111}
112
113impl RestoredFile {
114 pub(crate) fn new(
115 relative_path: impl Into<PathBuf>,
116 bytes: Vec<u8>,
117 actual_fidelity: RestoreFidelity,
118 ) -> Self {
119 Self {
120 relative_path: relative_path.into(),
121 bytes,
122 actual_fidelity,
123 }
124 }
125}
126
127pub trait Adapter: Send + Sync {
131 fn events(&self) -> EventStream<'_> {
136 let stream = self.events_with(&NoopOracle);
137 Box::pin(stream.filter_map(|res| match res {
138 Ok(AdapterYield::Event(event)) => Some(Ok(event)),
139 Ok(AdapterYield::Skipped { .. } | AdapterYield::SkippedBatch { .. }) => None,
140 Err(error) => Some(Err(error)),
141 }))
142 }
143
144 fn discover(&self) -> DiscoverFuture<'_>;
151
152 fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a>;
156
157 fn plan<'a>(&'a self, _oracle: &'a dyn SkipOracle) -> PlanFuture<'a> {
165 Box::pin(async { Ok(None) })
166 }
167}
168
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct SyncPlan {
177 pub sessions: usize,
178 pub fresh: usize,
179 pub pending: usize,
180}
181
182impl SyncPlan {
183 pub fn from_heads<'a>(
188 oracle: &dyn SkipOracle,
189 heads: impl IntoIterator<Item = (Option<&'a str>, SourceWatermark)>,
190 ) -> Self {
191 let mut plan = Self::default();
192 for (session_id, watermark) in heads {
193 plan.sessions += 1;
194 if source_in_sync(oracle, session_id, watermark) {
195 plan.fresh += 1;
196 } else {
197 plan.pending += 1;
198 }
199 }
200 plan
201 }
202
203 pub fn all_pending(sessions: usize) -> Self {
205 Self {
206 sessions,
207 pending: sessions,
208 ..Self::default()
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum SourceWatermark {
229 At(i64),
231 Empty,
233 Opaque,
235}
236
237pub fn source_in_sync(
241 oracle: &dyn SkipOracle,
242 session_id: Option<&str>,
243 watermark: SourceWatermark,
244) -> bool {
245 match watermark {
246 SourceWatermark::Empty => true,
247 SourceWatermark::At(ts) => {
248 session_id.is_some_and(|id| is_session_fresh(oracle, id, Some(ts)))
249 }
250 SourceWatermark::Opaque => false,
251 }
252}
253
254pub type PlanFuture<'a> = std::pin::Pin<
256 Box<dyn std::future::Future<Output = Result<Option<SyncPlan>, AdapterError>> + Send + 'a>,
257>;
258
259pub trait SkipOracle: Send + Sync {
272 fn session_max_ts(&self, session_id: &str) -> Option<i64>;
273
274 fn is_empty(&self) -> bool {
278 false
279 }
280}
281
282pub fn is_session_fresh(
287 oracle: &dyn SkipOracle,
288 session_id: &str,
289 source_last_ts_micros: Option<i64>,
290) -> bool {
291 matches!(
292 (oracle.session_max_ts(session_id), source_last_ts_micros),
293 (Some(stored), Some(source)) if source <= stored
294 )
295}
296
297#[derive(Debug, Default, Clone, Copy)]
300pub struct NoopOracle;
301
302impl SkipOracle for NoopOracle {
303 fn session_max_ts(&self, _session_id: &str) -> Option<i64> {
304 None
305 }
306
307 fn is_empty(&self) -> bool {
308 true
309 }
310}
311
312#[derive(Debug, Clone)]
313pub enum AdapterYield {
314 Event(IngestEvent),
315 Skipped {
316 session_id: Option<String>,
318 project: Option<String>,
319 reason: SkipReason,
320 },
321 SkippedBatch {
324 reason: SkipReason,
325 count: usize,
326 },
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum SkipReason {
331 Fresh,
332 Empty,
336 Unsupported(String),
342 Superseded,
348}
349
350pub type AdapterYieldStream<'a> =
351 std::pin::Pin<Box<dyn Stream<Item = Result<AdapterYield, AdapterError>> + Send + 'a>>;
352
353pub type DiscoverFuture<'a> =
357 std::pin::Pin<Box<dyn std::future::Future<Output = Result<usize, AdapterError>> + Send + 'a>>;
358
359pub struct Env {
364 pub home: PathBuf,
365}
366
367impl Env {
368 pub fn from_env() -> Option<Self> {
371 let home = std::env::var_os("HOME")?;
372 Some(Self {
373 home: PathBuf::from(home),
374 })
375 }
376
377 pub fn with_home(home: impl Into<PathBuf>) -> Self {
380 Self { home: home.into() }
381 }
382}
383
384pub type EventStream<'a> =
388 std::pin::Pin<Box<dyn Stream<Item = Result<IngestEvent, AdapterError>> + Send + 'a>>;
389
390#[derive(Debug)]
395pub struct AdapterError {
396 pub adapter: &'static str,
397 pub location: String,
398 pub kind: AdapterErrorKind,
399}
400
401#[derive(Debug)]
402pub enum AdapterErrorKind {
403 Io(std::io::Error),
405 Parse {
407 line: usize,
408 source: serde_json::Error,
409 },
410 Schema(String),
413 Config(String),
415 Transport(String),
417 Auth(String),
419}
420
421impl AdapterError {
422 pub fn io(adapter: &'static str, location: impl Into<String>, source: std::io::Error) -> Self {
423 Self {
424 adapter,
425 location: location.into(),
426 kind: AdapterErrorKind::Io(source),
427 }
428 }
429
430 pub fn parse(
431 adapter: &'static str,
432 location: impl Into<String>,
433 line: usize,
434 source: serde_json::Error,
435 ) -> Self {
436 Self {
437 adapter,
438 location: location.into(),
439 kind: AdapterErrorKind::Parse { line, source },
440 }
441 }
442
443 pub fn schema(
444 adapter: &'static str,
445 location: impl Into<String>,
446 message: impl Into<String>,
447 ) -> Self {
448 Self {
449 adapter,
450 location: location.into(),
451 kind: AdapterErrorKind::Schema(message.into()),
452 }
453 }
454
455 pub fn config(adapter: &'static str, message: impl Into<String>) -> Self {
456 Self {
457 adapter,
458 location: "config".to_owned(),
459 kind: AdapterErrorKind::Config(message.into()),
460 }
461 }
462}
463
464impl std::fmt::Display for AdapterError {
465 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 match &self.kind {
467 AdapterErrorKind::Io(source) => {
468 write!(
469 formatter,
470 "{} io error at {}: {source}",
471 self.adapter, self.location
472 )
473 }
474 AdapterErrorKind::Parse { line, source } => write!(
475 formatter,
476 "{} json parse error at {}:{line}: {source}",
477 self.adapter, self.location,
478 ),
479 AdapterErrorKind::Schema(message) => {
480 write!(
481 formatter,
482 "{} schema error at {}: {message}",
483 self.adapter, self.location
484 )
485 }
486 AdapterErrorKind::Config(message) => {
487 write!(formatter, "{} config error: {message}", self.adapter)
488 }
489 AdapterErrorKind::Transport(message) => write!(
490 formatter,
491 "{} transport error at {}: {message}",
492 self.adapter, self.location,
493 ),
494 AdapterErrorKind::Auth(message) => {
495 write!(formatter, "{} auth error: {message}", self.adapter)
496 }
497 }
498 }
499}
500
501impl std::error::Error for AdapterError {
502 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
503 match &self.kind {
504 AdapterErrorKind::Io(source) => Some(source),
505 AdapterErrorKind::Parse { source, .. } => Some(source),
506 _ => None,
507 }
508 }
509}
510
511pub fn registry() -> &'static [&'static dyn AdapterFactory] {
515 &[
516 &ClaudeCodeFactory,
517 &ClaudeDesktopAppFactory,
518 &ClaudeAiExportFactory,
519 &CodexCliFactory,
520 &OpencodeFactory,
521 &OpenClawFactory,
522 &NanoclawFactory,
523 &HermesFactory,
524 &PiCodingAgentFactory,
525 ]
526}
527
528pub fn by_name(name: &str) -> Option<&'static dyn AdapterFactory> {
531 registry().iter().copied().find(|f| f.name() == name)
532}
533
534pub fn known_names() -> Vec<&'static str> {
537 registry().iter().map(|f| f.name()).collect()
538}
539
540pub fn probe_all(env: &Env) -> Vec<(&'static str, Value)> {
544 registry()
545 .iter()
546 .filter_map(|factory| factory.probe_default(env).map(|cfg| (factory.name(), cfg)))
547 .collect()
548}
549
550pub(crate) fn part_id(message_id: &str, ordinal: usize) -> String {
553 format!("{message_id}:{ordinal:04}")
554}
555
556pub(crate) fn compact_json(value: &Value) -> String {
559 serde_json::to_string(value).unwrap_or_default()
560}
561
562pub(crate) fn jsonl_bytes(
563 adapter: &'static str,
564 records: &[Value],
565) -> Result<Vec<u8>, AdapterError> {
566 let mut bytes = Vec::new();
567 for record in records {
568 let line = serde_json::to_vec(record).map_err(|err| {
569 AdapterError::schema(adapter, "serialize", format!("json encode failed: {err}"))
570 })?;
571 bytes.extend(line);
572 bytes.push(b'\n');
573 }
574 Ok(bytes)
575}
576
577pub(crate) fn config_path(adapter: &'static str, config: Value) -> Result<PathBuf, AdapterError> {
580 use serde::Deserialize;
581 #[derive(Deserialize)]
582 struct Cfg {
583 path: PathBuf,
584 }
585 let cfg: Cfg = serde_json::from_value(config)
586 .map_err(|err| AdapterError::config(adapter, format!("bad config blob: {err}")))?;
587 Ok(match std::env::var_os("HOME") {
588 Some(home) => crate::config::expand_home_under(&cfg.path, Path::new(&home)),
589 None => cfg.path,
590 })
591}
592
593pub(crate) fn raw_record(options: &ProviderOptions) -> Option<Value> {
594 options
595 .get("source")
596 .and_then(|source| source.get("raw_record"))
597 .cloned()
598}
599
600pub(crate) fn source_options(adapter: &'static str, raw: &Value) -> ProviderOptions {
606 let mut options = ProviderOptions::new();
607 options.insert(
608 "source".to_owned(),
609 serde_json::json!({
610 "adapter": adapter,
611 "raw_record": extract_raw_record(raw),
612 }),
613 );
614 options
615}
616
617#[inline]
621pub(crate) fn part_ordinal(ordinal: usize) -> i32 {
622 i32::try_from(ordinal).unwrap_or(i32::MAX)
623}
624
625pub(crate) fn validate_path_id(
631 adapter: &'static str,
632 kind: &str,
633 id: &str,
634 location: impl Into<String>,
635) -> Result<(), AdapterError> {
636 if id.is_empty()
637 || id.contains('/')
638 || id.contains('\\')
639 || id.contains("..")
640 || std::path::Path::new(id).is_absolute()
641 {
642 return Err(AdapterError::schema(
643 adapter,
644 location,
645 format!("{kind} contains a path separator or traversal marker: {id}"),
646 ));
647 }
648 Ok(())
649}
650
651#[allow(dead_code)]
661pub(crate) fn write_restored_files(
662 root: &Path,
663 files: Vec<RestoredFile>,
664) -> Result<(), AdapterError> {
665 let parent = root.parent().unwrap_or_else(|| Path::new("."));
668 let stem = root
669 .file_name()
670 .and_then(|n| n.to_str())
671 .unwrap_or("restore");
672 let staging = parent.join(format!(".{stem}.tmp"));
673 let io =
674 |location: String, source: std::io::Error| AdapterError::io("restore", location, source);
675 let _ = std::fs::remove_dir_all(&staging);
676 std::fs::create_dir_all(&staging).map_err(|e| io(staging.display().to_string(), e))?;
677
678 let result = (|| -> Result<(), AdapterError> {
679 for file in files {
680 write_one_into_staging(&staging, &file)?;
681 }
682 Ok(())
683 })();
684
685 if let Err(error) = result {
686 let _ = std::fs::remove_dir_all(&staging);
687 return Err(error);
688 }
689
690 let _ = std::fs::remove_dir_all(root);
692 if let Some(parent) = root.parent()
693 && !parent.as_os_str().is_empty()
694 {
695 std::fs::create_dir_all(parent).map_err(|e| io(parent.display().to_string(), e))?;
696 }
697 std::fs::rename(&staging, root).map_err(|e| {
698 let _ = std::fs::remove_dir_all(&staging);
699 io(root.display().to_string(), e)
700 })?;
701 Ok(())
702}
703
704#[allow(dead_code)]
705fn write_one_into_staging(staging: &Path, file: &RestoredFile) -> Result<(), AdapterError> {
706 for component in file.relative_path.components() {
708 use std::path::Component;
709 let segment = match component {
710 Component::Normal(s) => s,
711 Component::CurDir => continue,
712 _ => {
715 return Err(AdapterError::schema(
716 "restore",
717 file.relative_path.display().to_string(),
718 "relative_path component is not a normal name",
719 ));
720 }
721 };
722 let Some(text) = segment.to_str() else {
723 return Err(AdapterError::schema(
724 "restore",
725 file.relative_path.display().to_string(),
726 "relative_path segment is not UTF-8",
727 ));
728 };
729 validate_path_id(
730 "restore",
731 "relative_path segment",
732 text,
733 file.relative_path.display().to_string(),
734 )?;
735 }
736
737 let dest = staging.join(&file.relative_path);
738 if !dest.starts_with(staging) {
741 return Err(AdapterError::schema(
742 "restore",
743 file.relative_path.display().to_string(),
744 "relative_path escaped the restore root after join",
745 ));
746 }
747 let io =
748 |location: String, source: std::io::Error| AdapterError::io("restore", location, source);
749 if let Some(parent) = dest.parent() {
750 std::fs::create_dir_all(parent).map_err(|e| io(parent.display().to_string(), e))?;
751 }
752 std::fs::write(&dest, &file.bytes).map_err(|e| io(dest.display().to_string(), e))?;
753 Ok(())
754}
755
756pub(crate) fn extracted_text(value: &Option<Extracted<String>>) -> &str {
757 value.as_deref().map(String::as_str).unwrap_or("")
758}
759
760pub(crate) fn by_timestamp_then_id(
763 left: &MessageWithParts,
764 right: &MessageWithParts,
765) -> std::cmp::Ordering {
766 left.message
767 .timestamp()
768 .cmp(&right.message.timestamp())
769 .then_with(|| left.message.id().cmp(right.message.id()))
770}
771
772#[inline]
775pub(crate) fn empty_options() -> ProviderOptions {
776 ProviderOptions::new()
777}
778
779#[cfg(test)]
780pub(crate) mod test_support {
781 use std::{
782 collections::BTreeSet,
783 path::{Path, PathBuf},
784 };
785
786 use serde_json::Value;
787 use tempfile::TempDir;
788
789 use super::{Adapter, AdapterFactory, Env, NoopOracle, RestoreFidelity, SkipOracle};
790 use crate::{handlers::ingest_adapter, sessions::Store};
791
792 pub(crate) struct MaxWatermarkOracle;
794 impl SkipOracle for MaxWatermarkOracle {
795 fn session_max_ts(&self, _session_id: &str) -> Option<i64> {
796 Some(i64::MAX)
797 }
798 }
799
800 pub(crate) fn assert_probe_default(
806 factory: &dyn AdapterFactory,
807 expected_subpath: &[&str],
808 ) -> anyhow::Result<()> {
809 let temp = TempDir::new()?;
810 let mut expected = temp.path().to_path_buf();
811 for segment in expected_subpath {
812 expected.push(segment);
813 }
814 std::fs::create_dir_all(&expected)?;
815 let env = Env::with_home(temp.path());
816
817 let probe = factory.probe_default(&env);
818 let got = probe
819 .as_ref()
820 .and_then(|value| value.get("path"))
821 .and_then(Value::as_str);
822 anyhow::ensure!(
823 got == expected.to_str(),
824 "factory must probe its install path: got {got:?}, expected {expected:?}",
825 );
826
827 std::fs::remove_dir_all(&expected)?;
828 anyhow::ensure!(
829 factory.probe_default(&env).is_none(),
830 "probe_default must be None once the install path disappears",
831 );
832 Ok(())
833 }
834
835 pub(crate) async fn assert_native_restore(
836 factory: &dyn AdapterFactory,
837 adapter: &dyn Adapter,
838 source_root: &Path,
839 ) -> anyhow::Result<()> {
840 let temp = TempDir::new()?;
841 let store = Store::open_local(temp.path()).await?;
842 ingest_adapter(&store, adapter, &NoopOracle, |_| {}).await?;
843 let session_ids = store.session_ids().await?;
844 assert!(
845 !session_ids.is_empty(),
846 "native restore fixture must ingest at least one session",
847 );
848
849 let mut restored_paths = BTreeSet::new();
850 for session_id in session_ids {
851 let Some(session) = store.get_session(&session_id).await? else {
852 anyhow::bail!("session id listed by store was not readable: {session_id}");
853 };
854 let restored = factory.serialize(&session, RestoreFidelity::Native)?;
855 for file in restored {
856 let expected = source_root.join(&file.relative_path);
857 let expected_bytes = std::fs::read(&expected)
858 .map_err(|err| anyhow::anyhow!("read {}: {err}", expected.display()))?;
859 assert_json_file_equal(&expected, &expected_bytes, &file.bytes)?;
860 restored_paths.insert(file.relative_path);
861 }
862 }
863 assert_eq!(
864 restored_paths,
865 source_json_files(source_root)?,
866 "native restore must emit exactly the source JSON/JSONL file set",
867 );
868 Ok(())
869 }
870
871 fn source_json_files(root: &Path) -> anyhow::Result<BTreeSet<PathBuf>> {
872 let mut out = BTreeSet::new();
873 collect_source_json_files(root, root, &mut out)?;
874 Ok(out)
875 }
876
877 fn collect_source_json_files(
878 root: &Path,
879 dir: &Path,
880 out: &mut BTreeSet<PathBuf>,
881 ) -> anyhow::Result<()> {
882 for entry in std::fs::read_dir(dir)? {
883 let entry = entry?;
884 let path = entry.path();
885 if entry.file_type()?.is_dir() {
886 collect_source_json_files(root, &path, out)?;
887 continue;
888 }
889 if let Some("json" | "jsonl") = path.extension().and_then(|ext| ext.to_str()) {
890 out.insert(path.strip_prefix(root)?.to_path_buf());
891 }
892 }
893 Ok(())
894 }
895
896 fn assert_json_file_equal(path: &Path, expected: &[u8], actual: &[u8]) -> anyhow::Result<()> {
897 if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") {
898 let expected_lines = json_lines(expected)?;
899 let actual_lines = json_lines(actual)?;
900 assert_eq!(
901 actual_lines,
902 expected_lines,
903 "jsonl mismatch at {}",
904 path.display()
905 );
906 } else {
907 let expected_value: serde_json::Value = serde_json::from_slice(expected)?;
908 let actual_value: serde_json::Value = serde_json::from_slice(actual)?;
909 assert_eq!(
910 actual_value,
911 expected_value,
912 "json mismatch at {}",
913 path.display()
914 );
915 }
916 Ok(())
917 }
918
919 fn json_lines(bytes: &[u8]) -> anyhow::Result<Vec<serde_json::Value>> {
920 let text = std::str::from_utf8(bytes)?;
921 text.lines()
922 .filter(|line| !line.trim().is_empty())
923 .map(|line| serde_json::from_str(line).map_err(Into::into))
924 .collect()
925 }
926}