1use std::path::{Path, PathBuf};
13
14pub use fallow_output::{RoutingFacts, RoutingUnit};
15use rustc_hash::FxHashSet;
16
17use fallow_config::ResolvedConfig;
18use fallow_engine::churn::{ChurnResult, SinceDuration, analyze_churn};
19use fallow_engine::codeowners::CodeOwners;
20use fallow_engine::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
21
22const ROUTING_CHURN_WINDOW: &str = "1 year ago";
25
26#[must_use]
30#[allow(
31 clippy::implicit_hasher,
32 reason = "callers always pass the audit changed-file FxHashSet; generalizing the hasher adds noise"
33)]
34pub fn compute_routing(
35 root: &Path,
36 config: &ResolvedConfig,
37 changed_files: &FxHashSet<PathBuf>,
38) -> RoutingFacts {
39 let since = SinceDuration {
40 git_after: ROUTING_CHURN_WINDOW.to_string(),
41 display: "1 year".to_string(),
42 };
43 let Some(churn_result) = analyze_churn(root, &since) else {
44 return RoutingFacts::default();
45 };
46
47 let ownership_cfg = &config.health.ownership;
48 let Ok(bot_globs) = compile_bot_globs(&ownership_cfg.bot_patterns) else {
49 return RoutingFacts::default();
50 };
51 let codeowners = CodeOwners::load(root, None).ok();
52 let now_secs = std::time::SystemTime::now()
53 .duration_since(std::time::UNIX_EPOCH)
54 .unwrap_or_default()
55 .as_secs();
56 let ctx = OwnershipContext {
57 author_pool: &churn_result.author_pool,
58 bot_globs: &bot_globs,
59 codeowners: codeowners.as_ref(),
60 email_mode: ownership_cfg.email_mode,
61 now_secs,
62 };
63
64 let self_ids = current_user_identities(root);
68
69 let mut units: Vec<RoutingUnit> = changed_files
70 .iter()
71 .filter_map(|abs| route_one(abs, root, &churn_result, &ctx, &self_ids))
72 .collect();
73 units.sort_by(|a, b| a.file.cmp(&b.file));
74 RoutingFacts { units }
75}
76
77fn current_user_identities(root: &Path) -> Vec<String> {
81 let read = |key: &str| -> Option<String> {
82 let output = std::process::Command::new("git")
83 .arg("-C")
84 .arg(root)
85 .args(["config", "--get", key])
86 .output()
87 .ok()?;
88 if !output.status.success() {
89 return None;
90 }
91 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
92 (!value.is_empty()).then_some(value)
93 };
94 let mut ids = Vec::new();
95 if let Some(email) = read("user.email") {
96 if let Some((local, _)) = email.split_once('@') {
97 ids.push(local.rsplit('+').next().unwrap_or(local).to_string());
99 }
100 ids.push(email);
101 }
102 if let Some(name) = read("user.name") {
103 ids.push(name);
104 }
105 ids
106}
107
108fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
111 let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
112 self_ids
113 .iter()
114 .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
115}
116
117fn route_one(
121 abs: &Path,
122 root: &Path,
123 churn_result: &ChurnResult,
124 ctx: &OwnershipContext<'_>,
125 self_ids: &[String],
126) -> Option<RoutingUnit> {
127 let file_churn = churn_result.files.get(abs)?;
128 let relative = abs.strip_prefix(root).unwrap_or(abs);
129 let metrics = compute_ownership(file_churn, relative, ctx)?;
130
131 let mut expert: Vec<String> = Vec::new();
134 if let Some(owner) = &metrics.declared_owner {
135 expert.push(owner.clone());
136 }
137 if expert.is_empty() {
138 expert.push(metrics.top_contributor.identifier.clone());
139 for reviewer in metrics.suggested_reviewers.iter().take(2) {
140 if !expert.contains(&reviewer.identifier) {
141 expert.push(reviewer.identifier.clone());
142 }
143 }
144 }
145
146 expert.retain(|e| !expert_is_self(e, self_ids));
151 if expert.is_empty() {
152 return None;
153 }
154
155 Some(RoutingUnit {
156 file: relative.to_string_lossy().replace('\\', "/"),
157 expert,
158 bus_factor_one: metrics.bus_factor == 1,
159 })
160}
161
162#[cfg(test)]
163mod tests {
164 use fallow_output::{
165 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
166 };
167
168 fn contributor(id: &str) -> ContributorEntry {
169 ContributorEntry {
170 identifier: id.to_string(),
171 format: ContributorIdentifierFormat::Handle,
172 share: 1.0,
173 stale_days: 1,
174 commits: 5,
175 }
176 }
177
178 fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
179 OwnershipMetrics {
180 bus_factor,
181 contributor_count: 1,
182 top_contributor: contributor("alice"),
183 recent_contributors: vec![],
184 suggested_reviewers: vec![contributor("bob")],
185 declared_owner: declared.map(str::to_string),
186 unowned: None,
187 ownership_state: OwnershipState::Active,
188 drift: false,
189 drift_reason: None,
190 }
191 }
192
193 #[test]
194 fn current_reviewer_is_excluded_from_routing() {
195 let self_ids = vec![
196 "bart".to_string(),
197 "bart@waardenburg.dev".to_string(),
198 "Bart Waardenburg".to_string(),
199 ];
200 assert!(super::expert_is_self("bart", &self_ids));
203 assert!(super::expert_is_self("Bart", &self_ids));
204 assert!(super::expert_is_self("@bart", &self_ids));
205 assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
206 assert!(!super::expert_is_self("alice", &self_ids));
208 assert!(!super::expert_is_self("@team/ui", &self_ids));
209 assert!(!super::expert_is_self("bart", &[]));
211 }
212
213 fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
216 let mut expert: Vec<String> = Vec::new();
217 if let Some(owner) = &metrics.declared_owner {
218 expert.push(owner.clone());
219 }
220 if expert.is_empty() {
221 expert.push(metrics.top_contributor.identifier.clone());
222 for reviewer in metrics.suggested_reviewers.iter().take(2) {
223 if !expert.contains(&reviewer.identifier) {
224 expert.push(reviewer.identifier.clone());
225 }
226 }
227 }
228 (expert, metrics.bus_factor == 1)
229 }
230
231 #[test]
232 fn declared_owner_wins() {
233 let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
234 assert_eq!(expert, vec!["@team/web".to_string()]);
235 }
236
237 #[test]
238 fn falls_back_to_git_contributors() {
239 let (expert, _) = select_expert(&metrics(None, 2));
240 assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
241 }
242
243 #[test]
244 fn bus_factor_one_is_flagged() {
245 let (_, bus1) = select_expert(&metrics(None, 1));
246 assert!(bus1);
247 let (_, bus2) = select_expert(&metrics(None, 2));
248 assert!(!bus2);
249 }
250}