Skip to main content

_diffctx/edges/semantic/
ansible.rs

1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::edge_weights::ANSIBLE_SEMANTIC;
8use crate::config::weights::EDGE_WEIGHTS;
9use crate::types::Fragment;
10
11use super::super::EdgeDict;
12use super::super::base::{
13    self, EdgeBuilder, FragmentIndex, add_edge, discover_files_by_refs, link_by_name,
14    link_by_path_match,
15};
16
17static ANSIBLE_EXTS: Lazy<FxHashSet<&str>> = Lazy::new(|| {
18    [".yml", ".yaml", ".j2", ".jinja2", ".jinja"]
19        .iter()
20        .copied()
21        .collect()
22});
23
24static INCLUDE_VARS_RE: Lazy<Regex> = Lazy::new(|| {
25    Regex::new(r#"(?m)^\s*(?:include_vars|vars_files)\s*:\s*["']?([^\s"']{1,300})["']?"#).unwrap()
26});
27static INCLUDE_TASKS_RE: Lazy<Regex> = Lazy::new(|| {
28    Regex::new(r#"(?m)^\s*(?:include_tasks|import_tasks|include_role|import_role|import_playbook)\s*:\s*["']?([^\s"']{1,300})["']?"#).unwrap()
29});
30static TEMPLATE_SRC_RE: Lazy<Regex> = Lazy::new(|| {
31    Regex::new(r#"(?m)^\s*src\s*:\s*["']?([^\s"']{1,300}\.j(?:2|inja2?))["']?"#).unwrap()
32});
33static ROLES_LIST_RE: Lazy<Regex> =
34    Lazy::new(|| Regex::new(r"(?m)^\s*-\s+(?:role:\s*)?([a-zA-Z_][\w.\-]{0,200})\s*$").unwrap());
35static VARS_FILES_LIST_RE: Lazy<Regex> =
36    Lazy::new(|| Regex::new(r#"(?m)^\s*-\s+["']([^"']{1,300}\.ya?ml)["']"#).unwrap());
37
38fn is_ansible_file(path: &Path) -> bool {
39    let ext = base::file_ext(path);
40    ANSIBLE_EXTS.contains(ext.as_str())
41}
42
43fn get_role_name(path: &Path) -> Option<String> {
44    let parts: Vec<&str> = path.iter().filter_map(|c| c.to_str()).collect();
45    for (i, part) in parts.iter().enumerate() {
46        if *part == "roles" && i + 1 < parts.len() {
47            return Some(parts[i + 1].to_string());
48        }
49    }
50    None
51}
52
53fn ref_to_filename(r: &str) -> String {
54    r.trim_end_matches('/')
55        .split('/')
56        .next_back()
57        .unwrap_or(r)
58        .to_lowercase()
59}
60
61fn extract_refs(content: &str, file_path: &Path) -> FxHashSet<String> {
62    let mut refs = FxHashSet::default();
63    for m in INCLUDE_VARS_RE.captures_iter(content) {
64        refs.insert(m[1].to_string());
65    }
66    for m in INCLUDE_TASKS_RE.captures_iter(content) {
67        refs.insert(m[1].to_string());
68    }
69    for m in TEMPLATE_SRC_RE.captures_iter(content) {
70        refs.insert(m[1].to_string());
71    }
72    for m in VARS_FILES_LIST_RE.captures_iter(content) {
73        refs.insert(m[1].to_string());
74    }
75
76    if let Some(role) = get_role_name(file_path) {
77        let path_str = file_path.to_string_lossy();
78        if path_str.contains("/tasks/") {
79            refs.insert(format!("roles/{}/handlers/main.yml", role));
80            refs.insert(format!("roles/{}/templates/", role));
81            refs.insert(format!("roles/{}/files/", role));
82        }
83    }
84    refs
85}
86
87fn extract_role_refs(content: &str) -> FxHashSet<String> {
88    let mut roles = FxHashSet::default();
89    let mut in_roles = false;
90    for line in content.lines() {
91        let stripped = line.trim();
92        if stripped.starts_with("roles:") {
93            in_roles = true;
94            continue;
95        }
96        if !in_roles {
97            continue;
98        }
99        if stripped.starts_with("- ") {
100            if let Some(c) = ROLES_LIST_RE.captures(line) {
101                roles.insert(c[1].to_string());
102            }
103        } else if !stripped.is_empty() && !stripped.starts_with('#') && stripped.contains(':') {
104            in_roles = false;
105        }
106    }
107    roles
108}
109
110pub struct AnsibleEdgeBuilder;
111
112impl EdgeBuilder for AnsibleEdgeBuilder {
113    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
114        let frags: Vec<&Fragment> = fragments
115            .iter()
116            .filter(|f| is_ansible_file(Path::new(f.path())))
117            .collect();
118        if frags.is_empty() {
119            return FxHashMap::default();
120        }
121
122        let include_w = EDGE_WEIGHTS["ansible_include"].forward;
123        let role_w = EDGE_WEIGHTS["ansible_role"].forward;
124        let rev = EDGE_WEIGHTS["ansible_include"].reverse_factor;
125        let sibling_w = include_w * ANSIBLE_SEMANTIC.sibling_modifier;
126
127        let idx = FragmentIndex::new(fragments, repo_root);
128        let mut edges: EdgeDict = FxHashMap::default();
129
130        for af in &frags {
131            let path = Path::new(af.path());
132            for r in extract_refs(&af.content, path) {
133                let filename = ref_to_filename(&r);
134                link_by_name(&af.id, &filename, &idx, &mut edges, include_w, rev);
135                link_by_path_match(&af.id, &r, &idx, &mut edges, include_w, rev);
136            }
137
138            for role in extract_role_refs(&af.content) {
139                for subdir in ["tasks", "handlers", "templates"] {
140                    let path_hint = format!("roles/{}/{}", role, subdir);
141                    link_by_path_match(&af.id, &path_hint, &idx, &mut edges, role_w, rev);
142                }
143            }
144        }
145
146        let mut role_frags: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
147        for f in &frags {
148            if let Some(role) = get_role_name(Path::new(f.path())) {
149                role_frags.entry(role).or_default().push(f);
150            }
151        }
152        for group in role_frags.values() {
153            for (i, f1) in group.iter().enumerate() {
154                for f2 in &group[i + 1..] {
155                    add_edge(&mut edges, &f1.id, &f2.id, sibling_w, rev);
156                }
157            }
158        }
159
160        edges
161    }
162
163    fn discover_related_files(
164        &self,
165        changed: &[PathBuf],
166        candidates: &[PathBuf],
167        repo_root: Option<&Path>,
168        file_cache: Option<&FxHashMap<PathBuf, String>>,
169    ) -> Vec<PathBuf> {
170        let ansible_changed: Vec<&PathBuf> =
171            changed.iter().filter(|p| is_ansible_file(p)).collect();
172        if ansible_changed.is_empty() {
173            return vec![];
174        }
175
176        let mut refs = FxHashSet::default();
177        let mut role_names = FxHashSet::default();
178
179        for f in &ansible_changed {
180            if let Some(content) = base::read_file_cached(f, file_cache) {
181                for r in extract_refs(&content, f) {
182                    refs.insert(ref_to_filename(&r));
183                    refs.insert(r);
184                }
185                for role in extract_role_refs(&content) {
186                    role_names.insert(role);
187                }
188            }
189            if let Some(role) = get_role_name(f) {
190                role_names.insert(role);
191            }
192        }
193
194        for role in &role_names {
195            refs.insert(format!("roles/{}/tasks/main.yml", role));
196            refs.insert(format!("roles/{}/handlers/main.yml", role));
197            refs.insert(format!("roles/{}/templates/", role));
198        }
199
200        discover_files_by_refs(&refs, changed, candidates, repo_root)
201    }
202
203    fn category_label(&self) -> Option<&str> {
204        Some("semantic")
205    }
206}