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 = github.map(|gh| gh.commit_url(&commit_info.hash));
104
105    // Try to get GitHub username from email or fall back to GitHub API.
106    let commit_author_github_url =
107        resolve_commit_author_profile_url(github, &commit_info.author_email, &commit_info.hash)
108            .await;
109
110    let commit_date = commit_info.date_rfc3339();
111    let release =
112        resolve_release_for_commit(git, github, &commit_info.hash, Some(&commit_date)).await;
113
114    IdentifiedThing::Enriched(Box::new(EnrichedInfo {
115        entry_point,
116        commit: Some(commit_info),
117        commit_url,
118        commit_author_github_url,
119        pr: None,
120        issue: None,
121        release,
122    }))
123}
124
125/// Resolve an issue/PR number
126async fn resolve_number(
127    number: u64,
128    git: &GitRepo,
129    github: Option<&GitHubClient>,
130) -> Option<IdentifiedThing> {
131    let gh = github?;
132
133    if let Some(pr_info) = gh.fetch_pr(number).await {
134        if let Some(merge_sha) = &pr_info.merge_commit_sha
135            && let Some(commit_info) = git.find_commit(merge_sha)
136        {
137            let commit_url = Some(gh.commit_url(&commit_info.hash));
138
139            let commit_author_github_url = resolve_commit_author_profile_url(
140                Some(gh),
141                &commit_info.author_email,
142                &commit_info.hash,
143            )
144            .await;
145
146            let commit_date = commit_info.date_rfc3339();
147            let release =
148                resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
149
150            return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
151                entry_point: EntryPoint::PullRequestNumber(number),
152                commit: Some(commit_info),
153                commit_url,
154                commit_author_github_url,
155                pr: Some(pr_info),
156                issue: None,
157                release,
158            })));
159        }
160
161        return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
162            entry_point: EntryPoint::PullRequestNumber(number),
163            commit: None,
164            commit_url: None,
165            commit_author_github_url: None,
166            pr: Some(pr_info),
167            issue: None,
168            release: None,
169        })));
170    }
171
172    if let Some(issue_info) = gh.fetch_issue(number).await {
173        if let Some(&first_pr_number) = issue_info.closing_prs.first()
174            && let Some(pr_info) = gh.fetch_pr(first_pr_number).await
175        {
176            if let Some(merge_sha) = &pr_info.merge_commit_sha
177                && let Some(commit_info) = git.find_commit(merge_sha)
178            {
179                let commit_url = Some(gh.commit_url(&commit_info.hash));
180
181                let commit_author_github_url = resolve_commit_author_profile_url(
182                    Some(gh),
183                    &commit_info.author_email,
184                    &commit_info.hash,
185                )
186                .await;
187
188                let commit_date = commit_info.date_rfc3339();
189                let release =
190                    resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
191
192                return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
193                    entry_point: EntryPoint::IssueNumber(number),
194                    commit: Some(commit_info),
195                    commit_url,
196                    commit_author_github_url,
197                    pr: Some(pr_info),
198                    issue: Some(issue_info),
199                    release,
200                })));
201            }
202
203            return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
204                entry_point: EntryPoint::IssueNumber(number),
205                commit: None,
206                commit_url: None,
207                commit_author_github_url: None,
208                pr: Some(pr_info),
209                issue: Some(issue_info),
210                release: None,
211            })));
212        }
213
214        return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
215            entry_point: EntryPoint::IssueNumber(number),
216            commit: None,
217            commit_url: None,
218            commit_author_github_url: None,
219            pr: None,
220            issue: Some(issue_info),
221            release: None,
222        })));
223    }
224
225    None
226}
227
228async fn resolve_release_for_commit(
229    git: &GitRepo,
230    github: Option<&GitHubClient>,
231    commit_hash: &str,
232    fallback_since: Option<&str>,
233) -> Option<TagInfo> {
234    let candidates = collect_tag_candidates(git, commit_hash);
235    let has_semver = candidates.iter().any(|candidate| candidate.info.is_semver);
236
237    let targeted_releases = if let Some(gh) = github {
238        let target_names: Vec<_> = if has_semver {
239            candidates
240                .iter()
241                .filter(|candidate| candidate.info.is_semver)
242                .map(|candidate| candidate.info.name.clone())
243                .collect()
244        } else {
245            candidates
246                .iter()
247                .map(|candidate| candidate.info.name.clone())
248                .collect()
249        };
250
251        let mut releases = Vec::new();
252        for tag_name in target_names {
253            if let Some(release) = gh.fetch_release_by_tag(&tag_name).await {
254                releases.push(release);
255            }
256        }
257        releases
258    } else {
259        Vec::new()
260    };
261
262    // Skip GitHub API fallback if we have any local tags (not just semver).
263    // This avoids expensive API calls and ancestry checks when we already have the answer locally.
264    let fallback_releases = if !candidates.is_empty() {
265        Vec::new()
266    } else if let (Some(gh), Some(since)) = (github, fallback_since) {
267        gh.fetch_releases_since(Some(since)).await
268    } else {
269        Vec::new()
270    };
271
272    resolve_release_from_data(
273        git,
274        candidates,
275        &targeted_releases,
276        if fallback_releases.is_empty() {
277            None
278        } else {
279            Some(&fallback_releases)
280        },
281        commit_hash,
282        has_semver,
283    )
284}
285
286struct TagCandidate {
287    info: TagInfo,
288    timestamp: i64,
289}
290
291fn collect_tag_candidates(git: &GitRepo, commit_hash: &str) -> Vec<TagCandidate> {
292    git.tags_containing_commit(commit_hash)
293        .into_iter()
294        .map(|tag| {
295            let timestamp = git.get_commit_timestamp(&tag.commit_hash);
296            TagCandidate {
297                info: tag,
298                timestamp,
299            }
300        })
301        .collect()
302}
303
304fn resolve_release_from_data(
305    git: &GitRepo,
306    mut candidates: Vec<TagCandidate>,
307    targeted_releases: &[ReleaseInfo],
308    fallback_releases: Option<&[ReleaseInfo]>,
309    commit_hash: &str,
310    had_semver: bool,
311) -> Option<TagInfo> {
312    apply_release_metadata(&mut candidates, targeted_releases);
313
314    let local_best = pick_best_tag(&candidates);
315
316    if had_semver {
317        return local_best;
318    }
319
320    let fallback_best = fallback_releases.and_then(|releases| {
321        let remote_candidates = releases
322            .iter()
323            .filter_map(|release| {
324                git.tag_from_release(release).and_then(|tag| {
325                    if git.tag_contains_commit(&tag.commit_hash, commit_hash) {
326                        let timestamp = git.get_commit_timestamp(&tag.commit_hash);
327                        Some(TagCandidate {
328                            info: tag,
329                            timestamp,
330                        })
331                    } else {
332                        None
333                    }
334                })
335            })
336            .collect::<Vec<_>>();
337
338        pick_best_tag(&remote_candidates)
339    });
340
341    fallback_best.or(local_best)
342}
343
344fn apply_release_metadata(candidates: &mut [TagCandidate], releases: &[ReleaseInfo]) {
345    let release_map: HashMap<&str, &ReleaseInfo> = releases
346        .iter()
347        .map(|release| (release.tag_name.as_str(), release))
348        .collect();
349
350    for candidate in candidates {
351        if let Some(release) = release_map.get(candidate.info.name.as_str()) {
352            candidate.info.is_release = true;
353            candidate.info.release_name = release.name.clone();
354            candidate.info.release_url = Some(release.url.clone());
355            candidate.info.published_at = release.published_at.clone();
356        }
357    }
358}
359
360fn pick_best_tag(candidates: &[TagCandidate]) -> Option<TagInfo> {
361    fn select_with_pred<F>(candidates: &[TagCandidate], predicate: F) -> Option<TagInfo>
362    where
363        F: Fn(&TagCandidate) -> bool,
364    {
365        candidates
366            .iter()
367            .filter(|candidate| predicate(candidate))
368            .min_by_key(|candidate| candidate.timestamp)
369            .map(|candidate| candidate.info.clone())
370    }
371
372    select_with_pred(candidates, |c| c.info.is_release && c.info.is_semver)
373        .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && c.info.is_semver))
374        .or_else(|| select_with_pred(candidates, |c| c.info.is_release && !c.info.is_semver))
375        .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && !c.info.is_semver))
376}
377
378/// Resolve a file path
379async fn resolve_file(
380    file_info: FileInfo,
381    git: &GitRepo,
382    github: Option<&GitHubClient>,
383) -> IdentifiedThing {
384    let commit_date = file_info.last_commit.date_rfc3339();
385    let release =
386        resolve_release_for_commit(git, github, &file_info.last_commit.hash, Some(&commit_date))
387            .await;
388
389    let (commit_url, author_urls) = if let Some(gh) = github {
390        let url = Some(gh.commit_url(&file_info.last_commit.hash));
391        let urls: Vec<Option<String>> = file_info
392            .previous_authors
393            .iter()
394            .map(|(_, _, email)| {
395                extract_github_username(email).map(|u| GitHubClient::profile_url(&u))
396            })
397            .collect();
398        (url, urls)
399    } else {
400        (None, vec![])
401    };
402
403    IdentifiedThing::File(Box::new(FileResult {
404        file_info,
405        commit_url,
406        author_urls,
407        release,
408    }))
409}
410
411async fn resolve_commit_author_profile_url(
412    github: Option<&GitHubClient>,
413    email: &str,
414    commit_hash: &str,
415) -> Option<String> {
416    if let Some(username) = extract_github_username(email) {
417        return Some(GitHubClient::profile_url(&username));
418    }
419
420    let gh = github?;
421    gh.fetch_commit_author(commit_hash)
422        .await
423        .map(|login| GitHubClient::profile_url(&login))
424}
425
426/// Try to extract GitHub username from email
427fn extract_github_username(email: &str) -> Option<String> {
428    // GitHub emails are typically in the format: username@users.noreply.github.com
429    // Or: id+username@users.noreply.github.com
430    if email.ends_with("@users.noreply.github.com") {
431        let parts: Vec<&str> = email.split('@').collect();
432        if let Some(user_part) = parts.first() {
433            // Handle both formats
434            if let Some(username) = user_part.split('+').next_back() {
435                return Some(username.to_string());
436            }
437        }
438    }
439
440    None
441}