Skip to main content

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}