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
122fn zone_abbr(zone: &str) -> &'static str {
125 match zone {
126 "FirstParty" => "1P",
127 "ThirdParty" => "3P",
128 _ => "?",
129 }
130}
131
132fn trunc(s: &str, max: usize) -> String {
134 let n = s.chars().count();
135 if n <= max {
136 s.to_string()
137 } else {
138 let mut out: String = s.chars().take(max - 1).collect();
139 out.push('…');
140 out
141 }
142}
143
144pub fn render_map(map: &AuthorityMap, term_width: usize) -> String {
151 if map.rows.is_empty() && map.authorities.is_empty() {
152 return "No steps or authority sources found.\n".to_string();
153 }
154
155 const MAX_STEP: usize = 28;
157 const MAX_COL: usize = 18;
158 const ZONE_W: usize = 4; let step_width = map
161 .rows
162 .iter()
163 .map(|r| r.step_name.chars().count().min(MAX_STEP))
164 .max()
165 .unwrap_or(4)
166 .max(4);
167
168 let prefix_width = step_width + 2 + ZONE_W + 2;
170
171 let display_names: Vec<String> = map.authorities.iter().map(|a| trunc(a, MAX_COL)).collect();
173 let any_truncated = display_names
174 .iter()
175 .zip(map.authorities.iter())
176 .any(|(d, o)| d != o);
177
178 let auth_widths: Vec<usize> = display_names
180 .iter()
181 .map(|a| a.chars().count().max(3))
182 .collect();
183
184 let total_cols = auth_widths.len();
188 let mut groups: Vec<(usize, usize)> = Vec::new(); let mut gi = 0;
190 while gi < total_cols {
191 let mut used = prefix_width;
192 let mut end = gi;
193 while end < total_cols {
194 let next = used + auth_widths[end] + 2;
195 if next > term_width && end > gi {
196 break;
197 }
198 used = next;
199 end += 1;
200 }
201 groups.push((gi, end));
202 gi = end;
203 }
204
205 let multi_group = groups.len() > 1;
206 let mut out = String::new();
207
208 for (group_idx, &(start, end)) in groups.iter().enumerate() {
209 if multi_group {
210 out.push_str(&format!(
211 " columns {}-{} of {}\n",
212 start + 1,
213 end,
214 total_cols
215 ));
216 }
217
218 out.push_str(&format!(
220 "{:<step_w$} {:<zone_w$}",
221 "Step",
222 "Zone",
223 step_w = step_width,
224 zone_w = ZONE_W,
225 ));
226 for (name, w) in display_names[start..end]
227 .iter()
228 .zip(&auth_widths[start..end])
229 {
230 out.push_str(&format!(" {:^w$}", name, w = w));
231 }
232 out.push('\n');
233
234 out.push_str(&"-".repeat(step_width));
236 out.push_str(" ");
237 out.push_str(&"-".repeat(ZONE_W));
238 for w in &auth_widths[start..end] {
239 out.push_str(" ");
240 out.push_str(&"-".repeat(*w));
241 }
242 out.push('\n');
243
244 for row in &map.rows {
246 let step_display = trunc(&row.step_name, MAX_STEP);
247 let zone_display = zone_abbr(&row.trust_zone);
248 out.push_str(&format!(
249 "{:<step_w$} {:<zone_w$}",
250 step_display,
251 zone_display,
252 step_w = step_width,
253 zone_w = ZONE_W,
254 ));
255 for (col, w) in auth_widths[start..end].iter().enumerate() {
256 let marker = if row.access[start + col] { "✓" } else { "·" };
257 out.push_str(&format!(" {:^w$}", marker, w = w));
258 }
259 out.push('\n');
260 }
261
262 if group_idx + 1 < groups.len() {
263 out.push('\n');
264 }
265 }
266
267 if any_truncated {
268 out.push_str(&format!(
269 "\nnote: column names truncated to {MAX_COL} chars\n"
270 ));
271 }
272
273 out
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::graph::*;
280
281 fn source(file: &str) -> PipelineSource {
282 PipelineSource {
283 file: file.into(),
284 repo: None,
285 git_ref: None,
286 }
287 }
288
289 #[test]
290 fn map_shows_step_access() {
291 let mut g = AuthorityGraph::new(source("ci.yml"));
292 let secret = g.add_node(NodeKind::Secret, "API_KEY", TrustZone::FirstParty);
293 let token = g.add_node(NodeKind::Identity, "GITHUB_TOKEN", TrustZone::FirstParty);
294 let build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
295 let deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
296
297 g.add_edge(build, secret, EdgeKind::HasAccessTo);
298 g.add_edge(build, token, EdgeKind::HasAccessTo);
299 g.add_edge(deploy, token, EdgeKind::HasAccessTo);
300
301 let map = authority_map(&g);
302 assert_eq!(map.authorities.len(), 2);
303 assert_eq!(map.rows.len(), 2);
304
305 let build_row = &map.rows[0];
307 assert!(build_row.access[0]); assert!(build_row.access[1]); let deploy_row = &map.rows[1];
312 assert!(!deploy_row.access[0]); assert!(deploy_row.access[1]); }
315
316 #[test]
317 fn map_renders_table() {
318 let mut g = AuthorityGraph::new(source("ci.yml"));
319 let secret = g.add_node(NodeKind::Secret, "KEY", TrustZone::FirstParty);
320 let step = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
321 g.add_edge(step, secret, EdgeKind::HasAccessTo);
322
323 let map = authority_map(&g);
324 let table = render_map(&map, 120);
325 assert!(table.contains("build"));
326 assert!(table.contains("KEY"));
327 assert!(table.contains('✓'));
328 }
329}