Skip to main content

smugmug_cli/
sort.rs

1//! `smugmug-cli sort`: how folders list their contents and how albums list
2//! their photos, for one folder or album or, with `--recursive`, everything
3//! under it.
4//!
5//! These are different settings with different values, so they're given
6//! separately (`--folders-by`, `--albums-by`) and a run only touches the kind
7//! you named: `--recursive --folders-by name` never changes an album. A
8//! node that already has the setting is left alone, so a run can be repeated
9//! (SmugMug's default for a new folder is by date modified, newest first, so
10//! folders made by older versions of this tool need fixing; later runs only
11//! find the ones made since).
12
13use anyhow::{Result, bail};
14use clap::ValueEnum;
15use futures_util::stream::{self, StreamExt};
16
17use crate::api::SmugMugClient;
18use crate::api::albums::{AlbumSettingsUpdate, ChildNode};
19
20/// How a folder lists its contents.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
22pub enum FolderSort {
23    Name,
24    DateAdded,
25    DateModified,
26    /// The order set by hand in SmugMug's organizer
27    Manual,
28}
29
30impl FolderSort {
31    fn api_name(self) -> &'static str {
32        match self {
33            FolderSort::Name => "Name",
34            FolderSort::DateAdded => "DateAdded",
35            FolderSort::DateModified => "DateModified",
36            FolderSort::Manual => "SortIndex",
37        }
38    }
39}
40
41/// How an album lists its photos.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
43pub enum AlbumSort {
44    /// The order set by hand in SmugMug's organizer
45    Manual,
46    Caption,
47    Filename,
48    DateUploaded,
49    DateModified,
50    DateTaken,
51}
52
53impl AlbumSort {
54    fn api_name(self) -> &'static str {
55        match self {
56            AlbumSort::Manual => "Position",
57            AlbumSort::Caption => "Caption",
58            AlbumSort::Filename => "Filename",
59            AlbumSort::DateUploaded => "Date Uploaded",
60            AlbumSort::DateModified => "Date Modified",
61            AlbumSort::DateTaken => "Date Taken",
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
67pub enum Direction {
68    #[value(alias = "ascending")]
69    Asc,
70    #[value(alias = "descending")]
71    Desc,
72}
73
74impl Direction {
75    fn api_name(self) -> &'static str {
76        match self {
77            Direction::Asc => "Ascending",
78            Direction::Desc => "Descending",
79        }
80    }
81}
82
83pub struct SortOptions {
84    /// Folder or album, as a path of names from the top of the account
85    /// ("Backup", "Backup/2014/07", "Backup/2014/07/2014-07-12").
86    pub path: String,
87    pub folders: Option<FolderSort>,
88    pub albums: Option<AlbumSort>,
89    pub direction: Direction,
90    /// Everything under `path` too.
91    pub recursive: bool,
92    /// Report what would change without changing it.
93    pub dry_run: bool,
94}
95
96/// What a run did (or, on a dry run, would do).
97#[derive(Debug, Default)]
98pub struct SortSummary {
99    pub folders_changed: usize,
100    pub folders_already: usize,
101    pub albums_changed: usize,
102    pub albums_already: usize,
103    /// Paths of the first few folders and albums changed.
104    pub examples: Vec<String>,
105    pub failed: Vec<String>,
106}
107
108const EXAMPLES: usize = 10;
109
110/// The SmugMug calls sorting needs.
111// Only implemented and used inside this crate with concrete types, so the
112// Send-bound caveat of async fns in public traits doesn't matter here.
113#[allow(async_fn_in_trait)]
114pub trait SortBackend {
115    /// The account's root folder node.
116    async fn root(&self) -> Result<String>;
117    async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>>;
118    /// An album's photo sort as SmugMug names it: (method, direction).
119    async fn album_sort(&self, album_key: &str) -> Result<(String, String)>;
120    async fn set_folder_sort(&self, node_uri: &str, method: &str, direction: &str) -> Result<()>;
121    async fn set_album_sort(&self, album_key: &str, method: &str, direction: &str) -> Result<()>;
122}
123
124impl SortBackend for SmugMugClient {
125    async fn root(&self) -> Result<String> {
126        Ok(self.auth_user().await?.root_node_uri)
127    }
128    async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
129        self.list_children(node_uri).await
130    }
131    async fn album_sort(&self, album_key: &str) -> Result<(String, String)> {
132        self.get_album_sort(album_key).await
133    }
134    async fn set_folder_sort(&self, node_uri: &str, method: &str, direction: &str) -> Result<()> {
135        SmugMugClient::set_folder_sort(self, node_uri, method, direction).await
136    }
137    async fn set_album_sort(&self, album_key: &str, method: &str, direction: &str) -> Result<()> {
138        self.update_album_settings(
139            album_key,
140            AlbumSettingsUpdate {
141                privacy: None,
142                description: None,
143                keywords: None,
144                sort_method: Some(method.to_string()),
145                sort_direction: Some(direction.to_string()),
146            },
147        )
148        .await
149    }
150}
151
152/// Something to set the sort order of.
153enum Item {
154    Folder {
155        path: String,
156        uri: String,
157        current: (String, String),
158    },
159    Album {
160        path: String,
161        key: String,
162    },
163}
164
165/// The folder or album at `path`.
166async fn find<B: SortBackend>(backend: &B, path: &str) -> Result<(ChildNode, String)> {
167    let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
168    if parts.is_empty() {
169        bail!("Give the folder or album to sort, e.g. Backup or Backup/2014/07");
170    }
171    let mut uri = backend.root().await?;
172    let mut shown = String::new();
173    for (i, part) in parts.iter().enumerate() {
174        let last = i == parts.len() - 1;
175        let matches: Vec<ChildNode> = backend
176            .children(&uri)
177            .await?
178            .into_iter()
179            .filter(|c| {
180                c.name == *part && (c.node_type == "Folder" || (last && c.node_type == "Album"))
181            })
182            .collect();
183        if !shown.is_empty() {
184            shown.push('/');
185        }
186        shown.push_str(part);
187        match matches.len() {
188            1 => {
189                let node = matches.into_iter().next().unwrap();
190                if last {
191                    return Ok((node, shown));
192                }
193                uri = node.uri;
194            }
195            0 => bail!(
196                "No folder{} named '{}' at '{}'",
197                if last { " or album" } else { "" },
198                part,
199                shown
200            ),
201            n => bail!(
202                "{} folders and albums are named '{}' at '{}'; rename one first",
203                n,
204                part,
205                shown
206            ),
207        }
208    }
209    unreachable!("the loop returns on its last part")
210}
211
212fn folder_item(node: &ChildNode, path: String) -> Item {
213    Item::Folder {
214        path,
215        uri: node.uri.clone(),
216        current: (
217            node.sort_method.clone().unwrap_or_default(),
218            node.sort_direction.clone().unwrap_or_default(),
219        ),
220    }
221}
222
223fn album_item(node: &ChildNode, path: String) -> Option<Item> {
224    node.album().map(|album| Item::Album {
225        path,
226        key: album.album_key,
227    })
228}
229
230/// The folders and albums to change, per `options`.
231async fn collect<B: SortBackend>(
232    backend: &B,
233    options: &SortOptions,
234) -> Result<(Vec<Item>, Vec<String>)> {
235    let (target, shown) = find(backend, &options.path).await?;
236    let is_folder = target.node_type == "Folder";
237
238    if is_folder && !options.recursive && options.folders.is_none() {
239        bail!(
240            "'{}' is a folder: give --folders-by to set how it lists its contents (or --recursive to reach the albums inside)",
241            shown
242        );
243    }
244    if !is_folder && options.albums.is_none() {
245        bail!(
246            "'{}' is an album: give --albums-by to set how it lists its photos",
247            shown
248        );
249    }
250
251    let mut items = Vec::new();
252    let mut failed = Vec::new();
253    if is_folder {
254        if options.folders.is_some() {
255            items.push(folder_item(&target, shown.clone()));
256        }
257        if options.recursive {
258            let mut pending = vec![(target.uri.clone(), shown)];
259            while let Some((uri, path)) = pending.pop() {
260                // A folder that can't be listed is reported, and the rest
261                // of the tree is still done.
262                let children = match backend.children(&uri).await {
263                    Ok(children) => children,
264                    Err(e) => {
265                        failed.push(format!("{}: couldn't list what's in it: {:#}", path, e));
266                        continue;
267                    }
268                };
269                for child in children {
270                    let child_path = format!("{}/{}", path, child.name);
271                    match child.node_type.as_str() {
272                        "Folder" => {
273                            if options.folders.is_some() {
274                                items.push(folder_item(&child, child_path.clone()));
275                            }
276                            pending.push((child.uri.clone(), child_path));
277                        }
278                        "Album" if options.albums.is_some() => {
279                            items.extend(album_item(&child, child_path));
280                        }
281                        _ => {}
282                    }
283                }
284            }
285        }
286    } else if let Some(item) = album_item(&target, shown) {
287        items.push(item);
288    }
289    Ok((items, failed))
290}
291
292enum Outcome {
293    Changed(String),
294    Already,
295    Failed(String),
296}
297
298/// Albums and folders handled at once.
299const CONCURRENCY: usize = 8;
300
301async fn apply<B: SortBackend>(backend: &B, item: Item, options: &SortOptions) -> (bool, Outcome) {
302    let direction = options.direction.api_name();
303    match item {
304        Item::Folder { path, uri, current } => {
305            let method = options
306                .folders
307                .expect("folders are only collected with --folders-by")
308                .api_name();
309            if current.0 == method && current.1 == direction {
310                return (true, Outcome::Already);
311            }
312            if options.dry_run {
313                return (true, Outcome::Changed(path));
314            }
315            match backend.set_folder_sort(&uri, method, direction).await {
316                Ok(()) => (true, Outcome::Changed(path)),
317                Err(e) => (true, Outcome::Failed(format!("{}: {:#}", path, e))),
318            }
319        }
320        Item::Album { path, key } => {
321            let method = options
322                .albums
323                .expect("albums are only collected with --albums-by")
324                .api_name();
325            let current = match backend.album_sort(&key).await {
326                Ok(current) => current,
327                Err(e) => return (false, Outcome::Failed(format!("{}: {:#}", path, e))),
328            };
329            if current.0 == method && current.1 == direction {
330                return (false, Outcome::Already);
331            }
332            if options.dry_run {
333                return (false, Outcome::Changed(path));
334            }
335            match backend.set_album_sort(&key, method, direction).await {
336                Ok(()) => (false, Outcome::Changed(path)),
337                Err(e) => (false, Outcome::Failed(format!("{}: {:#}", path, e))),
338            }
339        }
340    }
341}
342
343/// Set the sort order of `options.path` (and, if recursive, everything
344/// under it). Failures on single items are collected in the summary and
345/// don't stop the rest.
346pub async fn run<B: SortBackend>(backend: &B, options: &SortOptions) -> Result<SortSummary> {
347    if options.folders.is_none() && options.albums.is_none() {
348        bail!("Say what to sort: --folders-by and/or --albums-by");
349    }
350    let (items, listing_failures) = collect(backend, options).await?;
351    let total = items.len();
352    let mut summary = SortSummary {
353        failed: listing_failures,
354        ..SortSummary::default()
355    };
356    let mut done = 0;
357
358    let mut results = stream::iter(items)
359        .map(|item| apply(backend, item, options))
360        .buffer_unordered(CONCURRENCY);
361    while let Some((is_folder, outcome)) = results.next().await {
362        done += 1;
363        match outcome {
364            Outcome::Changed(path) => {
365                if is_folder {
366                    summary.folders_changed += 1;
367                } else {
368                    summary.albums_changed += 1;
369                }
370                if summary.examples.len() < EXAMPLES {
371                    summary.examples.push(path);
372                }
373            }
374            Outcome::Already if is_folder => summary.folders_already += 1,
375            Outcome::Already => summary.albums_already += 1,
376            Outcome::Failed(message) => summary.failed.push(message),
377        }
378        if !options.dry_run && done % 100 == 0 && done < total {
379            println!("  {}/{} done...", done, total);
380        }
381    }
382    Ok(summary)
383}
384
385/// Tell the user what a run did, or would do.
386pub fn print_summary(summary: &SortSummary, options: &SortOptions) {
387    let verb = if options.dry_run {
388        "Would change"
389    } else {
390        "Changed"
391    };
392    let mut parts = Vec::new();
393    if options.folders.is_some() {
394        parts.push(format!(
395            "{} {} folder{} ({} already right)",
396            verb,
397            summary.folders_changed,
398            if summary.folders_changed == 1 {
399                ""
400            } else {
401                "s"
402            },
403            summary.folders_already
404        ));
405    }
406    if options.albums.is_some() {
407        parts.push(format!(
408            "{} {} album{} ({} already right)",
409            verb,
410            summary.albums_changed,
411            if summary.albums_changed == 1 { "" } else { "s" },
412            summary.albums_already
413        ));
414    }
415    println!();
416    for part in parts {
417        println!("{}", part);
418    }
419    if options.dry_run && !summary.examples.is_empty() {
420        let shown = summary.examples.len();
421        let total = summary.folders_changed + summary.albums_changed;
422        println!("\nFor example:");
423        for path in &summary.examples {
424            println!("  {}", path);
425        }
426        if total > shown {
427            println!("  ... and {} more", total - shown);
428        }
429    }
430    if !summary.failed.is_empty() {
431        println!(
432            "\n{} couldn't be updated (run again to retry):",
433            summary.failed.len()
434        );
435        for failure in summary.failed.iter().take(20) {
436            println!("  {}", failure);
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::api::albums::{ChildNodeUris, UriRef};
445    use std::collections::HashMap;
446    use std::sync::Mutex;
447
448    /// An in-memory account: nodes by URI, albums' sorts by key.
449    #[derive(Default)]
450    struct Fake {
451        children: Mutex<HashMap<String, Vec<ChildNode>>>,
452        albums: Mutex<HashMap<String, (String, String)>>,
453        /// Node URIs and album keys whose update fails.
454        fail: Mutex<Vec<String>>,
455        /// Folders whose listing fails.
456        fail_listing: Mutex<Vec<String>>,
457        sets: Mutex<Vec<String>>,
458        next: Mutex<u32>,
459    }
460
461    impl Fake {
462        fn new() -> Self {
463            let fake = Fake::default();
464            fake.children
465                .lock()
466                .unwrap()
467                .insert("/node/root".into(), Vec::new());
468            fake
469        }
470
471        fn id(&self) -> u32 {
472            let mut n = self.next.lock().unwrap();
473            *n += 1;
474            *n
475        }
476
477        fn folder(&self, parent: &str, name: &str, sort: (&str, &str)) -> String {
478            let uri = format!("/node/f{}", self.id());
479            self.children
480                .lock()
481                .unwrap()
482                .get_mut(parent)
483                .unwrap()
484                .push(ChildNode {
485                    name: name.into(),
486                    node_type: "Folder".into(),
487                    uri: uri.clone(),
488                    node_id: String::new(),
489                    url_name: String::new(),
490                    web_uri: None,
491                    uris: None,
492                    sort_method: Some(sort.0.into()),
493                    sort_direction: Some(sort.1.into()),
494                });
495            self.children
496                .lock()
497                .unwrap()
498                .insert(uri.clone(), Vec::new());
499            uri
500        }
501
502        fn album(&self, parent: &str, name: &str, sort: (&str, &str)) -> String {
503            let key = format!("A{}", self.id());
504            self.children
505                .lock()
506                .unwrap()
507                .get_mut(parent)
508                .unwrap()
509                .push(ChildNode {
510                    name: name.into(),
511                    node_type: "Album".into(),
512                    uri: format!("/node/{}", key),
513                    node_id: String::new(),
514                    url_name: String::new(),
515                    web_uri: None,
516                    uris: Some(ChildNodeUris {
517                        album: Some(UriRef {
518                            uri: format!("/api/v2/album/{}", key),
519                        }),
520                    }),
521                    // A folder-style sort on an album node means nothing here
522                    sort_method: Some("Name".into()),
523                    sort_direction: Some("Ascending".into()),
524                });
525            self.albums
526                .lock()
527                .unwrap()
528                .insert(key.clone(), (sort.0.into(), sort.1.into()));
529            key
530        }
531
532        fn folder_sort(&self, uri: &str) -> (String, String) {
533            for list in self.children.lock().unwrap().values() {
534                if let Some(c) = list.iter().find(|c| c.uri == uri) {
535                    return (
536                        c.sort_method.clone().unwrap(),
537                        c.sort_direction.clone().unwrap(),
538                    );
539                }
540            }
541            panic!("no node {}", uri);
542        }
543
544        fn album_sort_of(&self, key: &str) -> (String, String) {
545            self.albums.lock().unwrap()[key].clone()
546        }
547
548        fn set_count(&self) -> usize {
549            self.sets.lock().unwrap().len()
550        }
551
552        fn fails_for(&self, id: &str) {
553            self.fail.lock().unwrap().push(id.into());
554        }
555    }
556
557    impl SortBackend for Fake {
558        async fn root(&self) -> Result<String> {
559            Ok("/node/root".into())
560        }
561        async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
562            if self
563                .fail_listing
564                .lock()
565                .unwrap()
566                .iter()
567                .any(|f| f == node_uri)
568            {
569                bail!("503");
570            }
571            Ok(self.children.lock().unwrap()[node_uri].clone())
572        }
573        async fn album_sort(&self, key: &str) -> Result<(String, String)> {
574            Ok(self.album_sort_of(key))
575        }
576        async fn set_folder_sort(&self, uri: &str, method: &str, direction: &str) -> Result<()> {
577            if self.fail.lock().unwrap().iter().any(|f| f == uri) {
578                bail!("503");
579            }
580            self.sets.lock().unwrap().push(uri.into());
581            for list in self.children.lock().unwrap().values_mut() {
582                if let Some(c) = list.iter_mut().find(|c| c.uri == uri) {
583                    c.sort_method = Some(method.into());
584                    c.sort_direction = Some(direction.into());
585                }
586            }
587            Ok(())
588        }
589        async fn set_album_sort(&self, key: &str, method: &str, direction: &str) -> Result<()> {
590            if self.fail.lock().unwrap().iter().any(|f| f == key) {
591                bail!("503");
592            }
593            self.sets.lock().unwrap().push(key.into());
594            self.albums
595                .lock()
596                .unwrap()
597                .insert(key.into(), (method.into(), direction.into()));
598            Ok(())
599        }
600    }
601
602    const DEFAULT: (&str, &str) = ("DateModified", "Descending");
603    const NAME_ASC: (&str, &str) = ("Name", "Ascending");
604
605    fn options(path: &str) -> SortOptions {
606        SortOptions {
607            path: path.into(),
608            folders: None,
609            albums: None,
610            direction: Direction::Asc,
611            recursive: false,
612            dry_run: false,
613        }
614    }
615
616    /// Backup/{2014/{07/{2014-07-12 (album)}, 08}, 2015}, plus an unrelated
617    /// Other/ folder. Returns the fake and the interesting URIs/keys.
618    fn account() -> (Fake, Tree) {
619        let fake = Fake::new();
620        let backup = fake.folder("/node/root", "Backup", NAME_ASC);
621        let y2014 = fake.folder(&backup, "2014", DEFAULT);
622        let m07 = fake.folder(&y2014, "07", DEFAULT);
623        let m08 = fake.folder(&y2014, "08", DEFAULT);
624        let y2015 = fake.folder(&backup, "2015", DEFAULT);
625        let day = fake.album(&m07, "2014-07-12", ("Date Taken", "Ascending"));
626        let other = fake.folder("/node/root", "Other", DEFAULT);
627        (
628            fake,
629            Tree {
630                backup,
631                y2014,
632                m07,
633                m08,
634                y2015,
635                day,
636                other,
637            },
638        )
639    }
640
641    struct Tree {
642        backup: String,
643        y2014: String,
644        m07: String,
645        m08: String,
646        y2015: String,
647        day: String,
648        other: String,
649    }
650
651    #[tokio::test]
652    async fn recursive_folder_sort_changes_folders_below_and_nothing_else() {
653        let (fake, t) = account();
654        let mut o = options("Backup");
655        o.folders = Some(FolderSort::Name);
656        o.recursive = true;
657        let summary = run(&fake, &o).await.unwrap();
658
659        // Backup was already right; the four below were not.
660        assert_eq!((summary.folders_changed, summary.folders_already), (4, 1));
661        for uri in [&t.y2014, &t.m07, &t.m08, &t.y2015] {
662            assert_eq!(fake.folder_sort(uri), ("Name".into(), "Ascending".into()));
663        }
664        // Not under Backup, and albums only change when asked
665        assert_eq!(
666            fake.folder_sort(&t.other),
667            (DEFAULT.0.into(), DEFAULT.1.into())
668        );
669        assert_eq!(
670            fake.album_sort_of(&t.day),
671            ("Date Taken".into(), "Ascending".into())
672        );
673        assert_eq!(summary.albums_changed + summary.albums_already, 0);
674        assert!(summary.failed.is_empty());
675    }
676
677    #[tokio::test]
678    async fn repeating_a_run_changes_nothing() {
679        let (fake, _) = account();
680        let mut o = options("Backup");
681        o.folders = Some(FolderSort::Name);
682        o.recursive = true;
683        run(&fake, &o).await.unwrap();
684        let sets = fake.set_count();
685
686        let again = run(&fake, &o).await.unwrap();
687        assert_eq!((again.folders_changed, again.folders_already), (0, 5));
688        assert_eq!(fake.set_count(), sets);
689    }
690
691    #[tokio::test]
692    async fn without_recursive_only_the_named_folder_changes() {
693        let (fake, t) = account();
694        let mut o = options("Backup/2014");
695        o.folders = Some(FolderSort::DateAdded);
696        o.direction = Direction::Desc;
697        let summary = run(&fake, &o).await.unwrap();
698        assert_eq!(summary.folders_changed, 1);
699        assert_eq!(
700            fake.folder_sort(&t.y2014),
701            ("DateAdded".into(), "Descending".into())
702        );
703        assert_eq!(
704            fake.folder_sort(&t.m07),
705            (DEFAULT.0.into(), DEFAULT.1.into())
706        );
707    }
708
709    #[tokio::test]
710    async fn albums_are_sorted_by_their_own_values() {
711        let (fake, t) = account();
712        let mut o = options("Backup/2014/07/2014-07-12");
713        o.albums = Some(AlbumSort::Filename);
714        let summary = run(&fake, &o).await.unwrap();
715        assert_eq!(summary.albums_changed, 1);
716        assert_eq!(
717            fake.album_sort_of(&t.day),
718            ("Filename".into(), "Ascending".into())
719        );
720
721        // Recursive from above, albums only: no folder changes
722        let mut o = options("Backup");
723        o.albums = Some(AlbumSort::DateTaken);
724        o.recursive = true;
725        let summary = run(&fake, &o).await.unwrap();
726        assert_eq!((summary.albums_changed, summary.folders_changed), (1, 0));
727        assert_eq!(
728            fake.album_sort_of(&t.day),
729            ("Date Taken".into(), "Ascending".into())
730        );
731        assert_eq!(
732            fake.folder_sort(&t.m07),
733            (DEFAULT.0.into(), DEFAULT.1.into())
734        );
735    }
736
737    #[tokio::test]
738    async fn folders_and_albums_can_be_sorted_in_one_run() {
739        let (fake, t) = account();
740        let mut o = options("Backup");
741        o.folders = Some(FolderSort::Name);
742        o.albums = Some(AlbumSort::Filename);
743        o.direction = Direction::Desc;
744        o.recursive = true;
745        let summary = run(&fake, &o).await.unwrap();
746        assert_eq!((summary.folders_changed, summary.albums_changed), (5, 1));
747        assert_eq!(
748            fake.folder_sort(&t.backup),
749            ("Name".into(), "Descending".into())
750        );
751        assert_eq!(
752            fake.album_sort_of(&t.day),
753            ("Filename".into(), "Descending".into())
754        );
755    }
756
757    #[tokio::test]
758    async fn dry_run_reports_without_changing() {
759        let (fake, t) = account();
760        let mut o = options("Backup");
761        o.folders = Some(FolderSort::Name);
762        o.recursive = true;
763        o.dry_run = true;
764        let summary = run(&fake, &o).await.unwrap();
765        assert_eq!(summary.folders_changed, 4);
766        assert_eq!(fake.set_count(), 0);
767        assert_eq!(
768            fake.folder_sort(&t.y2014),
769            (DEFAULT.0.into(), DEFAULT.1.into())
770        );
771        assert!(summary.examples.contains(&"Backup/2014/07".to_string()));
772    }
773
774    #[tokio::test]
775    async fn one_failure_is_reported_and_the_rest_still_change() {
776        let (fake, t) = account();
777        fake.fails_for(&t.m07);
778        let mut o = options("Backup");
779        o.folders = Some(FolderSort::Name);
780        o.recursive = true;
781        let summary = run(&fake, &o).await.unwrap();
782        assert_eq!(summary.folders_changed, 3);
783        assert_eq!(summary.failed.len(), 1);
784        assert!(
785            summary.failed[0].starts_with("Backup/2014/07:"),
786            "{:?}",
787            summary.failed
788        );
789        assert_eq!(
790            fake.folder_sort(&t.y2015),
791            ("Name".into(), "Ascending".into())
792        );
793    }
794
795    /// The error `run` gives for `options` against the sample account.
796    async fn error_for(options: SortOptions) -> String {
797        let (fake, _) = account();
798        run(&fake, &options).await.unwrap_err().to_string()
799    }
800
801    fn folders_by_name(path: &str) -> SortOptions {
802        let mut o = options(path);
803        o.folders = Some(FolderSort::Name);
804        o
805    }
806
807    #[tokio::test]
808    async fn a_folder_that_cant_be_listed_is_reported_and_the_rest_still_done() {
809        let (fake, t) = account();
810        fake.fail_listing.lock().unwrap().push(t.y2014.clone());
811        let mut o = options("Backup");
812        o.folders = Some(FolderSort::Name);
813        o.recursive = true;
814        let summary = run(&fake, &o).await.unwrap();
815
816        // 2014 itself and 2015 changed; what's inside 2014 couldn't be reached
817        assert_eq!(summary.folders_changed, 2);
818        assert_eq!(
819            fake.folder_sort(&t.y2014),
820            ("Name".into(), "Ascending".into())
821        );
822        assert_eq!(
823            fake.folder_sort(&t.y2015),
824            ("Name".into(), "Ascending".into())
825        );
826        assert_eq!(
827            fake.folder_sort(&t.m07),
828            (DEFAULT.0.into(), DEFAULT.1.into())
829        );
830        assert_eq!(summary.failed.len(), 1);
831        assert!(
832            summary.failed[0].starts_with("Backup/2014: couldn't list"),
833            "{:?}",
834            summary.failed
835        );
836    }
837
838    #[tokio::test]
839    async fn asks_for_what_it_needs() {
840        // Nothing to sort by
841        assert!(error_for(options("Backup")).await.contains("--folders-by"));
842
843        // A folder, but only an album sort given and no --recursive
844        let mut o = options("Backup");
845        o.albums = Some(AlbumSort::DateTaken);
846        assert!(error_for(o).await.contains("is a folder"));
847
848        // An album, but only a folder sort given
849        let err = error_for(folders_by_name("Backup/2014/07/2014-07-12")).await;
850        assert!(err.contains("is an album"), "{}", err);
851
852        // No path, a missing one, and an album in the middle of a path
853        let err = error_for(folders_by_name("")).await;
854        assert!(err.contains("Give the folder or album"), "{}", err);
855        let err = error_for(folders_by_name("Backup/1999")).await;
856        assert!(err.contains("No folder or album named '1999'"), "{}", err);
857        let err = error_for(folders_by_name("Backup/2014/07/2014-07-12/x")).await;
858        assert!(err.contains("No folder named '2014-07-12'"), "{}", err);
859    }
860
861    #[tokio::test]
862    async fn two_nodes_with_one_name_are_not_guessed_between() {
863        let (fake, t) = account();
864        fake.folder(&t.backup, "2014", DEFAULT);
865        let mut o = options("Backup/2014");
866        o.folders = Some(FolderSort::Name);
867        let err = run(&fake, &o).await.unwrap_err().to_string();
868        assert!(
869            err.contains("2 folders and albums are named '2014'"),
870            "{}",
871            err
872        );
873    }
874
875    #[test]
876    fn api_names() {
877        assert_eq!(FolderSort::Manual.api_name(), "SortIndex");
878        assert_eq!(FolderSort::DateModified.api_name(), "DateModified");
879        assert_eq!(AlbumSort::Manual.api_name(), "Position");
880        assert_eq!(AlbumSort::DateTaken.api_name(), "Date Taken");
881        assert_eq!(Direction::Desc.api_name(), "Descending");
882    }
883}