Skip to main content

kimun_notes/server_client/
dto.rs

1//! Wire types mirroring the RAG server's JSON. Defined here (not shared with the
2//! server crate) so the client stays independent of the server build.
3
4use serde::{Deserialize, Serialize};
5
6/// Body of `POST /api/index/docs`.
7#[derive(Debug, Serialize)]
8pub struct IndexDocsRequest {
9    pub vault_id: String,
10    pub docs: Vec<WireDoc>,
11}
12
13/// A note pushed to the server: its path, content hash, and heading sections.
14#[derive(Debug, Clone, Serialize)]
15pub struct WireDoc {
16    pub path: String,
17    pub hash: String,
18    pub sections: Vec<WireSection>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct WireSection {
23    pub title: String,
24    pub text: String,
25}
26
27/// Body of `POST /api/index/delete`.
28#[derive(Debug, Serialize)]
29pub struct DeleteRequest {
30    pub vault_id: String,
31    pub paths: Vec<String>,
32}
33
34/// One prior Q&A pair sent as conversation history on `/api/answer`.
35#[derive(Debug, Clone, Serialize)]
36pub struct HistoryTurn {
37    pub question: String,
38    pub answer: String,
39}
40
41/// Body of `POST /api/embeddings` and `POST /api/answer`.
42#[derive(Debug, Serialize)]
43pub struct QueryRequest {
44    pub vault_id: String,
45    pub query: String,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub context_size: Option<String>,
48    #[serde(skip_serializing_if = "Vec::is_empty")]
49    pub history: Vec<HistoryTurn>,
50}
51
52#[derive(Debug, Deserialize)]
53pub struct EmbeddingsResponse {
54    pub chunks: Vec<ChunkResult>,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58pub struct ChunkResult {
59    pub path: String,
60    pub title: String,
61    pub date: Option<String>,
62    pub content: String,
63    pub hash: String,
64    pub similarity_score: f64,
65    /// The 1-based ordinal the server assigned this chunk: the `[n]` citation
66    /// number for an answer's source, or the rank position for a search hit.
67    /// The pairing contract — a consumer keys citations off this, never off vec
68    /// position. `0` means the field was absent (an older server that predates
69    /// it); the TUI normalizes 0 to the 1-based position at conversion.
70    #[serde(default)]
71    pub ordinal: usize,
72}
73
74/// `GET /health` capability probe.
75#[derive(Debug, Clone, Deserialize)]
76pub struct Health {
77    pub status: String,
78    #[serde(default)]
79    pub reranker: bool,
80    /// The configured embedder provider, or `None` on an *unconfigured* server
81    /// (no embedder → no indexing, no search). Optional for the same
82    /// reason as `llm_provider`: the server sends an explicit `null`.
83    #[serde(default)]
84    pub embedder: Option<String>,
85    /// The configured LLM provider, or `None` on a semantic-only server (no LLM
86    /// → search works, question-answering does not). Must be optional: the
87    /// server sends an explicit `null` here, which a plain `String` field —
88    /// even with `#[serde(default)]` — fails to deserialize, marking a healthy
89    /// semantic-only server as offline.
90    #[serde(default)]
91    pub llm_provider: Option<String>,
92    #[serde(default)]
93    pub auth_required: bool,
94}
95
96/// Response to any job-creating endpoint.
97#[derive(Debug, Deserialize)]
98pub struct JobAccepted {
99    pub job_id: String,
100}
101
102/// `GET /api/job/{id}`.
103#[derive(Debug, Deserialize)]
104pub struct JobStatus {
105    pub status: String,
106    pub result: Option<serde_json::Value>,
107    pub error: Option<String>,
108}
109
110/// The `result` payload of a completed answer job.
111#[derive(Debug, Deserialize)]
112pub struct AnswerResult {
113    pub answer: String,
114    pub sources: Vec<ChunkResult>,
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn query_request_omits_empty_history() {
123        let req = QueryRequest {
124            vault_id: "v".into(),
125            query: "q".into(),
126            context_size: None,
127            history: vec![],
128        };
129        let json = serde_json::to_string(&req).unwrap();
130        assert!(
131            !json.contains("history"),
132            "empty history must not hit the wire: {json}"
133        );
134    }
135
136    #[test]
137    fn query_request_serializes_history_pairs() {
138        let req = QueryRequest {
139            vault_id: "v".into(),
140            query: "q".into(),
141            context_size: None,
142            history: vec![HistoryTurn {
143                question: "q1".into(),
144                answer: "a1".into(),
145            }],
146        };
147        let json = serde_json::to_string(&req).unwrap();
148        assert!(json.contains(r#""history":[{"question":"q1","answer":"a1"}]"#));
149    }
150
151    #[test]
152    fn health_parses_semantic_only_null_llm_provider() {
153        // A semantic-only server sends `llm_provider: null`. The probe must still
154        // parse (server reachable → online), so search stays available even with
155        // no LLM configured.
156        let json = r#"{"status":"ok","reranker":true,"llm_provider":null,"auth_required":false}"#;
157        let health: Health = serde_json::from_str(json).expect("must parse null llm_provider");
158        assert_eq!(health.status, "ok");
159        assert!(health.llm_provider.is_none());
160    }
161
162    #[test]
163    fn health_parses_configured_llm_provider() {
164        let json =
165            r#"{"status":"ok","reranker":false,"llm_provider":"gemini","auth_required":true}"#;
166        let health: Health = serde_json::from_str(json).unwrap();
167        assert_eq!(health.llm_provider.as_deref(), Some("gemini"));
168        assert!(health.auth_required);
169    }
170
171    #[test]
172    fn health_parses_unconfigured_null_embedder() {
173        // An unconfigured server (no embedder) sends embedder: null.
174        let json = r#"{"status":"ok","reranker":true,"embedder":null,"llm_provider":null,"auth_required":false}"#;
175        let health: Health = serde_json::from_str(json).expect("must parse null embedder");
176        assert!(health.embedder.is_none());
177    }
178
179    #[test]
180    fn health_parses_configured_embedder() {
181        let json = r#"{"status":"ok","reranker":true,"embedder":"fastembed","llm_provider":null,"auth_required":false}"#;
182        let health: Health = serde_json::from_str(json).unwrap();
183        assert_eq!(health.embedder.as_deref(), Some("fastembed"));
184    }
185
186    #[test]
187    fn health_tolerates_missing_embedder_field() {
188        // An older server without the field must still parse (probe stays green).
189        let json =
190            r#"{"status":"ok","reranker":true,"llm_provider":"gemini","auth_required":false}"#;
191        let health: Health = serde_json::from_str(json).unwrap();
192        assert!(health.embedder.is_none());
193    }
194
195    #[test]
196    fn chunk_result_parses_the_ordinal_when_present() {
197        let json = r#"{"path":"a.md","title":"t","date":null,"content":"c","hash":"h","similarity_score":0.9,"ordinal":3}"#;
198        let c: ChunkResult = serde_json::from_str(json).unwrap();
199        assert_eq!(c.ordinal, 3);
200    }
201
202    #[test]
203    fn chunk_result_defaults_ordinal_to_zero_when_absent() {
204        // An older server omits `ordinal`; parsing must still succeed and leave
205        // 0 (the "absent" sentinel the TUI turns into a position fallback).
206        let json = r#"{"path":"a.md","title":"t","date":null,"content":"c","hash":"h","similarity_score":0.9}"#;
207        let c: ChunkResult = serde_json::from_str(json).unwrap();
208        assert_eq!(c.ordinal, 0);
209    }
210}