Skip to main content

jugar_apr/
metadata.rs

1//! APR model metadata.
2//!
3//! Per spec Section 4.1: CBOR-encoded metadata including name, version,
4//! author, license, difficulty levels, and schemas.
5
6use crate::error::AprError;
7use serde::{Deserialize, Serialize};
8
9/// Metadata for an APR model
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct AprMetadata {
12    /// Model name (3-50 chars, alphanumeric + hyphen)
13    pub name: String,
14
15    /// Semantic version
16    pub version: semver::Version,
17
18    /// Author name or organization
19    pub author: String,
20
21    /// License identifier (e.g., "MIT", "Apache-2.0")
22    pub license: String,
23
24    /// Optional description
25    #[serde(default)]
26    pub description: String,
27
28    /// Number of difficulty levels (1-10 typically)
29    #[serde(default)]
30    pub difficulty_levels: Option<u8>,
31
32    /// Input schema description
33    #[serde(default)]
34    pub input_schema: Option<Schema>,
35
36    /// Output schema description
37    #[serde(default)]
38    pub output_schema: Option<Schema>,
39
40    /// File size in bytes (computed on save)
41    #[serde(default)]
42    pub file_size: u64,
43
44    /// Creation timestamp (ISO 8601)
45    #[serde(default)]
46    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
47}
48
49/// Schema description for model inputs/outputs
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
51pub struct Schema {
52    /// Field definitions
53    pub fields: Vec<SchemaField>,
54}
55
56/// A single field in a schema
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct SchemaField {
59    /// Field name
60    pub name: String,
61    /// Field type (f32, i32, bool, etc.)
62    pub field_type: String,
63    /// Optional description
64    #[serde(default)]
65    pub description: String,
66}
67
68/// Builder for `AprMetadata`
69#[derive(Debug, Default)]
70pub struct AprMetadataBuilder {
71    name: Option<String>,
72    version: Option<String>,
73    author: Option<String>,
74    license: Option<String>,
75    description: Option<String>,
76    difficulty_levels: Option<u8>,
77    input_schema: Option<Schema>,
78    output_schema: Option<Schema>,
79}
80
81impl AprMetadataBuilder {
82    /// Create a new builder
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Set the model name
89    #[must_use]
90    pub fn name(mut self, name: impl Into<String>) -> Self {
91        self.name = Some(name.into());
92        self
93    }
94
95    /// Set the version string (semver)
96    #[must_use]
97    pub fn version(mut self, version: impl Into<String>) -> Self {
98        self.version = Some(version.into());
99        self
100    }
101
102    /// Set the author
103    #[must_use]
104    pub fn author(mut self, author: impl Into<String>) -> Self {
105        self.author = Some(author.into());
106        self
107    }
108
109    /// Set the license
110    #[must_use]
111    pub fn license(mut self, license: impl Into<String>) -> Self {
112        self.license = Some(license.into());
113        self
114    }
115
116    /// Set the description
117    #[must_use]
118    pub fn description(mut self, description: impl Into<String>) -> Self {
119        self.description = Some(description.into());
120        self
121    }
122
123    /// Set difficulty levels
124    #[must_use]
125    pub const fn difficulty_levels(mut self, levels: u8) -> Self {
126        self.difficulty_levels = Some(levels);
127        self
128    }
129
130    /// Set input schema
131    #[must_use]
132    pub fn input_schema(mut self, schema: Schema) -> Self {
133        self.input_schema = Some(schema);
134        self
135    }
136
137    /// Set output schema
138    #[must_use]
139    pub fn output_schema(mut self, schema: Schema) -> Self {
140        self.output_schema = Some(schema);
141        self
142    }
143
144    /// Build the metadata, validating all fields
145    ///
146    /// # Errors
147    ///
148    /// Returns error if required fields are missing or invalid
149    pub fn build(self) -> Result<AprMetadata, AprError> {
150        let name = self.name.ok_or(AprError::MissingField { field: "name" })?;
151        let version_str = self
152            .version
153            .ok_or(AprError::MissingField { field: "version" })?;
154        let author = self
155            .author
156            .ok_or(AprError::MissingField { field: "author" })?;
157        let license = self
158            .license
159            .ok_or(AprError::MissingField { field: "license" })?;
160
161        // Validate name (3-50 chars, alphanumeric + hyphen)
162        if name.len() < 3 || name.len() > 50 {
163            return Err(AprError::InvalidName { name });
164        }
165        if !name
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
168        {
169            return Err(AprError::InvalidName { name });
170        }
171
172        // Parse version
173        let version =
174            semver::Version::parse(&version_str).map_err(|_| AprError::InvalidVersion {
175                version: version_str,
176            })?;
177
178        Ok(AprMetadata {
179            name,
180            version,
181            author,
182            license,
183            description: self.description.unwrap_or_default(),
184            difficulty_levels: self.difficulty_levels,
185            input_schema: self.input_schema,
186            output_schema: self.output_schema,
187            file_size: 0,
188            created_at: Some(chrono::Utc::now()),
189        })
190    }
191}
192
193impl AprMetadata {
194    /// Create a new metadata builder
195    #[must_use]
196    pub fn builder() -> AprMetadataBuilder {
197        AprMetadataBuilder::new()
198    }
199
200    /// Encode metadata to CBOR
201    ///
202    /// # Errors
203    ///
204    /// Returns error if CBOR encoding fails
205    pub fn to_cbor(&self) -> Result<Vec<u8>, AprError> {
206        let mut buffer = Vec::new();
207        ciborium::into_writer(self, &mut buffer)
208            .map_err(|e| AprError::CborEncode(e.to_string()))?;
209        Ok(buffer)
210    }
211
212    /// Decode metadata from CBOR
213    ///
214    /// # Errors
215    ///
216    /// Returns error if CBOR decoding fails
217    pub fn from_cbor(bytes: &[u8]) -> Result<Self, AprError> {
218        ciborium::from_reader(bytes).map_err(|e| AprError::CborDecode(e.to_string()))
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::unwrap_used, clippy::expect_used)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_builder_all_required() {
229        let result = AprMetadata::builder()
230            .name("test")
231            .version("1.0.0")
232            .author("Author")
233            .license("MIT")
234            .build();
235
236        assert!(result.is_ok());
237    }
238
239    #[test]
240    fn test_builder_missing_name() {
241        let result = AprMetadata::builder()
242            .version("1.0.0")
243            .author("Author")
244            .license("MIT")
245            .build();
246
247        assert!(matches!(
248            result,
249            Err(AprError::MissingField { field: "name" })
250        ));
251    }
252
253    #[test]
254    fn test_name_too_short() {
255        let result = AprMetadata::builder()
256            .name("ab")
257            .version("1.0.0")
258            .author("Author")
259            .license("MIT")
260            .build();
261
262        assert!(matches!(result, Err(AprError::InvalidName { .. })));
263    }
264
265    #[test]
266    fn test_name_too_long() {
267        let long_name = "a".repeat(51);
268        let result = AprMetadata::builder()
269            .name(long_name)
270            .version("1.0.0")
271            .author("Author")
272            .license("MIT")
273            .build();
274
275        assert!(matches!(result, Err(AprError::InvalidName { .. })));
276    }
277
278    #[test]
279    fn test_name_invalid_chars() {
280        let result = AprMetadata::builder()
281            .name("test model!") // Space and ! invalid
282            .version("1.0.0")
283            .author("Author")
284            .license("MIT")
285            .build();
286
287        assert!(matches!(result, Err(AprError::InvalidName { .. })));
288    }
289
290    #[test]
291    fn test_invalid_version() {
292        let result = AprMetadata::builder()
293            .name("test")
294            .version("not.a.version")
295            .author("Author")
296            .license("MIT")
297            .build();
298
299        assert!(matches!(result, Err(AprError::InvalidVersion { .. })));
300    }
301
302    #[test]
303    fn test_cbor_roundtrip() {
304        let original = AprMetadata::builder()
305            .name("test-model")
306            .version("1.2.3")
307            .author("Test Author")
308            .license("MIT")
309            .description("A test description")
310            .difficulty_levels(5)
311            .build()
312            .expect("Should build");
313
314        let encoded = original.to_cbor().expect("Should encode");
315        let decoded = AprMetadata::from_cbor(&encoded).expect("Should decode");
316
317        assert_eq!(original.name, decoded.name);
318        assert_eq!(original.version, decoded.version);
319        assert_eq!(original.author, decoded.author);
320        assert_eq!(original.license, decoded.license);
321        assert_eq!(original.description, decoded.description);
322        assert_eq!(original.difficulty_levels, decoded.difficulty_levels);
323    }
324}