Skip to main content

comindly_core/
configvals.rs

1//! Values from a repo's YAML config files (`config*.yml`, `settings*.yml`, `application*.yml`),
2//! kept as facts so settings paths the code cannot resolve (`config.task_queue.queue`) still
3//! name their queue, bucket or URL.
4
5use std::path::Path;
6
7use ignore::WalkBuilder;
8use yaml_rust2::{Yaml, YamlLoader};
9
10use crate::facts::{FileFacts, ValueExpr, ValueFact};
11use crate::model::Language;
12
13/// Scope prefix of config values; the full scope is `config:<repo>`.
14pub 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
34/// Facts for every config file in a repo.
35pub 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
54/// Facts for one config file: one value per scalar leaf, keyed by its dotted path.
55pub 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
116/// A literal value; `${VAR:default}` yields its default, other templates are skipped.
117fn 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}