Skip to main content

source_map_tauri/
config.rs

1use std::{
2    env, fs,
3    path::{Path, PathBuf},
4};
5
6use anyhow::{anyhow, Context, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use url::Url;
10
11use crate::cli::Cli;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ProjectConfig {
15    pub repo: String,
16    pub root: String,
17    pub frontend_roots: Vec<String>,
18    pub tauri_root: String,
19    pub plugin_roots: Vec<String>,
20    pub output_dir: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ScanConfig {
25    pub include_node_modules: bool,
26    pub include_target: bool,
27    pub include_dist: bool,
28    pub include_vendor: bool,
29    pub redact_secrets: bool,
30    pub detect_phi: bool,
31    pub fail_on_phi: bool,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FrontendConfig {
36    pub frameworks: Vec<String>,
37    pub parser: String,
38    pub recognize_hooks: bool,
39    pub recognize_tests: bool,
40    pub recognize_mock_ipc: bool,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TauriConfig {
45    pub parse_commands: bool,
46    pub parse_plugins: bool,
47    pub parse_plugin_permissions: bool,
48    pub parse_capabilities: bool,
49    pub parse_events: bool,
50    pub parse_channels: bool,
51    pub parse_lifecycle_hooks: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct SourcemapsConfig {
56    pub enabled: bool,
57    pub paths: Vec<String>,
58    pub collapse_strategy: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct MeiliConfig {
63    pub url: String,
64    pub index: String,
65    pub batch_size: usize,
66    pub wait_for_tasks: bool,
67    pub master_key_env: String,
68    pub search_key_env: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PhpBridgeConfig {
73    pub enabled: bool,
74    pub php_index_export: String,
75    pub join_http_routes: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RiskConfig {
80    pub high_keywords: Vec<String>,
81    pub critical_kinds: Vec<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct FileConfig {
86    pub project: ProjectConfig,
87    pub scan: ScanConfig,
88    pub frontend: FrontendConfig,
89    pub tauri: TauriConfig,
90    pub sourcemaps: SourcemapsConfig,
91    pub meilisearch: MeiliConfig,
92    pub php_bridge: PhpBridgeConfig,
93    pub risk: RiskConfig,
94}
95
96impl Default for FileConfig {
97    fn default() -> Self {
98        Self {
99            project: ProjectConfig {
100                repo: "source-map-tauri".to_owned(),
101                root: ".".to_owned(),
102                frontend_roots: vec![
103                    "src".to_owned(),
104                    "app".to_owned(),
105                    "frontend/src".to_owned(),
106                ],
107                tauri_root: "src-tauri".to_owned(),
108                plugin_roots: vec![
109                    "plugins".to_owned(),
110                    "crates".to_owned(),
111                    "src-tauri/plugins".to_owned(),
112                ],
113                output_dir: ".repo-search/tauri".to_owned(),
114            },
115            scan: ScanConfig {
116                include_node_modules: false,
117                include_target: false,
118                include_dist: false,
119                include_vendor: false,
120                redact_secrets: true,
121                detect_phi: true,
122                fail_on_phi: false,
123            },
124            frontend: FrontendConfig {
125                frameworks: vec!["react".to_owned(), "vue".to_owned(), "svelte".to_owned()],
126                parser: "tree-sitter".to_owned(),
127                recognize_hooks: true,
128                recognize_tests: true,
129                recognize_mock_ipc: true,
130            },
131            tauri: TauriConfig {
132                parse_commands: true,
133                parse_plugins: true,
134                parse_plugin_permissions: true,
135                parse_capabilities: true,
136                parse_events: true,
137                parse_channels: true,
138                parse_lifecycle_hooks: true,
139            },
140            sourcemaps: SourcemapsConfig {
141                enabled: true,
142                paths: vec!["dist/**/*.map".to_owned(), "build/**/*.map".to_owned()],
143                collapse_strategy: "nearest_symbol".to_owned(),
144            },
145            meilisearch: MeiliConfig {
146                url: "http://127.0.0.1:7700".to_owned(),
147                index: "tauri_source_map".to_owned(),
148                batch_size: 5000,
149                wait_for_tasks: true,
150                master_key_env: "MEILI_MASTER_KEY".to_owned(),
151                search_key_env: "MEILI_SEARCH_KEY".to_owned(),
152            },
153            php_bridge: PhpBridgeConfig {
154                enabled: false,
155                php_index_export: ".repo-search/php/symbols.ndjson".to_owned(),
156                join_http_routes: true,
157            },
158            risk: RiskConfig {
159                high_keywords: vec![
160                    "patient".to_owned(),
161                    "phi".to_owned(),
162                    "mrn".to_owned(),
163                    "consent".to_owned(),
164                    "medication".to_owned(),
165                    "lab".to_owned(),
166                    "diagnosis".to_owned(),
167                    "billing".to_owned(),
168                    "insurance".to_owned(),
169                    "discharge".to_owned(),
170                    "audit".to_owned(),
171                ],
172                critical_kinds: vec![
173                    "database_access".to_owned(),
174                    "filesystem_export".to_owned(),
175                    "external_integration".to_owned(),
176                ],
177            },
178        }
179    }
180}
181
182#[derive(Debug, Clone)]
183pub struct ResolvedConfig {
184    pub root: PathBuf,
185    pub repo: String,
186    pub output_dir: PathBuf,
187    pub file: FileConfig,
188}
189
190#[derive(Debug, Clone)]
191pub struct MeiliConnection {
192    pub host: Url,
193    pub api_key: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default)]
197struct ConnectFile {
198    host: Option<String>,
199    api_key: Option<String>,
200    search_key: Option<String>,
201}
202
203impl ResolvedConfig {
204    pub fn from_cli(cli: &Cli) -> Result<Self> {
205        let root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
206        let config_path = cli
207            .config
208            .clone()
209            .unwrap_or_else(|| root.join(".repo-search/tauri/source-map-tauri.toml"));
210
211        let mut file = if config_path.exists() {
212            let text = fs::read_to_string(&config_path)
213                .with_context(|| format!("failed to read {}", config_path.display()))?;
214            toml::from_str::<FileConfig>(&text)
215                .with_context(|| format!("failed to parse {}", config_path.display()))?
216        } else {
217            FileConfig::default()
218        };
219
220        file.project.root = root.to_string_lossy().to_string();
221        if let Some(repo) = &cli.repo {
222            file.project.repo = repo.clone();
223        }
224        file.scan.include_node_modules = cli.include_node_modules;
225        file.scan.include_target = cli.include_target;
226        file.scan.include_dist = cli.include_dist;
227        file.scan.include_vendor = cli.include_vendor;
228        file.scan.redact_secrets = cli.redact_secrets;
229        file.scan.detect_phi = cli.detect_phi;
230        file.scan.fail_on_phi = cli.fail_on_phi;
231
232        let output_dir = root.join(&file.project.output_dir);
233        Ok(Self {
234            root,
235            repo: file.project.repo.clone(),
236            output_dir,
237            file,
238        })
239    }
240
241    pub fn with_output_override(&self, output: Option<PathBuf>) -> Self {
242        let mut next = self.clone();
243        if let Some(path) = output {
244            next.output_dir = if path.is_absolute() {
245                path
246            } else {
247                self.root.join(path)
248            };
249        }
250        next
251    }
252
253    pub fn resolve_meili(
254        &self,
255        host_override: Option<&str>,
256        key_override: Option<&str>,
257        search_mode: bool,
258    ) -> Result<MeiliConnection> {
259        let env_host = env::var("MEILI_HOST").ok();
260        let env_key = if search_mode {
261            env::var(&self.file.meilisearch.search_key_env)
262                .ok()
263                .or_else(|| env::var(&self.file.meilisearch.master_key_env).ok())
264        } else {
265            env::var(&self.file.meilisearch.master_key_env).ok()
266        };
267        let connect_file = ConnectFile::load(&default_connect_file_path())?;
268
269        let host_source = host_override
270            .map(ToOwned::to_owned)
271            .or(env_host)
272            .or(connect_file.host)
273            .unwrap_or_else(|| self.file.meilisearch.url.clone());
274        let host = Url::parse(&host_source)
275            .with_context(|| format!("invalid Meilisearch host {host_source}"))?;
276
277        let key = key_override
278            .map(ToOwned::to_owned)
279            .or(env_key)
280            .or_else(|| {
281                if search_mode {
282                    connect_file.search_key.or(connect_file.api_key)
283                } else {
284                    connect_file.api_key
285                }
286            })
287            .ok_or_else(|| {
288                if search_mode {
289                    anyhow!(
290                        "missing meilisearch api key in env {} / {} or {}",
291                        self.file.meilisearch.search_key_env,
292                        self.file.meilisearch.master_key_env,
293                        default_connect_file_path().display()
294                    )
295                } else {
296                    anyhow!(
297                        "missing meilisearch api key in env {} or {}",
298                        self.file.meilisearch.master_key_env,
299                        default_connect_file_path().display()
300                    )
301                }
302            })?;
303
304        Ok(MeiliConnection { host, api_key: key })
305    }
306}
307
308pub fn init_project(config: &ResolvedConfig) -> Result<()> {
309    let output_dir = &config.output_dir;
310    fs::create_dir_all(output_dir)
311        .with_context(|| format!("failed to create {}", output_dir.display()))?;
312    fs::write(
313        output_dir.join("source-map-tauri.toml"),
314        toml::to_string_pretty(&config.file)?,
315    )
316    .with_context(|| format!("failed to write {}", output_dir.display()))?;
317    fs::write(output_dir.join(".gitignore"), "*\n!.gitignore\n")
318        .with_context(|| format!("failed to write {}", output_dir.display()))?;
319    write_connect_file_if_missing()?;
320    Ok(())
321}
322
323pub fn normalize_path(root: &Path, path: &Path) -> String {
324    path.strip_prefix(root)
325        .unwrap_or(path)
326        .to_string_lossy()
327        .replace('\\', "/")
328}
329
330pub fn default_connect_file_path() -> PathBuf {
331    env::var_os("HOME")
332        .map(PathBuf::from)
333        .unwrap_or_else(|| PathBuf::from("~"))
334        .join(".config/meilisearch/connect.json")
335}
336
337fn write_connect_file_if_missing() -> Result<()> {
338    let path = default_connect_file_path();
339    if path.exists() {
340        return Ok(());
341    }
342    if let Some(parent) = path.parent() {
343        fs::create_dir_all(parent)
344            .with_context(|| format!("failed to create {}", parent.display()))?;
345    }
346    let placeholder = serde_json::json!({
347        "host": "http://127.0.0.1:7700",
348        "api_key": "change-me",
349        "search_key": "change-me"
350    });
351    fs::write(&path, serde_json::to_vec_pretty(&placeholder)?)
352        .with_context(|| format!("failed to write {}", path.display()))?;
353    Ok(())
354}
355
356impl ConnectFile {
357    fn load(path: &Path) -> Result<Self> {
358        if !path.exists() {
359            return Ok(Self::default());
360        }
361        let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
362        Self::from_json(&raw).with_context(|| format!("parse {}", path.display()))
363    }
364
365    fn from_json(raw: &str) -> Result<Self> {
366        let value: Value = serde_json::from_str(raw)?;
367        Ok(Self {
368            host: value_lookup(&value, &["host", "url", "endpoint"]).or_else(|| {
369                nested_lookup(
370                    &value,
371                    &["connection", "default", "meilisearch"],
372                    &["host", "url", "endpoint"],
373                )
374            }),
375            api_key: value_lookup(
376                &value,
377                &["api_key", "apiKey", "master_key", "masterKey", "key"],
378            )
379            .or_else(|| {
380                nested_lookup(
381                    &value,
382                    &["connection", "default", "meilisearch"],
383                    &["api_key", "apiKey", "master_key", "masterKey", "key"],
384                )
385            }),
386            search_key: value_lookup(&value, &["search_key", "searchKey"]).or_else(|| {
387                nested_lookup(
388                    &value,
389                    &["connection", "default", "meilisearch"],
390                    &["search_key", "searchKey"],
391                )
392            }),
393        })
394    }
395}
396
397fn value_lookup(value: &Value, keys: &[&str]) -> Option<String> {
398    keys.iter().find_map(|key| {
399        value
400            .get(key)
401            .and_then(Value::as_str)
402            .map(|item| item.to_string())
403    })
404}
405
406fn nested_lookup(value: &Value, containers: &[&str], keys: &[&str]) -> Option<String> {
407    containers.iter().find_map(|container| {
408        value
409            .get(container)
410            .and_then(|nested| value_lookup(nested, keys))
411    })
412}
413
414#[cfg(test)]
415mod tests {
416    use tempfile::tempdir;
417
418    use super::{default_connect_file_path, ConnectFile, FileConfig, ResolvedConfig};
419
420    #[test]
421    fn connect_file_reads_host_and_keys() {
422        let parsed = ConnectFile::from_json(
423            r#"{"host":"http://meili.example:7700","api_key":"master","search_key":"search"}"#,
424        )
425        .unwrap();
426        assert_eq!(parsed.host.as_deref(), Some("http://meili.example:7700"));
427        assert_eq!(parsed.api_key.as_deref(), Some("master"));
428        assert_eq!(parsed.search_key.as_deref(), Some("search"));
429    }
430
431    #[test]
432    fn resolve_meili_uses_connect_file() {
433        let temp = tempdir().unwrap();
434        std::env::set_var("HOME", temp.path());
435        let path = default_connect_file_path();
436        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
437        std::fs::write(
438            &path,
439            r#"{"host":"http://127.0.0.1:7700","api_key":"master","search_key":"search"}"#,
440        )
441        .unwrap();
442
443        let config = ResolvedConfig {
444            root: temp.path().to_path_buf(),
445            repo: "fixture".to_owned(),
446            output_dir: temp.path().join("out"),
447            file: FileConfig::default(),
448        };
449
450        let admin = config.resolve_meili(None, None, false).unwrap();
451        let search = config.resolve_meili(None, None, true).unwrap();
452        assert_eq!(admin.api_key, "master");
453        assert_eq!(search.api_key, "search");
454    }
455}