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 = fallow_engine::repo_refs::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 expert_is_self(expert: &str, self_ids: &[String]) -> bool {
80 let normalized = expert.trim_start_matches('@').to_ascii_lowercase();
81 self_ids
82 .iter()
83 .any(|id| id.trim_start_matches('@').to_ascii_lowercase() == normalized)
84}
85
86fn route_one(
90 abs: &Path,
91 root: &Path,
92 churn_result: &ChurnResult,
93 ctx: &OwnershipContext<'_>,
94 self_ids: &[String],
95) -> Option<RoutingUnit> {
96 let file_churn = churn_result.files.get(abs)?;
97 let relative = abs.strip_prefix(root).unwrap_or(abs);
98 let metrics = compute_ownership(file_churn, relative, ctx)?;
99
100 let mut expert: Vec<String> = Vec::new();
103 if let Some(owner) = &metrics.declared_owner {
104 expert.push(owner.clone());
105 }
106 if expert.is_empty() {
107 expert.push(metrics.top_contributor.identifier.clone());
108 for reviewer in metrics.suggested_reviewers.iter().take(2) {
109 if !expert.contains(&reviewer.identifier) {
110 expert.push(reviewer.identifier.clone());
111 }
112 }
113 }
114
115 expert.retain(|e| !expert_is_self(e, self_ids));
120 if expert.is_empty() {
121 return None;
122 }
123
124 Some(RoutingUnit {
125 file: relative.to_string_lossy().replace('\\', "/"),
126 expert,
127 bus_factor_one: metrics.bus_factor == 1,
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 use fallow_output::{
134 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
135 };
136
137 fn contributor(id: &str) -> ContributorEntry {
138 ContributorEntry {
139 identifier: id.to_string(),
140 format: ContributorIdentifierFormat::Handle,
141 share: 1.0,
142 stale_days: 1,
143 commits: 5,
144 }
145 }
146
147 fn metrics(declared: Option<&str>, bus_factor: u32) -> OwnershipMetrics {
148 OwnershipMetrics {
149 bus_factor,
150 contributor_count: 1,
151 top_contributor: contributor("alice"),
152 recent_contributors: vec![],
153 suggested_reviewers: vec![contributor("bob")],
154 declared_owner: declared.map(str::to_string),
155 unowned: None,
156 ownership_state: OwnershipState::Active,
157 drift: false,
158 drift_reason: None,
159 }
160 }
161
162 #[test]
163 fn current_reviewer_is_excluded_from_routing() {
164 let self_ids = vec![
165 "bart".to_string(),
166 "bart@waardenburg.dev".to_string(),
167 "Bart Waardenburg".to_string(),
168 ];
169 assert!(super::expert_is_self("bart", &self_ids));
172 assert!(super::expert_is_self("Bart", &self_ids));
173 assert!(super::expert_is_self("@bart", &self_ids));
174 assert!(super::expert_is_self("bart@waardenburg.dev", &self_ids));
175 assert!(!super::expert_is_self("alice", &self_ids));
177 assert!(!super::expert_is_self("@team/ui", &self_ids));
178 assert!(!super::expert_is_self("bart", &[]));
180 }
181
182 fn select_expert(metrics: &OwnershipMetrics) -> (Vec<String>, bool) {
185 let mut expert: Vec<String> = Vec::new();
186 if let Some(owner) = &metrics.declared_owner {
187 expert.push(owner.clone());
188 }
189 if expert.is_empty() {
190 expert.push(metrics.top_contributor.identifier.clone());
191 for reviewer in metrics.suggested_reviewers.iter().take(2) {
192 if !expert.contains(&reviewer.identifier) {
193 expert.push(reviewer.identifier.clone());
194 }
195 }
196 }
197 (expert, metrics.bus_factor == 1)
198 }
199
200 #[test]
201 fn declared_owner_wins() {
202 let (expert, _) = select_expert(&metrics(Some("@team/web"), 3));
203 assert_eq!(expert, vec!["@team/web".to_string()]);
204 }
205
206 #[test]
207 fn falls_back_to_git_contributors() {
208 let (expert, _) = select_expert(&metrics(None, 2));
209 assert_eq!(expert, vec!["alice".to_string(), "bob".to_string()]);
210 }
211
212 #[test]
213 fn bus_factor_one_is_flagged() {
214 let (_, bus1) = select_expert(&metrics(None, 1));
215 assert!(bus1);
216 let (_, bus2) = select_expert(&metrics(None, 2));
217 assert!(!bus2);
218 }
219}