Skip to main content

pkg/
package.rs

1use std::{
2    borrow::Borrow,
3    collections::{BTreeMap, VecDeque},
4    ffi::{OsStr, OsString},
5    fmt,
6    path::{Path, PathBuf},
7};
8
9use serde_derive::{Deserialize, Serialize};
10
11fn is_zero(n: &u64) -> bool {
12    *n == 0
13}
14
15/// Denotes that the string is a remote key
16pub type RemoteName = String;
17
18#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd)]
19pub struct RemotePackage {
20    pub package: Package,
21    pub remote: RemoteName,
22}
23
24#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, PartialOrd)]
25#[serde(default)]
26pub struct Package {
27    /// package name
28    pub name: PackageName,
29    /// package version
30    #[serde(skip_serializing_if = "String::is_empty")]
31    pub version: String,
32    /// platform target
33    pub target: String,
34    /// hash in pkgar head
35    #[serde(skip_serializing_if = "String::is_empty")]
36    pub blake3: String,
37    /// git commit or tar hash of source package
38    #[serde(skip_serializing_if = "String::is_empty")]
39    pub source_identifier: String,
40    /// git commit of redox repository
41    #[serde(skip_serializing_if = "String::is_empty")]
42    pub commit_identifier: String,
43    /// time when this package published in IS0 8601
44    #[serde(skip_serializing_if = "String::is_empty")]
45    pub time_identifier: String,
46    /// size of files (uncompressed)
47    #[serde(skip_serializing_if = "is_zero")]
48    pub storage_size: u64,
49    /// size of pkgar (maybe compressed)
50    #[serde(skip_serializing_if = "is_zero")]
51    pub network_size: u64,
52    /// dependencies
53    pub depends: Vec<PackageName>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum PackagePrefix {
58    Any,
59    Host,
60    Target,
61}
62
63impl Package {
64    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, PackageError> {
65        let path = path.as_ref();
66        if !path.is_file() {
67            return Err(PackageError::FileMissing(path.to_path_buf()));
68        }
69        let toml = std::fs::read_to_string(path)
70            .map_err(|err| PackageError::FileError(err.raw_os_error(), path.to_path_buf()))?;
71
72        toml::from_str(&toml).map_err(|e| PackageError::Parse(e, Some(path.to_path_buf())))
73    }
74
75    pub fn from_toml(text: &str) -> Result<Self, PackageError> {
76        toml::from_str(text).map_err(|err| PackageError::Parse(err, None))
77    }
78
79    #[allow(dead_code)]
80    pub fn to_toml(&self) -> String {
81        // to_string *should* be safe to unwrap for this struct
82        // use error handling callbacks for this
83        toml::to_string(self).unwrap()
84    }
85}
86
87/// A package name is valid in these formats:
88///
89/// + `recipe` A recipe on mandatory package
90/// + `recipe.pkg` A recipe on "pkg" optional package
91/// + `host:recipe` A recipe with host target on mandatory package
92/// + `host:recipe.pkg` A recipe with host target on "pkg" optional package
93/// + `target:recipe` A recipe only for the target on mandatory package
94#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
95#[serde(into = "String")]
96#[serde(try_from = "String")]
97pub struct PackageName(String);
98
99impl PackageName {
100    pub fn new(name: impl Into<String>) -> Result<Self, PackageError> {
101        let name = name.into();
102        //TODO: are there any other characters that should be invalid?
103        if name.is_empty() {
104            return Err(PackageError::PackageNameInvalid(name));
105        }
106        let mut pkg_separator = 0;
107        let mut has_os_prefix = false;
108        for c in name.chars() {
109            if "/\0".contains(c) {
110                return Err(PackageError::PackageNameInvalid(name));
111            }
112            if c == '.' {
113                pkg_separator += 1;
114                if pkg_separator > 1 {
115                    return Err(PackageError::PackageNameInvalid(name));
116                }
117            }
118            if c == ':' {
119                if has_os_prefix {
120                    return Err(PackageError::PackageNameInvalid(name));
121                }
122                has_os_prefix = true;
123            }
124        }
125        let r = Self(name);
126        if has_os_prefix && !r.is_host() && !r.is_target() {
127            return Err(PackageError::PackageNameInvalid(r.0));
128        }
129        Ok(r)
130    }
131
132    pub fn from_list(vec: Vec<impl Into<String>>) -> Result<Vec<Self>, PackageError> {
133        vec.into_iter().map(|p| Self::new(p)).collect()
134    }
135
136    pub fn as_str(&self) -> &str {
137        self.0.as_str()
138    }
139
140    /// Check if "host:" prefix exists
141    pub fn is_host(&self) -> bool {
142        self.0.starts_with("host:")
143    }
144
145    /// Check if "target:" prefix exists
146    pub fn is_target(&self) -> bool {
147        self.0.starts_with("target:")
148    }
149
150    fn strip<'a>(
151        &'a self,
152        strip_os: bool,
153        strip_pkg: bool,
154    ) -> (Option<&'a str>, &'a str, Option<&'a str>) {
155        let mut s = self.0.as_str();
156        let mut os = None;
157        let mut pkg = None;
158        if strip_os {
159            if self.is_host() {
160                os = Some(&s[..4]);
161                s = &s[5..];
162            } else if self.is_target() {
163                os = Some(&s[..6]);
164                s = &s[7..];
165            }
166        }
167        if strip_pkg {
168            if let Some(pos) = s.find('.') {
169                pkg = Some(&s[pos + 1..]);
170                s = &s[..pos];
171            }
172        }
173        (os, s, pkg)
174    }
175
176    /// Get the name between os prefix and pkg suffix
177    pub fn name(&self) -> &str {
178        self.strip(true, true).1
179    }
180
181    /// Get ".pkg" suffix
182    pub fn suffix(&self) -> Option<&str> {
183        let s = self.without_host();
184        if let Some(pos) = s.find('.') {
185            Some(&s[pos + 1..])
186        } else {
187            None
188        }
189    }
190
191    /// Strip "host:" prefix if exists
192    pub fn without_host(&self) -> &str {
193        if self.is_host() {
194            &self.as_str()[5..]
195        } else {
196            self.as_str()
197        }
198    }
199
200    /// Strip "target:" prefix if exists
201    pub fn without_target(&self) -> &str {
202        if self.is_target() {
203            &self.as_str()[7..]
204        } else {
205            self.as_str()
206        }
207    }
208
209    /// Strip "host:" or "target:" prefix if exists
210    pub fn without_prefix(&self) -> &str {
211        let s = self.strip(true, false);
212        s.1
213    }
214
215    /// Add "host:" prefix if not exists
216    pub fn with_host(&self) -> PackageName {
217        self.with_prefix(PackagePrefix::Host)
218    }
219
220    /// Add "target:" prefix if not exists
221    pub fn with_target(&self) -> PackageName {
222        self.with_prefix(PackagePrefix::Target)
223    }
224
225    /// Add or replace os prefix
226    pub fn with_prefix(&self, os: PackagePrefix) -> PackageName {
227        let name = self.strip(true, false).1;
228        let name = match os {
229            PackagePrefix::Any => name.to_string(),
230            PackagePrefix::Host => format!("host:{}", name),
231            PackagePrefix::Target => format!("target:{}", name),
232        };
233
234        Self(name)
235    }
236
237    /// Add or replace pkg suffix. Retained the os prefix
238    pub fn with_prefixed_suffix(&self, suffix: Option<&str>) -> PackageName {
239        let mut name = self.strip(false, true).1.to_string();
240        if let Some(suffix) = suffix {
241            name.push('.');
242            name.push_str(suffix);
243        }
244
245        Self(name)
246    }
247
248    /// Add or replace suffix. Does not retain the os prefix
249    pub fn with_suffix(&self, suffix: Option<&str>) -> PackageName {
250        let mut name = self.strip(true, true).1.to_string();
251        if let Some(suffix) = suffix {
252            name.push('.');
253            name.push_str(suffix);
254        }
255
256        Self(name)
257    }
258}
259
260impl From<PackageName> for String {
261    fn from(package_name: PackageName) -> Self {
262        package_name.0
263    }
264}
265
266impl TryFrom<String> for PackageName {
267    type Error = PackageError;
268    fn try_from(name: String) -> Result<Self, Self::Error> {
269        Self::new(name)
270    }
271}
272
273impl TryFrom<&str> for PackageName {
274    type Error = PackageError;
275    fn try_from(name: &str) -> Result<Self, Self::Error> {
276        Self::new(name)
277    }
278}
279
280impl TryFrom<&OsStr> for PackageName {
281    type Error = PackageError;
282    fn try_from(name: &OsStr) -> Result<Self, Self::Error> {
283        let name = name
284            .to_str()
285            .ok_or_else(|| PackageError::PackageNameInvalid(name.to_string_lossy().to_string()))?;
286        Self::new(name)
287    }
288}
289
290impl TryFrom<OsString> for PackageName {
291    type Error = PackageError;
292    fn try_from(name: OsString) -> Result<Self, Self::Error> {
293        name.as_os_str().try_into()
294    }
295}
296
297impl fmt::Display for PackageName {
298    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
299        write!(f, "{}", self.0)
300    }
301}
302
303impl Borrow<str> for PackageName {
304    fn borrow(&self) -> &str {
305        self.as_str()
306    }
307}
308
309#[derive(Debug)]
310pub struct PackageInfo {
311    pub installed: bool,
312    pub package: RemotePackage,
313}
314
315#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct SourceIdentifier {
318    /// git commit or tar hash
319    #[serde(skip_serializing_if = "String::is_empty")]
320    pub source_identifier: String,
321    /// git commit of redox repository
322    #[serde(skip_serializing_if = "String::is_empty")]
323    pub commit_identifier: String,
324    /// time when source updated in IS0 8601
325    #[serde(skip_serializing_if = "String::is_empty")]
326    pub time_identifier: String,
327}
328
329#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
330#[serde(default)]
331pub struct Repository {
332    /// list of published packages
333    pub packages: BTreeMap<String, String>,
334    /// list of outdated/missing packages, with source identifier when it first time went outdated/missing
335    pub outdated_packages: BTreeMap<String, SourceIdentifier>,
336}
337
338impl Repository {
339    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, PackageError> {
340        let path = path.as_ref();
341        if !path.is_file() {
342            return Err(PackageError::FileMissing(path.to_path_buf()));
343        }
344        let toml = std::fs::read_to_string(path)
345            .map_err(|err| PackageError::FileError(err.raw_os_error(), path.to_path_buf()))?;
346
347        toml::from_str(&toml).map_err(|e| PackageError::Parse(e, Some(path.to_path_buf())))
348    }
349
350    pub fn from_toml(text: &str) -> Result<Self, PackageError> {
351        toml::from_str(text).map_err(|err| PackageError::Parse(err, None))
352    }
353}
354
355/// Errors that occur while opening or parsing [`Package`]s.
356///
357/// These errors are unrecoverable but useful for reporting.
358#[derive(Clone, Debug, thiserror::Error)]
359pub enum PackageError {
360    #[error("Missing package file {0:?}")]
361    FileMissing(PathBuf),
362    #[error("I/O package file error: {err}: {1}", err=std::io::Error::from_raw_os_error(.0.unwrap_or(0)))]
363    FileError(Option<i32>, PathBuf),
364    #[error("Package {0:?} name invalid")]
365    PackageNameInvalid(String),
366    #[error("Package {0:?} not found")]
367    PackageNotFound(PackageName),
368    #[error("Failed parsing package: {0}; file: {1:?}")]
369    Parse(toml::de::Error, Option<PathBuf>),
370    #[error("Recursion limit reached while processing dependencies; tree: {0:?}")]
371    Recursion(VecDeque<PackageName>),
372    #[error("Package {0:?} is missing one or more dependencies")]
373    DependencyInvalid(PackageName),
374}
375
376impl PackageError {
377    /// Append [`PackageName`] if the error is a recursion error.
378    ///
379    /// The [`PackageError::Recursion`] variant is a stack of package names that caused
380    /// the recursion limit to be reached. This functions conditionally pushes a package
381    /// name if the error is a recursion error to make it easier to build the stack.
382    pub fn append_recursion(&mut self, name: &PackageName) {
383        if let PackageError::Recursion(ref mut packages) = self {
384            packages.push_front(name.clone());
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use std::collections::BTreeMap;
392
393    use crate::{
394        package::{Repository, SourceIdentifier},
395        PackageError,
396    };
397
398    use super::{Package, PackageName};
399
400    const WORKING_DEPENDS: &str = r#"
401    name = "gzdoom"
402    version = "TODO"
403    target = "x86_64-unknown-redox"
404    depends = ["gtk3", "sdl2", "zmusic"]
405    "#;
406
407    const WORKING_NO_DEPENDS: &str = r#"
408    name = "kmquake2"
409    version = "TODO"
410    target = "x86_64-unknown-redox"
411    "#;
412
413    const WORKING_EMPTY_DEPENDS: &str = r#"
414    name = "iodoom3"
415    version = "TODO"
416    target = "x86_64-unknown-redox"
417    depends = []
418    "#;
419
420    const WORKING_EMPTY_VERSION: &str = r#"
421    name = "dev-essentials"
422    target = "x86_64-unknown-redox"
423    depends = ["gcc13"]
424    "#;
425
426    const WORKING_REPOSITORY: &str = r#"
427    [packages]
428    foo = "bar"
429    "#;
430
431    const WORKING_OUTDATED_REPOSITORY: &str = r#"
432    [outdated_packages.gnu-make]
433    source_identifier = "1a0e5353205e106bd9b3c0f4a5f37ee1156a1e1c8feb771d1b4842c216612cba"
434    commit_identifier = "da93b635fec96a6fac7da9bf7742d850cbce68b4"
435    time_identifier = "2025-12-13T05:33:07Z"
436    "#;
437
438    const INVALID_NAME: &str = r#"
439    name = "dolphin.emu.lator"
440    version = "TODO"
441    target = "x86_64-unknown-redox"
442    depends = ["qt5"]
443    "#;
444
445    const INVALID_NAME_DEPENDS: &str = r#"
446    name = "mgba"
447    version = "TODO"
448    target = "x86_64-unknown-redox"
449    depends = ["ffmpeg:latest"]
450    "#;
451
452    #[test]
453    fn package_name_split() -> Result<(), PackageError> {
454        let name1 = PackageName::new("foo").unwrap();
455        let name2 = PackageName::new("foo.bar").unwrap();
456        let name3 = PackageName::new("host:foo").unwrap();
457        let name4 = PackageName::new("host:foo.").unwrap();
458        assert_eq!(
459            (name1.name(), name1.is_host(), name1.suffix()),
460            ("foo", false, None)
461        );
462        assert_eq!(
463            (name2.name(), name2.is_host(), name2.suffix()),
464            ("foo", false, Some("bar"))
465        );
466        assert_eq!(
467            (name3.name(), name3.is_host(), name3.suffix()),
468            ("foo", true, None)
469        );
470        assert_eq!(
471            (name4.name(), name4.is_host(), name4.suffix()),
472            ("foo", true, Some(""))
473        );
474        Ok(())
475    }
476
477    #[test]
478    fn deserialize_with_depends() -> Result<(), PackageError> {
479        let actual = Package::from_toml(WORKING_DEPENDS)?;
480        let expected = Package {
481            name: PackageName("gzdoom".into()),
482            version: "TODO".into(),
483            target: "x86_64-unknown-redox".into(),
484            depends: vec![
485                PackageName("gtk3".into()),
486                PackageName("sdl2".into()),
487                PackageName("zmusic".into()),
488            ],
489            ..Default::default()
490        };
491
492        assert_eq!(expected, actual);
493        Ok(())
494    }
495
496    #[test]
497    fn deserialize_no_depends() -> Result<(), PackageError> {
498        let actual = Package::from_toml(WORKING_NO_DEPENDS)?;
499        let expected = Package {
500            name: PackageName("kmquake2".into()),
501            version: "TODO".into(),
502            target: "x86_64-unknown-redox".into(),
503            ..Default::default()
504        };
505
506        assert_eq!(expected, actual);
507        Ok(())
508    }
509
510    #[test]
511    fn deserialize_empty_depends() -> Result<(), PackageError> {
512        let actual = Package::from_toml(WORKING_EMPTY_DEPENDS)?;
513        let expected = Package {
514            name: PackageName("iodoom3".into()),
515            version: "TODO".into(),
516            target: "x86_64-unknown-redox".into(),
517            depends: vec![],
518            ..Default::default()
519        };
520
521        assert_eq!(expected, actual);
522        Ok(())
523    }
524
525    #[test]
526    fn deserialize_empty_version() -> Result<(), PackageError> {
527        let actual = Package::from_toml(WORKING_EMPTY_VERSION)?;
528        let expected = Package {
529            name: PackageName("dev-essentials".into()),
530            target: "x86_64-unknown-redox".into(),
531            depends: vec![PackageName("gcc13".into())],
532            ..Default::default()
533        };
534
535        assert_eq!(expected, actual);
536        Ok(())
537    }
538
539    #[test]
540    fn deserialize_repository() -> Result<(), PackageError> {
541        let actual = Repository::from_toml(WORKING_REPOSITORY)?;
542        let expected = Repository {
543            packages: BTreeMap::from([("foo".into(), "bar".into())]),
544            ..Default::default()
545        };
546
547        assert_eq!(expected, actual);
548        Ok(())
549    }
550
551    #[test]
552    fn deserialize_repository_outdated() -> Result<(), PackageError> {
553        let actual = Repository::from_toml(WORKING_OUTDATED_REPOSITORY)?;
554        let expected = Repository {
555            outdated_packages: BTreeMap::from([(
556                "gnu-make".into(),
557                SourceIdentifier {
558                    source_identifier:
559                        "1a0e5353205e106bd9b3c0f4a5f37ee1156a1e1c8feb771d1b4842c216612cba".into(),
560                    commit_identifier: "da93b635fec96a6fac7da9bf7742d850cbce68b4".into(),
561                    time_identifier: "2025-12-13T05:33:07Z".into(),
562                },
563            )]),
564            ..Default::default()
565        };
566
567        assert_eq!(expected, actual);
568        Ok(())
569    }
570
571    #[test]
572    #[should_panic]
573    fn deserialize_with_invalid_name_fails() {
574        Package::from_toml(INVALID_NAME).unwrap();
575    }
576
577    #[test]
578    #[should_panic]
579    fn deserialize_with_invalid_dependency_name_fails() {
580        Package::from_toml(INVALID_NAME_DEPENDS).unwrap();
581    }
582
583    #[test]
584    fn roundtrip() -> Result<(), PackageError> {
585        let package = Package::from_toml(WORKING_DEPENDS)?;
586        let package_roundtrip = Package::from_toml(&package.to_toml())?;
587
588        assert_eq!(package, package_roundtrip);
589        Ok(())
590    }
591}