Skip to main content

pkg/
package.rs

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