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}