Skip to main content

conduit_rs/
model.rs

1//! Typed response models returned by the Conduit API.
2
3use crate::error::{ConduitError, Result};
4use crate::matching::MatchingContext;
5use crate::reports::ReportTemplate;
6use serde::Deserialize;
7use serde_json::Value;
8use time::OffsetDateTime;
9use time::format_description::well_known::Rfc3339;
10
11#[derive(Debug, Clone)]
12/// Structured job error payload returned by the API.
13pub struct JobErrorData {
14    /// Stable error code.
15    pub code: String,
16    /// Human-readable error message.
17    pub message: String,
18    /// Optional structured details returned by the API.
19    pub details: Option<Box<Value>>,
20    /// Whether the failure is marked retryable by the API.
21    pub retryable: Option<bool>,
22}
23
24#[derive(Debug, Clone)]
25/// Usage data associated with a completed job.
26pub struct Usage {
27    /// Gross credits used.
28    pub credits_used: f64,
29    /// Net credits used after discounts.
30    pub credits_net_used: f64,
31    /// Credits discounted, when reported.
32    pub credits_discounted: Option<f64>,
33    /// Processed media duration in milliseconds, when reported.
34    pub duration_ms: Option<f64>,
35    /// Model version identifier, when reported.
36    pub model_version: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40/// Reservation lifecycle for job credit holds.
41pub enum CreditReservationStatus {
42    /// Credits are currently reserved.
43    Active,
44    /// Credits were released without being applied.
45    Released,
46    /// Credits were applied to the final usage record.
47    Applied,
48}
49
50impl CreditReservationStatus {
51    fn parse(value: &str, name: &str) -> Result<Self> {
52        match value {
53            "active" => Ok(Self::Active),
54            "released" => Ok(Self::Released),
55            "applied" => Ok(Self::Applied),
56            _ => Err(ConduitError::invalid_response(format!(
57                "invalid {name}: unsupported reservation status"
58            ))),
59        }
60    }
61}
62
63#[derive(Debug, Clone)]
64/// Credit reservation details attached to a job.
65pub struct JobCreditReservation {
66    /// Number of credits reserved for the job, when reported.
67    pub reserved_credits: Option<f64>,
68    /// Reservation lifecycle status, when reported.
69    pub reservation_status: Option<CreditReservationStatus>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73/// Initial status returned in a create receipt.
74pub enum ReceiptStatus {
75    /// The job has been accepted and queued.
76    Queued,
77    /// The job has already started running.
78    Running,
79}
80
81impl ReceiptStatus {
82    /// Returns the canonical API identifier for the receipt status.
83    pub fn as_str(self) -> &'static str {
84        match self {
85            Self::Queued => "queued",
86            Self::Running => "running",
87        }
88    }
89
90    fn parse(value: &str, name: &str) -> Result<Self> {
91        match value {
92            "queued" => Ok(Self::Queued),
93            "running" => Ok(Self::Running),
94            _ => Err(ConduitError::invalid_response(format!(
95                "invalid {name}: unsupported receipt status"
96            ))),
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102/// Kind of long-running job tracked by Conduit.
103pub enum JobKind {
104    /// Report generation job.
105    ReportGenerate,
106    /// Matching generation job.
107    MatchingGenerate,
108}
109
110impl JobKind {
111    fn parse(value: &str, name: &str) -> Result<Self> {
112        match value {
113            "report.generate" => Ok(Self::ReportGenerate),
114            "matching.generate" => Ok(Self::MatchingGenerate),
115            _ => Err(ConduitError::invalid_response(format!(
116                "invalid {name}: unsupported job type"
117            ))),
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123/// Current lifecycle status of a job.
124pub enum JobStatus {
125    /// The job has been accepted and queued.
126    Queued,
127    /// The job is actively running.
128    Running,
129    /// The job completed successfully.
130    Succeeded,
131    /// The job completed with a failure.
132    Failed,
133    /// The job was canceled.
134    Canceled,
135}
136
137impl JobStatus {
138    /// Returns the canonical API identifier for the job status.
139    pub fn as_str(self) -> &'static str {
140        match self {
141            Self::Queued => "queued",
142            Self::Running => "running",
143            Self::Succeeded => "succeeded",
144            Self::Failed => "failed",
145            Self::Canceled => "canceled",
146        }
147    }
148
149    /// Returns `true` when the status is terminal.
150    pub fn is_terminal(self) -> bool {
151        matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
152    }
153
154    fn parse(value: &str, name: &str) -> Result<Self> {
155        match value {
156            "queued" => Ok(Self::Queued),
157            "running" => Ok(Self::Running),
158            "succeeded" => Ok(Self::Succeeded),
159            "failed" => Ok(Self::Failed),
160            "canceled" => Ok(Self::Canceled),
161            _ => Err(ConduitError::invalid_response(format!(
162                "invalid {name}: unsupported job status"
163            ))),
164        }
165    }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169/// Best-effort stage within a long-running job.
170pub enum JobStage {
171    /// Media has been uploaded.
172    Uploaded,
173    /// The job is queued.
174    Queued,
175    /// Media is being transcoded.
176    Transcoding,
177    /// Target extraction is in progress.
178    Extracting,
179    /// Behavioral scoring is in progress.
180    Scoring,
181    /// Output rendering is in progress.
182    Rendering,
183    /// Final job finalization is in progress.
184    Finalizing,
185}
186
187impl JobStage {
188    /// Returns the canonical API identifier for the job stage.
189    pub fn as_str(self) -> &'static str {
190        match self {
191            Self::Uploaded => "uploaded",
192            Self::Queued => "queued",
193            Self::Transcoding => "transcoding",
194            Self::Extracting => "extracting",
195            Self::Scoring => "scoring",
196            Self::Rendering => "rendering",
197            Self::Finalizing => "finalizing",
198        }
199    }
200
201    fn parse(value: &str, name: &str) -> Result<Self> {
202        match value {
203            "uploaded" => Ok(Self::Uploaded),
204            "queued" => Ok(Self::Queued),
205            "transcoding" => Ok(Self::Transcoding),
206            "extracting" => Ok(Self::Extracting),
207            "scoring" => Ok(Self::Scoring),
208            "rendering" => Ok(Self::Rendering),
209            "finalizing" => Ok(Self::Finalizing),
210            _ => Err(ConduitError::invalid_response(format!(
211                "invalid {name}: unsupported job stage"
212            ))),
213        }
214    }
215}
216
217fn parse_job_stage(value: &str) -> Option<JobStage> {
218    JobStage::parse(value, "job.stage").ok()
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222/// Event kind emitted by polling helpers.
223pub enum JobEventKind {
224    /// A status transition was observed.
225    Status,
226    /// A stage transition was observed.
227    Stage,
228    /// A terminal state was observed.
229    Terminal,
230}
231
232impl JobEventKind {
233    /// Returns the canonical event kind identifier.
234    pub fn as_str(self) -> &'static str {
235        match self {
236            Self::Status => "status",
237            Self::Stage => "stage",
238            Self::Terminal => "terminal",
239        }
240    }
241}
242
243#[derive(Debug, Clone)]
244/// Long-running job record returned by jobs and handle helpers.
245pub struct Job {
246    /// Job identifier.
247    pub id: String,
248    /// Job kind.
249    pub kind: JobKind,
250    /// Current lifecycle status.
251    pub status: JobStatus,
252    /// Job creation timestamp.
253    pub created_at: OffsetDateTime,
254    /// Last update timestamp.
255    pub updated_at: OffsetDateTime,
256    /// Current stage, when reported.
257    pub stage: Option<JobStage>,
258    /// Advisory progress value, when reported.
259    pub progress: Option<f64>,
260    /// Completed report identifier for successful report jobs.
261    pub report_id: Option<String>,
262    /// Completed matching identifier for successful matching jobs.
263    pub matching_id: Option<String>,
264    /// Usage details, when reported.
265    pub usage: Option<Usage>,
266    /// Credit reservation details, when reported.
267    pub credits: Option<JobCreditReservation>,
268    /// Released credits, when reported.
269    pub released_credits: Option<f64>,
270    /// Structured failure payload for failed jobs.
271    pub error: Option<JobErrorData>,
272    /// Request identifier echoed by the API, when available.
273    pub request_id: Option<String>,
274}
275
276#[derive(Debug, Clone)]
277/// Event emitted by polling helpers such as `stream()` and `wait()` callbacks.
278pub struct JobEvent {
279    /// Event kind.
280    pub kind: JobEventKind,
281    /// Latest job snapshot.
282    pub job: Job,
283    /// Stage carried by stage events.
284    pub stage: Option<JobStage>,
285    /// Progress value carried by stage events, when available.
286    pub progress: Option<f64>,
287}
288
289#[derive(Debug, Clone)]
290/// Rendered report output payload.
291pub struct ReportOutputData {
292    /// Generated template identifier.
293    pub template: ReportTemplate,
294    /// Markdown rendering, when available.
295    pub markdown: Option<String>,
296    /// Structured JSON output, when available.
297    pub json: Option<Value>,
298    /// Hosted report URL, when available.
299    pub report_url: Option<String>,
300}
301
302#[derive(Debug, Clone)]
303/// Completed report record.
304pub struct Report {
305    /// Report identifier.
306    pub id: String,
307    /// Report creation timestamp.
308    pub created_at: OffsetDateTime,
309    /// Rendered output representations.
310    pub output: ReportOutputData,
311    /// Originating job identifier, when reported.
312    pub job_id: Option<String>,
313    /// Report label, when reported.
314    pub label: Option<String>,
315    /// Resolved entity identifier, when reported.
316    pub entity_id: Option<String>,
317    /// Resolved entity label, when reported.
318    pub entity_label: Option<String>,
319    /// Source media identifier, when reported.
320    pub media_id: Option<String>,
321}
322
323#[derive(Debug, Clone)]
324/// Resolved subject returned inside a matching response.
325pub struct MatchingResolvedSubject {
326    /// Original source reference preserved as JSON.
327    pub source: Value,
328    /// Resolved stable entity identifier, when available.
329    pub entity_id: Option<String>,
330    /// Resolved display label, when available.
331    pub resolved_label: Option<String>,
332}
333
334#[derive(Debug, Clone)]
335/// Rendered matching output payload.
336pub struct MatchingOutputData {
337    /// Markdown rendering, when available.
338    pub markdown: Option<String>,
339    /// Structured JSON output, when available.
340    pub json: Option<Value>,
341}
342
343#[derive(Debug, Clone)]
344/// Completed matching result record.
345pub struct Matching {
346    /// Matching identifier.
347    pub id: String,
348    /// Matching creation timestamp.
349    pub created_at: OffsetDateTime,
350    /// Matching context.
351    pub context: MatchingContext,
352    /// Rendered output representations.
353    pub output: MatchingOutputData,
354    /// Originating job identifier, when reported.
355    pub job_id: Option<String>,
356    /// Matching label, when reported.
357    pub label: Option<String>,
358    /// Resolved target subject, when reported.
359    pub target: Option<MatchingResolvedSubject>,
360    /// Resolved comparison group.
361    pub group: Vec<MatchingResolvedSubject>,
362}
363
364#[derive(Debug, Clone)]
365/// Minimal media record returned by upload operations.
366pub struct MediaObject {
367    /// Media identifier.
368    pub media_id: String,
369    /// Media creation timestamp.
370    pub created_at: OffsetDateTime,
371    /// Content type recorded for the media.
372    pub content_type: String,
373    /// User-facing label.
374    pub label: String,
375    /// Size in bytes, when reported.
376    pub size_bytes: Option<u64>,
377    /// Duration in seconds, when reported.
378    pub duration_seconds: Option<f64>,
379}
380
381#[derive(Debug, Clone)]
382/// Retention metadata attached to a media record.
383pub struct MediaRetention {
384    /// Expiration timestamp, when reported.
385    pub expires_at: Option<OffsetDateTime>,
386    /// Remaining retention days, when reported.
387    pub days_remaining: Option<u64>,
388    /// Whether a retention lock is active.
389    pub locked: bool,
390}
391
392#[derive(Debug, Clone)]
393/// Detailed media record returned by `media.get()` and `media.list()`.
394pub struct MediaFile {
395    /// Media identifier.
396    pub media_id: String,
397    /// Media creation timestamp.
398    pub created_at: OffsetDateTime,
399    /// Content type recorded for the media.
400    pub content_type: String,
401    /// Whether the media already has associated reports.
402    pub has_reports: bool,
403    /// User-facing label.
404    pub label: String,
405    /// Processing status reported by the API.
406    pub processing_status: String,
407    /// Last usage timestamp, when reported.
408    pub last_used_at: Option<OffsetDateTime>,
409    /// Retention metadata.
410    pub retention: MediaRetention,
411    /// Size in bytes, when reported.
412    pub size_bytes: Option<u64>,
413    /// Duration in seconds, when reported.
414    pub duration_seconds: Option<f64>,
415}
416
417#[derive(Debug, Clone, Deserialize)]
418/// Receipt returned after deleting a media record.
419pub struct FileDeleteReceipt {
420    #[serde(rename = "mediaId")]
421    /// Deleted media identifier.
422    pub media_id: String,
423    /// Whether the delete operation succeeded.
424    pub deleted: bool,
425}
426
427#[derive(Debug, Clone, Deserialize)]
428/// Response returned after updating a media retention lock.
429pub struct RetentionLockResult {
430    #[serde(rename = "mediaId")]
431    /// Media identifier.
432    pub media_id: String,
433    #[serde(rename = "retentionLock")]
434    /// Current retention lock state.
435    pub retention_lock: bool,
436    /// Human-readable API message.
437    pub message: String,
438}
439
440#[derive(Debug, Clone)]
441/// Cursor-paginated media list response.
442pub struct ListFilesResponse {
443    /// Returned media items.
444    pub files: Vec<MediaFile>,
445    /// Whether another page is available.
446    pub has_more: bool,
447    /// Cursor for the next page, when available.
448    pub next_cursor: Option<String>,
449}
450
451#[derive(Debug, Clone)]
452/// Stable entity record.
453pub struct Entity {
454    /// Entity identifier.
455    pub id: String,
456    /// Entity creation timestamp.
457    pub created_at: OffsetDateTime,
458    /// Current entity label, when available.
459    pub label: Option<String>,
460    /// Number of media records linked to the entity.
461    pub media_count: f64,
462    /// Last time the entity was observed, when reported.
463    pub last_seen_at: Option<OffsetDateTime>,
464}
465
466#[derive(Debug, Clone)]
467/// Cursor-paginated entity list response.
468pub struct ListEntitiesResponse {
469    /// Returned entity items.
470    pub entities: Vec<Entity>,
471    /// Whether another page is available.
472    pub has_more: bool,
473    /// Cursor for the next page, when available.
474    pub next_cursor: Option<String>,
475}
476
477#[derive(Debug, Clone)]
478pub(crate) struct JobReceipt {
479    pub job_id: String,
480    pub status: ReceiptStatus,
481    pub stage: Option<JobStage>,
482    pub estimated_wait_sec: Option<f64>,
483}
484
485#[derive(Debug, Deserialize)]
486struct JobWire {
487    id: String,
488    #[serde(rename = "type")]
489    kind: String,
490    status: String,
491    #[serde(rename = "createdAt")]
492    created_at: String,
493    #[serde(rename = "updatedAt")]
494    updated_at: String,
495    stage: Option<String>,
496    progress: Option<f64>,
497    #[serde(rename = "reportId")]
498    report_id: Option<String>,
499    #[serde(rename = "matchingId")]
500    matching_id: Option<String>,
501    usage: Option<UsageWire>,
502    credits: Option<JobCreditsWire>,
503    #[serde(rename = "releasedCredits")]
504    released_credits: Option<f64>,
505    error: Option<JobErrorWire>,
506    #[serde(rename = "requestId")]
507    request_id: Option<String>,
508}
509
510#[derive(Debug, Deserialize)]
511struct JobErrorWire {
512    code: String,
513    message: String,
514    details: Option<Value>,
515    retryable: Option<bool>,
516}
517
518#[derive(Debug, Deserialize)]
519struct UsageWire {
520    #[serde(rename = "creditsUsed")]
521    credits_used: f64,
522    #[serde(rename = "creditsNetUsed")]
523    credits_net_used: f64,
524    #[serde(rename = "creditsDiscounted")]
525    credits_discounted: Option<f64>,
526    #[serde(rename = "durationMs")]
527    duration_ms: Option<f64>,
528    #[serde(rename = "modelVersion")]
529    model_version: Option<String>,
530}
531
532#[derive(Debug, Deserialize)]
533struct JobCreditsWire {
534    #[serde(rename = "reservedCredits")]
535    reserved_credits: Option<f64>,
536    #[serde(rename = "reservationStatus")]
537    reservation_status: Option<String>,
538}
539
540#[derive(Debug, Deserialize)]
541struct JobReceiptWire {
542    #[serde(rename = "jobId")]
543    job_id: String,
544    status: String,
545    stage: Option<String>,
546    #[serde(rename = "estimatedWaitSec")]
547    estimated_wait_sec: Option<f64>,
548}
549
550#[derive(Debug, Deserialize)]
551struct ReportWire {
552    id: String,
553    #[serde(rename = "createdAt")]
554    created_at: String,
555    #[serde(rename = "jobId")]
556    job_id: Option<String>,
557    label: Option<String>,
558    entity: Option<ReportEntityWire>,
559    media: Option<ReportMediaWire>,
560    output: ReportOutputWire,
561    markdown: Option<String>,
562    json: Option<Value>,
563}
564
565#[derive(Debug, Deserialize)]
566struct ReportEntityWire {
567    id: String,
568    label: Option<String>,
569}
570
571#[derive(Debug, Deserialize)]
572struct ReportMediaWire {
573    #[serde(rename = "mediaId")]
574    media_id: Option<String>,
575    url: Option<String>,
576}
577
578#[derive(Debug, Deserialize)]
579struct ReportOutputWire {
580    template: String,
581}
582
583#[derive(Debug, Deserialize)]
584struct MatchingWire {
585    id: String,
586    #[serde(rename = "createdAt")]
587    created_at: String,
588    context: String,
589    #[serde(rename = "jobId")]
590    job_id: Option<String>,
591    label: Option<String>,
592    target: Option<MatchingSubjectWire>,
593    group: Option<Vec<MatchingSubjectWire>>,
594    markdown: Option<String>,
595    json: Option<Value>,
596}
597
598#[derive(Debug, Deserialize)]
599struct MatchingSubjectWire {
600    source: Value,
601    #[serde(rename = "entityId")]
602    entity_id: Option<String>,
603    #[serde(rename = "resolvedLabel")]
604    resolved_label: Option<String>,
605}
606
607#[derive(Debug, Deserialize)]
608struct MediaObjectWire {
609    #[serde(rename = "mediaId")]
610    media_id: String,
611    #[serde(rename = "createdAt")]
612    created_at: String,
613    #[serde(rename = "contentType")]
614    content_type: String,
615    label: String,
616    #[serde(rename = "sizeBytes")]
617    size_bytes: Option<u64>,
618    #[serde(rename = "durationSeconds")]
619    duration_seconds: Option<f64>,
620}
621
622#[derive(Debug, Deserialize)]
623struct MediaFileWire {
624    #[serde(rename = "mediaId")]
625    media_id: String,
626    #[serde(rename = "createdAt")]
627    created_at: String,
628    #[serde(rename = "contentType")]
629    content_type: String,
630    #[serde(rename = "hasReports")]
631    has_reports: bool,
632    label: String,
633    #[serde(rename = "processingStatus")]
634    processing_status: String,
635    #[serde(rename = "lastUsedAt")]
636    last_used_at: Option<String>,
637    retention: MediaRetentionWire,
638    #[serde(rename = "sizeBytes")]
639    size_bytes: Option<u64>,
640    #[serde(rename = "durationSeconds")]
641    duration_seconds: Option<f64>,
642}
643
644#[derive(Debug, Deserialize)]
645struct MediaRetentionWire {
646    #[serde(rename = "expiresAt")]
647    expires_at: Option<String>,
648    #[serde(rename = "daysRemaining")]
649    days_remaining: Option<u64>,
650    locked: bool,
651}
652
653#[derive(Debug, Deserialize)]
654struct ListFilesWire {
655    files: Vec<MediaFileWire>,
656    #[serde(rename = "hasMore")]
657    has_more: bool,
658    #[serde(rename = "nextCursor")]
659    next_cursor: Option<String>,
660}
661
662#[derive(Debug, Deserialize)]
663struct EntityWire {
664    id: String,
665    #[serde(rename = "createdAt")]
666    created_at: String,
667    label: Option<String>,
668    #[serde(rename = "mediaCount")]
669    media_count: f64,
670    #[serde(rename = "lastSeenAt")]
671    last_seen_at: Option<String>,
672}
673
674#[derive(Debug, Deserialize)]
675struct ListEntitiesWire {
676    entities: Vec<EntityWire>,
677    #[serde(rename = "hasMore")]
678    has_more: bool,
679    cursor: Option<String>,
680}
681
682pub(crate) fn parse_job(payload: &[u8]) -> Result<Job> {
683    let wire: JobWire = decode_json(payload)?;
684    Ok(Job {
685        id: response_string(&wire.id, "job.id")?,
686        kind: JobKind::parse(&wire.kind, "job.type")?,
687        status: JobStatus::parse(&wire.status, "job.status")?,
688        created_at: response_datetime(&wire.created_at, "job.createdAt")?,
689        updated_at: response_datetime(&wire.updated_at, "job.updatedAt")?,
690        stage: wire
691            .stage
692            .as_deref()
693            .filter(|value| !value.trim().is_empty())
694            .and_then(parse_job_stage),
695        progress: wire.progress,
696        report_id: wire.report_id.filter(|value| !value.trim().is_empty()),
697        matching_id: wire.matching_id.filter(|value| !value.trim().is_empty()),
698        usage: wire.usage.map(|usage| Usage {
699            credits_used: usage.credits_used,
700            credits_net_used: usage.credits_net_used,
701            credits_discounted: usage.credits_discounted,
702            duration_ms: usage.duration_ms,
703            model_version: usage.model_version.filter(|value| !value.trim().is_empty()),
704        }),
705        credits: wire
706            .credits
707            .map(|credits| -> Result<JobCreditReservation> {
708                Ok(JobCreditReservation {
709                    reserved_credits: credits.reserved_credits,
710                    reservation_status: credits
711                        .reservation_status
712                        .as_deref()
713                        .filter(|value| !value.trim().is_empty())
714                        .map(|value| {
715                            CreditReservationStatus::parse(value, "job.credits.reservationStatus")
716                        })
717                        .transpose()?,
718                })
719            })
720            .transpose()?,
721        released_credits: wire.released_credits,
722        error: wire.error.map(|error| JobErrorData {
723            code: error.code,
724            message: error.message,
725            details: error.details.map(Box::new),
726            retryable: error.retryable,
727        }),
728        request_id: wire.request_id.filter(|value| !value.trim().is_empty()),
729    })
730}
731
732pub(crate) fn parse_job_receipt(payload: &[u8]) -> Result<JobReceipt> {
733    let wire: JobReceiptWire = decode_json(payload)?;
734    Ok(JobReceipt {
735        job_id: response_string(&wire.job_id, "jobReceipt.jobId")?,
736        status: ReceiptStatus::parse(&wire.status, "jobReceipt.status")?,
737        stage: wire
738            .stage
739            .as_deref()
740            .filter(|value| !value.trim().is_empty())
741            .and_then(parse_job_stage),
742        estimated_wait_sec: wire.estimated_wait_sec,
743    })
744}
745
746pub(crate) fn parse_report(payload: &[u8]) -> Result<Report> {
747    let wire: ReportWire = decode_json(payload)?;
748    let media = wire.media.unwrap_or(ReportMediaWire {
749        media_id: None,
750        url: None,
751    });
752    Ok(Report {
753        id: response_string(&wire.id, "report.id")?,
754        created_at: response_datetime(&wire.created_at, "report.createdAt")?,
755        output: ReportOutputData {
756            template: ReportTemplate::parse(&wire.output.template, "report.output.template")?,
757            markdown: wire.markdown.filter(|value| !value.trim().is_empty()),
758            json: wire.json,
759            report_url: media.url.filter(|value| !value.trim().is_empty()),
760        },
761        job_id: wire.job_id.filter(|value| !value.trim().is_empty()),
762        label: wire.label.filter(|value| !value.trim().is_empty()),
763        entity_id: wire
764            .entity
765            .as_ref()
766            .and_then(|entity| (!entity.id.trim().is_empty()).then(|| entity.id.clone())),
767        entity_label: wire
768            .entity
769            .and_then(|entity| entity.label.filter(|value| !value.trim().is_empty())),
770        media_id: media.media_id.filter(|value| !value.trim().is_empty()),
771    })
772}
773
774pub(crate) fn parse_matching(payload: &[u8]) -> Result<Matching> {
775    let wire: MatchingWire = decode_json(payload)?;
776    Ok(Matching {
777        id: response_string(&wire.id, "matching.id")?,
778        created_at: response_datetime(&wire.created_at, "matching.createdAt")?,
779        context: MatchingContext::parse(&wire.context, "matching.context")?,
780        output: MatchingOutputData {
781            markdown: wire.markdown.filter(|value| !value.trim().is_empty()),
782            json: wire.json,
783        },
784        job_id: wire.job_id.filter(|value| !value.trim().is_empty()),
785        label: wire.label.filter(|value| !value.trim().is_empty()),
786        target: wire.target.map(parse_matching_subject),
787        group: wire
788            .group
789            .unwrap_or_default()
790            .into_iter()
791            .map(parse_matching_subject)
792            .collect(),
793    })
794}
795
796pub(crate) fn parse_media_object(payload: &[u8]) -> Result<MediaObject> {
797    let wire: MediaObjectWire = decode_json(payload)?;
798    Ok(MediaObject {
799        media_id: response_string(&wire.media_id, "media.mediaId")?,
800        created_at: response_datetime(&wire.created_at, "media.createdAt")?,
801        content_type: response_string(&wire.content_type, "media.contentType")?,
802        label: response_string(&wire.label, "media.label")?,
803        size_bytes: wire.size_bytes,
804        duration_seconds: wire.duration_seconds,
805    })
806}
807
808pub(crate) fn parse_media_file(payload: &[u8]) -> Result<MediaFile> {
809    let wire: MediaFileWire = decode_json(payload)?;
810    parse_media_file_wire(wire)
811}
812
813fn parse_media_file_wire(wire: MediaFileWire) -> Result<MediaFile> {
814    Ok(MediaFile {
815        media_id: response_string(&wire.media_id, "media.mediaId")?,
816        created_at: response_datetime(&wire.created_at, "media.createdAt")?,
817        content_type: response_string(&wire.content_type, "media.contentType")?,
818        has_reports: wire.has_reports,
819        label: response_string(&wire.label, "media.label")?,
820        processing_status: response_string(&wire.processing_status, "media.processingStatus")?,
821        last_used_at: optional_datetime(wire.last_used_at, "media.lastUsedAt")?,
822        retention: MediaRetention {
823            expires_at: optional_datetime(wire.retention.expires_at, "media.retention.expiresAt")?,
824            days_remaining: wire.retention.days_remaining,
825            locked: wire.retention.locked,
826        },
827        size_bytes: wire.size_bytes,
828        duration_seconds: wire.duration_seconds,
829    })
830}
831
832pub(crate) fn parse_list_files(payload: &[u8]) -> Result<ListFilesResponse> {
833    let wire: ListFilesWire = decode_json(payload)?;
834    let mut files = Vec::with_capacity(wire.files.len());
835    for file in wire.files {
836        files.push(parse_media_file_wire(file)?);
837    }
838    Ok(ListFilesResponse {
839        files,
840        has_more: wire.has_more,
841        next_cursor: wire.next_cursor.filter(|value| !value.trim().is_empty()),
842    })
843}
844
845pub(crate) fn parse_delete_receipt(payload: &[u8]) -> Result<FileDeleteReceipt> {
846    let receipt: FileDeleteReceipt = decode_json(payload)?;
847    response_string(&receipt.media_id, "delete.mediaId")?;
848    Ok(receipt)
849}
850
851pub(crate) fn parse_retention_lock(payload: &[u8]) -> Result<RetentionLockResult> {
852    let result: RetentionLockResult = decode_json(payload)?;
853    response_string(&result.media_id, "retention.mediaId")?;
854    response_string(&result.message, "retention.message")?;
855    Ok(result)
856}
857
858pub(crate) fn parse_entity(payload: &[u8]) -> Result<Entity> {
859    let wire: EntityWire = decode_json(payload)?;
860    parse_entity_wire(wire)
861}
862
863fn parse_entity_wire(wire: EntityWire) -> Result<Entity> {
864    Ok(Entity {
865        id: response_string(&wire.id, "entity.id")?,
866        created_at: response_datetime(&wire.created_at, "entity.createdAt")?,
867        label: wire.label.filter(|value| !value.trim().is_empty()),
868        media_count: wire.media_count,
869        last_seen_at: optional_datetime(wire.last_seen_at, "entity.lastSeenAt")?,
870    })
871}
872
873pub(crate) fn parse_list_entities(payload: &[u8]) -> Result<ListEntitiesResponse> {
874    let wire: ListEntitiesWire = decode_json(payload)?;
875    let mut entities = Vec::with_capacity(wire.entities.len());
876    for entity in wire.entities {
877        entities.push(parse_entity_wire(entity)?);
878    }
879    Ok(ListEntitiesResponse {
880        entities,
881        has_more: wire.has_more,
882        next_cursor: wire.cursor.filter(|value| !value.trim().is_empty()),
883    })
884}
885
886fn parse_matching_subject(wire: MatchingSubjectWire) -> MatchingResolvedSubject {
887    MatchingResolvedSubject {
888        source: wire.source,
889        entity_id: wire.entity_id.filter(|value| !value.trim().is_empty()),
890        resolved_label: wire.resolved_label.filter(|value| !value.trim().is_empty()),
891    }
892}
893
894fn response_string(value: &str, name: &str) -> Result<String> {
895    let trimmed = value.trim();
896    if trimmed.is_empty() {
897        return Err(ConduitError::invalid_response(format!(
898            "invalid {name}: expected string"
899        )));
900    }
901    Ok(trimmed.to_string())
902}
903
904fn response_datetime(value: &str, name: &str) -> Result<OffsetDateTime> {
905    let value = response_string(value, name)?;
906    OffsetDateTime::parse(&value, &Rfc3339).map_err(|error| {
907        ConduitError::invalid_response(format!("invalid {name}: expected ISO8601 string"))
908            .with_source(error)
909    })
910}
911
912fn optional_datetime(value: Option<String>, name: &str) -> Result<Option<OffsetDateTime>> {
913    value
914        .filter(|value| !value.trim().is_empty())
915        .map(|value| response_datetime(&value, name))
916        .transpose()
917}
918
919fn decode_json<T>(payload: &[u8]) -> Result<T>
920where
921    T: for<'de> Deserialize<'de>,
922{
923    serde_json::from_slice(payload)
924        .map_err(|error| ConduitError::invalid_response("invalid JSON response").with_source(error))
925}