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