use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(
tag = "type",
rename_all = "snake_case",
from = "PackageSourceRepr",
into = "PackageSourceRepr"
)]
pub(crate) enum PackageSource {
#[default]
Unknown,
Installed,
Path { path: String },
Git { url: String, rev: Option<String> },
Bundled { collection: Option<String> },
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum PackageSourceRepr {
Typed(PackageSourceTyped),
Legacy(String),
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PackageSourceTyped {
Unknown,
Installed,
Path { path: String },
Git { url: String, rev: Option<String> },
Bundled { collection: Option<String> },
}
impl From<PackageSourceTyped> for PackageSource {
fn from(t: PackageSourceTyped) -> Self {
match t {
PackageSourceTyped::Unknown => PackageSource::Unknown,
PackageSourceTyped::Installed => PackageSource::Installed,
PackageSourceTyped::Path { path } => PackageSource::Path { path },
PackageSourceTyped::Git { url, rev } => PackageSource::Git { url, rev },
PackageSourceTyped::Bundled { collection } => PackageSource::Bundled { collection },
}
}
}
impl From<PackageSource> for PackageSourceTyped {
fn from(s: PackageSource) -> Self {
match s {
PackageSource::Unknown => PackageSourceTyped::Unknown,
PackageSource::Installed => PackageSourceTyped::Installed,
PackageSource::Path { path } => PackageSourceTyped::Path { path },
PackageSource::Git { url, rev } => PackageSourceTyped::Git { url, rev },
PackageSource::Bundled { collection } => PackageSourceTyped::Bundled { collection },
}
}
}
impl From<PackageSourceRepr> for PackageSource {
fn from(r: PackageSourceRepr) -> Self {
match r {
PackageSourceRepr::Typed(t) => t.into(),
PackageSourceRepr::Legacy(s) => infer_from_legacy_source_string(&s),
}
}
}
impl From<PackageSource> for PackageSourceRepr {
fn from(s: PackageSource) -> Self {
PackageSourceRepr::Typed(s.into())
}
}
impl PackageSource {
pub(crate) fn git_url(&self) -> Option<&str> {
match self {
PackageSource::Git { url, .. } => Some(url.as_str()),
_ => None,
}
}
pub(crate) fn display_string(&self) -> String {
match self {
PackageSource::Unknown => String::new(),
PackageSource::Installed => "installed".to_string(),
PackageSource::Path { path } => path.clone(),
PackageSource::Git { url, .. } => url.clone(),
PackageSource::Bundled {
collection: Some(c),
} => format!("bundled:{c}"),
PackageSource::Bundled { collection: None } => "bundled".to_string(),
}
}
}
pub(crate) fn infer_from_legacy_source_string(s: &str) -> PackageSource {
if s.is_empty() {
return PackageSource::Unknown;
}
if s == "bundled" {
return PackageSource::Bundled { collection: None };
}
let p = Path::new(s);
if p.is_absolute() {
return PackageSource::Installed;
}
PackageSource::Git {
url: s.to_string(),
rev: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infer_git_url() {
let result =
infer_from_legacy_source_string("https://github.com/ynishi/algocline-bundled-packages");
assert_eq!(
result,
PackageSource::Git {
url: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
rev: None,
}
);
}
#[test]
fn infer_local_copy() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().to_str().unwrap().to_string();
let result = infer_from_legacy_source_string(&path);
assert_eq!(result, PackageSource::Installed);
}
#[test]
fn infer_bundled() {
let result = infer_from_legacy_source_string("bundled");
assert_eq!(result, PackageSource::Bundled { collection: None });
}
#[test]
fn infer_empty_string_is_unknown() {
let result = infer_from_legacy_source_string("");
assert_eq!(result, PackageSource::Unknown);
}
#[test]
fn infer_non_existent_absolute_path_is_installed() {
let result =
infer_from_legacy_source_string("/nonexistent/path/that/should/never/exist_xyz");
assert_eq!(result, PackageSource::Installed);
}
#[test]
fn infer_relative_path_is_git() {
let result = infer_from_legacy_source_string("relative/path/pkg");
assert!(matches!(result, PackageSource::Git { .. }));
}
#[test]
fn deserialize_empty_string_is_unknown() {
let src: PackageSource = serde_json::from_str("\"\"").unwrap();
assert_eq!(src, PackageSource::Unknown);
}
#[test]
fn deserialize_legacy_bundled_string() {
let src: PackageSource = serde_json::from_str("\"bundled\"").unwrap();
assert_eq!(src, PackageSource::Bundled { collection: None });
}
#[test]
fn deserialize_legacy_git_url_string() {
let src: PackageSource = serde_json::from_str("\"https://github.com/a/b\"").unwrap();
assert_eq!(
src,
PackageSource::Git {
url: "https://github.com/a/b".to_string(),
rev: None,
}
);
}
#[test]
fn deserialize_legacy_absolute_path_string() {
let src: PackageSource = serde_json::from_str("\"/abs/nonexistent/path\"").unwrap();
assert_eq!(src, PackageSource::Installed);
}
#[test]
fn deserialize_typed_form() {
let src: PackageSource =
serde_json::from_str(r#"{"type":"git","url":"https://x/y","rev":null}"#).unwrap();
assert_eq!(
src,
PackageSource::Git {
url: "https://x/y".to_string(),
rev: None,
}
);
let src: PackageSource = serde_json::from_str(r#"{"type":"unknown"}"#).unwrap();
assert_eq!(src, PackageSource::Unknown);
let src: PackageSource = serde_json::from_str(r#"{"type":"installed"}"#).unwrap();
assert_eq!(src, PackageSource::Installed);
let src: PackageSource =
serde_json::from_str(r#"{"type":"bundled","collection":"pkg-set"}"#).unwrap();
assert_eq!(
src,
PackageSource::Bundled {
collection: Some("pkg-set".to_string())
}
);
}
#[test]
fn serialize_always_typed_form() {
let src = PackageSource::Git {
url: "https://x/y".to_string(),
rev: None,
};
let json = serde_json::to_string(&src).unwrap();
assert!(
json.contains("\"type\":\"git\""),
"write must emit tagged form: {json}"
);
assert!(json.contains("\"url\":\"https://x/y\""), "{json}");
let unk = PackageSource::Unknown;
let json = serde_json::to_string(&unk).unwrap();
assert_eq!(json, r#"{"type":"unknown"}"#);
}
#[test]
fn round_trip_typed() {
let cases = [
PackageSource::Unknown,
PackageSource::Installed,
PackageSource::Path {
path: "/some/path".to_string(),
},
PackageSource::Git {
url: "https://x/y".to_string(),
rev: Some("abc123".to_string()),
},
PackageSource::Bundled {
collection: Some("main".to_string()),
},
];
for src in cases {
let json = serde_json::to_string(&src).unwrap();
let back: PackageSource = serde_json::from_str(&json).unwrap();
assert_eq!(back, src, "round trip failed: {json}");
}
}
#[test]
fn default_is_unknown() {
assert_eq!(PackageSource::default(), PackageSource::Unknown);
}
#[test]
fn deserialize_unknown_type_tag_is_error() {
let result: Result<PackageSource, _> =
serde_json::from_str(r#"{"type":"unknown_variant","path":"/x"}"#);
assert!(
result.is_err(),
"malformed tagged form must be rejected, got {result:?}"
);
}
#[test]
fn deserialize_git_missing_url_is_error() {
let result: Result<PackageSource, _> = serde_json::from_str(r#"{"type":"git"}"#);
assert!(
result.is_err(),
"git variant with missing url must be rejected, got {result:?}"
);
}
#[test]
fn deserialize_path_missing_path_is_error() {
let result: Result<PackageSource, _> = serde_json::from_str(r#"{"type":"path"}"#);
assert!(
result.is_err(),
"path variant with missing path must be rejected, got {result:?}"
);
}
#[test]
fn deserialize_object_without_type_key_is_error() {
let result: Result<PackageSource, _> = serde_json::from_str(r#"{"foo":"bar"}"#);
assert!(
result.is_err(),
"object without type tag must be rejected, got {result:?}"
);
}
#[test]
fn deserialize_non_string_non_object_scalar_is_error() {
for json in ["null", "42", "true", "[]"] {
let result: Result<PackageSource, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"scalar/array {json} must be rejected, got {result:?}"
);
}
}
#[test]
fn git_url_accessor_returns_url_only_for_git() {
assert_eq!(
PackageSource::Git {
url: "https://a/b".to_string(),
rev: None
}
.git_url(),
Some("https://a/b")
);
assert_eq!(PackageSource::Unknown.git_url(), None);
assert_eq!(PackageSource::Installed.git_url(), None);
assert_eq!(
PackageSource::Path {
path: "/x".to_string()
}
.git_url(),
None
);
assert_eq!(PackageSource::Bundled { collection: None }.git_url(), None);
}
}