wtg_cli/backend/
mod.rs

1//! Backend trait abstraction for git/GitHub operations.
2//!
3//! This module provides a trait-based abstraction over data sources (local git, GitHub API, or both),
4//! enabling:
5//! - Cross-project references (issues referencing PRs in different repos)
6//! - Future non-GitHub hosting support
7//! - Optimal path selection when both local and remote sources are available
8
9mod combined_backend;
10mod git_backend;
11mod github_backend;
12
13pub(crate) use combined_backend::CombinedBackend;
14pub use git_backend::GitBackend;
15pub(crate) use github_backend::GitHubBackend;
16
17use std::collections::HashSet;
18
19use async_trait::async_trait;
20use chrono::{DateTime, Utc};
21
22use crate::error::{WtgError, WtgResult};
23use crate::git::{CommitInfo, FileInfo, GitRepo, TagInfo};
24use crate::github::{ExtendedIssueInfo, PullRequestInfo};
25use crate::notice::{Notice, NoticeCallback, no_notices};
26use crate::parse_input::{ParsedInput, ParsedQuery, Query};
27use crate::remote::{RemoteHost, RemoteInfo};
28
29/// Unified backend trait for all git/GitHub operations.
30///
31/// Backends implement methods for operations they support. Default implementations
32/// return `WtgError::Unsupported` for operations not available.
33#[async_trait]
34pub trait Backend: Send + Sync {
35    // ============================================
36    // Cross-project support (default: not supported)
37    // ============================================
38
39    /// Get a backend for fetching PR data if the PR is from a different repository.
40    /// Returns None if same repo or cross-project not supported.
41    async fn backend_for_pr(&self, _pr: &PullRequestInfo) -> Option<Box<dyn Backend>> {
42        None
43    }
44
45    // ============================================
46    // Commit operations (default: Unsupported)
47    // ============================================
48
49    /// Find commit by hash (short or full).
50    async fn find_commit(&self, _hash: &str) -> WtgResult<CommitInfo> {
51        Err(WtgError::Unsupported("commit lookup".into()))
52    }
53
54    /// Enrich commit with additional info (author URLs, commit URL, etc.).
55    async fn enrich_commit(&self, commit: CommitInfo) -> CommitInfo {
56        commit
57    }
58
59    /// Find commit info from a PR (using merge commit SHA).
60    async fn find_commit_for_pr(&self, pr: &PullRequestInfo) -> WtgResult<CommitInfo> {
61        if let Some(ref sha) = pr.merge_commit_sha {
62            self.find_commit(sha).await
63        } else {
64            Err(WtgError::NotFound("PR has no merge commit".into()))
65        }
66    }
67
68    // ============================================
69    // File operations (default: Unsupported)
70    // ============================================
71
72    /// Find file and its history in the repository.
73    async fn find_file(&self, _branch: &str, _path: &str) -> WtgResult<FileInfo> {
74        Err(WtgError::Unsupported("file lookup".into()))
75    }
76
77    // ============================================
78    // Tag/Release operations (default: Unsupported)
79    // ============================================
80
81    /// Find a specific tag by name.
82    async fn find_tag(&self, _name: &str) -> WtgResult<TagInfo> {
83        Err(WtgError::Unsupported("tag lookup".into()))
84    }
85
86    /// Disambiguate a parsed query into a concrete query.
87    async fn disambiguate_query(&self, query: &ParsedQuery) -> WtgResult<Query> {
88        match query {
89            ParsedQuery::Resolved(resolved) => Ok(resolved.clone()),
90            ParsedQuery::Unknown(input) => Err(WtgError::NotFound(input.clone())),
91            ParsedQuery::UnknownPath { segments } => Err(WtgError::NotFound(segments.join("/"))),
92        }
93    }
94
95    /// Find a release/tag that contains the given commit.
96    async fn find_release_for_commit(
97        &self,
98        _commit_hash: &str,
99        _commit_date: Option<DateTime<Utc>>,
100    ) -> Option<TagInfo> {
101        None
102    }
103
104    // ============================================
105    // Issue operations (default: Unsupported)
106    // ============================================
107
108    /// Fetch issue details including closing PRs.
109    async fn fetch_issue(&self, _number: u64) -> WtgResult<ExtendedIssueInfo> {
110        Err(WtgError::Unsupported("issue lookup".into()))
111    }
112
113    // ============================================
114    // Pull request operations (default: Unsupported)
115    // ============================================
116
117    /// Fetch PR details.
118    async fn fetch_pr(&self, _number: u64) -> WtgResult<PullRequestInfo> {
119        Err(WtgError::Unsupported("PR lookup".into()))
120    }
121
122    // ============================================
123    // URL generation (default: None)
124    // ============================================
125
126    /// Generate URL to view a commit.
127    fn commit_url(&self, _hash: &str) -> Option<String> {
128        None
129    }
130
131    /// Generate URL to view a tag.
132    fn tag_url(&self, _tag: &str) -> Option<String> {
133        None
134    }
135
136    /// Generate author profile URL from email address.
137    fn author_url_from_email(&self, _email: &str) -> Option<String> {
138        None
139    }
140}
141
142// ============================================
143// Backend resolution
144// ============================================
145
146/// Resolve the best backend based on available resources.
147///
148/// # Arguments
149/// * `parsed_input` - The parsed user input
150/// * `allow_user_repo_fetch` - If true, allow fetching into user's local repo
151///
152/// Decision tree:
153/// 1. Explicit repo info provided → Use cached/cloned repo + GitHub API (hard error if GitHub client fails)
154/// 2. In local repo with GitHub remote → Combined backend (soft notice if GitHub client fails)
155/// 3. In local repo without GitHub remote → Git-only backend with appropriate notice
156/// 4. Not in repo and no info → Error
157pub fn resolve_backend(
158    parsed_input: &ParsedInput,
159    allow_user_repo_fetch: bool,
160) -> WtgResult<Box<dyn Backend>> {
161    resolve_backend_with_notices(parsed_input, allow_user_repo_fetch, no_notices())
162}
163
164/// Resolve the best backend based on available resources, with a notice callback.
165pub fn resolve_backend_with_notices(
166    parsed_input: &ParsedInput,
167    allow_user_repo_fetch: bool,
168    notice_cb: NoticeCallback,
169) -> WtgResult<Box<dyn Backend>> {
170    // Case 1: Explicit repo info provided (from URL/flags)
171    if let Some(repo_info) = parsed_input.gh_repo_info() {
172        // User explicitly provided GitHub info - GitHub client failure is a hard error
173        let github = GitHubBackend::new(repo_info.clone()).ok_or(WtgError::GitHubClientFailed)?;
174
175        // Try to get local git repo for combined backend
176        if let Ok(git_repo) = GitRepo::remote_with_notices(repo_info.clone(), notice_cb.clone()) {
177            let git = GitBackend::new(git_repo);
178            let mut combined = CombinedBackend::new(git, github);
179            combined.set_notice_callback(notice_cb);
180            Ok(Box::new(combined))
181        } else {
182            // Can't access git locally, use pure API (soft notice)
183            notice_cb(Notice::ApiOnly);
184            Ok(Box::new(github))
185        }
186    } else {
187        // Case 2: Local repo detection
188        resolve_local_backend_with_notices(allow_user_repo_fetch, notice_cb)
189    }
190}
191
192fn resolve_local_backend_with_notices(
193    allow_user_repo_fetch: bool,
194    notice_cb: NoticeCallback,
195) -> WtgResult<Box<dyn Backend>> {
196    let mut git_repo = GitRepo::open()?;
197    if allow_user_repo_fetch {
198        git_repo.set_allow_fetch(true);
199    }
200    git_repo.set_notice_callback(notice_cb.clone());
201
202    // Collect and sort remotes by priority (upstream > origin > other, GitHub first)
203    let mut remotes: Vec<RemoteInfo> = git_repo.remotes().collect();
204    remotes.sort_by_key(RemoteInfo::priority);
205
206    // No remotes at all
207    if remotes.is_empty() {
208        notice_cb(Notice::NoRemotes);
209        let git = GitBackend::new(git_repo);
210        return Ok(Box::new(git));
211    }
212
213    // Find the best GitHub remote (if any)
214    let github_remote = remotes
215        .iter()
216        .find(|r| r.host == Some(RemoteHost::GitHub))
217        .cloned();
218
219    if let Some(github_remote) = github_remote {
220        // We have a GitHub remote - try to use it
221        if let Some(repo_info) = git_repo.github_remote() {
222            let git = GitBackend::new(git_repo);
223
224            if let Some(github) = GitHubBackend::new(repo_info) {
225                // Full GitHub support!
226                let mut combined = CombinedBackend::new(git, github);
227                combined.set_notice_callback(notice_cb);
228                return Ok(Box::new(combined));
229            }
230
231            // GitHub remote found but client creation failed
232            notice_cb(Notice::UnreachableGitHub {
233                remote: github_remote,
234            });
235            return Ok(Box::new(git));
236        }
237    }
238
239    // No GitHub remote - analyze what we have
240    let git = GitBackend::new(git_repo);
241    let unique_hosts: HashSet<Option<RemoteHost>> = remotes.iter().map(|r| r.host).collect();
242
243    // Check if we have mixed hosts (excluding None/unknown)
244    let known_hosts: Vec<RemoteHost> = unique_hosts.iter().filter_map(|h| *h).collect();
245
246    if known_hosts.len() > 1 {
247        // Multiple different known hosts - we're lost!
248        notice_cb(Notice::MixedRemotes {
249            hosts: known_hosts,
250            count: remotes.len(),
251        });
252        return Ok(Box::new(git));
253    }
254
255    // Single host type (or all unknown) - return the best one
256    let best_remote = remotes.into_iter().next().unwrap();
257    notice_cb(Notice::UnsupportedHost { best_remote });
258    Ok(Box::new(git))
259}