fallow_api/
review_deltas.rs1use 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]
78pub fn public_export_keys_for(
79 graph: &fallow_engine::module_graph::RetainedModuleGraph,
80 config: &fallow_config::ResolvedConfig,
81 workspaces: &[fallow_config::WorkspaceInfo],
82 root: &Path,
83) -> FxHashSet<String> {
84 fallow_engine::project_analysis::public_export_keys_for_graph(graph, config, workspaces, root)
85}
86
87#[must_use]
89#[allow(
90 clippy::implicit_hasher,
91 reason = "callers always pass FxHashSet; generalizing the hasher adds noise for no in-tree benefit"
92)]
93pub fn introduced_keys(head: &FxHashSet<String>, base: &FxHashSet<String>) -> Vec<String> {
94 let mut introduced: Vec<String> = head.difference(base).cloned().collect();
95 introduced.sort_unstable();
96 introduced
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
103 use fallow_types::results::{BoundaryViolation, CircularDependency};
104 use std::path::PathBuf;
105
106 fn boundary(from_zone: &str, to_zone: &str, specifier: &str) -> BoundaryViolationFinding {
107 BoundaryViolationFinding::with_actions(BoundaryViolation {
108 from_path: PathBuf::from("/p/src/a.ts"),
109 to_path: PathBuf::from("/p/src/b.ts"),
110 from_zone: from_zone.to_string(),
111 to_zone: to_zone.to_string(),
112 import_specifier: specifier.to_string(),
113 line: 1,
114 col: 0,
115 })
116 }
117
118 fn cycle(files: &[&str]) -> CircularDependencyFinding {
119 CircularDependencyFinding::with_actions(CircularDependency {
120 files: files.iter().map(PathBuf::from).collect(),
121 length: files.len(),
122 line: 1,
123 col: 0,
124 edges: vec![],
125 is_cross_package: false,
126 })
127 }
128
129 #[test]
130 fn boundary_edges_dedup_per_zone_pair_r2() {
131 let findings = vec![
133 boundary("ui", "db", "./b"),
134 boundary("ui", "db", "./c"),
135 boundary("app", "db", "./d"),
136 ];
137 let keys = boundary_edge_keys(&findings);
138 assert_eq!(keys.len(), 2, "one key per zone pair: {keys:?}");
139 assert!(keys.contains("ui->-db"));
140 assert!(keys.contains("app->-db"));
141 }
142
143 #[test]
144 fn boundary_delta_fires_only_on_new_zone_pair() {
145 let base = boundary_edge_keys(&[boundary("ui", "db", "./b")]);
148 let head = boundary_edge_keys(&[
149 boundary("ui", "db", "./b"),
150 boundary("ui", "db", "./c"),
151 boundary("app", "db", "./d"),
152 ]);
153 let introduced = introduced_keys(&head, &base);
154 assert_eq!(introduced, vec!["app->-db".to_string()]);
155 }
156
157 #[test]
158 fn cycle_key_is_rotation_independent() {
159 let root = Path::new("/p");
160 let a = cycle(&["/p/a.ts", "/p/b.ts", "/p/c.ts"]);
161 let b = cycle(&["/p/c.ts", "/p/a.ts", "/p/b.ts"]);
162 assert_eq!(cycle_key(&a, root), cycle_key(&b, root));
163 }
164
165 #[test]
166 fn cycle_delta_new_vs_pre_existing() {
167 let root = Path::new("/p");
168 let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
169 let head = cycle_keys(
170 &[
171 cycle(&["/p/a.ts", "/p/b.ts"]),
172 cycle(&["/p/x.ts", "/p/y.ts"]),
173 ],
174 root,
175 );
176 let introduced = introduced_keys(&head, &base);
177 assert_eq!(introduced, vec!["x.ts|y.ts".to_string()]);
178 }
179
180 #[test]
181 fn relocation_fires_no_delta() {
182 let root = Path::new("/p");
185 let base = cycle_keys(&[cycle(&["/p/a.ts", "/p/b.ts"])], root);
186 let head = cycle_keys(&[cycle(&["/p/b.ts", "/p/a.ts"])], root);
187 assert!(introduced_keys(&head, &base).is_empty());
188 }
189
190 #[test]
191 fn public_api_delta_zero_for_internal_only_one_for_exports() {
192 let base: FxHashSet<String> = std::iter::once("src/impl.ts::pub".to_string()).collect();
197 let head: FxHashSet<String> = [
198 "src/impl.ts::pub".to_string(),
199 "src/impl.ts::widget".to_string(),
200 ]
201 .into_iter()
202 .collect();
203 let introduced = introduced_keys(&head, &base);
204 assert_eq!(introduced, vec!["src/impl.ts::widget".to_string()]);
205 }
206}