1use std::path::Path;
13use std::process::Command;
14use ta_changeset::DraftPackage;
15use ta_goal::GoalRun;
16
17use crate::adapter::{
18 CommitResult, MergeResult, PushResult, Result, ReviewResult, ReviewStatus, SavedVcsState,
19 SourceAdapter, SubmitError, SyncResult,
20};
21use crate::config::SubmitConfig;
22
23#[derive(Debug, Clone)]
25struct PerforceState {
26 client: String,
27 changelist: Option<String>,
28}
29
30pub struct PerforceAdapter {
34 work_dir: std::path::PathBuf,
35}
36
37impl PerforceAdapter {
38 pub fn new(work_dir: impl Into<std::path::PathBuf>) -> Self {
39 Self {
40 work_dir: work_dir.into(),
41 }
42 }
43
44 fn p4_cmd(&self, args: &[&str]) -> Result<String> {
45 let output = Command::new("p4")
46 .args(args)
47 .current_dir(&self.work_dir)
48 .output()?;
49
50 if !output.status.success() {
51 let stderr = String::from_utf8_lossy(&output.stderr);
52 return Err(SubmitError::VcsError(format!(
53 "p4 {} failed: {}",
54 args.join(" "),
55 stderr
56 )));
57 }
58
59 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
60 }
61
62 pub fn detect(project_root: &Path) -> bool {
64 if std::env::var("P4CONFIG").is_ok() {
66 return true;
67 }
68 project_root.join(".p4config").exists()
70 }
71}
72
73impl SourceAdapter for PerforceAdapter {
74 fn prepare(&self, goal: &GoalRun, _config: &SubmitConfig) -> Result<()> {
75 tracing::info!(
76 "PerforceAdapter: creating pending changelist for goal {}",
77 goal.goal_run_id
78 );
79
80 let spec = self.p4_cmd(&["change", "-o"])?;
83
84 let new_desc = format!("TA Goal: {} [{}]", goal.title, goal.goal_run_id);
86 let modified_spec = spec
87 .lines()
88 .map(|line| {
89 if line.starts_with("\t<enter description here>") {
90 format!("\t{}", new_desc)
91 } else {
92 line.to_string()
93 }
94 })
95 .collect::<Vec<_>>()
96 .join("\n");
97
98 let output = Command::new("p4")
100 .args(["change", "-i"])
101 .stdin(std::process::Stdio::piped())
102 .stdout(std::process::Stdio::piped())
103 .stderr(std::process::Stdio::piped())
104 .current_dir(&self.work_dir)
105 .spawn()
106 .and_then(|mut child| {
107 use std::io::Write;
108 if let Some(ref mut stdin) = child.stdin {
109 stdin.write_all(modified_spec.as_bytes())?;
110 }
111 child.wait_with_output()
112 })?;
113
114 if !output.status.success() {
115 let stderr = String::from_utf8_lossy(&output.stderr);
116 return Err(SubmitError::VcsError(format!(
117 "p4 change -i failed: {}",
118 stderr
119 )));
120 }
121
122 tracing::info!("PerforceAdapter: changelist created");
123 Ok(())
124 }
125
126 fn commit(&self, goal: &GoalRun, _pr: &DraftPackage, message: &str) -> Result<CommitResult> {
127 tracing::info!("PerforceAdapter: reconciling and shelving changes");
128
129 let _ = self.p4_cmd(&["reconcile", "..."]);
131
132 let shelve_output = self.p4_cmd(&["shelve", "-c", "default"])?;
134
135 let cl = shelve_output
137 .split_whitespace()
138 .find(|w| w.chars().all(|c| c.is_ascii_digit()))
139 .unwrap_or("unknown")
140 .to_string();
141
142 Ok(CommitResult {
143 commit_id: format!("cl:{}", cl),
144 message: format!("{} (shelved in changelist {})", message, cl),
145 metadata: [
146 ("changelist".to_string(), cl),
147 ("goal_id".to_string(), goal.goal_run_id.to_string()),
148 ]
149 .into_iter()
150 .collect(),
151 ignored_artifacts: vec![],
152 })
153 }
154
155 fn push(&self, _goal: &GoalRun) -> Result<PushResult> {
156 tracing::info!("PerforceAdapter: submitting changelist");
157
158 let output = self.p4_cmd(&["submit", "-c", "default"])?;
159
160 Ok(PushResult {
161 remote_ref: "p4://submitted".to_string(),
162 message: format!("Submitted: {}", output.lines().next().unwrap_or("ok")),
163 metadata: Default::default(),
164 })
165 }
166
167 fn open_review(&self, goal: &GoalRun, _pr: &DraftPackage) -> Result<ReviewResult> {
168 tracing::debug!(
171 "PerforceAdapter: open_review() — shelved changelist serves as review (use Helix Swarm for web UI)"
172 );
173 Ok(ReviewResult {
174 review_url: format!("p4://shelved/{}", goal.goal_run_id),
175 review_id: format!("p4-{}", goal.goal_run_id),
176 message: "Changes shelved. If Helix Swarm is configured, the review is available in the Swarm web UI.".to_string(),
177 metadata: Default::default(),
178 })
179 }
180
181 fn sync_upstream(&self) -> Result<SyncResult> {
182 tracing::info!("PerforceAdapter: running p4 sync");
183
184 match self.p4_cmd(&["sync"]) {
185 Ok(output) => {
186 let file_count = output.lines().count();
188
189 Ok(SyncResult {
190 updated: file_count > 0,
191 conflicts: vec![],
192 new_commits: file_count as u32,
193 message: format!("p4 sync completed: {} file(s) updated.", file_count),
194 metadata: Default::default(),
195 })
196 }
197 Err(e) => Err(SubmitError::SyncError(format!("p4 sync failed: {}", e))),
198 }
199 }
200
201 fn name(&self) -> &str {
202 "perforce"
203 }
204
205 fn exclude_patterns(&self) -> Vec<String> {
206 vec![".p4config".to_string(), ".p4ignore".to_string()]
207 }
208
209 fn save_state(&self) -> Result<Option<SavedVcsState>> {
210 let client = self
212 .p4_cmd(&["set", "P4CLIENT"])
213 .unwrap_or_else(|_| "unknown".to_string());
214 let changelist = self.p4_cmd(&["changes", "-s", "pending", "-m", "1"]).ok();
215
216 let state = PerforceState { client, changelist };
217
218 tracing::debug!(?state, "PerforceAdapter: saved state");
219 Ok(Some(SavedVcsState {
220 adapter: "perforce".to_string(),
221 data: Box::new(state),
222 }))
223 }
224
225 fn restore_state(&self, state: Option<SavedVcsState>) -> Result<()> {
226 let state = match state {
227 Some(s) => s,
228 None => return Ok(()),
229 };
230
231 if state.adapter != "perforce" {
232 return Err(SubmitError::InvalidState(format!(
233 "Cannot restore state from adapter '{}' in PerforceAdapter",
234 state.adapter
235 )));
236 }
237
238 if let Ok(p4_state) = state.data.downcast::<PerforceState>() {
241 tracing::info!(
242 client = %p4_state.client,
243 changelist = ?p4_state.changelist,
244 "PerforceAdapter: state restored"
245 );
246 }
247
248 Ok(())
249 }
250
251 fn revision_id(&self) -> Result<String> {
252 let output = self.p4_cmd(&["changes", "-m", "1", "...#have"])?;
254 let cl = output
255 .split_whitespace()
256 .nth(1) .unwrap_or("unknown")
258 .to_string();
259 Ok(format!("@{}", cl))
260 }
261
262 fn protected_submit_targets(&self) -> Vec<String> {
263 vec!["//depot/main/...".to_string()]
266 }
267
268 fn verify_not_on_protected_target(&self) -> Result<()> {
269 let p4_available = std::process::Command::new("p4")
273 .arg("-V")
274 .output()
275 .map(|o| o.status.success())
276 .unwrap_or(false);
277
278 if !p4_available {
279 tracing::warn!(
280 "PerforceAdapter: p4 CLI not found — cannot verify protected targets. \
281 Ensure your depot paths are not in: {:?}",
282 self.protected_submit_targets()
283 );
284 return Ok(());
285 }
286
287 match self.p4_cmd(&["info"]) {
289 Ok(info) => {
290 let client_root = info
291 .lines()
292 .find(|l| l.starts_with("Client root:"))
293 .map(|l| l.trim_start_matches("Client root:").trim().to_string())
294 .unwrap_or_default();
295
296 let protected = self.protected_submit_targets();
297 for target in &protected {
298 tracing::debug!(
303 client_root = %client_root,
304 protected_target = %target,
305 "PerforceAdapter: protected target check (informational)"
306 );
307 }
308 Ok(())
309 }
310 Err(e) => {
311 tracing::warn!(
312 error = %e,
313 "PerforceAdapter: could not run `p4 info` for protected target check"
314 );
315 Ok(()) }
317 }
318 }
319
320 fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
321 let cl = review_id
323 .strip_prefix("cl:")
324 .or_else(|| review_id.strip_prefix('@'))
325 .unwrap_or(review_id);
326
327 match self.p4_cmd(&["change", "-o", cl]) {
328 Ok(spec) => {
329 let state = spec
332 .lines()
333 .find(|l| l.starts_with("Status:"))
334 .and_then(|l| l.split_whitespace().nth(1))
335 .unwrap_or("unknown")
336 .to_lowercase();
337
338 let mapped_state = match state.as_str() {
339 "submitted" => "merged",
340 "pending" | "shelved" => "open",
341 other => other,
342 };
343
344 Ok(Some(ReviewStatus {
345 state: mapped_state.to_string(),
346 checks_passing: None,
347 }))
348 }
349 Err(_) => Ok(None),
350 }
351 }
352
353 fn merge_review(&self, review_id: &str) -> Result<MergeResult> {
354 let cl = review_id
356 .strip_prefix("cl:")
357 .or_else(|| review_id.strip_prefix('@'))
358 .unwrap_or(review_id);
359
360 tracing::info!(cl = %cl, "PerforceAdapter: submitting shelved changelist");
361
362 match self.p4_cmd(&["submit", "-c", cl]) {
363 Ok(output) => {
364 let submitted_cl = output
366 .lines()
367 .find(|l| l.contains("Submitted as change"))
368 .and_then(|l| l.split_whitespace().last())
369 .map(|s| s.trim_end_matches('.').to_string());
370
371 Ok(MergeResult {
372 merged: true,
373 merge_commit: submitted_cl.clone(),
374 message: format!(
375 "Changelist {} submitted to depot{}.",
376 cl,
377 submitted_cl
378 .as_ref()
379 .map(|n| format!(" as change {}", n))
380 .unwrap_or_default()
381 ),
382 metadata: [
383 ("changelist".to_string(), cl.to_string()),
384 ("submitted_cl".to_string(), submitted_cl.unwrap_or_default()),
385 ]
386 .into_iter()
387 .collect(),
388 })
389 }
390 Err(e) => Err(SubmitError::ReviewError(format!(
391 "p4 submit -c {} failed: {}. \
392 Resolve any conflicts, then re-run `ta draft merge <id>` or submit manually.",
393 cl, e
394 ))),
395 }
396 }
397
398 fn stage_env(
399 &self,
400 _staging_dir: &std::path::Path,
401 config: &crate::config::VcsAgentConfig,
402 ) -> crate::adapter::Result<std::collections::HashMap<String, String>> {
403 let mut env = std::collections::HashMap::new();
404 match config.p4_mode.as_str() {
405 "inherit" => {
406 }
408 "read-only" => {
409 env.insert("P4CLIENT".to_string(), String::new());
411 }
412 _ => {
413 env.insert("P4CLIENT".to_string(), String::new());
418 tracing::info!(
419 "Perforce staging mode: shelve — P4CLIENT cleared for agent isolation"
420 );
421 }
422 }
423 Ok(env)
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_perforce_adapter_name() {
433 let dir = tempfile::tempdir().unwrap();
434 let adapter = PerforceAdapter::new(dir.path());
435 assert_eq!(adapter.name(), "perforce");
436 }
437
438 #[test]
439 fn test_perforce_adapter_exclude_patterns() {
440 let dir = tempfile::tempdir().unwrap();
441 let adapter = PerforceAdapter::new(dir.path());
442 let patterns = adapter.exclude_patterns();
443 assert!(patterns.contains(&".p4config".to_string()));
444 assert!(patterns.contains(&".p4ignore".to_string()));
445 }
446
447 #[test]
448 fn test_perforce_adapter_detect_p4config_file() {
449 let dir = tempfile::tempdir().unwrap();
450
451 std::fs::write(dir.path().join(".p4config"), "P4PORT=ssl:perforce:1666\n").unwrap();
454 assert!(PerforceAdapter::detect(dir.path()));
455 }
456
457 #[test]
458 fn test_perforce_adapter_push_result() {
459 let dir = tempfile::tempdir().unwrap();
461 let adapter = PerforceAdapter::new(dir.path());
462 assert_eq!(adapter.name(), "perforce");
463 }
464
465 #[test]
466 fn test_perforce_adapter_protected_targets() {
467 let dir = tempfile::tempdir().unwrap();
468 let adapter = PerforceAdapter::new(dir.path());
469 let targets = adapter.protected_submit_targets();
470 assert!(targets.contains(&"//depot/main/...".to_string()));
471 }
472
473 #[test]
474 fn test_perforce_adapter_verify_degrades_without_p4() {
475 let dir = tempfile::tempdir().unwrap();
477 let adapter = PerforceAdapter::new(dir.path());
478 assert!(adapter.verify_not_on_protected_target().is_ok());
480 }
481}