1use 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
20pub use opensession_core::trace::{
22 Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
23};
24
25#[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#[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#[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
117pub fn saturating_i64(v: u64) -> i64 {
121 i64::try_from(v).unwrap_or(i64::MAX)
122}
123
124#[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#[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#[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#[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#[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#[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#[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#[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 #[serde(default)]
203 pub oauth_providers: Vec<oauth::LinkedProvider>,
204}
205
206#[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#[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#[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#[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#[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#[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 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#[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#[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 pub sort: Option<SortOrder>,
345 pub time_range: Option<TimeRange>,
347}
348
349impl SessionListQuery {
350 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#[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#[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#[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 Git {
453 remote: String,
454 r#ref: String,
455 path: String,
456 },
457 Github {
459 owner: String,
460 repo: String,
461 r#ref: String,
462 path: String,
463 },
464 Inline {
466 filename: String,
467 content_base64: String,
469 },
470}
471
472#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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 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 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#[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#[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 #[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 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 decl
795 .replacen("type ", "export interface ", 1)
796 .replace(" = {", " {")
797 .trim_end_matches(';')
798 .to_string()
799 } else {
800 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 SortOrder,
815 TimeRange,
816 LinkType,
817 AuthRegisterRequest,
819 LoginRequest,
820 AuthTokenResponse,
821 RefreshRequest,
822 LogoutRequest,
823 ChangePasswordRequest,
824 VerifyResponse,
825 UserSettingsResponse,
826 OkResponse,
827 IssueApiKeyResponse,
828 OAuthLinkResponse,
829 UploadResponse,
831 SessionSummary,
832 SessionListResponse,
833 SessionListQuery,
834 SessionDetail,
835 SessionLink,
836 ParseSource,
837 ParseCandidate,
838 ParsePreviewRequest,
839 ParsePreviewResponse,
840 ParsePreviewErrorResponse,
841 oauth::AuthProvidersResponse,
843 oauth::OAuthProviderInfo,
844 oauth::LinkedProvider,
845 HealthResponse,
847 CapabilitiesResponse,
848 ApiError,
849 );
850
851 let content = parts.join("\n");
852
853 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}