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}