Skip to main content

figshare_rs/
workflow.rs

1//! Higher-level workflow helpers built on top of the low-level client.
2
3use std::collections::BTreeSet;
4
5use crate::client::FigshareClient;
6use crate::error::FigshareError;
7use crate::ids::ArticleId;
8use crate::metadata::ArticleMetadata;
9use crate::model::{Article, ArticleFile};
10use crate::upload::{FileReplacePolicy, UploadSource, UploadSpec};
11
12/// Result of a complete publish workflow.
13#[derive(Clone, Debug, PartialEq)]
14pub struct PublishedArticle {
15    /// Private article payload after publication.
16    pub article: Article,
17    /// Published public article payload.
18    pub public_article: Article,
19}
20
21impl FigshareClient {
22    /// Reconciles article files using one of the provided replacement policies.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error if authentication is missing, if an upload conflicts
27    /// with the selected policy, if the request fails, or if Figshare returns a
28    /// non-success response.
29    pub(crate) async fn reconcile_files(
30        &self,
31        article: &Article,
32        policy: FileReplacePolicy,
33        uploads: Vec<UploadSpec>,
34    ) -> Result<Vec<ArticleFile>, FigshareError> {
35        let upload_filenames = validate_upload_filenames(&uploads)?;
36        let existing = self.list_files(article.id).await?;
37
38        match policy {
39            FileReplacePolicy::KeepExistingAndAdd => {
40                for upload in &uploads {
41                    if existing.iter().any(|file| file.name == upload.filename) {
42                        return Err(FigshareError::ConflictingDraftFile {
43                            filename: upload.filename.clone(),
44                        });
45                    }
46                }
47            }
48            FileReplacePolicy::ReplaceAll | FileReplacePolicy::UpsertByFilename => {}
49        }
50
51        let mut uploaded = Vec::new();
52        for upload in uploads {
53            let result = match upload.source {
54                UploadSource::Path(path) => {
55                    self.upload_path_with_filename(article.id, &upload.filename, &path)
56                        .await
57                }
58                UploadSource::Reader {
59                    reader,
60                    content_length,
61                } => {
62                    self.upload_reader(article.id, &upload.filename, reader, content_length)
63                        .await
64                }
65            };
66
67            match result {
68                Ok(file) => uploaded.push(file),
69                Err(error) => {
70                    self.cleanup_uploaded_files(article.id, &uploaded).await;
71                    return Err(error);
72                }
73            }
74        }
75
76        match policy {
77            FileReplacePolicy::ReplaceAll => {
78                for file in &existing {
79                    self.delete_file(article.id, file.id).await?;
80                }
81            }
82            FileReplacePolicy::UpsertByFilename => {
83                for file in existing
84                    .iter()
85                    .filter(|file| upload_filenames.contains(&file.name))
86                {
87                    self.delete_file(article.id, file.id).await?;
88                }
89            }
90            FileReplacePolicy::KeepExistingAndAdd => {}
91        }
92
93        self.list_files(article.id).await
94    }
95
96    /// Creates a new private article, uploads the provided files, and publishes
97    /// the result.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if authentication is missing, if uploading or
102    /// publication fails, or if Figshare returns a non-success response.
103    pub(crate) async fn create_and_publish_article(
104        &self,
105        metadata: &ArticleMetadata,
106        uploads: Vec<UploadSpec>,
107    ) -> Result<PublishedArticle, FigshareError> {
108        let article = self.create_article(metadata).await?;
109        if let Err(error) = self
110            .reconcile_files(&article, FileReplacePolicy::ReplaceAll, uploads)
111            .await
112        {
113            let _ = self.delete_article(article.id).await;
114            return Err(error);
115        }
116
117        let public_article = match self.publish_article(article.id).await {
118            Ok(public_article) => public_article,
119            Err(error) => {
120                let _ = self.delete_article(article.id).await;
121                return Err(error);
122            }
123        };
124        let article = self.wait_for_own_article_public(article.id).await?;
125
126        Ok(PublishedArticle {
127            article,
128            public_article,
129        })
130    }
131
132    /// Updates an existing private article, reconciles its files, and publishes
133    /// a new public version.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if authentication is missing, if the request fails, or
138    /// if Figshare returns a non-success response.
139    pub(crate) async fn publish_existing_article_with_policy(
140        &self,
141        article_id: ArticleId,
142        metadata: &ArticleMetadata,
143        policy: FileReplacePolicy,
144        uploads: Vec<UploadSpec>,
145    ) -> Result<PublishedArticle, FigshareError> {
146        let article = self.update_article(article_id, metadata).await?;
147        self.reconcile_files(&article, policy, uploads).await?;
148        let public_article = self.publish_article(article_id).await?;
149        let article = self.wait_for_own_article_public(article_id).await?;
150
151        Ok(PublishedArticle {
152            article,
153            public_article,
154        })
155    }
156}
157
158impl FigshareClient {
159    async fn cleanup_uploaded_files(&self, article_id: ArticleId, uploaded: &[ArticleFile]) {
160        for file in uploaded {
161            let _ = self.delete_file(article_id, file.id).await;
162        }
163    }
164}
165
166fn validate_upload_filenames(uploads: &[UploadSpec]) -> Result<BTreeSet<String>, FigshareError> {
167    client_uploader_traits::collect_upload_filenames(uploads.iter()).map_err(FigshareError::from)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::validate_upload_filenames;
173    use crate::{upload::UploadSpec, FigshareError};
174
175    #[test]
176    fn duplicate_filenames_are_rejected() {
177        let uploads = vec![
178            UploadSpec::from_reader("artifact.bin", std::io::Cursor::new(vec![1]), 1),
179            UploadSpec::from_reader("artifact.bin", std::io::Cursor::new(vec![2]), 1),
180        ];
181        assert!(matches!(
182            validate_upload_filenames(&uploads),
183            Err(FigshareError::DuplicateUploadFilename { .. })
184        ));
185    }
186
187    #[test]
188    fn empty_filenames_are_rejected() {
189        let uploads = vec![UploadSpec::from_reader(
190            "",
191            std::io::Cursor::new(vec![1]),
192            1,
193        )];
194
195        assert!(matches!(
196            validate_upload_filenames(&uploads),
197            Err(FigshareError::InvalidState(message)) if message == "upload filename cannot be empty"
198        ));
199    }
200}