1#![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#[derive(Debug, Clone)]
20pub struct ExportOptions {
21 pub format: Format,
23 pub filter: Option<String>,
25 pub limit: Option<usize>,
27 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, }
39 }
40}
41
42impl ExportOptions {
43 #[must_use]
45 pub const fn with_format(mut self, format: Format) -> Self {
46 self.format = format;
47 self
48 }
49
50 #[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 #[must_use]
59 pub const fn with_limit(mut self, limit: usize) -> Self {
60 self.limit = Some(limit);
61 self
62 }
63
64 #[must_use]
66 pub fn with_fields(mut self, fields: Vec<ExportField>) -> Self {
67 self.fields = Some(fields);
68 self
69 }
70
71 #[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#[derive(Debug, Clone)]
83pub struct ExportResult {
84 pub exported: usize,
86 pub total_matched: usize,
88 pub format: Format,
90 pub output_path: Option<String>,
92}
93
94impl ExportResult {
95 #[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 #[must_use]
108 pub const fn has_exports(&self) -> bool {
109 self.exported > 0
110 }
111}
112
113pub type ExportProgressCallback = Box<dyn Fn(usize, Option<usize>) + Send>;
115
116pub struct ExportService {
118 index: Arc<SqliteBackend>,
120}
121
122impl ExportService {
123 #[must_use]
125 pub const fn new(index: Arc<SqliteBackend>) -> Self {
126 Self { index }
127 }
128
129 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 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 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 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 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 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 #[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}