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