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}