use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
High,
Medium,
Low,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DetectedSource {
pub adapter: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
pub location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local_path: Option<PathBuf>,
pub confidence: Confidence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimated_records: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl DetectedSource {
pub fn local(
adapter: impl Into<String>,
path: impl Into<PathBuf>,
confidence: Confidence,
) -> Self {
let path = path.into();
Self {
adapter: adapter.into(),
instance: None,
location: path.display().to_string(),
local_path: Some(path),
confidence,
estimated_records: None,
note: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DetectOpts {
pub home_override: Option<PathBuf>,
pub extra_paths: Vec<PathBuf>,
}
#[async_trait]
pub trait SourceDetector: Send + Sync {
fn adapter_id(&self) -> &'static str;
async fn detect(&self, opts: &DetectOpts) -> Result<Vec<DetectedSource>>;
}
pub struct Discovery {
detectors: Vec<Box<dyn SourceDetector>>,
}
impl Discovery {
pub fn new() -> Self {
Self {
detectors: Vec::new(),
}
}
pub fn register(mut self, detector: Box<dyn SourceDetector>) -> Self {
self.detectors.push(detector);
self
}
pub fn len(&self) -> usize {
self.detectors.len()
}
pub fn is_empty(&self) -> bool {
self.detectors.is_empty()
}
pub async fn detect_all(&self, opts: &DetectOpts) -> Vec<DetectedSource> {
let mut out = Vec::new();
for d in &self.detectors {
match d.detect(opts).await {
Ok(found) => out.extend(found),
Err(e) => {
tracing::warn!(
adapter = d.adapter_id(),
error = %e,
"detector failed; skipping its results"
);
}
}
}
out
}
pub async fn detect_strict(&self, opts: &DetectOpts) -> Result<Vec<DetectedSource>> {
let mut out = Vec::new();
for d in &self.detectors {
out.extend(d.detect(opts).await?);
}
Ok(out)
}
}
impl Default for Discovery {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[test]
fn local_constructor_fills_location() {
let d = DetectedSource::local("claude-code", "/tmp/x", Confidence::High);
assert_eq!(d.adapter, "claude-code");
assert_eq!(d.location, "/tmp/x");
assert_eq!(
d.local_path.as_deref(),
Some(std::path::Path::new("/tmp/x"))
);
assert_eq!(d.confidence, Confidence::High);
}
#[test]
fn detected_source_roundtrips_through_json() {
let d = DetectedSource {
adapter: "mem0".into(),
instance: Some("self-hosted".into()),
location: "/Users/x/.mem0/db.sqlite".into(),
local_path: Some("/Users/x/.mem0/db.sqlite".into()),
confidence: Confidence::High,
estimated_records: Some(1234),
note: Some("schema v3".into()),
};
let s = serde_json::to_string(&d).unwrap();
let back: DetectedSource = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
#[test]
fn confidence_serializes_lowercase() {
let s = serde_json::to_string(&Confidence::Medium).unwrap();
assert_eq!(s, "\"medium\"");
}
#[test]
fn optional_fields_omitted_when_none() {
let d = DetectedSource::local("claude-code", "/tmp/x", Confidence::Low);
let s = serde_json::to_string(&d).unwrap();
assert!(!s.contains("instance"));
assert!(!s.contains("estimated_records"));
assert!(!s.contains("note"));
}
struct FakeDetector {
id: &'static str,
result: std::sync::Mutex<Vec<DetectedSource>>,
always_err: bool,
}
impl FakeDetector {
fn ok(id: &'static str, found: Vec<DetectedSource>) -> Self {
Self {
id,
result: std::sync::Mutex::new(found),
always_err: false,
}
}
fn failing(id: &'static str) -> Self {
Self {
id,
result: std::sync::Mutex::new(Vec::new()),
always_err: true,
}
}
}
#[async_trait]
impl SourceDetector for FakeDetector {
fn adapter_id(&self) -> &'static str {
self.id
}
async fn detect(&self, _opts: &DetectOpts) -> Result<Vec<DetectedSource>> {
if self.always_err {
return Err(Error::Other(format!("{} broken", self.id)));
}
Ok(self.result.lock().unwrap().clone())
}
}
#[tokio::test]
async fn discovery_runs_every_detector_in_registration_order() {
let a = DetectedSource::local("a-adapter", "/tmp/a", Confidence::High);
let b = DetectedSource::local("b-adapter", "/tmp/b", Confidence::Medium);
let d = Discovery::new()
.register(Box::new(FakeDetector::ok("a-adapter", vec![a.clone()])))
.register(Box::new(FakeDetector::ok("b-adapter", vec![b.clone()])));
assert_eq!(d.len(), 2);
let opts = DetectOpts::default();
let found = d.detect_all(&opts).await;
assert_eq!(found, vec![a, b]);
}
#[tokio::test]
async fn detect_all_skips_failing_detectors_but_keeps_others() {
let a = DetectedSource::local("a-adapter", "/tmp/a", Confidence::High);
let d = Discovery::new()
.register(Box::new(FakeDetector::failing("broken")))
.register(Box::new(FakeDetector::ok("a-adapter", vec![a.clone()])));
let found = d.detect_all(&DetectOpts::default()).await;
assert_eq!(found, vec![a]);
}
#[tokio::test]
async fn detect_strict_propagates_first_error() {
let d = Discovery::new()
.register(Box::new(FakeDetector::failing("broken")))
.register(Box::new(FakeDetector::ok("ok", vec![])));
let err = d.detect_strict(&DetectOpts::default()).await.unwrap_err();
assert!(format!("{err}").contains("broken"));
}
#[tokio::test]
async fn empty_discovery_returns_empty() {
let d = Discovery::new();
assert!(d.is_empty());
let found = d.detect_all(&DetectOpts::default()).await;
assert!(found.is_empty());
}
#[test]
fn detect_opts_default_is_clean() {
let o = DetectOpts::default();
assert!(o.home_override.is_none());
assert!(o.extra_paths.is_empty());
}
}