1use kimetsu_core::KimetsuResult;
31use rusqlite::Connection;
32
33pub const MAX_PREFERENCES: usize = 8;
39
40pub const PROFILE_CHAR_BUDGET: usize = 400;
43
44const MAX_PREFERENCE_CHARS: usize = 160;
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct Preference {
51 pub memory_id: String,
52 pub text: String,
53 pub global: bool,
57}
58
59pub fn preferences(
64 conn: &Connection,
65 global: bool,
66 limit: usize,
67) -> KimetsuResult<Vec<Preference>> {
68 let mut stmt = conn.prepare(
69 "SELECT memory_id, text
70 FROM memories
71 WHERE kind = 'preference'
72 AND invalidated_at IS NULL
73 AND superseded_by IS NULL
74 AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
75 AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
76 ORDER BY usefulness_score DESC, created_at DESC
77 LIMIT ?1",
78 )?;
79 let rows = stmt
80 .query_map(rusqlite::params![limit as i64], |row| {
81 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
82 })?
83 .collect::<Result<Vec<_>, _>>()?;
84 Ok(rows
85 .into_iter()
86 .map(|(memory_id, text)| Preference {
87 memory_id,
88 text,
89 global,
90 })
91 .collect())
92}
93
94pub fn build_profile(
100 project_conn: &Connection,
101 user_conn: Option<&Connection>,
102) -> KimetsuResult<Vec<Preference>> {
103 let mut profile = preferences(project_conn, false, MAX_PREFERENCES)?;
104 if let Some(user_conn) = user_conn {
105 let remaining = MAX_PREFERENCES.saturating_sub(profile.len());
106 if remaining > 0 {
107 let global = preferences(user_conn, true, remaining)?;
108 for pref in global {
111 if !profile.iter().any(|p| p.text == pref.text) {
112 profile.push(pref);
113 }
114 }
115 }
116 }
117 Ok(profile)
118}
119
120pub fn render_profile(preferences: &[Preference]) -> Option<String> {
127 if preferences.is_empty() {
128 return None;
129 }
130 let mut lines = Vec::new();
131 let mut used = 0usize;
132 for pref in preferences {
133 let text = pref.text.trim();
134 if text.is_empty() || text.chars().count() > MAX_PREFERENCE_CHARS {
135 continue;
136 }
137 let line = if pref.global {
138 format!("- {text} (across all your projects)")
139 } else {
140 format!("- {text}")
141 };
142 if used + line.len() > PROFILE_CHAR_BUDGET {
143 break;
144 }
145 used += line.len();
146 lines.push(line);
147 }
148 if lines.is_empty() {
149 return None;
150 }
151 Some(format!(
152 "Standing preferences — follow these without being asked:\n{}",
153 lines.join("\n")
154 ))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn conn() -> Connection {
162 let conn = Connection::open_in_memory().expect("open");
163 crate::schema::initialize(&conn).expect("schema");
164 conn
165 }
166
167 fn insert(conn: &Connection, id: &str, kind: &str, text: &str, usefulness: f32) {
168 conn.execute(
169 "INSERT INTO memories
170 (memory_id, scope, kind, text, normalized_text, confidence,
171 provenance_snapshot_json, created_at, usefulness_score)
172 VALUES (?1, 'project', ?2, ?3, ?3, 0.9, '{}', '2026-01-01T00:00:00Z', ?4)",
173 rusqlite::params![id, kind, text, usefulness],
174 )
175 .expect("insert");
176 }
177
178 #[test]
179 fn only_preference_memories_make_the_profile() {
180 let c = conn();
181 insert(
182 &c,
183 "p",
184 "preference",
185 "prefer thiserror for library errors",
186 0.0,
187 );
188 insert(&c, "c", "convention", "always run cargo fmt", 0.0);
189 insert(&c, "f", "fact", "the schema is at v11", 0.0);
190
191 let profile = preferences(&c, false, 10).expect("preferences");
192 assert_eq!(profile.len(), 1, "got: {profile:?}");
193 assert!(profile[0].text.contains("thiserror"));
194 }
195
196 #[test]
199 fn proven_preferences_come_first() {
200 let c = conn();
201 insert(&c, "unproven", "preference", "prefer tabs", 0.0);
202 insert(&c, "proven", "preference", "prefer thiserror", 5.0);
203 let profile = preferences(&c, false, 10).expect("preferences");
204 assert_eq!(profile[0].memory_id, "proven", "got: {profile:?}");
205 }
206
207 #[test]
208 fn retired_preferences_are_excluded() {
209 let c = conn();
210 insert(&c, "live", "preference", "prefer thiserror", 0.0);
211 insert(&c, "dead", "preference", "prefer failure crate", 0.0);
212 c.execute(
213 "UPDATE memories SET invalidated_at = '2026-02-01T00:00:00Z' WHERE memory_id = 'dead'",
214 [],
215 )
216 .unwrap();
217 let profile = preferences(&c, false, 10).expect("preferences");
218 assert_eq!(profile.len(), 1);
219 assert_eq!(profile[0].memory_id, "live");
220 }
221
222 #[test]
223 fn hardening_profile_checks_numeric_start_and_expiry() {
224 let c = conn();
225 for id in ["current", "future", "expired", "invalid"] {
226 insert(&c, id, "preference", id, 0.0);
227 }
228 c.execute(
229 "UPDATE memories SET valid_from='2099-01-01T00:00:00Z' WHERE memory_id='future'",
230 [],
231 )
232 .unwrap();
233 c.execute("UPDATE memories SET valid_to=strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 minute') WHERE memory_id='expired'", []).unwrap();
234 c.execute(
235 "UPDATE memories SET valid_from='not-a-date' WHERE memory_id='invalid'",
236 [],
237 )
238 .unwrap();
239 let profile = preferences(&c, false, 10).unwrap();
240 assert_eq!(
241 profile
242 .iter()
243 .map(|p| p.memory_id.as_str())
244 .collect::<Vec<_>>(),
245 vec!["current"]
246 );
247 }
248
249 #[test]
252 fn project_preferences_lead_and_globals_fill_the_remainder() {
253 let project = conn();
254 let user = conn();
255 insert(&project, "proj", "preference", "prefer thiserror here", 0.0);
256 insert(
257 &user,
258 "glob",
259 "preference",
260 "prefer 2-space indent everywhere",
261 0.0,
262 );
263
264 let profile = build_profile(&project, Some(&user)).expect("profile");
265 assert_eq!(profile.len(), 2);
266 assert!(!profile[0].global, "project first: {profile:?}");
267 assert!(profile[1].global);
268 }
269
270 #[test]
271 fn a_global_duplicate_is_not_repeated() {
272 let project = conn();
273 let user = conn();
274 insert(&project, "proj", "preference", "prefer thiserror", 0.0);
275 insert(&user, "glob", "preference", "prefer thiserror", 0.0);
276 let profile = build_profile(&project, Some(&user)).expect("profile");
277 assert_eq!(profile.len(), 1, "got: {profile:?}");
278 }
279
280 #[test]
281 fn the_profile_is_capped() {
282 let c = conn();
283 for i in 0..(MAX_PREFERENCES * 3) {
284 insert(
285 &c,
286 &format!("p{i}"),
287 "preference",
288 &format!("preference {i}"),
289 0.0,
290 );
291 }
292 let profile = build_profile(&c, None).expect("profile");
293 assert_eq!(profile.len(), MAX_PREFERENCES);
294 }
295
296 #[test]
299 fn an_empty_profile_renders_nothing() {
300 assert!(render_profile(&[]).is_none());
301 }
302
303 #[test]
306 fn the_profile_reads_as_instructions() {
307 let rendered = render_profile(&[Preference {
308 memory_id: "p".into(),
309 text: "prefer thiserror for library errors".into(),
310 global: false,
311 }])
312 .expect("rendered");
313 assert!(
314 rendered.contains("follow these without being asked"),
315 "got: {rendered}"
316 );
317 assert!(rendered.contains("thiserror"));
318 }
319
320 #[test]
321 fn a_global_preference_is_marked_as_such() {
322 let rendered = render_profile(&[Preference {
323 memory_id: "p".into(),
324 text: "prefer 2-space indent".into(),
325 global: true,
326 }])
327 .expect("rendered");
328 assert!(
329 rendered.contains("across all your projects"),
330 "got: {rendered}"
331 );
332 }
333
334 #[test]
337 fn overlong_preferences_are_skipped_and_the_block_is_budgeted() {
338 let essay = Preference {
339 memory_id: "long".into(),
340 text: "x".repeat(MAX_PREFERENCE_CHARS + 1),
341 global: false,
342 };
343 assert!(
344 render_profile(std::slice::from_ref(&essay)).is_none(),
345 "a 160+ char 'preference' is an essay"
346 );
347
348 let many: Vec<Preference> = (0..MAX_PREFERENCES)
349 .map(|i| Preference {
350 memory_id: format!("p{i}"),
351 text: "prefer something quite specific and reasonably wordy".repeat(2),
352 global: false,
353 })
354 .collect();
355 let rendered = render_profile(&many).expect("rendered");
356 assert!(
357 rendered.len() <= PROFILE_CHAR_BUDGET + 80,
358 "block must stay budgeted: {} chars",
359 rendered.len()
360 );
361 }
362}