1use chrono::{Local, NaiveDate, TimeZone};
13use serde_json::{Map, Value, json};
14
15use crate::search;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Kind {
20 Text,
22 Number,
24 Size,
26 Duration,
28 Date,
30 Tags,
32 Folders,
34 Type,
36 Rating,
38 Shape,
40 Color,
42 Font,
44}
45
46#[derive(Debug, Clone, Copy)]
48pub struct Property {
49 pub key: &'static str,
50 pub label: &'static str,
51 pub kind: Kind,
52 pub methods: &'static [&'static str],
54}
55
56const TEXT: &[&str] = &["contain", "uncontain", "startWith", "endWith", "equal", "empty", "not-empty", "regex"];
57const NUMBER: &[&str] = &[">", ">=", "=", "<", "<=", "between"];
58const DATE: &[&str] = &["before", "after", "between", "on", "within"];
59const SET: &[&str] = &["intersection", "union", "equal", "identity", "empty", "not-empty"];
60const IS: &[&str] = &["equal", "unequal"];
61
62const fn p(key: &'static str, label: &'static str, kind: Kind, methods: &'static [&'static str]) -> Property {
63 Property { key, label, kind, methods }
64}
65
66pub const PROPERTIES: &[Property] = &[
68 p("name", "Name", Kind::Text, TEXT),
69 p("type", "Type", Kind::Type, IS),
70 p("tags", "Tags", Kind::Tags, SET),
71 p("folders", "Folders", Kind::Folders, SET),
72 p("folderName", "Folder name", Kind::Text, TEXT),
73 p("color", "Colour", Kind::Color, &["similar", "accuracy", "grayscale"]),
74 p("createTime", "Date added", Kind::Date, DATE),
75 p("mtime", "Date modified", Kind::Date, DATE),
76 p("btime", "Date created", Kind::Date, DATE),
77 p("url", "URL", Kind::Text, TEXT),
78 p("annotation", "Notes", Kind::Text, TEXT),
79 p("width", "Width", Kind::Number, NUMBER),
80 p("height", "Height", Kind::Number, NUMBER),
81 p("fileSize", "File size", Kind::Size, NUMBER),
82 p("shape", "Shape", Kind::Shape, IS),
83 p("rating", "Rating", Kind::Rating, &["equal", "unequal", "contain"]),
84 p("comments", "Comments", Kind::Text, TEXT),
85 p("duration", "Duration", Kind::Duration, NUMBER),
86 p("bpm", "BPM", Kind::Number, NUMBER),
87 p("fontActivated", "Font", Kind::Font, &["activate", "deactivate"]),
88 p("camera", "Camera", Kind::Text, TEXT),
89 p("iso", "ISO", Kind::Number, NUMBER),
90 p("aperture", "Aperture (f/)", Kind::Number, NUMBER),
91 p("focalLength", "Focal length (mm)", Kind::Number, NUMBER),
92 p("shutter", "Shutter (1/x s)", Kind::Number, NUMBER),
93 p("timestamp", "Date taken", Kind::Date, DATE),
94];
95
96pub const SHAPES: &[(&str, &str)] = &[
98 ("landscape", "Landscape"),
99 ("portrait", "Portrait"),
100 ("square", "Square"),
101 ("panoramic-landscape", "Wide landscape"),
102 ("panoramic-portrait", "Tall portrait"),
103 ("custom", "Ratio W:H"),
104];
105
106pub const RATINGS: &[(&str, &str)] = &[("none", "Unrated"), ("1", "1 star"), ("2", "2 stars"), ("3", "3 stars"), ("4", "4 stars"), ("5", "5 stars")];
108
109pub fn property(key: &str) -> Option<&'static Property> {
110 PROPERTIES.iter().find(|p| p.key == key)
111}
112
113pub fn units(kind: Kind) -> &'static [&'static str] {
116 match kind {
117 Kind::Size => &["mb", "kb"],
118 Kind::Duration => &["s", "m", "h"],
119 _ => &[],
120 }
121}
122
123pub fn method_label(kind: Kind, method: &str) -> &'static str {
125 match (kind, method) {
126 (_, "contain") if kind == Kind::Rating => "is one of",
127 (_, "contain") => "contains",
128 (_, "uncontain") => "doesn't contain",
129 (_, "startWith") => "starts with",
130 (_, "endWith") => "ends with",
131 (Kind::Tags | Kind::Folders, "equal") => "are exactly",
132 (_, "equal") => "is",
133 (_, "unequal") => "is not",
134 (_, "empty") => "is empty",
135 (_, "not-empty") => "isn't empty",
136 (_, "regex") => "matches regex",
137 (_, ">") => ">",
138 (_, ">=") => "≥",
139 (_, "=") => "=",
140 (_, "<") => "<",
141 (_, "<=") => "≤",
142 (_, "between") => "between",
143 (_, "before") => "before",
144 (_, "after") => "after",
145 (_, "on") => "on",
146 (_, "within") => "in the last",
147 (_, "intersection") => "include all of",
148 (_, "union") => "include any of",
149 (_, "identity") => "include none of",
150 (_, "similar") => "similar to",
151 (_, "accuracy") => "very close to",
152 (_, "grayscale") => "is greyscale",
153 (_, "activate") => "is installed",
154 (_, "deactivate") => "isn't installed",
155 _ => "?",
156 }
157}
158
159pub fn no_value(kind: Kind, method: &str) -> bool {
161 kind == Kind::Font || matches!(method, "empty" | "not-empty" | "grayscale")
162}
163
164#[derive(Debug, Clone, PartialEq)]
166pub struct RuleFields {
167 pub property: &'static str,
168 pub method: &'static str,
169 pub value: String,
172 pub value2: String,
174 pub unit: &'static str,
176 pub width: String,
178 pub height: String,
179}
180
181impl Default for RuleFields {
182 fn default() -> Self {
184 RuleFields::new("name")
185 }
186}
187
188impl RuleFields {
189 pub fn new(property: &'static str) -> RuleFields {
191 let prop = self::property(property).unwrap_or(&PROPERTIES[0]);
192 let value = match prop.kind {
193 Kind::Shape => "landscape",
194 Kind::Rating => "5",
195 Kind::Color => "#0087EF",
196 _ => "",
197 };
198 RuleFields {
199 property: prop.key,
200 method: prop.methods[0],
201 value: value.into(),
202 value2: String::new(),
203 unit: units(prop.kind).first().copied().unwrap_or(""),
204 width: String::new(),
205 height: String::new(),
206 }
207 }
208
209 pub fn kind(&self) -> Kind {
210 property(self.property).map_or(Kind::Text, |p| p.kind)
211 }
212
213 pub fn set_property(&mut self, property: &'static str) {
216 if self.property != property {
217 *self = RuleFields::new(property);
218 }
219 }
220
221 pub fn set_method(&mut self, method: &'static str) {
225 if self.kind() == Kind::Date && (method == "within") != (self.method == "within") {
226 self.value = if method == "within" { "30".into() } else { String::new() };
227 self.value2.clear();
228 }
229 self.method = method;
230 }
231
232 fn from_json(r: &Value) -> Option<RuleFields> {
234 let prop = property(r.get("property")?.as_str()?)?;
235 let m = r.get("method")?.as_str()?;
236 let method = *prop.methods.iter().find(|x| **x == m)?;
237 let v = r.get("value").unwrap_or(&Value::Null);
238 let item = |i: usize| v.as_array().and_then(|a| a.get(i));
239 let num_at = |i: usize| item(i).map(num_text);
240 let text = |v: &Value| match v {
241 Value::String(s) => Some(s.clone()),
242 Value::Null => Some(String::new()),
243 _ => None,
244 };
245 let (mut value, mut value2) = (String::new(), String::new());
246 match prop.kind {
247 Kind::Number | Kind::Size | Kind::Duration => {
248 if !v.is_array() {
249 return None;
250 }
251 value = num_at(0).unwrap_or_default();
252 value2 = num_at(1).unwrap_or_default();
253 }
254 Kind::Date => match v {
255 Value::Array(_) if method == "within" => value = num_at(0).unwrap_or_default(),
256 Value::Array(_) => {
257 value = item(0).map(date_text).unwrap_or_default();
258 value2 = item(1).map(date_text).unwrap_or_default();
259 }
260 Value::String(s) if s.is_empty() => {}
262 Value::Null => {}
263 _ => return None,
264 },
265 Kind::Tags | Kind::Folders => match v {
266 Value::Array(a) => {
267 let names: Option<Vec<&str>> = a.iter().map(Value::as_str).collect();
268 value = names?.join(", ");
269 }
270 Value::Null => {}
271 _ => return None,
272 },
273 Kind::Rating if method == "contain" && v.is_array() => {
274 let stars: Option<Vec<String>> = v.as_array()?.iter().map(|x| x.as_str().map(str::to_string).or_else(|| x.as_u64().map(|n| n.to_string()))).collect();
275 value = stars?.concat();
276 }
277 Kind::Rating => value = text(v).or_else(|| v.as_u64().map(|n| n.to_string()))?,
278 Kind::Font => {}
279 Kind::Text | Kind::Type | Kind::Shape | Kind::Color => value = text(v)?,
280 }
281 let unit = match r.get("unit").and_then(Value::as_str) {
282 Some(u) => units(prop.kind).iter().find(|x| **x == u).copied(),
283 None => units(prop.kind).first().copied(),
284 };
285 let unit = if units(prop.kind).is_empty() { "" } else { unit? };
288 let dim = |k: &str| r.get(k).map(num_text).unwrap_or_default();
289 Some(RuleFields { property: prop.key, method, value, value2, unit, width: dim("width"), height: dim("height") })
290 }
291
292 fn value_json(&self) -> Result<Value, String> {
294 let label = property(self.property).map_or(self.property, |p| p.label);
295 let number = |s: &str| -> Result<Value, String> { parse_number(s).map(js_number).ok_or_else(|| format!("{label} needs a number")) };
296 let date = |s: &str| -> Result<Value, String> { parse_date(s).map(|ms| json!(ms)).ok_or_else(|| format!("{label} needs a date as YYYY-MM-DD")) };
297 let list = || json!(self.value.split(',').map(str::trim).filter(|x| !x.is_empty()).collect::<Vec<_>>());
298 Ok(match self.kind() {
299 Kind::Number | Kind::Size | Kind::Duration => {
300 let b = if self.method == "between" || !self.value2.trim().is_empty() { number(&self.value2)? } else { json!(0) };
301 json!([number(&self.value)?, b])
302 }
303 Kind::Date => match self.method {
304 "within" => json!([number(&self.value)?]),
305 "between" => json!([date(&self.value)?, date(&self.value2)?]),
306 _ => json!([date(&self.value)?]),
307 },
308 Kind::Tags | Kind::Folders => list(),
309 Kind::Type => json!(self.value.trim()),
310 Kind::Color if self.method != "grayscale" && !is_hex(self.value.trim()) => return Err("Colour needs a value like #FF0000".into()),
311 Kind::Color => json!(self.value.trim()),
312 Kind::Text | Kind::Rating | Kind::Shape | Kind::Font => json!(self.value),
313 })
314 }
315
316 fn form(&self) -> u8 {
319 match (self.kind(), self.method) {
320 (Kind::Date, "within") => 1,
321 (Kind::Date, "between") => 2,
322 (Kind::Rating, "contain") => 1,
323 _ => 0,
324 }
325 }
326
327 fn write(&self, rule: &mut Map<String, Value>, before: Option<&RuleFields>) -> Result<(), String> {
330 let same_prop = before.is_some_and(|b| b.property == self.property);
331 set(rule, "property", json!(self.property), before.is_none_or(|b| b.property != self.property));
332 set(rule, "method", json!(self.method), before.is_none_or(|b| b.method != self.method));
333 let value_changed = !same_prop || before.is_some_and(|b| b.value != self.value || b.value2 != self.value2 || b.form() != self.form());
334 if self.kind() == Kind::Font {
335 if !rule.contains_key("value") {
337 rule.insert("value".into(), json!(""));
338 }
339 } else if value_changed || before.is_none() {
340 rule.insert("value".into(), self.value_json()?);
341 }
342 if !self.unit.is_empty() {
343 set(rule, "unit", json!(self.unit), !same_prop || before.is_some_and(|b| b.unit != self.unit));
344 }
345 if self.kind() == Kind::Shape && self.value == "custom" {
346 let ratio = |s: &str| parse_number(s).filter(|n| *n > 0.0).map(js_number).ok_or_else(|| "Ratio W:H needs two numbers above 0".to_string());
347 let (w, h) = (ratio(&self.width)?, ratio(&self.height)?);
348 let moved = !same_prop || before.is_some_and(|b| b.width != self.width || b.height != self.height || b.value != self.value);
349 set(rule, "width", w, moved);
350 set(rule, "height", h, moved);
351 }
352 Ok(())
353 }
354}
355
356fn set(m: &mut Map<String, Value>, key: &str, v: Value, when: bool) {
359 if when || !m.contains_key(key) {
360 m.insert(key.into(), v);
361 }
362}
363
364#[derive(Debug, Clone, PartialEq)]
366pub enum RuleDraft {
367 Edit { fields: RuleFields, stored: Option<Box<(RuleFields, Value)>> },
370 Unsupported(Value),
372}
373
374impl Default for RuleDraft {
375 fn default() -> Self {
376 RuleDraft::Edit { fields: RuleFields::default(), stored: None }
377 }
378}
379
380impl RuleDraft {
381 pub fn from_json(r: &Value) -> RuleDraft {
382 match RuleFields::from_json(r) {
383 Some(f) => RuleDraft::Edit { fields: f.clone(), stored: Some(Box::new((f, r.clone()))) },
384 None => RuleDraft::Unsupported(r.clone()),
385 }
386 }
387
388 pub fn to_json(&self) -> Result<Value, String> {
389 match self {
390 RuleDraft::Unsupported(v) => Ok(v.clone()),
391 RuleDraft::Edit { fields, stored: Some(s) } if s.0 == *fields => Ok(s.1.clone()),
392 RuleDraft::Edit { fields, stored } => {
393 let (mut rule, before) = match stored.as_deref() {
394 Some((b, Value::Object(m))) => (m.clone(), Some(b)),
395 _ => (Map::new(), None),
396 };
397 fields.write(&mut rule, before)?;
398 Ok(Value::Object(rule))
399 }
400 }
401 }
402}
403
404#[derive(Debug, Clone, PartialEq)]
408pub struct ConditionDraft {
409 pub any: bool,
410 pub negate: bool,
411 pub rules: Vec<RuleDraft>,
412 stored: Option<Value>,
413}
414
415impl Default for ConditionDraft {
416 fn default() -> Self {
418 ConditionDraft { any: true, negate: false, rules: vec![RuleDraft::default()], stored: None }
419 }
420}
421
422impl ConditionDraft {
423 pub fn from_json(c: &Value) -> ConditionDraft {
424 ConditionDraft {
425 any: c.get("match").and_then(Value::as_str) == Some("OR"),
426 negate: c.get("boolean").and_then(Value::as_str) == Some("FALSE"),
427 rules: c.get("rules").and_then(Value::as_array).map(|r| r.iter().map(RuleDraft::from_json).collect()).unwrap_or_default(),
428 stored: Some(c.clone()),
429 }
430 }
431
432 pub fn to_json(&self) -> Result<Value, String> {
436 let list: Vec<Value> = self.rules.iter().map(RuleDraft::to_json).collect::<Result<_, _>>()?;
437 let mode = json!(if self.any { "OR" } else { "AND" });
438 let boolean = json!(if self.negate { "FALSE" } else { "TRUE" });
439 let Some(Value::Object(stored)) = &self.stored else {
440 return Ok(json!({"rules": list, "match": mode, "boolean": boolean}));
441 };
442 let mut c = stored.clone();
443 let was = ConditionDraft::from_json(&self.stored.clone().unwrap_or_default());
444 let same = match c.get("rules") {
446 Some(Value::Array(a)) => *a == list,
447 _ => list.is_empty(),
448 };
449 if !same {
450 c.insert("rules".into(), Value::Array(list));
451 }
452 if was.any != self.any {
453 c.insert("match".into(), mode);
454 }
455 if was.negate != self.negate {
456 c.insert("boolean".into(), boolean);
457 }
458 Ok(Value::Object(c))
459 }
460}
461
462pub fn drafts(conditions: &[Value]) -> Vec<ConditionDraft> {
464 conditions.iter().map(ConditionDraft::from_json).collect()
465}
466
467pub fn conditions(drafts: &[ConditionDraft]) -> Result<Vec<Value>, String> {
470 drafts.iter().map(ConditionDraft::to_json).collect()
471}
472
473fn js_number(n: f64) -> Value {
475 if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 { json!(n as i64) } else { json!(n) }
476}
477
478fn num_text(v: &Value) -> String {
479 match v {
480 Value::Number(n) => n.to_string(),
481 Value::String(s) => s.clone(),
482 _ => String::new(),
483 }
484}
485
486fn parse_number(s: &str) -> Option<f64> {
487 s.trim().parse::<f64>().ok().filter(|n| n.is_finite())
488}
489
490fn is_hex(s: &str) -> bool {
491 s.strip_prefix('#').is_some_and(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))
492}
493
494fn date_text(v: &Value) -> String {
496 v.as_f64()
497 .and_then(|ms| Local.timestamp_millis_opt(ms as i64).single())
498 .map(|d| d.format("%Y-%m-%d").to_string())
499 .unwrap_or_default()
500}
501
502fn parse_date(s: &str) -> Option<u64> {
505 let d = NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()?;
506 use chrono::Datelike;
507 search::local_day_start(d.year(), d.month(), d.day())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn round_trip(conditions: &Value) -> Value {
515 let list = conditions.as_array().unwrap();
516 Value::Array(super::conditions(&drafts(list)).unwrap())
517 }
518
519 #[test]
520 fn untouched_conditions_come_back_verbatim() {
521 let c = json!([
522 {"match": "AND", "rules": [{"property": "name", "method": "contain", "value": "x", "extra": 1}]},
523 {"boolean": "FALSE", "match": "OR", "note": "kept", "rules": [
524 {"property": "mystery", "method": "equal", "value": {"a": 1}},
525 {"property": "fileSize", "method": ">", "value": [1.50, 0], "unit": "kb"},
526 {"property": "width", "method": "teleport", "value": [1, 2]},
527 {"property": "createTime", "method": "between", "value": [1_700_000_123_456_u64, 1_700_100_000_000_u64]}
528 ]},
529 {"rules": []},
530 {"match": "OR"}
531 ]);
532 assert_eq!(round_trip(&c), c);
533 let d = drafts(c.as_array().unwrap());
534 assert!(!d[0].any && d[1].any && d[1].negate && !d[0].negate);
535 assert!(matches!(d[1].rules[0], RuleDraft::Unsupported(_)), "unknown property");
536 assert!(matches!(d[1].rules[2], RuleDraft::Unsupported(_)), "unknown method");
537 assert!(matches!(&d[1].rules[1], RuleDraft::Edit { fields, .. } if fields.unit == "kb" && fields.value == "1.5"));
538 }
539
540 #[test]
541 fn edits_touch_only_what_changed() {
542 let c = json!([{"note": 1, "match": "AND", "rules": [
543 {"x": true, "property": "createTime", "method": "on", "value": [1_700_000_123_456_u64]},
544 {"property": "width", "method": ">", "value": [480, 7]}
545 ]}]);
546 let mut d = drafts(c.as_array().unwrap());
547 d[0].negate = true;
548 let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
549 fields.method = "before"; let RuleDraft::Edit { fields, .. } = &mut d[0].rules[1] else { panic!() };
551 fields.value = "640".into();
552 let out = Value::Array(conditions(&d).unwrap());
553 assert_eq!(
554 out,
555 json!([{"note": 1, "match": "AND", "rules": [
556 {"x": true, "property": "createTime", "method": "before", "value": [1_700_000_123_456_u64]},
557 {"property": "width", "method": ">", "value": [640, 7]}
558 ], "boolean": "FALSE"}])
559 );
560 let mut d = drafts(c.as_array().unwrap());
562 let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
563 fields.method = "within";
564 assert!(conditions(&d).is_err());
565 let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
566 fields.value = "7".into();
567 assert_eq!(conditions(&d).unwrap()[0]["rules"][0]["value"], json!([7]));
568 let mut d = drafts(c.as_array().unwrap());
570 let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
571 fields.set_method("within");
572 assert_eq!(conditions(&d).unwrap()[0]["rules"][0]["value"], json!([30]));
573 let RuleDraft::Edit { fields, .. } = &mut d[0].rules[0] else { panic!() };
574 fields.set_method("after");
575 assert!(conditions(&d).is_err(), "back to a date, which must be entered");
576 }
577
578 #[test]
579 fn new_conditions_and_rules_take_eagles_shape() {
580 let mut c = ConditionDraft { any: false, ..Default::default() };
581 c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "2".into(), ..RuleFields::new("fileSize") }, stored: None });
582 c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "1.5".into(), unit: "m", ..RuleFields::new("duration") }, stored: None });
583 c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "custom".into(), width: "16".into(), height: "9".into(), ..RuleFields::new("shape") }, stored: None });
584 c.rules.push(RuleDraft::Edit { fields: RuleFields::new("fontActivated"), stored: None });
585 c.rules.push(RuleDraft::Edit { fields: RuleFields { value: "a, b ,".into(), ..RuleFields::new("tags") }, stored: None });
586 c.rules.push(RuleDraft::Edit { fields: RuleFields { method: "between", value: "2024-03-10".into(), value2: "2024-03-12".into(), ..RuleFields::new("timestamp") }, stored: None });
587 let text = serde_json::to_string(&c.to_json().unwrap()).unwrap();
588 let day = |d: u32| search::local_day_start(2024, 3, d).unwrap();
589 assert_eq!(
590 text,
591 format!(
592 concat!(
593 r#"{{"rules":[{{"property":"name","method":"contain","value":""}},"#,
594 r#"{{"property":"fileSize","method":">","value":[2,0],"unit":"mb"}},"#,
595 r#"{{"property":"duration","method":">","value":[1.5,0],"unit":"m"}},"#,
596 r#"{{"property":"shape","method":"equal","value":"custom","width":16,"height":9}},"#,
597 r#"{{"property":"fontActivated","method":"activate","value":""}},"#,
598 r#"{{"property":"tags","method":"intersection","value":["a","b"]}},"#,
599 r#"{{"property":"timestamp","method":"between","value":[{},{}]}}],"match":"AND","boolean":"TRUE"}}"#
600 ),
601 day(10),
602 day(12)
603 )
604 );
605 }
606
607 #[test]
611 fn eagle_smart_folder_survives_an_edit() {
612 let tmp = tempfile::tempdir().unwrap();
613 let mut lib = crate::Library::create(tmp.path().join("S.library")).unwrap();
614 let stored = json!([
615 {"rules": [
616 {"property": "name", "method": "regex", "value": "^ref", "$$extra": "x"},
617 {"property": "someFutureProperty", "method": "near", "value": [1, {"deep": true}]}
618 ], "match": "OR", "boolean": "TRUE"},
619 {"rules": [
620 {"property": "fileSize", "method": "between", "value": [0.5, 2.25], "unit": "kb"},
621 {"property": "tags", "method": "union", "value": ["draft", "wip"]}
622 ], "match": "AND", "boolean": "FALSE", "future": 3}
623 ]);
624 let id = lib.create_smart_folder("Refs", stored.as_array().unwrap().clone()).unwrap();
625 let file = lib.root().join("metadata.json");
626 let strip = |s: &str| {
628 let mut v: Value = serde_json::from_str(s).unwrap();
629 v.as_object_mut().unwrap().shift_remove("modificationTime");
630 for f in v["smartFolders"].as_array_mut().unwrap() {
631 f.as_object_mut().unwrap().shift_remove("modificationTime");
632 }
633 serde_json::to_string(&v).unwrap()
634 };
635 let before = std::fs::read_to_string(&file).unwrap();
636 let load = |lib: &crate::Library| drafts(&lib.smart_folders()[0].conditions);
637
638 lib.update_smart_folder(&id, "Refs", conditions(&load(&lib)).unwrap()).unwrap();
640 assert_eq!(strip(&std::fs::read_to_string(&file).unwrap()), strip(&before));
641
642 let mut d = load(&lib);
644 assert!(matches!(d[0].rules[1], RuleDraft::Unsupported(_)) && d[1].negate && !d[0].negate);
645 d[1].negate = false;
646 let RuleDraft::Edit { fields, .. } = &mut d[1].rules[1] else { panic!() };
647 fields.value.push_str(", todo");
648 lib.update_smart_folder(&id, "Refs", conditions(&d).unwrap()).unwrap();
649 let want = before.replace(r#"["draft","wip"]"#, r#"["draft","wip","todo"]"#).replace(r#""boolean":"FALSE""#, r#""boolean":"TRUE""#);
650 assert_ne!(want, before);
651 assert_eq!(strip(&std::fs::read_to_string(&file).unwrap()), strip(&want));
652
653 let mut d = load(&lib);
655 d[0].rules.remove(1);
656 lib.update_smart_folder(&id, "Refs", conditions(&d).unwrap()).unwrap();
657 assert_eq!(lib.smart_folders()[0].conditions[0]["rules"], json!([{"property": "name", "method": "regex", "value": "^ref", "$$extra": "x"}]));
658 }
659
660 #[test]
661 fn bad_values_are_refused() {
662 let bad = |f: RuleFields| RuleDraft::Edit { fields: f, stored: None }.to_json().is_err();
663 assert!(bad(RuleFields { value: "abc".into(), ..RuleFields::new("width") }));
664 assert!(bad(RuleFields { method: "between", value: "1".into(), ..RuleFields::new("height") }));
665 assert!(bad(RuleFields { value: "red".into(), ..RuleFields::new("color") }));
666 assert!(bad(RuleFields { value: "10/3/2024".into(), ..RuleFields::new("btime") }));
667 assert!(bad(RuleFields { value: "custom".into(), width: "0".into(), height: "1".into(), ..RuleFields::new("shape") }));
668 assert!(!bad(RuleFields::new("name")), "an empty text value is stored, as in Eagle");
669 assert!(!bad(RuleFields { method: "grayscale", value: String::new(), ..RuleFields::new("color") }));
670 }
671
672 #[test]
673 fn every_property_reads_back_its_own_output() {
674 for p in PROPERTIES {
675 for m in p.methods {
676 let mut f = RuleFields { method: m, ..RuleFields::new(p.key) };
677 match (p.kind, *m) {
678 (Kind::Number | Kind::Size | Kind::Duration, _) => (f.value, f.value2) = ("3".into(), "4".into()),
679 (Kind::Date, "within") => f.value = "7".into(),
680 (Kind::Date, "between") => (f.value, f.value2) = ("2024-01-02".into(), "2024-01-05".into()),
681 (Kind::Date, _) => f.value = "2024-01-02".into(),
682 (Kind::Tags | Kind::Folders, _) => f.value = "a, b".into(),
683 _ => {}
684 }
685 let v = RuleDraft::Edit { fields: f.clone(), stored: None }.to_json().unwrap();
686 let RuleDraft::Edit { fields: back, .. } = RuleDraft::from_json(&v) else { panic!("{v}") };
687 assert_eq!(back, f, "{v}");
688 }
689 }
690 }
691}