Skip to main content

oxios_memory/memory/
auto_bridge.rs

1//! Auto-memory bridge: synchronization between external memory systems and Oxios.
2//!
3//! Bridges Oxios MemoryManager with Claude Code's MEMORY.md format and
4//! similar external memory stores. Supports bidirectional sync:
5//!
6//! - **to-auto**: Export Oxios patterns/insights → external MEMORY.md format
7//! - **from-auto**: Import external memories → Oxios MemoryStore
8//! - **bidirectional**: Full two-way synchronization
9//!
10//! The bridge converts between Oxios's structured memory entries and
11//! free-form markdown memory formats used by external tools.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::Result;
17use chrono::Utc;
18use serde::{Deserialize, Serialize};
19
20use crate::memory::manager::MemoryManager;
21use crate::memory::types::{MemoryEntry, MemoryType};
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27/// Direction of memory synchronization.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum SyncDirection {
31    /// Export Oxios → external format.
32    ToAuto,
33    /// Import external → Oxios.
34    FromAuto,
35    /// Two-way sync.
36    Bidirectional,
37}
38
39/// Category of an imported memory insight.
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum InsightCategory {
43    /// Project-level patterns and conventions.
44    ProjectPatterns,
45    /// Debugging strategies and fixes.
46    Debugging,
47    /// Architecture decisions.
48    Architecture,
49    /// Performance observations.
50    Performance,
51    /// Security-related insights.
52    Security,
53    /// General knowledge.
54    General,
55}
56
57impl InsightCategory {
58    /// Parse from a string.
59    pub fn from_str_loose(s: &str) -> Self {
60        match s.to_lowercase().as_str() {
61            "project-patterns" | "project_patterns" | "patterns" => {
62                InsightCategory::ProjectPatterns
63            }
64            "debugging" | "debug" => InsightCategory::Debugging,
65            "architecture" | "arch" => InsightCategory::Architecture,
66            "performance" | "perf" => InsightCategory::Performance,
67            "security" | "sec" => InsightCategory::Security,
68            _ => InsightCategory::General,
69        }
70    }
71
72    /// Convert to a tag string.
73    pub fn to_tag(&self) -> &'static str {
74        match self {
75            InsightCategory::ProjectPatterns => "project-patterns",
76            InsightCategory::Debugging => "debugging",
77            InsightCategory::Architecture => "architecture",
78            InsightCategory::Performance => "performance",
79            InsightCategory::Security => "security",
80            InsightCategory::General => "general",
81        }
82    }
83}
84
85/// A single imported memory insight from an external system.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct MemoryInsight {
88    /// Category of the insight.
89    pub category: InsightCategory,
90    /// Brief summary (1 line).
91    pub summary: String,
92    /// Optional detailed content.
93    pub detail: Option<String>,
94    /// Source identifier (e.g., "claude-code", "user").
95    pub source: String,
96    /// Confidence score (0.0 - 1.0).
97    pub confidence: f32,
98}
99
100/// Result of importing memories from an external system.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct ImportResult {
103    /// Number of insights successfully imported.
104    pub imported: usize,
105    /// Number of duplicates skipped.
106    pub skipped_duplicates: usize,
107    /// Number of failed imports.
108    pub failed: usize,
109    /// Error messages for failed imports.
110    pub errors: Vec<String>,
111}
112
113/// Result of exporting memories to an external system.
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct ExportResult {
116    /// Number of patterns exported.
117    pub exported: usize,
118    /// Number of categories created/updated.
119    pub categories_updated: usize,
120}
121
122/// Result of a bidirectional sync operation.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct SyncResult {
125    /// Import results.
126    pub import: ImportResult,
127    /// Export results.
128    pub export: ExportResult,
129}
130
131/// A guidance pattern for export to external format.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct GuidancePattern {
134    /// Pattern identifier.
135    pub id: String,
136    /// Category.
137    pub category: InsightCategory,
138    /// Brief description.
139    pub description: String,
140    /// Confidence/importance.
141    pub confidence: f32,
142    /// Usage count.
143    pub usage_count: u32,
144}
145
146// ---------------------------------------------------------------------------
147// AutoMemoryBridge
148// ---------------------------------------------------------------------------
149
150/// Bridge between Oxios memory and external memory systems.
151///
152/// Bridges Oxios MemoryManager (agent session memory) and optional
153/// KnowledgeBase (markdown knowledge base) with external MEMORY.md
154/// files. Supports bidirectional sync with Claude Code and similar
155/// external tools.
156///
157/// **RFC-003: KnowledgeBase is the single source of truth for markdown.
158/// MemoryManager stores agent session memory. The bridge can sync from
159/// either source or both.**
160pub struct AutoMemoryBridge {
161    /// Directory containing external memory files.
162    auto_memory_dir: PathBuf,
163    /// Oxios memory manager (agent session memory).
164    oxios_memory: std::sync::Arc<MemoryManager>,
165    /// Optional markdown knowledge base (global, not per-Space).
166    knowledge_base: Option<std::sync::Arc<dyn crate::memory::storage::MarkdownSource>>,
167}
168
169impl AutoMemoryBridge {
170    /// Create a new bridge.
171    ///
172    /// # Arguments
173    /// * `auto_memory_dir` - Path to directory containing MEMORY.md files
174    /// * `oxios_memory` - Oxios MemoryManager instance
175    pub fn new(auto_memory_dir: PathBuf, oxios_memory: std::sync::Arc<MemoryManager>) -> Self {
176        Self {
177            auto_memory_dir,
178            oxios_memory,
179            knowledge_base: None,
180        }
181    }
182
183    /// Set the optional markdown knowledge base.
184    ///
185    /// When set, `export_knowledge_to_auto()` can read directly from
186    /// `.md` files instead of relying on MemoryManager entries.
187    pub fn with_knowledge_base(
188        mut self,
189        kb: std::sync::Arc<dyn crate::memory::storage::MarkdownSource>,
190    ) -> Self {
191        self.knowledge_base = Some(kb);
192        self
193    }
194
195    /// Import memories from external format into Oxios.
196    ///
197    /// Reads MEMORY.md files from the auto_memory_dir, parses them into
198    /// structured insights, and stores them via MemoryManager.
199    pub async fn import_from_auto(&self) -> Result<ImportResult> {
200        let mut result = ImportResult::default();
201
202        // Find all MEMORY.md files
203        let memory_files = self.find_memory_files()?;
204
205        for file_path in &memory_files {
206            match self.import_file(file_path).await {
207                Ok(file_result) => {
208                    result.imported += file_result.imported;
209                    result.skipped_duplicates += file_result.skipped_duplicates;
210                    result.failed += file_result.failed;
211                    result.errors.extend(file_result.errors);
212                }
213                Err(e) => {
214                    result.failed += 1;
215                    result
216                        .errors
217                        .push(format!("{}: {}", file_path.display(), e));
218                }
219            }
220        }
221
222        Ok(result)
223    }
224
225    /// Export Oxios patterns to external MEMORY.md format.
226    ///
227    /// Reads patterns from Oxios memory and writes them as structured
228    /// markdown files in the auto_memory_dir.
229    pub async fn export_to_auto(&self, patterns: &[GuidancePattern]) -> Result<ExportResult> {
230        let mut result = ExportResult::default();
231
232        // Ensure output directory exists
233        tokio::fs::create_dir_all(&self.auto_memory_dir).await?;
234
235        // Group patterns by category
236        let mut by_category: HashMap<InsightCategory, Vec<&GuidancePattern>> = HashMap::new();
237        for pattern in patterns {
238            by_category
239                .entry(pattern.category.clone())
240                .or_default()
241                .push(pattern);
242        }
243
244        // Write each category to its own file
245        for (category, cat_patterns) in &by_category {
246            let filename = match category {
247                InsightCategory::ProjectPatterns => "patterns.md",
248                InsightCategory::Debugging => "debugging.md",
249                InsightCategory::Architecture => "architecture.md",
250                InsightCategory::Performance => "performance.md",
251                InsightCategory::Security => "security.md",
252                InsightCategory::General => "general.md",
253            };
254
255            let content = self.format_patterns_md(cat_patterns);
256            let path = self.auto_memory_dir.join(filename);
257
258            tokio::fs::write(&path, &content).await?;
259            result.categories_updated += 1;
260        }
261
262        // Write main MEMORY.md with all patterns sorted by confidence
263        let mut all_patterns: Vec<&GuidancePattern> = patterns.iter().collect();
264        all_patterns.sort_by(|a, b| {
265            b.confidence
266                .partial_cmp(&a.confidence)
267                .unwrap_or(std::cmp::Ordering::Equal)
268        });
269
270        let main_content = self.format_main_md(&all_patterns);
271        let main_path = self.auto_memory_dir.join("MEMORY.md");
272        tokio::fs::write(&main_path, &main_content).await?;
273
274        result.exported = patterns.len();
275        result.categories_updated += 1; // MEMORY.md itself
276        Ok(result)
277    }
278
279    /// Perform a bidirectional sync.
280    pub async fn sync_session(&self, direction: SyncDirection) -> Result<SyncResult> {
281        let mut sync_result = SyncResult::default();
282
283        match direction {
284            SyncDirection::FromAuto => {
285                sync_result.import = self.import_from_auto().await?;
286            }
287            SyncDirection::ToAuto => {
288                sync_result.export = self.export_knowledge_to_auto().await?;
289            }
290            SyncDirection::Bidirectional => {
291                sync_result.import = self.import_from_auto().await?;
292                sync_result.export = self.export_knowledge_to_auto().await?;
293            }
294        }
295
296        Ok(sync_result)
297    }
298
299    /// Returns the auto-memory directory path.
300    pub fn auto_memory_dir(&self) -> &Path {
301        &self.auto_memory_dir
302    }
303
304    /// Sync learned patterns from SQLite to external MEMORY.md (Phase 7).
305    ///
306    /// Reads patterns from the SQLite `patterns` table and exports
307    /// them as structured markdown to the auto-memory directory.
308    #[cfg(feature = "sqlite-memory")]
309    pub async fn sync_sqlite_to_auto(
310        &self,
311        store: &crate::memory::sqlite::SqliteMemoryStore,
312    ) -> Result<ExportResult> {
313        let rows = store.load_patterns()?;
314        if rows.is_empty() {
315            return Ok(ExportResult::default());
316        }
317
318        let patterns: Vec<GuidancePattern> = rows
319            .iter()
320            .map(|r| GuidancePattern {
321                id: r.id.clone(),
322                category: r
323                    .domain
324                    .as_deref()
325                    .map(InsightCategory::from_str_loose)
326                    .unwrap_or(InsightCategory::General),
327                description: format!(
328                    "[{}] quality={:.2} uses={}",
329                    r.strategy, r.quality, r.use_count
330                ),
331                confidence: r.quality,
332                usage_count: r.use_count,
333            })
334            .collect();
335
336        self.export_to_auto(&patterns).await
337    }
338
339    /// Import external MEMORY.md insights into SQLite patterns (Phase 7).
340    ///
341    /// Reads from auto-memory directory and stores as patterns.
342    #[cfg(feature = "sqlite-memory")]
343    pub async fn sync_auto_to_sqlite(
344        &self,
345        store: &crate::memory::sqlite::SqliteMemoryStore,
346    ) -> Result<ImportResult> {
347        let import_result = self.import_from_auto().await?;
348
349        // Also store imported insights as patterns in SQLite
350        let memory_files = self.find_memory_files()?;
351        for file_path in &memory_files {
352            if let Ok(content) = tokio::fs::read_to_string(file_path).await {
353                let insights = self.parse_insights(&content);
354                for insight in insights {
355                    let id = uuid::Uuid::new_v4().to_string();
356                    let data = serde_json::to_string(&insight)?;
357                    let _ = store.save_pattern(
358                        &id,
359                        "imported",
360                        Some(insight.category.to_tag()),
361                        insight.confidence,
362                        &data,
363                    );
364                }
365            }
366        }
367
368        Ok(import_result)
369    }
370
371    /// Export all knowledge memories from Oxios to external format.
372    ///
373    /// Reads from MemoryManager (primary) or falls back to KnowledgeBase
374    /// `.md` files when `knowledge_base` is set and MemoryManager has no
375    /// `MemoryType::Knowledge` entries.
376    async fn export_knowledge_to_auto(&self) -> Result<ExportResult> {
377        // Try MemoryManager first (primary source)
378        let entries = self
379            .oxios_memory
380            .list(MemoryType::Knowledge, 1000)
381            .await
382            .unwrap_or_default();
383
384        if !entries.is_empty() {
385            let patterns: Vec<GuidancePattern> = entries
386                .iter()
387                .map(|e| GuidancePattern {
388                    id: e.id.clone(),
389                    category: e
390                        .tags
391                        .first()
392                        .map(|t| InsightCategory::from_str_loose(t))
393                        .unwrap_or(InsightCategory::General),
394                    description: e.content.clone(),
395                    confidence: e.importance,
396                    usage_count: e.access_count,
397                })
398                .collect();
399            return self.export_to_auto(&patterns).await;
400        }
401
402        // Fall back to KnowledgeBase .md files (RFC-003)
403        if let Some(kb) = &self.knowledge_base {
404            let entries = kb.index_all()?;
405            if entries > 0 {
406                let patterns = kb
407                    .note_tree("/")
408                    .unwrap_or_default()
409                    .into_iter()
410                    .filter(|e| !e.is_dir && e.name.ends_with(".md"))
411                    .map(|e| {
412                        let path = if e.parent_dir == "/" || e.parent_dir.is_empty() {
413                            e.name.clone()
414                        } else {
415                            format!("{}/{}", e.parent_dir, e.name)
416                        };
417                        let content = kb.note_read(&path).ok().flatten().unwrap_or_default();
418                        let headings = kb.extract_headings(&content);
419                        let tag = headings.first().map(|s| s.as_str()).unwrap_or("general");
420                        GuidancePattern {
421                            id: format!("note-{}", path.replace('/', "-").trim_end_matches(".md")),
422                            category: InsightCategory::from_str_loose(tag),
423                            description: content.chars().take(300).collect(),
424                            confidence: 0.6,
425                            usage_count: 0,
426                        }
427                    })
428                    .collect::<Vec<_>>();
429                if !patterns.is_empty() {
430                    return self.export_to_auto(&patterns).await;
431                }
432            }
433        }
434
435        // No entries from either source — export empty result
436        Ok(ExportResult::default())
437    }
438}
439
440// ---------------------------------------------------------------------------
441// Private helpers
442// ---------------------------------------------------------------------------
443
444impl AutoMemoryBridge {
445    /// Find all MEMORY.md files in the auto_memory_dir.
446    /// Parse insights from a markdown content string.
447    #[cfg(feature = "sqlite-memory")]
448    fn parse_insights(&self, content: &str) -> Vec<MemoryInsight> {
449        let mut insights = Vec::new();
450
451        for line in content.lines() {
452            let line = line.trim();
453            if line.is_empty() || line.starts_with('#') {
454                continue;
455            }
456            // Each bullet point is an insight
457            if line.starts_with("- ") || line.starts_with("* ") {
458                let text = line.trim_start_matches("- ").trim_start_matches("* ");
459                let category = if text.contains("debug") || text.contains("fix") {
460                    InsightCategory::Debugging
461                } else if text.contains("arch") || text.contains("design") {
462                    InsightCategory::Architecture
463                } else if text.contains("perf") || text.contains("speed") {
464                    InsightCategory::Performance
465                } else if text.contains("security") || text.contains("vuln") {
466                    InsightCategory::Security
467                } else if text.contains("pattern") || text.contains("convention") {
468                    InsightCategory::ProjectPatterns
469                } else {
470                    InsightCategory::General
471                };
472                insights.push(MemoryInsight {
473                    category,
474                    summary: text.to_string(),
475                    detail: None,
476                    source: "auto-bridge".to_string(),
477                    confidence: 0.7,
478                });
479            }
480        }
481
482        insights
483    }
484
485    fn find_memory_files(&self) -> Result<Vec<PathBuf>> {
486        let mut files = Vec::new();
487
488        if self.auto_memory_dir.exists() {
489            // Look for MEMORY.md directly
490            let main = self.auto_memory_dir.join("MEMORY.md");
491            if main.exists() {
492                files.push(main);
493            }
494
495            // Look for topic files
496            for topic in &[
497                "patterns.md",
498                "debugging.md",
499                "architecture.md",
500                "performance.md",
501                "security.md",
502                "general.md",
503            ] {
504                let path = self.auto_memory_dir.join(topic);
505                if path.exists() {
506                    files.push(path);
507                }
508            }
509
510            // Look for *.md files in subdirectories
511            if let Ok(entries) = std::fs::read_dir(&self.auto_memory_dir) {
512                for entry in entries.flatten() {
513                    let path = entry.path();
514                    if path.extension().is_some_and(|ext| ext == "md") {
515                        let name = path.file_name().unwrap_or_default().to_string_lossy();
516                        // Skip already-added files
517                        if ![
518                            "MEMORY.md",
519                            "patterns.md",
520                            "debugging.md",
521                            "architecture.md",
522                            "performance.md",
523                            "security.md",
524                            "general.md",
525                        ]
526                        .contains(&name.as_ref())
527                        {
528                            files.push(path);
529                        }
530                    }
531                }
532            }
533        }
534
535        Ok(files)
536    }
537
538    /// Import a single markdown file into Oxios memory.
539    async fn import_file(&self, path: &Path) -> Result<ImportResult> {
540        let content = tokio::fs::read_to_string(path).await?;
541        let insights = self.parse_markdown_insights(&content);
542        let mut result = ImportResult::default();
543
544        for insight in &insights {
545            // Check for duplicates
546            if self.oxios_memory.is_duplicate(&insight.summary).await {
547                result.skipped_duplicates += 1;
548                continue;
549            }
550
551            let entry = MemoryEntry {
552                id: format!(
553                    "auto-{}-{}",
554                    insight.source,
555                    chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
556                ),
557                memory_type: MemoryType::Knowledge,
558                tier: crate::memory::types::MemoryTier::Warm,
559                content: match &insight.detail {
560                    Some(d) => format!("{}\n\n{}", insight.summary, d),
561                    None => insight.summary.clone(),
562                },
563                content_hash: 0,
564                source: insight.source.clone(),
565                session_id: None,
566                tags: vec![insight.category.to_tag().to_string()],
567                importance: insight.confidence,
568                pinned: false,
569                protection: crate::memory::types::ProtectionLevel::None,
570                auto_classified: false,
571                session_appearances: 0,
572                user_corrected: false,
573                seen_in_sessions: vec![],
574                created_at: Utc::now(),
575                accessed_at: Utc::now(),
576                modified_at: Utc::now(),
577                access_count: 0,
578                decay_score: 1.0,
579                compaction_level: 0,
580                compacted_from: vec![],
581                related_ids: vec![],
582                contradicts: None,
583            };
584
585            match self.oxios_memory.remember(entry).await {
586                Ok(_) => result.imported += 1,
587                Err(e) => {
588                    result.failed += 1;
589                    result.errors.push(e.to_string());
590                    tracing::warn!(error = %e, "Failed to import memory insight");
591                }
592            }
593        }
594
595        Ok(result)
596    }
597
598    /// Parse insights from markdown content.
599    ///
600    /// Supports multiple formats:
601    /// - List items: `- **Category**: Description`
602    /// - Headers: `## Category` followed by bullet points
603    /// - Free-form text split into chunks
604    fn parse_markdown_insights(&self, content: &str) -> Vec<MemoryInsight> {
605        let mut insights = Vec::new();
606        let mut current_category = InsightCategory::General;
607
608        for line in content.lines() {
609            let trimmed = line.trim();
610
611            // Skip empty lines
612            if trimmed.is_empty() {
613                continue;
614            }
615
616            // Detect category headers: ## Category or # Category
617            if trimmed.starts_with('#') {
618                let header = trimmed.trim_start_matches('#').trim().to_lowercase();
619                current_category = InsightCategory::from_str_loose(&header);
620                continue;
621            }
622
623            // Parse bullet list items: - **Category**: Description
624            if trimmed.starts_with('-') || trimmed.starts_with('*') {
625                // Strip the leading list marker and whitespace, preserving ** bold markers
626                let item = if trimmed.starts_with('-') {
627                    trimmed.trim_start_matches('-').trim_start()
628                } else {
629                    trimmed.trim_start_matches('*').trim_start()
630                };
631
632                // Check for bold category prefix: **Category**:
633                if let Some(rest) = Self::extract_bold_category(item) {
634                    let (cat_name, description) = rest;
635                    let category = InsightCategory::from_str_loose(cat_name);
636
637                    // Split on colon for detail
638                    let (summary, detail) = if let Some(pos) = description.find(':') {
639                        let s = description[..pos].trim();
640                        let d = description[pos + 1..].trim();
641                        (
642                            s.to_string(),
643                            if d.is_empty() {
644                                None
645                            } else {
646                                Some(d.to_string())
647                            },
648                        )
649                    } else {
650                        (description.to_string(), None)
651                    };
652
653                    if !summary.is_empty() {
654                        insights.push(MemoryInsight {
655                            category,
656                            summary,
657                            detail,
658                            source: "auto-import".to_string(),
659                            confidence: 0.7,
660                        });
661                    }
662                } else if !item.is_empty() {
663                    // Plain bullet point
664                    insights.push(MemoryInsight {
665                        category: current_category.clone(),
666                        summary: item.to_string(),
667                        detail: None,
668                        source: "auto-import".to_string(),
669                        confidence: 0.6,
670                    });
671                }
672            }
673        }
674
675        // If no structured content found, treat the whole content as a single insight
676        if insights.is_empty() && !content.trim().is_empty() {
677            let summary: String = content
678                .lines()
679                .take(3)
680                .collect::<Vec<_>>()
681                .join(" ")
682                .chars()
683                .take(200)
684                .collect();
685
686            if !summary.trim().is_empty() {
687                insights.push(MemoryInsight {
688                    category: InsightCategory::General,
689                    summary,
690                    detail: Some(content.to_string()),
691                    source: "auto-import".to_string(),
692                    confidence: 0.5,
693                });
694            }
695        }
696
697        insights
698    }
699
700    /// Extract a bold category prefix from a markdown item.
701    ///
702    /// Returns Some((category, rest)) if the item starts with **Category**:
703    fn extract_bold_category(item: &str) -> Option<(&str, &str)> {
704        if !item.starts_with("**") {
705            return None;
706        }
707
708        let end = item[2..].find("**")?;
709        let category = &item[2..2 + end];
710        let rest = item[2 + end + 2..].trim_start_matches([' ', ':']);
711
712        Some((category, rest))
713    }
714
715    /// Format patterns as a category-specific markdown file.
716    fn format_patterns_md(&self, patterns: &[&GuidancePattern]) -> String {
717        let mut md = String::new();
718        md.push_str("# Memory Insights\n\n");
719
720        for pattern in patterns {
721            let confidence_bar = format_confidence_bar(pattern.confidence);
722            md.push_str(&format!(
723                "- **{}**: {} [{}]\n",
724                pattern.category.to_tag(),
725                pattern.description,
726                confidence_bar,
727            ));
728        }
729
730        md.push('\n');
731        md
732    }
733
734    /// Format all patterns as the main MEMORY.md file.
735    fn format_main_md(&self, patterns: &[&GuidancePattern]) -> String {
736        let mut md = String::new();
737        md.push_str("# Oxios Memory\n\n");
738        md.push_str(&format!(
739            "Auto-generated at {}\n\n",
740            Utc::now().to_rfc3339()
741        ));
742        md.push_str("## Insights\n\n");
743
744        for pattern in patterns {
745            let confidence_pct = (pattern.confidence * 100.0) as u8;
746            md.push_str(&format!(
747                "- **{}** [{}%]: {}\n",
748                pattern.category.to_tag(),
749                confidence_pct,
750                pattern.description,
751            ));
752        }
753
754        md.push('\n');
755        md
756    }
757}
758
759/// Format a confidence value as a visual bar.
760fn format_confidence_bar(confidence: f32) -> String {
761    let bars = (confidence * 5.0).round() as usize;
762    let bars = bars.min(5);
763    let filled: String = "█".repeat(bars);
764    let empty: String = "░".repeat(5 - bars);
765    format!("{}{} {:.0}%", filled, empty, confidence * 100.0)
766}
767
768// ---------------------------------------------------------------------------
769// Tests
770// ---------------------------------------------------------------------------
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use std::sync::Arc;
776
777    fn make_bridge(dir: &Path) -> AutoMemoryBridge {
778        let storage: Arc<dyn crate::memory::storage::MemoryStorage> =
779            Arc::new(crate::memory::test_support::InMemoryStorage::default());
780        let memory = Arc::new(MemoryManager::new(storage));
781        AutoMemoryBridge::new(dir.join("auto"), memory)
782    }
783
784    #[test]
785    fn test_parse_bold_category() {
786        let result =
787            AutoMemoryBridge::extract_bold_category("**Debugging**: Use trace-level logging");
788        assert!(result.is_some());
789        let (cat, rest) = result.unwrap();
790        assert_eq!(cat, "Debugging");
791        assert_eq!(rest, "Use trace-level logging");
792    }
793
794    #[test]
795    fn test_parse_bold_category_no_colon() {
796        let result = AutoMemoryBridge::extract_bold_category("**Security** important rule");
797        assert!(result.is_some());
798        let (cat, rest) = result.unwrap();
799        assert_eq!(cat, "Security");
800        assert_eq!(rest, "important rule");
801    }
802
803    #[test]
804    fn test_parse_no_bold() {
805        let result = AutoMemoryBridge::extract_bold_category("Just a plain item");
806        assert!(result.is_none());
807    }
808
809    #[test]
810    fn test_parse_markdown_insights() {
811        let temp_dir = tempfile::tempdir().unwrap();
812        let bridge = make_bridge(temp_dir.path());
813
814        let md = r#"# Project Patterns
815
816- **Debugging**: Use trace-level logging for async tasks
817- **Architecture**: Follow the kernel module pattern
818- **Performance**: Batch embeddings when possible
819
820## Security
821
822- Always validate input at the boundary
823- Use RBAC for multi-agent access control
824"#;
825
826        let insights = bridge.parse_markdown_insights(md);
827        assert!(!insights.is_empty());
828
829        // Should have parsed the bullet points
830        let debugging = insights
831            .iter()
832            .find(|i| i.category == InsightCategory::Debugging);
833        assert!(debugging.is_some());
834
835        let security = insights
836            .iter()
837            .find(|i| i.category == InsightCategory::Security);
838        assert!(security.is_some());
839    }
840
841    #[test]
842    fn test_parse_empty_markdown() {
843        let temp_dir = tempfile::tempdir().unwrap();
844        let bridge = make_bridge(temp_dir.path());
845
846        let insights = bridge.parse_markdown_insights("");
847        assert!(insights.is_empty());
848    }
849
850    #[test]
851    fn test_parse_plain_text_as_single_insight() {
852        let temp_dir = tempfile::tempdir().unwrap();
853        let bridge = make_bridge(temp_dir.path());
854
855        let text = "This is a plain text memory entry without markdown formatting.";
856        let insights = bridge.parse_markdown_insights(text);
857        assert_eq!(insights.len(), 1);
858        assert_eq!(insights[0].category, InsightCategory::General);
859    }
860
861    #[test]
862    fn test_insight_category_parsing() {
863        assert_eq!(
864            InsightCategory::from_str_loose("patterns"),
865            InsightCategory::ProjectPatterns
866        );
867        assert_eq!(
868            InsightCategory::from_str_loose("debug"),
869            InsightCategory::Debugging
870        );
871        assert_eq!(
872            InsightCategory::from_str_loose("arch"),
873            InsightCategory::Architecture
874        );
875        assert_eq!(
876            InsightCategory::from_str_loose("perf"),
877            InsightCategory::Performance
878        );
879        assert_eq!(
880            InsightCategory::from_str_loose("sec"),
881            InsightCategory::Security
882        );
883        assert_eq!(
884            InsightCategory::from_str_loose("unknown"),
885            InsightCategory::General
886        );
887    }
888
889    #[test]
890    fn test_confidence_bar() {
891        let bar = format_confidence_bar(0.8);
892        assert!(bar.contains("80%"));
893
894        let bar_low = format_confidence_bar(0.2);
895        assert!(bar_low.contains("20%"));
896    }
897
898    #[tokio::test]
899    async fn test_import_from_empty_dir() {
900        let temp_dir = tempfile::tempdir().unwrap();
901        let bridge = make_bridge(temp_dir.path());
902
903        // No files to import
904        let result = bridge.import_from_auto().await.unwrap();
905        assert_eq!(result.imported, 0);
906        assert_eq!(result.failed, 0);
907    }
908
909    #[tokio::test]
910    async fn test_import_from_file() {
911        let temp_dir = tempfile::tempdir().unwrap();
912        let auto_dir = temp_dir.path().join("auto");
913        tokio::fs::create_dir_all(&auto_dir).await.unwrap();
914
915        // Create a MEMORY.md file
916        let md = r#"- **Debugging**: Use println! for quick debugging
917- **Architecture**: Keep modules small and focused
918"#;
919        tokio::fs::write(auto_dir.join("MEMORY.md"), md)
920            .await
921            .unwrap();
922
923        let bridge = make_bridge(temp_dir.path());
924        let result = bridge.import_from_auto().await.unwrap();
925        assert!(result.imported >= 1, "Should import at least 1 insight");
926    }
927
928    #[tokio::test]
929    async fn test_export_to_auto() {
930        let temp_dir = tempfile::tempdir().unwrap();
931        let bridge = make_bridge(temp_dir.path());
932
933        let patterns = vec![
934            GuidancePattern {
935                id: "p1".to_string(),
936                category: InsightCategory::Debugging,
937                description: "Always check error chains".to_string(),
938                confidence: 0.9,
939                usage_count: 5,
940            },
941            GuidancePattern {
942                id: "p2".to_string(),
943                category: InsightCategory::Architecture,
944                description: "Use actor model for concurrency".to_string(),
945                confidence: 0.7,
946                usage_count: 3,
947            },
948        ];
949
950        let result = bridge.export_to_auto(&patterns).await.unwrap();
951        assert_eq!(result.exported, 2);
952        assert!(result.categories_updated >= 2);
953
954        // Verify files were created
955        assert!(bridge.auto_memory_dir.join("MEMORY.md").exists());
956        assert!(bridge.auto_memory_dir.join("debugging.md").exists());
957        assert!(bridge.auto_memory_dir.join("architecture.md").exists());
958
959        // Verify content
960        let main = tokio::fs::read_to_string(bridge.auto_memory_dir.join("MEMORY.md"))
961            .await
962            .unwrap();
963        assert!(main.contains("Oxios Memory"));
964        assert!(main.contains("Always check error chains"));
965        assert!(main.contains("Use actor model for concurrency"));
966    }
967
968    #[tokio::test]
969    async fn test_bidirectional_sync() {
970        let temp_dir = tempfile::tempdir().unwrap();
971        let auto_dir = temp_dir.path().join("auto");
972        tokio::fs::create_dir_all(&auto_dir).await.unwrap();
973
974        // Create an external memory file
975        tokio::fs::write(
976            auto_dir.join("MEMORY.md"),
977            "- **Debugging**: Test insight for sync",
978        )
979        .await
980        .unwrap();
981
982        let bridge = make_bridge(temp_dir.path());
983        let result = bridge
984            .sync_session(SyncDirection::Bidirectional)
985            .await
986            .unwrap();
987
988        // Should have imported the insight
989        assert!(result.import.imported >= 1 || result.import.skipped_duplicates > 0);
990
991        // Should have exported current state (usize, always >= 0)
992    }
993
994    #[test]
995    fn test_sync_direction_serialization() {
996        let dir = SyncDirection::ToAuto;
997        let json = serde_json::to_string(&dir).unwrap();
998        assert_eq!(json, "\"to_auto\"");
999
1000        let parsed: SyncDirection = serde_json::from_str(&json).unwrap();
1001        assert_eq!(parsed, SyncDirection::ToAuto);
1002    }
1003}