wtg_cli/
identifier.rs

1use crate::error::{Result, WtgError};
2use crate::git::{CommitInfo, FileInfo, GitRepo, TagInfo};
3use crate::github::{GitHubClient, IssueInfo, PullRequestInfo, ReleaseInfo};
4use std::collections::HashMap;
5use std::sync::Arc;
6
7/// What the user entered to search for
8#[derive(Debug, Clone)]
9pub enum EntryPoint {
10    Commit(String),         // Hash they entered
11    IssueNumber(u64),       // Issue # they entered
12    PullRequestNumber(u64), // PR # they entered
13    FilePath(String),       // File path they entered
14    Tag(String),            // Tag they entered
15}
16
17/// The enriched result of identification - progressively accumulates data
18#[derive(Debug, Clone)]
19pub struct EnrichedInfo {
20    pub entry_point: EntryPoint,
21
22    // Core - the commit (always present for complete results)
23    pub commit: Option<CommitInfo>,
24    pub commit_url: Option<String>,
25    pub commit_author_github_url: Option<String>,
26
27    // Enrichment Layer 1: PR (if this commit came from a PR)
28    pub pr: Option<PullRequestInfo>,
29
30    // Enrichment Layer 2: Issue (if this PR was fixing an issue)
31    pub issue: Option<IssueInfo>,
32
33    // Metadata
34    pub release: Option<TagInfo>,
35}
36
37/// For file results (special case with blame history)
38#[derive(Debug, Clone)]
39pub struct FileResult {
40    pub file_info: FileInfo,
41    pub commit_url: Option<String>,
42    pub author_urls: Vec<Option<String>>,
43    pub release: Option<TagInfo>,
44}
45
46#[derive(Debug, Clone)]
47pub enum IdentifiedThing {
48    Enriched(Box<EnrichedInfo>),
49    File(Box<FileResult>),
50    TagOnly(Box<TagInfo>, Option<String>), // Just a tag, no commit yet
51}
52
53pub async fn identify(input: &str, git: GitRepo) -> Result<IdentifiedThing> {
54    let github = git
55        .github_remote()
56        .map(|(owner, repo)| Arc::new(GitHubClient::new(owner, repo)));
57
58    // Try as commit hash first
59    if let Some(commit_info) = git.find_commit(input) {
60        return Ok(resolve_commit(
61            EntryPoint::Commit(input.to_string()),
62            commit_info,
63            &git,
64            github.as_deref(),
65        )
66        .await);
67    }
68
69    // Try as issue/PR number (if it's all digits or starts with #)
70    let number_str = input.strip_prefix('#').unwrap_or(input);
71    if let Ok(number) = number_str.parse::<u64>()
72        && let Some(result) = Box::pin(resolve_number(number, &git, github.as_deref())).await
73    {
74        return Ok(result);
75    }
76
77    // Try as file path
78    if let Some(file_info) = git.find_file(input) {
79        return Ok(resolve_file(file_info, &git, github.as_deref()).await);
80    }
81
82    // Try as tag
83    let tags = git.get_tags();
84    if let Some(tag_info) = tags.iter().find(|t| t.name == input) {
85        let github_url = github.as_deref().map(|gh| gh.tag_url(&tag_info.name));
86        return Ok(IdentifiedThing::TagOnly(
87            Box::new(tag_info.clone()),
88            github_url,
89        ));
90    }
91
92    // Nothing found
93    Err(WtgError::NotFound(input.to_string()))
94}
95
96/// Resolve a commit to enriched info
97async fn resolve_commit(
98    entry_point: EntryPoint,
99    commit_info: CommitInfo,
100    git: &GitRepo,
101    github: Option<&GitHubClient>,
102) -> IdentifiedThing {
103    let (commit_url, commit_author_github_url) =
104        resolve_commit_urls(github, &commit_info.author_email, &commit_info.hash).await;
105
106    let commit_date = commit_info.date_rfc3339();
107    let release =
108        resolve_release_for_commit(git, github, &commit_info.hash, Some(&commit_date)).await;
109
110    IdentifiedThing::Enriched(Box::new(EnrichedInfo {
111        entry_point,
112        commit: Some(commit_info),
113        commit_url,
114        commit_author_github_url,
115        pr: None,
116        issue: None,
117        release,
118    }))
119}
120
121/// Resolve an issue/PR number
122async fn resolve_number(
123    number: u64,
124    git: &GitRepo,
125    github: Option<&GitHubClient>,
126) -> Option<IdentifiedThing> {
127    let gh = github?;
128
129    if let Some(pr_info) = gh.fetch_pr(number).await {
130        if let Some(merge_sha) = &pr_info.merge_commit_sha
131            && let Some(commit_info) = git.find_commit(merge_sha)
132        {
133            let (commit_url, commit_author_github_url) =
134                resolve_commit_urls(Some(gh), &commit_info.author_email, &commit_info.hash).await;
135
136            let commit_date = commit_info.date_rfc3339();
137            let release =
138                resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
139
140            return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
141                entry_point: EntryPoint::PullRequestNumber(number),
142                commit: Some(commit_info),
143                commit_url,
144                commit_author_github_url,
145                pr: Some(pr_info),
146                issue: None,
147                release,
148            })));
149        }
150
151        return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
152            entry_point: EntryPoint::PullRequestNumber(number),
153            commit: None,
154            commit_url: None,
155            commit_author_github_url: None,
156            pr: Some(pr_info),
157            issue: None,
158            release: None,
159        })));
160    }
161
162    if let Some(issue_info) = gh.fetch_issue(number).await {
163        if let Some(&first_pr_number) = issue_info.closing_prs.first()
164            && let Some(pr_info) = gh.fetch_pr(first_pr_number).await
165        {
166            if let Some(merge_sha) = &pr_info.merge_commit_sha
167                && let Some(commit_info) = git.find_commit(merge_sha)
168            {
169                let (commit_url, commit_author_github_url) =
170                    resolve_commit_urls(Some(gh), &commit_info.author_email, &commit_info.hash)
171                        .await;
172
173                let commit_date = commit_info.date_rfc3339();
174                let release =
175                    resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
176
177                return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
178                    entry_point: EntryPoint::IssueNumber(number),
179                    commit: Some(commit_info),
180                    commit_url,
181                    commit_author_github_url,
182                    pr: Some(pr_info),
183                    issue: Some(issue_info),
184                    release,
185                })));
186            }
187
188            return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
189                entry_point: EntryPoint::IssueNumber(number),
190                commit: None,
191                commit_url: None,
192                commit_author_github_url: None,
193                pr: Some(pr_info),
194                issue: Some(issue_info),
195                release: None,
196            })));
197        }
198
199        return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
200            entry_point: EntryPoint::IssueNumber(number),
201            commit: None,
202            commit_url: None,
203            commit_author_github_url: None,
204            pr: None,
205            issue: Some(issue_info),
206            release: None,
207        })));
208    }
209
210    None
211}
212
213async fn resolve_release_for_commit(
214    git: &GitRepo,
215    github: Option<&GitHubClient>,
216    commit_hash: &str,
217    fallback_since: Option<&str>,
218) -> Option<TagInfo> {
219    let candidates = collect_tag_candidates(git, commit_hash);
220    let has_semver = candidates.iter().any(|candidate| candidate.info.is_semver);
221
222    let targeted_releases = if let Some(gh) = github {
223        let target_names: Vec<_> = if has_semver {
224            candidates
225                .iter()
226                .filter(|candidate| candidate.info.is_semver)
227                .map(|candidate| candidate.info.name.clone())
228                .collect()
229        } else {
230            candidates
231                .iter()
232                .map(|candidate| candidate.info.name.clone())
233                .collect()
234        };
235
236        let mut releases = Vec::new();
237        for tag_name in target_names {
238            if let Some(release) = gh.fetch_release_by_tag(&tag_name).await {
239                releases.push(release);
240            }
241        }
242        releases
243    } else {
244        Vec::new()
245    };
246
247    // Skip GitHub API fallback if we have any local tags (not just semver).
248    // This avoids expensive API calls and ancestry checks when we already have the answer locally.
249    let fallback_releases = if !candidates.is_empty() {
250        Vec::new()
251    } else if let (Some(gh), Some(since)) = (github, fallback_since) {
252        gh.fetch_releases_since(Some(since)).await
253    } else {
254        Vec::new()
255    };
256
257    resolve_release_from_data(
258        git,
259        candidates,
260        &targeted_releases,
261        if fallback_releases.is_empty() {
262            None
263        } else {
264            Some(&fallback_releases)
265        },
266        commit_hash,
267        has_semver,
268    )
269}
270
271struct TagCandidate {
272    info: TagInfo,
273    timestamp: i64,
274}
275
276fn collect_tag_candidates(git: &GitRepo, commit_hash: &str) -> Vec<TagCandidate> {
277    git.tags_containing_commit(commit_hash)
278        .into_iter()
279        .map(|tag| {
280            let timestamp = git.get_commit_timestamp(&tag.commit_hash);
281            TagCandidate {
282                info: tag,
283                timestamp,
284            }
285        })
286        .collect()
287}
288
289fn resolve_release_from_data(
290    git: &GitRepo,
291    mut candidates: Vec<TagCandidate>,
292    targeted_releases: &[ReleaseInfo],
293    fallback_releases: Option<&[ReleaseInfo]>,
294    commit_hash: &str,
295    had_semver: bool,
296) -> Option<TagInfo> {
297    apply_release_metadata(&mut candidates, targeted_releases);
298
299    let local_best = pick_best_tag(&candidates);
300
301    if had_semver {
302        return local_best;
303    }
304
305    let fallback_best = fallback_releases.and_then(|releases| {
306        let remote_candidates = releases
307            .iter()
308            .filter_map(|release| {
309                git.tag_from_release(release).and_then(|tag| {
310                    if git.tag_contains_commit(&tag.commit_hash, commit_hash) {
311                        let timestamp = git.get_commit_timestamp(&tag.commit_hash);
312                        Some(TagCandidate {
313                            info: tag,
314                            timestamp,
315                        })
316                    } else {
317                        None
318                    }
319                })
320            })
321            .collect::<Vec<_>>();
322
323        pick_best_tag(&remote_candidates)
324    });
325
326    fallback_best.or(local_best)
327}
328
329fn apply_release_metadata(candidates: &mut [TagCandidate], releases: &[ReleaseInfo]) {
330    let release_map: HashMap<&str, &ReleaseInfo> = releases
331        .iter()
332        .map(|release| (release.tag_name.as_str(), release))
333        .collect();
334
335    for candidate in candidates {
336        if let Some(release) = release_map.get(candidate.info.name.as_str()) {
337            candidate.info.is_release = true;
338            candidate.info.release_name = release.name.clone();
339            candidate.info.release_url = Some(release.url.clone());
340            candidate.info.published_at = release.published_at.clone();
341        }
342    }
343}
344
345fn pick_best_tag(candidates: &[TagCandidate]) -> Option<TagInfo> {
346    fn select_with_pred<F>(candidates: &[TagCandidate], predicate: F) -> Option<TagInfo>
347    where
348        F: Fn(&TagCandidate) -> bool,
349    {
350        candidates
351            .iter()
352            .filter(|candidate| predicate(candidate))
353            .min_by_key(|candidate| candidate.timestamp)
354            .map(|candidate| candidate.info.clone())
355    }
356
357    select_with_pred(candidates, |c| c.info.is_release && c.info.is_semver)
358        .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && c.info.is_semver))
359        .or_else(|| select_with_pred(candidates, |c| c.info.is_release && !c.info.is_semver))
360        .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && !c.info.is_semver))
361}
362
363/// Resolve a file path
364async fn resolve_file(
365    file_info: FileInfo,
366    git: &GitRepo,
367    github: Option<&GitHubClient>,
368) -> IdentifiedThing {
369    let commit_date = file_info.last_commit.date_rfc3339();
370    let release =
371        resolve_release_for_commit(git, github, &file_info.last_commit.hash, Some(&commit_date))
372            .await;
373
374    let (commit_url, author_urls) = if let Some(gh) = github {
375        let url = Some(gh.commit_url(&file_info.last_commit.hash));
376        let urls: Vec<Option<String>> = file_info
377            .previous_authors
378            .iter()
379            .map(|(_, _, email)| {
380                extract_github_username(email).map(|u| GitHubClient::profile_url(&u))
381            })
382            .collect();
383        (url, urls)
384    } else {
385        (None, vec![])
386    };
387
388    IdentifiedThing::File(Box::new(FileResult {
389        file_info,
390        commit_url,
391        author_urls,
392        release,
393    }))
394}
395
396/// Resolve commit and author URLs efficiently
397/// Returns (`commit_url`, `author_profile_url`)
398async fn resolve_commit_urls(
399    github: Option<&GitHubClient>,
400    email: &str,
401    commit_hash: &str,
402) -> (Option<String>, Option<String>) {
403    // Try to extract username from email first (cheap, no API call)
404    if let Some(username) = extract_github_username(email) {
405        // We have the username from email, but still need commit URL
406        let commit_url = github.map(|gh| gh.commit_url(commit_hash));
407        return (commit_url, Some(GitHubClient::profile_url(&username)));
408    }
409
410    // Try to fetch both URLs from GitHub API in one call
411    if let Some(gh) = github {
412        if let Some((_hash, commit_url, author_info)) = gh.fetch_commit_info(commit_hash).await {
413            let author_url = author_info.map(|(_login, url)| url);
414            return (Some(commit_url), author_url);
415        }
416        // API call failed, fall back to manual URL building
417        return (Some(gh.commit_url(commit_hash)), None);
418    }
419
420    (None, None)
421}
422
423/// Try to extract GitHub username from email
424fn extract_github_username(email: &str) -> Option<String> {
425    // GitHub emails are typically in the format: username@users.noreply.github.com
426    // Or: id+username@users.noreply.github.com
427    if email.ends_with("@users.noreply.github.com") {
428        let parts: Vec<&str> = email.split('@').collect();
429        if let Some(user_part) = parts.first() {
430            // Handle both formats
431            if let Some(username) = user_part.split('+').next_back() {
432                return Some(username.to_string());
433            }
434        }
435    }
436
437    None
438}