1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha1::{Digest, Sha1};
9use url::Url;
10
11use crate::Framework;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct IndexerConfig {
15 #[serde(default)]
16 pub project: ProjectConfig,
17 #[serde(default)]
18 pub paths: PathsConfig,
19 #[serde(default)]
20 pub meilisearch: MeilisearchConfig,
21 #[serde(default)]
22 pub search: SearchConfig,
23 #[serde(default)]
24 pub tests: TestsConfig,
25 #[serde(default)]
26 pub sanitizer: SanitizerConfig,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ProjectConfig {
31 pub slug: Option<String>,
32 #[serde(default = "default_framework")]
33 pub default_framework: Framework,
34 #[serde(default = "default_timezone")]
35 pub timezone: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct PathsConfig {
40 #[serde(default = "default_allow_paths")]
41 pub allow: Vec<String>,
42 #[serde(default = "default_allow_vendor")]
43 pub allow_vendor: bool,
44 #[serde(default = "default_allow_vendor_paths")]
45 pub allow_vendor_paths: Vec<String>,
46 #[serde(default = "default_deny_paths")]
47 pub deny: Vec<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct MeilisearchConfig {
52 #[serde(default = "default_meili_host")]
53 pub host: String,
54 #[serde(default = "default_index_prefix")]
55 pub index_prefix: Option<String>,
56 #[serde(default = "default_master_key_env")]
57 pub master_key_env: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SearchConfig {
62 #[serde(default = "default_exact_limit")]
63 pub exact_limit: usize,
64 #[serde(default = "default_natural_limit")]
65 pub natural_limit: usize,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TestsConfig {
70 #[serde(default = "default_include_tests")]
71 pub include_tests: bool,
72 #[serde(default = "default_validate_threshold")]
73 pub validate_threshold: f32,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct SanitizerConfig {
78 #[serde(default = "default_inline_comment_window")]
79 pub inline_comment_window: usize,
80}
81
82#[derive(Debug, Clone)]
83pub struct MeiliConnection {
84 pub host: Url,
85 pub api_key: String,
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Eq)]
89struct ConnectFile {
90 host: Option<String>,
91 api_key: Option<String>,
92}
93
94impl Default for IndexerConfig {
95 fn default() -> Self {
96 Self {
97 project: ProjectConfig {
98 slug: None,
99 default_framework: default_framework(),
100 timezone: default_timezone(),
101 },
102 paths: PathsConfig {
103 allow: default_allow_paths(),
104 allow_vendor: default_allow_vendor(),
105 allow_vendor_paths: default_allow_vendor_paths(),
106 deny: default_deny_paths(),
107 },
108 meilisearch: MeilisearchConfig {
109 host: default_meili_host(),
110 index_prefix: default_index_prefix(),
111 master_key_env: default_master_key_env(),
112 },
113 search: SearchConfig {
114 exact_limit: default_exact_limit(),
115 natural_limit: default_natural_limit(),
116 },
117 tests: TestsConfig {
118 include_tests: default_include_tests(),
119 validate_threshold: default_validate_threshold(),
120 },
121 sanitizer: SanitizerConfig {
122 inline_comment_window: default_inline_comment_window(),
123 },
124 }
125 }
126}
127
128impl Default for ProjectConfig {
129 fn default() -> Self {
130 Self {
131 slug: None,
132 default_framework: default_framework(),
133 timezone: default_timezone(),
134 }
135 }
136}
137
138impl Default for PathsConfig {
139 fn default() -> Self {
140 Self {
141 allow: default_allow_paths(),
142 allow_vendor: default_allow_vendor(),
143 allow_vendor_paths: default_allow_vendor_paths(),
144 deny: default_deny_paths(),
145 }
146 }
147}
148
149impl Default for MeilisearchConfig {
150 fn default() -> Self {
151 Self {
152 host: default_meili_host(),
153 index_prefix: default_index_prefix(),
154 master_key_env: default_master_key_env(),
155 }
156 }
157}
158
159impl Default for SearchConfig {
160 fn default() -> Self {
161 Self {
162 exact_limit: default_exact_limit(),
163 natural_limit: default_natural_limit(),
164 }
165 }
166}
167
168impl Default for TestsConfig {
169 fn default() -> Self {
170 Self {
171 include_tests: default_include_tests(),
172 validate_threshold: default_validate_threshold(),
173 }
174 }
175}
176
177impl Default for SanitizerConfig {
178 fn default() -> Self {
179 Self {
180 inline_comment_window: default_inline_comment_window(),
181 }
182 }
183}
184
185impl IndexerConfig {
186 pub fn load(path: &Path) -> Result<Self> {
187 if !path.exists() {
188 return Ok(Self::default());
189 }
190 let contents =
191 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
192 let config = toml::from_str::<Self>(&contents)
193 .with_context(|| format!("parse {}", path.display()))?;
194 Ok(config)
195 }
196
197 pub fn to_toml_string(&self) -> Result<String> {
198 Ok(toml::to_string_pretty(self)?)
199 }
200
201 pub fn effective_index_prefix(&self, repo: &Path) -> String {
202 if let Some(prefix) = &self.meilisearch.index_prefix {
203 return prefix.clone();
204 }
205 if let Some(slug) = &self.project.slug {
206 return slug.clone();
207 }
208 repo.file_name()
209 .and_then(|name| name.to_str())
210 .unwrap_or("source_map_php")
211 .replace(
212 |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_',
213 "-",
214 )
215 }
216
217 pub fn resolve_meili(&self) -> Result<MeiliConnection> {
218 let env_host = env::var("MEILI_HOST").ok();
219 let env_api_key = env::var(&self.meilisearch.master_key_env).ok();
220 let connect_path = default_connect_file_path();
221
222 self.resolve_meili_with_sources(&connect_path, env_host.as_deref(), env_api_key.as_deref())
223 }
224
225 fn resolve_meili_with_sources(
226 &self,
227 connect_path: &Path,
228 env_host: Option<&str>,
229 env_api_key: Option<&str>,
230 ) -> Result<MeiliConnection> {
231 let connect_file = ConnectFile::load(connect_path)?;
232
233 let host_source = env_host
234 .map(ToOwned::to_owned)
235 .or_else(|| {
236 if self.meilisearch.host != default_meili_host() {
237 Some(self.meilisearch.host.clone())
238 } else {
239 None
240 }
241 })
242 .or(connect_file.host)
243 .unwrap_or_else(|| self.meilisearch.host.clone());
244
245 let host = Url::parse(&host_source)
246 .with_context(|| format!("invalid MEILI_HOST {host_source}"))?;
247 let api_key = env_api_key
248 .map(ToOwned::to_owned)
249 .or(connect_file.api_key)
250 .ok_or_else(|| {
251 anyhow::anyhow!(
252 "missing meilisearch api key in env {} or {}",
253 self.meilisearch.master_key_env,
254 connect_path.display()
255 )
256 })?;
257 Ok(MeiliConnection { host, api_key })
258 }
259
260 pub fn hash(&self) -> Result<String> {
261 let raw = self.to_toml_string()?;
262 let mut hasher = Sha1::new();
263 hasher.update(raw.as_bytes());
264 Ok(format!("{:x}", hasher.finalize()))
265 }
266}
267
268impl ConnectFile {
269 fn load(path: &Path) -> Result<Self> {
270 if !path.exists() {
271 return Ok(Self::default());
272 }
273 let raw = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
274 Self::from_json(&raw).with_context(|| format!("parse {}", path.display()))
275 }
276
277 fn from_json(raw: &str) -> Result<Self> {
278 let value: Value = serde_json::from_str(raw)?;
279 Ok(Self {
280 host: value_lookup(&value, &["host", "url", "endpoint"]).or_else(|| {
281 nested_lookup(
282 &value,
283 &["connection", "default", "meilisearch"],
284 &["host", "url", "endpoint"],
285 )
286 }),
287 api_key: value_lookup(
288 &value,
289 &["api_key", "apiKey", "master_key", "masterKey", "key"],
290 )
291 .or_else(|| {
292 nested_lookup(
293 &value,
294 &["connection", "default", "meilisearch"],
295 &["api_key", "apiKey", "master_key", "masterKey", "key"],
296 )
297 }),
298 })
299 }
300}
301
302fn value_lookup(value: &Value, keys: &[&str]) -> Option<String> {
303 keys.iter().find_map(|key| {
304 value
305 .get(key)
306 .and_then(Value::as_str)
307 .map(|item| item.to_string())
308 })
309}
310
311fn nested_lookup(value: &Value, containers: &[&str], keys: &[&str]) -> Option<String> {
312 containers.iter().find_map(|container| {
313 value
314 .get(container)
315 .and_then(|nested| value_lookup(nested, keys))
316 })
317}
318
319pub(crate) fn default_connect_file_path() -> PathBuf {
320 env::var_os("HOME")
321 .map(PathBuf::from)
322 .unwrap_or_else(|| PathBuf::from("~"))
323 .join(".config/meilisearch/connect.json")
324}
325
326fn default_framework() -> Framework {
327 Framework::Auto
328}
329
330fn default_timezone() -> String {
331 "America/Winnipeg".to_string()
332}
333
334fn default_allow_paths() -> Vec<String> {
335 vec![
336 "app".into(),
337 "src".into(),
338 "routes".into(),
339 "config".into(),
340 "database/migrations".into(),
341 "database/factories".into(),
342 "database/seeders".into(),
343 "tests".into(),
344 "test".into(),
345 "composer.json".into(),
346 "composer.lock".into(),
347 "phpunit.xml".into(),
348 "pest.php".into(),
349 ]
350}
351
352fn default_allow_vendor() -> bool {
353 true
354}
355
356fn default_allow_vendor_paths() -> Vec<String> {
357 vec!["vendor/*/*/src".into(), "vendor/*/*/composer.json".into()]
358}
359
360fn default_deny_paths() -> Vec<String> {
361 vec![
362 ".env".into(),
363 ".env.*".into(),
364 "storage".into(),
365 "bootstrap/cache".into(),
366 "public/storage".into(),
367 "var/log".into(),
368 "logs".into(),
369 "tmp".into(),
370 "dump".into(),
371 "dumps".into(),
372 "backups".into(),
373 "*.sql".into(),
374 "*.sqlite".into(),
375 "*.db".into(),
376 "*.dump".into(),
377 "*.bak".into(),
378 "*.csv".into(),
379 "*.xlsx".into(),
380 "*.xls".into(),
381 "*.parquet".into(),
382 "*.zip".into(),
383 "*.tar".into(),
384 "*.gz".into(),
385 "*.7z".into(),
386 "node_modules".into(),
387 ]
388}
389
390fn default_meili_host() -> String {
391 "http://127.0.0.1:7700".to_string()
392}
393
394fn default_index_prefix() -> Option<String> {
395 None
396}
397
398fn default_master_key_env() -> String {
399 "MEILI_MASTER_KEY".to_string()
400}
401
402fn default_exact_limit() -> usize {
403 8
404}
405
406fn default_natural_limit() -> usize {
407 10
408}
409
410fn default_include_tests() -> bool {
411 true
412}
413
414fn default_validate_threshold() -> f32 {
415 0.60
416}
417
418fn default_inline_comment_window() -> usize {
419 2
420}
421
422#[cfg(test)]
423mod tests {
424 use std::path::Path;
425
426 use tempfile::tempdir;
427
428 use super::{ConnectFile, IndexerConfig};
429
430 #[test]
431 fn defaults_derive_prefix_from_repo_name() {
432 let config = IndexerConfig::default();
433 let dir = tempdir().unwrap();
434 let repo = dir.path().join("my-php-repo");
435 std::fs::create_dir_all(&repo).unwrap();
436
437 assert_eq!(config.effective_index_prefix(&repo), "my-php-repo");
438 }
439
440 #[test]
441 fn loads_config_from_disk() {
442 let dir = tempdir().unwrap();
443 let path = dir.path().join("indexer.toml");
444 std::fs::write(
445 &path,
446 r#"[project]
447slug = "custom"
448"#,
449 )
450 .unwrap();
451
452 let config = IndexerConfig::load(&path).unwrap();
453 assert_eq!(config.project.slug.as_deref(), Some("custom"));
454 }
455
456 #[test]
457 fn parses_flat_connect_file_shape() {
458 let raw = r#"{
459 "host": "http://meili.example:7700",
460 "api_key": "secret"
461 }"#;
462
463 let parsed = ConnectFile::from_json(raw).unwrap();
464 assert_eq!(parsed.host.as_deref(), Some("http://meili.example:7700"));
465 assert_eq!(parsed.api_key.as_deref(), Some("secret"));
466 }
467
468 #[test]
469 fn parses_nested_connect_file_shape() {
470 let raw = r#"{
471 "connection": {
472 "url": "http://nested.example:7700",
473 "masterKey": "nested-secret"
474 }
475 }"#;
476
477 let parsed = ConnectFile::from_json(raw).unwrap();
478 assert_eq!(parsed.host.as_deref(), Some("http://nested.example:7700"));
479 assert_eq!(parsed.api_key.as_deref(), Some("nested-secret"));
480 }
481
482 #[test]
483 fn connect_file_fills_missing_api_key() {
484 let dir = tempdir().unwrap();
485 let connect_path = dir.path().join("connect.json");
486 std::fs::write(
487 &connect_path,
488 r#"{"url":"http://file.example:7700","apiKey":"from-file"}"#,
489 )
490 .unwrap();
491
492 let config = IndexerConfig::default();
493 let connection = config
494 .resolve_meili_with_sources(&connect_path, None, None)
495 .unwrap();
496
497 assert_eq!(connection.host.as_str(), "http://file.example:7700/");
498 assert_eq!(connection.api_key, "from-file");
499 }
500
501 #[test]
502 fn explicit_config_host_beats_connect_file_host() {
503 let dir = tempdir().unwrap();
504 let connect_path = dir.path().join("connect.json");
505 std::fs::write(
506 &connect_path,
507 r#"{"url":"http://file.example:7700","apiKey":"from-file"}"#,
508 )
509 .unwrap();
510
511 let mut config = IndexerConfig::default();
512 config.meilisearch.host = "http://project.example:7700".to_string();
513
514 let connection = config
515 .resolve_meili_with_sources(&connect_path, None, None)
516 .unwrap();
517
518 assert_eq!(connection.host.as_str(), "http://project.example:7700/");
519 assert_eq!(connection.api_key, "from-file");
520 }
521
522 #[test]
523 fn env_values_beat_connect_file() {
524 let dir = tempdir().unwrap();
525 let connect_path = dir.path().join("connect.json");
526 std::fs::write(
527 &connect_path,
528 r#"{"url":"http://file.example:7700","apiKey":"from-file"}"#,
529 )
530 .unwrap();
531
532 let config = IndexerConfig::default();
533 let connection = config
534 .resolve_meili_with_sources(
535 &connect_path,
536 Some("http://env.example:7700"),
537 Some("from-env"),
538 )
539 .unwrap();
540
541 assert_eq!(connection.host.as_str(), "http://env.example:7700/");
542 assert_eq!(connection.api_key, "from-env");
543 }
544
545 #[test]
546 fn missing_sources_still_errors_for_api_key() {
547 let config = IndexerConfig::default();
548 let err = config
549 .resolve_meili_with_sources(Path::new("/definitely/missing/connect.json"), None, None)
550 .unwrap_err();
551
552 assert!(err.to_string().contains("missing meilisearch api key"));
553 }
554}