1use crate::graph::{AuthorityGraph, EdgeKind, NodeId, NodeKind, META_IDENTITY_SCOPE};
2
3#[derive(Debug)]
5pub struct MapRow {
6 pub step_name: String,
7 pub trust_zone: String,
8 pub access: Vec<bool>,
10}
11
12#[derive(Debug)]
14pub struct AuthorityMap {
15 pub authorities: Vec<String>,
17 pub rows: Vec<MapRow>,
19}
20
21pub fn authority_map(graph: &AuthorityGraph) -> AuthorityMap {
23 struct RawAuthority {
26 id: NodeId,
27 name: String,
28 zone: String,
29 scope: Option<String>,
30 }
31
32 let raw: Vec<RawAuthority> = graph
33 .authority_sources()
34 .map(|n| RawAuthority {
35 id: n.id,
36 name: n.name.clone(),
37 zone: format!("{:?}", n.trust_zone),
38 scope: n.metadata.get(META_IDENTITY_SCOPE).cloned(),
39 })
40 .collect();
41
42 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
44 for r in &raw {
45 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
46 }
47
48 let mut qualifier_counts: std::collections::HashMap<(String, String), usize> =
52 std::collections::HashMap::new();
53 for r in &raw {
54 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
55 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
56 *qualifier_counts
57 .entry((r.name.clone(), qualifier))
58 .or_insert(0) += 1;
59 }
60 }
61
62 let mut seen: std::collections::HashMap<(String, String), usize> =
63 std::collections::HashMap::new();
64 let authority_names: Vec<String> = raw
65 .iter()
66 .map(|r| {
67 if name_counts.get(r.name.as_str()).copied().unwrap_or(0) <= 1 {
68 r.name.clone()
70 } else {
71 let qualifier = r.scope.clone().unwrap_or_else(|| r.zone.clone());
72 let key = (r.name.clone(), qualifier.clone());
73 let total_with_qualifier = qualifier_counts.get(&key).copied().unwrap_or(1);
74 let idx = {
75 let entry = seen.entry(key).or_insert(0);
76 *entry += 1;
77 *entry
78 };
79 if total_with_qualifier <= 1 {
80 format!("{} ({})", r.name, qualifier)
82 } else {
83 format!("{} ({}#{})", r.name, qualifier, idx)
85 }
86 }
87 })
88 .collect();
89
90 let authorities: Vec<(NodeId, String)> = raw.iter().map(|r| (r.id, r.name.clone())).collect();
93
94 let mut rows = Vec::new();
96 for step in graph.nodes_of_kind(NodeKind::Step) {
97 let mut access = vec![false; authorities.len()];
98
99 for edge in graph.edges_from(step.id) {
100 if edge.kind != EdgeKind::HasAccessTo {
101 continue;
102 }
103 if let Some(idx) = authorities.iter().position(|(id, _)| *id == edge.to) {
105 access[idx] = true;
106 }
107 }
108
109 rows.push(MapRow {
110 step_name: step.name.clone(),
111 trust_zone: format!("{:?}", step.trust_zone),
112 access,
113 });
114 }
115
116 AuthorityMap {
117 authorities: authority_names,
118 rows,
119 }
120}
121
122pub fn render_map(map: &AuthorityMap) -> String {
124 if map.rows.is_empty() && map.authorities.is_empty() {
125 return "No steps or authority sources found.\n".to_string();
126 }
127
128 let step_width = map
130 .rows
131 .iter()
132 .map(|r| r.step_name.len())
133 .max()
134 .unwrap_or(4)
135 .max(4);
136
137 let zone_width = map
138 .rows
139 .iter()
140 .map(|r| r.trust_zone.len())
141 .max()
142 .unwrap_or(4)
143 .max(4);
144
145 const MAX_COL: usize = 20;
148
149 let display_names: Vec<String> = map
150 .authorities
151 .iter()
152 .map(|a| {
153 let char_count = a.chars().count();
154 if char_count > MAX_COL {
155 let mut s: String = a.chars().take(MAX_COL - 1).collect();
156 s.push('…');
157 s
158 } else {
159 a.clone()
160 }
161 })
162 .collect();
163 let any_truncated = display_names
164 .iter()
165 .zip(map.authorities.iter())
166 .any(|(d, o)| d != o);
167
168 let auth_widths: Vec<usize> = display_names
169 .iter()
170 .map(|a| a.chars().count().max(3))
171 .collect();
172
173 let mut out = String::new();
174
175 out.push_str(&format!(
177 "{:<step_w$} {:<zone_w$}",
178 "Step",
179 "Zone",
180 step_w = step_width,
181 zone_w = zone_width
182 ));
183 for (i, auth) in display_names.iter().enumerate() {
184 out.push_str(&format!(" {:^w$}", auth, w = auth_widths[i]));
185 }
186 out.push('\n');
187
188 out.push_str(&"-".repeat(step_width));
190 out.push_str(" ");
191 out.push_str(&"-".repeat(zone_width));
192 for w in &auth_widths {
193 out.push_str(" ");
194 out.push_str(&"-".repeat(*w));
195 }
196 out.push('\n');
197
198 for row in &map.rows {
200 out.push_str(&format!(
201 "{:<step_w$} {:<zone_w$}",
202 row.step_name,
203 row.trust_zone,
204 step_w = step_width,
205 zone_w = zone_width
206 ));
207 for (i, &has) in row.access.iter().enumerate() {
208 let marker = if has { "X" } else { "." };
209 out.push_str(&format!(" {:^w$}", marker, w = auth_widths[i]));
210 }
211 out.push('\n');
212 }
213
214 if any_truncated {
215 out.push_str(&format!(
216 "\nNote: column names truncated to {MAX_COL} chars.\n"
217 ));
218 }
219
220 out
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::graph::*;
227
228 fn source(file: &str) -> PipelineSource {
229 PipelineSource {
230 file: file.into(),
231 repo: None,
232 git_ref: None,
233 }
234 }
235
236 #[test]
237 fn map_shows_step_access() {
238 let mut g = AuthorityGraph::new(source("ci.yml"));
239 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
240 let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
241 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
242 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
243
244 g.add_edge(build, secret, EdgeKind::HasAccessTo);
245 g.add_edge(build, token, EdgeKind::HasAccessTo);
246 g.add_edge(deploy, token, EdgeKind::HasAccessTo);
247
248 let map = authority_map(&g);
249 assert_eq!(map.authorities.len(), 2);
250 assert_eq!(map.rows.len(), 2);
251
252 let build_row = &map.rows[0];
254 assert!(build_row.access[0]); assert!(build_row.access[1]); let deploy_row = &map.rows[1];
259 assert!(!deploy_row.access[0]); assert!(deploy_row.access[1]); }
262
263 #[test]
264 fn map_renders_table() {
265 let mut g = AuthorityGraph::new(source("ci.yml"));
266 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
267 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
268 g.add_edge(step, secret, EdgeKind::HasAccessTo);
269
270 let map = authority_map(&g);
271 let table = render_map(&map);
272 assert!(table.contains("build"));
273 assert!(table.contains("KEY"));
274 assert!(table.contains("X"));
275 }
276}