Skip to main content

opensession_api/
lib.rs

1//! Shared API types, crypto, and SQL builders for opensession.io
2//!
3//! This crate is the **single source of truth** for all API request/response types.
4//! TypeScript types are auto-generated via `ts-rs` and consumed by the frontend.
5//!
6//! To regenerate TypeScript types:
7//!   cargo test -p opensession-api -- export_typescript --nocapture
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "backend")]
12pub mod crypto;
13#[cfg(feature = "backend")]
14pub mod db;
15pub mod deploy;
16pub mod oauth;
17#[cfg(feature = "backend")]
18pub mod service;
19
20// Re-export core HAIL types for convenience
21pub use opensession_core::trace::{
22    Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
23};
24
25// ─── Shared Enums ────────────────────────────────────────────────────────────
26
27/// Sort order for session listings.
28#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31#[cfg_attr(feature = "ts", ts(export))]
32pub enum SortOrder {
33    #[default]
34    Recent,
35    Popular,
36    Longest,
37}
38
39impl SortOrder {
40    pub fn as_str(&self) -> &str {
41        match self {
42            Self::Recent => "recent",
43            Self::Popular => "popular",
44            Self::Longest => "longest",
45        }
46    }
47}
48
49impl std::fmt::Display for SortOrder {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55/// Time range filter for queries.
56#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
57#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "ts", ts(export))]
59pub enum TimeRange {
60    #[serde(rename = "24h")]
61    Hours24,
62    #[serde(rename = "7d")]
63    Days7,
64    #[serde(rename = "30d")]
65    Days30,
66    #[default]
67    #[serde(rename = "all")]
68    All,
69}
70
71impl TimeRange {
72    pub fn as_str(&self) -> &str {
73        match self {
74            Self::Hours24 => "24h",
75            Self::Days7 => "7d",
76            Self::Days30 => "30d",
77            Self::All => "all",
78        }
79    }
80}
81
82impl std::fmt::Display for TimeRange {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88/// Type of link between two sessions.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case")]
91#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
92#[cfg_attr(feature = "ts", ts(export))]
93pub enum LinkType {
94    Handoff,
95    Related,
96    Parent,
97    Child,
98}
99
100impl LinkType {
101    pub fn as_str(&self) -> &str {
102        match self {
103            Self::Handoff => "handoff",
104            Self::Related => "related",
105            Self::Parent => "parent",
106            Self::Child => "child",
107        }
108    }
109}
110
111impl std::fmt::Display for LinkType {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(self.as_str())
114    }
115}
116
117// ─── Utilities ───────────────────────────────────────────────────────────────
118
119/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping.
120pub fn saturating_i64(v: u64) -> i64 {
121    i64::try_from(v).unwrap_or(i64::MAX)
122}
123
124// ─── Auth ────────────────────────────────────────────────────────────────────
125
126/// Email + password registration.
127#[derive(Debug, Serialize, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct AuthRegisterRequest {
131    pub email: String,
132    pub password: String,
133    pub nickname: String,
134}
135
136/// Email + password login.
137#[derive(Debug, Serialize, Deserialize)]
138#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
139#[cfg_attr(feature = "ts", ts(export))]
140pub struct LoginRequest {
141    pub email: String,
142    pub password: String,
143}
144
145/// Returned on successful login / register / refresh.
146#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
148#[cfg_attr(feature = "ts", ts(export))]
149pub struct AuthTokenResponse {
150    pub access_token: String,
151    pub refresh_token: String,
152    pub expires_in: u64,
153    pub user_id: String,
154    pub nickname: String,
155}
156
157/// Refresh token request.
158#[derive(Debug, Serialize, Deserialize)]
159#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
160#[cfg_attr(feature = "ts", ts(export))]
161pub struct RefreshRequest {
162    pub refresh_token: String,
163}
164
165/// Logout request (invalidate refresh token).
166#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[cfg_attr(feature = "ts", ts(export))]
169pub struct LogoutRequest {
170    pub refresh_token: String,
171}
172
173/// Change password request.
174#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
176#[cfg_attr(feature = "ts", ts(export))]
177pub struct ChangePasswordRequest {
178    pub current_password: String,
179    pub new_password: String,
180}
181
182/// Returned by `POST /api/auth/verify` — confirms token validity.
183#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[cfg_attr(feature = "ts", ts(export))]
186pub struct VerifyResponse {
187    pub user_id: String,
188    pub nickname: String,
189}
190
191/// Full user profile returned by `GET /api/auth/me`.
192#[derive(Debug, Serialize, Deserialize)]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[cfg_attr(feature = "ts", ts(export))]
195pub struct UserSettingsResponse {
196    pub user_id: String,
197    pub nickname: String,
198    pub created_at: String,
199    pub email: Option<String>,
200    pub avatar_url: Option<String>,
201    /// Linked OAuth providers.
202    #[serde(default)]
203    pub oauth_providers: Vec<oauth::LinkedProvider>,
204}
205
206/// Generic success response for operations that don't return data.
207#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[cfg_attr(feature = "ts", ts(export))]
210pub struct OkResponse {
211    pub ok: bool,
212}
213
214/// Response for API key issuance. The key is visible only at issuance time.
215#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
217#[cfg_attr(feature = "ts", ts(export))]
218pub struct IssueApiKeyResponse {
219    pub api_key: String,
220}
221
222/// Response for OAuth link initiation (redirect URL).
223#[derive(Debug, Serialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct OAuthLinkResponse {
227    pub url: String,
228}
229
230// ─── Sessions ────────────────────────────────────────────────────────────────
231
232/// Request body for `POST /api/sessions` — upload a recorded session.
233#[derive(Debug, Serialize, Deserialize)]
234pub struct UploadRequest {
235    pub session: Session,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub body_url: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub linked_session_ids: Option<Vec<String>>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub git_remote: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub git_branch: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub git_commit: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub git_repo_name: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub pr_number: Option<i64>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub pr_url: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub score_plugin: Option<String>,
254}
255
256/// Returned on successful session upload — contains the new session ID and URL.
257#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
259#[cfg_attr(feature = "ts", ts(export))]
260pub struct UploadResponse {
261    pub id: String,
262    pub url: String,
263    #[serde(default)]
264    pub session_score: i64,
265    #[serde(default = "default_score_plugin")]
266    pub score_plugin: String,
267}
268
269/// Flat session summary returned by list/detail endpoints.
270/// This is NOT the full HAIL Session — it's a DB-derived summary.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
273#[cfg_attr(feature = "ts", ts(export))]
274pub struct SessionSummary {
275    pub id: String,
276    pub user_id: Option<String>,
277    pub nickname: Option<String>,
278    pub tool: String,
279    pub agent_provider: Option<String>,
280    pub agent_model: Option<String>,
281    pub title: Option<String>,
282    pub description: Option<String>,
283    /// Comma-separated tags string
284    pub tags: Option<String>,
285    pub created_at: String,
286    pub uploaded_at: String,
287    pub message_count: i64,
288    pub task_count: i64,
289    pub event_count: i64,
290    pub duration_seconds: i64,
291    pub total_input_tokens: i64,
292    pub total_output_tokens: i64,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub git_remote: Option<String>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub git_branch: Option<String>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub git_commit: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub git_repo_name: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub pr_number: Option<i64>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub pr_url: Option<String>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub working_directory: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub files_modified: Option<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub files_read: Option<String>,
311    #[serde(default)]
312    pub has_errors: bool,
313    #[serde(default = "default_max_active_agents")]
314    pub max_active_agents: i64,
315    #[serde(default)]
316    pub session_score: i64,
317    #[serde(default = "default_score_plugin")]
318    pub score_plugin: String,
319}
320
321/// Paginated session listing returned by `GET /api/sessions`.
322#[derive(Debug, Serialize, Deserialize)]
323#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
324#[cfg_attr(feature = "ts", ts(export))]
325pub struct SessionListResponse {
326    pub sessions: Vec<SessionSummary>,
327    pub total: i64,
328    pub page: u32,
329    pub per_page: u32,
330}
331
332/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
333#[derive(Debug, Deserialize)]
334#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
335#[cfg_attr(feature = "ts", ts(export))]
336pub struct SessionListQuery {
337    #[serde(default = "default_page")]
338    pub page: u32,
339    #[serde(default = "default_per_page")]
340    pub per_page: u32,
341    pub search: Option<String>,
342    pub tool: Option<String>,
343    /// Sort order (default: recent)
344    pub sort: Option<SortOrder>,
345    /// Time range filter (default: all)
346    pub time_range: Option<TimeRange>,
347}
348
349impl SessionListQuery {
350    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
351    pub fn is_public_feed_cacheable(
352        &self,
353        has_auth_header: bool,
354        has_session_cookie: bool,
355    ) -> bool {
356        !has_auth_header
357            && !has_session_cookie
358            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
359            && self.page <= 10
360            && self.per_page <= 50
361    }
362}
363
364#[cfg(test)]
365mod session_list_query_tests {
366    use super::*;
367
368    fn base_query() -> SessionListQuery {
369        SessionListQuery {
370            page: 1,
371            per_page: 20,
372            search: None,
373            tool: None,
374            sort: None,
375            time_range: None,
376        }
377    }
378
379    #[test]
380    fn public_feed_cacheable_when_anonymous_default_feed() {
381        let q = base_query();
382        assert!(q.is_public_feed_cacheable(false, false));
383    }
384
385    #[test]
386    fn public_feed_not_cacheable_with_auth_or_cookie() {
387        let q = base_query();
388        assert!(!q.is_public_feed_cacheable(true, false));
389        assert!(!q.is_public_feed_cacheable(false, true));
390    }
391
392    #[test]
393    fn public_feed_not_cacheable_for_search_or_large_page() {
394        let mut q = base_query();
395        q.search = Some("hello".into());
396        assert!(!q.is_public_feed_cacheable(false, false));
397
398        let mut q = base_query();
399        q.page = 11;
400        assert!(!q.is_public_feed_cacheable(false, false));
401
402        let mut q = base_query();
403        q.per_page = 100;
404        assert!(!q.is_public_feed_cacheable(false, false));
405    }
406}
407
408fn default_page() -> u32 {
409    1
410}
411fn default_per_page() -> u32 {
412    20
413}
414fn default_max_active_agents() -> i64 {
415    1
416}
417
418fn default_score_plugin() -> String {
419    opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
420}
421
422/// Single session detail returned by `GET /api/sessions/:id`.
423#[derive(Debug, Serialize, Deserialize)]
424#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
425#[cfg_attr(feature = "ts", ts(export))]
426pub struct SessionDetail {
427    #[serde(flatten)]
428    #[cfg_attr(feature = "ts", ts(flatten))]
429    pub summary: SessionSummary,
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub linked_sessions: Vec<SessionLink>,
432}
433
434/// A link between two sessions (e.g., handoff chain).
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
437#[cfg_attr(feature = "ts", ts(export))]
438pub struct SessionLink {
439    pub session_id: String,
440    pub linked_session_id: String,
441    pub link_type: LinkType,
442    pub created_at: String,
443}
444
445/// Source descriptor for parser preview requests.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
449#[cfg_attr(feature = "ts", ts(export))]
450pub enum ParseSource {
451    /// Fetch and parse a raw file from a generic Git remote/ref/path source.
452    Git {
453        remote: String,
454        r#ref: String,
455        path: String,
456    },
457    /// Fetch and parse a raw file from a public GitHub repository.
458    Github {
459        owner: String,
460        repo: String,
461        r#ref: String,
462        path: String,
463    },
464    /// Parse inline file content supplied by clients (for local upload preview).
465    Inline {
466        filename: String,
467        /// Base64-encoded UTF-8 text content.
468        content_base64: String,
469    },
470}
471
472/// Candidate parser ranked by detection confidence.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
475#[cfg_attr(feature = "ts", ts(export))]
476pub struct ParseCandidate {
477    pub id: String,
478    pub confidence: u8,
479    pub reason: String,
480}
481
482/// Request body for `POST /api/parse/preview`.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485#[cfg_attr(feature = "ts", ts(export))]
486pub struct ParsePreviewRequest {
487    pub source: ParseSource,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub parser_hint: Option<String>,
490}
491
492/// Response body for `POST /api/parse/preview`.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
495#[cfg_attr(feature = "ts", ts(export))]
496pub struct ParsePreviewResponse {
497    pub parser_used: String,
498    #[serde(default)]
499    pub parser_candidates: Vec<ParseCandidate>,
500    #[cfg_attr(feature = "ts", ts(type = "any"))]
501    pub session: Session,
502    pub source: ParseSource,
503    #[serde(default)]
504    pub warnings: Vec<String>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub native_adapter: Option<String>,
507}
508
509/// Structured parser preview error response.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
512#[cfg_attr(feature = "ts", ts(export))]
513pub struct ParsePreviewErrorResponse {
514    pub code: String,
515    pub message: String,
516    #[serde(default, skip_serializing_if = "Vec::is_empty")]
517    pub parser_candidates: Vec<ParseCandidate>,
518}
519
520// ─── Streaming Events ────────────────────────────────────────────────────────
521
522/// Request body for `POST /api/sessions/:id/events` — append live events.
523#[derive(Debug, Deserialize)]
524#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
525#[cfg_attr(feature = "ts", ts(export))]
526pub struct StreamEventsRequest {
527    #[cfg_attr(feature = "ts", ts(type = "any"))]
528    pub agent: Option<Agent>,
529    #[cfg_attr(feature = "ts", ts(type = "any"))]
530    pub context: Option<SessionContext>,
531    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
532    pub events: Vec<Event>,
533}
534
535/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
536#[derive(Debug, Serialize, Deserialize)]
537#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
538#[cfg_attr(feature = "ts", ts(export))]
539pub struct StreamEventsResponse {
540    pub accepted: usize,
541}
542
543// ─── Health ──────────────────────────────────────────────────────────────────
544
545/// Returned by `GET /api/health` — server liveness check.
546#[derive(Debug, Serialize, Deserialize)]
547#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
548#[cfg_attr(feature = "ts", ts(export))]
549pub struct HealthResponse {
550    pub status: String,
551    pub version: String,
552}
553
554/// Returned by `GET /api/capabilities` — runtime feature availability.
555#[derive(Debug, Serialize, Deserialize)]
556#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
557#[cfg_attr(feature = "ts", ts(export))]
558pub struct CapabilitiesResponse {
559    pub auth_enabled: bool,
560    pub parse_preview_enabled: bool,
561    pub register_targets: Vec<String>,
562    pub share_modes: Vec<String>,
563}
564
565// ─── Service Error ───────────────────────────────────────────────────────────
566
567/// Framework-agnostic service error.
568///
569/// Each variant maps to an HTTP status code. Both the Axum server and
570/// Cloudflare Worker convert this into the appropriate response type.
571#[derive(Debug, Clone)]
572#[non_exhaustive]
573pub enum ServiceError {
574    BadRequest(String),
575    Unauthorized(String),
576    Forbidden(String),
577    NotFound(String),
578    Conflict(String),
579    Internal(String),
580}
581
582impl ServiceError {
583    /// HTTP status code as a `u16`.
584    pub fn status_code(&self) -> u16 {
585        match self {
586            Self::BadRequest(_) => 400,
587            Self::Unauthorized(_) => 401,
588            Self::Forbidden(_) => 403,
589            Self::NotFound(_) => 404,
590            Self::Conflict(_) => 409,
591            Self::Internal(_) => 500,
592        }
593    }
594
595    /// Stable machine-readable error code.
596    pub fn code(&self) -> &'static str {
597        match self {
598            Self::BadRequest(_) => "bad_request",
599            Self::Unauthorized(_) => "unauthorized",
600            Self::Forbidden(_) => "forbidden",
601            Self::NotFound(_) => "not_found",
602            Self::Conflict(_) => "conflict",
603            Self::Internal(_) => "internal",
604        }
605    }
606
607    /// The error message.
608    pub fn message(&self) -> &str {
609        match self {
610            Self::BadRequest(m)
611            | Self::Unauthorized(m)
612            | Self::Forbidden(m)
613            | Self::NotFound(m)
614            | Self::Conflict(m)
615            | Self::Internal(m) => m,
616        }
617    }
618
619    /// Build a closure that logs a DB/IO error and returns `Internal`.
620    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
621        move |e| Self::Internal(format!("{context}: {e}"))
622    }
623}
624
625impl std::fmt::Display for ServiceError {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        write!(f, "{}", self.message())
628    }
629}
630
631impl std::error::Error for ServiceError {}
632
633// ─── Error ───────────────────────────────────────────────────────────────────
634
635/// API error payload.
636#[derive(Debug, Serialize)]
637#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
638#[cfg_attr(feature = "ts", ts(export))]
639pub struct ApiError {
640    pub code: String,
641    pub message: String,
642}
643
644impl From<&ServiceError> for ApiError {
645    fn from(e: &ServiceError) -> Self {
646        Self {
647            code: e.code().to_string(),
648            message: e.message().to_string(),
649        }
650    }
651}
652
653// ─── TypeScript generation ───────────────────────────────────────────────────
654
655#[cfg(test)]
656mod schema_tests {
657    use super::*;
658
659    #[test]
660    fn parse_preview_request_round_trip_git() {
661        let req = ParsePreviewRequest {
662            source: ParseSource::Git {
663                remote: "https://github.com/hwisu/opensession".to_string(),
664                r#ref: "main".to_string(),
665                path: "sessions/demo.hail.jsonl".to_string(),
666            },
667            parser_hint: Some("hail".to_string()),
668        };
669
670        let json = serde_json::to_string(&req).expect("request should serialize");
671        let decoded: ParsePreviewRequest =
672            serde_json::from_str(&json).expect("request should deserialize");
673
674        match decoded.source {
675            ParseSource::Git {
676                remote,
677                r#ref,
678                path,
679            } => {
680                assert_eq!(remote, "https://github.com/hwisu/opensession");
681                assert_eq!(r#ref, "main");
682                assert_eq!(path, "sessions/demo.hail.jsonl");
683            }
684            _ => panic!("expected git parse source"),
685        }
686        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
687    }
688
689    #[test]
690    fn parse_preview_request_round_trip_github_compat() {
691        let req = ParsePreviewRequest {
692            source: ParseSource::Github {
693                owner: "hwisu".to_string(),
694                repo: "opensession".to_string(),
695                r#ref: "main".to_string(),
696                path: "sessions/demo.hail.jsonl".to_string(),
697            },
698            parser_hint: Some("hail".to_string()),
699        };
700
701        let json = serde_json::to_string(&req).expect("request should serialize");
702        let decoded: ParsePreviewRequest =
703            serde_json::from_str(&json).expect("request should deserialize");
704
705        match decoded.source {
706            ParseSource::Github {
707                owner,
708                repo,
709                r#ref,
710                path,
711            } => {
712                assert_eq!(owner, "hwisu");
713                assert_eq!(repo, "opensession");
714                assert_eq!(r#ref, "main");
715                assert_eq!(path, "sessions/demo.hail.jsonl");
716            }
717            _ => panic!("expected github parse source"),
718        }
719        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
720    }
721
722    #[test]
723    fn parse_preview_error_response_round_trip_with_candidates() {
724        let payload = ParsePreviewErrorResponse {
725            code: "parser_selection_required".to_string(),
726            message: "choose parser".to_string(),
727            parser_candidates: vec![ParseCandidate {
728                id: "codex".to_string(),
729                confidence: 89,
730                reason: "event markers".to_string(),
731            }],
732        };
733
734        let json = serde_json::to_string(&payload).expect("error payload should serialize");
735        let decoded: ParsePreviewErrorResponse =
736            serde_json::from_str(&json).expect("error payload should deserialize");
737
738        assert_eq!(decoded.code, "parser_selection_required");
739        assert_eq!(decoded.parser_candidates.len(), 1);
740        assert_eq!(decoded.parser_candidates[0].id, "codex");
741    }
742
743    #[test]
744    fn capabilities_response_round_trip_includes_new_fields() {
745        let caps = CapabilitiesResponse {
746            auth_enabled: true,
747            parse_preview_enabled: true,
748            register_targets: vec!["local".to_string(), "git".to_string()],
749            share_modes: vec!["web".to_string(), "git".to_string(), "json".to_string()],
750        };
751
752        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
753        let decoded: CapabilitiesResponse =
754            serde_json::from_str(&json).expect("capabilities should deserialize");
755
756        assert!(decoded.auth_enabled);
757        assert!(decoded.parse_preview_enabled);
758        assert_eq!(decoded.register_targets, vec!["local", "git"]);
759        assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
760    }
761}
762
763#[cfg(all(test, feature = "ts"))]
764mod tests {
765    use super::*;
766    use std::io::Write;
767    use std::path::PathBuf;
768    use ts_rs::TS;
769
770    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
771    #[test]
772    fn export_typescript() {
773        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
774            .join("../../packages/ui/src/api-types.generated.ts");
775
776        let cfg = ts_rs::Config::new().with_large_int("number");
777        let mut parts: Vec<String> = Vec::new();
778        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
779        parts.push(
780            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
781        );
782        parts.push(String::new());
783
784        // Collect all type declarations.
785        // Structs: `type X = {...}` → `export interface X {...}`
786        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
787        macro_rules! collect_ts {
788            ($($t:ty),+ $(,)?) => {
789                $(
790                    let decl = <$t>::decl(&cfg);
791                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
792                    let decl = if is_struct_decl {
793                        // Struct → export interface
794                        decl
795                            .replacen("type ", "export interface ", 1)
796                            .replace(" = {", " {")
797                            .trim_end_matches(';')
798                            .to_string()
799                    } else {
800                        // Enum/union → export type
801                        decl
802                            .replacen("type ", "export type ", 1)
803                            .trim_end_matches(';')
804                            .to_string()
805                    };
806                    parts.push(decl);
807                    parts.push(String::new());
808                )+
809            };
810        }
811
812        collect_ts!(
813            // Shared enums
814            SortOrder,
815            TimeRange,
816            LinkType,
817            // Auth
818            AuthRegisterRequest,
819            LoginRequest,
820            AuthTokenResponse,
821            RefreshRequest,
822            LogoutRequest,
823            ChangePasswordRequest,
824            VerifyResponse,
825            UserSettingsResponse,
826            OkResponse,
827            IssueApiKeyResponse,
828            OAuthLinkResponse,
829            // Sessions
830            UploadResponse,
831            SessionSummary,
832            SessionListResponse,
833            SessionListQuery,
834            SessionDetail,
835            SessionLink,
836            ParseSource,
837            ParseCandidate,
838            ParsePreviewRequest,
839            ParsePreviewResponse,
840            ParsePreviewErrorResponse,
841            // OAuth
842            oauth::AuthProvidersResponse,
843            oauth::OAuthProviderInfo,
844            oauth::LinkedProvider,
845            // Health
846            HealthResponse,
847            CapabilitiesResponse,
848            ApiError,
849        );
850
851        let content = parts.join("\n");
852
853        // Write to file
854        if let Some(parent) = out_dir.parent() {
855            std::fs::create_dir_all(parent).ok();
856        }
857        let mut file = std::fs::File::create(&out_dir)
858            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
859        file.write_all(content.as_bytes())
860            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
861
862        println!("Generated TypeScript types at: {}", out_dir.display());
863    }
864}