Skip to main content

sqlite_graphrag/
memory_source.rs

1//! Type-safe enumeration of the `memories.source` column domain.
2//!
3//! The CHECK constraint on the `memories` table accepts exactly five values:
4//! `agent`, `user`, `system`, `import`, `sync`. Any other literal is rejected
5//! at runtime by SQLite with `SQLITE_CONSTRAINT_CHECK`.
6//!
7//! This enum eliminates the silent footgun of `pub source: String` by forcing
8//! every call-site to pick a typed variant that maps deterministically to one
9//! of the five allowed CHECK values via [`crate::memory_source::MemorySource::as_str`].
10//!
11//! # Examples
12//!
13//! ```
14//! use sqlite_graphrag::memory_source::MemorySource;
15//!
16//! let src = MemorySource::Agent;
17//! assert_eq!(src.as_str(), "agent");
18//!
19//! let parsed = MemorySource::try_from("user").expect("user is valid");
20//! assert_eq!(parsed, MemorySource::User);
21//!
22//! let err = MemorySource::try_from("enrich").unwrap_err();
23//! // Locale-safe, mirroring `rejects_unknown_source` below: the message comes
24//! // from the `crate::i18n::validation` catalog and follows the host locale,
25//! // so asserting only the English wording fails on a `pt-BR` machine.
26//! let msg = format!("{err}");
27//! assert!(
28//!     msg.contains("invalid memory source") || msg.contains("fonte de memória inválida"),
29//!     "unexpected message: {msg}"
30//! );
31//! ```
32
33use crate::errors::AppError;
34use serde::{Deserialize, Serialize};
35
36/// Enumerates the five values accepted by the `memories.source` CHECK constraint.
37///
38/// Adding a new variant requires:
39///
40/// 1. Updating the DDL CHECK constraint in `migrations/V001__init.sql`.
41/// 2. Running a migration that backfills any pre-existing values
42///    (`UPDATE memories SET source='agent' WHERE source NOT IN (...)`).
43/// 3. Bumping [`crate::constants::CURRENT_SCHEMA_VERSION`].
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum MemorySource {
47    /// Mutated by an LLM agent (remember, edit, rename, body-enrich).
48    Agent,
49    /// Mutated by a human operator.
50    User,
51    /// Mutated by an internal migration or system job.
52    System,
53    /// Inserted by bulk import (`ingest`). Rows written before v1.2.0 may also
54    /// come from the retired `--mode claude-code` / `--mode codex` frontends.
55    Import,
56    /// Inserted by an external sync job.
57    Sync,
58}
59
60impl MemorySource {
61    /// Returns the canonical snake_case string stored in the SQLite column.
62    ///
63    /// The returned slice has `'static` lifetime because all five values are
64    /// ASCII literals known at compile time.
65    pub const fn as_str(self) -> &'static str {
66        match self {
67            Self::Agent => "agent",
68            Self::User => "user",
69            Self::System => "system",
70            Self::Import => "import",
71            Self::Sync => "sync",
72        }
73    }
74
75    /// Returns every variant as a static slice, useful for error messages and docs.
76    pub const ALL: &'static [MemorySource] = &[
77        Self::Agent,
78        Self::User,
79        Self::System,
80        Self::Import,
81        Self::Sync,
82    ];
83}
84
85impl std::fmt::Display for MemorySource {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91/// Parses a stored `memories.source` string back into a typed variant.
92///
93/// # Errors
94///
95/// Returns [`AppError::Validation`] when the input is not one of the five
96/// canonical values. The error message lists every accepted value so the
97/// caller can self-correct without consulting the schema.
98impl TryFrom<&str> for MemorySource {
99    type Error = AppError;
100
101    fn try_from(value: &str) -> Result<Self, Self::Error> {
102        match value {
103            "agent" => Ok(Self::Agent),
104            "user" => Ok(Self::User),
105            "system" => Ok(Self::System),
106            "import" => Ok(Self::Import),
107            "sync" => Ok(Self::Sync),
108            other => Err(AppError::Validation(
109                crate::i18n::validation::invalid_memory_source(
110                    &format!("{other:?}"),
111                    &Self::ALL
112                        .iter()
113                        .map(|v| v.as_str())
114                        .collect::<Vec<_>>()
115                        .join(", "),
116                ),
117            )),
118        }
119    }
120}
121
122impl TryFrom<String> for MemorySource {
123    type Error = AppError;
124
125    fn try_from(value: String) -> Result<Self, Self::Error> {
126        Self::try_from(value.as_str())
127    }
128}
129
130/// Validates a raw `memories.source` string against the CHECK constraint domain.
131///
132/// This is the runtime guard for callers that still take `&str` (legacy
133/// call-sites, FTS rows already in the database, deserialised JSON). The
134/// function returns the canonical slice on success and an [`AppError::Validation`]
135/// on failure, with an actionable message listing every accepted value.
136///
137/// Use this at every boundary that touches the `source` column:
138/// `memories::insert`, `memories::update`, and any new code path that
139/// builds a `NewMemory` from operator-supplied input. It is the safety
140/// net that prevented the original G29 bug from regressing in v1.0.69
141/// when the typed [`MemorySource`] enum was still being rolled out.
142pub fn validate_source(raw: &str) -> Result<&'static str, AppError> {
143    match raw {
144        "agent" => Ok("agent"),
145        "user" => Ok("user"),
146        "system" => Ok("system"),
147        "import" => Ok("import"),
148        "sync" => Ok("sync"),
149        other => Err(AppError::Validation(
150            crate::i18n::validation::invalid_memory_source(
151                &format!("{other:?}"),
152                &MemorySource::ALL
153                    .iter()
154                    .map(|v| v.as_str())
155                    .collect::<Vec<_>>()
156                    .join(", "),
157            ),
158        )),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn as_str_returns_canonical_lowercase() {
168        assert_eq!(MemorySource::Agent.as_str(), "agent");
169        assert_eq!(MemorySource::User.as_str(), "user");
170        assert_eq!(MemorySource::System.as_str(), "system");
171        assert_eq!(MemorySource::Import.as_str(), "import");
172        assert_eq!(MemorySource::Sync.as_str(), "sync");
173    }
174
175    #[test]
176    fn try_from_valid_strings_succeeds() {
177        assert_eq!(
178            MemorySource::try_from("agent").unwrap(),
179            MemorySource::Agent
180        );
181        assert_eq!(MemorySource::try_from("user").unwrap(), MemorySource::User);
182        assert_eq!(
183            MemorySource::try_from("system").unwrap(),
184            MemorySource::System
185        );
186        assert_eq!(
187            MemorySource::try_from("import").unwrap(),
188            MemorySource::Import
189        );
190        assert_eq!(MemorySource::try_from("sync").unwrap(), MemorySource::Sync);
191    }
192
193    #[test]
194    fn try_from_invalid_string_returns_err() {
195        // G29 reproducer: "enrich" is the historical bug.
196        let err = MemorySource::try_from("enrich").unwrap_err();
197        let msg = format!("{err}");
198        // Locale-safe: EN "invalid memory source" / PT "fonte de memória inválida"
199        assert!(
200            msg.contains("invalid memory source") || msg.contains("fonte de memória inválida"),
201            "got: {msg}"
202        );
203        assert!(msg.contains("\"enrich\""), "got: {msg}");
204        assert!(msg.contains("agent"), "must list agent as valid: {msg}");
205    }
206
207    #[test]
208    fn try_from_empty_string_returns_err() {
209        assert!(MemorySource::try_from("").is_err());
210    }
211
212    #[test]
213    fn try_from_string_owned_works() {
214        let src: MemorySource = String::from("agent").try_into().unwrap();
215        assert_eq!(src, MemorySource::Agent);
216    }
217
218    #[test]
219    fn display_matches_as_str() {
220        for v in MemorySource::ALL {
221            assert_eq!(format!("{v}"), v.as_str());
222        }
223    }
224
225    #[test]
226    fn serialize_round_trip_preserves_variant() {
227        let v = MemorySource::Import;
228        let json = serde_json::to_string(&v).unwrap();
229        assert_eq!(json, "\"import\"");
230        let back: MemorySource = serde_json::from_str(&json).unwrap();
231        assert_eq!(back, v);
232    }
233
234    #[test]
235    fn all_slice_has_exactly_five_variants() {
236        assert_eq!(MemorySource::ALL.len(), 5);
237    }
238}