Skip to main content

subcog/io/services/
export.rs

1//! Memory export service.
2//!
3//! Orchestrates bulk memory export to various formats.
4
5#![allow(clippy::needless_pass_by_value)]
6
7use crate::io::formats::{Format, create_export_sink};
8use crate::io::traits::{ExportField, ExportSink, ExportableMemory};
9use crate::models::{Memory, SearchFilter};
10use crate::services::parse_filter_query;
11use crate::storage::IndexBackend;
12use crate::storage::index::SqliteBackend;
13use crate::{Error, Result};
14use std::io::Write;
15use std::path::Path;
16use std::sync::Arc;
17
18/// Options for memory export.
19#[derive(Debug, Clone)]
20pub struct ExportOptions {
21    /// File format to export to.
22    pub format: Format,
23    /// Filter query string (GitHub-style syntax).
24    pub filter: Option<String>,
25    /// Maximum number of memories to export.
26    pub limit: Option<usize>,
27    /// Fields to include in export.
28    pub fields: Option<Vec<ExportField>>,
29}
30
31impl Default for ExportOptions {
32    fn default() -> Self {
33        Self {
34            format: Format::Json,
35            filter: None,
36            limit: None,
37            fields: None, // All fields
38        }
39    }
40}
41
42impl ExportOptions {
43    /// Creates export options with the given format.
44    #[must_use]
45    pub const fn with_format(mut self, format: Format) -> Self {
46        self.format = format;
47        self
48    }
49
50    /// Sets the filter query string.
51    #[must_use]
52    pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
53        self.filter = Some(filter.into());
54        self
55    }
56
57    /// Sets the maximum number of memories to export.
58    #[must_use]
59    pub const fn with_limit(mut self, limit: usize) -> Self {
60        self.limit = Some(limit);
61        self
62    }
63
64    /// Sets the fields to include in export.
65    #[must_use]
66    pub fn with_fields(mut self, fields: Vec<ExportField>) -> Self {
67        self.fields = Some(fields);
68        self
69    }
70
71    /// Parses the filter query into a `SearchFilter`.
72    #[must_use]
73    pub fn parse_filter(&self) -> SearchFilter {
74        self.filter
75            .as_ref()
76            .map(|f| parse_filter_query(f))
77            .unwrap_or_default()
78    }
79}
80
81/// Result of an export operation.
82#[derive(Debug, Clone)]
83pub struct ExportResult {
84    /// Number of memories exported.
85    pub exported: usize,
86    /// Total memories that matched the filter.
87    pub total_matched: usize,
88    /// Format used for export.
89    pub format: Format,
90    /// Output path (if file export).
91    pub output_path: Option<String>,
92}
93
94impl ExportResult {
95    /// Creates a new export result.
96    #[must_use]
97    pub const fn new(format: Format) -> Self {
98        Self {
99            exported: 0,
100            total_matched: 0,
101            format,
102            output_path: None,
103        }
104    }
105
106    /// Returns whether any memories were exported.
107    #[must_use]
108    pub const fn has_exports(&self) -> bool {
109        self.exported > 0
110    }
111}
112
113/// Progress callback for export operations.
114pub type ExportProgressCallback = Box<dyn Fn(usize, Option<usize>) + Send>;
115
116/// Service for exporting memories to external formats.
117pub struct ExportService {
118    /// Index backend for querying memories.
119    index: Arc<SqliteBackend>,
120}
121
122impl ExportService {
123    /// Creates a new export service.
124    #[must_use]
125    pub const fn new(index: Arc<SqliteBackend>) -> Self {
126        Self { index }
127    }
128
129    /// Exports memories to a file.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the file cannot be written or export fails.
134    pub fn export_to_file(
135        &self,
136        path: &Path,
137        options: ExportOptions,
138        progress: Option<ExportProgressCallback>,
139    ) -> Result<ExportResult> {
140        let format = if options.format == Format::Json {
141            // Auto-detect from extension if using default
142            Format::from_path(path).unwrap_or(Format::Json)
143        } else {
144            options.format
145        };
146
147        if !format.supports_export() {
148            return Err(Error::InvalidInput(format!(
149                "Format {format} does not support export"
150            )));
151        }
152
153        let file = std::fs::File::create(path).map_err(|e| Error::OperationFailed {
154            operation: "create_export_file".to_string(),
155            cause: e.to_string(),
156        })?;
157        let writer = std::io::BufWriter::new(file);
158
159        let mut result = self.export_to_writer(writer, options.with_format(format), progress)?;
160        result.output_path = Some(path.display().to_string());
161        Ok(result)
162    }
163
164    /// Exports memories to a writer.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if writing fails.
169    pub fn export_to_writer<W: Write + Send + 'static>(
170        &self,
171        writer: W,
172        options: ExportOptions,
173        progress: Option<ExportProgressCallback>,
174    ) -> Result<ExportResult> {
175        let mut sink = create_export_sink(writer, options.format)?;
176        let result = self.export_to_sink(sink.as_mut(), &options, progress)?;
177        sink.finalize()?;
178        Ok(result)
179    }
180
181    /// Exports memories to a sink.
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if export fails.
186    pub fn export_to_sink(
187        &self,
188        sink: &mut dyn ExportSink,
189        options: &ExportOptions,
190        progress: Option<ExportProgressCallback>,
191    ) -> Result<ExportResult> {
192        let filter = options.parse_filter();
193        let limit = options.limit.unwrap_or(usize::MAX);
194
195        // Query memories from index
196        let memory_ids = self.index.list_all(&filter, limit)?;
197        let total_matched = memory_ids.len();
198
199        let mut result = ExportResult::new(options.format);
200        result.total_matched = total_matched;
201
202        // Batch fetch memories
203        let ids: Vec<_> = memory_ids.iter().map(|(id, _)| id.clone()).collect();
204        let memories = self.index.get_memories_batch(&ids)?;
205
206        for memory in memories.into_iter().flatten() {
207            let exportable = ExportableMemory::from(&memory);
208            sink.write(&exportable)?;
209            result.exported += 1;
210
211            if let Some(ref cb) = progress {
212                cb(result.exported, Some(total_matched));
213            }
214        }
215
216        Ok(result)
217    }
218
219    /// Exports memories directly from an iterator.
220    ///
221    /// Useful when memories are already loaded.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if export fails.
226    #[allow(clippy::excessive_nesting)]
227    pub fn export_memories<'a, I>(
228        &self,
229        memories: I,
230        sink: &mut dyn ExportSink,
231        progress: Option<ExportProgressCallback>,
232    ) -> Result<ExportResult>
233    where
234        I: IntoIterator<Item = &'a Memory>,
235    {
236        let mut result = ExportResult::new(Format::Json);
237
238        for memory in memories {
239            let exportable = ExportableMemory::from(memory);
240            sink.write(&exportable)?;
241            result.exported += 1;
242
243            if let Some(ref cb) = progress {
244                cb(result.exported, None);
245            }
246        }
247
248        result.total_matched = result.exported;
249        Ok(result)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::io::formats::json::JsonExportSink;
257    use crate::models::{Domain, MemoryId, MemoryStatus, Namespace};
258
259    fn test_memory(id: &str, content: &str) -> Memory {
260        Memory {
261            id: MemoryId::new(id),
262            content: content.to_string(),
263            namespace: Namespace::Decisions,
264            domain: Domain::new(),
265            project_id: None,
266            branch: None,
267            file_path: None,
268            status: MemoryStatus::Active,
269            created_at: 0,
270            updated_at: 0,
271            tombstoned_at: None,
272            expires_at: None,
273            embedding: None,
274            tags: vec![],
275            #[cfg(feature = "group-scope")]
276            group_id: None,
277            source: None,
278            is_summary: false,
279            source_memory_ids: None,
280            consolidation_timestamp: None,
281        }
282    }
283
284    #[test]
285    fn test_export_options_defaults() {
286        let options = ExportOptions::default();
287        assert_eq!(options.format, Format::Json);
288        assert!(options.filter.is_none());
289        assert!(options.limit.is_none());
290    }
291
292    #[test]
293    fn test_export_options_with_filter() {
294        let options = ExportOptions::default().with_filter("ns:decisions tag:rust");
295        assert!(options.filter.is_some());
296
297        let filter = options.parse_filter();
298        assert_eq!(filter.namespaces.len(), 1);
299        assert_eq!(filter.tags.len(), 1);
300    }
301
302    #[test]
303    fn test_export_result_has_exports() {
304        let mut result = ExportResult::new(Format::Json);
305        assert!(!result.has_exports());
306
307        result.exported = 1;
308        assert!(result.has_exports());
309    }
310
311    #[test]
312    fn test_export_memories_to_json() {
313        let index = Arc::new(SqliteBackend::in_memory().unwrap());
314        let service = ExportService::new(index);
315
316        let memories = [
317            test_memory("1", "First memory"),
318            test_memory("2", "Second memory"),
319        ];
320
321        let mut output = Vec::new();
322        {
323            let mut sink = JsonExportSink::new(&mut output);
324            service
325                .export_memories(memories.iter(), &mut sink, None)
326                .unwrap();
327            Box::new(sink).finalize().unwrap();
328        }
329
330        let output_str = String::from_utf8(output).unwrap();
331        assert!(output_str.contains("First memory"));
332        assert!(output_str.contains("Second memory"));
333    }
334}