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
565pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
566pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
567
568impl CapabilitiesResponse {
569    /// Build runtime capability payload with shared defaults.
570    pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
571        Self {
572            auth_enabled,
573            parse_preview_enabled,
574            register_targets: DEFAULT_REGISTER_TARGETS
575                .iter()
576                .map(|target| (*target).to_string())
577                .collect(),
578            share_modes: DEFAULT_SHARE_MODES
579                .iter()
580                .map(|mode| (*mode).to_string())
581                .collect(),
582        }
583    }
584}
585
586// ─── Service Error ───────────────────────────────────────────────────────────
587
588/// Framework-agnostic service error.
589///
590/// Each variant maps to an HTTP status code. Both the Axum server and
591/// Cloudflare Worker convert this into the appropriate response type.
592#[derive(Debug, Clone)]
593#[non_exhaustive]
594pub enum ServiceError {
595    BadRequest(String),
596    Unauthorized(String),
597    Forbidden(String),
598    NotFound(String),
599    Conflict(String),
600    Internal(String),
601}
602
603impl ServiceError {
604    /// HTTP status code as a `u16`.
605    pub fn status_code(&self) -> u16 {
606        match self {
607            Self::BadRequest(_) => 400,
608            Self::Unauthorized(_) => 401,
609            Self::Forbidden(_) => 403,
610            Self::NotFound(_) => 404,
611            Self::Conflict(_) => 409,
612            Self::Internal(_) => 500,
613        }
614    }
615
616    /// Stable machine-readable error code.
617    pub fn code(&self) -> &'static str {
618        match self {
619            Self::BadRequest(_) => "bad_request",
620            Self::Unauthorized(_) => "unauthorized",
621            Self::Forbidden(_) => "forbidden",
622            Self::NotFound(_) => "not_found",
623            Self::Conflict(_) => "conflict",
624            Self::Internal(_) => "internal",
625        }
626    }
627
628    /// The error message.
629    pub fn message(&self) -> &str {
630        match self {
631            Self::BadRequest(m)
632            | Self::Unauthorized(m)
633            | Self::Forbidden(m)
634            | Self::NotFound(m)
635            | Self::Conflict(m)
636            | Self::Internal(m) => m,
637        }
638    }
639
640    /// Build a closure that logs a DB/IO error and returns `Internal`.
641    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
642        move |e| Self::Internal(format!("{context}: {e}"))
643    }
644}
645
646impl std::fmt::Display for ServiceError {
647    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648        write!(f, "{}", self.message())
649    }
650}
651
652impl std::error::Error for ServiceError {}
653
654// ─── Error ───────────────────────────────────────────────────────────────────
655
656/// API error payload.
657#[derive(Debug, Serialize)]
658#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
659#[cfg_attr(feature = "ts", ts(export))]
660pub struct ApiError {
661    pub code: String,
662    pub message: String,
663}
664
665impl From<&ServiceError> for ApiError {
666    fn from(e: &ServiceError) -> Self {
667        Self {
668            code: e.code().to_string(),
669            message: e.message().to_string(),
670        }
671    }
672}
673
674// ─── TypeScript generation ───────────────────────────────────────────────────
675
676#[cfg(test)]
677mod schema_tests {
678    use super::*;
679
680    #[test]
681    fn parse_preview_request_round_trip_git() {
682        let req = ParsePreviewRequest {
683            source: ParseSource::Git {
684                remote: "https://github.com/hwisu/opensession".to_string(),
685                r#ref: "main".to_string(),
686                path: "sessions/demo.hail.jsonl".to_string(),
687            },
688            parser_hint: Some("hail".to_string()),
689        };
690
691        let json = serde_json::to_string(&req).expect("request should serialize");
692        let decoded: ParsePreviewRequest =
693            serde_json::from_str(&json).expect("request should deserialize");
694
695        match decoded.source {
696            ParseSource::Git {
697                remote,
698                r#ref,
699                path,
700            } => {
701                assert_eq!(remote, "https://github.com/hwisu/opensession");
702                assert_eq!(r#ref, "main");
703                assert_eq!(path, "sessions/demo.hail.jsonl");
704            }
705            _ => panic!("expected git parse source"),
706        }
707        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
708    }
709
710    #[test]
711    fn parse_preview_request_round_trip_github_compat() {
712        let req = ParsePreviewRequest {
713            source: ParseSource::Github {
714                owner: "hwisu".to_string(),
715                repo: "opensession".to_string(),
716                r#ref: "main".to_string(),
717                path: "sessions/demo.hail.jsonl".to_string(),
718            },
719            parser_hint: Some("hail".to_string()),
720        };
721
722        let json = serde_json::to_string(&req).expect("request should serialize");
723        let decoded: ParsePreviewRequest =
724            serde_json::from_str(&json).expect("request should deserialize");
725
726        match decoded.source {
727            ParseSource::Github {
728                owner,
729                repo,
730                r#ref,
731                path,
732            } => {
733                assert_eq!(owner, "hwisu");
734                assert_eq!(repo, "opensession");
735                assert_eq!(r#ref, "main");
736                assert_eq!(path, "sessions/demo.hail.jsonl");
737            }
738            _ => panic!("expected github parse source"),
739        }
740        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
741    }
742
743    #[test]
744    fn parse_preview_error_response_round_trip_with_candidates() {
745        let payload = ParsePreviewErrorResponse {
746            code: "parser_selection_required".to_string(),
747            message: "choose parser".to_string(),
748            parser_candidates: vec![ParseCandidate {
749                id: "codex".to_string(),
750                confidence: 89,
751                reason: "event markers".to_string(),
752            }],
753        };
754
755        let json = serde_json::to_string(&payload).expect("error payload should serialize");
756        let decoded: ParsePreviewErrorResponse =
757            serde_json::from_str(&json).expect("error payload should deserialize");
758
759        assert_eq!(decoded.code, "parser_selection_required");
760        assert_eq!(decoded.parser_candidates.len(), 1);
761        assert_eq!(decoded.parser_candidates[0].id, "codex");
762    }
763
764    #[test]
765    fn capabilities_response_round_trip_includes_new_fields() {
766        let caps = CapabilitiesResponse::for_runtime(true, true);
767
768        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
769        let decoded: CapabilitiesResponse =
770            serde_json::from_str(&json).expect("capabilities should deserialize");
771
772        assert!(decoded.auth_enabled);
773        assert!(decoded.parse_preview_enabled);
774        assert_eq!(decoded.register_targets, vec!["local", "git"]);
775        assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
776    }
777
778    #[test]
779    fn capabilities_defaults_are_stable() {
780        assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
781        assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
782    }
783}
784
785#[cfg(all(test, feature = "ts"))]
786mod tests {
787    use super::*;
788    use std::io::Write;
789    use std::path::PathBuf;
790    use ts_rs::TS;
791
792    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
793    #[test]
794    fn export_typescript() {
795        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
796            .join("../../packages/ui/src/api-types.generated.ts");
797
798        let cfg = ts_rs::Config::new().with_large_int("number");
799        let mut parts: Vec<String> = Vec::new();
800        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
801        parts.push(
802            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
803        );
804        parts.push(String::new());
805
806        // Collect all type declarations.
807        // Structs: `type X = {...}` → `export interface X {...}`
808        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
809        macro_rules! collect_ts {
810            ($($t:ty),+ $(,)?) => {
811                $(
812                    let decl = <$t>::decl(&cfg);
813                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
814                    let decl = if is_struct_decl {
815                        // Struct → export interface
816                        decl
817                            .replacen("type ", "export interface ", 1)
818                            .replace(" = {", " {")
819                            .trim_end_matches(';')
820                            .to_string()
821                    } else {
822                        // Enum/union → export type
823                        decl
824                            .replacen("type ", "export type ", 1)
825                            .trim_end_matches(';')
826                            .to_string()
827                    };
828                    parts.push(decl);
829                    parts.push(String::new());
830                )+
831            };
832        }
833
834        collect_ts!(
835            // Shared enums
836            SortOrder,
837            TimeRange,
838            LinkType,
839            // Auth
840            AuthRegisterRequest,
841            LoginRequest,
842            AuthTokenResponse,
843            RefreshRequest,
844            LogoutRequest,
845            ChangePasswordRequest,
846            VerifyResponse,
847            UserSettingsResponse,
848            OkResponse,
849            IssueApiKeyResponse,
850            OAuthLinkResponse,
851            // Sessions
852            UploadResponse,
853            SessionSummary,
854            SessionListResponse,
855            SessionListQuery,
856            SessionDetail,
857            SessionLink,
858            ParseSource,
859            ParseCandidate,
860            ParsePreviewRequest,
861            ParsePreviewResponse,
862            ParsePreviewErrorResponse,
863            // OAuth
864            oauth::AuthProvidersResponse,
865            oauth::OAuthProviderInfo,
866            oauth::LinkedProvider,
867            // Health
868            HealthResponse,
869            CapabilitiesResponse,
870            ApiError,
871        );
872
873        let content = parts.join("\n");
874
875        // Write to file
876        if let Some(parent) = out_dir.parent() {
877            std::fs::create_dir_all(parent).ok();
878        }
879        let mut file = std::fs::File::create(&out_dir)
880            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
881        file.write_all(content.as_bytes())
882            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
883
884        println!("Generated TypeScript types at: {}", out_dir.display());
885    }
886}