Skip to main content

wenlan_types/
requests.rs

1// SPDX-License-Identifier: Apache-2.0
2//! API request types for all HTTP endpoints.
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7// ===== Memory CRUD =====
8
9#[derive(Debug, Serialize, Deserialize)]
10pub struct StoreMemoryRequest {
11    pub content: String,
12    #[serde(default)]
13    pub memory_type: Option<String>,
14    #[serde(default, alias = "domain")]
15    pub space: Option<String>,
16    #[serde(default)]
17    pub source_agent: Option<String>,
18    #[serde(default)]
19    pub title: Option<String>,
20    #[serde(default)]
21    pub confidence: Option<f32>,
22    #[serde(default)]
23    pub supersedes: Option<String>,
24    /// Entity name for resolution (e.g. "Alice", "PostgreSQL")
25    #[serde(default)]
26    pub entity: Option<String>,
27    /// Direct entity ID (bypasses name resolution)
28    #[serde(default)]
29    pub entity_id: Option<String>,
30    #[serde(default)]
31    pub structured_fields: Option<serde_json::Value>,
32    #[serde(default)]
33    pub retrieval_cue: Option<String>,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37pub struct SearchMemoryRequest {
38    pub query: String,
39    #[serde(default = "default_limit")]
40    pub limit: usize,
41    #[serde(default)]
42    pub memory_type: Option<String>,
43    #[serde(default, alias = "domain")]
44    pub space: Option<String>,
45    #[serde(default)]
46    pub source_agent: Option<String>,
47    /// When `true` AND the daemon has a reranker wired (via
48    /// `WENLAN_RERANKER_ENABLED=1`), results pass through a cross-encoder
49    /// reranker after the embedding+FTS hybrid step. When `true` but no
50    /// reranker is available, the daemon logs a warning and falls back to
51    /// the plain hybrid ordering. Default `false` to preserve current
52    /// behavior for callers that don't opt in.
53    #[serde(default)]
54    pub rerank: bool,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58pub struct ListMemoriesRequest {
59    #[serde(default)]
60    pub memory_type: Option<String>,
61    #[serde(default, alias = "domain")]
62    pub space: Option<String>,
63    #[serde(default = "default_list_limit")]
64    pub limit: usize,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub confirmed: Option<bool>,
67}
68
69#[derive(Debug, Serialize, Deserialize)]
70pub struct ConfirmRequest {
71    #[serde(default = "default_confirmed")]
72    pub confirmed: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct ReclassifyMemoryRequest {
77    pub memory_type: String,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81pub struct ImportMemoriesRequest {
82    pub source: String,
83    pub content: String,
84    #[serde(default)]
85    pub label: Option<String>,
86}
87
88// ===== General search/context =====
89
90#[derive(Debug, Serialize, Deserialize)]
91pub struct SearchRequest {
92    pub query: String,
93    #[serde(default = "default_limit")]
94    pub limit: usize,
95    pub source_filter: Option<String>,
96    #[serde(default, alias = "domain")]
97    pub space: Option<String>,
98}
99
100#[doc(hidden)]
101#[derive(Debug, Serialize, Deserialize)]
102pub struct ContextRequest {
103    pub current_file: String,
104    pub cursor_prefix: String,
105    #[serde(default = "default_limit")]
106    pub limit: usize,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
110pub struct ChatContextRequest {
111    #[serde(default)]
112    pub query: Option<String>,
113    #[serde(default)]
114    pub conversation_id: Option<String>,
115    #[serde(default = "default_max_chunks")]
116    pub max_chunks: usize,
117    #[serde(default)]
118    pub relevance_threshold: Option<f64>,
119    /// Deprecated: goal-typed memories were folded into identity by migration 45
120    /// (Phase 0). Daemon ignores this field — no goal-load path exists anymore.
121    /// Field stays for wire backward compat; will be removed in 0.4.
122    #[deprecated(
123        since = "0.3.2",
124        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
125                Daemon ignores this field. Will be removed in 0.4."
126    )]
127    #[serde(default = "default_true")]
128    pub include_goals: bool,
129    #[serde(default, alias = "domain")]
130    pub space: Option<String>,
131}
132
133// ===== Knowledge graph =====
134
135#[derive(Debug, Serialize, Deserialize)]
136pub struct CreateEntityRequest {
137    pub name: String,
138    pub entity_type: String,
139    #[serde(default, alias = "domain")]
140    pub space: Option<String>,
141    #[serde(default)]
142    pub source_agent: Option<String>,
143    #[serde(default)]
144    pub confidence: Option<f32>,
145}
146
147#[doc(hidden)]
148#[derive(Debug, Serialize, Deserialize)]
149pub struct CreateRelationRequest {
150    pub from_entity: String,
151    pub to_entity: String,
152    pub relation_type: String,
153    #[serde(default)]
154    pub source_agent: Option<String>,
155    #[serde(default)]
156    pub confidence: Option<f64>,
157    #[serde(default)]
158    pub explanation: Option<String>,
159    #[serde(default)]
160    pub source_memory_id: Option<String>,
161}
162
163#[derive(Debug, Serialize, Deserialize)]
164pub struct AddObservationRequest {
165    pub entity_id: String,
166    pub content: String,
167    #[serde(default)]
168    pub source_agent: Option<String>,
169    #[serde(default)]
170    pub confidence: Option<f32>,
171}
172
173#[doc(hidden)]
174#[derive(Debug, Serialize, Deserialize)]
175pub struct LinkEntityRequest {
176    pub source_id: String,
177    pub entity_id: String,
178}
179
180#[derive(Debug, Serialize, Deserialize)]
181pub struct ListEntitiesRequest {
182    #[serde(default)]
183    pub entity_type: Option<String>,
184    #[serde(default, alias = "domain")]
185    pub space: Option<String>,
186}
187
188#[derive(Debug, Serialize, Deserialize)]
189pub struct SearchEntitiesRequest {
190    pub query: String,
191    #[serde(default = "default_entity_search_limit")]
192    pub limit: usize,
193}
194
195// ===== Profile & Agents =====
196
197#[derive(Debug, Serialize, Deserialize)]
198pub struct UpdateProfileRequest {
199    #[serde(default)]
200    pub name: Option<String>,
201    #[serde(default)]
202    pub display_name: Option<String>,
203    #[serde(default)]
204    pub email: Option<String>,
205    #[serde(default)]
206    pub bio: Option<String>,
207    #[serde(default)]
208    pub avatar_path: Option<String>,
209}
210
211#[derive(Debug, Serialize, Deserialize)]
212pub struct UpdateAgentRequest {
213    #[serde(default)]
214    pub agent_type: Option<String>,
215    #[serde(default)]
216    pub description: Option<String>,
217    #[serde(default)]
218    pub enabled: Option<bool>,
219    #[serde(default)]
220    pub trust_level: Option<String>,
221    /// Empty string clears the field; None leaves it unchanged.
222    #[serde(default)]
223    pub display_name: Option<String>,
224}
225
226// ===== Spaces =====
227
228#[derive(Debug, Serialize, Deserialize)]
229pub struct CreateSpaceRequest {
230    pub name: String,
231    pub description: Option<String>,
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235pub struct UpdateSpaceRequest {
236    pub new_name: Option<String>,
237    pub description: Option<String>,
238}
239
240// ===== Concepts =====
241
242#[doc(hidden)]
243#[derive(Debug, Serialize, Deserialize)]
244pub struct CreateConceptRequest {
245    pub title: String,
246    pub content: String,
247    #[serde(default)]
248    pub summary: Option<String>,
249    #[serde(default)]
250    pub entity_id: Option<String>,
251    #[serde(default, alias = "domain")]
252    pub space: Option<String>,
253    #[serde(default)]
254    pub source_memory_ids: Vec<String>,
255    #[serde(default)]
256    pub creation_kind: Option<String>,
257    /// Dedicated workspace axis (P3). When Some, persisted to `pages.workspace`.
258    /// Distinct from `space` (category column used for page_type filtering).
259    #[serde(default)]
260    pub workspace: Option<String>,
261}
262
263/// First durable snapshot for a human-authored Page draft.
264///
265/// The client only sends this request once either `title` or `content` is
266/// meaningful. Both fields default to empty so title-first and body-first
267/// writing flows share one wire shape.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct CreatePageDraftRequest {
270    /// Stable client-generated id used to make an ambiguous create retry safe.
271    pub draft_id: String,
272    pub title: String,
273    pub content: String,
274    pub space: Option<String>,
275    space_provided: bool,
276}
277
278impl CreatePageDraftRequest {
279    /// Build a request with an explicit `space` value.
280    ///
281    /// Passing `None` serializes as `"space": null`.
282    pub fn new(draft_id: String, title: String, content: String, space: Option<String>) -> Self {
283        Self {
284            draft_id,
285            title,
286            content,
287            space,
288            space_provided: true,
289        }
290    }
291
292    /// Build a request that omits `space`, allowing the server to inherit the
293    /// `X-Wenlan-Space` request header.
294    pub fn new_inheriting_header_space(draft_id: String, title: String, content: String) -> Self {
295        Self {
296            draft_id,
297            title,
298            content,
299            space: None,
300            space_provided: false,
301        }
302    }
303
304    /// Whether the JSON body contained a `space` key.
305    ///
306    /// This distinguishes an omitted key (inherit the request header) from an
307    /// explicit `null` (clear the header-provided Space).
308    pub fn space_was_provided(&self) -> bool {
309        self.space_provided
310    }
311}
312
313impl Serialize for CreatePageDraftRequest {
314    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
315    where
316        S: serde::Serializer,
317    {
318        use serde::ser::SerializeStruct;
319
320        let field_count = if self.space_provided { 4 } else { 3 };
321        let mut state = serializer.serialize_struct("CreatePageDraftRequest", field_count)?;
322        state.serialize_field("draft_id", &self.draft_id)?;
323        state.serialize_field("title", &self.title)?;
324        state.serialize_field("content", &self.content)?;
325        if self.space_provided {
326            state.serialize_field("space", &self.space)?;
327        }
328        state.end()
329    }
330}
331
332impl<'de> Deserialize<'de> for CreatePageDraftRequest {
333    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
334    where
335        D: serde::Deserializer<'de>,
336    {
337        #[derive(Deserialize)]
338        struct Wire {
339            draft_id: String,
340            #[serde(default)]
341            title: String,
342            #[serde(default)]
343            content: String,
344            #[serde(default, deserialize_with = "double_option")]
345            space: Option<Option<String>>,
346        }
347
348        let wire = Wire::deserialize(deserializer)?;
349        let (space_provided, space) = match wire.space {
350            Some(space) => (true, space),
351            None => (false, None),
352        };
353        Ok(Self {
354            draft_id: wire.draft_id,
355            title: wire.title,
356            content: wire.content,
357            space,
358            space_provided,
359        })
360    }
361}
362
363/// Complete replacement snapshot for an existing Page draft.
364#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
365pub struct UpdatePageDraftRequest {
366    pub expected_version: i64,
367    pub title: String,
368    pub content: String,
369    pub space: Option<String>,
370}
371
372impl<'de> Deserialize<'de> for UpdatePageDraftRequest {
373    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
374    where
375        D: serde::Deserializer<'de>,
376    {
377        #[derive(Deserialize)]
378        struct Wire {
379            expected_version: i64,
380            title: String,
381            content: String,
382            #[serde(default, deserialize_with = "double_option")]
383            space: Option<Option<String>>,
384        }
385
386        let wire = Wire::deserialize(deserializer)?;
387        let space = wire
388            .space
389            .ok_or_else(|| serde::de::Error::missing_field("space"))?;
390        Ok(Self {
391            expected_version: wire.expected_version,
392            title: wire.title,
393            content: wire.content,
394            space,
395        })
396    }
397}
398
399/// Optimistic-concurrency body shared by draft publish and discard.
400#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
401pub struct PageDraftVersionRequest {
402    pub expected_version: i64,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406#[serde(tag = "action", rename_all = "snake_case")]
407pub enum AcceptRefinementRequest {
408    Accept {
409        #[serde(default, skip_serializing_if = "Option::is_none")]
410        notes: Option<String>,
411    },
412    PickSpace {
413        space: String,
414        #[serde(default, skip_serializing_if = "Option::is_none")]
415        notes: Option<String>,
416    },
417}
418
419#[derive(Debug, Serialize, Deserialize)]
420pub struct SearchPagesRequest {
421    pub query: String,
422    #[serde(default)]
423    pub limit: Option<usize>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub page_type: Option<String>,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub space: Option<String>,
428}
429
430// ===== Ingest =====
431
432#[derive(Debug, Serialize, Deserialize)]
433pub struct IngestTextRequest {
434    pub source: String,
435    pub source_id: String,
436    pub title: String,
437    pub content: String,
438    pub url: Option<String>,
439    pub metadata: Option<HashMap<String, String>>,
440}
441
442#[doc(hidden)]
443#[derive(Debug, Serialize, Deserialize)]
444pub struct IngestWebpageRequest {
445    pub url: String,
446    pub title: String,
447    pub content: String,
448    pub metadata: Option<HashMap<String, String>>,
449}
450
451#[derive(Debug, Serialize, Deserialize)]
452pub struct IngestMemoryRequest {
453    pub source: String,
454    pub source_id: String,
455    pub title: String,
456    pub content: String,
457    pub url: Option<String>,
458    pub tags: Option<Vec<String>>,
459    pub metadata: Option<HashMap<String, String>>,
460}
461
462// ===== Sources =====
463
464#[doc(hidden)]
465#[derive(Debug, Serialize, Deserialize)]
466pub struct AddSourceRequest {
467    pub source_type: String,
468    /// Filesystem path as a string. Kept as `String` (not `PathBuf`) because
469    /// this is an HTTP wire format — the handler converts it to `PathBuf`.
470    pub path: String,
471}
472
473// ===== Config =====
474
475/// Distinguishes an omitted JSON field (outer None) from an explicit `null`
476/// (Some(None)). Used for presence-sensitive wire fields.
477fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
478where
479    T: serde::Deserialize<'de>,
480    D: serde::Deserializer<'de>,
481{
482    serde::Deserialize::deserialize(de).map(Some)
483}
484
485#[derive(Debug, Serialize, Deserialize)]
486pub struct UpdateConfigRequest {
487    #[serde(default)]
488    pub skip_apps: Option<Vec<String>>,
489    #[serde(default)]
490    pub skip_title_patterns: Option<Vec<String>>,
491    #[serde(default)]
492    pub private_browsing_detection: Option<bool>,
493    #[serde(default)]
494    pub setup_completed: Option<bool>,
495    #[serde(default)]
496    pub clipboard_enabled: Option<bool>,
497    #[serde(default)]
498    pub screen_capture_enabled: Option<bool>,
499    #[serde(default)]
500    pub remote_access_enabled: Option<bool>,
501    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
502    #[serde(default)]
503    pub routine_model: Option<String>,
504    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
505    #[serde(default)]
506    pub synthesis_model: Option<String>,
507    /// Base URL for an OpenAI-compatible external LLM endpoint.
508    #[serde(default)]
509    pub external_llm_endpoint: Option<String>,
510    /// Model identifier to use with the external LLM endpoint.
511    #[serde(default)]
512    pub external_llm_model: Option<String>,
513    /// API key for the external endpoint. Tri-state: omitted = preserve stored
514    /// key; `null` or `""` = clear; non-empty = replace. Never echoed back.
515    #[serde(
516        default,
517        deserialize_with = "double_option",
518        skip_serializing_if = "Option::is_none"
519    )]
520    pub external_llm_api_key: Option<Option<String>>,
521    /// Per-job source pin for everyday work: `"anthropic"` | `"external"` |
522    /// `"on_device"`. Omitted = preserve; `""` = clear; other values are
523    /// validated by the config route.
524    #[serde(default)]
525    pub everyday_source: Option<String>,
526    /// Per-job source pin for synthesis: `"anthropic"` | `"external"`
527    /// (`"on_device"` only when the compile gate is set). Omitted = preserve;
528    /// `""` = clear; validated by the config route.
529    #[serde(default)]
530    pub synthesis_source: Option<String>,
531    /// Gates the proactive Page-Map suggestion phase in the scheduler. Omitted =
532    /// preserve stored value; present = set. Never gates the explicit improve route.
533    #[serde(default)]
534    pub page_map_auto_suggest: Option<bool>,
535}
536
537// ===== Chunks / indexed files =====
538
539#[derive(Debug, Serialize, Deserialize)]
540pub struct DeleteByTimeRangeRequest {
541    pub start: i64,
542    pub end: i64,
543}
544
545#[derive(Debug, Serialize, Deserialize)]
546pub struct BulkDeleteItem {
547    pub source: String,
548    pub source_id: String,
549}
550
551#[derive(Debug, Serialize, Deserialize)]
552pub struct BulkDeleteRequest {
553    pub items: Vec<BulkDeleteItem>,
554}
555
556#[derive(Debug, Serialize, Deserialize)]
557pub struct UpdateChunkRequest {
558    pub content: String,
559}
560
561// ===== Entity / Observation CRUD =====
562
563#[derive(Debug, Serialize, Deserialize)]
564pub struct ConfirmEntityRequest {
565    #[serde(default = "default_confirmed")]
566    pub confirmed: bool,
567}
568
569#[derive(Debug, Serialize, Deserialize)]
570pub struct AddEntityObservationRequest {
571    pub content: String,
572    #[serde(default)]
573    pub source_agent: Option<String>,
574    #[serde(default)]
575    pub confidence: Option<f32>,
576}
577
578#[derive(Debug, Serialize, Deserialize)]
579pub struct UpdateObservationRequest {
580    pub content: String,
581}
582
583#[derive(Debug, Serialize, Deserialize)]
584pub struct ConfirmObservationRequest {
585    #[serde(default = "default_confirmed")]
586    pub confirmed: bool,
587}
588
589// ===== Spaces extended =====
590
591#[derive(Debug, Serialize, Deserialize)]
592pub struct ReorderSpaceRequest {
593    pub name: String,
594    pub new_order: i64,
595}
596
597#[derive(Debug, Serialize, Deserialize)]
598pub struct SetDocumentSpaceRequest {
599    pub space_name: String,
600}
601
602// ===== Tags =====
603
604#[derive(Debug, Serialize, Deserialize)]
605pub struct SetDocumentTagsRequest {
606    #[serde(default)]
607    pub source: Option<String>,
608    pub tags: Vec<String>,
609}
610
611// ===== Memory update =====
612
613#[derive(Debug, Serialize, Deserialize)]
614pub struct UpdateMemoryRequest {
615    #[serde(default)]
616    pub content: Option<String>,
617    #[serde(default, alias = "domain")]
618    pub space: Option<String>,
619    #[serde(default)]
620    pub confirmed: Option<bool>,
621    #[serde(default)]
622    pub memory_type: Option<String>,
623}
624
625#[derive(Debug, Serialize, Deserialize)]
626pub struct SetStabilityRequest {
627    pub stability: String,
628}
629
630#[derive(Debug, Serialize, Deserialize)]
631pub struct CorrectMemoryRequest {
632    pub correction_prompt: String,
633}
634
635// ===== Concepts update =====
636
637#[derive(Debug, Serialize, Deserialize)]
638pub struct UpdatePageRequest {
639    pub content: String,
640    /// Source memory IDs to associate with this page version.
641    /// Omitted or empty by HTTP callers that preserve existing sources;
642    /// always populated by `post_write::update_page`.
643    #[serde(default)]
644    pub source_memory_ids: Vec<String>,
645    /// Optimistic-concurrency guard for the M0 write gate. `Some(v)` lands the
646    /// write only while the stored page is still at version `v`; a mismatch is
647    /// refused instead of overwriting whatever landed in between.
648    ///
649    /// Omitted by legacy clients, in which case the server guards on the version
650    /// it loaded to make the ownership decision — so the decision and the write
651    /// always describe the same row.
652    #[serde(default)]
653    pub expected_version: Option<i64>,
654    /// Retry identity. Sent together, `caller_id` and `operation_id` make the
655    /// write replayable: the same pair with the same request replays the
656    /// stored response instead of writing again, and the same pair with a
657    /// different request is a conflict. This is what turns "the response was
658    /// lost, the client retried" into a no-op rather than a second version.
659    ///
660    /// Either one alone is ignored — an operation id is only meaningful
661    /// within the caller that minted it.
662    #[serde(default)]
663    pub caller_id: Option<String>,
664    #[serde(default)]
665    pub operation_id: Option<String>,
666}
667
668/// Body for `PUT /api/pages/{id}` — agent-side refresh of a stale page.
669///
670/// Distinct from `UpdatePageRequest` (manual content edit via POST) because:
671///  - `source_memory_ids` is replaced, not preserved.
672///  - `summary` is optionally updated.
673///  - The handler clears `stale_reason` in the same transaction.
674///
675/// v1 deliberately excludes title / entity_id / space changes — slug rename
676/// has its own concurrent-read failure mode and lands as a separate route.
677#[derive(Debug, Serialize, Deserialize)]
678pub struct RefreshPageRequest {
679    pub content: String,
680    pub source_memory_ids: Vec<String>,
681    #[serde(default)]
682    pub summary: Option<String>,
683}
684
685// ===== Concept Export =====
686
687/// Request body for `POST /api/pages/export` (bulk export all pages to an Obsidian vault).
688#[derive(Debug, Deserialize, Serialize)]
689pub struct ExportPagesRequest {
690    pub vault_path: Option<String>,
691}
692
693#[derive(Debug, Deserialize, Serialize)]
694pub struct ExportPageRequest {
695    pub vault_path: String,
696}
697
698// ===== LLM test =====
699
700/// `POST /api/llm/test` — probe an OpenAI-compatible LLM endpoint with a 1-shot prompt.
701/// Used by the app settings UI to validate a custom endpoint before saving.
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct TestLlmRequest {
704    pub endpoint: String,
705    pub model: String,
706    /// Optional override prompt. Defaults to "Say 'hello' and nothing else." server-side.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub prompt: Option<String>,
709    /// Optional bearer key for this probe only — not persisted.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub api_key: Option<String>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct TestLlmResponse {
716    pub response: String,
717}
718
719// ===== On-device model =====
720
721/// Body for `POST /api/on-device-model/download`.
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
723pub struct OnDeviceModelRequest {
724    pub model_id: String,
725}
726
727// ===== Default value functions =====
728
729fn default_limit() -> usize {
730    10
731}
732
733fn default_list_limit() -> usize {
734    100
735}
736
737fn default_confirmed() -> bool {
738    true
739}
740
741fn default_max_chunks() -> usize {
742    5
743}
744
745fn default_true() -> bool {
746    true
747}
748
749fn default_entity_search_limit() -> usize {
750    20
751}
752
753#[cfg(test)]
754mod search_pages_page_type_test {
755    use super::*;
756
757    #[test]
758    fn search_pages_request_accepts_page_type() {
759        let json = r#"{"query":"foo","limit":10,"page_type":"recap"}"#;
760        let parsed: SearchPagesRequest = serde_json::from_str(json).unwrap();
761        assert_eq!(parsed.page_type.as_deref(), Some("recap"));
762    }
763
764    #[test]
765    fn search_pages_request_page_type_optional() {
766        let json = r#"{"query":"foo","limit":10}"#;
767        let parsed: SearchPagesRequest = serde_json::from_str(json).unwrap();
768        assert!(parsed.page_type.is_none());
769    }
770
771    #[test]
772    fn search_pages_request_accepts_optional_space() {
773        let json = r#"{"query":"foo","space":"work"}"#;
774        let parsed: SearchPagesRequest = serde_json::from_str(json).unwrap();
775        assert_eq!(parsed.space.as_deref(), Some("work"));
776
777        let omitted: SearchPagesRequest = serde_json::from_str(r#"{"query":"foo"}"#).unwrap();
778        assert!(omitted.space.is_none());
779    }
780}
781
782#[cfg(test)]
783mod list_memories_confirmed_test {
784    use super::*;
785
786    #[test]
787    fn confirmed_false_serializes_to_json() {
788        let req = ListMemoriesRequest {
789            memory_type: None,
790            space: None,
791            limit: 20,
792            confirmed: Some(false),
793        };
794        let s = serde_json::to_string(&req).unwrap();
795        assert!(s.contains("\"confirmed\":false"), "got: {s}");
796    }
797
798    #[test]
799    fn confirmed_none_skips_serialization() {
800        let req = ListMemoriesRequest {
801            memory_type: None,
802            space: None,
803            limit: 20,
804            confirmed: None,
805        };
806        let s = serde_json::to_string(&req).unwrap();
807        assert!(!s.contains("confirmed"), "got: {s}");
808    }
809}
810
811#[cfg(test)]
812mod set_document_tags_test {
813    use super::*;
814
815    #[test]
816    fn set_document_tags_request_accepts_optional_source() {
817        let req: SetDocumentTagsRequest =
818            serde_json::from_str(r#"{"source":"manual","tags":["rust"]}"#).unwrap();
819
820        assert_eq!(req.source.as_deref(), Some("manual"));
821        assert_eq!(req.tags, vec!["rust"]);
822    }
823
824    #[test]
825    fn set_document_tags_request_defaults_source_for_old_payloads() {
826        let req: SetDocumentTagsRequest = serde_json::from_str(r#"{"tags":["rust"]}"#).unwrap();
827
828        assert!(req.source.is_none());
829        assert_eq!(req.tags, vec!["rust"]);
830    }
831}
832
833#[cfg(test)]
834mod on_device_model_request_test {
835    use super::*;
836
837    #[test]
838    fn on_device_model_request_round_trips_model_id() {
839        let req: OnDeviceModelRequest = serde_json::from_str(r#"{"model_id":"qwen3-4b"}"#).unwrap();
840
841        assert_eq!(req.model_id, "qwen3-4b");
842        assert_eq!(
843            serde_json::to_string(&req).unwrap(),
844            r#"{"model_id":"qwen3-4b"}"#
845        );
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn update_config_request_external_key_tristate() {
855        let r: UpdateConfigRequest = serde_json::from_str("{}").unwrap();
856        assert_eq!(r.external_llm_api_key, None); // omitted
857        let r: UpdateConfigRequest =
858            serde_json::from_str(r#"{"external_llm_api_key":null}"#).unwrap();
859        assert_eq!(r.external_llm_api_key, Some(None)); // explicit null
860        let r: UpdateConfigRequest =
861            serde_json::from_str(r#"{"external_llm_api_key":"sk-x"}"#).unwrap();
862        assert_eq!(r.external_llm_api_key, Some(Some("sk-x".to_string())));
863    }
864
865    #[test]
866    fn test_llm_request_api_key_optional() {
867        let r: TestLlmRequest =
868            serde_json::from_str(r#"{"endpoint":"http://x","model":"m"}"#).unwrap();
869        assert!(r.api_key.is_none());
870    }
871}