1use chrono::{DateTime, Utc};
7use perfgate_types::RunReceipt;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12pub const BASELINE_SCHEMA_V1: &str = "perfgate.baseline.v1";
14
15pub const PROJECT_SCHEMA_V1: &str = "perfgate.project.v1";
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
27pub struct BaselineRecord {
28 pub schema: String,
30
31 pub id: String,
33
34 pub project: String,
36
37 pub benchmark: String,
39
40 pub version: String,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub git_ref: Option<String>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub git_sha: Option<String>,
50
51 pub receipt: RunReceipt,
53
54 #[serde(default)]
56 pub metadata: BTreeMap<String, String>,
57
58 #[serde(default)]
60 pub tags: Vec<String>,
61
62 pub created_at: DateTime<Utc>,
64
65 pub updated_at: DateTime<Utc>,
67
68 pub content_hash: String,
70
71 pub source: BaselineSource,
73
74 #[serde(default)]
76 pub deleted: bool,
77}
78
79impl BaselineRecord {
80 #[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 pub fn etag(&self) -> String {
118 format!("\"sha256:{}\"", self.content_hash)
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
124#[serde(rename_all = "snake_case")]
125pub enum BaselineSource {
126 #[default]
128 Upload,
129 Promote,
131 Migrate,
133 Rollback,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
139pub struct BaselineVersion {
140 pub version: String,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub git_ref: Option<String>,
146
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub git_sha: Option<String>,
150
151 pub created_at: DateTime<Utc>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub created_by: Option<String>,
157
158 pub is_current: bool,
160
161 pub source: BaselineSource,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
171pub struct Project {
172 pub schema: String,
174
175 pub id: String,
177
178 pub name: String,
180
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub description: Option<String>,
184
185 pub created_at: DateTime<Utc>,
187
188 pub retention: RetentionPolicy,
190
191 pub versioning: VersioningStrategy,
193}
194
195impl Project {
196 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
212pub struct RetentionPolicy {
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub max_versions: Option<u32>,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub max_age_days: Option<u32>,
220
221 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
238#[serde(rename_all = "snake_case")]
239pub enum VersioningStrategy {
240 #[default]
242 Semantic,
243 Timestamp,
245 GitRef,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
255pub struct UploadBaselineRequest {
256 pub benchmark: String,
258
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub version: Option<String>,
262
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub git_ref: Option<String>,
266
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub git_sha: Option<String>,
270
271 pub receipt: RunReceipt,
273
274 #[serde(default)]
276 pub metadata: BTreeMap<String, String>,
277
278 #[serde(default)]
280 pub tags: Vec<String>,
281
282 #[serde(default)]
284 pub normalize: bool,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
289pub struct PromoteBaselineRequest {
290 pub from_version: String,
292
293 pub to_version: String,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub git_ref: Option<String>,
299
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub git_sha: Option<String>,
303
304 #[serde(default)]
306 pub normalize: bool,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
311pub struct ListBaselinesQuery {
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub benchmark: Option<String>,
315
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub benchmark_prefix: Option<String>,
319
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub git_ref: Option<String>,
323
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub git_sha: Option<String>,
327
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub tags: Option<String>,
331
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub since: Option<DateTime<Utc>>,
335
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub until: Option<DateTime<Utc>>,
339
340 #[serde(default = "default_limit")]
342 pub limit: u32,
343
344 #[serde(default)]
346 pub offset: u64,
347
348 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
400pub struct UploadBaselineResponse {
401 pub id: String,
403
404 pub benchmark: String,
406
407 pub version: String,
409
410 pub created_at: DateTime<Utc>,
412
413 pub etag: String,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
419pub struct ListBaselinesResponse {
420 pub baselines: Vec<BaselineSummary>,
422
423 pub pagination: PaginationInfo,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct BaselineSummary {
430 pub id: String,
432
433 pub benchmark: String,
435
436 pub version: String,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub git_ref: Option<String>,
442
443 pub created_at: DateTime<Utc>,
445
446 #[serde(default)]
448 pub tags: Vec<String>,
449
450 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471pub struct PaginationInfo {
472 pub total: u64,
474
475 pub limit: u32,
477
478 pub offset: u64,
480
481 pub has_more: bool,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
487pub struct DeleteBaselineResponse {
488 pub deleted: bool,
490
491 pub id: String,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
497pub struct PromoteBaselineResponse {
498 pub id: String,
500
501 pub benchmark: String,
503
504 pub version: String,
506
507 pub promoted_from: String,
509
510 pub created_at: DateTime<Utc>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
520pub struct HealthResponse {
521 pub status: String,
523
524 pub version: String,
526
527 pub storage: StorageHealth,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
533pub struct StorageHealth {
534 pub backend: String,
536
537 pub status: String,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547pub struct ApiError {
548 pub error: ApiErrorBody,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
554pub struct ApiErrorBody {
555 pub code: String,
557
558 pub message: String,
560
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub details: Option<serde_json::Value>,
564
565 #[serde(skip_serializing_if = "Option::is_none")]
567 pub request_id: Option<String>,
568}
569
570impl ApiError {
571 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 pub fn with_details(mut self, details: serde_json::Value) -> Self {
585 self.error.details = Some(details);
586 self
587 }
588
589 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 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 pub fn unauthorized(message: &str) -> Self {
605 Self::new("UNAUTHORIZED", message)
606 }
607
608 pub fn forbidden(message: &str) -> Self {
610 Self::new("FORBIDDEN", message)
611 }
612
613 pub fn validation(message: &str) -> Self {
615 Self::new("VALIDATION_ERROR", message)
616 }
617
618 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 pub fn internal(message: &str) -> Self {
628 Self::new("INTERNAL_ERROR", message)
629 }
630}
631
632fn 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 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
650fn compute_content_hash(receipt: &RunReceipt) -> String {
652 use sha2::{Digest, Sha256};
653
654 let canonical = serde_json::to_string(receipt).unwrap_or_default();
656
657 let mut hasher = Sha256::new();
659 hasher.update(canonical.as_bytes());
660 let hash = hasher.finalize();
661
662 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 assert_ne!(id1, id2);
677
678 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}