ajour_core/repository/
mod.rs

1use crate::config::Flavor;
2use crate::error::{DownloadError, RepositoryError};
3
4use chrono::{DateTime, Utc};
5use isahc::http::uri::Uri;
6use serde::{Deserialize, Serialize};
7
8use std::cmp::Ordering;
9use std::collections::HashMap;
10use std::fmt::Display;
11use std::str::FromStr;
12
13mod backend;
14use backend::Backend;
15
16pub use backend::{curse, git, townlongyak, tukui, wowi};
17use backend::{Curse, Github, Gitlab, TownlongYak, Tukui, WowI};
18
19#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq, Serialize, Deserialize)]
20pub enum RepositoryKind {
21    Curse,
22    Tukui,
23    WowI,
24    TownlongYak,
25    Git(GitKind),
26}
27
28impl std::fmt::Display for RepositoryKind {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(
31            f,
32            "{}",
33            match self {
34                RepositoryKind::WowI => "WoWInterface",
35                RepositoryKind::Tukui => "Tukui",
36                RepositoryKind::Curse => "CurseForge",
37                RepositoryKind::TownlongYak => "TownlongYak",
38                RepositoryKind::Git(git) => match git {
39                    GitKind::Github => "GitHub",
40                    GitKind::Gitlab => "GitLab",
41                },
42            }
43        )
44    }
45}
46
47impl RepositoryKind {
48    pub(crate) fn is_git(self) -> bool {
49        matches!(self, RepositoryKind::Git(_))
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq, Serialize, Deserialize)]
54pub enum GitKind {
55    Github,
56    Gitlab,
57}
58
59#[derive(Clone)]
60pub struct RepositoryPackage {
61    backend: Box<dyn Backend>,
62    pub id: String,
63    pub kind: RepositoryKind,
64    pub metadata: RepositoryMetadata,
65}
66
67impl std::fmt::Debug for RepositoryPackage {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(
70            f,
71            "RepositoryPackage {{ kind: {:?}, id: {}, metadata: {:?} }}",
72            self.kind, self.id, self.metadata
73        )
74    }
75}
76
77impl RepositoryPackage {
78    pub fn from_source_url(flavor: Flavor, url: Uri) -> Result<Self, RepositoryError> {
79        let host = url.host().ok_or(RepositoryError::GitMissingHost {
80            url: url.to_string(),
81        })?;
82
83        let (backend, kind): (Box<dyn Backend>, _) = match host {
84            "github.com" => (
85                Box::new(Github {
86                    url: url.clone(),
87                    flavor,
88                }),
89                RepositoryKind::Git(GitKind::Github),
90            ),
91            "gitlab.com" => (
92                Box::new(Gitlab {
93                    url: url.clone(),
94                    flavor,
95                }),
96                RepositoryKind::Git(GitKind::Gitlab),
97            ),
98            _ => {
99                return Err(RepositoryError::GitInvalidHost {
100                    host: host.to_string(),
101                })
102            }
103        };
104
105        Ok(RepositoryPackage {
106            backend,
107            id: url.to_string(),
108            kind,
109            metadata: Default::default(),
110        })
111    }
112
113    pub fn from_repo_id(
114        flavor: Flavor,
115        kind: RepositoryKind,
116        id: String,
117    ) -> Result<Self, RepositoryError> {
118        let backend: Box<dyn Backend> = match kind {
119            RepositoryKind::Curse => Box::new(Curse {
120                id: id.clone(),
121                flavor,
122            }),
123            RepositoryKind::WowI => Box::new(WowI {
124                id: id.clone(),
125                flavor,
126            }),
127            RepositoryKind::Tukui => Box::new(Tukui {
128                id: id.clone(),
129                flavor,
130            }),
131            RepositoryKind::TownlongYak => Box::new(TownlongYak {
132                id: id.clone(),
133                flavor,
134            }),
135            RepositoryKind::Git(_) => return Err(RepositoryError::GitWrongConstructor),
136        };
137
138        Ok(RepositoryPackage {
139            backend,
140            id,
141            kind,
142            metadata: Default::default(),
143        })
144    }
145
146    pub(crate) fn with_metadata(mut self, metadata: RepositoryMetadata) -> Self {
147        self.metadata = metadata;
148
149        self
150    }
151
152    pub async fn resolve_metadata(&mut self) -> Result<(), RepositoryError> {
153        let metadata = self.backend.get_metadata().await?;
154
155        self.metadata = metadata;
156
157        Ok(())
158    }
159
160    /// Get changelog from the repository
161    ///
162    /// `channel` is only used for the Curse & GitHub repository since
163    /// we can get unique changelogs for each version
164    pub(crate) async fn get_changelog(
165        &self,
166        release_channel: ReleaseChannel,
167        default_release_channel: GlobalReleaseChannel,
168    ) -> Result<Option<String>, RepositoryError> {
169        let release_channel = if release_channel == ReleaseChannel::Default {
170            default_release_channel.convert_to_release_channel()
171        } else {
172            release_channel
173        };
174
175        let file_id = if self.kind == RepositoryKind::Curse {
176            self.metadata
177                .remote_packages
178                .get(&release_channel)
179                .and_then(|p| p.file_id)
180        } else {
181            None
182        };
183
184        let tag_name = if self.kind.is_git() {
185            let remote_package = self.metadata.remote_packages.get(&release_channel).ok_or(
186                RepositoryError::MissingPackageChannel {
187                    channel: release_channel,
188                },
189            )?;
190
191            Some(remote_package.version.clone())
192        } else {
193            None
194        };
195
196        self.backend.get_changelog(file_id, tag_name).await
197    }
198}
199
200/// Metadata from one of the repository APIs
201#[derive(Default, Debug, Clone)]
202pub struct RepositoryMetadata {
203    // If these fields are not set, we will try to get the value
204    // from the primary `AddonFolder` of the `Addon`
205    pub(crate) version: Option<String>,
206    pub(crate) title: Option<String>,
207    pub(crate) author: Option<String>,
208    pub(crate) notes: Option<String>,
209
210    // These fields are only available from the repo API
211    pub(crate) website_url: Option<String>,
212    pub(crate) game_version: Option<String>,
213    pub(crate) file_id: Option<i64>,
214
215    // todo (casperstorm): better description here.
216    // This is constructed, and is different for each repo.
217    pub(crate) changelog_url: Option<String>,
218
219    /// Remote packages available from the Repository
220    pub(crate) remote_packages: HashMap<ReleaseChannel, RemotePackage>,
221}
222
223impl RepositoryMetadata {
224    pub(crate) fn empty() -> Self {
225        Default::default()
226    }
227
228    pub(crate) fn modules(&self) -> Vec<String> {
229        let mut entries: Vec<_> = self.remote_packages.iter().collect();
230        entries.sort_by_key(|(key, _)| *key);
231
232        if let Some((_, package)) = entries.get(0) {
233            return package.modules.clone();
234        }
235
236        vec![]
237    }
238}
239
240#[derive(Debug, Clone, Eq, PartialEq)]
241pub struct RemotePackage {
242    pub version: String,
243    pub download_url: String,
244    pub file_id: Option<i64>,
245    pub date_time: Option<DateTime<Utc>>,
246    pub modules: Vec<String>,
247}
248
249impl PartialOrd for RemotePackage {
250    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
251        Some(self.version.cmp(&other.version))
252    }
253}
254
255impl Ord for RemotePackage {
256    fn cmp(&self, other: &Self) -> Ordering {
257        self.version.cmp(&other.version)
258    }
259}
260
261/// This is the global channel used.
262/// If an addon has chosen `Default` as `ReleaseChannel`, we will `GlobalReleaseChannel`
263/// instead, which is saved in the config.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash, PartialOrd, Ord)]
265pub enum GlobalReleaseChannel {
266    Stable,
267    Beta,
268    Alpha,
269}
270
271impl GlobalReleaseChannel {
272    pub const ALL: [GlobalReleaseChannel; 3] = [
273        GlobalReleaseChannel::Stable,
274        GlobalReleaseChannel::Beta,
275        GlobalReleaseChannel::Alpha,
276    ];
277
278    pub fn convert_to_release_channel(&self) -> ReleaseChannel {
279        match self {
280            GlobalReleaseChannel::Stable => ReleaseChannel::Stable,
281            GlobalReleaseChannel::Beta => ReleaseChannel::Beta,
282            GlobalReleaseChannel::Alpha => ReleaseChannel::Alpha,
283        }
284    }
285}
286
287impl Default for GlobalReleaseChannel {
288    fn default() -> GlobalReleaseChannel {
289        GlobalReleaseChannel::Stable
290    }
291}
292
293impl std::fmt::Display for GlobalReleaseChannel {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        write!(
296            f,
297            "{}",
298            match self {
299                GlobalReleaseChannel::Stable => "Stable",
300                GlobalReleaseChannel::Beta => "Beta",
301                GlobalReleaseChannel::Alpha => "Alpha",
302            }
303        )
304    }
305}
306
307/// This is the channel used on an addon level.
308/// If `Default` is chosen, we will use the value from `GlobalReleaseChannel` which
309/// is saved in the config.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash, PartialOrd, Ord)]
311pub enum ReleaseChannel {
312    Default,
313    Stable,
314    Beta,
315    Alpha,
316}
317
318impl ReleaseChannel {
319    pub const ALL: [ReleaseChannel; 4] = [
320        ReleaseChannel::Default,
321        ReleaseChannel::Stable,
322        ReleaseChannel::Beta,
323        ReleaseChannel::Alpha,
324    ];
325}
326
327impl Default for ReleaseChannel {
328    fn default() -> ReleaseChannel {
329        ReleaseChannel::Default
330    }
331}
332
333impl std::fmt::Display for ReleaseChannel {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        write!(
336            f,
337            "{}",
338            match self {
339                ReleaseChannel::Default => "Default",
340                ReleaseChannel::Stable => "Stable",
341                ReleaseChannel::Beta => "Beta",
342                ReleaseChannel::Alpha => "Alpha",
343            }
344        )
345    }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Hash, PartialOrd, Ord)]
349pub enum CompressionFormat {
350    Zip,
351    Zstd,
352}
353
354impl CompressionFormat {
355    pub const ALL: [CompressionFormat; 2] = [CompressionFormat::Zip, CompressionFormat::Zstd];
356
357    pub(crate) const fn file_ext(&self) -> &'static str {
358        match self {
359            CompressionFormat::Zip => "zip",
360            CompressionFormat::Zstd => "tar.zst",
361        }
362    }
363}
364
365impl Default for CompressionFormat {
366    fn default() -> CompressionFormat {
367        CompressionFormat::Zip
368    }
369}
370
371impl Display for CompressionFormat {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        match self {
374            CompressionFormat::Zip => f.write_str("Zip"),
375            CompressionFormat::Zstd => f.write_str("Zstd"),
376        }
377    }
378}
379
380impl FromStr for CompressionFormat {
381    type Err = &'static str;
382
383    fn from_str(s: &str) -> Result<Self, Self::Err> {
384        match s {
385            "zip" | "Zip" => Ok(CompressionFormat::Zip),
386            "zstd" | "Zstd" => Ok(CompressionFormat::Zstd),
387            _ => Err("valid values are: zip, zstd"),
388        }
389    }
390}
391
392#[derive(Default, Debug, Clone)]
393/// Struct which stores identifiers for the different repositories.
394pub struct RepositoryIdentifiers {
395    pub wowi: Option<String>,
396    pub tukui: Option<String>,
397    pub curse: Option<i32>,
398    pub git: Option<String>,
399}
400
401#[derive(Debug, Clone)]
402pub struct Changelog {
403    pub text: Option<String>,
404}
405
406pub async fn batch_refresh_repository_packages(
407    flavor: Flavor,
408    repos: &[RepositoryPackage],
409) -> Result<Vec<RepositoryPackage>, DownloadError> {
410    let curse_ids = repos
411        .iter()
412        .filter(|r| r.kind == RepositoryKind::Curse)
413        .map(|r| r.id.parse::<i32>().ok())
414        .flatten()
415        .collect::<Vec<_>>();
416    let tukui_ids = repos
417        .iter()
418        .filter(|r| r.kind == RepositoryKind::Tukui)
419        .map(|r| r.id.clone())
420        .collect::<Vec<_>>();
421    let wowi_ids = repos
422        .iter()
423        .filter(|r| r.kind == RepositoryKind::WowI)
424        .map(|r| r.id.clone())
425        .collect::<Vec<_>>();
426    let townlong_ids = repos
427        .iter()
428        .filter(|r| r.kind == RepositoryKind::TownlongYak)
429        .map(|r| r.id.clone())
430        .collect::<Vec<_>>();
431    let git_urls = repos
432        .iter()
433        .filter(|r| matches!(r.kind, RepositoryKind::Git(_)))
434        .map(|r| r.id.clone())
435        .collect::<Vec<_>>();
436
437    // Get all curse repo packages
438    let curse_repo_packages = curse::batch_fetch_repo_packages(flavor, &curse_ids, None).await?;
439
440    // Get all tukui repo packages
441    let tukui_repo_packages = tukui::batch_fetch_repo_packages(flavor, &tukui_ids).await?;
442
443    // Get all wowi repo packages
444    let wowi_repo_packages = wowi::batch_fetch_repo_packages(flavor, &wowi_ids).await?;
445
446    // Get all townlong repo packages
447    let townlong_repo_packages =
448        townlongyak::batch_fetch_repo_packages(flavor, &townlong_ids).await?;
449
450    // Get all git repo packages
451    let git_repo_packages = git::batch_fetch_repo_packages(flavor, &git_urls).await?;
452
453    Ok([
454        &curse_repo_packages[..],
455        &tukui_repo_packages[..],
456        &wowi_repo_packages[..],
457        &townlong_repo_packages[..],
458        &git_repo_packages[..],
459    ]
460    .concat())
461}