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