Skip to main content

source_map_tauri/
meili.rs

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.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}