Skip to main content

remem/memory/preference/
command.rs

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}