Skip to main content

remem/rules/
compiler.rs

1//! Worker-side preference rule compiler (SP671-T3).
2//!
3//! Canonical SQLite state is compiled only by the background worker. Hooks
4//! read the derived artifact and never perform DB, network, or LLM work.
5
6use anyhow::{bail, Context, Result};
7use rusqlite::{params, Connection, OptionalExtension};
8use std::io::ErrorKind;
9
10use crate::rules::artifact::{CompiledRule, CompiledRulesArtifact, RuleAction, RulePredicate};
11use crate::rules::store::{
12    artifact_path_for_project, load_artifact_fail_open, write_artifact_atomic, ArtifactLoad,
13};
14use crate::runtime_config::{rule_compilation_config, RuleCompilationConfig};
15
16mod classify;
17
18pub use classify::{
19    classify_preference_predicate, classify_preference_predicates, PreferenceClassification,
20    PreferencePredicate,
21};
22
23const PACKAGE_MANAGER_MESSAGE: &str = "Command violates a compiled package-manager preference";
24const FORBIDDEN_COMMAND_MESSAGE: &str = "Command violates a compiled forbidden-command preference";
25const COMMIT_TRAILER_MESSAGE: &str = "Commit message violates a compiled trailer preference";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CompileOutcome {
29    pub project: String,
30    pub rule_count: usize,
31    pub artifact_path: std::path::PathBuf,
32    pub artifact_changed: bool,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct CompileSweepOutcome {
37    pub projects_seen: usize,
38    pub artifacts_changed: usize,
39    pub failures: usize,
40}
41
42/// Rebuild artifacts for every known project on a bounded cadence. One bad
43/// project is isolated so it cannot stop unrelated memory work.
44pub fn run_compile_rules_sweep() -> Result<CompileSweepOutcome> {
45    let config = rule_compilation_config()?;
46    if !config.enabled {
47        return Ok(CompileSweepOutcome::default());
48    }
49
50    let conn = crate::db::open_db()?;
51    let projects = load_rule_compilation_projects(&conn)?;
52    drop(conn);
53
54    let mut outcome = CompileSweepOutcome {
55        projects_seen: projects.len(),
56        ..Default::default()
57    };
58    for project in &projects {
59        match run_compile_rules_job(project) {
60            Ok(Some(project_outcome)) => {
61                outcome.artifacts_changed += usize::from(project_outcome.artifact_changed);
62            }
63            Ok(None) => {}
64            Err(error) => {
65                outcome.failures += 1;
66                crate::log::error(
67                    "rules",
68                    &format!("rule compilation sweep failed for project {project}: {error}"),
69                );
70            }
71        }
72    }
73    Ok(outcome)
74}
75
76/// Worker-only entry point. Failures are durably recorded and propagated;
77/// unchanged successful artifacts and diagnostics are not rewritten.
78pub fn run_compile_rules_job(project: &str) -> Result<Option<CompileOutcome>> {
79    let config = rule_compilation_config()?;
80    if !config.enabled {
81        return Ok(None);
82    }
83    let conn = crate::db::open_db()?;
84    let data_dir = crate::db::absolute_data_dir()?;
85    let artifact_path = artifact_path_for_project(&data_dir, project);
86
87    let mut conflict_messages = Vec::new();
88    let artifact = match compile_project_rules_with_conflicts(
89        &conn,
90        project,
91        config,
92        &mut conflict_messages,
93    ) {
94        Ok(artifact) => artifact,
95        Err(error) => {
96            if let Err(diagnostic_error) =
97                record_diagnostic(&conn, project, "error", &error.to_string(), None, None)
98            {
99                crate::log::error(
100                    "rules",
101                    &format!(
102                        "compile and diagnostic persistence failed for project {project}: {diagnostic_error}"
103                    ),
104                );
105                return Err(error.context(format!(
106                    "failed to persist compile diagnostic: {diagnostic_error}"
107                )));
108            }
109            crate::log::error(
110                "rules",
111                &format!("compile failed for project {project}: {error}"),
112            );
113            return Err(error);
114        }
115    };
116
117    let rule_count = artifact.rules.len();
118    if matches!(
119        load_artifact_fail_open(&artifact_path),
120        ArtifactLoad::Loaded(existing) if existing.rules == artifact.rules
121    ) {
122        record_compile_success(
123            &conn,
124            project,
125            rule_count,
126            &artifact_path,
127            false,
128            &conflict_messages,
129        )?;
130        return Ok(Some(CompileOutcome {
131            project: project.to_string(),
132            rule_count,
133            artifact_path,
134            artifact_changed: false,
135        }));
136    }
137
138    let previous_artifact = match snapshot_artifact(&artifact_path) {
139        Ok(previous) => previous,
140        Err(error) => {
141            let message = format!("artifact snapshot failed: {error}");
142            if let Err(diagnostic_error) =
143                record_diagnostic(&conn, project, "error", &message, None, None)
144            {
145                return Err(error.context(format!(
146                    "failed to persist artifact snapshot diagnostic: {diagnostic_error}"
147                )));
148            }
149            return Err(error);
150        }
151    };
152
153    if let Err(error) = write_artifact_atomic(&artifact_path, &artifact) {
154        let message = format!("artifact write failed: {error}");
155        crate::log::error(
156            "rules",
157            &format!("compile artifact write failed for project {project}: {error}"),
158        );
159        if let Err(diagnostic_error) =
160            record_diagnostic(&conn, project, "error", &message, None, None)
161        {
162            return Err(error.context(format!(
163                "failed to persist artifact write diagnostic: {diagnostic_error}"
164            )));
165        }
166        return Err(error);
167    }
168
169    if let Err(diagnostic_error) = record_compile_success(
170        &conn,
171        project,
172        rule_count,
173        &artifact_path,
174        true,
175        &conflict_messages,
176    ) {
177        crate::log::error(
178            "rules",
179            &format!(
180                "compile success diagnostic failed for project {project}; restoring previous artifact: {diagnostic_error}"
181            ),
182        );
183        return match restore_artifact(&artifact_path, previous_artifact) {
184            Ok(()) => Err(diagnostic_error
185                .context("compile success diagnostic failed; previous artifact restored")),
186            Err(restore_error) => Err(diagnostic_error.context(format!(
187                "compile success diagnostic failed and previous artifact restoration failed: {restore_error}"
188            ))),
189        };
190    }
191    Ok(Some(CompileOutcome {
192        project: project.to_string(),
193        rule_count,
194        artifact_path,
195        artifact_changed: true,
196    }))
197}
198
199fn snapshot_artifact(path: &std::path::Path) -> Result<Option<Vec<u8>>> {
200    match std::fs::read(path) {
201        Ok(contents) => Ok(Some(contents)),
202        Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
203        Err(error) => Err(error)
204            .with_context(|| format!("read previous compiled rules artifact {}", path.display())),
205    }
206}
207
208fn restore_artifact(path: &std::path::Path, previous: Option<Vec<u8>>) -> Result<()> {
209    match previous {
210        Some(contents) => crate::atomic_file::write_atomic(path, contents)
211            .with_context(|| format!("restore compiled rules artifact {}", path.display())),
212        None => match std::fs::remove_file(path) {
213            Ok(()) => Ok(()),
214            Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
215            Err(error) => Err(error)
216                .with_context(|| format!("remove unpublished artifact {}", path.display())),
217        },
218    }
219}
220
221/// Pure compile pass used by tests and the worker wrapper.
222pub fn compile_project_rules(
223    conn: &Connection,
224    project: &str,
225    config: RuleCompilationConfig,
226) -> Result<CompiledRulesArtifact> {
227    compile_project_rules_with_conflicts(conn, project, config, &mut Vec::new())
228}
229
230fn compile_project_rules_with_conflicts(
231    conn: &Connection,
232    project: &str,
233    config: RuleCompilationConfig,
234    conflict_messages: &mut Vec<String>,
235) -> Result<CompiledRulesArtifact> {
236    let now = chrono::Utc::now().timestamp();
237    let eligible = select_eligible_preferences(conn, project, config.min_reinforcement, now)?;
238    let overrides = load_overrides(conn, project)?;
239
240    // Rows are project-before-global and newest-first. One source may emit
241    // several distinct trailer rules, but a later source cannot replace a
242    // conflict family already claimed by the authoritative earlier source.
243    let mut conflict_sources = std::collections::HashMap::<String, i64>::new();
244    let mut rules = Vec::new();
245    for pref in eligible {
246        let classifications = classify_preference_predicates(&pref.content);
247        if classifications.is_empty() {
248            bail!(
249                "preference memory {} is marked machine_checkable but no safe v1 predicate can be derived",
250                pref.memory_id
251            );
252        }
253
254        for (index, classification) in classifications.into_iter().enumerate() {
255            let conflict_key = classification.predicate.conflict_key();
256            match conflict_sources.get(&conflict_key) {
257                Some(source_memory_id) if *source_memory_id != pref.memory_id => {
258                    conflict_messages.push(format!(
259                        "dropped preference #{} behind authoritative conflicting rule ({conflict_key})",
260                        pref.memory_id
261                    ));
262                    continue;
263                }
264                Some(_) => {}
265                None => {
266                    conflict_sources.insert(conflict_key, pref.memory_id);
267                }
268            }
269
270            let rule_id = format!("pref-{}-{}", pref.memory_id, index + 1);
271            let predicate = match classification.predicate {
272                PreferencePredicate::CommandRegex { pattern, .. } => RulePredicate::CommandRegex {
273                    pattern,
274                    message: PACKAGE_MANAGER_MESSAGE.to_string(),
275                },
276                PreferencePredicate::CommitTrailerForbidden { trailer, .. } => {
277                    RulePredicate::CommitTrailerForbidden {
278                        trailer,
279                        message: COMMIT_TRAILER_MESSAGE.to_string(),
280                    }
281                }
282                PreferencePredicate::GitPushForceForbidden { .. } => {
283                    RulePredicate::GitPushForceForbidden {
284                        message: FORBIDDEN_COMMAND_MESSAGE.to_string(),
285                    }
286                }
287            };
288            let override_state =
289                overrides
290                    .get(&rule_id)
291                    .cloned()
292                    .unwrap_or(crate::rules::RuleOverrideState {
293                        disabled: false,
294                        action_override: None,
295                    });
296            rules.push(CompiledRule {
297                rule_id,
298                source_memory_id: pref.memory_id,
299                reinforcement_count: pref.reinforcement_count,
300                action: RuleAction::Warn,
301                override_state,
302                predicate,
303            });
304        }
305    }
306
307    rules.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
308    Ok(CompiledRulesArtifact::new(now, rules))
309}
310
311struct EligiblePreference {
312    memory_id: i64,
313    content: String,
314    reinforcement_count: i64,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318enum ClosedValue {
319    Allowed,
320    Denied,
321    Unknown,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325enum EligibilityScope {
326    Project,
327    Global,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331enum RuleEligibilityDecision {
332    Eligible,
333    Rejected(RejectReason),
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337enum RejectReason {
338    Type,
339    Lifecycle,
340    Expiry,
341    Scope,
342    Owner,
343    Trust,
344    MachineCheckable,
345    Threshold,
346    ReinforcementRisk,
347    CandidateRisk,
348    Review,
349    Policy,
350    Suppressed,
351}
352
353const KNOWN_TRUST: &[&str] = &[
354    "local_tool_output",
355    "repo_file",
356    "user_prompt",
357    "external_content",
358    "pack",
359];
360const KNOWN_RISK: &[&str] = &["low", "medium", "high", "unknown"];
361const KNOWN_REVIEW: &[&str] = &[
362    "pending_review",
363    "quarantined",
364    "auto_promoted",
365    "approved",
366    "edited",
367    "rejected",
368    "discarded",
369    "deferred",
370];
371
372#[derive(Clone, Copy)]
373struct RuleEligibilityInput<'a> {
374    memory_type: ClosedValue,
375    lifecycle: ClosedValue,
376    expires_at: Option<i64>,
377    scope: Option<EligibilityScope>,
378    owner_scope: Option<&'a str>,
379    owner_key: Option<&'a str>,
380    target_project: Option<&'a str>,
381    legacy_project: &'a str,
382    current_project: &'a str,
383    trust: ClosedValue,
384    machine_checkable: i64,
385    reinforcement_count: i64,
386    min_reinforcement: i64,
387    reinforcement_risk: ClosedValue,
388    candidate_risk: ClosedValue,
389    review: ClosedValue,
390    policy: ClosedValue,
391    now: i64,
392}
393
394fn closed_value(value: &str, known: &[&str], allowed: &[&str]) -> ClosedValue {
395    if allowed.contains(&value) {
396        ClosedValue::Allowed
397    } else if known.contains(&value) {
398        ClosedValue::Denied
399    } else {
400        ClosedValue::Unknown
401    }
402}
403
404fn non_empty(value: Option<&str>) -> Option<&str> {
405    value.filter(|value| !value.is_empty())
406}
407
408fn eligibility_decision(input: &RuleEligibilityInput<'_>) -> RuleEligibilityDecision {
409    let reject = |reason| RuleEligibilityDecision::Rejected(reason);
410    if input.memory_type != ClosedValue::Allowed {
411        return reject(RejectReason::Type);
412    }
413    if input.lifecycle != ClosedValue::Allowed {
414        return reject(RejectReason::Lifecycle);
415    }
416    if input.expires_at.is_some_and(|expiry| expiry <= input.now) {
417        return reject(RejectReason::Expiry);
418    }
419    let owner_matches = match input.scope {
420        Some(EligibilityScope::Project) => {
421            let authority = non_empty(input.target_project)
422                .or_else(|| non_empty(input.owner_key))
423                .unwrap_or(input.legacy_project);
424            input.owner_scope == Some("repo") && authority == input.current_project
425        }
426        Some(EligibilityScope::Global) => {
427            input.owner_scope == Some("user")
428                && input.owner_key == Some("user:default")
429                && non_empty(input.target_project).is_none()
430        }
431        None => return reject(RejectReason::Scope),
432    };
433    if !owner_matches {
434        return reject(RejectReason::Owner);
435    }
436    for (value, reason) in [
437        (input.trust, RejectReason::Trust),
438        (input.reinforcement_risk, RejectReason::ReinforcementRisk),
439        (input.candidate_risk, RejectReason::CandidateRisk),
440        (input.review, RejectReason::Review),
441    ] {
442        if value != ClosedValue::Allowed {
443            return reject(reason);
444        }
445    }
446    if input.machine_checkable != 1 {
447        return reject(RejectReason::MachineCheckable);
448    }
449    if input.reinforcement_count < input.min_reinforcement {
450        return reject(RejectReason::Threshold);
451    }
452    match input.policy {
453        ClosedValue::Allowed => {}
454        ClosedValue::Denied => return reject(RejectReason::Suppressed),
455        ClosedValue::Unknown => return reject(RejectReason::Policy),
456    }
457    RuleEligibilityDecision::Eligible
458}
459
460fn select_eligible_preferences(
461    conn: &Connection,
462    project: &str,
463    min_reinforcement: i64,
464    now: i64,
465) -> Result<Vec<EligiblePreference>> {
466    let policy_filter = crate::memory::suppression::memory_policy_filter_sql("m");
467    let sql = format!(
468        "SELECT m.id, m.content, m.memory_type, m.status, m.expires_at_epoch,
469                m.scope, m.owner_scope, m.owner_key, m.target_project, m.project,
470                m.source_trust_class, r.machine_checkable, r.reinforcement_count,
471                r.risk_class, c.risk_class, c.review_status,
472                CASE WHEN EXISTS (
473                    SELECT 1 FROM memory_suppressions malformed
474                    WHERE malformed.status NOT IN ('active', 'revoked')
475                       OR (malformed.status = 'active' AND COALESCE((
476                        (malformed.target_kind IN ('memory', 'user_claim', 'user_candidate')
477                         AND malformed.target_id > 0 AND malformed.target_value IS NULL)
478                        OR (malformed.target_kind IN ('topic_key', 'entity', 'pattern')
479                            AND malformed.target_id IS NULL
480                            AND length(trim(malformed.target_value)) > 0)
481                        OR (malformed.target_kind = 'summary'
482                            AND (malformed.target_id IS NULL OR malformed.target_id > 0)
483                            AND (malformed.target_id > 0
484                                 OR length(trim(malformed.target_value)) > 0))), 0) = 0))
485                     THEN -1 WHEN {policy_filter} THEN 1 ELSE 0 END
486         FROM memories m
487         JOIN memory_preference_reinforcements r ON r.memory_id = m.id
488         JOIN memory_candidates c ON c.id = m.source_candidate_id
489         WHERE m.project = ?1 OR m.target_project = ?1 OR m.owner_key = ?1
490            OR m.scope = 'global'
491         ORDER BY CASE WHEN m.scope = 'project' THEN 0 ELSE 1 END,
492                  m.updated_at_epoch DESC, m.id DESC"
493    );
494    let mut stmt = conn.prepare(&sql)?;
495    let rows = stmt.query_map(params![project], |row| {
496        Ok((
497            row.get::<_, i64>(0)?,
498            row.get::<_, String>(1)?,
499            row.get::<_, String>(2)?,
500            row.get::<_, String>(3)?,
501            row.get::<_, Option<i64>>(4)?,
502            row.get::<_, Option<String>>(5)?,
503            row.get::<_, Option<String>>(6)?,
504            row.get::<_, Option<String>>(7)?,
505            row.get::<_, Option<String>>(8)?,
506            row.get::<_, String>(9)?,
507            row.get::<_, String>(10)?,
508            row.get::<_, i64>(11)?,
509            row.get::<_, i64>(12)?,
510            row.get::<_, String>(13)?,
511            row.get::<_, String>(14)?,
512            row.get::<_, String>(15)?,
513            row.get::<_, i64>(16)?,
514        ))
515    })?;
516    let rows = crate::db::query::collect_rows(rows)
517        .context("load rule eligibility candidates for compilation")?;
518    let mut eligible = Vec::new();
519    for row in rows {
520        let memory_type = match crate::memory::types::MemoryType::parse(&row.2) {
521            Some(crate::memory::types::MemoryType::Preference) => ClosedValue::Allowed,
522            Some(_) => ClosedValue::Denied,
523            None => ClosedValue::Unknown,
524        };
525        let input = RuleEligibilityInput {
526            memory_type,
527            lifecycle: closed_value(&row.3, &["active", "stale", "archived"], &["active"]),
528            expires_at: row.4,
529            scope: row.5.as_deref().and_then(|scope| match scope {
530                "project" => Some(EligibilityScope::Project),
531                "global" => Some(EligibilityScope::Global),
532                _ => None,
533            }),
534            owner_scope: row.6.as_deref(),
535            owner_key: row.7.as_deref(),
536            target_project: row.8.as_deref(),
537            legacy_project: &row.9,
538            current_project: project,
539            trust: closed_value(&row.10, KNOWN_TRUST, &KNOWN_TRUST[..3]),
540            machine_checkable: row.11,
541            reinforcement_count: row.12,
542            min_reinforcement,
543            reinforcement_risk: closed_value(&row.13, KNOWN_RISK, &["low"]),
544            candidate_risk: closed_value(&row.14, KNOWN_RISK, &["low"]),
545            review: closed_value(
546                &row.15,
547                KNOWN_REVIEW,
548                &["approved", "edited", "auto_promoted"],
549            ),
550            policy: match row.16 {
551                1 => ClosedValue::Allowed,
552                0 => ClosedValue::Denied,
553                _ => ClosedValue::Unknown,
554            },
555            now,
556        };
557        match eligibility_decision(&input) {
558            RuleEligibilityDecision::Eligible => eligible.push(EligiblePreference {
559                memory_id: row.0,
560                content: row.1,
561                reinforcement_count: row.12,
562            }),
563            RuleEligibilityDecision::Rejected(reason)
564                if matches!(memory_type, ClosedValue::Unknown)
565                    || [
566                        input.lifecycle,
567                        input.trust,
568                        input.reinforcement_risk,
569                        input.candidate_risk,
570                        input.review,
571                    ]
572                    .contains(&ClosedValue::Unknown)
573                    || matches!(
574                        reason,
575                        RejectReason::Scope | RejectReason::Owner | RejectReason::Policy
576                    ) =>
577            {
578                crate::log::error(
579                    "rules",
580                    &format!("rule eligibility rejected memory {}: {reason:?}", row.0),
581                )
582            }
583            RuleEligibilityDecision::Rejected(_) => {}
584        }
585    }
586    Ok(eligible)
587}
588
589fn load_rule_compilation_projects(conn: &Connection) -> Result<Vec<String>> {
590    let mut stmt = conn.prepare(
591        "SELECT project
592         FROM (
593             SELECT DISTINCT project FROM memories
594             UNION
595             SELECT DISTINCT CASE
596                        WHEN COALESCE(m.scope, 'project') = 'global'
597                         AND m.owner_scope = 'user'
598                         AND m.owner_key = 'user:default'
599                         AND COALESCE(NULLIF(m.target_project, ''), '') = ''
600                        THEN m.project
601                        ELSE COALESCE(
602                            NULLIF(m.target_project, ''),
603                            CASE WHEN m.owner_scope = 'repo' THEN NULLIF(m.owner_key, '') END,
604                            m.project
605                        )
606                    END AS project
607             FROM memories m
608             JOIN memory_preference_reinforcements r ON r.memory_id = m.id
609             UNION
610             SELECT DISTINCT project FROM preference_rule_overrides
611             UNION
612             SELECT DISTINCT project FROM preference_rule_diagnostics
613             UNION
614             SELECT DISTINCT project FROM jobs WHERE job_type = 'compile_rules'
615             UNION
616             SELECT DISTINCT project_path AS project FROM projects
617         )
618         WHERE project IS NOT NULL AND TRIM(project) <> ''
619         ORDER BY project",
620    )?;
621    let rows = stmt.query_map([], |row| row.get(0))?;
622    crate::db::query::collect_rows(rows).context("load projects for rule compilation sweep")
623}
624
625fn load_overrides(
626    conn: &Connection,
627    project: &str,
628) -> Result<std::collections::HashMap<String, crate::rules::RuleOverrideState>> {
629    let mut stmt = conn.prepare(
630        "SELECT rule_id, disabled, action_override
631         FROM preference_rule_overrides
632         WHERE project = ?1",
633    )?;
634    let rows = stmt.query_map(params![project], |row| {
635        let rule_id: String = row.get(0)?;
636        let disabled: i64 = row.get(1)?;
637        let action_override: Option<String> = row.get(2)?;
638        Ok((rule_id, disabled != 0, action_override))
639    })?;
640    let mut map = std::collections::HashMap::new();
641    for row in rows {
642        let (rule_id, disabled, action_override) = row?;
643        let action_override = match action_override.as_deref() {
644            Some("warn") => Some(RuleAction::Warn),
645            Some("block") => Some(RuleAction::Block),
646            Some(other) => bail!("invalid action_override '{other}' for rule {rule_id}"),
647            None => None,
648        };
649        map.insert(
650            rule_id,
651            crate::rules::RuleOverrideState {
652                disabled,
653                action_override,
654            },
655        );
656    }
657    Ok(map)
658}
659
660fn record_diagnostic(
661    conn: &Connection,
662    project: &str,
663    status: &str,
664    message: &str,
665    rule_count: Option<usize>,
666    artifact_path: Option<&str>,
667) -> Result<()> {
668    if status != "ok"
669        && latest_compile_diagnostic(conn, project)?.is_some_and(
670            |(latest_status, latest_message)| latest_status == status && latest_message == message,
671        )
672    {
673        return Ok(());
674    }
675    let now = chrono::Utc::now().timestamp();
676    conn.execute(
677        "INSERT INTO preference_rule_diagnostics
678         (project, event_kind, status, message, rule_id, artifact_path, rule_count, occurred_at_epoch)
679         VALUES (?1, 'compile', ?2, ?3, NULL, ?4, ?5, ?6)",
680        params![
681            project,
682            status,
683            message,
684            artifact_path,
685            rule_count.map(|count| count as i64),
686            now
687        ],
688    )
689    .with_context(|| format!("persist compile diagnostic for {project}"))?;
690    Ok(())
691}
692
693fn record_compile_success(
694    conn: &Connection,
695    project: &str,
696    rule_count: usize,
697    artifact_path: &std::path::Path,
698    artifact_changed: bool,
699    conflict_messages: &[String],
700) -> Result<()> {
701    if !conflict_messages.is_empty() {
702        let mut conflicts = conflict_messages.to_vec();
703        conflicts.sort();
704        conflicts.dedup();
705        return record_diagnostic(
706            conn,
707            project,
708            "warn",
709            &format!(
710                "compiled {rule_count} rule(s) with conflicts: {}",
711                conflicts.join("; ")
712            ),
713            Some(rule_count),
714            Some(&artifact_path.display().to_string()),
715        );
716    }
717
718    let latest = latest_compile_diagnostic(conn, project)
719        .with_context(|| format!("load latest compile diagnostic for {project}"))?;
720    if artifact_changed
721        || latest
722            .as_ref()
723            .is_none_or(|(latest_status, _)| latest_status != "ok")
724    {
725        record_diagnostic(
726            conn,
727            project,
728            "ok",
729            &format!("compiled {rule_count} rule(s)"),
730            Some(rule_count),
731            Some(&artifact_path.display().to_string()),
732        )?;
733    }
734    Ok(())
735}
736
737fn latest_compile_diagnostic(
738    conn: &Connection,
739    project: &str,
740) -> rusqlite::Result<Option<(String, String)>> {
741    conn.query_row(
742        "SELECT status, COALESCE(message, '')
743         FROM preference_rule_diagnostics
744         WHERE project = ?1
745           AND event_kind = 'compile'
746         ORDER BY id DESC
747         LIMIT 1",
748        params![project],
749        |row| Ok((row.get(0)?, row.get(1)?)),
750    )
751    .optional()
752}
753
754#[cfg(test)]
755mod eligibility_tests;
756#[cfg(test)]
757mod fixture_tests;
758#[cfg(test)]
759mod sweep_tests;
760#[cfg(test)]
761mod tests;