Skip to main content

perfgate_client/
types.rs

1//! Request and response types for the baseline service API.
2//!
3//! These types mirror the server's API contract defined in `perfgate-server`.
4
5use chrono::{DateTime, Utc};
6use perfgate_types::RunReceipt;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10// ----------------------------
11// Core Storage Models
12// ----------------------------
13
14/// The primary storage model for baselines.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct BaselineRecord {
17    /// Schema identifier (perfgate.baseline.v1).
18    pub schema: String,
19    /// Unique baseline identifier (ULID format).
20    pub id: String,
21    /// Project/namespace identifier.
22    pub project: String,
23    /// Benchmark name.
24    pub benchmark: String,
25    /// Semantic version for this baseline.
26    pub version: String,
27    /// Git reference (branch, tag, or ref).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub git_ref: Option<String>,
30    /// Git commit SHA.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub git_sha: Option<String>,
33    /// Full run receipt (perfgate.run.v1).
34    pub receipt: RunReceipt,
35    /// User-provided metadata.
36    #[serde(default)]
37    pub metadata: BTreeMap<String, String>,
38    /// Tags for filtering.
39    #[serde(default)]
40    pub tags: Vec<String>,
41    /// Creation timestamp (RFC 3339).
42    pub created_at: DateTime<Utc>,
43    /// Last modification timestamp.
44    pub updated_at: DateTime<Utc>,
45    /// Content hash for ETag/optimistic locking.
46    pub content_hash: String,
47    /// Creation source (upload, promote, migrate).
48    pub source: BaselineSource,
49    /// Soft delete flag.
50    #[serde(default)]
51    pub deleted: bool,
52}
53
54/// Source of baseline creation.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum BaselineSource {
58    /// Uploaded directly via API.
59    #[default]
60    Upload,
61    /// Created via promote operation.
62    Promote,
63    /// Migrated from external storage.
64    Migrate,
65    /// Created via rollback operation.
66    Rollback,
67}
68
69// ----------------------------
70// API Request Types
71// ----------------------------
72
73/// Request to upload a new baseline.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct UploadBaselineRequest {
76    /// Benchmark name.
77    pub benchmark: String,
78    /// Version identifier (defaults to timestamp if not provided).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub version: Option<String>,
81    /// Git reference.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub git_ref: Option<String>,
84    /// Git commit SHA.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub git_sha: Option<String>,
87    /// Run receipt (perfgate.run.v1).
88    pub receipt: RunReceipt,
89    /// Optional metadata.
90    #[serde(default)]
91    pub metadata: BTreeMap<String, String>,
92    /// Optional tags.
93    #[serde(default)]
94    pub tags: Vec<String>,
95    /// Normalize receipt before storing (strip run_id, timestamps).
96    #[serde(default)]
97    pub normalize: bool,
98}
99
100/// Request to promote a baseline version.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PromoteBaselineRequest {
103    /// Source version to promote from.
104    pub from_version: String,
105    /// Target version identifier.
106    pub to_version: String,
107    /// Git reference for the promoted version.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub git_ref: Option<String>,
110    /// Git commit SHA for the promoted version.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub git_sha: Option<String>,
113    /// Normalize receipt during promotion.
114    #[serde(default)]
115    pub normalize: bool,
116}
117
118/// Query parameters for listing baselines.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ListBaselinesQuery {
121    /// Exact benchmark name match.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub benchmark: Option<String>,
124    /// Benchmark name prefix filter.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub benchmark_prefix: Option<String>,
127    /// Git reference filter (supports glob patterns).
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub git_ref: Option<String>,
130    /// Exact git SHA filter.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub git_sha: Option<String>,
133    /// Filter by tags (comma-separated, AND logic).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub tags: Option<String>,
136    /// Filter baselines created after this time.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub since: Option<DateTime<Utc>>,
139    /// Filter baselines created before this time.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub until: Option<DateTime<Utc>>,
142    /// Maximum results (default: 50, max: 200).
143    #[serde(default = "default_limit")]
144    pub limit: u32,
145    /// Pagination offset.
146    #[serde(default)]
147    pub offset: u64,
148    /// Include full receipt in response.
149    #[serde(default)]
150    pub include_receipt: bool,
151}
152
153impl Default for ListBaselinesQuery {
154    fn default() -> Self {
155        Self {
156            benchmark: None,
157            benchmark_prefix: None,
158            git_ref: None,
159            git_sha: None,
160            tags: None,
161            since: None,
162            until: None,
163            limit: default_limit(),
164            offset: 0,
165            include_receipt: false,
166        }
167    }
168}
169
170fn default_limit() -> u32 {
171    50
172}
173
174impl ListBaselinesQuery {
175    /// Creates a new query with default values.
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Filters by benchmark name.
181    pub fn with_benchmark(mut self, benchmark: impl Into<String>) -> Self {
182        self.benchmark = Some(benchmark.into());
183        self
184    }
185
186    /// Filters by benchmark name prefix.
187    pub fn with_benchmark_prefix(mut self, prefix: impl Into<String>) -> Self {
188        self.benchmark_prefix = Some(prefix.into());
189        self
190    }
191
192    /// Filters by git reference.
193    pub fn with_git_ref(mut self, git_ref: impl Into<String>) -> Self {
194        self.git_ref = Some(git_ref.into());
195        self
196    }
197
198    /// Filters by tags (comma-separated).
199    pub fn with_tags(mut self, tags: impl Into<String>) -> Self {
200        self.tags = Some(tags.into());
201        self
202    }
203
204    /// Sets the maximum number of results.
205    pub fn with_limit(mut self, limit: u32) -> Self {
206        self.limit = limit.min(200);
207        self
208    }
209
210    /// Sets the pagination offset.
211    pub fn with_offset(mut self, offset: u64) -> Self {
212        self.offset = offset;
213        self
214    }
215
216    /// Includes full receipts in the response.
217    pub fn with_receipts(mut self) -> Self {
218        self.include_receipt = true;
219        self
220    }
221
222    /// Converts the query to URL query parameters.
223    pub fn to_query_params(&self) -> Vec<(String, String)> {
224        let mut params = Vec::new();
225
226        if let Some(ref v) = self.benchmark {
227            params.push(("benchmark".to_string(), v.clone()));
228        }
229        if let Some(ref v) = self.benchmark_prefix {
230            params.push(("benchmark_prefix".to_string(), v.clone()));
231        }
232        if let Some(ref v) = self.git_ref {
233            params.push(("git_ref".to_string(), v.clone()));
234        }
235        if let Some(ref v) = self.git_sha {
236            params.push(("git_sha".to_string(), v.clone()));
237        }
238        if let Some(ref v) = self.tags {
239            params.push(("tags".to_string(), v.clone()));
240        }
241        if let Some(ref v) = self.since {
242            params.push(("since".to_string(), v.to_rfc3339()));
243        }
244        if let Some(ref v) = self.until {
245            params.push(("until".to_string(), v.to_rfc3339()));
246        }
247        params.push(("limit".to_string(), self.limit.to_string()));
248        params.push(("offset".to_string(), self.offset.to_string()));
249        if self.include_receipt {
250            params.push(("include_receipt".to_string(), "true".to_string()));
251        }
252
253        params
254    }
255}
256
257// ----------------------------
258// API Response Types
259// ----------------------------
260
261/// Response for successful baseline upload.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct UploadBaselineResponse {
264    /// Unique baseline identifier.
265    pub id: String,
266    /// Benchmark name.
267    pub benchmark: String,
268    /// Version identifier.
269    pub version: String,
270    /// Creation timestamp.
271    pub created_at: DateTime<Utc>,
272    /// ETag for caching.
273    pub etag: String,
274}
275
276/// Response for listing baselines.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct ListBaselinesResponse {
279    /// List of baseline summaries.
280    pub baselines: Vec<BaselineSummary>,
281    /// Pagination information.
282    pub pagination: PaginationInfo,
283}
284
285/// Summary of a baseline (without full receipt by default).
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct BaselineSummary {
288    /// Unique baseline identifier.
289    pub id: String,
290    /// Benchmark name.
291    pub benchmark: String,
292    /// Version identifier.
293    pub version: String,
294    /// Git reference.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub git_ref: Option<String>,
297    /// Creation timestamp.
298    pub created_at: DateTime<Utc>,
299    /// Tags.
300    #[serde(default)]
301    pub tags: Vec<String>,
302    /// Full receipt (only included when include_receipt=true).
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub receipt: Option<RunReceipt>,
305}
306
307/// Pagination information for list responses.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct PaginationInfo {
310    /// Total number of results.
311    pub total: u64,
312    /// Maximum results per page.
313    pub limit: u32,
314    /// Current offset.
315    pub offset: u64,
316    /// Whether more results exist.
317    pub has_more: bool,
318}
319
320/// Response for baseline deletion.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct DeleteBaselineResponse {
323    /// Whether deletion was successful.
324    pub deleted: bool,
325    /// ID of the deleted baseline.
326    pub id: String,
327}
328
329/// Response for baseline promotion.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct PromoteBaselineResponse {
332    /// Unique baseline identifier.
333    pub id: String,
334    /// Benchmark name.
335    pub benchmark: String,
336    /// New version identifier.
337    pub version: String,
338    /// Source version that was promoted.
339    pub promoted_from: String,
340    /// Creation timestamp.
341    pub created_at: DateTime<Utc>,
342}
343
344// ----------------------------
345// Health Check Types
346// ----------------------------
347
348/// Health check response.
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct HealthResponse {
351    /// Health status.
352    pub status: String,
353    /// Server version.
354    pub version: String,
355    /// Storage backend status.
356    pub storage: StorageHealth,
357}
358
359/// Storage backend health status.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct StorageHealth {
362    /// Backend type (memory, sqlite, postgres).
363    pub backend: String,
364    /// Connection status.
365    pub status: String,
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_list_baselines_query_params() {
374        let query = ListBaselinesQuery::new()
375            .with_benchmark("my-bench")
376            .with_limit(100)
377            .with_offset(50)
378            .with_receipts();
379
380        let params = query.to_query_params();
381        assert!(
382            params
383                .iter()
384                .any(|(k, v)| k == "benchmark" && v == "my-bench")
385        );
386        assert!(params.iter().any(|(k, v)| k == "limit" && v == "100"));
387        assert!(params.iter().any(|(k, v)| k == "offset" && v == "50"));
388        assert!(
389            params
390                .iter()
391                .any(|(k, v)| k == "include_receipt" && v == "true")
392        );
393    }
394
395    #[test]
396    fn test_list_baselines_query_limit_capped() {
397        let query = ListBaselinesQuery::new().with_limit(500);
398        assert_eq!(query.limit, 200); // Capped at max
399    }
400
401    #[test]
402    fn test_baseline_source_serde() {
403        let source = BaselineSource::Promote;
404        let json = serde_json::to_string(&source).unwrap();
405        assert_eq!(json, "\"promote\"");
406        let parsed: BaselineSource = serde_json::from_str(&json).unwrap();
407        assert_eq!(parsed, source);
408    }
409}