Skip to main content

pond/adapter/
mod.rs

1//! Source-adapter seam.
2//!
3//! Pond ingests sessions from many runtimes. The seam splits in two:
4//!
5//! - [`AdapterFactory`] is the stateless face every format publishes once,
6//!   collected by [`registry`]. It knows how to construct configured adapters
7//!   from an opaque JSON config blob ([`AdapterFactory::open`]) and how to
8//!   probe the user's environment for a default config
9//!   ([`AdapterFactory::probe_default`]).
10//! - [`Adapter`] is the live, configured instance. Its only job is
11//!   [`Adapter::events`]: stream canonical [`IngestEvent`]s in append-only
12//!   order per session. The "source" is opaque to the seam - a directory
13//!   tree, an HTTP endpoint, a database, an archive file.
14//!
15//! Concrete implementations live in `adapter/<format>.rs` and are tied
16//! together by [`registry`]. A new adapter is one file plus one line in the
17//! registry; no central dispatch table to edit.
18
19use 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
63/// Stateless face of an adapter type: how the registry knows about it without
64/// instantiating it. One implementation per known format, registered in
65/// [`registry`].
66pub trait AdapterFactory: Send + Sync {
67    /// Stable short name. Used as the `[adapters.<name>]` config key, the
68    /// `pond sync <name>` positional arg, and the `Session.source_agent`
69    /// value emitted by the corresponding adapter.
70    fn name(&self) -> &'static str;
71
72    /// Open a configured adapter from a JSON-shaped config blob. The shape is
73    /// owned by each factory: filesystem adapters expect `{ "path": "..." }`,
74    /// API-backed adapters expect `{ "endpoint": "...", "auth_token": "..." }`,
75    /// etc. The seam doesn't know or care. A factory rejects a bad blob with
76    /// [`AdapterErrorKind::Config`].
77    fn open(&self, config: Value) -> Result<Box<dyn Adapter>, AdapterError>;
78
79    /// Probe the user's environment for a default config. Returns the JSON
80    /// blob that would go into `[adapters.<name>]` if the picker writes it
81    /// back. Filesystem adapters check their canonical install path under
82    /// `env.home`; adapters with no auto-discovery rule (e.g. API adapters
83    /// that need explicit creds) return `None`.
84    fn probe_default(&self, env: &Env) -> Option<Value>;
85
86    /// Restore one canonical session into this adapter's native file layout.
87    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    /// Fidelity actually served when this file was produced. Equal to the
105    /// requested fidelity unless the adapter had to downgrade (e.g. caller
106    /// asked `Native` but the session lacks a stored `raw_record`, so the
107    /// adapter served `Foreign`). spec.md#adapter-native-restore-lossless:
108    /// native may be impossible on older logs; the signal lets the CLI warn
109    /// rather than silently degrade.
110    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
127/// Live, configured adapter instance. Holds whatever handle the source needs
128/// (an open directory root, an HTTP client + auth, a database connection)
129/// for the lifetime of its event stream.
130pub trait Adapter: Send + Sync {
131    /// Stream every canonical event for every session this adapter knows
132    /// about, in append-only order per session. The stream borrows `self`
133    /// so callers can pass `&adapter` or hold a `Box<dyn Adapter>` and
134    /// invoke this through `as_ref()`.
135    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    /// Count how many sessions [`Self::events`] will produce, used by the
145    /// CLI bar to set its length up front. A filesystem adapter walks its
146    /// root and counts `.jsonl` files; an API adapter calls its list
147    /// endpoint. Cheap and best-effort: errors here only mean we run with
148    /// an unknown total (the bar still ticks per session), so callers
149    /// fall back to a rolling counter rather than failing the sync.
150    fn discover(&self) -> DiscoverFuture<'_>;
151
152    /// Stream events with a [`SkipOracle`] the adapter MAY consult to
153    /// short-circuit per-session re-decoding (spec.md#adapter-integrity-event-ordering). Default impl
154    /// ignores the oracle.
155    fn events_with<'a>(&'a self, oracle: &'a dyn SkipOracle) -> AdapterYieldStream<'a>;
156
157    /// Cheap sync preview: classify every discovered source as fresh vs
158    /// pending against the oracle's watermarks WITHOUT decoding bodies (the
159    /// same gate [`Self::events_with`] applies before its expensive read).
160    /// Powers `pond sync --dry-run` and the `pond status` pending count, so it
161    /// must stay bounded-read cheap. `Ok(None)` (the default) means this
162    /// adapter has no gate cheaper than a full read and callers report the
163    /// pending count as unknown rather than paying for it.
164    fn plan<'a>(&'a self, _oracle: &'a dyn SkipOracle) -> PlanFuture<'a> {
165        Box::pin(async { Ok(None) })
166    }
167}
168
169/// What the next `pond sync` would do for one adapter, computed from the
170/// freshness gate alone: `pending` sessions get read; `fresh` ones are skipped
171/// outright - including sessions whose source provably holds nothing ingestible
172/// right now ([`SourceWatermark::Empty`]), because "nothing to sync" IS up to
173/// date. The unit is the SESSION, not the file: a source may hold many (an
174/// export archive) and the gate enumerates and counts what pond stores.
175#[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    /// The one classifier: fold session heads through [`source_in_sync`].
184    /// Callers with an empty oracle must not use this - there is nothing to
185    /// compare against, so a first sync's plan is [`SyncPlan::all_pending`]
186    /// without paying for peeks.
187    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    /// A first-sync plan: no oracle entries means every session will be read.
204    pub fn all_pending(sessions: usize) -> Self {
205        Self {
206            sessions,
207            pending: sessions,
208            ..Self::default()
209        }
210    }
211}
212
213/// Source-side verdict of a freshness peek.
214///
215/// `Empty` MUST be claimed only on PROOF that the source currently holds
216/// nothing ingestible (a zero-byte file; a whole-source inspection finding no
217/// ingestible record). The proof is re-derived from current source content on
218/// every run - never a cached marker - so the moment the source gains real
219/// content the peek stops saying `Empty` and the source re-reads
220/// (spec.md#session-movement-complete). Without `Empty`, a permanently
221/// content-free source can never earn a stored watermark and reports a store
222/// that syncs clean as forever out of date.
223///
224/// `Opaque` is the safe default for anything undeterminable cheaply (an
225/// oversized record, a tail window smaller than the file): the source counts
226/// pending and re-reads.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum SourceWatermark {
229    /// Latest ingestible-content timestamp (micros) found in the source.
230    At(i64),
231    /// Provably nothing to ingest right now.
232    Empty,
233    /// Could not determine cheaply - re-read to be safe.
234    Opaque,
235}
236
237/// Gate verdict for one source: skip (in sync) or re-read. `Empty` sources are
238/// in sync by definition; a watermark compares via [`is_session_fresh`], which
239/// needs a session id to look up the stored side; anything else re-reads.
240pub 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
254/// Boxed future for [`Adapter::plan`], mirroring [`DiscoverFuture`].
255pub type PlanFuture<'a> = std::pin::Pin<
256    Box<dyn std::future::Future<Output = Result<Option<SyncPlan>, AdapterError>> + Send + 'a>,
257>;
258
259/// Store-side freshness watermark: the max message timestamp (micros) pond
260/// already holds for a session. Backed by the resident row-meta map (zero S3 -
261/// see [`crate::rowmap`]), which is itself rebuilt from the store, so the check
262/// is deterministic with no local cursor to desync. `None` means pond has never
263/// seen the session, or the resident map is behind the store - either way the
264/// caller re-reads.
265///
266/// The skip is sound because pond and every source are append-only: a session's
267/// max message timestamp only advances as it gains messages. The one residual is
268/// two messages sharing the exact micros across a sync boundary (negligible at
269/// sub-second precision, self-healing once any newer message arrives); `pond sync
270/// --verify` (which passes [`NoopOracle`]) is the full-re-read backstop.
271pub trait SkipOracle: Send + Sync {
272    fn session_max_ts(&self, session_id: &str) -> Option<i64>;
273
274    /// Fast-path hint: the oracle has no entries at all (first ingest or
275    /// `NoopOracle`). Lets adapters skip the per-session work needed to read the
276    /// source's latest message timestamp. Defaults to `false`.
277    fn is_empty(&self) -> bool {
278        false
279    }
280}
281
282/// Seam decision rule - the only place the freshness comparison lives. A session
283/// is fresh (skip the re-decode) iff the source's latest message timestamp is no
284/// newer than pond's stored watermark. A missing signal on either side is never
285/// fresh.
286pub 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/// `SkipOracle` that always returns `None`. Used by `--verify`, tests, and
298/// benches that want every source re-read.
299#[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        /// `None` for files that never yield a session id (empty `.jsonl`).
317        session_id: Option<String>,
318        project: Option<String>,
319        reason: SkipReason,
320    },
321    /// Aggregate skip; one yield per N files (typically `Fresh` recurring
322    /// sync) instead of N. Avoids O(N) per-session orchestrator overhead.
323    SkippedBatch {
324        reason: SkipReason,
325        count: usize,
326    },
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub enum SkipReason {
331    Fresh,
332    /// File produced no importable session (empty `.jsonl`, sidecar-only rows,
333    /// or an unextractable header). Benign: counted, never an error or a
334    /// per-event drop. The underlying cause is logged at `-vv` (debug) verbosity.
335    Empty,
336    /// File is structurally a known sidecar whose specific shape this adapter
337    /// version can't ingest. Surfaced as a visible, counted failure - NOT a
338    /// benign skip - so the gap is actionable and the file is never folded into
339    /// another session under a borrowed id. The payload is the user-facing
340    /// reason naming the file and the fix.
341    Unsupported(String),
342    /// Session present in more than one source form; this copy is superseded by
343    /// an authoritative copy the same run ingests (e.g. opencode's legacy tree
344    /// copy of a DB-resident session). Content identity is not verified -
345    /// supersession is by session id (the source's documented migration
346    /// contract). Visible and counted, never folded into `Empty`.
347    Superseded,
348}
349
350pub type AdapterYieldStream<'a> =
351    std::pin::Pin<Box<dyn Stream<Item = Result<AdapterYield, AdapterError>> + Send + 'a>>;
352
353/// Boxed future returning the number of sessions an adapter will emit. The
354/// shape mirrors [`EventStream`] - one alias per async trait method so the
355/// trait stays `dyn`-compatible without per-adapter associated types.
356pub type DiscoverFuture<'a> =
357    std::pin::Pin<Box<dyn std::future::Future<Output = Result<usize, AdapterError>> + Send + 'a>>;
358
359/// Environment slice handed to [`AdapterFactory::probe_default`]. Kept
360/// deliberately small - just `home`, because env-var lookups for API creds
361/// are unreliable and most adapters with API backends should require
362/// explicit config rather than opportunistic env reads.
363pub struct Env {
364    pub home: PathBuf,
365}
366
367impl Env {
368    /// Read `home` from the `HOME` env var. Returns `None` when `HOME` is
369    /// unset (CI, post-install hooks, sandboxed runs).
370    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    /// Construct an `Env` with an explicit home. Tests use this to inject a
378    /// `TempDir`-backed home without touching the process env.
379    pub fn with_home(home: impl Into<PathBuf>) -> Self {
380        Self { home: home.into() }
381    }
382}
383
384/// Boxed, `Send`-only stream of [`IngestEvent`]s with one shared error type.
385/// The lifetime parameter lets future adapters borrow from their config; for
386/// `self: Box<Self>` impls the lifetime collapses to `'static`.
387pub type EventStream<'a> =
388    std::pin::Pin<Box<dyn Stream<Item = Result<IngestEvent, AdapterError>> + Send + 'a>>;
389
390/// One error type for every adapter. Each call site tags the error with the
391/// adapter's name (so multi-adapter syncs can attribute failures) and a
392/// `location` string the operator can act on (file path, URL, line number,
393/// config key, ...). The `kind` carries the underlying class.
394#[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    /// Filesystem / network IO at `location`.
404    Io(std::io::Error),
405    /// JSON parse error at line `line` inside `location`.
406    Parse {
407        line: usize,
408        source: serde_json::Error,
409    },
410    /// Format-specific shape error: missing required field, unknown role,
411    /// unsupported record type. The `String` is operator-facing.
412    Schema(String),
413    /// `AdapterFactory::open` rejected its config blob.
414    Config(String),
415    /// HTTP / RPC / timeout error from an API-backed adapter.
416    Transport(String),
417    /// Auth failure from an API-backed adapter (bad token, expired creds).
418    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
511/// The static, ordered registry of every adapter pond knows. A new adapter
512/// adds one `&Factory` here plus one file under `src/adapter/`. Order is the
513/// order discovery presents to the operator.
514pub 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
528/// Look up a factory by name. Returns `None` for unknown names; callers
529/// usually wrap that in a clear error using [`known_names`].
530pub fn by_name(name: &str) -> Option<&'static dyn AdapterFactory> {
531    registry().iter().copied().find(|f| f.name() == name)
532}
533
534/// The names of every registered adapter. Drives error messages
535/// ("unknown adapter X; known: ...") and the discovery picker labels.
536pub fn known_names() -> Vec<&'static str> {
537    registry().iter().map(|f| f.name()).collect()
538}
539
540/// Probe every adapter for a default config under `env.home`. Returns
541/// `(name, default_config)` pairs in registry order, skipping adapters whose
542/// `probe_default` returned `None`. The picker shows these to the operator.
543pub 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
550/// Stable Part-row id: `"{message_id}:{ordinal:04}"`. Both JSONL adapters use
551/// this shape so the cross-adapter id space stays predictable.
552pub(crate) fn part_id(message_id: &str, ordinal: usize) -> String {
553    format!("{message_id}:{ordinal:04}")
554}
555
556/// Compact (no-whitespace) JSON serialization used as a fallback Part body
557/// when a row carries something we don't have a richer canonical shape for.
558pub(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
577/// Shared `AdapterFactory::open` plumbing: parse the config blob's `path` and
578/// expand a leading `~` against `$HOME` once, not per path adapter.
579pub(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
600/// Standard `options.source = {adapter, raw_record}` shape used by every
601/// adapter that captures its source record for native restore. Centralized so
602/// the writer side of the raw-record convention lives next to the reader
603/// ([`raw_record`]); per-adapter side-fields (e.g. claude-code's `cwd`,
604/// codex-cli's `git`) extend this map after construction.
605pub(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/// `Part.ordinal` is stored as `i32`; ingest counts as `usize`. A session
618/// could in principle exceed `i32::MAX` parts, in which case we clamp rather
619/// than drop the record.
620#[inline]
621pub(crate) fn part_ordinal(ordinal: usize) -> i32 {
622    i32::try_from(ordinal).unwrap_or(i32::MAX)
623}
624
625/// Reject `/`, `\`, `..`, and absolute paths in any segment that will become
626/// part of a filesystem path during restore. Centralizing it here keeps every
627/// adapter's restore-write path on the same allowlist; the writer
628/// ([`write_restored_files`]) re-applies it as a defense-in-depth check on
629/// every segment regardless of which adapter built the `RestoredFile`.
630pub(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/// Atomically write a batch of `RestoredFile`s under `root`. Every path
652/// segment is re-validated and the joined path is required to stay inside
653/// `root` (spec.md#adapter-native-restore-lossless: restore writes are
654/// adapter-supplied, but the gate lives at the writer so a single audit
655/// covers every adapter today and tomorrow). On partial failure the
656/// half-written tree is discarded before the error is returned.
657///
658/// Currently exercised only by adapter tests; the production restore CLI
659/// will route through this same helper when it lands.
660#[allow(dead_code)]
661pub(crate) fn write_restored_files(
662    root: &Path,
663    files: Vec<RestoredFile>,
664) -> Result<(), AdapterError> {
665    // Stage under a sibling temp dir and atomically rename so a partial
666    // failure cannot leave a half-populated restore in place.
667    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    // Replace any existing restore root; the staging dir becomes the new root.
691    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    // Validate every segment of the supplied relative path.
707    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            // Absolute prefixes, root, and `..` are categorically rejected -
713            // a restored file's relative_path is by contract relative + safe.
714            _ => {
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    // Defense-in-depth: confirm the joined path is still inside the staging
739    // dir even if every individual segment passed the syntactic check.
740    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
760/// Deterministic message ordering for restore: timestamp, then id as a
761/// tiebreaker so equal-timestamp messages always serialize in a stable order.
762pub(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/// `ProviderOptions::new()` shortcut; both adapters reach for an empty
773/// options map often enough that naming the no-op clarifies the call sites.
774#[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    /// Oracle that makes every session gate as fresh.
793    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    /// Shared probe_default contract: when the adapter's expected install
801    /// subpath exists under an injected `HOME`, `probe_default` returns it;
802    /// when the path is removed, it returns `None`. Each adapter owns its
803    /// `probe_default_*` test (per the seam-boundaries rule) but the shape
804    /// is the same, so the helper takes the factory + its expected subpath.
805    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}