1use 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
42pub 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
76pub 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
221pub 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 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;