1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct CatalogEntry {
10 pub name: String,
12 pub description: String,
14 pub flake_ref: String,
16 pub profile: String,
18 pub default_cpus: u8,
20 pub default_memory_mib: u32,
22 #[serde(default)]
24 pub tags: Vec<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Catalog {
30 #[serde(default = "default_schema_version")]
32 pub schema_version: u32,
33 pub entries: Vec<CatalogEntry>,
35}
36
37fn default_schema_version() -> u32 {
38 1
39}
40
41impl Catalog {
42 pub fn search(&self, query: &str) -> Vec<&CatalogEntry> {
44 let q = query.to_lowercase();
45 self.entries
46 .iter()
47 .filter(|e| {
48 e.name.to_lowercase().contains(&q)
49 || e.description.to_lowercase().contains(&q)
50 || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
51 })
52 .collect()
53 }
54
55 pub fn find(&self, name: &str) -> Option<&CatalogEntry> {
57 self.entries.iter().find(|e| e.name == name)
58 }
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 fn sample_catalog() -> Catalog {
66 Catalog {
67 schema_version: 1,
68 entries: vec![
69 CatalogEntry {
70 name: "minimal".to_string(),
71 description: "Bare-bones microVM image".to_string(),
72 flake_ref: "github:auser/mvm-images#minimal".to_string(),
73 profile: "minimal".to_string(),
74 default_cpus: 1,
75 default_memory_mib: 256,
76 tags: vec!["base".to_string(), "minimal".to_string()],
77 },
78 CatalogEntry {
79 name: "http-server".to_string(),
80 description: "Nginx-based HTTP server".to_string(),
81 flake_ref: "github:auser/mvm-images#http".to_string(),
82 profile: "http".to_string(),
83 default_cpus: 2,
84 default_memory_mib: 512,
85 tags: vec!["web".to_string(), "nginx".to_string()],
86 },
87 CatalogEntry {
88 name: "postgres".to_string(),
89 description: "PostgreSQL database server".to_string(),
90 flake_ref: "github:auser/mvm-images#postgres".to_string(),
91 profile: "postgres".to_string(),
92 default_cpus: 2,
93 default_memory_mib: 1024,
94 tags: vec!["database".to_string(), "sql".to_string()],
95 },
96 ],
97 }
98 }
99
100 #[test]
101 fn test_serde_roundtrip() {
102 let cat = sample_catalog();
103 let json = serde_json::to_string_pretty(&cat).unwrap();
104 let parsed: Catalog = serde_json::from_str(&json).unwrap();
105 assert_eq!(cat, parsed);
106 }
107
108 #[test]
109 fn test_find_by_name() {
110 let cat = sample_catalog();
111 assert_eq!(cat.find("minimal").unwrap().name, "minimal");
112 assert_eq!(cat.find("postgres").unwrap().default_memory_mib, 1024);
113 assert!(cat.find("nonexistent").is_none());
114 }
115
116 #[test]
117 fn test_search_by_name() {
118 let cat = sample_catalog();
119 let results = cat.search("http");
120 assert_eq!(results.len(), 1);
121 assert_eq!(results[0].name, "http-server");
122 }
123
124 #[test]
125 fn test_search_by_tag() {
126 let cat = sample_catalog();
127 let results = cat.search("database");
128 assert_eq!(results.len(), 1);
129 assert_eq!(results[0].name, "postgres");
130 }
131
132 #[test]
133 fn test_search_by_description() {
134 let cat = sample_catalog();
135 let results = cat.search("bare-bones");
136 assert_eq!(results.len(), 1);
137 assert_eq!(results[0].name, "minimal");
138 }
139
140 #[test]
141 fn test_search_case_insensitive() {
142 let cat = sample_catalog();
143 let results = cat.search("NGINX");
144 assert_eq!(results.len(), 1);
145 }
146
147 #[test]
148 fn test_search_no_results() {
149 let cat = sample_catalog();
150 let results = cat.search("zzz-nonexistent");
151 assert!(results.is_empty());
152 }
153
154 #[test]
155 fn test_schema_version_default() {
156 let json = r#"{"entries": []}"#;
157 let cat: Catalog = serde_json::from_str(json).unwrap();
158 assert_eq!(cat.schema_version, 1);
159 }
160
161 #[test]
162 fn test_catalog_entry_no_tags() {
163 let json = r#"{
164 "name": "test",
165 "description": "test image",
166 "flake_ref": ".",
167 "profile": "test",
168 "default_cpus": 1,
169 "default_memory_mib": 256
170 }"#;
171 let entry: CatalogEntry = serde_json::from_str(json).unwrap();
172 assert!(entry.tags.is_empty());
173 }
174}