use serde::{Deserialize, Serialize};
use crate::constants::API_VERSION_COMPONENTS;
use crate::resources::{ChartRef, ObjectMetadata};
fn default_spec() -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::new())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NylComponent {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: ObjectMetadata,
#[serde(default = "default_spec")]
pub spec: serde_json::Value,
}
pub fn is_nyl_component(manifest: &serde_json::Value) -> bool {
manifest.get("apiVersion").and_then(|v| v.as_str()) == Some(API_VERSION_COMPONENTS)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComponentKindParsed {
pub base: String,
pub name: Option<String>,
pub version: Option<String>,
}
fn is_remote_repository(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://") || s.starts_with("git+") || s.starts_with("oci://")
}
pub fn parse_component_kind(kind: &str) -> ComponentKindParsed {
if let Some(hash_pos) = kind.rfind('#') {
let base = kind[..hash_pos].to_string();
let after_hash = &kind[hash_pos + 1..];
if let Some(at_pos) = after_hash.rfind('@') {
let name = after_hash[..at_pos].to_string();
let version = after_hash[at_pos + 1..].to_string();
ComponentKindParsed {
base,
name: Some(name),
version: Some(version),
}
} else {
ComponentKindParsed {
base,
name: Some(after_hash.to_string()),
version: None,
}
}
} else {
if let Some(at_pos) = kind.rfind('@') {
ComponentKindParsed {
base: kind[..at_pos].to_string(),
name: None,
version: Some(kind[at_pos + 1..].to_string()),
}
} else {
ComponentKindParsed {
base: kind.to_string(),
name: None,
version: None,
}
}
}
}
pub fn is_remote_helm_chart_shortcut(kind: &str) -> bool {
let parsed = parse_component_kind(kind);
is_remote_repository(&parsed.base)
}
pub fn component_kind_to_chart_ref(parsed: &ComponentKindParsed) -> ChartRef {
if is_remote_repository(&parsed.base) {
ChartRef {
repository: Some(parsed.base.clone()),
name: parsed.name.clone(),
version: parsed.version.clone(),
}
} else {
ChartRef {
repository: None,
name: Some(parsed.base.clone()),
version: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_is_nyl_component_positive() {
let manifest = json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/Nginx",
"metadata": { "name": "my-nginx", "namespace": "default" },
"spec": { "replicas": 3 }
});
assert!(is_nyl_component(&manifest));
}
#[test]
fn test_is_nyl_component_negative_wrong_api_version() {
let manifest = json!({
"apiVersion": "nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/Nginx",
"metadata": { "name": "my-nginx" }
});
assert!(!is_nyl_component(&manifest));
}
#[test]
fn test_is_nyl_component_negative_missing_api_version() {
let manifest = json!({
"kind": "example/v1/Nginx",
"metadata": { "name": "my-nginx" }
});
assert!(!is_nyl_component(&manifest));
}
#[test]
fn test_deserialize_full() {
let manifest = json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/Nginx",
"metadata": { "name": "my-nginx", "namespace": "default" },
"spec": { "replicas": 3, "image": "nginx:latest" }
});
let component: NylComponent = serde_json::from_value(manifest).unwrap();
assert_eq!(component.api_version, "components.nyl.niklasrosenstein.github.com/v1");
assert_eq!(component.kind, "example/v1/Nginx");
assert_eq!(component.metadata.name, "my-nginx");
assert_eq!(component.metadata.namespace, Some("default".to_string()));
assert_eq!(component.spec["replicas"], 3);
assert_eq!(component.spec["image"], "nginx:latest");
}
#[test]
fn test_round_trip() {
let manifest = json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "libs/v2/Redis",
"metadata": { "name": "my-redis", "namespace": "infra" },
"spec": { "port": 6379 }
});
let component: NylComponent = serde_json::from_value(manifest).unwrap();
let serialized = serde_json::to_value(&component).unwrap();
let round_tripped: NylComponent = serde_json::from_value(serialized).unwrap();
assert_eq!(round_tripped.kind, "libs/v2/Redis");
assert_eq!(round_tripped.metadata.name, "my-redis");
assert_eq!(round_tripped.spec["port"], 6379);
}
#[test]
fn test_spec_defaults_to_empty_object_when_omitted() {
let manifest = json!({
"apiVersion": "components.nyl.niklasrosenstein.github.com/v1",
"kind": "example/v1/Nginx",
"metadata": { "name": "no-spec" }
});
let component: NylComponent = serde_json::from_value(manifest).unwrap();
assert!(component.spec.is_object());
assert!(component.spec.as_object().unwrap().is_empty());
}
#[test]
fn test_is_remote_repository() {
assert!(is_remote_repository("http://example.com"));
assert!(is_remote_repository("https://example.com"));
assert!(is_remote_repository("git+https://github.com/user/repo"));
assert!(is_remote_repository("oci://ghcr.io/owner/chart"));
assert!(!is_remote_repository("my-chart"));
assert!(!is_remote_repository("path/to/chart"));
assert!(!is_remote_repository("./charts/app"));
}
#[test]
fn test_parse_component_kind_full_format() {
let parsed = parse_component_kind("http://my-repo.org#my-chart@1.0.0");
assert_eq!(parsed.base, "http://my-repo.org");
assert_eq!(parsed.name, Some("my-chart".to_string()));
assert_eq!(parsed.version, Some("1.0.0".to_string()));
}
#[test]
fn test_parse_component_kind_no_version() {
let parsed = parse_component_kind("https://charts.example.com#nginx");
assert_eq!(parsed.base, "https://charts.example.com");
assert_eq!(parsed.name, Some("nginx".to_string()));
assert_eq!(parsed.version, None);
}
#[test]
fn test_parse_component_kind_no_name() {
let parsed = parse_component_kind("oci://ghcr.io/owner/chart@v1.0.0");
assert_eq!(parsed.base, "oci://ghcr.io/owner/chart");
assert_eq!(parsed.name, None);
assert_eq!(parsed.version, Some("v1.0.0".to_string()));
}
#[test]
fn test_parse_component_kind_only_base() {
let parsed = parse_component_kind("http://my-chart-repo.org");
assert_eq!(parsed.base, "http://my-chart-repo.org");
assert_eq!(parsed.name, None);
assert_eq!(parsed.version, None);
}
#[test]
fn test_parse_component_kind_local_path() {
let parsed = parse_component_kind("my-chart");
assert_eq!(parsed.base, "my-chart");
assert_eq!(parsed.name, None);
assert_eq!(parsed.version, None);
}
#[test]
fn test_parse_component_kind_local_path_with_slashes() {
let parsed = parse_component_kind("path/to/my-chart");
assert_eq!(parsed.base, "path/to/my-chart");
assert_eq!(parsed.name, None);
assert_eq!(parsed.version, None);
}
#[test]
fn test_parse_component_kind_git_format() {
let parsed = parse_component_kind("git+https://github.com/user/repo#charts/app@main");
assert_eq!(parsed.base, "git+https://github.com/user/repo");
assert_eq!(parsed.name, Some("charts/app".to_string()));
assert_eq!(parsed.version, Some("main".to_string()));
}
#[test]
fn test_parse_component_kind_git_ssh_with_at() {
let parsed = parse_component_kind("git+git@github.com:user/repo#charts/app");
assert_eq!(parsed.base, "git+git@github.com:user/repo");
assert_eq!(parsed.name, Some("charts/app".to_string()));
assert_eq!(parsed.version, None);
}
#[test]
fn test_parse_component_kind_git_ssh_with_version() {
let parsed = parse_component_kind("git+git@github.com:user/repo#charts/app@v1.0");
assert_eq!(parsed.base, "git+git@github.com:user/repo");
assert_eq!(parsed.name, Some("charts/app".to_string()));
assert_eq!(parsed.version, Some("v1.0".to_string()));
}
#[test]
fn test_is_remote_helm_chart_shortcut_remote() {
assert!(is_remote_helm_chart_shortcut("http://my-repo.org#chart@1.0.0"));
assert!(is_remote_helm_chart_shortcut("https://charts.example.com#nginx"));
assert!(is_remote_helm_chart_shortcut("oci://ghcr.io/chart@v1.0.0"));
assert!(is_remote_helm_chart_shortcut("git+https://github.com/user/repo"));
}
#[test]
fn test_is_remote_helm_chart_shortcut_local() {
assert!(!is_remote_helm_chart_shortcut("my-chart"));
assert!(!is_remote_helm_chart_shortcut("path/to/chart"));
assert!(!is_remote_helm_chart_shortcut("example/v1/Nginx"));
}
#[test]
fn test_component_kind_to_chart_ref_remote_full() {
let parsed = parse_component_kind("http://my-repo.org#my-chart@1.0.0");
let chart_ref = component_kind_to_chart_ref(&parsed);
assert_eq!(chart_ref.repository, Some("http://my-repo.org".to_string()));
assert_eq!(chart_ref.name, Some("my-chart".to_string()));
assert_eq!(chart_ref.version, Some("1.0.0".to_string()));
}
#[test]
fn test_component_kind_to_chart_ref_remote_no_version() {
let parsed = parse_component_kind("https://charts.example.com#nginx");
let chart_ref = component_kind_to_chart_ref(&parsed);
assert_eq!(chart_ref.repository, Some("https://charts.example.com".to_string()));
assert_eq!(chart_ref.name, Some("nginx".to_string()));
assert_eq!(chart_ref.version, None);
}
#[test]
fn test_component_kind_to_chart_ref_local() {
let parsed = parse_component_kind("my-chart");
let chart_ref = component_kind_to_chart_ref(&parsed);
assert_eq!(chart_ref.repository, None);
assert_eq!(chart_ref.name, Some("my-chart".to_string()));
assert_eq!(chart_ref.version, None);
}
#[test]
fn test_component_kind_to_chart_ref_local_path() {
let parsed = parse_component_kind("path/to/my-chart");
let chart_ref = component_kind_to_chart_ref(&parsed);
assert_eq!(chart_ref.repository, None);
assert_eq!(chart_ref.name, Some("path/to/my-chart".to_string()));
assert_eq!(chart_ref.version, None);
}
#[test]
fn test_nyl_component_rejects_unknown_fields() {
let yaml = r"
apiVersion: components.nyl.niklasrosenstein.github.com/v1
kind: MyComponent
metadata:
name: test
spec:
key: value
unknownField: should-fail
";
let result: std::result::Result<NylComponent, _> = serde_norway::from_str(yaml);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unknown field"));
}
}