1use std::collections::HashMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use serde_json::{Map, Value};
26
27use crate::error::{Error, Result};
28use crate::library::Library;
29
30pub fn library_key(root: &Path) -> String {
37 let canonical = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
38 let text = canonical.to_string_lossy();
39 let text = text.trim_end_matches(['/', '\\']);
40 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
41 for b in text.bytes() {
42 h ^= u64::from(b);
43 h = h.wrapping_mul(0x0000_0100_0000_01b3);
44 }
45 format!("{h:016x}")
46}
47
48pub fn store_path(dir: &Path, root: &Path) -> PathBuf {
51 dir.join(format!("{}.json", library_key(root)))
52}
53
54#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
57#[serde(tag = "type", rename_all = "camelCase")]
58pub enum Step {
59 AddTags { tags: Vec<String> },
60 RemoveTags { tags: Vec<String> },
61 #[serde(rename = "clearTag")]
63 ClearTags,
64 AddFolders { folders: Vec<String> },
66 RemoveFolders { folders: Vec<String> },
67 #[serde(rename = "clearFolder")]
69 ClearFolders,
70 Rating { rating: u8 },
72 AutoTag,
75 Describe {
78 #[serde(default)]
79 replace: bool,
80 },
81}
82
83impl Step {
84 pub fn kinds() -> [Step; 9] {
86 [
87 Step::AddTags { tags: Vec::new() },
88 Step::RemoveTags { tags: Vec::new() },
89 Step::ClearTags,
90 Step::AddFolders { folders: Vec::new() },
91 Step::RemoveFolders { folders: Vec::new() },
92 Step::ClearFolders,
93 Step::Rating { rating: 0 },
94 Step::AutoTag,
95 Step::Describe { replace: false },
96 ]
97 }
98
99 pub fn is_ai(&self) -> bool {
101 matches!(self, Step::AutoTag | Step::Describe { .. })
102 }
103
104 pub fn same_kind(&self, other: &Step) -> bool {
106 std::mem::discriminant(self) == std::mem::discriminant(other)
107 }
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Default)]
112pub struct ActionDef {
113 pub id: String,
114 pub name: String,
115 pub shortcut: Option<String>,
117 pub steps: Vec<Step>,
118 pub runnable: bool,
122}
123
124#[derive(Clone, Debug, PartialEq, Default)]
126pub struct ActionsFile {
127 entries: Vec<Map<String, Value>>,
128 pretty: bool,
130}
131
132impl ActionsFile {
133 pub fn parse(text: &str) -> serde_json::Result<ActionsFile> {
136 let entries: Vec<Map<String, Value>> = serde_json::from_str(text)?;
137 let pretty = text.trim_start().strip_prefix('[').is_some_and(|rest| rest.starts_with(['\n', '\r']));
138 Ok(ActionsFile { entries, pretty })
139 }
140
141 pub fn to_text(&self) -> String {
143 if self.pretty {
144 serde_json::to_string_pretty(&self.entries).unwrap_or_else(|_| "[]".into())
145 } else {
146 serde_json::to_string(&self.entries).unwrap_or_else(|_| "[]".into())
147 }
148 }
149
150 pub fn load(path: &Path) -> Result<ActionsFile> {
152 match fs::read_to_string(path) {
153 Ok(text) => Self::parse(&text).map_err(|e| Error::json(path, e)),
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ActionsFile::default()),
155 Err(e) => Err(Error::io(path, e)),
156 }
157 }
158
159 pub fn save(&self, path: &Path) -> Result<()> {
163 let io = |e: std::io::Error| Error::io(path, e);
164 let dir = path.parent().unwrap_or(Path::new("."));
165 fs::create_dir_all(dir).map_err(io)?;
166 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(io)?;
167 std::io::Write::write_all(&mut tmp, self.to_text().as_bytes()).map_err(io)?;
168 tmp.persist(path).map_err(|e| io(e.error))?;
169 Ok(())
170 }
171
172 pub fn len(&self) -> usize {
173 self.entries.len()
174 }
175
176 pub fn is_empty(&self) -> bool {
177 self.entries.is_empty()
178 }
179
180 pub fn actions(&self) -> Vec<ActionDef> {
182 self.entries.iter().map(read_entry).collect()
183 }
184
185 fn index(&self, id: &str) -> Option<usize> {
186 self.entries.iter().position(|e| e.get("id").and_then(Value::as_str) == Some(id))
187 }
188
189 pub fn put(&mut self, def: &ActionDef) {
193 let entry = match self.index(&def.id) {
194 Some(i) => &mut self.entries[i],
195 None => {
196 self.entries.push(Map::new());
197 self.entries.last_mut().expect("just pushed")
198 }
199 };
200 entry.insert("id".into(), def.id.clone().into());
201 entry.insert("name".into(), def.name.clone().into());
202 entry.insert("shortcut".into(), def.shortcut.clone().unwrap_or_default().into());
203 if def.runnable {
204 entry.insert("steps".into(), serde_json::to_value(&def.steps).unwrap_or_default());
205 }
206 }
207
208 pub fn set_shortcut(&mut self, id: &str, shortcut: Option<String>) {
210 if let Some(i) = self.index(id) {
211 self.entries[i].insert("shortcut".into(), shortcut.unwrap_or_default().into());
212 }
213 }
214
215 pub fn remove(&mut self, id: &str) {
217 if let Some(i) = self.index(id) {
218 self.entries.remove(i);
219 }
220 }
221
222 pub fn duplicate(&mut self, id: &str, name: &str) -> Option<String> {
225 let i = self.index(id)?;
226 let mut copy = self.entries[i].clone();
227 let new_id = crate::ids::new_id();
228 copy.insert("id".into(), new_id.clone().into());
229 copy.insert("name".into(), name.into());
230 copy.insert("shortcut".into(), "".into());
232 self.entries.insert(i + 1, copy);
233 Some(new_id)
234 }
235
236 pub fn reorder(&mut self, id: &str, to: usize) {
238 if let Some(i) = self.index(id) {
239 let e = self.entries.remove(i);
240 self.entries.insert(to.min(self.entries.len()), e);
241 }
242 }
243}
244
245fn read_entry(e: &Map<String, Value>) -> ActionDef {
246 let text = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
247 let shortcut = Some(text("shortcut")).filter(|s| !s.trim().is_empty());
248 let steps: Option<Vec<Step>> = match e.get("steps") {
249 None => Some(Vec::new()),
250 Some(v) => serde_json::from_value(v.clone()).ok(),
251 };
252 let foreign = e.keys().any(|k| !matches!(k.as_str(), "id" | "name" | "shortcut" | "steps"));
254 ActionDef { id: text("id"), name: text("name"), shortcut, runnable: steps.is_some() && !foreign, steps: steps.unwrap_or_default() }
255}
256
257#[derive(Clone, Debug, PartialEq, Eq)]
260pub enum StepOutput {
261 Tags(Vec<String>),
262 Description(String),
263}
264
265#[derive(Clone, Debug, Default, PartialEq, Eq)]
267pub struct RunReport {
268 pub changed: usize,
270 pub missing_folders: Vec<String>,
272}
273
274impl Library {
275 pub fn run_steps(&mut self, ids: &[String], steps: &[Step]) -> Result<RunReport> {
281 let ids: Vec<String> = ids.iter().filter(|id| self.item(id).is_some_and(|i| !i.is_deleted)).cloned().collect();
282 let mut report = RunReport::default();
283 let mut touched = std::collections::HashSet::new();
284 let known: std::collections::HashSet<String> = self.folder_ids().into_iter().map(str::to_string).collect();
285 for step in steps {
286 match step {
287 Step::AddTags { tags } => {
288 for id in &ids {
289 if self.item(id).is_some_and(|i| tags.iter().filter_map(|t| crate::tagging::clean(t)).any(|t| !i.tags.contains(&t))) {
290 touched.insert(id.clone());
291 }
292 }
293 self.add_tags_to_items(&ids, tags)?;
294 }
295 Step::RemoveTags { tags } => {
296 for id in &ids {
297 if self.item(id).is_some_and(|i| i.tags.iter().any(|t| tags.contains(t))) {
298 touched.insert(id.clone());
299 }
300 }
301 self.remove_tags_from_items(&ids, tags)?;
302 }
303 Step::ClearTags => {
304 for id in &ids {
305 if self.item(id).is_some_and(|i| !i.tags.is_empty()) {
306 touched.insert(id.clone());
307 }
308 }
309 self.clear_tags(&ids)?;
310 }
311 Step::AddFolders { folders } => {
312 for fid in folders {
313 if !known.contains(fid) {
314 if !report.missing_folders.contains(fid) {
315 report.missing_folders.push(fid.clone());
316 }
317 continue;
318 }
319 for id in &ids {
320 let Some(mut item) = self.item(id).cloned() else { continue };
321 if crate::autotag::join_folder(&mut item, &self.metadata.folders, fid) {
322 self.update_item(item)?;
323 touched.insert(id.clone());
324 }
325 }
326 }
327 }
328 Step::RemoveFolders { folders } => {
329 for id in &ids {
330 let Some(mut item) = self.item(id).cloned() else { continue };
331 let before = item.folders.len();
332 item.folders.retain(|f| !folders.contains(f));
333 if item.folders.len() != before {
334 self.update_item(item)?;
335 touched.insert(id.clone());
336 }
337 }
338 }
339 Step::ClearFolders => {
340 for id in &ids {
341 let Some(mut item) = self.item(id).cloned() else { continue };
342 if !item.folders.is_empty() {
343 item.folders.clear();
344 self.update_item(item)?;
345 touched.insert(id.clone());
346 }
347 }
348 }
349 Step::Rating { rating } => {
350 let rating = (*rating).min(5);
351 for id in &ids {
352 let Some(mut item) = self.item(id).cloned() else { continue };
353 let now = item.extra.get("star").and_then(Value::as_u64).unwrap_or(0);
354 if now == u64::from(rating) {
355 continue;
356 }
357 if rating == 0 {
358 item.extra.remove("star");
359 } else {
360 item.extra.insert("star".into(), rating.into());
361 }
362 self.update_item(item)?;
363 touched.insert(id.clone());
364 }
365 }
366 Step::AutoTag | Step::Describe { .. } => {}
367 }
368 }
369 report.changed = touched.len();
370 Ok(report)
371 }
372
373 pub fn run_steps_with(&mut self, ids: &[String], steps: &[Step], outputs: &HashMap<(usize, String), StepOutput>) -> Result<RunReport> {
378 let state = |lib: &Library, id: &String| lib.item(id).filter(|i| !i.is_deleted).map(|i| (i.tags.clone(), i.folders.clone(), i.annotation.clone(), i.extra.get("star").cloned()));
379 let before: Vec<_> = ids.iter().map(|id| state(self, id)).collect();
380 let mut report = RunReport::default();
381 for (n, step) in steps.iter().enumerate() {
382 if !step.is_ai() {
383 let done = self.run_steps(ids, std::slice::from_ref(step))?;
384 for f in done.missing_folders {
385 if !report.missing_folders.contains(&f) {
386 report.missing_folders.push(f);
387 }
388 }
389 continue;
390 }
391 for id in ids {
392 if !self.item(id).is_some_and(|i| !i.is_deleted) {
393 continue;
394 }
395 match outputs.get(&(n, id.clone())) {
396 Some(StepOutput::Tags(tags)) => {
397 self.add_tags_to_items(std::slice::from_ref(id), tags)?;
398 }
399 Some(StepOutput::Description(text)) => {
400 if let Some(mut item) = self.item(id).cloned()
401 && item.annotation != *text
402 {
403 item.annotation.clone_from(text);
404 self.update_item(item)?;
405 }
406 }
407 None => {}
408 }
409 }
410 }
411 report.changed = ids.iter().zip(&before).filter(|(id, was)| state(self, id) != **was).count();
412 Ok(report)
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::tagging::tests::{library, s};
420
421 const SAMPLE: &str = r#"[{"id":"MA1B2C3D4E5F6","name":"File as reference","shortcut":"Command + Shift + 1","steps":[{"type":"addTags","tags":["reference","to sort"]},{"type":"removeTags","tags":["inbox"]},{"type":"addFolders","folders":["MFOLDER000001"]},{"type":"rating","rating":4}]},{"id":"MA1B2C3D4E5F7","name":"Reset","shortcut":"","steps":[{"type":"clearTag"},{"type":"clearFolder"},{"type":"removeFolders","folders":["MFOLDER000002"]},{"type":"rating","rating":0}]}]"#;
423
424 #[test]
425 fn sample_parses_and_writes_back_byte_for_byte() {
426 let f = ActionsFile::parse(SAMPLE).unwrap();
427 assert_eq!(f.to_text(), SAMPLE);
428 let a = f.actions();
429 assert_eq!(a.len(), 2);
430 assert!(a.iter().all(|a| a.runnable));
431 assert_eq!(a[0].shortcut.as_deref(), Some("Command + Shift + 1"));
432 assert_eq!(a[1].shortcut, None, "an empty string is no shortcut");
433 assert_eq!(a[0].steps[0], Step::AddTags { tags: s(&["reference", "to sort"]) });
434 assert_eq!(a[0].steps[3], Step::Rating { rating: 4 });
435 assert_eq!(a[1].steps[..2], [Step::ClearTags, Step::ClearFolders]);
436 }
437
438 #[test]
439 fn put_round_trips_the_sample() {
440 let f = ActionsFile::parse(SAMPLE).unwrap();
441 let mut g = ActionsFile::default();
442 for a in f.actions() {
443 g.put(&a);
444 }
445 assert_eq!(g.to_text(), SAMPLE);
446 }
447
448 #[test]
449 fn foreign_entries_survive_edits_untouched() {
450 let text = r#"[{"name":"Describe","id":"X1","prompt":"Write a note","model":{"n":1.5},"shortcut":"Command + 7"},{"id":"X2","name":"Mine","shortcut":"","steps":[{"type":"rating","rating":2}]}]"#;
451 let mut f = ActionsFile::parse(text).unwrap();
452 let a = f.actions();
453 assert!(!a[0].runnable, "unknown fields mean another program runs it");
454 assert_eq!(a[0].shortcut.as_deref(), Some("Command + 7"));
455 assert!(a[1].runnable);
456 let mut mine = a[1].clone();
457 mine.name = "Mine too".into();
458 f.put(&mine);
459 let out = f.to_text();
460 assert!(out.starts_with(r#"[{"name":"Describe","id":"X1","prompt":"Write a note","model":{"n":1.5},"shortcut":"Command + 7"},"#), "{out}");
461 let odd = r#"[{"id":"Y","name":"Odd","shortcut":"","steps":[{"type":"teleport"}]}]"#;
463 let mut f = ActionsFile::parse(odd).unwrap();
464 let y = f.actions().remove(0);
465 assert!(!y.runnable);
466 f.put(&y);
467 assert_eq!(f.to_text(), odd, "an entry falcon can't run keeps its steps");
468 }
469
470 #[test]
471 fn indented_files_stay_indented() {
472 let text = "[\n {\n \"id\": \"A\",\n \"name\": \"N\",\n \"shortcut\": \"\",\n \"steps\": []\n }\n]";
473 let f = ActionsFile::parse(text).unwrap();
474 assert_eq!(f.to_text(), text);
475 assert_eq!(ActionsFile::parse("[]").unwrap().to_text(), "[]");
476 }
477
478 #[test]
479 fn edits_reorder_duplicate_remove() {
480 let mut f = ActionsFile::parse(SAMPLE).unwrap();
481 let id = f.duplicate("MA1B2C3D4E5F6", "Copy").unwrap();
482 let a = f.actions();
483 assert_eq!(a[1].id, id);
484 assert_eq!((a[1].name.as_str(), a[1].shortcut.as_deref()), ("Copy", None));
485 assert_eq!(a[1].steps, a[0].steps);
486 f.reorder(&id, 0);
487 assert_eq!(f.actions()[0].id, id);
488 f.set_shortcut(&id, Some("Alt + 9".into()));
489 assert_eq!(f.actions()[0].shortcut.as_deref(), Some("Alt + 9"));
490 f.remove(&id);
491 assert_eq!(f.to_text(), SAMPLE);
492 }
493
494 #[test]
495 fn missing_file_is_empty_and_save_is_atomic() {
496 let d = tempfile::tempdir().unwrap();
497 let path = d.path().join("actions/k.json");
498 assert!(ActionsFile::load(&path).unwrap().is_empty());
499 let f = ActionsFile::parse(SAMPLE).unwrap();
500 f.save(&path).unwrap();
501 assert_eq!(fs::read_to_string(&path).unwrap(), SAMPLE);
502 assert_eq!(fs::read_dir(d.path().join("actions")).unwrap().count(), 1, "no temp file left");
503 assert_eq!(ActionsFile::load(&path).unwrap(), f);
504 fs::write(&path, "{oops").unwrap();
505 assert!(ActionsFile::load(&path).is_err());
506 }
507
508 #[test]
509 fn a_failed_save_leaves_no_partial_file() {
510 let d = tempfile::tempdir().unwrap();
511 let blocked = d.path().join("k.json");
513 fs::create_dir(&blocked).unwrap();
514 assert!(ActionsFile::parse(SAMPLE).unwrap().save(&blocked).is_err());
515 let names: Vec<_> = fs::read_dir(d.path()).unwrap().map(|e| e.unwrap().file_name()).collect();
516 assert_eq!(names, ["k.json"], "the temp file is gone");
517 assert!(blocked.is_dir());
518 #[cfg(unix)]
520 {
521 use std::os::unix::fs::PermissionsExt;
522 let ro = d.path().join("ro");
523 fs::create_dir(&ro).unwrap();
524 let path = ro.join("k.json");
525 fs::write(&path, "[]").unwrap();
526 fs::set_permissions(&ro, fs::Permissions::from_mode(0o555)).unwrap();
527 let res = ActionsFile::parse(SAMPLE).unwrap().save(&path);
528 fs::set_permissions(&ro, fs::Permissions::from_mode(0o755)).unwrap();
529 assert!(res.is_err());
530 assert_eq!(fs::read_to_string(&path).unwrap(), "[]");
531 assert_eq!(fs::read_dir(&ro).unwrap().count(), 1);
532 }
533 }
534
535 #[test]
536 fn keys_follow_the_canonical_library_path() {
537 let d = tempfile::tempdir().unwrap();
538 let (a, b) = (d.path().join("A.library"), d.path().join("B.library"));
539 fs::create_dir_all(&a).unwrap();
540 fs::create_dir_all(&b).unwrap();
541 assert_ne!(library_key(&a), library_key(&b));
542 assert_eq!(library_key(&a).len(), 16);
543 assert_eq!(library_key(&a), library_key(&d.path().join("A.library/")), "a trailing slash is the same library");
544 assert_eq!(library_key(&a), library_key(&d.path().join("B.library/../A.library")), "resolved before hashing");
545 let dir = Path::new("/app/actions");
546 assert_eq!(store_path(dir, &a), dir.join(format!("{}.json", library_key(&a))));
547 assert!(!store_path(dir, &a).starts_with(&a), "never inside the library");
548 }
549
550 fn folder(lib: &mut Library, name: &str) -> String {
551 lib.create_folder(None, name).unwrap()
552 }
553
554 fn star(lib: &Library, id: &str) -> u64 {
555 lib.item(id).unwrap().extra.get("star").and_then(Value::as_u64).unwrap_or(0)
556 }
557
558 #[test]
559 fn each_step_kind_on_a_multi_selection() {
560 let d = tempfile::tempdir().unwrap();
561 let mut lib = library(d.path(), &[&["inbox", "x"], &["y"], &["inbox"]]);
562 let (f1, f2) = (folder(&mut lib, "One"), folder(&mut lib, "Two"));
563 let ids = s(&["A1", "A2"]);
564 let r = lib.run_steps(&ids, &[Step::AddTags { tags: s(&["ref"]) }, Step::RemoveTags { tags: s(&["inbox"]) }]).unwrap();
565 assert_eq!(r.changed, 2);
566 assert_eq!(lib.item("A1").unwrap().tags, s(&["x", "ref"]));
567 assert_eq!(lib.item("A2").unwrap().tags, s(&["y", "ref"]));
568 assert_eq!(lib.item("A3").unwrap().tags, s(&["inbox"]), "only the selection");
569 lib.run_steps(&ids, &[Step::AddFolders { folders: vec![f1.clone(), f2.clone()] }]).unwrap();
570 assert_eq!(lib.item("A1").unwrap().folders, vec![f1.clone(), f2.clone()]);
571 lib.run_steps(&ids, &[Step::RemoveFolders { folders: vec![f1.clone()] }]).unwrap();
572 assert_eq!(lib.item("A2").unwrap().folders, vec![f2.clone()]);
573 lib.run_steps(&ids, &[Step::Rating { rating: 3 }]).unwrap();
574 assert_eq!((star(&lib, "A1"), star(&lib, "A2"), star(&lib, "A3")), (3, 3, 0));
575 let r = lib.run_steps(&ids, &[Step::ClearTags, Step::ClearFolders, Step::Rating { rating: 0 }]).unwrap();
576 assert_eq!(r.changed, 2);
577 let a1 = lib.item("A1").unwrap();
578 assert!(a1.tags.is_empty() && a1.folders.is_empty() && !a1.extra.contains_key("star"));
579 let reopened = Library::open(lib.root()).unwrap();
581 assert!(reopened.item("A2").unwrap().tags.is_empty());
582 assert_eq!(reopened.item("A3").unwrap().tags, s(&["inbox"]));
583 }
584
585 #[test]
586 fn steps_run_in_order() {
587 let d = tempfile::tempdir().unwrap();
588 let mut lib = library(d.path(), &[&["a"]]);
589 lib.run_steps(&s(&["A1"]), &[Step::ClearTags, Step::AddTags { tags: s(&["b"]) }]).unwrap();
590 assert_eq!(lib.item("A1").unwrap().tags, s(&["b"]));
591 lib.run_steps(&s(&["A1"]), &[Step::AddTags { tags: s(&["c"]) }, Step::ClearTags]).unwrap();
592 assert!(lib.item("A1").unwrap().tags.is_empty());
593 }
594
595 #[test]
596 fn missing_folders_are_skipped_and_trash_is_left_alone() {
597 let d = tempfile::tempdir().unwrap();
598 let mut lib = library(d.path(), &[&[], &[]]);
599 let f = folder(&mut lib, "Here");
600 let mut gone = lib.item("A2").unwrap().clone();
601 gone.is_deleted = true;
602 lib.update_item(gone).unwrap();
603 let r = lib.run_steps(&s(&["A1", "A2"]), &[Step::AddFolders { folders: vec!["NOPE".into(), f.clone()] }, Step::Rating { rating: 9 }]).unwrap();
604 assert_eq!(r.missing_folders, s(&["NOPE"]));
605 assert_eq!(r.changed, 1);
606 assert_eq!(lib.item("A1").unwrap().folders, vec![f]);
607 assert_eq!(star(&lib, "A1"), 5, "ratings cap at five");
608 let a2 = lib.item("A2").unwrap();
609 assert!(a2.is_deleted && a2.folders.is_empty(), "a trashed item is not restored");
610 }
611
612 #[test]
613 fn ai_steps_round_trip_and_old_entries_stay_readable() {
614 let text = r#"[{"id":"A","name":"Label","shortcut":"","steps":[{"type":"autoTag"},{"type":"describe","replace":true},{"type":"describe"}]}]"#;
615 let f = ActionsFile::parse(text).unwrap();
616 let a = f.actions().remove(0);
617 assert!(a.runnable);
618 assert_eq!(a.steps, [Step::AutoTag, Step::Describe { replace: true }, Step::Describe { replace: false }]);
619 assert!(a.steps.iter().all(Step::is_ai));
620 assert!(!Step::Rating { rating: 1 }.is_ai());
621 let mut g = ActionsFile::default();
622 g.put(&a);
623 assert!(g.to_text().contains(r#"{"type":"autoTag"}"#));
624 }
625
626 #[test]
627 fn outputs_apply_in_step_order_with_the_rule_steps() {
628 let d = tempfile::tempdir().unwrap();
629 let mut lib = library(d.path(), &[&["old"], &["kept"], &[]]);
630 let steps = [Step::ClearTags, Step::AutoTag, Step::Describe { replace: true }, Step::Rating { rating: 2 }];
631 let outputs = HashMap::from([
632 ((1, "A1".to_string()), StepOutput::Tags(s(&["new", " new ", ""]))),
633 ((2, "A1".to_string()), StepOutput::Description("A cat.".into())),
634 ((1, "A3".to_string()), StepOutput::Tags(s(&["other"]))),
635 ]);
636 let r = lib.run_steps_with(&s(&["A1", "A3"]), &steps, &outputs).unwrap();
637 assert_eq!(r.changed, 2);
638 let a1 = lib.item("A1").unwrap();
639 assert_eq!(a1.tags, s(&["new"]), "added after the clear, once");
640 assert_eq!(a1.annotation, "A cat.");
641 assert_eq!(star(&lib, "A1"), 2);
642 let a3 = lib.item("A3").unwrap();
643 assert_eq!(a3.tags, s(&["other"]));
644 assert_eq!(a3.annotation, "");
645 assert_eq!(lib.item("A2").unwrap().tags, s(&["kept"]), "not in the run");
646 let r = lib.run_steps(&s(&["A2"]), &[Step::AutoTag, Step::Describe { replace: true }]).unwrap();
648 assert_eq!(r.changed, 0);
649 }
650
651 #[test]
652 fn outputs_undo_as_one_step_and_skip_the_trash() {
653 let d = tempfile::tempdir().unwrap();
654 let mut lib = library(d.path(), &[&["a"], &[]]);
655 let mut gone = lib.item("A2").unwrap().clone();
656 gone.is_deleted = true;
657 lib.update_item(gone).unwrap();
658 let outputs = HashMap::from([
659 ((0, "A1".to_string()), StepOutput::Tags(s(&["n"]))),
660 ((1, "A1".to_string()), StepOutput::Description("d".into())),
661 ((0, "A2".to_string()), StepOutput::Tags(s(&["n"]))),
662 ]);
663 lib.begin_transaction("Run");
664 let r = lib.run_steps_with(&s(&["A1", "A2"]), &[Step::AutoTag, Step::Describe { replace: true }], &outputs).unwrap();
665 let tx = lib.end_transaction().unwrap();
666 assert_eq!(r.changed, 1);
667 assert!(lib.item("A2").unwrap().tags.is_empty(), "a trashed item is left alone");
668 assert_eq!((lib.item("A1").unwrap().tags.len(), lib.item("A1").unwrap().annotation.as_str()), (2, "d"));
669 lib.revert(tx).unwrap();
670 assert_eq!((lib.item("A1").unwrap().tags.clone(), lib.item("A1").unwrap().annotation.as_str()), (s(&["a"]), ""));
671 }
672
673 #[test]
674 fn a_run_undoes_as_one_step() {
675 let d = tempfile::tempdir().unwrap();
676 let mut lib = library(d.path(), &[&["a"], &["b"]]);
677 let f = folder(&mut lib, "F");
678 let snap = |lib: &Library| lib.items().iter().map(|i| (i.tags.clone(), i.folders.clone(), i.extra.get("star").cloned())).collect::<Vec<_>>();
679 let before = snap(&lib);
680 let tags_before = lib.tags.clone();
681 lib.begin_transaction("Run");
682 lib.run_steps(&s(&["A1", "A2"]), &[Step::AddTags { tags: s(&["n"]) }, Step::AddFolders { folders: vec![f] }, Step::Rating { rating: 2 }, Step::RemoveTags { tags: s(&["a"]) }])
683 .unwrap();
684 let tx = lib.end_transaction().expect("recorded");
685 assert_ne!(snap(&lib), before);
686 lib.revert(tx).unwrap();
687 assert_eq!(snap(&lib), before, "every step of the run comes back at once");
688 assert_eq!(lib.tags, tags_before);
689 }
690}