comindly_core/
configvals.rs1use std::path::Path;
6
7use ignore::WalkBuilder;
8use yaml_rust2::{Yaml, YamlLoader};
9
10use crate::facts::{FileFacts, ValueExpr, ValueFact};
11use crate::model::Language;
12
13pub const SCOPE: &str = "config:";
15
16pub fn is_config(rel: &str) -> bool {
17 let p = rel.to_ascii_lowercase();
18 let file = p.rsplit('/').next().unwrap_or(&p);
19 let stem_ok = ["config", "settings", "application"]
20 .iter()
21 .any(|s| file.starts_with(s));
22 let in_skipped = p.split('/').any(|d| {
23 matches!(
24 d,
25 "node_modules" | ".github" | "tests" | "test" | "fixtures" | ".venv" | "target"
26 )
27 });
28 stem_ok
29 && p.matches('/').count() < 6
30 && (file.ends_with(".yml") || file.ends_with(".yaml"))
31 && !in_skipped
32}
33
34pub fn scan(root: &Path, repo: &str) -> Vec<FileFacts> {
36 let mut rels: Vec<String> = WalkBuilder::new(root)
37 .hidden(false)
38 .max_depth(Some(6))
39 .build()
40 .filter_map(|e| e.ok())
41 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
42 .filter_map(|e| {
43 e.path()
44 .strip_prefix(root)
45 .ok()
46 .map(|r| r.to_string_lossy().replace('\\', "/"))
47 })
48 .filter(|r| is_config(r))
49 .collect();
50 rels.sort();
51 rels.iter().filter_map(|r| file(root, repo, r)).collect()
52}
53
54pub fn file(root: &Path, repo: &str, rel: &str) -> Option<FileFacts> {
56 let text = std::fs::read_to_string(root.join(rel)).ok()?;
57 let docs = YamlLoader::load_from_str(&text).ok()?;
58 let mut values = Vec::new();
59 for d in &docs {
60 leaves(d, &mut Vec::new(), &mut values);
61 }
62 let scope = format!("{SCOPE}{repo}");
63 Some(FileFacts {
64 repo: repo.to_string(),
65 path: rel.to_string(),
66 language: Language::Other("yaml".into()),
67 file_id: format!("{SCOPE}{repo}:{rel}"),
68 modules: Vec::new(),
69 imports: Vec::new(),
70 refs: Vec::new(),
71 types: Vec::new(),
72 scip: None,
73 contracts: Vec::new(),
74 values: values
75 .into_iter()
76 .map(|(name, v)| ValueFact {
77 scope: scope.clone(),
78 name,
79 value: ValueExpr::Lit(v),
80 })
81 .collect(),
82 params: Vec::new(),
83 calls: Vec::new(),
84 })
85}
86
87fn leaves(y: &Yaml, path: &mut Vec<String>, out: &mut Vec<(String, String)>) {
88 match y {
89 Yaml::Hash(h) => {
90 for (k, v) in h {
91 let Some(k) = scalar(k) else {
92 continue;
93 };
94 path.push(k);
95 leaves(v, path, out);
96 path.pop();
97 }
98 }
99 other => {
100 if let (Some(v), false) = (scalar(other).and_then(concrete), path.is_empty()) {
101 out.push((path.join("."), v));
102 }
103 }
104 }
105}
106
107fn scalar(y: &Yaml) -> Option<String> {
108 match y {
109 Yaml::String(s) | Yaml::Real(s) => Some(s.clone()),
110 Yaml::Integer(i) => Some(i.to_string()),
111 Yaml::Boolean(b) => Some(b.to_string()),
112 _ => None,
113 }
114}
115
116fn concrete(v: String) -> Option<String> {
118 if let Some(inner) = v.strip_prefix("${").and_then(|r| r.strip_suffix('}')) {
119 return inner
120 .split_once(':')
121 .map(|(_, d)| d.to_string())
122 .filter(|d| !d.is_empty() && !d.contains("${"));
123 }
124 (!v.contains("${") && !v.contains("{{")).then_some(v)
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn leaves_are_dotted_and_concrete() {
133 let docs = YamlLoader::load_from_str(
134 "task_queue:\n queue: jobs-q\n url: ${QUEUE_URL}\nport: 8080\nbucket: ${B:files}\n",
135 )
136 .unwrap();
137 let mut out = Vec::new();
138 leaves(&docs[0], &mut Vec::new(), &mut out);
139 assert_eq!(
140 out,
141 vec![
142 ("task_queue.queue".to_string(), "jobs-q".to_string()),
143 ("port".to_string(), "8080".to_string()),
144 ("bucket".to_string(), "files".to_string()),
145 ]
146 );
147 assert!(is_config("app/config.local.yml"));
148 assert!(is_config("src/main/resources/application-prod.yaml"));
149 assert!(!is_config("app/tests/config.yml"));
150 assert!(!is_config(".gitlab-ci.yml"));
151 }
152}