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 ConditionalPattern {
25 kind: "Profile",
26 patterns: &[
27 "@Profile(",
28 "@ConditionalOnProperty(",
29 "@ConditionalOnBean(",
30 "@ConditionalOnMissingBean(",
31 "@ConditionalOnClass(",
32 "@ConditionalOnExpression(",
33 ],
34 },
35 ConditionalPattern {
37 kind: "Qualifier",
38 patterns: &["@Qualifier(", "@Primary", "@Named("],
39 },
40 ConditionalPattern {
42 kind: "Environment",
43 patterns: &[
44 "[Environment(",
45 "IsDevelopment()",
46 "IsProduction()",
47 "IsStaging()",
48 "ASPNETCORE_ENVIRONMENT",
49 ],
50 },
51 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 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 ConditionalPattern {
77 kind: "BuildTag",
78 patterns: &["//go:build ", "// +build "],
79 },
80 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 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}