1use 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
178 .map(str::to_owned)
179 .or_else(|| endpoint_flow_filter(query));
180 let response = client.search(
181 index_name,
182 json!({
183 "q": query,
184 "filter": effective_filter,
185 "limit": limit,
186 "showRankingScore": true
187 }),
188 )?;
189 println!("{}", serde_json::to_string_pretty(&response)?);
190 Ok(())
191}
192
193fn normalized_http_endpoint_query(query: &str) -> Option<String> {
194 let trimmed = query.trim();
195 if trimmed.is_empty() || trimmed.contains(' ') || trimmed.contains('.') {
196 return None;
197 }
198 let valid = trimmed.contains('/')
199 && trimmed
200 .chars()
201 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-'));
202 if !valid {
203 return None;
204 }
205 if trimmed.starts_with('/') {
206 Some(trimmed.to_owned())
207 } else {
208 Some(format!("/{trimmed}"))
209 }
210}
211
212fn endpoint_flow_filter(query: &str) -> Option<String> {
213 let path = normalized_http_endpoint_query(query)?;
214 let escaped = path.replace('"', "\\\"");
215 Some(format!(
216 "kind = frontend_http_flow AND (normalized_path = \"{escaped}\" OR path_aliases = \"{escaped}\")"
217 ))
218}
219
220pub fn doctor_health(config: &ResolvedConfig) -> Option<Value> {
221 let connection = config.resolve_meili(None, None, false).ok()?;
222 let client = MeiliClient::new(connection).ok()?;
223 client.health().ok()
224}
225
226fn task_uid(value: &Value) -> Result<u64> {
227 value
228 .get("taskUid")
229 .or_else(|| value.get("uid"))
230 .and_then(Value::as_u64)
231 .ok_or_else(|| anyhow!("meilisearch response missing task uid: {value}"))
232}
233
234fn read_project_info(parent: Option<&Path>) -> Result<Option<ProjectInfo>> {
235 let Some(parent) = parent else {
236 return Ok(None);
237 };
238 let path = parent.join("project-info.json");
239 if !path.exists() {
240 return Ok(None);
241 }
242 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
243 Ok(Some(
244 serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?,
245 ))
246}
247
248fn write_project_info(parent: Option<&Path>, project_info: &ProjectInfo) -> Result<()> {
249 let Some(parent) = parent else {
250 return Ok(());
251 };
252 let path = parent.join("project-info.json");
253 fs::write(&path, serde_json::to_vec_pretty(project_info)?)
254 .with_context(|| format!("write {}", path.display()))?;
255 Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260 use super::{endpoint_flow_filter, normalized_http_endpoint_query};
261
262 #[test]
263 fn detects_endpoint_style_queries() {
264 assert_eq!(
265 normalized_http_endpoint_query("auth/login").as_deref(),
266 Some("/auth/login")
267 );
268 assert_eq!(
269 normalized_http_endpoint_query("/auth/check-token").as_deref(),
270 Some("/auth/check-token")
271 );
272 assert!(normalized_http_endpoint_query("login").is_none());
273 assert!(normalized_http_endpoint_query("src/app/page.tsx").is_none());
274 assert!(normalized_http_endpoint_query("auth/login button").is_none());
275 assert_eq!(
276 normalized_http_endpoint_query("auth/login").as_deref(),
277 Some("/auth/login")
278 );
279 assert_eq!(
280 endpoint_flow_filter("/home/search").as_deref(),
281 Some(
282 "kind = frontend_http_flow AND (normalized_path = \"/home/search\" OR path_aliases = \"/home/search\")"
283 )
284 );
285 }
286}