subcog/io/traits.rs
1//! Core traits for import/export operations.
2//!
3//! Defines the [`ImportSource`] and [`ExportSink`] traits that format adapters
4//! implement to support different file formats.
5
6use crate::Result;
7use crate::models::Memory;
8use serde::{Deserialize, Serialize};
9
10/// Intermediate representation for imported memory data.
11///
12/// This struct captures the fields that can be imported from external formats.
13/// Optional fields allow partial data to be imported with defaults applied
14/// during validation.
15///
16/// # Field Mapping
17///
18/// | Field | Required | Default |
19/// |-------|----------|---------|
20/// | `content` | Yes | - |
21/// | `namespace` | No | `decisions` |
22/// | `domain` | No | Context-dependent |
23/// | `tags` | No | `[]` |
24/// | `source` | No | `None` |
25/// | `created_at` | No | Current time |
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ImportedMemory {
28 /// The memory content (required).
29 pub content: String,
30
31 /// Namespace for categorization.
32 #[serde(default)]
33 pub namespace: Option<String>,
34
35 /// Domain scope (project, user, org).
36 #[serde(default)]
37 pub domain: Option<String>,
38
39 /// Tags for categorization.
40 #[serde(default)]
41 pub tags: Vec<String>,
42
43 /// Source reference (file path, URL).
44 #[serde(default)]
45 pub source: Option<String>,
46
47 /// Original creation timestamp (Unix epoch seconds).
48 ///
49 /// If provided, preserves the original creation time during import.
50 #[serde(default)]
51 pub created_at: Option<u64>,
52
53 /// TTL in seconds for automatic expiration.
54 #[serde(default)]
55 pub ttl_seconds: Option<u64>,
56}
57
58impl ImportedMemory {
59 /// Creates a new imported memory with just content.
60 #[must_use]
61 pub fn new(content: impl Into<String>) -> Self {
62 Self {
63 content: content.into(),
64 namespace: None,
65 domain: None,
66 tags: Vec::new(),
67 source: None,
68 created_at: None,
69 ttl_seconds: None,
70 }
71 }
72
73 /// Sets the namespace.
74 #[must_use]
75 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
76 self.namespace = Some(namespace.into());
77 self
78 }
79
80 /// Sets the domain.
81 #[must_use]
82 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
83 self.domain = Some(domain.into());
84 self
85 }
86
87 /// Adds a tag.
88 #[must_use]
89 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
90 self.tags.push(tag.into());
91 self
92 }
93
94 /// Sets the source reference.
95 #[must_use]
96 pub fn with_source(mut self, source: impl Into<String>) -> Self {
97 self.source = Some(source.into());
98 self
99 }
100}
101
102/// Source of imported memories.
103///
104/// Implementations read memories from a specific format (JSON, YAML, CSV, etc.)
105/// and yield them one at a time for processing.
106///
107/// # Streaming
108///
109/// Sources should read data incrementally where possible to support large files
110/// without loading everything into memory.
111///
112/// # Example Implementation
113///
114/// ```rust,ignore
115/// impl ImportSource for JsonSource {
116/// fn next(&mut self) -> Result<Option<ImportedMemory>> {
117/// // Read next line, parse JSON, return memory
118/// }
119///
120/// fn size_hint(&self) -> Option<usize> {
121/// None // Unknown for streaming
122/// }
123/// }
124/// ```
125pub trait ImportSource {
126 /// Reads the next memory from the source.
127 ///
128 /// Returns `Ok(None)` when the source is exhausted.
129 ///
130 /// # Errors
131 ///
132 /// Returns an error if parsing fails or I/O errors occur.
133 fn next(&mut self) -> Result<Option<ImportedMemory>>;
134
135 /// Returns an estimate of the total number of records.
136 ///
137 /// Used for progress reporting. Returns `None` if unknown.
138 fn size_hint(&self) -> Option<usize> {
139 None
140 }
141}
142
143/// Memory representation for export.
144///
145/// A subset of [`Memory`] fields that are meaningful for external consumption.
146/// Excludes internal fields like embeddings.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ExportableMemory {
149 /// Unique memory identifier.
150 pub id: String,
151 /// Memory content.
152 pub content: String,
153 /// Namespace (e.g., "decisions", "learnings").
154 pub namespace: String,
155 /// Domain (e.g., "project", "user").
156 pub domain: String,
157 /// Project identifier (git remote URL).
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub project_id: Option<String>,
160 /// Branch name.
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub branch: Option<String>,
163 /// File path relative to repo root.
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub file_path: Option<String>,
166 /// Status (e.g., "active", "archived").
167 pub status: String,
168 /// Creation timestamp (Unix epoch seconds).
169 pub created_at: u64,
170 /// Last update timestamp (Unix epoch seconds).
171 pub updated_at: u64,
172 /// Tags for categorization.
173 pub tags: Vec<String>,
174 /// Source reference.
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub source: Option<String>,
177}
178
179impl From<Memory> for ExportableMemory {
180 fn from(m: Memory) -> Self {
181 Self {
182 id: m.id.to_string(),
183 content: m.content,
184 namespace: m.namespace.as_str().to_string(),
185 domain: m.domain.to_string(),
186 project_id: m.project_id,
187 branch: m.branch,
188 file_path: m.file_path,
189 status: m.status.as_str().to_string(),
190 created_at: m.created_at,
191 updated_at: m.updated_at,
192 tags: m.tags,
193 source: m.source,
194 }
195 }
196}
197
198impl From<&Memory> for ExportableMemory {
199 fn from(m: &Memory) -> Self {
200 Self {
201 id: m.id.to_string(),
202 content: m.content.clone(),
203 namespace: m.namespace.as_str().to_string(),
204 domain: m.domain.to_string(),
205 project_id: m.project_id.clone(),
206 branch: m.branch.clone(),
207 file_path: m.file_path.clone(),
208 status: m.status.as_str().to_string(),
209 created_at: m.created_at,
210 updated_at: m.updated_at,
211 tags: m.tags.clone(),
212 source: m.source.clone(),
213 }
214 }
215}
216
217/// Sink for exported memories.
218///
219/// Implementations write memories to a specific format (JSON, YAML, CSV, etc.).
220///
221/// # Lifecycle
222///
223/// 1. Create sink with output destination
224/// 2. Call `write()` for each memory
225/// 3. Call `finalize()` to complete the export
226///
227/// # Example Implementation
228///
229/// ```rust,ignore
230/// impl ExportSink for JsonSink {
231/// fn write(&mut self, memory: &ExportableMemory) -> Result<()> {
232/// serde_json::to_writer(&mut self.writer, memory)?;
233/// writeln!(self.writer)?;
234/// Ok(())
235/// }
236///
237/// fn finalize(self: Box<Self>) -> Result<()> {
238/// self.writer.flush()?;
239/// Ok(())
240/// }
241/// }
242/// ```
243pub trait ExportSink {
244 /// Writes a single memory to the sink.
245 ///
246 /// # Errors
247 ///
248 /// Returns an error if serialization or I/O fails.
249 fn write(&mut self, memory: &ExportableMemory) -> Result<()>;
250
251 /// Finalizes the export, writing any footers and flushing buffers.
252 ///
253 /// This method consumes the sink.
254 ///
255 /// # Errors
256 ///
257 /// Returns an error if I/O fails.
258 fn finalize(self: Box<Self>) -> Result<()>;
259}
260
261/// Fields that can be selected for export.
262///
263/// Used with [`crate::io::services::export::ExportOptions::fields`] to customize output.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum ExportField {
267 /// Memory ID.
268 Id,
269 /// Memory content.
270 Content,
271 /// Namespace.
272 Namespace,
273 /// Domain.
274 Domain,
275 /// Project ID.
276 ProjectId,
277 /// Branch.
278 Branch,
279 /// File path.
280 FilePath,
281 /// Status.
282 Status,
283 /// Creation timestamp.
284 CreatedAt,
285 /// Update timestamp.
286 UpdatedAt,
287 /// Tags.
288 Tags,
289 /// Source reference.
290 Source,
291}
292
293impl ExportField {
294 /// Returns all available fields.
295 #[must_use]
296 pub const fn all() -> &'static [Self] {
297 &[
298 Self::Id,
299 Self::Content,
300 Self::Namespace,
301 Self::Domain,
302 Self::ProjectId,
303 Self::Branch,
304 Self::FilePath,
305 Self::Status,
306 Self::CreatedAt,
307 Self::UpdatedAt,
308 Self::Tags,
309 Self::Source,
310 ]
311 }
312
313 /// Returns the string representation.
314 #[must_use]
315 pub const fn as_str(&self) -> &'static str {
316 match self {
317 Self::Id => "id",
318 Self::Content => "content",
319 Self::Namespace => "namespace",
320 Self::Domain => "domain",
321 Self::ProjectId => "project_id",
322 Self::Branch => "branch",
323 Self::FilePath => "file_path",
324 Self::Status => "status",
325 Self::CreatedAt => "created_at",
326 Self::UpdatedAt => "updated_at",
327 Self::Tags => "tags",
328 Self::Source => "source",
329 }
330 }
331
332 /// Parses a field name string.
333 ///
334 /// Returns `None` if the field name is not recognized.
335 #[must_use]
336 pub fn parse(s: &str) -> Option<Self> {
337 match s.to_lowercase().as_str() {
338 "id" => Some(Self::Id),
339 "content" => Some(Self::Content),
340 "namespace" | "ns" => Some(Self::Namespace),
341 "domain" => Some(Self::Domain),
342 "project_id" | "project" => Some(Self::ProjectId),
343 "branch" => Some(Self::Branch),
344 "file_path" | "file" | "path" => Some(Self::FilePath),
345 "status" => Some(Self::Status),
346 "created_at" | "created" => Some(Self::CreatedAt),
347 "updated_at" | "updated" => Some(Self::UpdatedAt),
348 "tags" => Some(Self::Tags),
349 "source" => Some(Self::Source),
350 _ => None,
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_imported_memory_builder() {
361 let mem = ImportedMemory::new("Test content")
362 .with_namespace("decisions")
363 .with_domain("project")
364 .with_tag("rust")
365 .with_tag("test")
366 .with_source("test.rs");
367
368 assert_eq!(mem.content, "Test content");
369 assert_eq!(mem.namespace, Some("decisions".to_string()));
370 assert_eq!(mem.domain, Some("project".to_string()));
371 assert_eq!(mem.tags, vec!["rust", "test"]);
372 assert_eq!(mem.source, Some("test.rs".to_string()));
373 }
374
375 #[test]
376 fn test_export_field_parsing() {
377 assert_eq!(ExportField::parse("id"), Some(ExportField::Id));
378 assert_eq!(ExportField::parse("content"), Some(ExportField::Content));
379 assert_eq!(ExportField::parse("ns"), Some(ExportField::Namespace));
380 assert_eq!(
381 ExportField::parse("namespace"),
382 Some(ExportField::Namespace)
383 );
384 assert_eq!(ExportField::parse("unknown"), None);
385 }
386
387 #[test]
388 fn test_export_field_all() {
389 let all = ExportField::all();
390 assert_eq!(all.len(), 12);
391 assert!(all.contains(&ExportField::Id));
392 assert!(all.contains(&ExportField::Content));
393 }
394}