1use chrono::{DateTime, Duration, Utc};
2use globset::Glob;
3use open_kioku_core::{
4 Confidence, MemorySearchResult, Owner, OwnerSuggestion, OwnershipConfidenceBreakdown,
5 OwnershipEvidence, OwnershipReport, OwnershipSourceType, PolicyComponentMatch, ProvenanceTouch,
6};
7use open_kioku_errors::Result;
8use open_kioku_storage::HistoryStore;
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14const HISTORY_LIMIT: usize = 100;
15const STALE_AFTER_DAYS: i64 = 365;
16const CODEOWNERS_SCORE: f32 = 0.70;
17const MEMORY_ONLY_CAP: f32 = 0.45;
18
19const CODEOWNERS_CANDIDATES: &[&str] = &[
20 ".open-kioku/CODEOWNERS",
21 ".github/CODEOWNERS",
22 "CODEOWNERS",
23 "docs/CODEOWNERS",
24 "OWNERS",
25];
26
27pub struct OwnershipInput<'a> {
28 pub repo: &'a Path,
29 pub path: &'a Path,
30 pub history: &'a dyn HistoryStore,
31 pub memory_facts: &'a [MemorySearchResult],
32 pub components: Vec<PolicyComponentMatch>,
33}
34
35pub fn ownership_for_path(input: OwnershipInput<'_>) -> Result<OwnershipReport> {
36 let generated_at = Utc::now();
37 let path = repo_relative_path(input.repo, input.path);
38 let mut uncertainty = Vec::new();
39 let mut owners = BTreeMap::<String, OwnerAggregate>::new();
40
41 add_codeowners_evidence(
42 input.repo,
43 &path,
44 generated_at,
45 &mut owners,
46 &mut uncertainty,
47 );
48 add_git_history_evidence(
49 input.history,
50 &path,
51 generated_at,
52 &mut owners,
53 &mut uncertainty,
54 );
55 add_memory_evidence(
56 input.memory_facts,
57 generated_at,
58 &mut owners,
59 &mut uncertainty,
60 );
61
62 let suggestions = owner_suggestions(owners, &mut uncertainty);
63 if suggestions.is_empty() {
64 uncertainty.push(format!(
65 "no owner suggestions found for `{}` from CODEOWNERS, git history, or repo memory",
66 path.display()
67 ));
68 }
69 if suggestions.iter().any(|suggestion| {
70 suggestion
71 .source_types
72 .iter()
73 .all(|source| *source == OwnershipSourceType::RepoMemory)
74 }) {
75 uncertainty.push(
76 "memory-only ownership evidence is secondary and uncorroborated by CODEOWNERS or git history"
77 .into(),
78 );
79 }
80
81 Ok(OwnershipReport {
82 path,
83 components: input.components,
84 generated_at,
85 owners: suggestions,
86 uncertainty,
87 })
88}
89
90#[derive(Debug, Clone)]
91struct CodeownersRule {
92 file: PathBuf,
93 line_number: usize,
94 pattern: String,
95 owners: Vec<Owner>,
96}
97
98#[derive(Debug, Clone)]
99struct OwnerAggregate {
100 owner: Owner,
101 evidence: Vec<OwnershipEvidence>,
102 codeowners: f32,
103 git_history: f32,
104 memory: f32,
105}
106
107impl OwnerAggregate {
108 fn new(owner: Owner) -> Self {
109 Self {
110 owner,
111 evidence: Vec::new(),
112 codeowners: 0.0,
113 git_history: 0.0,
114 memory: 0.0,
115 }
116 }
117
118 fn source_types(&self) -> Vec<OwnershipSourceType> {
119 [
120 OwnershipSourceType::Codeowners,
121 OwnershipSourceType::GitHistory,
122 OwnershipSourceType::RepoMemory,
123 ]
124 .into_iter()
125 .filter(|source| {
126 self.evidence
127 .iter()
128 .any(|evidence| evidence.source_type == *source)
129 })
130 .collect()
131 }
132
133 fn has_source(&self, source_type: OwnershipSourceType) -> bool {
134 self.evidence
135 .iter()
136 .any(|evidence| evidence.source_type == source_type)
137 }
138
139 fn stale(&self) -> bool {
140 !self.evidence.is_empty() && self.evidence.iter().all(|evidence| evidence.stale)
141 }
142}
143
144fn add_codeowners_evidence(
145 repo: &Path,
146 path: &Path,
147 generated_at: DateTime<Utc>,
148 owners: &mut BTreeMap<String, OwnerAggregate>,
149 uncertainty: &mut Vec<String>,
150) {
151 let rules = read_codeowners_rules(repo, uncertainty);
152 if rules.is_empty() {
153 uncertainty.push("no CODEOWNERS or owner config file was found".into());
154 return;
155 }
156
157 let mut matched = None;
158 for rule in &rules {
159 match codeowners_pattern_matches(&rule.pattern, path) {
160 Ok(true) => matched = Some(rule),
161 Ok(false) => {}
162 Err(err) => uncertainty.push(format!(
163 "ignored invalid CODEOWNERS pattern `{}` in {}:{}: {err}",
164 rule.pattern,
165 rule.file.display(),
166 rule.line_number
167 )),
168 }
169 }
170
171 let Some(rule) = matched else {
172 uncertainty.push(format!(
173 "CODEOWNERS files were present but no rule matched `{}`",
174 path.display()
175 ));
176 return;
177 };
178
179 for owner in &rule.owners {
180 let evidence = OwnershipEvidence {
181 source_type: OwnershipSourceType::Codeowners,
182 owner: owner.clone(),
183 source: format!(
184 "{}:{} `{}`",
185 rule.file.display(),
186 rule.line_number,
187 rule.pattern
188 ),
189 message: format!(
190 "CODEOWNERS rule `{}` matched `{}`",
191 rule.pattern,
192 path.display()
193 ),
194 confidence: Confidence::High,
195 observed_at: Some(generated_at),
196 stale: false,
197 };
198 add_evidence(
199 owners,
200 owner.clone(),
201 evidence,
202 OwnershipSourceType::Codeowners,
203 CODEOWNERS_SCORE,
204 );
205 }
206}
207
208fn read_codeowners_rules(repo: &Path, uncertainty: &mut Vec<String>) -> Vec<CodeownersRule> {
209 let mut rules = Vec::new();
210 for candidate in CODEOWNERS_CANDIDATES {
211 let path = repo.join(candidate);
212 if !path.is_file() {
213 continue;
214 }
215 let Ok(contents) = fs::read_to_string(&path) else {
216 uncertainty.push(format!("could not read owner config `{}`", path.display()));
217 continue;
218 };
219 for (index, raw_line) in contents.lines().enumerate() {
220 let line_number = index + 1;
221 let line = raw_line.trim();
222 if line.is_empty() || line.starts_with('#') {
223 continue;
224 }
225 let mut parts = line.split_whitespace();
226 let Some(pattern) = parts.next() else {
227 continue;
228 };
229 if pattern.starts_with('!') {
230 uncertainty.push(format!(
231 "ignored unsupported negative CODEOWNERS pattern `{pattern}` in {}:{line_number}",
232 path.display()
233 ));
234 continue;
235 }
236 let mut rule_owners = Vec::new();
237 for token in parts {
238 if token.starts_with('#') {
239 break;
240 }
241 if let Some(owner) = owner_from_token(token) {
242 rule_owners.push(owner);
243 }
244 }
245 if rule_owners.is_empty() {
246 uncertainty.push(format!(
247 "ignored CODEOWNERS rule `{}` in {}:{} because it has no owner",
248 pattern,
249 path.display(),
250 line_number
251 ));
252 continue;
253 }
254 rules.push(CodeownersRule {
255 file: PathBuf::from(candidate),
256 line_number,
257 pattern: pattern.to_string(),
258 owners: rule_owners,
259 });
260 }
261 }
262 rules
263}
264
265fn codeowners_pattern_matches(pattern: &str, path: &Path) -> Result<bool> {
266 let normalized_path = normalize_path_for_glob(path);
267 for candidate in codeowners_globs(pattern) {
268 let matcher = Glob::new(&candidate)
269 .map_err(|err| open_kioku_errors::OkError::Config(err.to_string()))?
270 .compile_matcher();
271 if matcher.is_match(Path::new(&normalized_path)) {
272 return Ok(true);
273 }
274 }
275 Ok(false)
276}
277
278fn codeowners_globs(pattern: &str) -> Vec<String> {
279 let anchored = pattern.starts_with('/');
280 let directory = pattern.ends_with('/');
281 let mut normalized = pattern
282 .trim_start_matches('/')
283 .trim_end_matches('/')
284 .to_string();
285 if normalized.is_empty() {
286 normalized = "**".into();
287 }
288 if directory {
289 normalized.push_str("/**");
290 }
291
292 let mut candidates = Vec::new();
293 if anchored {
294 candidates.push(normalized);
295 } else {
296 candidates.push(normalized.clone());
297 candidates.push(format!("**/{normalized}"));
298 }
299 candidates.sort();
300 candidates.dedup();
301 candidates
302}
303
304fn add_git_history_evidence(
305 history: &dyn HistoryStore,
306 path: &Path,
307 generated_at: DateTime<Utc>,
308 owners: &mut BTreeMap<String, OwnerAggregate>,
309 uncertainty: &mut Vec<String>,
310) {
311 let provenance = match history.provenance_for_path(path, HISTORY_LIMIT) {
312 Ok(provenance) => provenance,
313 Err(err) => {
314 uncertainty.push(format!("git history ownership evidence unavailable: {err}"));
315 return;
316 }
317 };
318 uncertainty.extend(provenance.uncertainty.iter().cloned());
319 if provenance.truncated {
320 uncertainty.push(format!(
321 "git history ownership evidence for `{}` is truncated at {HISTORY_LIMIT} touches",
322 path.display()
323 ));
324 }
325
326 let touches = unique_touches(&provenance.recent_touches);
327 if touches.is_empty() {
328 uncertainty.push(format!(
329 "no git author touches were available for `{}`",
330 path.display()
331 ));
332 return;
333 }
334
335 let total = touches.len() as f32;
336 let mut by_owner = BTreeMap::<String, GitOwnerStats>::new();
337 for touch in touches {
338 let key = owner_key(&touch.commit.author);
339 let entry = by_owner
340 .entry(key)
341 .or_insert_with(|| GitOwnerStats::new(touch.commit.author.clone()));
342 entry.count += 1;
343 entry.latest = entry.latest.max(Some(touch.commit.committed_at));
344 entry.latest_commit = Some(touch.commit.id.0.clone());
345 entry.latest_summary = Some(touch.commit.summary.clone());
346 }
347
348 for stats in by_owner.into_values() {
349 let share = stats.count as f32 / total;
350 let count_factor = 0.60 + ((stats.count as f32 / 3.0).min(1.0) * 0.40);
351 let observed_at = stats.latest.unwrap_or(generated_at);
352 let stale = is_stale(generated_at, observed_at);
353 let freshness_multiplier = if stale { 0.55 } else { 1.0 };
354 let git_score = ((0.30 + (0.32 * share)) * count_factor * freshness_multiplier).min(0.62);
355 let message = format!(
356 "{} authored {} of {} persisted touch(es) for `{}`; latest `{}`",
357 stats.owner.name,
358 stats.count,
359 total as usize,
360 path.display(),
361 stats
362 .latest_summary
363 .as_deref()
364 .unwrap_or("unknown commit summary")
365 );
366 let evidence = OwnershipEvidence {
367 source_type: OwnershipSourceType::GitHistory,
368 owner: stats.owner.clone(),
369 source: format!(
370 "git history:{}",
371 stats.latest_commit.as_deref().unwrap_or("unknown")
372 ),
373 message,
374 confidence: Confidence::from_score(git_score),
375 observed_at: Some(observed_at),
376 stale,
377 };
378 add_evidence(
379 owners,
380 stats.owner,
381 evidence,
382 OwnershipSourceType::GitHistory,
383 git_score,
384 );
385 }
386}
387
388#[derive(Debug)]
389struct GitOwnerStats {
390 owner: Owner,
391 count: usize,
392 latest: Option<DateTime<Utc>>,
393 latest_commit: Option<String>,
394 latest_summary: Option<String>,
395}
396
397impl GitOwnerStats {
398 fn new(owner: Owner) -> Self {
399 Self {
400 owner,
401 count: 0,
402 latest: None,
403 latest_commit: None,
404 latest_summary: None,
405 }
406 }
407}
408
409fn unique_touches(touches: &[ProvenanceTouch]) -> Vec<&ProvenanceTouch> {
410 let mut seen = BTreeSet::new();
411 let mut unique = Vec::new();
412 for touch in touches {
413 let key = format!(
414 "{}:{}:{}",
415 touch.commit.id.0,
416 touch.path.display(),
417 touch.qualified_name.as_deref().unwrap_or("<file>")
418 );
419 if seen.insert(key) {
420 unique.push(touch);
421 }
422 }
423 unique
424}
425
426fn add_memory_evidence(
427 memory_facts: &[MemorySearchResult],
428 generated_at: DateTime<Utc>,
429 owners: &mut BTreeMap<String, OwnerAggregate>,
430 uncertainty: &mut Vec<String>,
431) {
432 if memory_facts.is_empty() {
433 uncertainty.push("no repo memory ownership facts matched this path".into());
434 return;
435 }
436
437 let mut owner_hits = 0;
438 for result in memory_facts {
439 let fact_owners = memory_owner_tokens(&result.fact.text);
440 if fact_owners.is_empty() {
441 continue;
442 }
443 owner_hits += fact_owners.len();
444 for owner in fact_owners {
445 let stale = is_stale(generated_at, result.fact.created_at);
446 let memory_score = ((0.08 + 0.10 * result.score.clamp(0.0, 1.0))
447 * result.fact.confidence.score())
448 .min(0.22);
449 let evidence = OwnershipEvidence {
450 source_type: OwnershipSourceType::RepoMemory,
451 owner: owner.clone(),
452 source: result.fact.id.0.clone(),
453 message: format!(
454 "repo memory matched ownership terms: {}; source `{}`",
455 result.match_reason, result.fact.source
456 ),
457 confidence: Confidence::from_score(memory_score),
458 observed_at: Some(result.fact.created_at),
459 stale,
460 };
461 add_evidence(
462 owners,
463 owner,
464 evidence,
465 OwnershipSourceType::RepoMemory,
466 memory_score,
467 );
468 }
469 }
470
471 if owner_hits == 0 {
472 uncertainty.push(
473 "repo memory matched this path but did not contain owner handles or email tokens"
474 .into(),
475 );
476 }
477}
478
479fn add_evidence(
480 owners: &mut BTreeMap<String, OwnerAggregate>,
481 owner: Owner,
482 evidence: OwnershipEvidence,
483 source_type: OwnershipSourceType,
484 score: f32,
485) {
486 let key = owner_key(&owner);
487 let entry = owners
488 .entry(key)
489 .or_insert_with(|| OwnerAggregate::new(owner));
490 match source_type {
491 OwnershipSourceType::Codeowners => entry.codeowners = entry.codeowners.max(score),
492 OwnershipSourceType::GitHistory => entry.git_history = entry.git_history.max(score),
493 OwnershipSourceType::RepoMemory => entry.memory = (entry.memory + score).min(0.22),
494 }
495 entry.evidence.push(evidence);
496}
497
498fn owner_suggestions(
499 owners: BTreeMap<String, OwnerAggregate>,
500 uncertainty: &mut Vec<String>,
501) -> Vec<OwnerSuggestion> {
502 let mut drafts = owners
503 .into_values()
504 .map(|owner| {
505 let freshness = if owner.evidence.iter().any(|evidence| !evidence.stale) {
506 0.08
507 } else {
508 0.0
509 };
510 let mut raw_score =
511 (owner.codeowners + owner.git_history + owner.memory + freshness).min(1.0);
512 if !owner.has_source(OwnershipSourceType::Codeowners)
513 && !owner.has_source(OwnershipSourceType::GitHistory)
514 {
515 raw_score = raw_score.min(MEMORY_ONLY_CAP);
516 }
517 SuggestionDraft {
518 owner,
519 freshness,
520 raw_score,
521 ambiguity_penalty: 0.0,
522 }
523 })
524 .collect::<Vec<_>>();
525
526 drafts.sort_by(compare_drafts);
527 if let Some(top_score) = drafts.first().map(|draft| draft.raw_score) {
528 let close_without_codeowners = drafts
529 .iter()
530 .filter(|draft| {
531 !draft.owner.has_source(OwnershipSourceType::Codeowners)
532 && (top_score - draft.raw_score).abs() <= 0.08
533 })
534 .count();
535 if close_without_codeowners > 1 {
536 uncertainty.push(format!(
537 "ownership is ambiguous across {close_without_codeowners} similarly scored non-CODEOWNERS owner candidates"
538 ));
539 for draft in &mut drafts {
540 if !draft.owner.has_source(OwnershipSourceType::Codeowners)
541 && (top_score - draft.raw_score).abs() <= 0.08
542 {
543 draft.ambiguity_penalty = 0.12;
544 }
545 }
546 }
547 }
548
549 let mut suggestions = drafts
550 .into_iter()
551 .map(|draft| {
552 let final_score = (draft.raw_score - draft.ambiguity_penalty).clamp(0.0, 1.0);
553 let source_types = draft.owner.source_types();
554 let stale = draft.owner.stale();
555 let rationale = ownership_rationale(&source_types, stale);
556 OwnerSuggestion {
557 owner: draft.owner.owner,
558 rationale,
559 confidence: Confidence::from_score(final_score),
560 score: final_score,
561 source_types,
562 stale,
563 evidence: draft.owner.evidence,
564 confidence_breakdown: OwnershipConfidenceBreakdown {
565 codeowners: draft.owner.codeowners,
566 git_history: draft.owner.git_history,
567 memory: draft.owner.memory,
568 freshness: draft.freshness,
569 ambiguity_penalty: draft.ambiguity_penalty,
570 final_score,
571 },
572 }
573 })
574 .collect::<Vec<_>>();
575 suggestions.sort_by(compare_suggestions);
576 suggestions
577}
578
579struct SuggestionDraft {
580 owner: OwnerAggregate,
581 freshness: f32,
582 raw_score: f32,
583 ambiguity_penalty: f32,
584}
585
586fn compare_drafts(left: &SuggestionDraft, right: &SuggestionDraft) -> Ordering {
587 right
588 .raw_score
589 .partial_cmp(&left.raw_score)
590 .unwrap_or(Ordering::Equal)
591 .then_with(|| left.owner.owner.name.cmp(&right.owner.owner.name))
592}
593
594fn compare_suggestions(left: &OwnerSuggestion, right: &OwnerSuggestion) -> Ordering {
595 right
596 .score
597 .partial_cmp(&left.score)
598 .unwrap_or(Ordering::Equal)
599 .then_with(|| left.owner.name.cmp(&right.owner.name))
600}
601
602fn ownership_rationale(source_types: &[OwnershipSourceType], stale: bool) -> String {
603 let mut parts = Vec::new();
604 if source_types.contains(&OwnershipSourceType::Codeowners) {
605 parts.push("CODEOWNERS matched the queried path");
606 }
607 if source_types.contains(&OwnershipSourceType::GitHistory) {
608 parts.push("local git history shows author touch evidence");
609 }
610 if source_types.contains(&OwnershipSourceType::RepoMemory) {
611 parts.push("repo memory contributed secondary ownership evidence");
612 }
613 if stale {
614 parts.push("all ownership evidence is stale");
615 }
616 if parts.is_empty() {
617 "ownership evidence is unavailable".into()
618 } else {
619 parts.join("; ")
620 }
621}
622
623fn memory_owner_tokens(text: &str) -> Vec<Owner> {
624 let mut owners = Vec::new();
625 let mut seen = BTreeSet::new();
626 for token in text.split_whitespace() {
627 if let Some(owner) = owner_from_token(token) {
628 let key = owner_key(&owner);
629 if seen.insert(key) {
630 owners.push(owner);
631 }
632 }
633 }
634 owners
635}
636
637fn owner_from_token(token: &str) -> Option<Owner> {
638 let cleaned = token.trim_matches(|ch: char| {
639 matches!(
640 ch,
641 ',' | ';' | ':' | '.' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | '"' | '\''
642 )
643 });
644 if cleaned.len() < 2 {
645 return None;
646 }
647 if cleaned.starts_with('@') && cleaned.len() > 1 {
648 return Some(Owner {
649 name: cleaned.to_string(),
650 email: None,
651 });
652 }
653 if looks_like_email(cleaned) {
654 return Some(Owner {
655 name: cleaned.to_string(),
656 email: Some(cleaned.to_string()),
657 });
658 }
659 None
660}
661
662fn looks_like_email(value: &str) -> bool {
663 let Some((local, domain)) = value.split_once('@') else {
664 return false;
665 };
666 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
667}
668
669fn owner_key(owner: &Owner) -> String {
670 owner
671 .email
672 .as_deref()
673 .unwrap_or(&owner.name)
674 .trim_start_matches('@')
675 .to_ascii_lowercase()
676}
677
678fn is_stale(generated_at: DateTime<Utc>, observed_at: DateTime<Utc>) -> bool {
679 generated_at.signed_duration_since(observed_at) > Duration::days(STALE_AFTER_DAYS)
680}
681
682fn repo_relative_path(repo: &Path, path: &Path) -> PathBuf {
683 if path.is_absolute() {
684 let repo = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
685 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
686 if let Ok(relative) = path.strip_prefix(repo) {
687 return clean_relative_path(relative);
688 }
689 return clean_relative_path(&path);
690 }
691 clean_relative_path(path)
692}
693
694fn clean_relative_path(path: &Path) -> PathBuf {
695 let mut cleaned = PathBuf::new();
696 for component in path.components() {
697 match component {
698 Component::CurDir => {}
699 Component::Normal(value) => cleaned.push(value),
700 Component::ParentDir => cleaned.push(".."),
701 Component::RootDir | Component::Prefix(_) => cleaned.push(component.as_os_str()),
702 }
703 }
704 if cleaned.as_os_str().is_empty() {
705 PathBuf::from(".")
706 } else {
707 cleaned
708 }
709}
710
711fn normalize_path_for_glob(path: &Path) -> String {
712 clean_relative_path(path)
713 .to_string_lossy()
714 .replace('\\', "/")
715}
716
717#[cfg(test)]
718mod tests {
719 use super::*;
720 use chrono::TimeZone;
721 use open_kioku_core::{
722 FileProvenance, GitChangeKind, GitCochangeEdge, GitCommitId, GitCommitRecord,
723 HistorySnapshot, HistorySummary, MemoryFact, MemoryFactId, ProvenanceTouch,
724 };
725 use std::sync::Mutex;
726
727 #[derive(Default)]
728 struct StubHistoryStore {
729 provenance: Mutex<Option<FileProvenance>>,
730 }
731
732 impl StubHistoryStore {
733 fn with_provenance(provenance: FileProvenance) -> Self {
734 Self {
735 provenance: Mutex::new(Some(provenance)),
736 }
737 }
738 }
739
740 impl HistoryStore for StubHistoryStore {
741 fn put_history_snapshot(&self, _snapshot: &HistorySnapshot) -> Result<()> {
742 Ok(())
743 }
744
745 fn history_for_file(&self, path: &Path, _limit: usize) -> Result<HistorySummary> {
746 Ok(HistorySummary::empty(path))
747 }
748
749 fn provenance_for_path(&self, path: &Path, _limit: usize) -> Result<FileProvenance> {
750 Ok(self
751 .provenance
752 .lock()
753 .unwrap()
754 .clone()
755 .unwrap_or_else(|| empty_provenance(path)))
756 }
757
758 fn cochange_neighbors(&self, _path: &Path, _limit: usize) -> Result<Vec<GitCochangeEdge>> {
759 Ok(Vec::new())
760 }
761
762 fn recent_commits(&self, _limit: usize) -> Result<Vec<GitCommitRecord>> {
763 Ok(Vec::new())
764 }
765 }
766
767 #[test]
768 fn codeowners_outranks_weak_memory_only_evidence() {
769 let dir = tempfile::tempdir().unwrap();
770 fs::create_dir_all(dir.path().join(".github")).unwrap();
771 fs::write(
772 dir.path().join(".github/CODEOWNERS"),
773 "src/** @platform-team\n",
774 )
775 .unwrap();
776 let history = StubHistoryStore::default();
777 let memory = vec![memory_result(
778 "src/a.rs owner @memory-team",
779 Confidence::High,
780 )];
781
782 let report = ownership_for_path(OwnershipInput {
783 repo: dir.path(),
784 path: Path::new("src/a.rs"),
785 history: &history,
786 memory_facts: &memory,
787 components: Vec::new(),
788 })
789 .unwrap();
790
791 assert_eq!(report.owners[0].owner.name, "@platform-team");
792 assert_eq!(report.owners[0].confidence, Confidence::High);
793 assert!(report.owners[0]
794 .source_types
795 .contains(&OwnershipSourceType::Codeowners));
796 let memory_owner = report
797 .owners
798 .iter()
799 .find(|owner| owner.owner.name == "@memory-team")
800 .unwrap();
801 assert!(report.owners[0].score > memory_owner.score);
802 assert_eq!(memory_owner.confidence, Confidence::Low);
803 }
804
805 #[test]
806 fn source_mixing_raises_confidence_for_same_owner() {
807 let dir = tempfile::tempdir().unwrap();
808 fs::write(dir.path().join("CODEOWNERS"), "src/** dev@example.com\n").unwrap();
809 let history = StubHistoryStore::with_provenance(FileProvenance {
810 path: PathBuf::from("src/a.rs"),
811 first_seen: None,
812 last_touched: None,
813 recent_touches: vec![touch("one", "Dev", "dev@example.com", 2026, 6, 1)],
814 confidence: Confidence::High,
815 truncated: false,
816 uncertainty: Vec::new(),
817 });
818 let memory = vec![memory_result(
819 "src/a.rs maintainer dev@example.com",
820 Confidence::High,
821 )];
822
823 let report = ownership_for_path(OwnershipInput {
824 repo: dir.path(),
825 path: Path::new("src/a.rs"),
826 history: &history,
827 memory_facts: &memory,
828 components: Vec::new(),
829 })
830 .unwrap();
831
832 let owner = &report.owners[0];
833 assert_eq!(owner.owner.email.as_deref(), Some("dev@example.com"));
834 assert!(owner
835 .source_types
836 .contains(&OwnershipSourceType::Codeowners));
837 assert!(owner
838 .source_types
839 .contains(&OwnershipSourceType::GitHistory));
840 assert!(owner
841 .source_types
842 .contains(&OwnershipSourceType::RepoMemory));
843 assert!(owner.score >= 0.95);
844 }
845
846 #[test]
847 fn stale_ambiguous_git_history_is_not_authoritative() {
848 let dir = tempfile::tempdir().unwrap();
849 let history = StubHistoryStore::with_provenance(FileProvenance {
850 path: PathBuf::from("src/a.rs"),
851 first_seen: None,
852 last_touched: None,
853 recent_touches: vec![
854 touch("one", "Old One", "one@example.com", 2020, 1, 1),
855 touch("two", "Old Two", "two@example.com", 2020, 1, 2),
856 ],
857 confidence: Confidence::Medium,
858 truncated: false,
859 uncertainty: Vec::new(),
860 });
861
862 let report = ownership_for_path(OwnershipInput {
863 repo: dir.path(),
864 path: Path::new("src/a.rs"),
865 history: &history,
866 memory_facts: &[],
867 components: Vec::new(),
868 })
869 .unwrap();
870
871 assert_eq!(report.owners.len(), 2);
872 assert!(report.owners.iter().all(|owner| owner.stale));
873 assert!(report
874 .owners
875 .iter()
876 .all(|owner| owner.confidence == Confidence::Low));
877 assert!(report
878 .uncertainty
879 .iter()
880 .any(|note| note.contains("ambiguous")));
881 }
882
883 #[test]
884 fn missing_ownership_returns_uncertainty_without_fabricating_owner() {
885 let dir = tempfile::tempdir().unwrap();
886 let history = StubHistoryStore::default();
887
888 let report = ownership_for_path(OwnershipInput {
889 repo: dir.path(),
890 path: Path::new("src/a.rs"),
891 history: &history,
892 memory_facts: &[],
893 components: Vec::new(),
894 })
895 .unwrap();
896
897 assert!(report.owners.is_empty());
898 assert!(report
899 .uncertainty
900 .iter()
901 .any(|note| note.contains("no owner suggestions")));
902 }
903
904 #[test]
905 fn invalid_codeowners_pattern_is_reported_as_uncertainty() {
906 let dir = tempfile::tempdir().unwrap();
907 fs::write(dir.path().join("CODEOWNERS"), "[ @team\n").unwrap();
908 let history = StubHistoryStore::default();
909
910 let report = ownership_for_path(OwnershipInput {
911 repo: dir.path(),
912 path: Path::new("src/a.rs"),
913 history: &history,
914 memory_facts: &[],
915 components: Vec::new(),
916 })
917 .unwrap();
918
919 assert!(report
920 .uncertainty
921 .iter()
922 .any(|note| note.contains("invalid CODEOWNERS pattern")));
923 }
924
925 fn empty_provenance(path: &Path) -> FileProvenance {
926 FileProvenance {
927 path: path.to_path_buf(),
928 first_seen: None,
929 last_touched: None,
930 recent_touches: Vec::new(),
931 confidence: Confidence::Low,
932 truncated: false,
933 uncertainty: Vec::new(),
934 }
935 }
936
937 fn touch(
938 id: &str,
939 name: &str,
940 email: &str,
941 year: i32,
942 month: u32,
943 day: u32,
944 ) -> ProvenanceTouch {
945 let timestamp = Utc
946 .with_ymd_and_hms(year, month, day, 12, 0, 0)
947 .single()
948 .unwrap();
949 ProvenanceTouch {
950 commit: GitCommitRecord {
951 id: GitCommitId::new(id),
952 parent_ids: Vec::new(),
953 author: Owner {
954 name: name.into(),
955 email: Some(email.into()),
956 },
957 committer: None,
958 authored_at: timestamp,
959 committed_at: timestamp,
960 summary: format!("commit {id}"),
961 message: format!("commit {id}"),
962 file_count: 1,
963 },
964 path: PathBuf::from("src/a.rs"),
965 previous_path: None,
966 symbol_id: None,
967 qualified_name: None,
968 change_kind: GitChangeKind::Modified,
969 line_ranges: Vec::new(),
970 confidence: Confidence::High,
971 uncertainty: Vec::new(),
972 }
973 }
974
975 fn memory_result(text: &str, confidence: Confidence) -> MemorySearchResult {
976 MemorySearchResult {
977 fact: MemoryFact {
978 id: MemoryFactId::new(format!("memory:{}", text.len())),
979 text: text.into(),
980 source: "test".into(),
981 confidence,
982 entities: Vec::new(),
983 created_at: Utc::now(),
984 },
985 score: 0.50,
986 match_reason: "test memory match".into(),
987 evidence: vec!["test".into()],
988 }
989 }
990}