1use std::path::Path;
32
33use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
34use rustc_hash::FxHashSet;
35
36use crate::audit_keys::relative_key_path;
37
38#[must_use]
42pub fn boundary_edge_key(finding: &BoundaryViolationFinding) -> String {
43 format!(
44 "{}->-{}",
45 finding.violation.from_zone, finding.violation.to_zone
46 )
47}
48
49#[must_use]
51pub fn cycle_key(finding: &CircularDependencyFinding, root: &Path) -> String {
52 let mut files: Vec<String> = finding
53 .cycle
54 .files
55 .iter()
56 .map(|p| relative_key_path(p, root))
57 .collect();
58 files.sort_unstable();
59 files.dedup();
60 files.join("|")
61}
62
63#[must_use]
66pub fn boundary_edge_keys(findings: &[BoundaryViolationFinding]) -> FxHashSet<String> {
67 findings.iter().map(boundary_edge_key).collect()
68}
69
70#[must_use]
72pub fn cycle_keys(findings: &[CircularDependencyFinding], root: &Path) -> FxHashSet<String> {
73 findings.iter().map(|f| cycle_key(f, root)).collect()
74}
75
76#[must_use]
81pub fn public_export_keys_for(
82 graph: &fallow_engine::module_graph::RetainedModuleGraph,
83 config: &fallow_config::ResolvedConfig,
84 root_pkg: Option<&fallow_config::PackageJson>,
85 workspaces: &[fallow_config::WorkspaceInfo],
86 root: &Path,
87) -> FxHashSet<String> {
88 let public_entries = fallow_engine::project_analysis::public_api_package_entry_points(
89 graph, config, root_pkg, workspaces,
90 );
91 graph.public_export_keys(&public_entries, root)
92}
93
94#[must_use]
96#[allow(
97 clippy::implicit_hasher,
98 reason = "callers always pass FxHashSet; generalizing the hasher adds noise for no in-tree benefit"
99)]
100pub fn introduced_keys(head: &FxHashSet<String>, base: &FxHashSet<String>) -> Vec<String> {
101 let mut introduced: Vec<String> = head.difference(base).cloned().collect();
102 introduced.sort_unstable();
103 introduced
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
110 use fallow_types::results::{BoundaryViolation, CircularDependency};
111 use std::path::PathBuf;
112
113 fn boundary(from_zone: &str, to_zone: &str, specifier: &str) -> BoundaryViolationFinding {
114 BoundaryViolationFinding::with_actions(BoundaryViolation {
115 from_path: PathBuf::from("/p/src/a.ts"),
116 to_path: PathBuf::from("/p/src/b.ts"),
117 from_zone: from_zone.to_string(),
118 to_zone: to_zone.to_string(),
119 import_specifier: specifier.to_string(),
120 line: 1,
121 col: 0,
122 })
123 }
124
125 fn cycle(files: &[&str]) -> CircularDependencyFinding {
126 CircularDependencyFinding::with_actions(CircularDependency {
127 files: files.iter().map(PathBuf::from).collect(),
128 length: files.len(),
129 line: 1,
130 col: 0,
131 edges: vec![],
132 is_cross_package: false,
133 })
134 }
135
136 #[test]
137 fn boundary_edges_dedup_per_zone_pair_r2() {
138 let findings = vec![
140 boundary("ui", "db", "./b"),
141 boundary("ui", "db", "./c"),
142 boundary("app", "db", "./d"),
143 ];
144 let keys = boundary_edge_keys(&findings);
145 assert_eq!(keys.len(), 2, "one key per zone pair: {keys:?}");
146 assert!(keys.contains("ui->-db"));
147 assert!(keys.contains("app->-db"));
148 }
149
150 #[test]
151 fn boundary_delta_fires_only_on_new_zone_pair() {
152 let base = boundary_edge_keys(&[boundary("ui", "db", "./b")]);
155 let head = boundary_edge_keys(&[
156 boundary("ui", "db", "./b"),
157 boundary("ui", "db", "./c"),
158 boundary("app", "db", "./d"),
159 ]);
160 let introduced = introduced_keys(&head, &base);
161 assert_eq!(introduced, vec!["app->-db".to_string()]);
162 }
163
164 #[test]
165 fn cycle_key_is_rotation_independent() {
166 let root = Path::new("/p");
167 let a = cycle(&["/p/a.ts", "/p/b.ts", "/p/c.ts"]);
168 let b = cycle(&["/p/c.ts", "/p/a.ts", "/p/b.ts"]);
169 assert_eq!(cycle_key(&a, root), cycle_key(&b, root));
170 }
171
172 #[test]
173 fn cycle_delta_new_vs_pre_existing() {
174 let root = Path::new("/p");
175 let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
176 let head = cycle_keys(
177 &[
178 cycle(&["/p/a.ts", "/p/b.ts"]),
179 cycle(&["/p/x.ts", "/p/y.ts"]),
180 ],
181 root,
182 );
183 let introduced = introduced_keys(&head, &base);
184 assert_eq!(introduced, vec!["x.ts|y.ts".to_string()]);
185 }
186
187 #[test]
188 fn relocation_fires_no_delta() {
189 let root = Path::new("/p");
192 let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
193 let head = cycle_keys(&[cycle(&["/p/b.ts", "/p/a.ts"])], root);
194 assert!(introduced_keys(&head, &base).is_empty());
195 }
196
197 #[test]
198 fn public_api_delta_zero_for_internal_only_one_for_exports() {
199 let base: FxHashSet<String> = std::iter::once("src/impl.ts::pub".to_string()).collect();
204 let head: FxHashSet<String> = [
205 "src/impl.ts::pub".to_string(),
206 "src/impl.ts::widget".to_string(),
207 ]
208 .into_iter()
209 .collect();
210 let introduced = introduced_keys(&head, &base);
211 assert_eq!(introduced, vec!["src/impl.ts::widget".to_string()]);
212 }
213}