Skip to main content

ta_submit/
adapter.rs

1//! Core SourceAdapter trait and result types
2//!
3//! The `SourceAdapter` trait (formerly `SubmitAdapter`) is the unified abstraction
4//! for VCS operations. It combines submit operations (commit, push, open review)
5//! with sync operations (fetch upstream, detect conflicts).
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::Path;
10use ta_changeset::DraftPackage;
11use ta_goal::GoalRun;
12use thiserror::Error;
13
14use crate::config::SubmitConfig;
15
16/// Errors that can occur during source operations
17#[derive(Debug, Error)]
18pub enum SubmitError {
19    #[error("Adapter not configured: {0}")]
20    NotConfigured(String),
21
22    #[error("VCS operation failed: {0}")]
23    VcsError(String),
24
25    #[error("IO error: {0}")]
26    IoError(#[from] std::io::Error),
27
28    #[error("Configuration error: {0}")]
29    ConfigError(String),
30
31    #[error("Review creation failed: {0}")]
32    ReviewError(String),
33
34    #[error("Invalid state: {0}")]
35    InvalidState(String),
36
37    #[error("Sync failed: {0}")]
38    SyncError(String),
39
40    #[error("Sync conflict: {conflicts} file(s) in conflict")]
41    SyncConflict { conflicts: usize },
42}
43
44pub type Result<T> = std::result::Result<T, SubmitError>;
45
46/// Result of a commit operation
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct CommitResult {
49    /// Commit identifier (hash, changelist number, etc.)
50    pub commit_id: String,
51
52    /// Human-readable message
53    pub message: String,
54
55    /// Adapter-specific metadata
56    #[serde(default)]
57    pub metadata: HashMap<String, String>,
58
59    /// Gitignored artifacts that were dropped from this commit (v0.13.17.5).
60    /// Known-safe paths (.mcp.json, *.local.toml, .ta/ runtime files) are
61    /// dropped silently; unexpected-ignored paths trigger a warning.
62    #[serde(default)]
63    pub ignored_artifacts: Vec<ta_changeset::IgnoredArtifact>,
64}
65
66/// Result of a push operation
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PushResult {
69    /// Remote reference (branch name, changelist URL, etc.)
70    pub remote_ref: String,
71
72    /// Human-readable message
73    pub message: String,
74
75    /// Adapter-specific metadata
76    #[serde(default)]
77    pub metadata: HashMap<String, String>,
78}
79
80/// Result of opening a review request
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ReviewResult {
83    /// Review URL (GitHub PR, Perforce review, etc.)
84    pub review_url: String,
85
86    /// Review identifier
87    pub review_id: String,
88
89    /// Human-readable message
90    pub message: String,
91
92    /// Adapter-specific metadata
93    #[serde(default)]
94    pub metadata: HashMap<String, String>,
95}
96
97/// Result of a sync operation — pulling upstream changes into the local workspace.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SyncResult {
100    /// Whether upstream had new changes that were incorporated.
101    pub updated: bool,
102
103    /// Files with merge conflicts (empty if none).
104    pub conflicts: Vec<String>,
105
106    /// Number of new upstream commits incorporated.
107    pub new_commits: u32,
108
109    /// Human-readable summary of what happened.
110    pub message: String,
111
112    /// Adapter-specific metadata.
113    #[serde(default)]
114    pub metadata: HashMap<String, String>,
115}
116
117impl SyncResult {
118    /// Whether the sync completed without conflicts.
119    pub fn is_clean(&self) -> bool {
120        self.conflicts.is_empty()
121    }
122}
123
124/// Opaque saved VCS state for save/restore around apply operations.
125///
126/// Each adapter stores its own state (e.g., Git saves the current branch name,
127/// Perforce saves the current changelist). The state is passed back to
128/// `restore_state()` after the apply operation completes.
129pub struct SavedVcsState {
130    /// Adapter name that created this state (for safety checks).
131    pub adapter: String,
132    /// Opaque state data — only the creating adapter knows how to interpret this.
133    pub data: Box<dyn std::any::Any + Send>,
134}
135
136/// Pluggable adapter for source control operations (submit + sync).
137///
138/// The staging->review->apply loop is VCS-agnostic. This trait allows
139/// different implementations for Git, Perforce, SVN, or custom workflows.
140///
141/// Renamed from `SubmitAdapter` in v0.11.1 to reflect the unified scope
142/// (submit + sync). The old name is available as a type alias.
143pub trait SourceAdapter: Send + Sync {
144    /// Create a working branch/changelist/workspace for this goal
145    ///
146    /// For Git: creates a feature branch
147    /// For Perforce: creates a changelist
148    /// For "none": no-op
149    fn prepare(&self, goal: &GoalRun, config: &SubmitConfig) -> Result<()>;
150
151    /// Commit the approved changes from staging
152    ///
153    /// For Git: `git add` + `git commit`
154    /// For Perforce: shelve files
155    /// For "none": no-op
156    fn commit(&self, goal: &GoalRun, pr: &DraftPackage, message: &str) -> Result<CommitResult>;
157
158    /// Push the committed changes
159    ///
160    /// For Git: `git push`
161    /// For Perforce: submit changelist
162    /// For "none": no-op
163    fn push(&self, goal: &GoalRun) -> Result<PushResult>;
164
165    /// Open a review request
166    ///
167    /// For Git: create GitHub/GitLab PR via API or `gh pr create`
168    /// For Perforce: create Swarm review
169    /// For "none": no-op
170    fn open_review(&self, goal: &GoalRun, pr: &DraftPackage) -> Result<ReviewResult>;
171
172    /// Sync the local workspace with upstream changes.
173    ///
174    /// For Git: `git fetch` + merge/rebase/ff per `source.git.sync_strategy`
175    /// For SVN: `svn update`
176    /// For Perforce: `p4 sync`
177    /// For "none": no-op (always returns updated=false)
178    ///
179    /// Returns a `SyncResult` describing what happened. If conflicts are
180    /// detected, `SyncResult.conflicts` is non-empty but the method still
181    /// returns `Ok` — the caller decides how to handle conflicts. Only
182    /// returns `Err` for infrastructure failures (network, permissions).
183    fn sync_upstream(&self) -> Result<SyncResult> {
184        Ok(SyncResult {
185            updated: false,
186            conflicts: vec![],
187            new_commits: 0,
188            message: "No sync operation (default implementation)".to_string(),
189            metadata: HashMap::new(),
190        })
191    }
192
193    /// Adapter display name (for CLI output)
194    fn name(&self) -> &str;
195
196    /// Patterns to exclude from staging copy (VCS metadata dirs, etc.)
197    ///
198    /// Returns patterns in .taignore format: "dirname/", "*.ext", "name".
199    /// These are merged with user .taignore patterns and built-in defaults
200    /// during overlay workspace creation and diffing.
201    fn exclude_patterns(&self) -> Vec<String> {
202        vec![]
203    }
204
205    /// Save working state before apply operations.
206    ///
207    /// Git: saves the current branch name so it can be restored after commit.
208    /// Perforce: saves the current changelist context.
209    /// Default: no-op (returns None).
210    fn save_state(&self) -> Result<Option<SavedVcsState>> {
211        Ok(None)
212    }
213
214    /// Restore working state after apply operations.
215    ///
216    /// Git: switches back to the original branch.
217    /// Perforce: reverts to saved client state.
218    /// Default: no-op.
219    fn restore_state(&self, _state: Option<SavedVcsState>) -> Result<()> {
220        Ok(())
221    }
222
223    /// Get the current branch or workspace name.
224    ///
225    /// Git: current branch name (e.g., "main", "feature/abc-def")
226    /// SVN: working copy URL path
227    /// Perforce: current client/workspace name
228    /// Default: "unknown"
229    fn current_branch(&self) -> Result<String> {
230        Ok("unknown".to_string())
231    }
232
233    /// Get the current revision identifier for the working directory.
234    ///
235    /// Git: short commit hash (e.g., "abc1234")
236    /// SVN: revision number (e.g., "r1234")
237    /// Perforce: changelist number (e.g., "@1234")
238    /// Default: "unknown"
239    fn revision_id(&self) -> Result<String> {
240        Ok("unknown".to_string())
241    }
242
243    /// Check the status of a review/PR by its review ID (e.g., PR number).
244    ///
245    /// Git: uses `gh pr view --json state` to check PR status.
246    /// Returns the current state as a string: "open", "merged", "closed".
247    /// Default: returns None (not supported).
248    fn check_review(&self, _review_id: &str) -> Result<Option<ReviewStatus>> {
249        Ok(None)
250    }
251
252    /// Merge a review/PR into the target branch and sync the local workspace.
253    ///
254    /// Git: calls `gh pr merge` to merge the PR immediately.
255    /// Perforce: calls `p4 submit -c <CL>` to submit the shelved changelist.
256    /// SVN: no-op (SVN commits directly; no separate merge step).
257    /// Default: no-op, returns a guidance message telling the user what to do.
258    ///
259    /// Returns a `MergeResult` describing what happened. `merged = true` means
260    /// the merge was completed immediately; `merged = false` means auto-merge is
261    /// pending (CI must pass first).
262    fn merge_review(&self, _review_id: &str) -> Result<MergeResult> {
263        Ok(MergeResult {
264            merged: false,
265            merge_commit: None,
266            message: "This adapter does not support automatic merging. \
267                      Merge the PR manually in your VCS platform, then run `ta sync`."
268                .to_string(),
269            metadata: HashMap::new(),
270        })
271    }
272
273    /// Auto-detect whether this adapter applies to the given project root.
274    ///
275    /// Git: checks for .git/ directory
276    /// SVN: checks for .svn/ directory
277    /// Perforce: checks for P4CONFIG env var or .p4config
278    fn detect(project_root: &Path) -> bool
279    where
280        Self: Sized,
281    {
282        let _ = project_root;
283        false
284    }
285
286    /// Protected submit targets for this adapter (§15 VCS Submit Invariant).
287    ///
288    /// Returns the list of refs/branches/paths that agents must never commit
289    /// directly to. `prepare()` must create an isolation mechanism (feature
290    /// branch, shelved CL, etc.) before `verify_not_on_protected_target()` is
291    /// called.
292    ///
293    /// Default: empty list (no protected targets — applies to adapters that
294    /// handle isolation entirely through their `prepare()` implementation).
295    fn protected_submit_targets(&self) -> Vec<String> {
296        vec![]
297    }
298
299    /// Assert the post-`prepare()` invariant: the adapter must not be
300    /// positioned to commit directly to a protected target (§15).
301    ///
302    /// Called immediately after `prepare()` succeeds, before any commit or
303    /// push. Hard failure aborts the apply workflow.
304    ///
305    /// Default implementation: if `protected_submit_targets()` returns a
306    /// non-empty list, subclasses should override this to check the current
307    /// position. The base implementation is a no-op (safe for adapters whose
308    /// `prepare()` guarantees isolation without needing an extra check).
309    fn verify_not_on_protected_target(&self) -> Result<()> {
310        Ok(())
311    }
312
313    /// Produce environment variables to inject into the agent process so it
314    /// operates on the staging directory instead of the developer's real VCS
315    /// workspace (v0.13.17.3).
316    ///
317    /// Called by `ta run` before spawning the agent. The returned vars are
318    /// merged into the agent's process environment.
319    ///
320    /// Default: no-op (returns empty map). VCS adapters that support isolation
321    /// should override this method.
322    fn stage_env(
323        &self,
324        _staging_dir: &Path,
325        _config: &crate::config::VcsAgentConfig,
326    ) -> Result<HashMap<String, String>> {
327        Ok(HashMap::new())
328    }
329}
330
331/// Result of merging a review (PR, shelved CL, etc.) into the target branch.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MergeResult {
334    /// Whether the merge was completed (false = pending CI, auto-merge enabled).
335    pub merged: bool,
336    /// Merge commit SHA or changelist number (if available).
337    pub merge_commit: Option<String>,
338    /// Human-readable message about what happened.
339    pub message: String,
340    /// Adapter-specific metadata.
341    #[serde(default)]
342    pub metadata: HashMap<String, String>,
343}
344
345/// Status of a VCS review/PR (v0.11.2.3).
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ReviewStatus {
348    /// Current state: "open", "merged", "closed", "draft".
349    pub state: String,
350    /// Whether CI checks are passing.
351    pub checks_passing: Option<bool>,
352}
353
354/// Backward-compatible alias: `SubmitAdapter` is the old name for `SourceAdapter`.
355///
356/// Deprecated in v0.11.1. Use `SourceAdapter` instead.
357pub use SourceAdapter as SubmitAdapter;
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    struct MockAdapter;
364    impl SourceAdapter for MockAdapter {
365        fn prepare(&self, _: &GoalRun, _: &SubmitConfig) -> Result<()> {
366            Ok(())
367        }
368        fn commit(&self, _: &GoalRun, _: &DraftPackage, _: &str) -> Result<CommitResult> {
369            unimplemented!()
370        }
371        fn push(&self, _: &GoalRun) -> Result<PushResult> {
372            unimplemented!()
373        }
374        fn open_review(&self, _: &GoalRun, _: &DraftPackage) -> Result<ReviewResult> {
375            unimplemented!()
376        }
377        fn name(&self) -> &str {
378            "mock"
379        }
380    }
381
382    #[test]
383    fn default_protected_targets_empty() {
384        let adapter = MockAdapter;
385        assert!(adapter.protected_submit_targets().is_empty());
386    }
387
388    #[test]
389    fn default_verify_not_on_protected_target_ok() {
390        let adapter = MockAdapter;
391        assert!(adapter.verify_not_on_protected_target().is_ok());
392    }
393
394    #[test]
395    fn sync_result_is_clean_when_no_conflicts() {
396        let result = SyncResult {
397            updated: true,
398            conflicts: vec![],
399            new_commits: 3,
400            message: "ok".to_string(),
401            metadata: HashMap::new(),
402        };
403        assert!(result.is_clean());
404    }
405
406    #[test]
407    fn sync_result_is_not_clean_with_conflicts() {
408        let result = SyncResult {
409            updated: true,
410            conflicts: vec!["src/main.rs".to_string()],
411            new_commits: 3,
412            message: "conflict".to_string(),
413            metadata: HashMap::new(),
414        };
415        assert!(!result.is_clean());
416    }
417
418    #[test]
419    fn sync_result_serialization_roundtrip() {
420        let result = SyncResult {
421            updated: true,
422            conflicts: vec!["a.rs".to_string()],
423            new_commits: 5,
424            message: "synced".to_string(),
425            metadata: [("branch".to_string(), "main".to_string())]
426                .into_iter()
427                .collect(),
428        };
429        let json = serde_json::to_string(&result).unwrap();
430        let restored: SyncResult = serde_json::from_str(&json).unwrap();
431        assert!(restored.updated);
432        assert_eq!(restored.conflicts, vec!["a.rs"]);
433        assert_eq!(restored.new_commits, 5);
434    }
435}