Skip to main content

falcon_library/
actions.rs

1//! Actions: named, ordered lists of organising steps with an optional
2//! shortcut, run on the selected items. AI steps get their results from
3//! the app, which asks the model; here they only become tags or notes.
4//!
5//! In Eagle 4.0.0 actions are a plugin (`ai-action`) that keeps its list
6//! in `actions.config.json` at the library root. That file belongs to the
7//! plugin: falcon never reads, creates, changes or deletes it. Falcon's
8//! own actions are its implementation of the feature and live in falcon's
9//! app data, one file per library (see [`store_path`]); the app picks the
10//! folder (`<app data>/actions/`).
11//!
12//! The file is a JSON array of objects, each with `id`, `name`,
13//! `shortcut` (an accelerator such as `"Command + Shift + 1"`, empty for
14//! none) and `steps`, one object per step tagged by `type`. Falcon keeps
15//! each entry as the JSON it read, so fields it doesn't know and key
16//! order survive a save byte for byte. It is written only after an edit,
17//! atomically (temp file and rename), compact, or indented with two
18//! spaces when the file it replaces was.
19
20use 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
30/// The key naming a library's actions file: Eagle's `metadata.json`
31/// carries no library id, so it is the 64-bit FNV-1a hash of the
32/// library's canonical path (symlinks resolved, no trailing slash) as 16
33/// lowercase hex digits. A path that can't be resolved (the library is
34/// gone) is hashed as given. Moving or renaming a library therefore
35/// starts it with no actions.
36pub 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
48/// The file holding the actions of the library at `root`, inside `dir`
49/// (falcon's `<app data>/actions/`): `<dir>/<library_key>.json`.
50pub fn store_path(dir: &Path, root: &Path) -> PathBuf {
51    dir.join(format!("{}.json", library_key(root)))
52}
53
54/// One organising step. The `type` strings follow the rule names Eagle's
55/// translations still carry from its earlier built-in Actions.
56#[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    /// Takes every tag off.
62    #[serde(rename = "clearTag")]
63    ClearTags,
64    /// Folder ids.
65    AddFolders { folders: Vec<String> },
66    RemoveFolders { folders: Vec<String> },
67    /// Takes the items out of every folder.
68    #[serde(rename = "clearFolder")]
69    ClearFolders,
70    /// 0 (none) to 5 stars.
71    Rating { rating: u8 },
72    /// Tags suggested by the default model. Falcon's own: the app asks the
73    /// model and hands the result to [`Library::run_steps_with`].
74    AutoTag,
75    /// A description from the default model, written to the item's notes.
76    /// An item that has notes keeps them unless `replace` is set.
77    Describe {
78        #[serde(default)]
79        replace: bool,
80    },
81}
82
83impl Step {
84    /// Every kind, with empty parameters, in the order the editor offers them.
85    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    /// Whether the step asks a model, so the run needs AI Models.
100    pub fn is_ai(&self) -> bool {
101        matches!(self, Step::AutoTag | Step::Describe { .. })
102    }
103
104    /// Whether two steps are of the same kind, parameters aside.
105    pub fn same_kind(&self, other: &Step) -> bool {
106        std::mem::discriminant(self) == std::mem::discriminant(other)
107    }
108}
109
110/// An action as falcon edits it.
111#[derive(Clone, Debug, PartialEq, Eq, Default)]
112pub struct ActionDef {
113    pub id: String,
114    pub name: String,
115    /// The accelerator text, as Eagle stores it; `None` when unset.
116    pub shortcut: Option<String>,
117    pub steps: Vec<Step>,
118    /// Falcon understood every step. An entry with fields or steps falcon
119    /// doesn't know (written by a newer falcon, or by hand) lists and keeps
120    /// its shortcut but cannot run or be edited here.
121    pub runnable: bool,
122}
123
124/// A store file as read: each entry's JSON, untouched.
125#[derive(Clone, Debug, PartialEq, Default)]
126pub struct ActionsFile {
127    entries: Vec<Map<String, Value>>,
128    /// The file it was read from was indented.
129    pretty: bool,
130}
131
132impl ActionsFile {
133    /// Parses the file's text. Entries that are not objects are refused,
134    /// as is anything but an array.
135    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    /// The text falcon writes.
142    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    /// Reads a store file; a missing file is an empty list.
151    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    /// Writes a store file atomically: a temp file beside it, then a
160    /// rename, creating its folder if needed. On a failure the temp file
161    /// is removed and the old file, if any, stays as it was.
162    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    /// The actions, in file order.
181    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    /// Adds an action at the end, or replaces the entry with its id. Only
190    /// `id`, `name`, `shortcut` and `steps` are written; the entry's other
191    /// fields and its key order stay.
192    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    /// Changes only an action's shortcut (also for an entry falcon can't run).
209    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    /// Removes an action.
216    pub fn remove(&mut self, id: &str) {
217        if let Some(i) = self.index(id) {
218            self.entries.remove(i);
219        }
220    }
221
222    /// A copy of the action right after it, with a new id and `name`.
223    /// Returns the new id.
224    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        // A shortcut belongs to one action.
231        copy.insert("shortcut".into(), "".into());
232        self.entries.insert(i + 1, copy);
233        Some(new_id)
234    }
235
236    /// Moves an action to `to` (an index in the list after its removal).
237    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    // An entry with fields only another program reads runs elsewhere.
253    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/// What a model produced for one item in one AI step. It can only become
258/// the item's tags or notes.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub enum StepOutput {
261    Tags(Vec<String>),
262    Description(String),
263}
264
265/// What running an action did.
266#[derive(Clone, Debug, Default, PartialEq, Eq)]
267pub struct RunReport {
268    /// Items at least one step changed.
269    pub changed: usize,
270    /// Folder ids named by steps that no longer exist (skipped).
271    pub missing_folders: Vec<String>,
272}
273
274impl Library {
275    /// Runs `steps` in order on the items `ids`: each step on every item
276    /// before the next step. Items in the Trash are left alone. A step
277    /// that changes nothing is no error; a folder that no longer exists
278    /// is skipped and reported; a write error stops the run there. AI steps
279    /// do nothing here; [`Library::run_steps_with`] applies their results.
280    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    /// Runs `steps` in order on `ids` as [`Library::run_steps`] does, with
374    /// each AI step taking its results from `outputs`, keyed by the step's
375    /// index and the item's id. An item with no result for an AI step is
376    /// left as it is by that step.
377    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    /// The shape documented in `docs/src/actions.md`.
422    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        // Steps falcon can't read are no reason to drop the entry.
462        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        // The rename fails: a folder sits where the file goes.
512        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        // The temp file can't be made: the old file stays whole.
519        #[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        // Written to disk.
580        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        // run_steps alone ignores the AI steps.
647        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}