1use std::path::{Path, PathBuf};
13
14use rustc_hash::FxHashSet;
15use serde::Serialize;
16
17use crate::codeowners::CodeOwners;
18use crate::health::ownership::{OwnershipContext, compile_bot_globs, compute_ownership};
19use fallow_config::ResolvedConfig;
20use fallow_core::churn::{self, SinceDuration};
21
22const ROUTING_CHURN_WINDOW: &str = "1 year ago";
25
26#[derive(Debug, Clone, Serialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub struct RoutingUnit {
30 pub file: String,
32 pub expert: Vec<String>,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
38 pub bus_factor_one: bool,
39}
40
41#[derive(Debug, Clone, Default, Serialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45pub struct RoutingFacts {
46 pub units: Vec<RoutingUnit>,
48}
49
50#[must_use]
54#[allow(
55 clippy::implicit_hasher,
56 reason = "callers always pass the audit changed-file FxHashSet; generalizing the hasher adds noise"
57)]
58pub fn compute_routing(
59 root: &Path,
60 config: &ResolvedConfig,
61 changed_files: &FxHashSet<PathBuf>,
62) -> RoutingFacts {
63 let since = SinceDuration {
64 git_after: ROUTING_CHURN_WINDOW.to_string(),
65 display: "1 year".to_string(),
66 };
67 let Some(churn_result) = churn::analyze_churn(root, &since) else {
68 return RoutingFacts::default();
69 };
70
71 let ownership_cfg = &config.health.ownership;
72 let Ok(bot_globs) = compile_bot_globs(&ownership_cfg.bot_patterns) else {
73 return RoutingFacts::default();
74 };
75 let codeowners = CodeOwners::load(root, None).ok();
76 let now_secs = std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .unwrap_or_default()
79 .as_secs();
80 let ctx = OwnershipContext {
81 author_pool: &churn_result.author_pool,
82 bot_globs: &bot_globs,
83 codeowners: codeowners.as_ref(),
84 email_mode: ownership_cfg.email_mode,
85 now_secs,
86 };
87
88 let self_ids = current_user_identities(root);
92
93 let mut units: Vec<RoutingUnit> = changed_files
94 .iter()
95 .filter_map(|abs| route_one(abs, root, &churn_result, &ctx, &self_ids))
96 .collect();
97 units.sort_by(|a, b| a.file.cmp(&b.file));
98 RoutingFacts { units }
99}
100
101fn current_user_identities(root: &Path) -> Vec<String> {
105 let read = |key: &str| -> Option<String> {
106 let output = std::process::Command::new("git")
107 .arg("-C")
108 .arg(root)
109 .args(["config", "--get", key])
110 .output()
111 .ok()?;
112 if !output.status.success() {
113 return None;
114 }
115 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
116 (!value.is_empty()).then_some(value)
117 };
118 let mut ids = Vec::new();
119 if let Some(email) = read("user.email") {
120 if let Some((local, _)) = email.split_once('@') {
121 ids.push(local.rsplit('+').next().unwrap_or(local).to_string());
123 }
124 ids.push(email);
125 }
126 if let Some(name) = read("user.name") {
127 ids.push(name);
128 }
129 ids
130}
131
132fn expert_is_self(expert: &str, self_ids: &[String]) -> bool {
135 let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
136 self_ids
137 .iter()
138 .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
139}
140
141fn route_one(
145 abs: &Path,
146 root: &Path,
147 churn_result: &churn::ChurnResult,
148 ctx: &OwnershipContext<'_>,
149 self_ids: &[String],
150) -> Option<RoutingUnit> {
151 let file_churn = churn_result.files.get(abs)?;
152 let relative = abs.strip_prefix(root).unwrap_or(abs);
153 let metrics = compute_ownership(file_churn, relative, ctx)?;
154
155 let mut expert: Vec<String> = Vec::new();
158 if let Some(owner) = &metrics.declared_owner {
159 expert.push(owner.clone());
160 }
161 if expert.is_empty() {
162 expert.push(metrics.top_contributor.identifier.clone());
163 for reviewer in metrics.suggested_reviewers.iter().take(2) {
164 if !expert.contains(&reviewer.identifier) {
165 expert.push(reviewer.identifier.clone());
166 }
167 }
168 }
169
170 expert.retain(|e| !expert_is_self(e, self_ids));
175 if expert.is_empty() {
176 return None;
177 }
178
179 Some(RoutingUnit {
180 file: relative.to_string_lossy().replace('\\', "/"),
181 expert,
182 bus_factor_one: metrics.bus_factor == 1,
183 })
184}
185
186#[cfg(test)]
187mod tests {
188 use crate::health_types::{
189 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
190 };
191
192 fn contributor(id: &str) -> ContributorEntry {
193 ContributorEntry {
194 identifier: id.to_string(),
195 format: ContributorIdentifierFormat::Handle,
196 share: 1.0,
197 stale_days: 1,
198 commits: 5,
199 }
200 }
201
202 fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
203 OwnershipMetrics {
204 bus_factor,
205 contributor_count: 1,
206 top_contributor: contributor("alice"),
207 recent_contributors: vec![],
208 suggested_reviewers: vec![contributor("bob")],
209 declared_owner: declared.map(str::to_string),
210 unowned: None,
211 ownership_state: OwnershipState::Active,
212 drift: false,
213 drift_reason: None,
214 }
215 }
216
217 #[test]
218 fn current_reviewer_is_excluded_from_routing() {
219 let self_ids = vec![
220 "bart".to_string(),
221 "bart@waardenburg.dev".to_string(),
222 "Bart Waardenburg".to_string(),
223 ];
224 assert!(super::expert_is_self("bart", &self_ids));
227 assert!(super::expert_is_self("Bart", &self_ids));
228 assert!(super::expert_is_self("@bart", &self_ids));
229 assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
230 assert!(!super::expert_is_self("alice", &self_ids));
232 assert!(!super::expert_is_self("@team/ui", &self_ids));
233 assert!(!super::expert_is_self("bart", &[]));
235 }
236
237 fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
240 let mut expert: Vec<String> = Vec::new();
241 if let Some(owner) = &metrics.declared_owner {
242 expert.push(owner.clone());
243 }
244 if expert.is_empty() {
245 expert.push(metrics.top_contributor.identifier.clone());
246 for reviewer in metrics.suggested_reviewers.iter().take(2) {
247 if !expert.contains(&reviewer.identifier) {
248 expert.push(reviewer.identifier.clone());
249 }
250 }
251 }
252 (expert, metrics.bus_factor == 1)
253 }
254
255 #[test]
256 fn declared_owner_wins() {
257 let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
258 assert_eq!(expert, vec!["@team/web".to_string()]);
259 }
260
261 #[test]
262 fn falls_back_to_git_contributors() {
263 let (expert, _) = select_expert(&metrics(None, 2));
264 assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
265 }
266
267 #[test]
268 fn bus_factor_one_is_flagged() {
269 let (_, bus1) = select_expert(&metrics(None, 1));
270 assert!(bus1);
271 let (_, bus2) = select_expert(&metrics(None, 2));
272 assert!(!bus2);
273 }
274}