source-map-tauri 0.4.0

Static Tauri app scanner that emits Meilisearch-ready NDJSON
Documentation
use std::{fs, path::Path, thread, time::Duration};

use anyhow::{anyhow, Context, Result};
use reqwest::blocking::Client;
use serde_json::{json, Value};

use crate::{
    config::{MeiliConnection, ResolvedConfig},
    model::ProjectInfo,
    projects::{upsert_project_registry, ProjectRecord},
};

#[derive(Debug, Clone)]
pub struct MeiliClient {
    client: Client,
    connection: MeiliConnection,
}

pub struct UploadRequest<'a> {
    pub meili_url: Option<&'a str>,
    pub meili_key: Option<&'a str>,
    pub index: Option<&'a str>,
    pub input: &'a Path,
    pub edges: Option<&'a Path>,
    pub warnings: Option<&'a Path>,
    pub wait: bool,
    pub _batch_size: usize,
}

impl MeiliClient {
    pub fn new(connection: MeiliConnection) -> Result<Self> {
        Ok(Self {
            client: Client::builder().build()?,
            connection,
        })
    }

    pub fn health(&self) -> Result<Value> {
        self.get("health")
    }

    pub fn create_index(&self, name: &str) -> Result<()> {
        let response = self
            .client
            .post(self.url("indexes")?)
            .bearer_auth(&self.connection.api_key)
            .json(&json!({ "uid": name, "primaryKey": "id" }))
            .send()?;
        if response.status().is_success() || response.status().as_u16() == 409 {
            return Ok(());
        }
        Err(anyhow!(
            "failed to create index {name}: {}",
            response.text()?
        ))
    }

    pub fn apply_settings(&self, index: &str, settings: &Value, wait: bool) -> Result<()> {
        let task = self
            .client
            .patch(self.url(&format!("indexes/{index}/settings"))?)
            .bearer_auth(&self.connection.api_key)
            .json(settings)
            .send()?
            .json::<Value>()?;
        if wait {
            self.wait_for_task(task_uid(&task)?)?;
        }
        Ok(())
    }

    pub fn replace_documents_ndjson(&self, index: &str, body: Vec<u8>, wait: bool) -> Result<()> {
        let task = self
            .client
            .post(self.url(&format!("indexes/{index}/documents"))?)
            .bearer_auth(&self.connection.api_key)
            .header("Content-Type", "application/x-ndjson")
            .body(body)
            .send()?
            .json::<Value>()?;
        if wait {
            self.wait_for_task(task_uid(&task)?)?;
        }
        Ok(())
    }

    pub fn search(&self, index: &str, body: Value) -> Result<Value> {
        Ok(self
            .client
            .post(self.url(&format!("indexes/{index}/search"))?)
            .bearer_auth(&self.connection.api_key)
            .json(&body)
            .send()?
            .json()?)
    }

    pub fn wait_for_task(&self, uid: u64) -> Result<()> {
        for _ in 0..50 {
            let task = self.get(&format!("tasks/{uid}"))?;
            match task.get("status").and_then(Value::as_str) {
                Some("succeeded") => return Ok(()),
                Some("failed") => return Err(anyhow!("meilisearch task {uid} failed: {task}")),
                _ => thread::sleep(Duration::from_millis(100)),
            }
        }
        Err(anyhow!("timed out waiting for meilisearch task {uid}"))
    }

    fn get(&self, path: &str) -> Result<Value> {
        Ok(self
            .client
            .get(self.url(path)?)
            .bearer_auth(&self.connection.api_key)
            .send()?
            .json()?)
    }

    fn url(&self, path: &str) -> Result<reqwest::Url> {
        self.connection
            .host
            .join(path)
            .with_context(|| format!("join meilisearch path {path}"))
    }
}

pub fn upload(config: &ResolvedConfig, request: UploadRequest<'_>) -> Result<()> {
    let connection = config.resolve_meili(request.meili_url, request.meili_key, false)?;
    let client = MeiliClient::new(connection.clone())?;
    let index_name = request.index.unwrap_or(&config.file.meilisearch.index);

    client.create_index(index_name)?;

    let settings_path = request
        .input
        .parent()
        .map(|path| path.join("meili-settings.json"));
    if let Some(settings_path) = settings_path.filter(|path| path.exists()) {
        let payload: Value = serde_json::from_str(&fs::read_to_string(&settings_path)?)?;
        client.apply_settings(index_name, &payload, request.wait)?;
    }

    for path in [Some(request.input), request.edges, request.warnings]
        .into_iter()
        .flatten()
    {
        client.replace_documents_ndjson(index_name, fs::read(path)?, request.wait)?;
    }

    if let Some(mut project_info) = read_project_info(request.input.parent())? {
        project_info.index_uid = index_name.to_owned();
        write_project_info(request.input.parent(), &project_info)?;
        upsert_project_registry(ProjectRecord {
            name: project_info.repo,
            repo_path: project_info.repo_path,
            index_uid: index_name.to_owned(),
            meili_host: connection.host.to_string(),
            updated_at: chrono::Utc::now(),
        })?;
    }

    println!("upload complete index={index_name}");
    Ok(())
}

pub fn search(
    config: &ResolvedConfig,
    meili_url: Option<&str>,
    meili_key: Option<&str>,
    index: Option<&str>,
    query: &str,
    filter: Option<&str>,
    limit: usize,
) -> Result<()> {
    let connection = config.resolve_meili(meili_url, meili_key, true)?;
    let client = MeiliClient::new(connection)?;
    let index_name = index.unwrap_or(&config.file.meilisearch.index);
    let effective_filter = filter
        .map(str::to_owned)
        .or_else(|| endpoint_flow_filter(query));
    let response = client.search(
        index_name,
        json!({
            "q": query,
            "filter": effective_filter,
            "limit": limit,
            "showRankingScore": true
        }),
    )?;
    println!("{}", serde_json::to_string_pretty(&response)?);
    Ok(())
}

fn normalized_http_endpoint_query(query: &str) -> Option<String> {
    let trimmed = query.trim();
    if trimmed.is_empty() || trimmed.contains(' ') || trimmed.contains('.') {
        return None;
    }
    let valid = trimmed.contains('/')
        && trimmed
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-'));
    if !valid {
        return None;
    }
    if trimmed.starts_with('/') {
        Some(trimmed.to_owned())
    } else {
        Some(format!("/{trimmed}"))
    }
}

fn endpoint_flow_filter(query: &str) -> Option<String> {
    let path = normalized_http_endpoint_query(query)?;
    let escaped = path.replace('"', "\\\"");
    Some(format!(
        "kind = frontend_http_flow AND (normalized_path = \"{escaped}\" OR path_aliases = \"{escaped}\")"
    ))
}

pub fn doctor_health(config: &ResolvedConfig) -> Option<Value> {
    let connection = config.resolve_meili(None, None, false).ok()?;
    let client = MeiliClient::new(connection).ok()?;
    client.health().ok()
}

fn task_uid(value: &Value) -> Result<u64> {
    value
        .get("taskUid")
        .or_else(|| value.get("uid"))
        .and_then(Value::as_u64)
        .ok_or_else(|| anyhow!("meilisearch response missing task uid: {value}"))
}

fn read_project_info(parent: Option<&Path>) -> Result<Option<ProjectInfo>> {
    let Some(parent) = parent else {
        return Ok(None);
    };
    let path = parent.join("project-info.json");
    if !path.exists() {
        return Ok(None);
    }
    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
    Ok(Some(
        serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?,
    ))
}

fn write_project_info(parent: Option<&Path>, project_info: &ProjectInfo) -> Result<()> {
    let Some(parent) = parent else {
        return Ok(());
    };
    let path = parent.join("project-info.json");
    fs::write(&path, serde_json::to_vec_pretty(project_info)?)
        .with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{endpoint_flow_filter, normalized_http_endpoint_query};

    #[test]
    fn detects_endpoint_style_queries() {
        assert_eq!(
            normalized_http_endpoint_query("auth/login").as_deref(),
            Some("/auth/login")
        );
        assert_eq!(
            normalized_http_endpoint_query("/auth/check-token").as_deref(),
            Some("/auth/check-token")
        );
        assert!(normalized_http_endpoint_query("login").is_none());
        assert!(normalized_http_endpoint_query("src/app/page.tsx").is_none());
        assert!(normalized_http_endpoint_query("auth/login button").is_none());
        assert_eq!(
            normalized_http_endpoint_query("auth/login").as_deref(),
            Some("/auth/login")
        );
        assert_eq!(
            endpoint_flow_filter("/home/search").as_deref(),
            Some(
                "kind = frontend_http_flow AND (normalized_path = \"/home/search\" OR path_aliases = \"/home/search\")"
            )
        );
    }
}