Skip to main content

mvm_core/
catalog.rs

1use serde::{Deserialize, Serialize};
2
3/// An entry in the Nix-based image catalog.
4///
5/// Each entry maps a human-friendly name to a Nix flake reference.
6/// Running `mvmctl image fetch <name>` creates a template from this
7/// entry's flake_ref and builds it via Nix.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct CatalogEntry {
10    /// Human-friendly image name (e.g. "minimal", "http-server").
11    pub name: String,
12    /// Short description of the image.
13    pub description: String,
14    /// Nix flake reference (e.g. "github:auser/mvm-images#minimal").
15    pub flake_ref: String,
16    /// Nix profile to build (e.g. "minimal", "gateway").
17    pub profile: String,
18    /// Default vCPU count.
19    pub default_cpus: u8,
20    /// Default memory in MiB.
21    pub default_memory_mib: u32,
22    /// Searchable tags (e.g. ["base", "minimal", "nix"]).
23    #[serde(default)]
24    pub tags: Vec<String>,
25}
26
27/// A catalog is a collection of image entries.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Catalog {
30    /// Schema version for forward compatibility.
31    #[serde(default = "default_schema_version")]
32    pub schema_version: u32,
33    /// The image entries.
34    pub entries: Vec<CatalogEntry>,
35}
36
37fn default_schema_version() -> u32 {
38    1
39}
40
41impl Catalog {
42    /// Search entries by name or tag substring (case-insensitive).
43    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    /// Find an entry by exact name.
56    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}