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(|c: char| c == '(' || c == '"' || c == '\'')
161            .split(|c: char| c == ')' || c == '"' || c == '\'')
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(|c: char| c == '"' || c == '\'')
186                    .split(|c: char| c == '"' || c == '\'' || c == ')')
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
304fn walk_config_dir(
305    root: &Path,
306    dir: &Path,
307    files: &mut Vec<std::path::PathBuf>,
308    depth: usize,
309) -> Result<()> {
310    if depth > 5 {
311        return Ok(());
312    }
313    let entries = match std::fs::read_dir(dir) {
314        Ok(e) => e,
315        Err(_) => return Ok(()),
316    };
317    for entry in entries {
318        let entry = entry?;
319        let path = entry.path();
320        let name = entry.file_name().to_string_lossy().to_string();
321        if name.starts_with('.') && name != ".env" && !name.starts_with(".env.") {
322            continue;
323        }
324        if path.is_dir() {
325            let skip = [
326                "node_modules",
327                "target",
328                "build",
329                "dist",
330                ".git",
331                "__pycache__",
332                "venv",
333                ".venv",
334            ];
335            if !skip.contains(&name.as_str()) {
336                walk_config_dir(root, &path, files, depth + 1)?;
337            }
338        } else {
339            files.push(path);
340        }
341    }
342    Ok(())
343}
344
345fn matches_config_pattern(filename: &str, rel_path: &str, pattern: &str) -> bool {
346    if pattern.contains('*') {
347        let parts: Vec<&str> = pattern.split('*').collect();
348        if parts.len() == 2 {
349            return filename.starts_with(parts[0]) && filename.ends_with(parts[1]);
350        }
351    }
352    if pattern.contains('/') {
353        return rel_path.contains(pattern);
354    }
355    filename == pattern
356}
357
358fn extract_profile_from_filename(filename: &str, framework: &str) -> String {
359    match framework {
360        "Spring" => {
361            if filename.starts_with("application-") {
362                let name = filename.strip_prefix("application-").unwrap_or("");
363                let profile = name.split('.').next().unwrap_or("default");
364                return profile.to_string();
365            }
366            "default".to_string()
367        }
368        "DotNet" => {
369            if filename.starts_with("appsettings.") && filename != "appsettings.json" {
370                let name = filename.strip_prefix("appsettings.").unwrap_or("");
371                let profile = name.strip_suffix(".json").unwrap_or(name);
372                return profile.to_string();
373            }
374            "default".to_string()
375        }
376        "Generic" => {
377            if filename.starts_with(".env.") {
378                return filename
379                    .strip_prefix(".env.")
380                    .unwrap_or("default")
381                    .to_string();
382            }
383            "default".to_string()
384        }
385        _ => "default".to_string(),
386    }
387}
388
389pub fn format_config_bindings(
390    bindings: &[ConfigBinding],
391    config_files: &[ConfigFileInfo],
392) -> String {
393    if bindings.is_empty() && config_files.is_empty() {
394        return "No configuration bindings or config files detected.".to_string();
395    }
396
397    let mut out = String::new();
398
399    if !config_files.is_empty() {
400        out.push_str(&format!(
401            "Config files detected: {}\n\n",
402            config_files.len()
403        ));
404        let mut by_fw: std::collections::BTreeMap<&str, Vec<&ConfigFileInfo>> =
405            std::collections::BTreeMap::new();
406        for cf in config_files {
407            by_fw.entry(&cf.framework).or_default().push(cf);
408        }
409        for (fw, files) in &by_fw {
410            out.push_str(&format!("## {} ({})\n", fw, files.len()));
411            for f in files {
412                out.push_str(&format!("  {} [profile: {}]\n", f.path, f.profile));
413            }
414            out.push('\n');
415        }
416    }
417
418    if !bindings.is_empty() {
419        out.push_str(&format!("Config bindings: {} total\n\n", bindings.len()));
420        let mut by_kind: std::collections::BTreeMap<&str, Vec<&ConfigBinding>> =
421            std::collections::BTreeMap::new();
422        for b in bindings {
423            by_kind.entry(b.kind).or_default().push(b);
424        }
425        for (kind, items) in &by_kind {
426            out.push_str(&format!("## {} ({} symbols)\n", kind, items.len()));
427            for item in items {
428                if item.value.is_empty() {
429                    out.push_str(&format!(
430                        "  {} — {} [profile: {}]\n",
431                        item.symbol_id, item.key, item.profile
432                    ));
433                } else {
434                    out.push_str(&format!(
435                        "  {} — {}={} [profile: {}]\n",
436                        item.symbol_id, item.key, item.value, item.profile
437                    ));
438                }
439            }
440            out.push('\n');
441        }
442    }
443
444    out
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_detect_spring_profile() {
453        let docstring = "@Profile(\"production\")\n@Component\npublic class ProdDataSource {}";
454        let mut found = Vec::new();
455        for cp in CONDITIONAL_PATTERNS {
456            for &pattern in cp.patterns {
457                if docstring.contains(pattern) {
458                    found.push(cp.kind);
459                    break;
460                }
461            }
462        }
463        assert!(found.contains(&"Profile"), "should detect @Profile");
464    }
465
466    #[test]
467    fn test_detect_spring_qualifier() {
468        let docstring = "@Qualifier(\"primaryDB\")\n@Autowired\nprivate DataSource ds;";
469        let mut found = Vec::new();
470        for cp in CONDITIONAL_PATTERNS {
471            for &pattern in cp.patterns {
472                if docstring.contains(pattern) {
473                    found.push(cp.kind);
474                    break;
475                }
476            }
477        }
478        assert!(found.contains(&"Qualifier"), "should detect @Qualifier");
479    }
480
481    #[test]
482    fn test_detect_dotnet_environment() {
483        let docstring = "if (env.IsDevelopment())\n{\n    app.UseDeveloperExceptionPage();\n}";
484        let mut found = Vec::new();
485        for cp in CONDITIONAL_PATTERNS {
486            for &pattern in cp.patterns {
487                if docstring.contains(pattern) {
488                    found.push(cp.kind);
489                    break;
490                }
491            }
492        }
493        assert!(
494            found.contains(&"Environment"),
495            "should detect IsDevelopment()"
496        );
497    }
498
499    #[test]
500    fn test_detect_django_settings() {
501        let docstring = "if settings.DEBUG:\n    print('debug mode')";
502        let mut found = Vec::new();
503        for cp in CONDITIONAL_PATTERNS {
504            for &pattern in cp.patterns {
505                if docstring.contains(pattern) {
506                    found.push(cp.kind);
507                    break;
508                }
509            }
510        }
511        assert!(
512            found.contains(&"DjangoSetting"),
513            "should detect settings.DEBUG"
514        );
515    }
516
517    #[test]
518    fn test_detect_rails_env() {
519        let docstring = "if Rails.env.production?\n  config.force_ssl = true\nend";
520        let mut found = Vec::new();
521        for cp in CONDITIONAL_PATTERNS {
522            for &pattern in cp.patterns {
523                if docstring.contains(pattern) {
524                    found.push(cp.kind);
525                    break;
526                }
527            }
528        }
529        assert!(
530            found.contains(&"RailsEnv"),
531            "should detect Rails.env.production?"
532        );
533    }
534
535    #[test]
536    fn test_detect_go_build_tag() {
537        let docstring = "//go:build linux && amd64\npackage main";
538        let mut found = Vec::new();
539        for cp in CONDITIONAL_PATTERNS {
540            for &pattern in cp.patterns {
541                if docstring.contains(pattern) {
542                    found.push(cp.kind);
543                    break;
544                }
545            }
546        }
547        assert!(found.contains(&"BuildTag"), "should detect //go:build");
548    }
549
550    #[test]
551    fn test_detect_rust_cfg() {
552        let docstring = "#[cfg(feature = \"postgres\")]\nmod postgres_backend {}";
553        let mut found = Vec::new();
554        for cp in CONDITIONAL_PATTERNS {
555            for &pattern in cp.patterns {
556                if docstring.contains(pattern) {
557                    found.push(cp.kind);
558                    break;
559                }
560            }
561        }
562        assert!(
563            found.contains(&"FeatureGate"),
564            "should detect #[cfg(feature"
565        );
566    }
567
568    #[test]
569    fn test_detect_node_env() {
570        let docstring = "const port = process.env.PORT || 3000;";
571        let mut found = Vec::new();
572        for cp in CONDITIONAL_PATTERNS {
573            for &pattern in cp.patterns {
574                if docstring.contains(pattern) {
575                    found.push(cp.kind);
576                    break;
577                }
578            }
579        }
580        assert!(found.contains(&"EnvConfig"), "should detect process.env.");
581    }
582
583    #[test]
584    fn test_extract_profile_spring() {
585        let detail = "@Profile(\"production\")";
586        let profile = extract_profile(detail, "Profile");
587        assert_eq!(profile, "production");
588    }
589
590    #[test]
591    fn test_extract_profile_rails() {
592        let detail = "Rails.env.production?";
593        let profile = extract_profile(detail, "RailsEnv");
594        assert_eq!(profile, "production");
595    }
596
597    #[test]
598    fn test_extract_profile_dotnet() {
599        let detail = "env.IsProduction()";
600        let profile = extract_profile(detail, "Environment");
601        assert_eq!(profile, "production");
602    }
603
604    #[test]
605    fn test_config_file_spring_profile() {
606        assert_eq!(
607            extract_profile_from_filename("application-prod.yml", "Spring"),
608            "prod"
609        );
610        assert_eq!(
611            extract_profile_from_filename("application.yml", "Spring"),
612            "default"
613        );
614    }
615
616    #[test]
617    fn test_config_file_dotnet_profile() {
618        assert_eq!(
619            extract_profile_from_filename("appsettings.Production.json", "DotNet"),
620            "Production"
621        );
622        assert_eq!(
623            extract_profile_from_filename("appsettings.json", "DotNet"),
624            "default"
625        );
626    }
627
628    #[test]
629    fn test_config_file_env_profile() {
630        assert_eq!(
631            extract_profile_from_filename(".env.production", "Generic"),
632            "production"
633        );
634        assert_eq!(extract_profile_from_filename(".env", "Generic"), "default");
635    }
636
637    #[test]
638    fn test_matches_config_pattern() {
639        assert!(matches_config_pattern(
640            "application.yml",
641            "application.yml",
642            "application.yml"
643        ));
644        assert!(matches_config_pattern(
645            "application-prod.yml",
646            "application-prod.yml",
647            "application-*.yml"
648        ));
649        assert!(!matches_config_pattern(
650            "other.yml",
651            "other.yml",
652            "application-*.yml"
653        ));
654        assert!(matches_config_pattern(
655            ".env.production",
656            ".env.production",
657            ".env.*"
658        ));
659    }
660
661    #[test]
662    fn test_no_false_positive_on_plain_text() {
663        let docstring = "This configures the production database settings.";
664        let mut found = Vec::new();
665        for cp in CONDITIONAL_PATTERNS {
666            for &pattern in cp.patterns {
667                if docstring.contains(pattern) {
668                    found.push(cp.kind);
669                    break;
670                }
671            }
672        }
673        assert!(found.is_empty(), "plain text should not match: {:?}", found);
674    }
675
676    #[test]
677    fn test_parse_config_kv_spring_profile() {
678        let detail = "@Profile(\"production\")";
679        let (key, value) = parse_config_kv(detail, "@Profile(");
680        assert_eq!(key, "production");
681    }
682
683    #[test]
684    fn test_parse_config_kv_conditional() {
685        let detail = "@ConditionalOnProperty(name=\"feature.enabled\", havingValue=\"true\")";
686        let (key, _val) = parse_config_kv(detail, "@ConditionalOnProperty(");
687        assert!(
688            key.contains("feature.enabled") || key.contains("name"),
689            "key={}",
690            key
691        );
692    }
693}