Skip to main content

open_kioku_git/
ownership.rs

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}