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}