Skip to main content

infigraph_core/config/
mod.rs

1use anyhow::Result;
2use serde::Serialize;
3use std::path::Path;
4
5use crate::graph::GraphStore;
6
7#[derive(Debug, Clone, Serialize)]
8pub struct ConfigBinding {
9    pub symbol_id: String,
10    pub kind: &'static str,
11    pub key: String,
12    pub value: String,
13    pub profile: String,
14    pub source_file: String,
15}
16
17struct ConditionalPattern {
18    kind: &'static str,
19    patterns: &'static [&'static str],
20}
21
22static CONDITIONAL_PATTERNS: &[ConditionalPattern] = &[
23    // Spring profiles
24    ConditionalPattern {
25        kind: "Profile",
26        patterns: &[
27            "@Profile(",
28            "@ConditionalOnProperty(",
29            "@ConditionalOnBean(",
30            "@ConditionalOnMissingBean(",
31            "@ConditionalOnClass(",
32            "@ConditionalOnExpression(",
33        ],
34    },
35    // Spring qualifiers
36    ConditionalPattern {
37        kind: "Qualifier",
38        patterns: &["@Qualifier(", "@Primary", "@Named("],
39    },
40    // .NET environment
41    ConditionalPattern {
42        kind: "Environment",
43        patterns: &[
44            "[Environment(",
45            "IsDevelopment()",
46            "IsProduction()",
47            "IsStaging()",
48            "ASPNETCORE_ENVIRONMENT",
49        ],
50    },
51    // Python/Django
52    ConditionalPattern {
53        kind: "DjangoSetting",
54        patterns: &[
55            "settings.DEBUG",
56            "settings.DATABASES",
57            "settings.INSTALLED_APPS",
58            "settings.MIDDLEWARE",
59            "getattr(settings,",
60            "os.environ.get(",
61            "os.getenv(",
62        ],
63    },
64    // Rails
65    ConditionalPattern {
66        kind: "RailsEnv",
67        patterns: &[
68            "Rails.env.production?",
69            "Rails.env.development?",
70            "Rails.env.test?",
71            "Rails.env.staging?",
72            "Rails.application.config.",
73        ],
74    },
75    // Go build tags
76    ConditionalPattern {
77        kind: "BuildTag",
78        patterns: &["//go:build ", "// +build "],
79    },
80    // Rust feature gates
81    ConditionalPattern {
82        kind: "FeatureGate",
83        patterns: &[
84            "#[cfg(feature",
85            "#[cfg(target_os",
86            "#[cfg(test)]",
87            "#[cfg(not(",
88            "#[cfg_attr(",
89        ],
90    },
91    // Node.js / NestJS
92    ConditionalPattern {
93        kind: "EnvConfig",
94        patterns: &[
95            "process.env.",
96            "ConfigService.get(",
97            "ConfigService.getOrThrow(",
98            "@Optional()",
99        ],
100    },
101];
102
103pub fn detect_config_bindings(store: &GraphStore) -> Result<Vec<ConfigBinding>> {
104    let _lock = store.write_lock()?;
105    let conn = store.connection()?;
106
107    let result = conn
108        .query("MATCH (s:Symbol) WHERE s.docstring IS NOT NULL AND s.docstring <> '' RETURN s.id, s.docstring, s.file")
109        .map_err(|e| anyhow::anyhow!("query failed: {e}"))?;
110
111    let mut bindings = Vec::new();
112
113    for row in result {
114        if row.len() < 3 {
115            continue;
116        }
117        let symbol_id = row[0].to_string();
118        let docstring = row[1].to_string();
119        let file = row[2].to_string();
120
121        for cp in CONDITIONAL_PATTERNS {
122            for &pattern in cp.patterns {
123                if docstring.contains(pattern) {
124                    let detail = extract_config_detail(&docstring, pattern);
125                    let (key, value) = parse_config_kv(&detail, pattern);
126                    bindings.push(ConfigBinding {
127                        symbol_id: symbol_id.clone(),
128                        kind: cp.kind,
129                        key,
130                        value,
131                        profile: extract_profile(&detail, cp.kind),
132                        source_file: file.clone(),
133                    });
134                    break;
135                }
136            }
137        }
138    }
139
140    if !bindings.is_empty() {
141        write_config_bindings(store, &bindings)?;
142    }
143
144    Ok(bindings)
145}
146
147fn extract_config_detail(docstring: &str, pattern: &str) -> String {
148    for line in docstring.lines() {
149        if line.contains(pattern) {
150            return line.trim().to_string();
151        }
152    }
153    pattern.to_string()
154}
155
156fn parse_config_kv(detail: &str, pattern: &str) -> (String, String) {
157    if let Some(start) = detail.find(pattern) {
158        let after = &detail[start + pattern.len()..];
159        let inner = after
160            .trim_start_matches(['(', '"', '\''])
161            .split([')', '"', '\''])
162            .next()
163            .unwrap_or("");
164        if inner.contains('=') {
165            let parts: Vec<&str> = inner.splitn(2, '=').collect();
166            return (
167                parts[0].trim().to_string(),
168                parts
169                    .get(1)
170                    .map(|s| s.trim().to_string())
171                    .unwrap_or_default(),
172            );
173        }
174        return (inner.to_string(), String::new());
175    }
176    (detail.to_string(), String::new())
177}
178
179fn extract_profile(detail: &str, kind: &str) -> String {
180    match kind {
181        "Profile" => {
182            if let Some(start) = detail.find("@Profile(") {
183                let after = &detail[start + 9..];
184                let inner = after
185                    .trim_start_matches(['"', '\''])
186                    .split(['"', '\'', ')'])
187                    .next()
188                    .unwrap_or("default");
189                return inner.to_string();
190            }
191            "default".to_string()
192        }
193        "RailsEnv" => {
194            if detail.contains("production") {
195                "production".to_string()
196            } else if detail.contains("development") {
197                "development".to_string()
198            } else if detail.contains("staging") {
199                "staging".to_string()
200            } else if detail.contains("test") {
201                "test".to_string()
202            } else {
203                "default".to_string()
204            }
205        }
206        "Environment" => {
207            if detail.contains("Production") {
208                "production".to_string()
209            } else if detail.contains("Development") {
210                "development".to_string()
211            } else if detail.contains("Staging") {
212                "staging".to_string()
213            } else {
214                "default".to_string()
215            }
216        }
217        _ => "default".to_string(),
218    }
219}
220
221fn write_config_bindings(store: &GraphStore, bindings: &[ConfigBinding]) -> Result<()> {
222    let conn = store.connection()?;
223
224    conn.query("BEGIN TRANSACTION")
225        .map_err(|e| anyhow::anyhow!("begin txn: {e}"))?;
226
227    let _ = conn.query("MATCH (c:ConfigBinding) DETACH DELETE c");
228
229    for b in bindings {
230        let id = format!("{}::{}::{}", b.symbol_id, b.kind, b.key);
231        let id_esc = crate::escape_str(&id);
232        let kind_esc = crate::escape_str(b.kind);
233        let key_esc = crate::escape_str(&b.key);
234        let val_esc = crate::escape_str(&b.value);
235        let profile_esc = crate::escape_str(&b.profile);
236        let src_esc = crate::escape_str(&b.source_file);
237        let sym_esc = crate::escape_str(&b.symbol_id);
238
239        let _ = conn.query(&format!(
240            "CREATE (c:ConfigBinding {{id: '{id_esc}', kind: '{kind_esc}', key: '{key_esc}', value: '{val_esc}', `profile`: '{profile_esc}', source_file: '{src_esc}'}})"
241        ));
242        let _ = conn.query(&format!(
243            "MATCH (s:Symbol), (c:ConfigBinding) WHERE s.id = '{sym_esc}' AND c.id = '{id_esc}' CREATE (s)-[:HAS_CONFIG]->(c)"
244        ));
245    }
246
247    conn.query("COMMIT")
248        .map_err(|e| anyhow::anyhow!("commit txn: {e}"))?;
249
250    Ok(())
251}
252
253pub fn detect_config_files(root: &Path) -> Vec<ConfigFileInfo> {
254    let mut configs = Vec::new();
255    let patterns = [
256        ("application.yml", "Spring"),
257        ("application.yaml", "Spring"),
258        ("application.properties", "Spring"),
259        ("application-*.yml", "Spring"),
260        ("application-*.yaml", "Spring"),
261        ("application-*.properties", "Spring"),
262        ("settings.py", "Django"),
263        ("appsettings.json", "DotNet"),
264        ("appsettings.*.json", "DotNet"),
265        ("config/database.yml", "Rails"),
266        ("config/environments/", "Rails"),
267        (".env", "Generic"),
268        (".env.*", "Generic"),
269    ];
270
271    if let Ok(walker) = glob_walk(root) {
272        for entry in walker {
273            let rel = entry.strip_prefix(root).unwrap_or(&entry);
274            let name = rel.file_name().unwrap_or_default().to_string_lossy();
275            for (pat, framework) in &patterns {
276                if matches_config_pattern(&name, &rel.to_string_lossy(), pat) {
277                    let profile = extract_profile_from_filename(&name, framework);
278                    configs.push(ConfigFileInfo {
279                        path: rel.to_string_lossy().to_string(),
280                        framework: framework.to_string(),
281                        profile,
282                    });
283                    break;
284                }
285            }
286        }
287    }
288    configs
289}
290
291#[derive(Debug, Clone, Serialize)]
292pub struct ConfigFileInfo {
293    pub path: String,
294    pub framework: String,
295    pub profile: String,
296}
297
298fn glob_walk(root: &Path) -> Result<Vec<std::path::PathBuf>> {
299    let mut files = Vec::new();
300    walk_config_dir(root, root, &mut files, 0)?;
301    Ok(files)
302}
303
304#[allow(clippy::only_used_in_recursion)]
305fn walk_config_dir(
306    root: &Path,
307    dir: &Path,
308    files: &mut Vec<std::path::PathBuf>,
309    depth: usize,
310) -> Result<()> {
311    if depth > 5 {
312        return Ok(());
313    }
314    let entries = match std::fs::read_dir(dir) {
315        Ok(e) => e,
316        Err(_) => return Ok(()),
317    };
318    for entry in entries {
319        let entry = entry?;
320        let path = entry.path();
321        let name = entry.file_name().to_string_lossy().to_string();
322        if name.starts_with('.') && name != ".env" && !name.starts_with(".env.") {
323            continue;
324        }
325        if path.is_dir() {
326            let skip = [
327                "node_modules",
328                "target",
329                "build",
330                "dist",
331                ".git",
332                "__pycache__",
333                "venv",
334                ".venv",
335            ];
336            if !skip.contains(&name.as_str()) {
337                walk_config_dir(root, &path, files, depth + 1)?;
338            }
339        } else {
340            files.push(path);
341        }
342    }
343    Ok(())
344}
345
346fn matches_config_pattern(filename: &str, rel_path: &str, pattern: &str) -> bool {
347    if pattern.contains('*') {
348        let parts: Vec<&str> = pattern.split('*').collect();
349        if parts.len() == 2 {
350            return filename.starts_with(parts[0]) && filename.ends_with(parts[1]);
351        }
352    }
353    if pattern.contains('/') {
354        return rel_path.contains(pattern);
355    }
356    filename == pattern
357}
358
359fn extract_profile_from_filename(filename: &str, framework: &str) -> String {
360    match framework {
361        "Spring" => {
362            if filename.starts_with("application-") {
363                let name = filename.strip_prefix("application-").unwrap_or("");
364                let profile = name.split('.').next().unwrap_or("default");
365                return profile.to_string();
366            }
367            "default".to_string()
368        }
369        "DotNet" => {
370            if filename.starts_with("appsettings.") && filename != "appsettings.json" {
371                let name = filename.strip_prefix("appsettings.").unwrap_or("");
372                let profile = name.strip_suffix(".json").unwrap_or(name);
373                return profile.to_string();
374            }
375            "default".to_string()
376        }
377        "Generic" => {
378            if filename.starts_with(".env.") {
379                return filename
380                    .strip_prefix(".env.")
381                    .unwrap_or("default")
382                    .to_string();
383            }
384            "default".to_string()
385        }
386        _ => "default".to_string(),
387    }
388}
389
390pub fn format_config_bindings(
391    bindings: &[ConfigBinding],
392    config_files: &[ConfigFileInfo],
393) -> String {
394    if bindings.is_empty() && config_files.is_empty() {
395        return "No configuration bindings or config files detected.".to_string();
396    }
397
398    let mut out = String::new();
399
400    if !config_files.is_empty() {
401        out.push_str(&format!(
402            "Config files detected: {}\n\n",
403            config_files.len()
404        ));
405        let mut by_fw: std::collections::BTreeMap<&str, Vec<&ConfigFileInfo>> =
406            std::collections::BTreeMap::new();
407        for cf in config_files {
408            by_fw.entry(&cf.framework).or_default().push(cf);
409        }
410        for (fw, files) in &by_fw {
411            out.push_str(&format!("## {} ({})\n", fw, files.len()));
412            for f in files {
413                out.push_str(&format!("  {} [profile: {}]\n", f.path, f.profile));
414            }
415            out.push('\n');
416        }
417    }
418
419    if !bindings.is_empty() {
420        out.push_str(&format!("Config bindings: {} total\n\n", bindings.len()));
421        let mut by_kind: std::collections::BTreeMap<&str, Vec<&ConfigBinding>> =
422            std::collections::BTreeMap::new();
423        for b in bindings {
424            by_kind.entry(b.kind).or_default().push(b);
425        }
426        for (kind, items) in &by_kind {
427            out.push_str(&format!("## {} ({} symbols)\n", kind, items.len()));
428            for item in items {
429                if item.value.is_empty() {
430                    out.push_str(&format!(
431                        "  {} — {} [profile: {}]\n",
432                        item.symbol_id, item.key, item.profile
433                    ));
434                } else {
435                    out.push_str(&format!(
436                        "  {} — {}={} [profile: {}]\n",
437                        item.symbol_id, item.key, item.value, item.profile
438                    ));
439                }
440            }
441            out.push('\n');
442        }
443    }
444
445    out
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_detect_spring_profile() {
454        let docstring = "@Profile(\"production\")\n@Component\npublic class ProdDataSource {}";
455        let mut found = Vec::new();
456        for cp in CONDITIONAL_PATTERNS {
457            for &pattern in cp.patterns {
458                if docstring.contains(pattern) {
459                    found.push(cp.kind);
460                    break;
461                }
462            }
463        }
464        assert!(found.contains(&"Profile"), "should detect @Profile");
465    }
466
467    #[test]
468    fn test_detect_spring_qualifier() {
469        let docstring = "@Qualifier(\"primaryDB\")\n@Autowired\nprivate DataSource ds;";
470        let mut found = Vec::new();
471        for cp in CONDITIONAL_PATTERNS {
472            for &pattern in cp.patterns {
473                if docstring.contains(pattern) {
474                    found.push(cp.kind);
475                    break;
476                }
477            }
478        }
479        assert!(found.contains(&"Qualifier"), "should detect @Qualifier");
480    }
481
482    #[test]
483    fn test_detect_dotnet_environment() {
484        let docstring = "if (env.IsDevelopment())\n{\n    app.UseDeveloperExceptionPage();\n}";
485        let mut found = Vec::new();
486        for cp in CONDITIONAL_PATTERNS {
487            for &pattern in cp.patterns {
488                if docstring.contains(pattern) {
489                    found.push(cp.kind);
490                    break;
491                }
492            }
493        }
494        assert!(
495            found.contains(&"Environment"),
496            "should detect IsDevelopment()"
497        );
498    }
499
500    #[test]
501    fn test_detect_django_settings() {
502        let docstring = "if settings.DEBUG:\n    print('debug mode')";
503        let mut found = Vec::new();
504        for cp in CONDITIONAL_PATTERNS {
505            for &pattern in cp.patterns {
506                if docstring.contains(pattern) {
507                    found.push(cp.kind);
508                    break;
509                }
510            }
511        }
512        assert!(
513            found.contains(&"DjangoSetting"),
514            "should detect settings.DEBUG"
515        );
516    }
517
518    #[test]
519    fn test_detect_rails_env() {
520        let docstring = "if Rails.env.production?\n  config.force_ssl = true\nend";
521        let mut found = Vec::new();
522        for cp in CONDITIONAL_PATTERNS {
523            for &pattern in cp.patterns {
524                if docstring.contains(pattern) {
525                    found.push(cp.kind);
526                    break;
527                }
528            }
529        }
530        assert!(
531            found.contains(&"RailsEnv"),
532            "should detect Rails.env.production?"
533        );
534    }
535
536    #[test]
537    fn test_detect_go_build_tag() {
538        let docstring = "//go:build linux && amd64\npackage main";
539        let mut found = Vec::new();
540        for cp in CONDITIONAL_PATTERNS {
541            for &pattern in cp.patterns {
542                if docstring.contains(pattern) {
543                    found.push(cp.kind);
544                    break;
545                }
546            }
547        }
548        assert!(found.contains(&"BuildTag"), "should detect //go:build");
549    }
550
551    #[test]
552    fn test_detect_rust_cfg() {
553        let docstring = "#[cfg(feature = \"postgres\")]\nmod postgres_backend {}";
554        let mut found = Vec::new();
555        for cp in CONDITIONAL_PATTERNS {
556            for &pattern in cp.patterns {
557                if docstring.contains(pattern) {
558                    found.push(cp.kind);
559                    break;
560                }
561            }
562        }
563        assert!(
564            found.contains(&"FeatureGate"),
565            "should detect #[cfg(feature"
566        );
567    }
568
569    #[test]
570    fn test_detect_node_env() {
571        let docstring = "const port = process.env.PORT || 3000;";
572        let mut found = Vec::new();
573        for cp in CONDITIONAL_PATTERNS {
574            for &pattern in cp.patterns {
575                if docstring.contains(pattern) {
576                    found.push(cp.kind);
577                    break;
578                }
579            }
580        }
581        assert!(found.contains(&"EnvConfig"), "should detect process.env.");
582    }
583
584    #[test]
585    fn test_extract_profile_spring() {
586        let detail = "@Profile(\"production\")";
587        let profile = extract_profile(detail, "Profile");
588        assert_eq!(profile, "production");
589    }
590
591    #[test]
592    fn test_extract_profile_rails() {
593        let detail = "Rails.env.production?";
594        let profile = extract_profile(detail, "RailsEnv");
595        assert_eq!(profile, "production");
596    }
597
598    #[test]
599    fn test_extract_profile_dotnet() {
600        let detail = "env.IsProduction()";
601        let profile = extract_profile(detail, "Environment");
602        assert_eq!(profile, "production");
603    }
604
605    #[test]
606    fn test_config_file_spring_profile() {
607        assert_eq!(
608            extract_profile_from_filename("application-prod.yml", "Spring"),
609            "prod"
610        );
611        assert_eq!(
612            extract_profile_from_filename("application.yml", "Spring"),
613            "default"
614        );
615    }
616
617    #[test]
618    fn test_config_file_dotnet_profile() {
619        assert_eq!(
620            extract_profile_from_filename("appsettings.Production.json", "DotNet"),
621            "Production"
622        );
623        assert_eq!(
624            extract_profile_from_filename("appsettings.json", "DotNet"),
625            "default"
626        );
627    }
628
629    #[test]
630    fn test_config_file_env_profile() {
631        assert_eq!(
632            extract_profile_from_filename(".env.production", "Generic"),
633            "production"
634        );
635        assert_eq!(extract_profile_from_filename(".env", "Generic"), "default");
636    }
637
638    #[test]
639    fn test_matches_config_pattern() {
640        assert!(matches_config_pattern(
641            "application.yml",
642            "application.yml",
643            "application.yml"
644        ));
645        assert!(matches_config_pattern(
646            "application-prod.yml",
647            "application-prod.yml",
648            "application-*.yml"
649        ));
650        assert!(!matches_config_pattern(
651            "other.yml",
652            "other.yml",
653            "application-*.yml"
654        ));
655        assert!(matches_config_pattern(
656            ".env.production",
657            ".env.production",
658            ".env.*"
659        ));
660    }
661
662    #[test]
663    fn test_no_false_positive_on_plain_text() {
664        let docstring = "This configures the production database settings.";
665        let mut found = Vec::new();
666        for cp in CONDITIONAL_PATTERNS {
667            for &pattern in cp.patterns {
668                if docstring.contains(pattern) {
669                    found.push(cp.kind);
670                    break;
671                }
672            }
673        }
674        assert!(found.is_empty(), "plain text should not match: {:?}", found);
675    }
676
677    #[test]
678    fn test_parse_config_kv_spring_profile() {
679        let detail = "@Profile(\"production\")";
680        let (key, _value) = parse_config_kv(detail, "@Profile(");
681        assert_eq!(key, "production");
682    }
683
684    #[test]
685    fn test_parse_config_kv_conditional() {
686        let detail = "@ConditionalOnProperty(name=\"feature.enabled\", havingValue=\"true\")";
687        let (key, _val) = parse_config_kv(detail, "@ConditionalOnProperty(");
688        assert!(
689            key.contains("feature.enabled") || key.contains("name"),
690            "key={}",
691            key
692        );
693    }
694}