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
15pub 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 pub name: PackageName,
29 #[serde(skip_serializing_if = "String::is_empty")]
31 pub version: String,
32 pub target: String,
34 #[serde(skip_serializing_if = "String::is_empty")]
36 pub blake3: String,
37 #[serde(skip_serializing_if = "String::is_empty")]
39 pub source_identifier: String,
40 #[serde(skip_serializing_if = "String::is_empty")]
42 pub commit_identifier: String,
43 #[serde(skip_serializing_if = "String::is_empty")]
45 pub time_identifier: String,
46 #[serde(skip_serializing_if = "is_zero")]
48 pub storage_size: u64,
49 #[serde(skip_serializing_if = "is_zero")]
51 pub network_size: u64,
52 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 toml::to_string(self).unwrap()
84 }
85}
86
87#[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 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 pub fn is_host(&self) -> bool {
142 self.0.starts_with("host:")
143 }
144
145 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 pub fn name(&self) -> &str {
178 self.strip(true, true).1
179 }
180
181 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 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 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 pub fn without_prefix(&self) -> &str {
211 let s = self.strip(true, false);
212 s.1
213 }
214
215 pub fn with_host(&self) -> PackageName {
217 self.with_prefix(PackagePrefix::Host)
218 }
219
220 pub fn with_target(&self) -> PackageName {
222 self.with_prefix(PackagePrefix::Target)
223 }
224
225 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 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 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 #[serde(skip_serializing_if = "String::is_empty")]
320 pub source_identifier: String,
321 #[serde(skip_serializing_if = "String::is_empty")]
323 pub commit_identifier: String,
324 #[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 pub packages: BTreeMap<String, String>,
334 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#[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 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}