1use 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
21pub 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 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 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 let _ = self.svn_cmd(&["add", "--force", "."]);
73
74 let commit_msg = format!("{}\n\nGoal-ID: {}", message, goal.goal_run_id);
76
77 let output = self.svn_cmd(&["commit", "-m", &commit_msg])?;
79
80 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 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 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 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 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 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 vec!["/trunk".to_string()]
178 }
179
180 fn verify_not_on_protected_target(&self) -> Result<()> {
181 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 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 assert!(!SvnAdapter::detect(dir.path()));
237
238 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 let dir = tempfile::tempdir().unwrap();
255 let adapter = SvnAdapter::new(dir.path());
256 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}