_diffctx/edges/semantic/
ansible.rs1use 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}