Skip to main content

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::release_filter::ReleaseFilter;
28use crate::remote::{RemoteHost, RemoteInfo};
29
30/// Unified backend trait for all git/GitHub operations.
31///
32/// Backends implement methods for operations they support. Default implementations
33/// return `WtgError::Unsupported` for operations not available.
34#[async_trait]
35pub trait Backend: Send + Sync {
36    // ============================================
37    // Notice emission (default: no-op)
38    // ============================================
39
40    /// Emit a notice to the registered callback.
41    /// Used by resolution code to surface warnings to the user.
42    fn emit_notice(&self, _notice: Notice) {}
43
44    // ============================================
45    // Cross-project support (default: not supported)
46    // ============================================
47
48    /// Get a backend for fetching PR data if the PR is from a different repository.
49    /// Returns None if same repo or cross-project not supported.
50    async fn backend_for_pr(&self, _pr: &PullRequestInfo) -> Option<Box<dyn Backend>> {
51        None
52    }
53
54    // ============================================
55    // Commit operations (default: Unsupported)
56    // ============================================
57
58    /// Find commit by hash (short or full).
59    async fn find_commit(&self, _hash: &str) -> WtgResult<CommitInfo> {
60        Err(WtgError::Unsupported("commit lookup".into()))
61    }
62
63    /// Enrich commit with additional info (author URLs, commit URL, etc.).
64    async fn enrich_commit(&self, commit: CommitInfo) -> CommitInfo {
65        commit
66    }
67
68    /// Find commit info from a PR (using merge commit SHA).
69    async fn find_commit_for_pr(&self, pr: &PullRequestInfo) -> WtgResult<CommitInfo> {
70        if let Some(ref sha) = pr.merge_commit_sha {
71            self.find_commit(sha).await
72        } else {
73            Err(WtgError::NotFound("PR has no merge commit".into()))
74        }
75    }
76
77    // ============================================
78    // File operations (default: Unsupported)
79    // ============================================
80
81    /// Find file and its history in the repository.
82    async fn find_file(&self, _branch: &str, _path: &str) -> WtgResult<FileInfo> {
83        Err(WtgError::Unsupported("file lookup".into()))
84    }
85
86    // ============================================
87    // Tag/Release operations (default: Unsupported)
88    // ============================================
89
90    /// Find a specific tag by name.
91    async fn find_tag(&self, _name: &str) -> WtgResult<TagInfo> {
92        Err(WtgError::Unsupported("tag lookup".into()))
93    }
94
95    /// Find the previous tag before the given tag.
96    ///
97    /// For semver tags, returns the immediately preceding version by semver ordering.
98    /// For non-semver tags, returns the most recent tag pointing to an earlier commit.
99    async fn find_previous_tag(&self, _tag_name: &str) -> WtgResult<Option<TagInfo>> {
100        Err(WtgError::Unsupported("find previous tag".into()))
101    }
102
103    /// Get commits between two tags (`from_tag` exclusive, `to_tag` inclusive).
104    ///
105    /// Returns up to `limit` commits, most recent first.
106    async fn commits_between_tags(
107        &self,
108        _from_tag: &str,
109        _to_tag: &str,
110        _limit: usize,
111    ) -> WtgResult<Vec<CommitInfo>> {
112        Err(WtgError::Unsupported("commits between tags".into()))
113    }
114
115    /// Disambiguate a parsed query into a concrete query.
116    async fn disambiguate_query(&self, query: &ParsedQuery) -> WtgResult<Query> {
117        match query {
118            ParsedQuery::Resolved(resolved) => Ok(resolved.clone()),
119            ParsedQuery::Unknown(input) => Err(WtgError::NotFound(input.clone())),
120            ParsedQuery::UnknownPath { segments } => Err(WtgError::NotFound(segments.join("/"))),
121        }
122    }
123
124    /// Find a release/tag that contains the given commit.
125    ///
126    /// The `filter` parameter controls which tags are considered:
127    /// - `Unrestricted`: All tags (default behavior)
128    /// - `SkipPrereleases`: Filter out pre-release versions
129    /// - `Specific(tag)`: Check if the commit is in a specific tag
130    async fn find_release_for_commit(
131        &self,
132        _commit_hash: &str,
133        _commit_date: Option<DateTime<Utc>>,
134        _filter: &ReleaseFilter,
135    ) -> Option<TagInfo> {
136        None
137    }
138
139    /// Fetch the body/description of a GitHub release by tag name.
140    async fn fetch_release_body(&self, _tag_name: &str) -> Option<String> {
141        None
142    }
143
144    /// Parse changelog for a specific version from repository root.
145    ///
146    /// Returns the changelog section content for the given version, or None if
147    /// not found. Backends implement this to access CHANGELOG.md via their
148    /// native method (local filesystem or API).
149    async fn changelog_for_version(&self, _version: &str) -> Option<String> {
150        None
151    }
152
153    // ============================================
154    // Issue operations (default: Unsupported)
155    // ============================================
156
157    /// Fetch issue details including closing PRs.
158    async fn fetch_issue(&self, _number: u64) -> WtgResult<ExtendedIssueInfo> {
159        Err(WtgError::Unsupported("issue lookup".into()))
160    }
161
162    // ============================================
163    // Pull request operations (default: Unsupported)
164    // ============================================
165
166    /// Fetch PR details.
167    async fn fetch_pr(&self, _number: u64) -> WtgResult<PullRequestInfo> {
168        Err(WtgError::Unsupported("PR lookup".into()))
169    }
170
171    // ============================================
172    // URL generation (default: None)
173    // ============================================
174
175    /// Generate URL to view a commit.
176    fn commit_url(&self, _hash: &str) -> Option<String> {
177        None
178    }
179
180    /// Generate URL to view a tag (tree view for plain git tags).
181    fn tag_url(&self, _tag: &str) -> Option<String> {
182        None
183    }
184
185    /// Generate URL to view a release (releases page for tags with releases).
186    fn release_tag_url(&self, _tag: &str) -> Option<String> {
187        None
188    }
189
190    /// Generate author profile URL from email address.
191    fn author_url_from_email(&self, _email: &str) -> Option<String> {
192        None
193    }
194}
195
196// ============================================
197// Backend resolution
198// ============================================
199
200/// Resolve the best backend based on available resources.
201///
202/// # Arguments
203/// * `parsed_input` - The parsed user input
204/// * `allow_user_repo_fetch` - If true, allow fetching into user's local repo
205///
206/// Decision tree:
207/// 1. Explicit repo info provided → Use cached/cloned repo + GitHub API (hard error if GitHub client fails)
208/// 2. In local repo with GitHub remote → Combined backend (soft notice if GitHub client fails)
209/// 3. In local repo without GitHub remote → Git-only backend with appropriate notice
210/// 4. Not in repo and no info → Error
211pub fn resolve_backend(
212    parsed_input: &ParsedInput,
213    allow_user_repo_fetch: bool,
214) -> WtgResult<Box<dyn Backend>> {
215    resolve_backend_with_notices(parsed_input, allow_user_repo_fetch, no_notices())
216}
217
218/// Resolve the best backend based on available resources, with a notice callback.
219pub fn resolve_backend_with_notices(
220    parsed_input: &ParsedInput,
221    allow_user_repo_fetch: bool,
222    notice_cb: NoticeCallback,
223) -> WtgResult<Box<dyn Backend>> {
224    // Case 1: Explicit repo info provided (from URL/flags)
225    if let Some(repo_info) = parsed_input.gh_repo_info() {
226        // User explicitly provided GitHub info - GitHub client failure is a hard error
227        let github = GitHubBackend::new(repo_info.clone()).ok_or(WtgError::GitHubClientFailed)?;
228
229        // Try to get local git repo for combined backend
230        if let Ok(git_repo) = GitRepo::remote_with_notices(repo_info.clone(), notice_cb.clone()) {
231            let git = GitBackend::new(git_repo);
232            let mut combined = CombinedBackend::new(git, github);
233            combined.set_notice_callback(notice_cb);
234            Ok(Box::new(combined))
235        } else {
236            // Can't access git locally, use pure API (soft notice)
237            notice_cb(Notice::ApiOnly);
238            github.set_notice_callback(notice_cb);
239            Ok(Box::new(github))
240        }
241    } else {
242        // Case 2: Local repo detection
243        resolve_local_backend_with_notices(allow_user_repo_fetch, notice_cb)
244    }
245}
246
247fn resolve_local_backend_with_notices(
248    allow_user_repo_fetch: bool,
249    notice_cb: NoticeCallback,
250) -> WtgResult<Box<dyn Backend>> {
251    let mut git_repo = GitRepo::open()?;
252    if allow_user_repo_fetch {
253        git_repo.set_allow_fetch(true);
254    }
255    git_repo.set_notice_callback(notice_cb.clone());
256
257    // Collect and sort remotes by priority (upstream > origin > other, GitHub first)
258    let mut remotes: Vec<RemoteInfo> = git_repo.remotes().collect();
259    remotes.sort_by_key(RemoteInfo::priority);
260
261    // No remotes at all
262    if remotes.is_empty() {
263        notice_cb(Notice::NoRemotes);
264        let git = GitBackend::new(git_repo);
265        return Ok(Box::new(git));
266    }
267
268    // Find the best GitHub remote (if any)
269    let github_remote = remotes
270        .iter()
271        .find(|r| r.host == Some(RemoteHost::GitHub))
272        .cloned();
273
274    if let Some(github_remote) = github_remote {
275        // We have a GitHub remote - try to use it
276        if let Some(repo_info) = git_repo.github_remote() {
277            let git = GitBackend::new(git_repo);
278
279            if let Some(github) = GitHubBackend::new(repo_info) {
280                // Full GitHub support!
281                let mut combined = CombinedBackend::new(git, github);
282                combined.set_notice_callback(notice_cb);
283                return Ok(Box::new(combined));
284            }
285
286            // GitHub remote found but client creation failed
287            notice_cb(Notice::UnreachableGitHub {
288                remote: github_remote,
289            });
290            return Ok(Box::new(git));
291        }
292    }
293
294    // No GitHub remote - analyze what we have
295    let git = GitBackend::new(git_repo);
296    let unique_hosts: HashSet<Option<RemoteHost>> = remotes.iter().map(|r| r.host).collect();
297
298    // Check if we have mixed hosts (excluding None/unknown)
299    let known_hosts: Vec<RemoteHost> = unique_hosts.iter().filter_map(|h| *h).collect();
300
301    if known_hosts.len() > 1 {
302        // Multiple different known hosts - we're lost!
303        notice_cb(Notice::MixedRemotes {
304            hosts: known_hosts,
305            count: remotes.len(),
306        });
307        return Ok(Box::new(git));
308    }
309
310    // Single host type (or all unknown) - return the best one
311    let best_remote = remotes.into_iter().next().unwrap();
312    notice_cb(Notice::UnsupportedHost { best_remote });
313    Ok(Box::new(git))
314}