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 Github {
453 owner: String,
454 repo: String,
455 r#ref: String,
456 path: String,
457 },
458 Inline {
460 filename: String,
461 content_base64: String,
463 },
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
469#[cfg_attr(feature = "ts", ts(export))]
470pub struct ParseCandidate {
471 pub id: String,
472 pub confidence: u8,
473 pub reason: String,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
479#[cfg_attr(feature = "ts", ts(export))]
480pub struct ParsePreviewRequest {
481 pub source: ParseSource,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub parser_hint: Option<String>,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
488#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
489#[cfg_attr(feature = "ts", ts(export))]
490pub struct ParsePreviewResponse {
491 pub parser_used: String,
492 #[serde(default)]
493 pub parser_candidates: Vec<ParseCandidate>,
494 #[cfg_attr(feature = "ts", ts(type = "any"))]
495 pub session: Session,
496 pub source: ParseSource,
497 #[serde(default)]
498 pub warnings: Vec<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub native_adapter: Option<String>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
506#[cfg_attr(feature = "ts", ts(export))]
507pub struct ParsePreviewErrorResponse {
508 pub code: String,
509 pub message: String,
510 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub parser_candidates: Vec<ParseCandidate>,
512}
513
514#[derive(Debug, Deserialize)]
518#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
519#[cfg_attr(feature = "ts", ts(export))]
520pub struct StreamEventsRequest {
521 #[cfg_attr(feature = "ts", ts(type = "any"))]
522 pub agent: Option<Agent>,
523 #[cfg_attr(feature = "ts", ts(type = "any"))]
524 pub context: Option<SessionContext>,
525 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
526 pub events: Vec<Event>,
527}
528
529#[derive(Debug, Serialize, Deserialize)]
531#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
532#[cfg_attr(feature = "ts", ts(export))]
533pub struct StreamEventsResponse {
534 pub accepted: usize,
535}
536
537#[derive(Debug, Serialize, Deserialize)]
541#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
542#[cfg_attr(feature = "ts", ts(export))]
543pub struct HealthResponse {
544 pub status: String,
545 pub version: String,
546}
547
548#[derive(Debug, Serialize, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct CapabilitiesResponse {
553 pub auth_enabled: bool,
554 pub upload_enabled: bool,
555 pub ingest_preview_enabled: bool,
556 pub gh_share_enabled: bool,
557}
558
559#[derive(Debug, Clone)]
566#[non_exhaustive]
567pub enum ServiceError {
568 BadRequest(String),
569 Unauthorized(String),
570 Forbidden(String),
571 NotFound(String),
572 Conflict(String),
573 Internal(String),
574}
575
576impl ServiceError {
577 pub fn status_code(&self) -> u16 {
579 match self {
580 Self::BadRequest(_) => 400,
581 Self::Unauthorized(_) => 401,
582 Self::Forbidden(_) => 403,
583 Self::NotFound(_) => 404,
584 Self::Conflict(_) => 409,
585 Self::Internal(_) => 500,
586 }
587 }
588
589 pub fn code(&self) -> &'static str {
591 match self {
592 Self::BadRequest(_) => "bad_request",
593 Self::Unauthorized(_) => "unauthorized",
594 Self::Forbidden(_) => "forbidden",
595 Self::NotFound(_) => "not_found",
596 Self::Conflict(_) => "conflict",
597 Self::Internal(_) => "internal",
598 }
599 }
600
601 pub fn message(&self) -> &str {
603 match self {
604 Self::BadRequest(m)
605 | Self::Unauthorized(m)
606 | Self::Forbidden(m)
607 | Self::NotFound(m)
608 | Self::Conflict(m)
609 | Self::Internal(m) => m,
610 }
611 }
612
613 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
615 move |e| Self::Internal(format!("{context}: {e}"))
616 }
617}
618
619impl std::fmt::Display for ServiceError {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621 write!(f, "{}", self.message())
622 }
623}
624
625impl std::error::Error for ServiceError {}
626
627#[derive(Debug, Serialize)]
631#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
632#[cfg_attr(feature = "ts", ts(export))]
633pub struct ApiError {
634 pub code: String,
635 pub message: String,
636}
637
638impl From<&ServiceError> for ApiError {
639 fn from(e: &ServiceError) -> Self {
640 Self {
641 code: e.code().to_string(),
642 message: e.message().to_string(),
643 }
644 }
645}
646
647#[cfg(test)]
650mod schema_tests {
651 use super::*;
652
653 #[test]
654 fn parse_preview_request_round_trip() {
655 let req = ParsePreviewRequest {
656 source: ParseSource::Github {
657 owner: "hwisu".to_string(),
658 repo: "opensession".to_string(),
659 r#ref: "main".to_string(),
660 path: "sessions/demo.hail.jsonl".to_string(),
661 },
662 parser_hint: Some("hail".to_string()),
663 };
664
665 let json = serde_json::to_string(&req).expect("request should serialize");
666 let decoded: ParsePreviewRequest =
667 serde_json::from_str(&json).expect("request should deserialize");
668
669 match decoded.source {
670 ParseSource::Github {
671 owner,
672 repo,
673 r#ref,
674 path,
675 } => {
676 assert_eq!(owner, "hwisu");
677 assert_eq!(repo, "opensession");
678 assert_eq!(r#ref, "main");
679 assert_eq!(path, "sessions/demo.hail.jsonl");
680 }
681 _ => panic!("expected github parse source"),
682 }
683 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
684 }
685
686 #[test]
687 fn parse_preview_error_response_round_trip_with_candidates() {
688 let payload = ParsePreviewErrorResponse {
689 code: "parser_selection_required".to_string(),
690 message: "choose parser".to_string(),
691 parser_candidates: vec![ParseCandidate {
692 id: "codex".to_string(),
693 confidence: 89,
694 reason: "event markers".to_string(),
695 }],
696 };
697
698 let json = serde_json::to_string(&payload).expect("error payload should serialize");
699 let decoded: ParsePreviewErrorResponse =
700 serde_json::from_str(&json).expect("error payload should deserialize");
701
702 assert_eq!(decoded.code, "parser_selection_required");
703 assert_eq!(decoded.parser_candidates.len(), 1);
704 assert_eq!(decoded.parser_candidates[0].id, "codex");
705 }
706
707 #[test]
708 fn capabilities_response_round_trip_includes_new_fields() {
709 let caps = CapabilitiesResponse {
710 auth_enabled: true,
711 upload_enabled: true,
712 ingest_preview_enabled: true,
713 gh_share_enabled: false,
714 };
715
716 let json = serde_json::to_string(&caps).expect("capabilities should serialize");
717 let decoded: CapabilitiesResponse =
718 serde_json::from_str(&json).expect("capabilities should deserialize");
719
720 assert!(decoded.auth_enabled);
721 assert!(decoded.upload_enabled);
722 assert!(decoded.ingest_preview_enabled);
723 assert!(!decoded.gh_share_enabled);
724 }
725}
726
727#[cfg(all(test, feature = "ts"))]
728mod tests {
729 use super::*;
730 use std::io::Write;
731 use std::path::PathBuf;
732 use ts_rs::TS;
733
734 #[test]
736 fn export_typescript() {
737 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
738 .join("../../packages/ui/src/api-types.generated.ts");
739
740 let cfg = ts_rs::Config::new().with_large_int("number");
741 let mut parts: Vec<String> = Vec::new();
742 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
743 parts.push(
744 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
745 );
746 parts.push(String::new());
747
748 macro_rules! collect_ts {
752 ($($t:ty),+ $(,)?) => {
753 $(
754 let decl = <$t>::decl(&cfg);
755 let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
756 let decl = if is_struct_decl {
757 decl
759 .replacen("type ", "export interface ", 1)
760 .replace(" = {", " {")
761 .trim_end_matches(';')
762 .to_string()
763 } else {
764 decl
766 .replacen("type ", "export type ", 1)
767 .trim_end_matches(';')
768 .to_string()
769 };
770 parts.push(decl);
771 parts.push(String::new());
772 )+
773 };
774 }
775
776 collect_ts!(
777 SortOrder,
779 TimeRange,
780 LinkType,
781 AuthRegisterRequest,
783 LoginRequest,
784 AuthTokenResponse,
785 RefreshRequest,
786 LogoutRequest,
787 ChangePasswordRequest,
788 VerifyResponse,
789 UserSettingsResponse,
790 OkResponse,
791 IssueApiKeyResponse,
792 OAuthLinkResponse,
793 UploadResponse,
795 SessionSummary,
796 SessionListResponse,
797 SessionListQuery,
798 SessionDetail,
799 SessionLink,
800 ParseSource,
801 ParseCandidate,
802 ParsePreviewRequest,
803 ParsePreviewResponse,
804 ParsePreviewErrorResponse,
805 oauth::AuthProvidersResponse,
807 oauth::OAuthProviderInfo,
808 oauth::LinkedProvider,
809 HealthResponse,
811 CapabilitiesResponse,
812 ApiError,
813 );
814
815 let content = parts.join("\n");
816
817 if let Some(parent) = out_dir.parent() {
819 std::fs::create_dir_all(parent).ok();
820 }
821 let mut file = std::fs::File::create(&out_dir)
822 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
823 file.write_all(content.as_bytes())
824 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
825
826 println!("Generated TypeScript types at: {}", out_dir.display());
827 }
828}