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(|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}