Skip to main content

ta_submit/
svn.rs

1//! SVN adapter stub — untested, contributed by AI.
2//!
3//! This adapter provides basic SVN integration for projects using Subversion.
4//! It is **untested** and needs validation by an SVN user before production use.
5//!
6//! Key differences from Git:
7//! - SVN commit is immediately remote (no local-only commits)
8//! - No built-in branch-based code review workflow
9//! - `push()` is a no-op since `commit()` already sends to the server
10
11use std::path::Path;
12use std::process::Command;
13use ta_changeset::DraftPackage;
14use ta_goal::GoalRun;
15
16use crate::adapter::{
17    CommitResult, PushResult, Result, ReviewResult, SourceAdapter, SubmitError, SyncResult,
18};
19use crate::config::SubmitConfig;
20
21/// SVN adapter implementing Subversion workflow.
22///
23/// **Status: UNTESTED** — needs validation by an SVN user.
24pub struct SvnAdapter {
25    work_dir: std::path::PathBuf,
26}
27
28impl SvnAdapter {
29    pub fn new(work_dir: impl Into<std::path::PathBuf>) -> Self {
30        Self {
31            work_dir: work_dir.into(),
32        }
33    }
34
35    fn svn_cmd(&self, args: &[&str]) -> Result<String> {
36        let output = Command::new("svn")
37            .args(args)
38            .current_dir(&self.work_dir)
39            .output()?;
40
41        if !output.status.success() {
42            let stderr = String::from_utf8_lossy(&output.stderr);
43            return Err(SubmitError::VcsError(format!(
44                "svn {} failed: {}",
45                args.join(" "),
46                stderr
47            )));
48        }
49
50        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
51    }
52
53    /// Auto-detect whether this is an SVN working copy.
54    pub fn detect(project_root: &Path) -> bool {
55        project_root.join(".svn").exists()
56    }
57}
58
59impl SourceAdapter for SvnAdapter {
60    fn prepare(&self, _goal: &GoalRun, _config: &SubmitConfig) -> Result<()> {
61        // SVN doesn't use branches the same way as Git.
62        // No-op: the working copy is already pointing at the correct location.
63        tracing::debug!("SvnAdapter: prepare() — no-op (SVN working copy)");
64        Ok(())
65    }
66
67    fn commit(&self, goal: &GoalRun, _pr: &DraftPackage, message: &str) -> Result<CommitResult> {
68        tracing::info!("SvnAdapter: committing changes");
69
70        // Add any new (unversioned) files.
71        // `svn add` with --force adds unversioned files without erroring on already-tracked ones.
72        let _ = self.svn_cmd(&["add", "--force", "."]);
73
74        // Build commit message with goal metadata.
75        let commit_msg = format!("{}\n\nGoal-ID: {}", message, goal.goal_run_id);
76
77        // Commit — this sends changes to the remote server immediately.
78        let output = self.svn_cmd(&["commit", "-m", &commit_msg])?;
79
80        // Try to extract revision number from commit output.
81        // SVN output: "Committed revision 1234."
82        let rev = output
83            .lines()
84            .find(|l| l.contains("Committed revision"))
85            .and_then(|l| {
86                l.split_whitespace()
87                    .find(|w| w.chars().any(|c| c.is_ascii_digit()))
88                    .map(|w| w.trim_end_matches('.').to_string())
89            })
90            .unwrap_or_else(|| "unknown".to_string());
91
92        Ok(CommitResult {
93            commit_id: format!("r{}", rev),
94            message: format!("Committed revision {}", rev),
95            metadata: [("revision".to_string(), rev)].into_iter().collect(),
96            ignored_artifacts: vec![],
97        })
98    }
99
100    fn push(&self, _goal: &GoalRun) -> Result<PushResult> {
101        // SVN commit is already remote — no separate push step.
102        tracing::debug!("SvnAdapter: push() — no-op (SVN commit is already remote)");
103        Ok(PushResult {
104            remote_ref: "svn://committed".to_string(),
105            message: "SVN commit is already remote — no push needed".to_string(),
106            metadata: Default::default(),
107        })
108    }
109
110    fn open_review(&self, _goal: &GoalRun, _pr: &DraftPackage) -> Result<ReviewResult> {
111        // SVN doesn't have built-in code review.
112        tracing::debug!("SvnAdapter: open_review() — no-op (SVN has no built-in review)");
113        Ok(ReviewResult {
114            review_url: "svn://no-review".to_string(),
115            review_id: "none".to_string(),
116            message: "SVN has no built-in review workflow. Consider using a code review tool like Crucible or ReviewBoard.".to_string(),
117            metadata: Default::default(),
118        })
119    }
120
121    fn sync_upstream(&self) -> Result<SyncResult> {
122        tracing::info!("SvnAdapter: running svn update");
123
124        match self.svn_cmd(&["update"]) {
125            Ok(output) => {
126                // Try to detect conflicts from "C " prefix lines in svn update output.
127                let conflicts: Vec<String> = output
128                    .lines()
129                    .filter(|l| l.starts_with("C ") || l.starts_with("C\t"))
130                    .map(|l| l[2..].trim().to_string())
131                    .collect();
132
133                // Try to count updated files from "U " prefix lines.
134                let updated_count = output
135                    .lines()
136                    .filter(|l| l.starts_with("U ") || l.starts_with("A ") || l.starts_with("D "))
137                    .count();
138
139                Ok(SyncResult {
140                    updated: updated_count > 0 || !conflicts.is_empty(),
141                    conflicts,
142                    new_commits: updated_count as u32,
143                    message: format!(
144                        "svn update completed. {}",
145                        output.lines().last().unwrap_or("")
146                    ),
147                    metadata: Default::default(),
148                })
149            }
150            Err(e) => Err(SubmitError::SyncError(format!("svn update failed: {}", e))),
151        }
152    }
153
154    fn name(&self) -> &str {
155        "svn"
156    }
157
158    fn exclude_patterns(&self) -> Vec<String> {
159        vec![".svn/".to_string()]
160    }
161
162    fn revision_id(&self) -> Result<String> {
163        // `svn info` outputs "Revision: 1234" among other fields.
164        let info = self.svn_cmd(&["info"])?;
165        let rev = info
166            .lines()
167            .find(|l| l.starts_with("Revision:"))
168            .and_then(|l| l.split(':').nth(1))
169            .map(|r| r.trim().to_string())
170            .unwrap_or_else(|| "unknown".to_string());
171        Ok(format!("r{}", rev))
172    }
173
174    fn protected_submit_targets(&self) -> Vec<String> {
175        // SVN paths that agents must not commit directly to.
176        // Default: /trunk (the conventional integration line).
177        vec!["/trunk".to_string()]
178    }
179
180    fn verify_not_on_protected_target(&self) -> Result<()> {
181        // Check the working copy's URL via `svn info --show-item url`.
182        // SVN's `prepare()` is currently a no-op (no branching), so this
183        // guard blocks commits to /trunk until proper branch/copy support
184        // is added.
185        let url_result = self.svn_cmd(&["info", "--show-item", "url"]);
186        match url_result {
187            Ok(url) => {
188                let protected = self.protected_submit_targets();
189                for target in &protected {
190                    if url.contains(target.as_str()) {
191                        return Err(SubmitError::InvalidState(format!(
192                            "Refusing to commit: working copy URL '{}' contains protected path \
193                             '{}'. SVN branching is not yet supported — use a branch or \
194                             feature copy before applying changes to a protected path.",
195                            url, target
196                        )));
197                    }
198                }
199                Ok(())
200            }
201            Err(_) => {
202                // svn not installed or not an SVN working copy — allow (svn commit
203                // would also fail in this case, providing its own error).
204                tracing::warn!(
205                    "SvnAdapter: could not run `svn info` for protected target check — skipping"
206                );
207                Ok(())
208            }
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_svn_adapter_name() {
219        let dir = tempfile::tempdir().unwrap();
220        let adapter = SvnAdapter::new(dir.path());
221        assert_eq!(adapter.name(), "svn");
222    }
223
224    #[test]
225    fn test_svn_adapter_exclude_patterns() {
226        let dir = tempfile::tempdir().unwrap();
227        let adapter = SvnAdapter::new(dir.path());
228        assert_eq!(adapter.exclude_patterns(), vec![".svn/"]);
229    }
230
231    #[test]
232    fn test_svn_adapter_detect() {
233        let dir = tempfile::tempdir().unwrap();
234
235        // No .svn directory — should not detect
236        assert!(!SvnAdapter::detect(dir.path()));
237
238        // Create .svn directory — should detect
239        std::fs::create_dir(dir.path().join(".svn")).unwrap();
240        assert!(SvnAdapter::detect(dir.path()));
241    }
242
243    #[test]
244    fn test_svn_adapter_protected_targets() {
245        let dir = tempfile::tempdir().unwrap();
246        let adapter = SvnAdapter::new(dir.path());
247        let targets = adapter.protected_submit_targets();
248        assert!(targets.contains(&"/trunk".to_string()));
249    }
250
251    #[test]
252    fn test_svn_adapter_verify_degrades_without_svn() {
253        // Without svn CLI or a real working copy, verify should degrade gracefully.
254        let dir = tempfile::tempdir().unwrap();
255        let adapter = SvnAdapter::new(dir.path());
256        // Not an SVN working copy, so svn info will fail → should return Ok
257        assert!(adapter.verify_not_on_protected_target().is_ok());
258    }
259
260    #[test]
261    fn test_svn_adapter_push_is_noop() {
262        let dir = tempfile::tempdir().unwrap();
263        let adapter = SvnAdapter::new(dir.path());
264        let goal = GoalRun::new(
265            "Test",
266            "Test",
267            "test-agent",
268            dir.path().to_path_buf(),
269            dir.path().join("store"),
270        );
271        let result = adapter.push(&goal).unwrap();
272        assert_eq!(result.remote_ref, "svn://committed");
273    }
274}