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    /// The server's own version. `None` means a server that predates version
95    /// reporting — necessarily outdated.
96    #[serde(default)]
97    pub version: Option<String>,
98    /// A newer stable server release, when the server's own update check found
99    /// one; `None` otherwise (and on servers too old to report it).
100    #[serde(default)]
101    pub latest_version: Option<String>,
102}
103
104/// Response to any job-creating endpoint.
105#[derive(Debug, Deserialize)]
106pub struct JobAccepted {
107    pub job_id: String,
108}
109
110/// `GET /api/job/{id}`.
111#[derive(Debug, Deserialize)]
112pub struct JobStatus {
113    pub status: String,
114    pub result: Option<serde_json::Value>,
115    pub error: Option<String>,
116}
117
118/// The `result` payload of a completed answer job.
119#[derive(Debug, Deserialize)]
120pub struct AnswerResult {
121    pub answer: String,
122    pub sources: Vec<ChunkResult>,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn query_request_omits_empty_history() {
131        let req = QueryRequest {
132            vault_id: "v".into(),
133            query: "q".into(),
134            context_size: None,
135            history: vec![],
136        };
137        let json = serde_json::to_string(&req).unwrap();
138        assert!(
139            !json.contains("history"),
140            "empty history must not hit the wire: {json}"
141        );
142    }
143
144    #[test]
145    fn query_request_serializes_history_pairs() {
146        let req = QueryRequest {
147            vault_id: "v".into(),
148            query: "q".into(),
149            context_size: None,
150            history: vec![HistoryTurn {
151                question: "q1".into(),
152                answer: "a1".into(),
153            }],
154        };
155        let json = serde_json::to_string(&req).unwrap();
156        assert!(json.contains(r#""history":[{"question":"q1","answer":"a1"}]"#));
157    }
158
159    #[test]
160    fn health_parses_semantic_only_null_llm_provider() {
161        // A semantic-only server sends `llm_provider: null`. The probe must still
162        // parse (server reachable → online), so search stays available even with
163        // no LLM configured.
164        let json = r#"{"status":"ok","reranker":true,"llm_provider":null,"auth_required":false}"#;
165        let health: Health = serde_json::from_str(json).expect("must parse null llm_provider");
166        assert_eq!(health.status, "ok");
167        assert!(health.llm_provider.is_none());
168    }
169
170    #[test]
171    fn health_parses_configured_llm_provider() {
172        let json =
173            r#"{"status":"ok","reranker":false,"llm_provider":"gemini","auth_required":true}"#;
174        let health: Health = serde_json::from_str(json).unwrap();
175        assert_eq!(health.llm_provider.as_deref(), Some("gemini"));
176        assert!(health.auth_required);
177    }
178
179    #[test]
180    fn health_parses_unconfigured_null_embedder() {
181        // An unconfigured server (no embedder) sends embedder: null.
182        let json = r#"{"status":"ok","reranker":true,"embedder":null,"llm_provider":null,"auth_required":false}"#;
183        let health: Health = serde_json::from_str(json).expect("must parse null embedder");
184        assert!(health.embedder.is_none());
185    }
186
187    #[test]
188    fn health_parses_configured_embedder() {
189        let json = r#"{"status":"ok","reranker":true,"embedder":"fastembed","llm_provider":null,"auth_required":false}"#;
190        let health: Health = serde_json::from_str(json).unwrap();
191        assert_eq!(health.embedder.as_deref(), Some("fastembed"));
192    }
193
194    #[test]
195    fn health_tolerates_missing_embedder_field() {
196        // An older server without the field must still parse (probe stays green).
197        let json =
198            r#"{"status":"ok","reranker":true,"llm_provider":"gemini","auth_required":false}"#;
199        let health: Health = serde_json::from_str(json).unwrap();
200        assert!(health.embedder.is_none());
201    }
202
203    #[test]
204    fn chunk_result_parses_the_ordinal_when_present() {
205        let json = r#"{"path":"a.md","title":"t","date":null,"content":"c","hash":"h","similarity_score":0.9,"ordinal":3}"#;
206        let c: ChunkResult = serde_json::from_str(json).unwrap();
207        assert_eq!(c.ordinal, 3);
208    }
209
210    #[test]
211    fn chunk_result_defaults_ordinal_to_zero_when_absent() {
212        // An older server omits `ordinal`; parsing must still succeed and leave
213        // 0 (the "absent" sentinel the TUI turns into a position fallback).
214        let json = r#"{"path":"a.md","title":"t","date":null,"content":"c","hash":"h","similarity_score":0.9}"#;
215        let c: ChunkResult = serde_json::from_str(json).unwrap();
216        assert_eq!(c.ordinal, 0);
217    }
218}