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