use std::num::ParseIntError;
use std::path::PathBuf;
use thiserror::Error;
pub use pkgsrc_kv_derive::Kv;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Span {
pub offset: usize,
pub len: usize,
}
impl From<Span> for std::ops::Range<usize> {
fn from(span: Span) -> Self {
span.offset..span.offset + span.len
}
}
#[derive(Debug, Error)]
pub enum KvError {
#[error("line is not in KEY=VALUE format")]
ParseLine(Span),
#[error("missing required field '{0}'")]
Incomplete(String),
#[error("unknown variable '{variable}'")]
UnknownVariable {
variable: String,
span: Span,
},
#[error("failed to parse integer")]
ParseInt {
#[source]
source: ParseIntError,
span: Span,
},
#[error("{message}")]
Parse {
message: String,
span: Span,
},
}
impl KvError {
#[must_use]
pub const fn span(&self) -> Option<Span> {
match self {
Self::ParseLine(span)
| Self::UnknownVariable { span, .. }
| Self::ParseInt { span, .. }
| Self::Parse { span, .. } => Some(*span),
Self::Incomplete(_) => None,
}
}
}
pub type Result<T> = std::result::Result<T, KvError>;
pub trait FromKv: Sized {
fn from_kv(value: &str, span: Span) -> Result<Self>;
}
impl FromKv for String {
fn from_kv(value: &str, _span: Span) -> Result<Self> {
Ok(value.to_string())
}
}
macro_rules! impl_fromkv_for_int {
($($t:ty),*) => {
$(
impl FromKv for $t {
fn from_kv(value: &str, span: Span) -> Result<Self> {
value.parse().map_err(|source: ParseIntError| KvError::ParseInt {
source,
span,
})
}
}
)*
};
}
impl_fromkv_for_int!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize);
impl FromKv for PathBuf {
fn from_kv(value: &str, _span: Span) -> Result<Self> {
Ok(Self::from(value))
}
}
impl FromKv for bool {
fn from_kv(value: &str, span: Span) -> Result<Self> {
match value.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(true),
"false" | "no" | "0" => Ok(false),
_ => Err(KvError::Parse {
message: format!("invalid boolean: {value}"),
span,
}),
}
}
}
impl<T: FromKv> FromKv for Vec<T> {
fn from_kv(value: &str, span: Span) -> Result<Self> {
value
.split_whitespace()
.map(|word| T::from_kv(word, span))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Depend, PkgName};
use indoc::indoc;
use std::collections::HashMap;
const MKTOOL_INPUT: &str = indoc! {"
PKGNAME=mktool-1.4.2
COMMENT=High performance alternatives for pkgsrc/mk
SIZE_PKG=6999600
CATEGORIES=pkgtools
HOMEPAGE=https://github.com/jperkin/mktool/
"};
#[test]
fn span_to_range() {
let span = Span { offset: 10, len: 5 };
let range: std::ops::Range<usize> = span.into();
assert_eq!(range, 10..15);
}
#[test]
fn fromkv_string() -> Result<()> {
let span = Span::default();
assert_eq!(String::from_kv("hello", span)?, "hello");
Ok(())
}
#[test]
fn fromkv_u64() -> Result<()> {
let span = Span::default();
assert_eq!(u64::from_kv("6999600", span)?, 6999600);
assert!(u64::from_kv("not_a_number", span).is_err());
Ok(())
}
#[test]
fn fromkv_bool() -> Result<()> {
let span = Span::default();
assert!(bool::from_kv("true", span)?);
assert!(bool::from_kv("yes", span)?);
assert!(bool::from_kv("1", span)?);
assert!(!bool::from_kv("false", span)?);
assert!(!bool::from_kv("no", span)?);
assert!(!bool::from_kv("0", span)?);
assert!(bool::from_kv("maybe", span).is_err());
Ok(())
}
#[test]
fn fromkv_pathbuf() -> Result<()> {
let span = Span::default();
let path = PathBuf::from_kv("/usr/bin", span)?;
assert_eq!(path, PathBuf::from("/usr/bin"));
Ok(())
}
#[derive(Kv, Debug, PartialEq)]
#[kv(allow_unknown)]
struct SimplePackage {
pkgname: String,
#[kv(variable = "SIZE_PKG")]
size: u64,
comment: Option<String>,
}
#[test]
fn derive_simple() -> Result<()> {
let pkg = SimplePackage::parse(MKTOOL_INPUT)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(pkg.size, 6999600);
assert_eq!(
pkg.comment,
Some("High performance alternatives for pkgsrc/mk".to_string())
);
Ok(())
}
#[test]
fn derive_with_optional() -> Result<()> {
let input = indoc! {"
PKGNAME=mktool-1.4.2
SIZE_PKG=6999600
COMMENT=High performance alternatives for pkgsrc/mk
"};
let pkg = SimplePackage::parse(input)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(pkg.size, 6999600);
assert_eq!(
pkg.comment,
Some("High performance alternatives for pkgsrc/mk".to_string())
);
Ok(())
}
#[test]
fn derive_optional_missing() -> Result<()> {
let input = indoc! {"
PKGNAME=mktool-1.4.2
SIZE_PKG=6999600
"};
let pkg = SimplePackage::parse(input)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(pkg.size, 6999600);
assert_eq!(pkg.comment, None);
Ok(())
}
#[test]
fn derive_unknown_ignored() -> Result<()> {
let pkg = SimplePackage::parse(MKTOOL_INPUT)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
Ok(())
}
#[test]
fn derive_missing_required() {
let input = "PKGNAME=mktool-1.4.2\n";
let result = SimplePackage::parse(input);
assert!(matches!(result, Err(KvError::Incomplete(_))));
}
#[derive(Kv, Debug, PartialEq)]
struct VecPackage {
pkgname: String,
categories: Vec<String>,
}
#[test]
fn derive_vec_whitespace_separated() -> Result<()> {
let input = indoc! {"
PKGNAME=mktool-1.4.2
CATEGORIES=pkgtools devel
"};
let pkg = VecPackage::parse(input)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(pkg.categories, vec!["pkgtools", "devel"]);
Ok(())
}
#[derive(Kv, Debug, PartialEq)]
struct MultiLinePackage {
pkgname: String,
#[kv(multiline)]
description: Vec<String>,
}
#[test]
fn derive_multiline() -> Result<()> {
let input = indoc! {"
PKGNAME=mktool-1.4.2
DESCRIPTION=This is a highly-performant collection of utilities.
DESCRIPTION=Many targets under pkgsrc/mk are implemented using shell.
"};
let pkg = MultiLinePackage::parse(input)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(pkg.description.len(), 2);
assert_eq!(
pkg.description[0],
"This is a highly-performant collection of utilities."
);
assert_eq!(
pkg.description[1],
"Many targets under pkgsrc/mk are implemented using shell."
);
Ok(())
}
#[test]
fn derive_parse_error() {
let input = indoc! {"
PKGNAME=mktool-1.4.2
SIZE_PKG=not_a_number
"};
let result = SimplePackage::parse(input);
assert!(matches!(result, Err(KvError::ParseInt { .. })));
}
#[test]
fn derive_bad_line() {
let input = indoc! {"
PKGNAME=mktool-1.4.2
bad-line
SIZE_PKG=6999600
"};
let result = SimplePackage::parse(input);
assert!(matches!(result, Err(KvError::ParseLine(_))));
}
#[derive(Kv, Debug, PartialEq)]
#[kv(allow_unknown)]
struct ScanIndexTest {
pkgname: PkgName,
all_depends: Option<Vec<Depend>>,
}
#[test]
fn derive_pkgname() -> Result<()> {
let input = "PKGNAME=mktool-1.4.2\n";
let pkg = ScanIndexTest::parse(input)?;
assert_eq!(pkg.pkgname.pkgbase(), "mktool");
assert_eq!(pkg.pkgname.pkgversion(), "1.4.2");
assert_eq!(pkg.all_depends, None);
Ok(())
}
#[test]
fn derive_depend_vec() -> Result<()> {
let input = indoc! {"
PKGNAME=mktool-1.4.2
ALL_DEPENDS=rust-[0-9]*:../../lang/rust curl>=7.0:../../www/curl
"};
let pkg = ScanIndexTest::parse(input)?;
let all_depends = pkg
.all_depends
.as_ref()
.ok_or(KvError::Incomplete("all_depends".to_string()))?;
assert_eq!(all_depends.len(), 2);
Ok(())
}
#[test]
fn derive_depend_invalid() {
let input = indoc! {"
PKGNAME=mktool-1.4.2
ALL_DEPENDS=invalid
"};
let result = ScanIndexTest::parse(input);
assert!(matches!(result, Err(KvError::Parse { .. })));
}
#[derive(Kv, Debug, PartialEq)]
struct WithExtras {
pkgname: String,
#[kv(collect)]
extra: HashMap<String, String>,
}
#[test]
fn derive_extras() -> Result<()> {
let pkg = WithExtras::parse(MKTOOL_INPUT)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert_eq!(
pkg.extra.get("COMMENT"),
Some(&"High performance alternatives for pkgsrc/mk".to_string())
);
assert_eq!(pkg.extra.get("SIZE_PKG"), Some(&"6999600".to_string()));
assert_eq!(pkg.extra.get("CATEGORIES"), Some(&"pkgtools".to_string()));
assert_eq!(
pkg.extra.get("HOMEPAGE"),
Some(&"https://github.com/jperkin/mktool/".to_string())
);
assert_eq!(pkg.extra.len(), 4);
Ok(())
}
#[test]
fn derive_extras_empty() -> Result<()> {
let input = "PKGNAME=mktool-1.4.2\n";
let pkg = WithExtras::parse(input)?;
assert_eq!(pkg.pkgname, "mktool-1.4.2");
assert!(pkg.extra.is_empty());
Ok(())
}
}