source_map_tauri/
meili.rs1use std::{fs, path::Path, thread, time::Duration};
2
3use anyhow::{anyhow, Context, Result};
4use reqwest::blocking::Client;
5use serde_json::{json, Value};
6
7use crate::{
8 config::{MeiliConnection, ResolvedConfig},
9 model::ProjectInfo,
10 projects::{upsert_project_registry, ProjectRecord},
11};
12
13#[derive(Debug, Clone)]
14pub struct MeiliClient {
15 client: Client,
16 connection: MeiliConnection,
17}
18
19pub struct UploadRequest<'a> {
20 pub meili_url: Option<&'a str>,
21 pub meili_key: Option<&'a str>,
22 pub index: Option<&'a str>,
23 pub input: &'a Path,
24 pub edges: Option<&'a Path>,
25 pub warnings: Option<&'a Path>,
26 pub wait: bool,
27 pub _batch_size: usize,
28}
29
30impl MeiliClient {
31 pub fn new(connection: MeiliConnection) -> Result<Self> {
32 Ok(Self {
33 client: Client::builder().build()?,
34 connection,
35 })
36 }
37
38 pub fn health(&self) -> Result<Value> {
39 self.get("health")
40 }
41
42 pub fn create_index(&self, name: &str) -> Result<()> {
43 let response = self
44 .client
45 .post(self.url("indexes")?)
46 .bearer_auth(&self.connection.api_key)
47 .json(&json!({ "uid": name, "primaryKey": "id" }))
48 .send()?;
49 if response.status().is_success() || response.status().as_u16() == 409 {
50 return Ok(());
51 }
52 Err(anyhow!(
53 "failed to create index {name}: {}",
54 response.text()?
55 ))
56 }
57
58 pub fn apply_settings(&self, index: &str, settings: &Value, wait: bool) -> Result<()> {
59 let task = self
60 .client
61 .patch(self.url(&format!("indexes/{index}/settings"))?)
62 .bearer_auth(&self.connection.api_key)
63 .json(settings)
64 .send()?
65 .json::<Value>()?;
66 if wait {
67 self.wait_for_task(task_uid(&task)?)?;
68 }
69 Ok(())
70 }
71
72 pub fn replace_documents_ndjson(&self, index: &str, body: Vec<u8>, wait: bool) -> Result<()> {
73 let task = self
74 .client
75 .post(self.url(&format!("indexes/{index}/documents"))?)
76 .bearer_auth(&self.connection.api_key)
77 .header("Content-Type", "application/x-ndjson")
78 .body(body)
79 .send()?
80 .json::<Value>()?;
81 if wait {
82 self.wait_for_task(task_uid(&task)?)?;
83 }
84 Ok(())
85 }
86
87 pub fn search(&self, index: &str, body: Value) -> Result<Value> {
88 Ok(self
89 .client
90 .post(self.url(&format!("indexes/{index}/search"))?)
91 .bearer_auth(&self.connection.api_key)
92 .json(&body)
93 .send()?
94 .json()?)
95 }
96
97 pub fn wait_for_task(&self, uid: u64) -> Result<()> {
98 for _ in 0..50 {
99 let task = self.get(&format!("tasks/{uid}"))?;
100 match task.get("status").and_then(Value::as_str) {
101 Some("succeeded") => return Ok(()),
102 Some("failed") => return Err(anyhow!("meilisearch task {uid} failed: {task}")),
103 _ => thread::sleep(Duration::from_millis(100)),
104 }
105 }
106 Err(anyhow!("timed out waiting for meilisearch task {uid}"))
107 }
108
109 fn get(&self, path: &str) -> Result<Value> {
110 Ok(self
111 .client
112 .get(self.url(path)?)
113 .bearer_auth(&self.connection.api_key)
114 .send()?
115 .json()?)
116 }
117
118 fn url(&self, path: &str) -> Result<reqwest::Url> {
119 self.connection
120 .host
121 .join(path)
122 .with_context(|| format!("join meilisearch path {path}"))
123 }
124}
125
126pub fn upload(config: &ResolvedConfig, request: UploadRequest<'_>) -> Result<()> {
127 let connection = config.resolve_meili(request.meili_url, request.meili_key, false)?;
128 let client = MeiliClient::new(connection.clone())?;
129 let index_name = request.index.unwrap_or(&config.file.meilisearch.index);
130
131 client.create_index(index_name)?;
132
133 let settings_path = request
134 .input
135 .parent()
136 .map(|path| path.join("meili-settings.json"));
137 if let Some(settings_path) = settings_path.filter(|path| path.exists()) {
138 let payload: Value = serde_json::from_str(&fs::read_to_string(&settings_path)?)?;
139 client.apply_settings(index_name, &payload, request.wait)?;
140 }
141
142 for path in [Some(request.input), request.edges, request.warnings]
143 .into_iter()
144 .flatten()
145 {
146 client.replace_documents_ndjson(index_name, fs::read(path)?, request.wait)?;
147 }
148
149 if let Some(mut project_info) = read_project_info(request.input.parent())? {
150 project_info.index_uid = index_name.to_owned();
151 write_project_info(request.input.parent(), &project_info)?;
152 upsert_project_registry(ProjectRecord {
153 name: project_info.repo,
154 repo_path: project_info.repo_path,
155 index_uid: index_name.to_owned(),
156 meili_host: connection.host.to_string(),
157 updated_at: chrono::Utc::now(),
158 })?;
159 }
160
161 println!("upload complete index={index_name}");
162 Ok(())
163}
164
165pub fn search(
166 config: &ResolvedConfig,
167 meili_url: Option<&str>,
168 meili_key: Option<&str>,
169 index: Option<&str>,
170 query: &str,
171 filter: Option<&str>,
172 limit: usize,
173) -> Result<()> {
174 let connection = config.resolve_meili(meili_url, meili_key, true)?;
175 let client = MeiliClient::new(connection)?;
176 let index_name = index.unwrap_or(&config.file.meilisearch.index);
177 let effective_filter = filter.map(str::to_owned).or_else(|| {
178 normalized_http_endpoint_query(query)
179 .map(|path| format!("kind = frontend_http_flow AND normalized_path = \"{path}\""))
180 });
181 let response = client.search(
182 index_name,
183 json!({
184 "q": query,
185 "filter": effective_filter,
186 "limit": limit,
187 "showRankingScore": true
188 }),
189 )?;
190 println!("{}", serde_json::to_string_pretty(&response)?);
191 Ok(())
192}
193
194fn normalized_http_endpoint_query(query: &str) -> Option<String> {
195 let trimmed = query.trim();
196 if trimmed.is_empty() || trimmed.contains(' ') || trimmed.contains('.') {
197 return None;
198 }
199 let valid = trimmed.contains('/')
200 && trimmed
201 .chars()
202 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-'));
203 if !valid {
204 return None;
205 }
206 if trimmed.starts_with('/') {
207 Some(trimmed.to_owned())
208 } else {
209 Some(format!("/{trimmed}"))
210 }
211}
212
213pub fn doctor_health(config: &ResolvedConfig) -> Option<Value> {
214 let connection = config.resolve_meili(None, None, false).ok()?;
215 let client = MeiliClient::new(connection).ok()?;
216 client.health().ok()
217}
218
219fn task_uid(value: &Value) -> Result<u64> {
220 value
221 .get("taskUid")
222 .or_else(|| value.get("uid"))
223 .and_then(Value::as_u64)
224 .ok_or_else(|| anyhow!("meilisearch response missing task uid: {value}"))
225}
226
227fn read_project_info(parent: Option<&Path>) -> Result<Option<ProjectInfo>> {
228 let Some(parent) = parent else {
229 return Ok(None);
230 };
231 let path = parent.join("project-info.json");
232 if !path.exists() {
233 return Ok(None);
234 }
235 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
236 Ok(Some(
237 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?,
238 ))
239}
240
241fn write_project_info(parent: Option<&Path>, project_info: &ProjectInfo) -> Result<()> {
242 let Some(parent) = parent else {
243 return Ok(());
244 };
245 let path = parent.join("project-info.json");
246 fs::write(&path, serde_json::to_vec_pretty(project_info)?)
247 .with_context(|| format!("write {}", path.display()))?;
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::normalized_http_endpoint_query;
254
255 #[test]
256 fn detects_endpoint_style_queries() {
257 assert_eq!(
258 normalized_http_endpoint_query("auth/login").as_deref(),
259 Some("/auth/login")
260 );
261 assert_eq!(
262 normalized_http_endpoint_query("/auth/check-token").as_deref(),
263 Some("/auth/check-token")
264 );
265 assert!(normalized_http_endpoint_query("login").is_none());
266 assert!(normalized_http_endpoint_query("src/app/page.tsx").is_none());
267 assert!(normalized_http_endpoint_query("auth/login button").is_none());
268 assert_eq!(
269 normalized_http_endpoint_query("auth/login").as_deref(),
270 Some("/auth/login")
271 );
272 }
273}