1use anyhow::{bail, ensure, Context, Result};
2use rusqlite::{params, Connection};
3use std::path::Path;
4
5use super::{
6 artifact_path_for_project, load_artifact_fail_open, ArtifactLoad, CompiledRule, RuleAction,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ProjectRules {
11 pub project: String,
12 pub compiled_at_epoch: i64,
13 pub rules: Vec<CompiledRule>,
14}
15
16pub fn list_project_rules(data_dir: &Path, project: &str) -> Result<ProjectRules> {
17 let path = artifact_path_for_project(data_dir, project);
18 let artifact = match load_artifact_fail_open(&path) {
19 ArtifactLoad::Loaded(artifact) => artifact,
20 ArtifactLoad::FailOpen { message, .. } => bail!(message),
21 };
22 Ok(ProjectRules {
23 project: project.to_string(),
24 compiled_at_epoch: artifact.compiled_at_epoch,
25 rules: artifact.rules,
26 })
27}
28
29pub fn set_rule_disabled(
30 conn: &Connection,
31 data_dir: &Path,
32 project: &str,
33 rule_id: &str,
34 disabled: bool,
35) -> Result<()> {
36 update_rule_override(
37 conn,
38 data_dir,
39 project,
40 rule_id,
41 RuleOverrideUpdate::Disabled(disabled),
42 )
43}
44
45pub fn set_rule_action(
46 conn: &Connection,
47 data_dir: &Path,
48 project: &str,
49 rule_id: &str,
50 action: RuleAction,
51 pre_execution_supported: bool,
52) -> Result<()> {
53 if action == RuleAction::Block && !pre_execution_supported {
54 bail!(
55 "block action requires a supported pre-execution host; Codex command enforcement is unsupported, so use 'warn' or select Claude Code explicitly"
56 );
57 }
58 update_rule_override(
59 conn,
60 data_dir,
61 project,
62 rule_id,
63 RuleOverrideUpdate::Action(action),
64 )
65}
66
67enum RuleOverrideUpdate {
68 Disabled(bool),
69 Action(RuleAction),
70}
71
72fn update_rule_override(
73 conn: &Connection,
74 data_dir: &Path,
75 project: &str,
76 rule_id: &str,
77 update: RuleOverrideUpdate,
78) -> Result<()> {
79 let project_rules = list_project_rules(data_dir, project)?;
80 let artifact_rule = project_rules
81 .rules
82 .iter()
83 .find(|rule| rule.rule_id == rule_id)
84 .with_context(|| format!("compiled rule '{rule_id}' not found for project '{project}'"))?;
85 let config = crate::runtime_config::rule_compilation_config()
86 .context("read rule compilation config before rule override")?;
87 ensure!(
88 config.enabled,
89 "rule compilation is disabled; enable rule_compilation.enabled before changing compiled rule overrides"
90 );
91
92 let tx = conn.unchecked_transaction()?;
93 let current_rules = crate::rules::compile_project_rules(&tx, project, config)
94 .context("validate current rule eligibility before override")?;
95 let current_rule = current_rules
96 .rules
97 .iter()
98 .find(|rule| rule.rule_id == rule_id)
99 .with_context(|| {
100 format!(
101 "compiled rule '{rule_id}' is stale and no longer eligible for project '{project}'"
102 )
103 })?;
104 ensure!(
105 artifact_rule.source_memory_id == current_rule.source_memory_id
106 && artifact_rule.predicate == current_rule.predicate,
107 "compiled rule '{rule_id}' is stale; wait for the pending worker rebuild before changing it"
108 );
109
110 execute_override_upsert(&tx, project, current_rule, update)?;
111 crate::memory::preference::compilation::enqueue_project_required(&tx, project)?;
112 tx.commit()?;
113 Ok(())
114}
115
116fn execute_override_upsert(
117 conn: &Connection,
118 project: &str,
119 rule: &CompiledRule,
120 update: RuleOverrideUpdate,
121) -> Result<()> {
122 let now = chrono::Utc::now().timestamp();
123 match update {
124 RuleOverrideUpdate::Disabled(disabled) => conn.execute(
125 "INSERT INTO preference_rule_overrides
126 (project, rule_id, source_memory_id, disabled, action_override,
127 reason, updated_by, updated_at_epoch)
128 VALUES (?1, ?2, ?3, ?4, ?5, 'remem rules CLI', 'user', ?6)
129 ON CONFLICT(project, rule_id) DO UPDATE SET
130 source_memory_id = excluded.source_memory_id,
131 disabled = excluded.disabled,
132 reason = excluded.reason,
133 updated_by = excluded.updated_by,
134 updated_at_epoch = excluded.updated_at_epoch",
135 params![
136 project,
137 rule.rule_id,
138 rule.source_memory_id,
139 i64::from(disabled),
140 rule.override_state
141 .action_override
142 .map(rule_action_db_value),
143 now
144 ],
145 ),
146 RuleOverrideUpdate::Action(action) => conn.execute(
147 "INSERT INTO preference_rule_overrides
148 (project, rule_id, source_memory_id, disabled, action_override,
149 reason, updated_by, updated_at_epoch)
150 VALUES (?1, ?2, ?3, ?4, ?5, 'remem rules CLI', 'user', ?6)
151 ON CONFLICT(project, rule_id) DO UPDATE SET
152 source_memory_id = excluded.source_memory_id,
153 action_override = excluded.action_override,
154 reason = excluded.reason,
155 updated_by = excluded.updated_by,
156 updated_at_epoch = excluded.updated_at_epoch",
157 params![
158 project,
159 rule.rule_id,
160 rule.source_memory_id,
161 i64::from(rule.override_state.disabled),
162 rule_action_db_value(action),
163 now
164 ],
165 ),
166 }
167 .with_context(|| format!("persist override for compiled rule '{}'", rule.rule_id))?;
168 Ok(())
169}
170
171fn rule_action_db_value(action: RuleAction) -> &'static str {
172 match action {
173 RuleAction::Warn => "warn",
174 RuleAction::Block => "block",
175 }
176}
177
178#[cfg(test)]
179mod tests;