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_to IS NULL OR valid_to > datetime('now'))
75 ORDER BY usefulness_score DESC, created_at DESC
76 LIMIT ?1",
77 )?;
78 let rows = stmt
79 .query_map(rusqlite::params![limit as i64], |row| {
80 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
81 })?
82 .collect::<Result<Vec<_>, _>>()?;
83 Ok(rows
84 .into_iter()
85 .map(|(memory_id, text)| Preference {
86 memory_id,
87 text,
88 global,
89 })
90 .collect())
91}
92
93pub fn build_profile(
99 project_conn: &Connection,
100 user_conn: Option<&Connection>,
101) -> KimetsuResult<Vec<Preference>> {
102 let mut profile = preferences(project_conn, false, MAX_PREFERENCES)?;
103 if let Some(user_conn) = user_conn {
104 let remaining = MAX_PREFERENCES.saturating_sub(profile.len());
105 if remaining > 0 {
106 let global = preferences(user_conn, true, remaining)?;
107 for pref in global {
110 if !profile.iter().any(|p| p.text == pref.text) {
111 profile.push(pref);
112 }
113 }
114 }
115 }
116 Ok(profile)
117}
118
119pub fn render_profile(preferences: &[Preference]) -> Option<String> {
126 if preferences.is_empty() {
127 return None;
128 }
129 let mut lines = Vec::new();
130 let mut used = 0usize;
131 for pref in preferences {
132 let text = pref.text.trim();
133 if text.is_empty() || text.chars().count() > MAX_PREFERENCE_CHARS {
134 continue;
135 }
136 let line = if pref.global {
137 format!("- {text} (across all your projects)")
138 } else {
139 format!("- {text}")
140 };
141 if used + line.len() > PROFILE_CHAR_BUDGET {
142 break;
143 }
144 used += line.len();
145 lines.push(line);
146 }
147 if lines.is_empty() {
148 return None;
149 }
150 Some(format!(
151 "Standing preferences — follow these without being asked:\n{}",
152 lines.join("\n")
153 ))
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn conn() -> Connection {
161 let conn = Connection::open_in_memory().expect("open");
162 crate::schema::initialize(&conn).expect("schema");
163 conn
164 }
165
166 fn insert(conn: &Connection, id: &str, kind: &str, text: &str, usefulness: f32) {
167 conn.execute(
168 "INSERT INTO memories
169 (memory_id, scope, kind, text, normalized_text, confidence,
170 provenance_snapshot_json, created_at, usefulness_score)
171 VALUES (?1, 'project', ?2, ?3, ?3, 0.9, '{}', '2026-01-01T00:00:00Z', ?4)",
172 rusqlite::params![id, kind, text, usefulness],
173 )
174 .expect("insert");
175 }
176
177 #[test]
178 fn only_preference_memories_make_the_profile() {
179 let c = conn();
180 insert(
181 &c,
182 "p",
183 "preference",
184 "prefer thiserror for library errors",
185 0.0,
186 );
187 insert(&c, "c", "convention", "always run cargo fmt", 0.0);
188 insert(&c, "f", "fact", "the schema is at v11", 0.0);
189
190 let profile = preferences(&c, false, 10).expect("preferences");
191 assert_eq!(profile.len(), 1, "got: {profile:?}");
192 assert!(profile[0].text.contains("thiserror"));
193 }
194
195 #[test]
198 fn proven_preferences_come_first() {
199 let c = conn();
200 insert(&c, "unproven", "preference", "prefer tabs", 0.0);
201 insert(&c, "proven", "preference", "prefer thiserror", 5.0);
202 let profile = preferences(&c, false, 10).expect("preferences");
203 assert_eq!(profile[0].memory_id, "proven", "got: {profile:?}");
204 }
205
206 #[test]
207 fn retired_preferences_are_excluded() {
208 let c = conn();
209 insert(&c, "live", "preference", "prefer thiserror", 0.0);
210 insert(&c, "dead", "preference", "prefer failure crate", 0.0);
211 c.execute(
212 "UPDATE memories SET invalidated_at = '2026-02-01T00:00:00Z' WHERE memory_id = 'dead'",
213 [],
214 )
215 .unwrap();
216 let profile = preferences(&c, false, 10).expect("preferences");
217 assert_eq!(profile.len(), 1);
218 assert_eq!(profile[0].memory_id, "live");
219 }
220
221 #[test]
224 fn project_preferences_lead_and_globals_fill_the_remainder() {
225 let project = conn();
226 let user = conn();
227 insert(&project, "proj", "preference", "prefer thiserror here", 0.0);
228 insert(
229 &user,
230 "glob",
231 "preference",
232 "prefer 2-space indent everywhere",
233 0.0,
234 );
235
236 let profile = build_profile(&project, Some(&user)).expect("profile");
237 assert_eq!(profile.len(), 2);
238 assert!(!profile[0].global, "project first: {profile:?}");
239 assert!(profile[1].global);
240 }
241
242 #[test]
243 fn a_global_duplicate_is_not_repeated() {
244 let project = conn();
245 let user = conn();
246 insert(&project, "proj", "preference", "prefer thiserror", 0.0);
247 insert(&user, "glob", "preference", "prefer thiserror", 0.0);
248 let profile = build_profile(&project, Some(&user)).expect("profile");
249 assert_eq!(profile.len(), 1, "got: {profile:?}");
250 }
251
252 #[test]
253 fn the_profile_is_capped() {
254 let c = conn();
255 for i in 0..(MAX_PREFERENCES * 3) {
256 insert(
257 &c,
258 &format!("p{i}"),
259 "preference",
260 &format!("preference {i}"),
261 0.0,
262 );
263 }
264 let profile = build_profile(&c, None).expect("profile");
265 assert_eq!(profile.len(), MAX_PREFERENCES);
266 }
267
268 #[test]
271 fn an_empty_profile_renders_nothing() {
272 assert!(render_profile(&[]).is_none());
273 }
274
275 #[test]
278 fn the_profile_reads_as_instructions() {
279 let rendered = render_profile(&[Preference {
280 memory_id: "p".into(),
281 text: "prefer thiserror for library errors".into(),
282 global: false,
283 }])
284 .expect("rendered");
285 assert!(
286 rendered.contains("follow these without being asked"),
287 "got: {rendered}"
288 );
289 assert!(rendered.contains("thiserror"));
290 }
291
292 #[test]
293 fn a_global_preference_is_marked_as_such() {
294 let rendered = render_profile(&[Preference {
295 memory_id: "p".into(),
296 text: "prefer 2-space indent".into(),
297 global: true,
298 }])
299 .expect("rendered");
300 assert!(
301 rendered.contains("across all your projects"),
302 "got: {rendered}"
303 );
304 }
305
306 #[test]
309 fn overlong_preferences_are_skipped_and_the_block_is_budgeted() {
310 let essay = Preference {
311 memory_id: "long".into(),
312 text: "x".repeat(MAX_PREFERENCE_CHARS + 1),
313 global: false,
314 };
315 assert!(
316 render_profile(std::slice::from_ref(&essay)).is_none(),
317 "a 160+ char 'preference' is an essay"
318 );
319
320 let many: Vec<Preference> = (0..MAX_PREFERENCES)
321 .map(|i| Preference {
322 memory_id: format!("p{i}"),
323 text: "prefer something quite specific and reasonably wordy".repeat(2),
324 global: false,
325 })
326 .collect();
327 let rendered = render_profile(&many).expect("rendered");
328 assert!(
329 rendered.len() <= PROFILE_CHAR_BUDGET + 80,
330 "block must stay budgeted: {} chars",
331 rendered.len()
332 );
333 }
334}