Skip to main content

perfgate_server/
models.rs

1//! Data models for the perfgate baseline service.
2//!
3//! These types represent the core domain objects and API request/response types
4//! for the baseline storage service.
5
6use chrono::{DateTime, Utc};
7use perfgate_types::RunReceipt;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12/// Schema identifier for baseline records.
13pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
14
15/// Schema identifier for project records.
16pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
17
18// ----------------------------
19// Core Storage Models
20// ----------------------------
21
22/// The primary storage model for baselines.
23///
24/// This represents a stored baseline with all its metadata, ready for
25/// persistence and retrieval.
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
27pub struct BaselineRecord {
28    /// Schema identifier (perfgate.baseline.v1)
29    pub schema: String,
30
31    /// Unique baseline identifier (ULID format)
32    pub id: String,
33
34    /// Project/namespace identifier
35    pub project: String,
36
37    /// Benchmark name (must match perfgate-types validation)
38    pub benchmark: String,
39
40    /// Semantic version for this baseline
41    pub version: String,
42
43    /// Git reference (branch, tag, or ref)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub git_ref: Option<String>,
46
47    /// Git commit SHA
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub git_sha: Option<String>,
50
51    /// Full run receipt (perfgate.run.v1)
52    pub receipt: RunReceipt,
53
54    /// User-provided metadata
55    #[serde(default)]
56    pub metadata: BTreeMap<String, String>,
57
58    /// Tags for filtering
59    #[serde(default)]
60    pub tags: Vec<String>,
61
62    /// Creation timestamp (RFC 3339)
63    pub created_at: DateTime<Utc>,
64
65    /// Last modification timestamp
66    pub updated_at: DateTime<Utc>,
67
68    /// Content hash for ETag/optimistic locking
69    pub content_hash: String,
70
71    /// Creation source (upload, promote, migrate)
72    pub source: BaselineSource,
73
74    /// Soft delete flag
75    #[serde(default)]
76    pub deleted: bool,
77}
78
79impl BaselineRecord {
80    /// Creates a new baseline record with generated ID and timestamps.
81    #[allow(clippy::too_many_arguments)]
82    pub fn new(
83        project: String,
84        benchmark: String,
85        version: String,
86        receipt: RunReceipt,
87        git_ref: Option<String>,
88        git_sha: Option<String>,
89        metadata: BTreeMap<String, String>,
90        tags: Vec<String>,
91        source: BaselineSource,
92    ) -> Self {
93        let now = Utc::now();
94        let id = generate_ulid();
95        let content_hash = compute_content_hash(&receipt);
96
97        Self {
98            schema: BASELINE_SCHEMA_V1.to_string(),
99            id,
100            project,
101            benchmark,
102            version,
103            git_ref,
104            git_sha,
105            receipt,
106            metadata,
107            tags,
108            created_at: now,
109            updated_at: now,
110            content_hash,
111            source,
112            deleted: false,
113        }
114    }
115
116    /// Returns the ETag value for this baseline.
117    pub fn etag(&self) -> String {
118        format!("\"sha256:{}\"", self.content_hash)
119    }
120}
121
122/// Source of baseline creation.
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
124#[serde(rename_all = "snake_case")]
125pub enum BaselineSource {
126    /// Uploaded directly via API
127    #[default]
128    Upload,
129    /// Created via promote operation
130    Promote,
131    /// Migrated from external storage
132    Migrate,
133    /// Created via rollback operation
134    Rollback,
135}
136
137/// Version history metadata (without full receipt).
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
139pub struct BaselineVersion {
140    /// Version identifier
141    pub version: String,
142
143    /// Git reference
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub git_ref: Option<String>,
146
147    /// Git commit SHA
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub git_sha: Option<String>,
150
151    /// Creation timestamp
152    pub created_at: DateTime<Utc>,
153
154    /// Creator identifier
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub created_by: Option<String>,
157
158    /// Whether this is the current/promoted version
159    pub is_current: bool,
160
161    /// Source of this version
162    pub source: BaselineSource,
163}
164
165// ----------------------------
166// Project Model
167// ----------------------------
168
169/// Multi-tenancy namespace with retention policies.
170#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
171pub struct Project {
172    /// Schema identifier (perfgate.project.v1)
173    pub schema: String,
174
175    /// Project identifier (URL-safe)
176    pub id: String,
177
178    /// Display name
179    pub name: String,
180
181    /// Project description
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub description: Option<String>,
184
185    /// Creation timestamp
186    pub created_at: DateTime<Utc>,
187
188    /// Retention policy
189    pub retention: RetentionPolicy,
190
191    /// Default baseline versioning strategy
192    pub versioning: VersioningStrategy,
193}
194
195impl Project {
196    /// Creates a new project with default settings.
197    pub fn new(id: String, name: String) -> Self {
198        Self {
199            schema: PROJECT_SCHEMA_V1.to_string(),
200            id,
201            name,
202            description: None,
203            created_at: Utc::now(),
204            retention: RetentionPolicy::default(),
205            versioning: VersioningStrategy::default(),
206        }
207    }
208}
209
210/// Retention policy for baseline versions.
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
212pub struct RetentionPolicy {
213    /// Maximum versions to keep per benchmark
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub max_versions: Option<u32>,
216
217    /// Delete baselines older than this many days
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub max_age_days: Option<u32>,
220
221    /// Keep versions with these tags indefinitely
222    #[serde(default)]
223    pub preserve_tags: Vec<String>,
224}
225
226impl Default for RetentionPolicy {
227    fn default() -> Self {
228        Self {
229            max_versions: Some(50),
230            max_age_days: Some(365),
231            preserve_tags: vec!["production".to_string()],
232        }
233    }
234}
235
236/// Baseline versioning strategy.
237#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
238#[serde(rename_all = "snake_case")]
239pub enum VersioningStrategy {
240    /// Semantic versioning (v1.0.0)
241    #[default]
242    Semantic,
243    /// Timestamp-based versioning
244    Timestamp,
245    /// Git reference-based versioning
246    GitRef,
247}
248
249// ----------------------------
250// API Request Types
251// ----------------------------
252
253/// Request to upload a new baseline.
254#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
255pub struct UploadBaselineRequest {
256    /// Benchmark name
257    pub benchmark: String,
258
259    /// Version identifier (defaults to timestamp if not provided)
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub version: Option<String>,
262
263    /// Git reference
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub git_ref: Option<String>,
266
267    /// Git commit SHA
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub git_sha: Option<String>,
270
271    /// Run receipt (perfgate.run.v1)
272    pub receipt: RunReceipt,
273
274    /// Optional metadata
275    #[serde(default)]
276    pub metadata: BTreeMap<String, String>,
277
278    /// Optional tags
279    #[serde(default)]
280    pub tags: Vec<String>,
281
282    /// Normalize receipt before storing (strip run_id, timestamps)
283    #[serde(default)]
284    pub normalize: bool,
285}
286
287/// Request to promote a baseline version.
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
289pub struct PromoteBaselineRequest {
290    /// Source version to promote from
291    pub from_version: String,
292
293    /// Target version identifier
294    pub to_version: String,
295
296    /// Git reference for the promoted version
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub git_ref: Option<String>,
299
300    /// Git commit SHA for the promoted version
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub git_sha: Option<String>,
303
304    /// Normalize receipt during promotion
305    #[serde(default)]
306    pub normalize: bool,
307}
308
309/// Query parameters for listing baselines.
310#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
311pub struct ListBaselinesQuery {
312    /// Exact benchmark name match
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub benchmark: Option<String>,
315
316    /// Benchmark name prefix filter
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub benchmark_prefix: Option<String>,
319
320    /// Git reference filter (supports glob patterns)
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub git_ref: Option<String>,
323
324    /// Exact git SHA filter
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub git_sha: Option<String>,
327
328    /// Filter by tags (comma-separated, AND logic)
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub tags: Option<String>,
331
332    /// Filter baselines created after this time
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub since: Option<DateTime<Utc>>,
335
336    /// Filter baselines created before this time
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub until: Option<DateTime<Utc>>,
339
340    /// Maximum results (default: 50, max: 200)
341    #[serde(default = "default_limit")]
342    pub limit: u32,
343
344    /// Pagination offset
345    #[serde(default)]
346    pub offset: u64,
347
348    /// Include full receipt in response
349    #[serde(default)]
350    pub include_receipt: bool,
351}
352
353fn default_limit() -> u32 {
354    50
355}
356
357impl Default for ListBaselinesQuery {
358    fn default() -> Self {
359        Self {
360            benchmark: None,
361            benchmark_prefix: None,
362            git_ref: None,
363            git_sha: None,
364            tags: None,
365            since: None,
366            until: None,
367            limit: default_limit(),
368            offset: 0,
369            include_receipt: false,
370        }
371    }
372}
373
374impl ListBaselinesQuery {
375    /// Parses tags from comma-separated string into a vector.
376    pub fn parsed_tags(&self) -> Option<Vec<String>> {
377        self.tags.as_ref().map(|s| {
378            s.split(',')
379                .map(|t| t.trim().to_string())
380                .filter(|t| !t.is_empty())
381                .collect()
382        })
383    }
384
385    /// Validates the query parameters.
386    pub fn validate(&self) -> Result<(), String> {
387        if self.limit > 200 {
388            return Err("limit must not exceed 200".to_string());
389        }
390        Ok(())
391    }
392}
393
394// ----------------------------
395// API Response Types
396// ----------------------------
397
398/// Response for successful baseline upload.
399#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
400pub struct UploadBaselineResponse {
401    /// Unique baseline identifier
402    pub id: String,
403
404    /// Benchmark name
405    pub benchmark: String,
406
407    /// Version identifier
408    pub version: String,
409
410    /// Creation timestamp
411    pub created_at: DateTime<Utc>,
412
413    /// ETag for caching
414    pub etag: String,
415}
416
417/// Response for listing baselines.
418#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
419pub struct ListBaselinesResponse {
420    /// List of baseline summaries
421    pub baselines: Vec<BaselineSummary>,
422
423    /// Pagination information
424    pub pagination: PaginationInfo,
425}
426
427/// Summary of a baseline (without full receipt by default).
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct BaselineSummary {
430    /// Unique baseline identifier
431    pub id: String,
432
433    /// Benchmark name
434    pub benchmark: String,
435
436    /// Version identifier
437    pub version: String,
438
439    /// Git reference
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub git_ref: Option<String>,
442
443    /// Creation timestamp
444    pub created_at: DateTime<Utc>,
445
446    /// Tags
447    #[serde(default)]
448    pub tags: Vec<String>,
449
450    /// Full receipt (only included when include_receipt=true)
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub receipt: Option<RunReceipt>,
453}
454
455impl From<BaselineRecord> for BaselineSummary {
456    fn from(record: BaselineRecord) -> Self {
457        Self {
458            id: record.id,
459            benchmark: record.benchmark,
460            version: record.version,
461            git_ref: record.git_ref,
462            created_at: record.created_at,
463            tags: record.tags,
464            receipt: None,
465        }
466    }
467}
468
469/// Pagination information for list responses.
470#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471pub struct PaginationInfo {
472    /// Total number of results
473    pub total: u64,
474
475    /// Maximum results per page
476    pub limit: u32,
477
478    /// Current offset
479    pub offset: u64,
480
481    /// Whether more results exist
482    pub has_more: bool,
483}
484
485/// Response for baseline deletion.
486#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
487pub struct DeleteBaselineResponse {
488    /// Whether deletion was successful
489    pub deleted: bool,
490
491    /// ID of the deleted baseline
492    pub id: String,
493}
494
495/// Response for baseline promotion.
496#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
497pub struct PromoteBaselineResponse {
498    /// Unique baseline identifier
499    pub id: String,
500
501    /// Benchmark name
502    pub benchmark: String,
503
504    /// New version identifier
505    pub version: String,
506
507    /// Source version that was promoted
508    pub promoted_from: String,
509
510    /// Creation timestamp
511    pub created_at: DateTime<Utc>,
512}
513
514// ----------------------------
515// Health Check Types
516// ----------------------------
517
518/// Health check response.
519#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
520pub struct HealthResponse {
521    /// Health status
522    pub status: String,
523
524    /// Server version
525    pub version: String,
526
527    /// Storage backend status
528    pub storage: StorageHealth,
529}
530
531/// Storage backend health status.
532#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
533pub struct StorageHealth {
534    /// Backend type (memory, sqlite, postgres)
535    pub backend: String,
536
537    /// Connection status
538    pub status: String,
539}
540
541// ----------------------------
542// Error Types
543// ----------------------------
544
545/// API error response envelope.
546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547pub struct ApiError {
548    /// Error details
549    pub error: ApiErrorBody,
550}
551
552/// API error body.
553#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
554pub struct ApiErrorBody {
555    /// Error code
556    pub code: String,
557
558    /// Human-readable message
559    pub message: String,
560
561    /// Additional details
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub details: Option<serde_json::Value>,
564
565    /// Request ID for tracing
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub request_id: Option<String>,
568}
569
570impl ApiError {
571    /// Creates a new API error.
572    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
573        Self {
574            error: ApiErrorBody {
575                code: code.into(),
576                message: message.into(),
577                details: None,
578                request_id: None,
579            },
580        }
581    }
582
583    /// Adds details to the error.
584    pub fn with_details(mut self, details: serde_json::Value) -> Self {
585        self.error.details = Some(details);
586        self
587    }
588
589    /// Adds request ID to the error.
590    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
591        self.error.request_id = Some(request_id.into());
592        self
593    }
594
595    /// Creates a not found error.
596    pub fn not_found(resource: &str, identifier: &str) -> Self {
597        Self::new(
598            "NOT_FOUND",
599            format!("{} '{}' not found", resource, identifier),
600        )
601    }
602
603    /// Creates an unauthorized error.
604    pub fn unauthorized(message: &str) -> Self {
605        Self::new("UNAUTHORIZED", message)
606    }
607
608    /// Creates a forbidden error.
609    pub fn forbidden(message: &str) -> Self {
610        Self::new("FORBIDDEN", message)
611    }
612
613    /// Creates a validation error.
614    pub fn validation(message: &str) -> Self {
615        Self::new("VALIDATION_ERROR", message)
616    }
617
618    /// Creates an already exists error.
619    pub fn already_exists(resource: &str, identifier: &str) -> Self {
620        Self::new(
621            "ALREADY_EXISTS",
622            format!("{} '{}' already exists", resource, identifier),
623        )
624    }
625
626    /// Creates an internal error.
627    pub fn internal(message: &str) -> Self {
628        Self::new("INTERNAL_ERROR", message)
629    }
630}
631
632// ----------------------------
633// Helper Functions
634// ----------------------------
635
636/// Generates a ULID-style identifier.
637fn generate_ulid() -> String {
638    use base64::Engine;
639    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
640
641    let now = chrono::Utc::now();
642    let timestamp = now.timestamp_millis() as u64;
643
644    // Simple ULID-like format: timestamp (10 chars) + random (16 chars)
645    let random_bytes: [u8; 10] = uuid::Uuid::new_v4().as_bytes()[..10].try_into().unwrap();
646
647    format!("{:010X}{}", timestamp, URL_SAFE_NO_PAD.encode(random_bytes))
648}
649
650/// Computes a content hash for a run receipt.
651fn compute_content_hash(receipt: &RunReceipt) -> String {
652    use sha2::{Digest, Sha256};
653
654    // Serialize receipt to canonical JSON
655    let canonical = serde_json::to_string(receipt).unwrap_or_default();
656
657    // Compute SHA-256 hash
658    let mut hasher = Sha256::new();
659    hasher.update(canonical.as_bytes());
660    let hash = hasher.finalize();
661
662    // Return hex-encoded hash (first 32 chars)
663    format!("{:x}", hash).chars().take(32).collect()
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn test_generate_ulid() {
672        let id1 = generate_ulid();
673        let id2 = generate_ulid();
674
675        // IDs should be different
676        assert_ne!(id1, id2);
677
678        // IDs should be 26 characters (ULID-like format)
679        assert!(id1.len() >= 16);
680    }
681
682    #[test]
683    fn test_api_error_creation() {
684        let error = ApiError::new("TEST_CODE", "Test message");
685        assert_eq!(error.error.code, "TEST_CODE");
686        assert_eq!(error.error.message, "Test message");
687        assert!(error.error.details.is_none());
688        assert!(error.error.request_id.is_none());
689    }
690
691    #[test]
692    fn test_api_error_with_details() {
693        let details = serde_json::json!({ "key": "value" });
694        let error = ApiError::new("TEST", "test").with_details(details.clone());
695
696        assert_eq!(error.error.details, Some(details));
697    }
698
699    #[test]
700    fn test_list_baselines_query_tags_parsing() {
701        let query = ListBaselinesQuery {
702            tags: Some("tag1, tag2,tag3".to_string()),
703            ..Default::default()
704        };
705
706        let tags = query.parsed_tags().unwrap();
707        assert_eq!(tags, vec!["tag1", "tag2", "tag3"]);
708    }
709
710    #[test]
711    fn test_list_baselines_query_validation() {
712        let invalid_query = ListBaselinesQuery {
713            limit: 300,
714            ..Default::default()
715        };
716
717        assert!(invalid_query.validate().is_err());
718
719        let valid_query = ListBaselinesQuery {
720            limit: 50,
721            ..Default::default()
722        };
723
724        assert!(valid_query.validate().is_ok());
725    }
726
727    #[test]
728    fn test_retention_policy_default() {
729        let policy = RetentionPolicy::default();
730
731        assert_eq!(policy.max_versions, Some(50));
732        assert_eq!(policy.max_age_days, Some(365));
733        assert!(policy.preserve_tags.contains(&"production".to_string()));
734    }
735}