Skip to main content

max / audiofiles

49.1 KB · 1402 lines History Blame Raw
1 //! Deterministic tag rules (Layer A of the hybrid tag classifier).
2 //!
3 //! A rule is an ordered, user-authored `IF <conditions> THEN <actions>` over cheap
4 //! sample metadata + DSP features. Rules ship empty; the user (or, later, a one-click
5 //! starter pack) creates them. Evaluation is deterministic and re-runnable.
6 //!
7 //! Tag provenance keeps manual tags sticky: a tag applied by a rule records a
8 //! `tag_provenance` row (`source = 'rule'`), and reconciliation only ever touches
9 //! rule-sourced tags. A tag with no provenance row is treated as manual and is never
10 //! removed by the engine. This is the contract the rest of the pipeline (k-NN, `.afcl`
11 //! layers) builds on.
12
13 use std::sync::atomic::{AtomicU64, Ordering};
14
15 use serde::{Deserialize, Serialize};
16 use tracing::instrument;
17
18 use crate::db::{Database, Tx};
19 use crate::error::{CoreError, Result};
20 use crate::tags::validate_tag;
21
22 // ── Model ──
23
24 /// How a rule's conditions combine.
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
26 #[serde(rename_all = "snake_case")]
27 pub enum MatchMode {
28 /// All conditions must hold (AND).
29 #[default]
30 All,
31 /// Any condition may hold (OR).
32 Any,
33 }
34
35 /// A field of a sample a condition can test.
36 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37 #[serde(rename_all = "snake_case")]
38 pub enum RuleField {
39 // String (single-valued)
40 Name,
41 SourcePath,
42 MusicalKey,
43 FileExtension,
44 // String (multi-valued, match if any element satisfies)
45 VfsPath,
46 Tag,
47 // Numeric
48 Duration,
49 Bpm,
50 SpectralCentroid,
51 SpectralFlatness,
52 SpectralRolloff,
53 Zcr,
54 SpectralBandwidth,
55 CentroidVariance,
56 CrestFactor,
57 AttackTime,
58 PeakDb,
59 RmsDb,
60 Lufs,
61 SampleRate,
62 Channels,
63 FileSize,
64 // Boolean
65 IsLoop,
66 }
67
68 /// A comparison operator. Applicability depends on the field's value kind;
69 /// inapplicable combinations never match.
70 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71 #[serde(rename_all = "snake_case")]
72 pub enum RuleOp {
73 // String (case-insensitive)
74 Contains,
75 NotContains,
76 Equals,
77 NotEquals,
78 StartsWith,
79 EndsWith,
80 // Numeric
81 Lt,
82 Le,
83 Gt,
84 Ge,
85 // Boolean
86 IsTrue,
87 IsFalse,
88 // Any
89 Exists,
90 NotExists,
91 }
92
93 /// A single condition: `<field> <op> <value>`.
94 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95 pub struct RuleCondition {
96 pub field: RuleField,
97 pub op: RuleOp,
98 /// Comparison operand. Parsed as a number for numeric ops; ignored for
99 /// boolean / existence ops.
100 #[serde(default)]
101 pub value: String,
102 }
103
104 /// What a rule does when it matches.
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106 #[serde(rename_all = "snake_case", tag = "kind", content = "tag")]
107 pub enum RuleAction {
108 /// Add a tag (deduped against the working set).
109 AddTag(String),
110 /// Remove a tag from the rule-desired set (suppresses an earlier add).
111 /// Never removes a manual tag.
112 RemoveTag(String),
113 /// Stop evaluating further rules for this sample.
114 #[serde(rename = "stop")]
115 Stop,
116 }
117
118 /// A complete rule.
119 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120 pub struct Rule {
121 pub id: String,
122 pub name: String,
123 pub enabled: bool,
124 /// Evaluation order; lower runs first.
125 pub priority: i64,
126 pub match_mode: MatchMode,
127 pub conditions: Vec<RuleCondition>,
128 pub actions: Vec<RuleAction>,
129 pub created_at: i64,
130 }
131
132 /// A rule to create (id + created_at are assigned by `create_rule`).
133 #[derive(Debug, Clone)]
134 pub struct NewRule {
135 pub name: String,
136 pub enabled: bool,
137 /// Explicit order, or `None` to append after the current last rule.
138 pub priority: Option<i64>,
139 pub match_mode: MatchMode,
140 pub conditions: Vec<RuleCondition>,
141 pub actions: Vec<RuleAction>,
142 }
143
144 // ── ID generation (no uuid dependency; unique within a process) ──
145
146 static RULE_COUNTER: AtomicU64 = AtomicU64::new(0);
147
148 fn new_rule_id() -> String {
149 use std::time::{SystemTime, UNIX_EPOCH};
150 let nanos = SystemTime::now()
151 .duration_since(UNIX_EPOCH)
152 .unwrap_or_default()
153 .as_nanos();
154 let n = RULE_COUNTER.fetch_add(1, Ordering::Relaxed);
155 format!("{nanos:x}{n:x}")
156 }
157
158 fn now_secs() -> i64 {
159 use std::time::{SystemTime, UNIX_EPOCH};
160 SystemTime::now()
161 .duration_since(UNIX_EPOCH)
162 .map_or(0, |d| d.as_secs() as i64)
163 }
164
165 // ── Evaluation context ──
166
167 /// Snapshot of a sample's testable fields, loaded once per evaluation.
168 #[derive(Debug, Clone, Default)]
169 pub struct RuleContext {
170 pub hash: String,
171 pub name: String,
172 pub source_path: Option<String>,
173 pub file_extension: Option<String>,
174 pub file_size: Option<f64>,
175 pub vfs_paths: Vec<String>,
176 pub tags: Vec<String>,
177 pub musical_key: Option<String>,
178 pub duration: Option<f64>,
179 pub bpm: Option<f64>,
180 pub is_loop: Option<bool>,
181 pub spectral_centroid: Option<f64>,
182 pub spectral_flatness: Option<f64>,
183 pub spectral_rolloff: Option<f64>,
184 pub zcr: Option<f64>,
185 pub spectral_bandwidth: Option<f64>,
186 pub centroid_variance: Option<f64>,
187 pub crest_factor: Option<f64>,
188 pub attack_time: Option<f64>,
189 pub peak_db: Option<f64>,
190 pub rms_db: Option<f64>,
191 pub lufs: Option<f64>,
192 pub sample_rate: Option<f64>,
193 pub channels: Option<f64>,
194 }
195
196 /// A field's value for the current sample.
197 enum FieldVal<'a> {
198 Str(Option<&'a str>),
199 Num(Option<f64>),
200 Bool(Option<bool>),
201 List(&'a [String]),
202 }
203
204 impl RuleContext {
205 fn field(&self, field: RuleField) -> FieldVal<'_> {
206 use RuleField::{
207 AttackTime, Bpm, CentroidVariance, Channels, CrestFactor, Duration, FileExtension,
208 FileSize, IsLoop, Lufs, MusicalKey, Name, PeakDb, RmsDb, SampleRate, SourcePath,
209 SpectralBandwidth, SpectralCentroid, SpectralFlatness, SpectralRolloff, Tag, VfsPath,
210 Zcr,
211 };
212 match field {
213 Name => FieldVal::Str(Some(self.name.as_str())),
214 SourcePath => FieldVal::Str(self.source_path.as_deref()),
215 MusicalKey => FieldVal::Str(self.musical_key.as_deref()),
216 FileExtension => FieldVal::Str(self.file_extension.as_deref()),
217 VfsPath => FieldVal::List(&self.vfs_paths),
218 Tag => FieldVal::List(&self.tags),
219 Duration => FieldVal::Num(self.duration),
220 Bpm => FieldVal::Num(self.bpm),
221 SpectralCentroid => FieldVal::Num(self.spectral_centroid),
222 SpectralFlatness => FieldVal::Num(self.spectral_flatness),
223 SpectralRolloff => FieldVal::Num(self.spectral_rolloff),
224 Zcr => FieldVal::Num(self.zcr),
225 SpectralBandwidth => FieldVal::Num(self.spectral_bandwidth),
226 CentroidVariance => FieldVal::Num(self.centroid_variance),
227 CrestFactor => FieldVal::Num(self.crest_factor),
228 AttackTime => FieldVal::Num(self.attack_time),
229 PeakDb => FieldVal::Num(self.peak_db),
230 RmsDb => FieldVal::Num(self.rms_db),
231 Lufs => FieldVal::Num(self.lufs),
232 SampleRate => FieldVal::Num(self.sample_rate),
233 Channels => FieldVal::Num(self.channels),
234 FileSize => FieldVal::Num(self.file_size),
235 IsLoop => FieldVal::Bool(self.is_loop),
236 }
237 }
238 }
239
240 // ── Operator evaluation ──
241
242 /// Apply a string operator to a single value (case-insensitive). Returns `None`
243 /// for operators that aren't string-applicable (handled by the caller).
244 fn str_op(op: RuleOp, s: &str, target: &str) -> Option<bool> {
245 let s = s.to_lowercase();
246 let t = target.to_lowercase();
247 Some(match op {
248 RuleOp::Contains => s.contains(&t),
249 RuleOp::NotContains => !s.contains(&t),
250 RuleOp::Equals => s == t,
251 RuleOp::NotEquals => s != t,
252 RuleOp::StartsWith => s.starts_with(&t),
253 RuleOp::EndsWith => s.ends_with(&t),
254 _ => return None,
255 })
256 }
257
258 /// Whether an operator is a "negative" string op (true when nothing matches).
259 fn is_negative_str_op(op: RuleOp) -> bool {
260 matches!(op, RuleOp::NotContains | RuleOp::NotEquals)
261 }
262
263 fn num_op(op: RuleOp, n: f64, target: &str) -> bool {
264 let Ok(t) = target.trim().parse::<f64>() else {
265 return false;
266 };
267 match op {
268 RuleOp::Lt => n < t,
269 RuleOp::Le => n <= t,
270 RuleOp::Gt => n > t,
271 RuleOp::Ge => n >= t,
272 RuleOp::Equals => (n - t).abs() < f64::EPSILON.max(t.abs() * 1e-9),
273 RuleOp::NotEquals => (n - t).abs() >= f64::EPSILON.max(t.abs() * 1e-9),
274 _ => false,
275 }
276 }
277
278 fn eval_condition(ctx: &RuleContext, cond: &RuleCondition) -> bool {
279 match ctx.field(cond.field) {
280 FieldVal::Str(opt) => match cond.op {
281 RuleOp::Exists => opt.is_some(),
282 RuleOp::NotExists => opt.is_none(),
283 _ => match opt {
284 Some(s) => str_op(cond.op, s, &cond.value).unwrap_or(false),
285 // Missing string: positive ops fail, negative ops hold.
286 None => is_negative_str_op(cond.op),
287 },
288 },
289 FieldVal::Num(opt) => match cond.op {
290 RuleOp::Exists => opt.is_some(),
291 RuleOp::NotExists => opt.is_none(),
292 _ => opt.is_some_and(|n| num_op(cond.op, n, &cond.value)),
293 },
294 FieldVal::Bool(opt) => match cond.op {
295 RuleOp::Exists => opt.is_some(),
296 RuleOp::NotExists => opt.is_none(),
297 RuleOp::IsTrue => opt == Some(true),
298 RuleOp::IsFalse => opt == Some(false),
299 _ => false,
300 },
301 FieldVal::List(items) => match cond.op {
302 RuleOp::Exists => !items.is_empty(),
303 RuleOp::NotExists => items.is_empty(),
304 _ if is_negative_str_op(cond.op) => {
305 // Negative ops hold only when NO element matches the positive form.
306 let positive = match cond.op {
307 RuleOp::NotContains => RuleOp::Contains,
308 RuleOp::NotEquals => RuleOp::Equals,
309 _ => cond.op,
310 };
311 !items
312 .iter()
313 .any(|it| str_op(positive, it, &cond.value).unwrap_or(false))
314 }
315 _ => items
316 .iter()
317 .any(|it| str_op(cond.op, it, &cond.value).unwrap_or(false)),
318 },
319 }
320 }
321
322 /// Whether a rule's conditions hold for a sample (ignores actions). An empty
323 /// condition list matches everything (the rule is unconditional).
324 pub fn rule_matches(rule: &Rule, ctx: &RuleContext) -> bool {
325 if rule.conditions.is_empty() {
326 return true;
327 }
328 match rule.match_mode {
329 MatchMode::All => rule.conditions.iter().all(|c| eval_condition(ctx, c)),
330 MatchMode::Any => rule.conditions.iter().any(|c| eval_condition(ctx, c)),
331 }
332 }
333
334 /// Evaluate all enabled rules in priority order, producing the rule-desired tag
335 /// set with attribution (tag -> id of the rule that last added it).
336 fn evaluate(rules: &[Rule], ctx: &RuleContext) -> Vec<(String, String)> {
337 // Insertion-ordered: preserve the order tags were added for stable output.
338 let mut desired: Vec<(String, String)> = Vec::new();
339 for rule in rules.iter().filter(|r| r.enabled) {
340 if !rule_matches(rule, ctx) {
341 continue;
342 }
343 let mut stop = false;
344 for action in &rule.actions {
345 match action {
346 RuleAction::AddTag(tag) => {
347 if validate_tag(tag).is_err() {
348 tracing::warn!(rule = %rule.id, %tag, "rule produced an invalid tag; skipped");
349 continue;
350 }
351 if let Some(slot) = desired.iter_mut().find(|(t, _)| t == tag) {
352 slot.1.clone_from(&rule.id);
353 } else {
354 desired.push((tag.clone(), rule.id.clone()));
355 }
356 }
357 RuleAction::RemoveTag(tag) => {
358 desired.retain(|(t, _)| t != tag);
359 }
360 RuleAction::Stop => stop = true,
361 }
362 }
363 if stop {
364 break;
365 }
366 }
367 desired
368 }
369
370 // ── Context loading ──
371
372 /// Load a sample's testable fields. Returns `None` if the sample doesn't exist.
373 #[instrument(skip_all)]
374 pub fn load_context(db: &Database, hash: &str) -> Result<Option<RuleContext>> {
375 let conn = db.conn();
376 let base = conn
377 .query_row(
378 "SELECT original_name, file_extension, file_size, source_path
379 FROM live_samples WHERE hash = ?1",
380 [hash],
381 |row| {
382 Ok((
383 row.get::<_, String>(0)?,
384 row.get::<_, Option<String>>(1)?,
385 row.get::<_, Option<i64>>(2)?,
386 row.get::<_, Option<String>>(3)?,
387 ))
388 },
389 )
390 .ok();
391
392 let Some((name, file_extension, file_size, source_path)) = base else {
393 return Ok(None);
394 };
395
396 let mut ctx = RuleContext {
397 hash: hash.to_string(),
398 name,
399 source_path,
400 file_extension,
401 file_size: file_size.map(|v| v as f64),
402 ..Default::default()
403 };
404
405 // Analysis fields (optional, sample may be unanalyzed).
406 let _ = conn.query_row(
407 "SELECT duration, bpm, musical_key, is_loop, spectral_centroid, spectral_flatness,
408 spectral_rolloff, zero_crossing_rate, spectral_bandwidth, centroid_variance,
409 crest_factor, attack_time, peak_db, rms_db, lufs, sample_rate, channels
410 FROM audio_analysis WHERE hash = ?1",
411 [hash],
412 |row| {
413 ctx.duration = row.get(0)?;
414 ctx.bpm = row.get(1)?;
415 ctx.musical_key = row.get(2)?;
416 ctx.is_loop = row.get::<_, Option<bool>>(3)?;
417 ctx.spectral_centroid = row.get(4)?;
418 ctx.spectral_flatness = row.get(5)?;
419 ctx.spectral_rolloff = row.get(6)?;
420 ctx.zcr = row.get(7)?;
421 ctx.spectral_bandwidth = row.get(8)?;
422 ctx.centroid_variance = row.get(9)?;
423 ctx.crest_factor = row.get(10)?;
424 ctx.attack_time = row.get(11)?;
425 ctx.peak_db = row.get(12)?;
426 ctx.rms_db = row.get(13)?;
427 ctx.lufs = row.get(14)?;
428 ctx.sample_rate = row.get::<_, Option<i64>>(15)?.map(|v| v as f64);
429 ctx.channels = row.get::<_, Option<i64>>(16)?.map(|v| v as f64);
430 Ok(())
431 },
432 );
433
434 // Tags.
435 {
436 let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?;
437 ctx.tags = stmt
438 .query_map([hash], |row| row.get(0))?
439 .collect::<std::result::Result<Vec<_>, _>>()?;
440 }
441
442 // VFS directory paths containing this sample (parent chain of each linking node).
443 ctx.vfs_paths = sample_vfs_paths(db, hash)?;
444
445 Ok(Some(ctx))
446 }
447
448 /// Directory paths (e.g. `Drums/808 Kicks`) of every VFS location linking this sample.
449 fn sample_vfs_paths(db: &Database, hash: &str) -> Result<Vec<String>> {
450 let mut stmt = db.conn().prepare(
451 "WITH RECURSIVE chain(node_id, parent_id, name, depth) AS (
452 SELECT vn.id, vn.parent_id, p.name, 0
453 FROM vfs_nodes vn
454 JOIN vfs_nodes p ON p.id = vn.parent_id
455 WHERE vn.sample_hash = ?1 AND vn.node_type = 'sample'
456 UNION ALL
457 SELECT c.node_id, p.parent_id, p.name, c.depth + 1
458 FROM chain c
459 JOIN vfs_nodes p ON p.id = c.parent_id
460 )
461 SELECT node_id, group_concat(name, '/') FROM (
462 SELECT node_id, name, depth FROM chain ORDER BY node_id, depth DESC
463 ) GROUP BY node_id",
464 )?;
465 let rows = stmt.query_map([hash], |row| row.get::<_, Option<String>>(1))?;
466 let mut paths = Vec::new();
467 for r in rows {
468 if let Some(p) = r? {
469 paths.push(p);
470 }
471 }
472 Ok(paths)
473 }
474
475 // ── CRUD ──
476
477 fn parse_rule(row: &rusqlite::Row) -> rusqlite::Result<Rule> {
478 let conditions_json: String = row.get(5)?;
479 let actions_json: String = row.get(6)?;
480 Ok(Rule {
481 id: row.get(0)?,
482 name: row.get(1)?,
483 enabled: row.get(2)?,
484 priority: row.get(3)?,
485 match_mode: serde_json::from_str(&row.get::<_, String>(4)?).unwrap_or(MatchMode::All),
486 conditions: serde_json::from_str(&conditions_json).unwrap_or_default(),
487 actions: serde_json::from_str(&actions_json).unwrap_or_default(),
488 created_at: row.get(7)?,
489 })
490 }
491
492 const RULE_COLUMNS: &str =
493 "id, name, enabled, priority, match_mode, conditions, actions, created_at";
494
495 /// List all rules ordered by priority (evaluation order).
496 #[instrument(skip_all)]
497 pub fn list_rules(db: &Database) -> Result<Vec<Rule>> {
498 let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules ORDER BY priority, created_at");
499 let mut stmt = db.conn().prepare(&sql)?;
500 let rows = stmt.query_map([], parse_rule)?;
501 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
502 }
503
504 /// Fetch a single rule by id.
505 #[instrument(skip_all)]
506 pub fn get_rule(db: &Database, id: &str) -> Result<Option<Rule>> {
507 let sql = format!("SELECT {RULE_COLUMNS} FROM tag_rules WHERE id = ?1");
508 Ok(db.conn().query_row(&sql, [id], parse_rule).ok())
509 }
510
511 /// Serialize a rule's JSON columns. Shared by insert and update.
512 fn rule_json(rule: &Rule) -> Result<(String, String, String)> {
513 let match_mode = serde_json::to_string(&rule.match_mode)
514 .map_err(|e| CoreError::Serialization(e.to_string()))?;
515 let conditions = serde_json::to_string(&rule.conditions)
516 .map_err(|e| CoreError::Serialization(e.to_string()))?;
517 let actions = serde_json::to_string(&rule.actions)
518 .map_err(|e| CoreError::Serialization(e.to_string()))?;
519 Ok((match_mode, conditions, actions))
520 }
521
522 /// Insert a brand-new rule. Plain INSERT (not INSERT OR REPLACE): a primary-key
523 /// collision is a real error, not a silent clobber of an existing rule.
524 fn insert_rule(db: &Database, rule: &Rule) -> Result<()> {
525 let (match_mode, conditions, actions) = rule_json(rule)?;
526 db.conn().execute(
527 "INSERT INTO tag_rules
528 (id, name, enabled, priority, match_mode, conditions, actions, created_at)
529 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
530 rusqlite::params![
531 rule.id,
532 rule.name,
533 rule.enabled,
534 rule.priority,
535 match_mode,
536 conditions,
537 actions,
538 rule.created_at,
539 ],
540 )?;
541 Ok(())
542 }
543
544 /// Create a rule. Validates any literal tags in its actions up front.
545 #[instrument(skip_all)]
546 pub fn create_rule(db: &Database, new: NewRule) -> Result<Rule> {
547 for action in &new.actions {
548 if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action {
549 validate_tag(tag)?;
550 }
551 }
552 let priority = match new.priority {
553 Some(p) => p,
554 None => db
555 .conn()
556 .query_row(
557 "SELECT COALESCE(MAX(priority), -1) + 1 FROM tag_rules",
558 [],
559 |r| r.get(0),
560 )
561 .unwrap_or(0),
562 };
563 let rule = Rule {
564 id: new_rule_id(),
565 name: new.name,
566 enabled: new.enabled,
567 priority,
568 match_mode: new.match_mode,
569 conditions: new.conditions,
570 actions: new.actions,
571 created_at: now_secs(),
572 };
573 insert_rule(db, &rule)?;
574 Ok(rule)
575 }
576
577 /// Replace an existing rule (matched by `id`).
578 #[instrument(skip_all)]
579 pub fn update_rule(db: &Database, rule: &Rule) -> Result<()> {
580 for action in &rule.actions {
581 if let RuleAction::AddTag(tag) | RuleAction::RemoveTag(tag) = action {
582 validate_tag(tag)?;
583 }
584 }
585 let (match_mode, conditions, actions) = rule_json(rule)?;
586 // Real UPDATE (not INSERT OR REPLACE): updating a deleted/unknown id must
587 // error, not silently resurrect the rule. created_at is preserved (not in
588 // the SET list).
589 let changed = db.conn().execute(
590 "UPDATE tag_rules SET
591 name = ?2, enabled = ?3, priority = ?4,
592 match_mode = ?5, conditions = ?6, actions = ?7
593 WHERE id = ?1",
594 rusqlite::params![
595 rule.id,
596 rule.name,
597 rule.enabled,
598 rule.priority,
599 match_mode,
600 conditions,
601 actions,
602 ],
603 )?;
604 if changed == 0 {
605 return Err(CoreError::RuleNotFound(rule.id.clone()));
606 }
607 Ok(())
608 }
609
610 /// Toggle a rule's enabled flag and reconcile affected samples.
611 ///
612 /// Toggling membership without reconciling leaves stale `source = 'rule'` tags
613 /// applied (when disabling) or missing (when enabling) until some unrelated
614 /// `apply_*` runs. This mirrors [`delete_rule`]: the flag flip and the
615 /// reconciliation run in one transaction so membership is never observed
616 /// half-updated. Disabling only touches samples the rule had tagged; enabling
617 /// re-evaluates the whole library, since the rule may now match samples it never
618 /// tagged before.
619 #[instrument(skip_all)]
620 pub fn set_rule_enabled(db: &Database, id: &str, enabled: bool) -> Result<()> {
621 let previously_tagged = samples_tagged_by_rule(db, id)?;
622 db.transaction_core(|tx| {
623 db.conn().execute(
624 "UPDATE tag_rules SET enabled = ?2 WHERE id = ?1",
625 rusqlite::params![id, enabled],
626 )?;
627 let rules = list_rules(db)?;
628 let to_reconcile: Vec<String> = if enabled {
629 let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?;
630 stmt.query_map([], |row| row.get(0))?
631 .collect::<std::result::Result<Vec<_>, _>>()?
632 } else {
633 previously_tagged
634 };
635 for hash in &to_reconcile {
636 reconcile_sample(db, tx, hash, &rules)?;
637 }
638 Ok(())
639 })?;
640 Ok(())
641 }
642
643 /// Delete a rule and reconcile the samples it had tagged (its rule-sourced tags
644 /// are removed unless another enabled rule still produces them).
645 #[instrument(skip_all)]
646 pub fn delete_rule(db: &Database, id: &str) -> Result<()> {
647 let affected = samples_tagged_by_rule(db, id)?;
648 // One transaction for the rule delete + reconciling every affected sample,
649 // instead of an autocommit per sample. The rule must be deleted *before*
650 // re-listing, so reconcile evaluates against the rule set without it and
651 // removes its now-orphaned tags.
652 db.transaction_core(|tx| {
653 db.conn()
654 .execute("DELETE FROM tag_rules WHERE id = ?1", [id])?;
655 let rules = list_rules(db)?;
656 for hash in &affected {
657 reconcile_sample(db, tx, hash, &rules)?;
658 }
659 Ok(())
660 })?;
661 Ok(())
662 }
663
664 fn samples_tagged_by_rule(db: &Database, id: &str) -> Result<Vec<String>> {
665 let mut stmt = db.conn().prepare(
666 "SELECT DISTINCT sample_hash FROM tag_provenance WHERE source = 'rule' AND rule_id = ?1",
667 )?;
668 let rows = stmt.query_map([id], |row| row.get(0))?;
669 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
670 }
671
672 // ── Application / reconciliation ──
673
674 /// Re-evaluate `rules` for one sample and reconcile the tag table + provenance.
675 ///
676 /// Only rule-sourced tags are added/removed; manual tags (no provenance row) are
677 /// never touched. Returns `true` if any tag changed.
678 fn reconcile_sample(db: &Database, _tx: &Tx, hash: &str, rules: &[Rule]) -> Result<bool> {
679 let Some(ctx) = load_context(db, hash)? else {
680 return Ok(false);
681 };
682 let desired = evaluate(rules, &ctx);
683 let desired_tags: std::collections::HashSet<&str> =
684 desired.iter().map(|(t, _)| t.as_str()).collect();
685
686 let conn = db.conn();
687
688 // Current rule-sourced tags for this sample.
689 let current_rule_tags: Vec<String> = {
690 let mut stmt = conn
691 .prepare("SELECT tag FROM tag_provenance WHERE sample_hash = ?1 AND source = 'rule'")?;
692 stmt.query_map([hash], |row| row.get(0))?
693 .collect::<std::result::Result<Vec<_>, _>>()?
694 };
695 let existing_tags: std::collections::HashSet<String> = {
696 let mut stmt = conn.prepare("SELECT tag FROM tags WHERE sample_hash = ?1")?;
697 stmt.query_map([hash], |row| row.get(0))?
698 .collect::<std::result::Result<std::collections::HashSet<_>, _>>()?
699 };
700
701 let mut changed = false;
702
703 // Remove rule tags no longer desired.
704 for tag in &current_rule_tags {
705 if !desired_tags.contains(tag.as_str()) {
706 conn.execute(
707 "DELETE FROM tags WHERE sample_hash = ?1 AND tag = ?2",
708 rusqlite::params![hash, tag],
709 )?;
710 conn.execute(
711 "DELETE FROM tag_provenance WHERE sample_hash = ?1 AND tag = ?2",
712 rusqlite::params![hash, tag],
713 )?;
714 changed = true;
715 }
716 }
717
718 // Add newly desired tags. A tag that already exists with NO rule provenance is
719 // manual/legacy, leave it sticky and do not claim it.
720 let current_rule_set: std::collections::HashSet<&str> = current_rule_tags
721 .iter()
722 .map(std::string::String::as_str)
723 .collect();
724 for (tag, rule_id) in &desired {
725 let already_manual =
726 existing_tags.contains(tag) && !current_rule_set.contains(tag.as_str());
727 if already_manual {
728 continue;
729 }
730 conn.execute(
731 "INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)",
732 rusqlite::params![hash, tag],
733 )?;
734 conn.execute(
735 "INSERT INTO tag_provenance (sample_hash, tag, source, rule_id)
736 VALUES (?1, ?2, 'rule', ?3)
737 ON CONFLICT(sample_hash, tag) DO UPDATE SET source = 'rule', rule_id = ?3",
738 rusqlite::params![hash, tag, rule_id],
739 )?;
740 if !existing_tags.contains(tag) {
741 changed = true;
742 }
743 }
744
745 Ok(changed)
746 }
747
748 /// Apply all enabled rules to a single sample (e.g. after analysis/import).
749 #[instrument(skip_all)]
750 pub fn apply_rules_to_sample(db: &Database, hash: &str) -> Result<bool> {
751 let rules = list_rules(db)?;
752 db.transaction_core(|tx| reconcile_sample(db, tx, hash, &rules))
753 }
754
755 /// Apply all enabled rules to a set of samples in a single transaction. The
756 /// batched form of [`apply_rules_to_sample`]: callers processing many freshly
757 /// analyzed samples reconcile them in one commit instead of one per sample.
758 /// Returns the number of samples whose tags changed.
759 #[instrument(skip_all)]
760 pub fn apply_rules_to_samples(db: &Database, hashes: &[String]) -> Result<usize> {
761 if hashes.is_empty() {
762 return Ok(0);
763 }
764 let rules = list_rules(db)?;
765 db.transaction_core(|tx| {
766 let mut changed = 0;
767 for hash in hashes {
768 if reconcile_sample(db, tx, hash, &rules)? {
769 changed += 1;
770 }
771 }
772 Ok(changed)
773 })
774 }
775
776 /// Re-apply all rules across the whole library. Returns the number of samples changed.
777 #[instrument(skip_all)]
778 pub fn apply_all_rules(db: &Database) -> Result<usize> {
779 let rules = list_rules(db)?;
780 let hashes: Vec<String> = {
781 let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?;
782 stmt.query_map([], |row| row.get(0))?
783 .collect::<std::result::Result<Vec<_>, _>>()?
784 };
785 db.transaction_core(|tx| {
786 let mut changed = 0;
787 for hash in hashes {
788 if reconcile_sample(db, tx, &hash, &rules)? {
789 changed += 1;
790 }
791 }
792 Ok(changed)
793 })
794 }
795
796 /// Dry-run: how many (non-deleted) samples a rule's conditions would match.
797 #[instrument(skip_all)]
798 pub fn preview_rule_matches(db: &Database, rule: &Rule) -> Result<usize> {
799 let hashes: Vec<String> = {
800 let mut stmt = db.conn().prepare("SELECT hash FROM live_samples")?;
801 stmt.query_map([], |row| row.get(0))?
802 .collect::<std::result::Result<Vec<_>, _>>()?
803 };
804 let mut count = 0;
805 for hash in hashes {
806 if let Some(ctx) = load_context(db, &hash)?
807 && rule_matches(rule, &ctx)
808 {
809 count += 1;
810 }
811 }
812 Ok(count)
813 }
814
815 /// Apply a tag attributed to a non-rule machine source (`cluster`, `harvest`, `ml`).
816 ///
817 /// Sticky: the rules engine never reconciles these away (only `source = 'rule'` tags
818 /// are regenerable). Existing provenance is preserved (`INSERT OR IGNORE`), so this
819 /// won't downgrade a tag that is already rule-sourced or manual. Returns `true` if the
820 /// tag was newly added to the sample.
821 /// Requires a [`Tx`]: machine-source tagging is always a bulk operation (a whole
822 /// cluster / harvest / ML pass), so callers must batch the loop in one
823 /// transaction rather than autocommitting per sample.
824 #[instrument(skip_all)]
825 pub fn apply_tag_sourced(
826 db: &Database,
827 _tx: &Tx,
828 hash: &str,
829 tag: &str,
830 source: &str,
831 ) -> Result<bool> {
832 validate_tag(tag)?;
833 let conn = db.conn();
834 let added = conn.execute(
835 "INSERT OR IGNORE INTO tags (sample_hash, tag) VALUES (?1, ?2)",
836 rusqlite::params![hash, tag],
837 )? > 0;
838 conn.execute(
839 "INSERT OR IGNORE INTO tag_provenance (sample_hash, tag, source, rule_id)
840 VALUES (?1, ?2, ?3, NULL)",
841 rusqlite::params![hash, tag, source],
842 )?;
843 Ok(added)
844 }
845
846 /// Remove every tag applied by a given source (e.g. undo a clustering pass). Only
847 /// affects tags whose provenance matches `source`; manual tags are untouched.
848 #[instrument(skip_all)]
849 pub fn remove_tags_by_source(db: &Database, source: &str) -> Result<usize> {
850 // Two dependent deletes (tags, then their provenance) in one transaction, so a
851 // crash between them can't orphan provenance rows from their tags.
852 db.transaction_core(|_tx| {
853 let conn = db.conn();
854 let removed = conn.execute(
855 "DELETE FROM tags WHERE (sample_hash, tag) IN
856 (SELECT sample_hash, tag FROM tag_provenance WHERE source = ?1)",
857 rusqlite::params![source],
858 )?;
859 conn.execute(
860 "DELETE FROM tag_provenance WHERE source = ?1",
861 rusqlite::params![source],
862 )?;
863 Ok(removed)
864 })
865 }
866
867 /// Provenance of each tag on a sample: `(tag, source, rule_id)`. Tags absent from
868 /// the result (but present on the sample) are manual.
869 #[instrument(skip_all)]
870 pub fn sample_tag_provenance(
871 db: &Database,
872 hash: &str,
873 ) -> Result<Vec<(String, String, Option<String>)>> {
874 let mut stmt = db.conn().prepare(
875 "SELECT tag, source, rule_id FROM tag_provenance WHERE sample_hash = ?1 ORDER BY tag",
876 )?;
877 let rows = stmt.query_map([hash], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
878 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
879 }
880
881 #[cfg(test)]
882 mod tests {
883 use super::*;
884
885 fn db_with_sample(hash: &str, name: &str) -> Database {
886 let db = Database::open_in_memory().unwrap();
887 db.conn()
888 .execute(
889 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) \
890 VALUES (?1, ?2, 'wav', 1000, 0, 0)",
891 rusqlite::params![hash, name],
892 )
893 .unwrap();
894 db
895 }
896
897 fn cond(field: RuleField, op: RuleOp, value: &str) -> RuleCondition {
898 RuleCondition {
899 field,
900 op,
901 value: value.to_string(),
902 }
903 }
904
905 fn new_rule(name: &str, conds: Vec<RuleCondition>, acts: Vec<RuleAction>) -> NewRule {
906 NewRule {
907 name: name.to_string(),
908 enabled: true,
909 priority: None,
910 match_mode: MatchMode::All,
911 conditions: conds,
912 actions: acts,
913 }
914 }
915
916 #[test]
917 fn name_contains_applies_tag() {
918 let db = db_with_sample("h1", "808 Kick Loud.wav");
919 create_rule(
920 &db,
921 new_rule(
922 "kicks",
923 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
924 vec![RuleAction::AddTag("instrument.drum.kick".into())],
925 ),
926 )
927 .unwrap();
928
929 assert!(apply_rules_to_sample(&db, "h1").unwrap());
930 let tags = crate::tags::get_sample_tags(&db, "h1").unwrap();
931 assert_eq!(tags, vec!["instrument.drum.kick"]);
932
933 let prov = sample_tag_provenance(&db, "h1").unwrap();
934 assert_eq!(prov.len(), 1);
935 assert_eq!(prov[0].1, "rule");
936 }
937
938 #[test]
939 fn numeric_condition_on_analysis() {
940 let db = db_with_sample("h2", "loop.wav");
941 db.conn()
942 .execute(
943 "INSERT INTO audio_analysis (hash, duration, sample_rate, channels, bpm, analyzed_at) \
944 VALUES ('h2', 4.0, 44100, 2, 128.0, 0)",
945 [],
946 )
947 .unwrap();
948 create_rule(
949 &db,
950 new_rule(
951 "fast",
952 vec![cond(RuleField::Bpm, RuleOp::Ge, "120")],
953 vec![RuleAction::AddTag("tempo.fast".into())],
954 ),
955 )
956 .unwrap();
957 apply_rules_to_sample(&db, "h2").unwrap();
958 assert!(
959 crate::tags::get_sample_tags(&db, "h2")
960 .unwrap()
961 .contains(&"tempo.fast".to_string())
962 );
963 }
964
965 #[test]
966 fn manual_tags_are_sticky() {
967 let db = db_with_sample("h3", "kick.wav");
968 crate::tags::add_tag(&db, "h3", "manual.keep").unwrap();
969 let rule = create_rule(
970 &db,
971 new_rule(
972 "kicks",
973 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
974 vec![RuleAction::AddTag("instrument.drum.kick".into())],
975 ),
976 )
977 .unwrap();
978 apply_rules_to_sample(&db, "h3").unwrap();
979
980 // Deleting the rule must remove its tag but keep the manual one.
981 delete_rule(&db, &rule.id).unwrap();
982 let tags = crate::tags::get_sample_tags(&db, "h3").unwrap();
983 assert_eq!(tags, vec!["manual.keep"]);
984 }
985
986 #[test]
987 fn reconcile_removes_tags_when_rule_no_longer_matches() {
988 let db = db_with_sample("h4", "kick.wav");
989 let mut rule = create_rule(
990 &db,
991 new_rule(
992 "kicks",
993 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
994 vec![RuleAction::AddTag("instrument.drum.kick".into())],
995 ),
996 )
997 .unwrap();
998 apply_rules_to_sample(&db, "h4").unwrap();
999 assert!(!crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty());
1000
1001 // Narrow the rule so it no longer matches, then reconcile.
1002 rule.conditions = vec![cond(RuleField::Name, RuleOp::Contains, "snare")];
1003 update_rule(&db, &rule).unwrap();
1004 apply_rules_to_sample(&db, "h4").unwrap();
1005 assert!(crate::tags::get_sample_tags(&db, "h4").unwrap().is_empty());
1006 }
1007
1008 #[test]
1009 fn toggling_enabled_reconciles_membership() {
1010 let db = db_with_sample("h6", "kick.wav");
1011 let rule = create_rule(
1012 &db,
1013 new_rule(
1014 "kicks",
1015 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
1016 vec![RuleAction::AddTag("instrument.drum.kick".into())],
1017 ),
1018 )
1019 .unwrap();
1020 apply_rules_to_sample(&db, "h6").unwrap();
1021 assert!(!crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty());
1022
1023 // Disabling must remove the rule-sourced tag immediately (no separate
1024 // apply_* call), not leave it stale.
1025 set_rule_enabled(&db, &rule.id, false).unwrap();
1026 assert!(crate::tags::get_sample_tags(&db, "h6").unwrap().is_empty());
1027
1028 // Re-enabling must re-apply it across the library, again without an
1029 // explicit apply_* call.
1030 set_rule_enabled(&db, &rule.id, true).unwrap();
1031 assert!(
1032 crate::tags::get_sample_tags(&db, "h6")
1033 .unwrap()
1034 .contains(&"instrument.drum.kick".to_string())
1035 );
1036 }
1037
1038 #[test]
1039 fn update_unknown_rule_errors_not_resurrects() {
1040 let db = db_with_sample("h7", "kick.wav");
1041 let rule = create_rule(
1042 &db,
1043 new_rule(
1044 "kicks",
1045 vec![cond(RuleField::Name, RuleOp::Contains, "kick")],
1046 vec![RuleAction::AddTag("instrument.drum.kick".into())],
1047 ),
1048 )
1049 .unwrap();
1050 delete_rule(&db, &rule.id).unwrap();
1051
1052 // Updating the now-deleted rule must error, not silently re-insert it.
1053 assert!(matches!(
1054 update_rule(&db, &rule),
1055 Err(CoreError::RuleNotFound(_))
1056 ));
1057 assert!(get_rule(&db, &rule.id).unwrap().is_none());
1058 }
1059
1060 #[test]
1061 fn match_mode_any_vs_all() {
1062 let db = db_with_sample("h5", "snare hit.wav");
1063 let any = create_rule(
1064 &db,
1065 NewRule {
1066 match_mode: MatchMode::Any,
1067 ..new_rule(
1068 "any",
1069 vec![
1070 cond(RuleField::Name, RuleOp::Contains, "kick"),
1071 cond(RuleField::Name, RuleOp::Contains, "snare"),
1072 ],
1073 vec![RuleAction::AddTag("matched.any".into())],
1074 )
1075 },
1076 )
1077 .unwrap();
1078 assert_eq!(preview_rule_matches(&db, &any).unwrap(), 1);
1079
1080 let all = Rule {
1081 match_mode: MatchMode::All,
1082 ..any
1083 };
1084 assert_eq!(preview_rule_matches(&db, &all).unwrap(), 0);
1085 }
1086
1087 #[test]
1088 fn stop_action_halts_later_rules() {
1089 let db = db_with_sample("h6", "kick.wav");
1090 create_rule(
1091 &db,
1092 NewRule {
1093 priority: Some(0),
1094 ..new_rule(
1095 "first",
1096 vec![],
1097 vec![RuleAction::AddTag("a.first".into()), RuleAction::Stop],
1098 )
1099 },
1100 )
1101 .unwrap();
1102 create_rule(
1103 &db,
1104 NewRule {
1105 priority: Some(1),
1106 ..new_rule(
1107 "second",
1108 vec![],
1109 vec![RuleAction::AddTag("a.second".into())],
1110 )
1111 },
1112 )
1113 .unwrap();
1114 apply_rules_to_sample(&db, "h6").unwrap();
1115 let tags = crate::tags::get_sample_tags(&db, "h6").unwrap();
1116 assert_eq!(tags, vec!["a.first"]);
1117 }
1118
1119 #[test]
1120 fn rules_round_trip_through_db() {
1121 let db = db_with_sample("h7", "x.wav");
1122 let created = create_rule(
1123 &db,
1124 new_rule(
1125 "complex",
1126 vec![
1127 cond(RuleField::SpectralFlatness, RuleOp::Lt, "0.2"),
1128 cond(RuleField::Tag, RuleOp::StartsWith, "instrument.drum"),
1129 ],
1130 vec![
1131 RuleAction::AddTag("character.tonal".into()),
1132 RuleAction::Stop,
1133 ],
1134 ),
1135 )
1136 .unwrap();
1137 let fetched = get_rule(&db, &created.id).unwrap().unwrap();
1138 assert_eq!(created, fetched);
1139 }
1140
1141 #[test]
1142 fn empty_ruleset_is_noop() {
1143 let db = db_with_sample("h8", "kick.wav");
1144 assert!(!apply_rules_to_sample(&db, "h8").unwrap());
1145 assert!(crate::tags::get_sample_tags(&db, "h8").unwrap().is_empty());
1146 }
1147
1148 // ── Operator / field matrix ──
1149 //
1150 // These exercise `eval_condition` directly against a hand-built `RuleContext`,
1151 // covering the cross-product of value kind (string / numeric / boolean / list)
1152 // and operator, plus the missing-value and inapplicable-operator edges that
1153 // never reach a DB.
1154
1155 /// One condition against a context.
1156 fn eval(ctx: &RuleContext, field: RuleField, op: RuleOp, value: &str) -> bool {
1157 eval_condition(ctx, &cond(field, op, value))
1158 }
1159
1160 #[test]
1161 fn str_op_is_case_insensitive_over_all_string_ops() {
1162 // Positive ops fold case on both sides.
1163 assert_eq!(str_op(RuleOp::Contains, "Kick DRUM", "kick"), Some(true));
1164 assert_eq!(str_op(RuleOp::Contains, "snare", "KICK"), Some(false));
1165 assert_eq!(str_op(RuleOp::Equals, "WaV", "wav"), Some(true));
1166 assert_eq!(str_op(RuleOp::Equals, "wave", "wav"), Some(false));
1167 assert_eq!(str_op(RuleOp::StartsWith, "808_Kick", "808"), Some(true));
1168 assert_eq!(str_op(RuleOp::StartsWith, "kick", "808"), Some(false));
1169 assert_eq!(str_op(RuleOp::EndsWith, "loop.WAV", ".wav"), Some(true));
1170 assert_eq!(str_op(RuleOp::EndsWith, "loop.aif", ".wav"), Some(false));
1171 // Negative ops are the logical inverse.
1172 assert_eq!(str_op(RuleOp::NotContains, "snare", "kick"), Some(true));
1173 assert_eq!(str_op(RuleOp::NotContains, "Kick", "kick"), Some(false));
1174 assert_eq!(str_op(RuleOp::NotEquals, "snare", "kick"), Some(true));
1175 assert_eq!(str_op(RuleOp::NotEquals, "KICK", "kick"), Some(false));
1176 // Non-string ops are not str-applicable.
1177 for op in [RuleOp::Lt, RuleOp::Ge, RuleOp::IsTrue, RuleOp::Exists] {
1178 assert_eq!(
1179 str_op(op, "x", "y"),
1180 None,
1181 "{op:?} should not be str-applicable"
1182 );
1183 }
1184 }
1185
1186 #[test]
1187 fn num_op_covers_every_comparison_and_bad_input() {
1188 assert!(num_op(RuleOp::Lt, 1.0, "2"));
1189 assert!(!num_op(RuleOp::Lt, 2.0, "2"));
1190 assert!(num_op(RuleOp::Le, 2.0, "2"));
1191 assert!(!num_op(RuleOp::Le, 3.0, "2"));
1192 assert!(num_op(RuleOp::Gt, 3.0, "2"));
1193 assert!(!num_op(RuleOp::Gt, 2.0, "2"));
1194 assert!(num_op(RuleOp::Ge, 2.0, "2"));
1195 assert!(!num_op(RuleOp::Ge, 1.0, "2"));
1196 assert!(num_op(RuleOp::Equals, 2.0, "2"));
1197 assert!(!num_op(RuleOp::Equals, 2.5, "2"));
1198 assert!(num_op(RuleOp::NotEquals, 2.5, "2"));
1199 assert!(!num_op(RuleOp::NotEquals, 2.0, "2"));
1200 // Whitespace in the operand is tolerated.
1201 assert!(num_op(RuleOp::Ge, 128.0, " 120 "));
1202 // Unparseable operand never matches, for any op.
1203 for op in [
1204 RuleOp::Lt,
1205 RuleOp::Le,
1206 RuleOp::Gt,
1207 RuleOp::Ge,
1208 RuleOp::Equals,
1209 RuleOp::NotEquals,
1210 ] {
1211 assert!(
1212 !num_op(op, 1.0, "notanumber"),
1213 "{op:?} should fail on bad operand"
1214 );
1215 }
1216 // String-only ops are not numeric-applicable.
1217 assert!(!num_op(RuleOp::Contains, 1.0, "1"));
1218 assert!(!num_op(RuleOp::StartsWith, 1.0, "1"));
1219 }
1220
1221 #[test]
1222 fn num_op_equals_uses_relative_epsilon() {
1223 // Exact hits and values within the relative tolerance are equal.
1224 assert!(num_op(RuleOp::Equals, 44100.0, "44100"));
1225 assert!(num_op(RuleOp::Equals, 1_000_000.0, "1000000.00005"));
1226 assert!(!num_op(RuleOp::Equals, 1_000_000.0, "1000001"));
1227 }
1228
1229 #[test]
1230 fn string_field_present_matrix() {
1231 let ctx = RuleContext {
1232 name: "808 Kick.wav".into(),
1233 ..Default::default()
1234 };
1235 assert!(eval(&ctx, RuleField::Name, RuleOp::Contains, "kick"));
1236 assert!(!eval(&ctx, RuleField::Name, RuleOp::Contains, "snare"));
1237 assert!(eval(&ctx, RuleField::Name, RuleOp::StartsWith, "808"));
1238 assert!(eval(&ctx, RuleField::Name, RuleOp::EndsWith, ".wav"));
1239 assert!(eval(&ctx, RuleField::Name, RuleOp::NotContains, "snare"));
1240 assert!(eval(&ctx, RuleField::Name, RuleOp::NotEquals, "other"));
1241 assert!(eval(&ctx, RuleField::Name, RuleOp::Exists, ""));
1242 assert!(!eval(&ctx, RuleField::Name, RuleOp::NotExists, ""));
1243 // A numeric operator on a string field never matches.
1244 assert!(!eval(&ctx, RuleField::Name, RuleOp::Gt, "0"));
1245 assert!(!eval(&ctx, RuleField::Name, RuleOp::IsTrue, ""));
1246 }
1247
1248 #[test]
1249 fn string_field_missing_matrix() {
1250 // source_path is None: positive ops fail, negative ops hold, existence flips.
1251 let ctx = RuleContext::default();
1252 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Contains, "x"));
1253 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Equals, "x"));
1254 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::StartsWith, "x"));
1255 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotContains, "x"));
1256 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotEquals, "x"));
1257 assert!(!eval(&ctx, RuleField::SourcePath, RuleOp::Exists, ""));
1258 assert!(eval(&ctx, RuleField::SourcePath, RuleOp::NotExists, ""));
1259 }
1260
1261 #[test]
1262 fn numeric_field_present_and_missing_matrix() {
1263 let ctx = RuleContext {
1264 bpm: Some(128.0),
1265 ..Default::default()
1266 };
1267 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Gt, "120"));
1268 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Ge, "128"));
1269 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Le, "128"));
1270 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Lt, "128"));
1271 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Equals, "128"));
1272 assert!(eval(&ctx, RuleField::Bpm, RuleOp::NotEquals, "120"));
1273 assert!(eval(&ctx, RuleField::Bpm, RuleOp::Exists, ""));
1274 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::NotExists, ""));
1275 // A string operator on a numeric field never matches.
1276 assert!(!eval(&ctx, RuleField::Bpm, RuleOp::Contains, "12"));
1277
1278 // Missing numeric: every comparison fails, only NotExists holds.
1279 let empty = RuleContext::default();
1280 for op in [
1281 RuleOp::Lt,
1282 RuleOp::Le,
1283 RuleOp::Gt,
1284 RuleOp::Ge,
1285 RuleOp::Equals,
1286 RuleOp::NotEquals,
1287 ] {
1288 assert!(
1289 !eval(&empty, RuleField::Bpm, op, "128"),
1290 "{op:?} on missing num"
1291 );
1292 }
1293 assert!(!eval(&empty, RuleField::Bpm, RuleOp::Exists, ""));
1294 assert!(eval(&empty, RuleField::Bpm, RuleOp::NotExists, ""));
1295 }
1296
1297 #[test]
1298 fn boolean_field_matrix() {
1299 let t = RuleContext {
1300 is_loop: Some(true),
1301 ..Default::default()
1302 };
1303 let f = RuleContext {
1304 is_loop: Some(false),
1305 ..Default::default()
1306 };
1307 let n = RuleContext::default();
1308 assert!(eval(&t, RuleField::IsLoop, RuleOp::IsTrue, ""));
1309 assert!(!eval(&t, RuleField::IsLoop, RuleOp::IsFalse, ""));
1310 assert!(eval(&f, RuleField::IsLoop, RuleOp::IsFalse, ""));
1311 assert!(!eval(&f, RuleField::IsLoop, RuleOp::IsTrue, ""));
1312 assert!(eval(&t, RuleField::IsLoop, RuleOp::Exists, ""));
1313 assert!(eval(&n, RuleField::IsLoop, RuleOp::NotExists, ""));
1314 assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsTrue, ""));
1315 assert!(!eval(&n, RuleField::IsLoop, RuleOp::IsFalse, ""));
1316 // Non-boolean operators never match a boolean field.
1317 assert!(!eval(&t, RuleField::IsLoop, RuleOp::Contains, "true"));
1318 assert!(!eval(&t, RuleField::IsLoop, RuleOp::Gt, "0"));
1319 }
1320
1321 #[test]
1322 fn list_field_matrix() {
1323 let ctx = RuleContext {
1324 tags: vec!["instrument.drum.kick".into(), "character.punchy".into()],
1325 ..Default::default()
1326 };
1327 // Positive ops match if ANY element satisfies.
1328 assert!(eval(&ctx, RuleField::Tag, RuleOp::Contains, "drum"));
1329 assert!(eval(&ctx, RuleField::Tag, RuleOp::StartsWith, "instrument"));
1330 assert!(eval(
1331 &ctx,
1332 RuleField::Tag,
1333 RuleOp::Equals,
1334 "character.punchy"
1335 ));
1336 assert!(!eval(&ctx, RuleField::Tag, RuleOp::Contains, "bass"));
1337 // Negative ops hold only when NO element matches the positive form.
1338 assert!(eval(&ctx, RuleField::Tag, RuleOp::NotContains, "bass"));
1339 assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotContains, "drum"));
1340 assert!(eval(&ctx, RuleField::Tag, RuleOp::NotEquals, "nope"));
1341 assert!(!eval(
1342 &ctx,
1343 RuleField::Tag,
1344 RuleOp::NotEquals,
1345 "character.punchy"
1346 ));
1347 // Existence tracks emptiness.
1348 assert!(eval(&ctx, RuleField::Tag, RuleOp::Exists, ""));
1349 assert!(!eval(&ctx, RuleField::Tag, RuleOp::NotExists, ""));
1350
1351 let empty = RuleContext::default();
1352 assert!(!eval(&empty, RuleField::Tag, RuleOp::Exists, ""));
1353 assert!(eval(&empty, RuleField::Tag, RuleOp::NotExists, ""));
1354 // A negative op over an empty list vacuously holds; a positive op does not.
1355 assert!(eval(&empty, RuleField::Tag, RuleOp::NotContains, "x"));
1356 assert!(!eval(&empty, RuleField::Tag, RuleOp::Contains, "x"));
1357 // Inapplicable operator on a list never matches.
1358 assert!(!eval(&ctx, RuleField::Tag, RuleOp::Gt, "0"));
1359 assert!(!eval(&ctx, RuleField::Tag, RuleOp::IsTrue, ""));
1360 }
1361
1362 #[test]
1363 fn match_mode_all_vs_any_over_conditions() {
1364 let ctx = RuleContext {
1365 name: "kick".into(),
1366 bpm: Some(90.0),
1367 ..Default::default()
1368 };
1369 let conds = vec![
1370 cond(RuleField::Name, RuleOp::Contains, "kick"), // true
1371 cond(RuleField::Bpm, RuleOp::Gt, "120"), // false
1372 ];
1373 let rule = |mode| Rule {
1374 id: "r".into(),
1375 name: "r".into(),
1376 enabled: true,
1377 priority: 0,
1378 match_mode: mode,
1379 conditions: conds.clone(),
1380 actions: vec![],
1381 created_at: 0,
1382 };
1383 assert!(!rule_matches(&rule(MatchMode::All), &ctx));
1384 assert!(rule_matches(&rule(MatchMode::Any), &ctx));
1385 }
1386
1387 #[test]
1388 fn empty_conditions_match_unconditionally() {
1389 let rule = Rule {
1390 id: "r".into(),
1391 name: "r".into(),
1392 enabled: true,
1393 priority: 0,
1394 match_mode: MatchMode::All,
1395 conditions: vec![],
1396 actions: vec![],
1397 created_at: 0,
1398 };
1399 assert!(rule_matches(&rule, &RuleContext::default()));
1400 }
1401 }
1402