Skip to main content

source_map_php/
config.rs

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}