1use anyhow::Result;
2use rusqlite::{params, Connection};
3
4use crate::memory;
5
6use super::{query_global_preferences, query_project_preferences};
7
8pub fn list_preferences(conn: &Connection, project: &str) -> Result<()> {
9 let project_prefs = query_project_preferences(conn, project, 50)?;
10 let global_prefs = query_global_preferences(conn, 10).unwrap_or_default();
11
12 if project_prefs.is_empty() && global_prefs.is_empty() {
13 println!("No preferences found.");
14 return Ok(());
15 }
16
17 if !project_prefs.is_empty() {
18 println!("Project preferences ({}):", project);
19 for pref in &project_prefs {
20 let text_preview: String = pref.text.chars().take(80).collect();
21 println!(" [{}] {}", pref.id, text_preview);
22 }
23 }
24
25 if !global_prefs.is_empty() {
26 println!("\nGlobal preferences:");
27 for pref in &global_prefs {
28 let text_preview: String = pref.text.chars().take(80).collect();
29 println!(" [{}] {} (from: {})", pref.id, text_preview, pref.project);
30 }
31 }
32
33 Ok(())
34}
35
36pub fn add_preference(conn: &Connection, project: &str, text: &str, global: bool) -> Result<i64> {
37 let title = format!("Preference: {}", &text[..text.len().min(60)]);
38 let topic_key = format!(
39 "manual-preference-{}",
40 crate::memory::slugify_for_topic(text, 50)
41 );
42 let scope = if global { "global" } else { "project" };
43 let tx = conn.unchecked_transaction()?;
44 let mut stmt = tx.prepare(
45 "SELECT m.id, m.content,
46 EXISTS(
47 SELECT 1 FROM memory_preference_reinforcements r WHERE r.memory_id = m.id
48 )
49 FROM memories m
50 WHERE m.memory_type = 'preference'",
51 )?;
52 let rows = stmt.query_map([], |row| {
53 Ok((
54 row.get::<_, i64>(0)?,
55 row.get::<_, String>(1)?,
56 row.get::<_, bool>(2)?,
57 ))
58 })?;
59 let previous_preferences = crate::db::query::collect_rows(rows)?;
60 drop(stmt);
61 let id = memory::insert_memory_full(
62 &tx,
63 None,
64 project,
65 Some(&topic_key),
66 &title,
67 text,
68 "preference",
69 None,
70 None,
71 scope,
72 None,
73 )?;
74 if let Some((_, previous_text, _)) = previous_preferences
75 .iter()
76 .find(|(memory_id, _, had_rule_state)| *memory_id == id && *had_rule_state)
77 {
78 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &[id])?;
79 crate::memory::preference::reinforcement::reconcile_in_place_preference_update(
80 &tx,
81 id,
82 previous_text,
83 text,
84 )?;
85 }
86 tx.commit()?;
87 Ok(id)
88}
89
90pub fn remove_preference(conn: &Connection, id: i64) -> Result<bool> {
91 let tx = conn.unchecked_transaction()?;
92 crate::memory::preference::compilation::enqueue_for_memory_ids(&tx, &[id])?;
93 let count = tx.execute(
94 "UPDATE memories SET status = 'archived' WHERE id = ?1 AND memory_type = 'preference'",
95 params![id],
96 )?;
97 tx.commit()?;
98 Ok(count > 0)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::db::test_support::ScopedTestDataDir;
105
106 #[test]
107 fn cli_add_refinement_reconciles_candidate_rule_state_and_enqueues_compile() -> Result<()> {
108 let _dir = ScopedTestDataDir::new("preference-cli-add-rule-reconciliation");
109 crate::runtime_config::init_config()?;
110 crate::runtime_config::set_config_value("rule_compilation.enabled", "true")?;
111 let conn = crate::db::open_db()?;
112 let original = "Do not add AI-generated-by or Co-authored-by trailers to commits";
113 let refinement = "Do not add Co-authored-by trailers to commits";
114 let id = add_preference(&conn, "/repo", original, false)?;
115 conn.execute(
116 "INSERT INTO memory_candidates
117 (id, scope, memory_type, topic_key, text, evidence_event_ids,
118 confidence, risk_class, review_status, created_at_epoch, updated_at_epoch,
119 source_trust_class)
120 VALUES (900, 'project', 'preference', 'commit-trailers', ?1, '[1,2,3]',
121 0.95, 'low', 'approved', 1, 3, 'user_prompt')",
122 [original],
123 )?;
124 conn.execute(
125 "UPDATE memories
126 SET source_candidate_id = 900, source_trust_class = 'user_prompt'
127 WHERE id = ?1",
128 [id],
129 )?;
130 conn.execute(
131 "INSERT INTO memory_preference_reinforcements
132 (memory_id, reinforcement_count, source_evidence,
133 last_reinforced_at_epoch, created_at_epoch, updated_at_epoch,
134 machine_checkable, risk_class)
135 VALUES (?1, 3, '[1,2,3]', 3, 1, 3, 1, 'low')",
136 [id],
137 )?;
138 conn.execute(
139 "INSERT INTO preference_rule_overrides
140 (project, rule_id, source_memory_id, disabled, action_override,
141 updated_by, updated_at_epoch)
142 VALUES ('/repo', ?1, ?2, 1, 'block', 'user', 4)",
143 params![format!("pref-{id}-1"), id],
144 )?;
145
146 let refined_id = add_preference(&conn, "/repo", refinement, false)?;
147
148 assert_eq!(
149 refined_id, id,
150 "CLI refinement should reuse the canonical row"
151 );
152 let state: (String, Option<i64>) = conn.query_row(
153 "SELECT content, source_candidate_id FROM memories WHERE id = ?1",
154 [id],
155 |row| Ok((row.get(0)?, row.get(1)?)),
156 )?;
157 assert_eq!(state, (refinement.to_string(), None));
158 let reinforcement_rows: i64 = conn.query_row(
159 "SELECT COUNT(*) FROM memory_preference_reinforcements WHERE memory_id = ?1",
160 [id],
161 |row| row.get(0),
162 )?;
163 assert_eq!(reinforcement_rows, 0);
164 let override_rows: i64 = conn.query_row(
165 "SELECT COUNT(*) FROM preference_rule_overrides WHERE source_memory_id = ?1",
166 [id],
167 |row| row.get(0),
168 )?;
169 assert_eq!(override_rows, 0);
170 let pending: i64 = conn.query_row(
171 "SELECT COUNT(*) FROM jobs
172 WHERE job_type = 'compile_rules' AND project = '/repo' AND state = 'pending'",
173 [],
174 |row| row.get(0),
175 )?;
176 assert_eq!(pending, 1);
177 Ok(())
178 }
179}