Skip to main content

ta_submit/
perforce.rs

1//! Perforce adapter stub — untested, contributed by AI.
2//!
3//! This adapter provides basic Perforce/Helix Core integration.
4//! It is **untested** and needs validation by a Perforce user before production use.
5//!
6//! Key differences from Git:
7//! - Uses changelists instead of branches
8//! - `commit()` shelves files (staging for review)
9//! - `push()` submits the changelist (makes it permanent)
10//! - Review via Helix Swarm API (if configured)
11
12use 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/// Saved Perforce state: current changelist number and client name.
24#[derive(Debug, Clone)]
25struct PerforceState {
26    client: String,
27    changelist: Option<String>,
28}
29
30/// Perforce/Helix Core adapter implementing changelist-based workflow.
31///
32/// **Status: UNTESTED** — needs validation by a Perforce user.
33pub 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    /// Auto-detect whether this is a Perforce workspace.
63    pub fn detect(project_root: &Path) -> bool {
64        // Check for P4CONFIG env var
65        if std::env::var("P4CONFIG").is_ok() {
66            return true;
67        }
68        // Check for .p4config file
69        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        // Create a new pending changelist.
81        // `p4 change -o` outputs a changelist spec, we modify and pipe to `p4 change -i`.
82        let spec = self.p4_cmd(&["change", "-o"])?;
83
84        // Replace the description in the spec.
85        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        // Pipe modified spec to create the changelist.
99        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        // Reconcile: detect added/edited/deleted files.
130        let _ = self.p4_cmd(&["reconcile", "..."]);
131
132        // Shelve the files (staging for review).
133        let shelve_output = self.p4_cmd(&["shelve", "-c", "default"])?;
134
135        // Try to extract changelist number from output.
136        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        // Shelving is the Perforce equivalent of opening a review.
169        // If Helix Swarm is configured, the shelved changelist appears there automatically.
170        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                // Count synced files from p4 sync output.
187                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        // Save current client and pending changelist info.
211        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        // For Perforce, restore is mostly informational — the client workspace
239        // persists across operations. Log for observability.
240        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        // Get the latest changelist number synced to this client.
253        let output = self.p4_cmd(&["changes", "-m", "1", "...#have"])?;
254        let cl = output
255            .split_whitespace()
256            .nth(1) // "Change 1234 ..."
257            .unwrap_or("unknown")
258            .to_string();
259        Ok(format!("@{}", cl))
260    }
261
262    fn protected_submit_targets(&self) -> Vec<String> {
263        // Depot paths that agents must never submit directly to.
264        // Default: the conventional main depot path.
265        vec!["//depot/main/...".to_string()]
266    }
267
268    fn verify_not_on_protected_target(&self) -> Result<()> {
269        // Check current CL's target stream/depot via `p4 info`.
270        // If p4 is not installed, degrade gracefully (allow the submit to proceed
271        // but log a warning — p4 itself will enforce restrictions).
272        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        // Get the current client's root stream/depot mapping.
288        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                    // Simple check: if the target depot path appears in client info
299                    // and there's no branch indicator, warn but allow (Perforce
300                    // enforces protection through its own permission system; our
301                    // `prepare()` creates a pending CL which is the isolation mechanism).
302                    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(()) // Degrade gracefully
316            }
317        }
318    }
319
320    fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
321        // Extract the raw CL number (strip "cl:" or "@" prefix if present).
322        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                // Parse the Status field from the CL spec.
330                // Possible values: pending, shelved, submitted.
331                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        // Extract raw CL number.
355        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                // Extract submitted CL number from output ("Submitted as change N.")
365                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                // No env changes — agent inherits developer's P4CLIENT.
407            }
408            "read-only" => {
409                // Clear P4CLIENT so writes are rejected.
410                env.insert("P4CLIENT".to_string(), String::new());
411            }
412            _ => {
413                // "shelve" (default): clear P4CLIENT to prevent accidental submits.
414                // A real P4 staging workspace must be created server-side separately.
415                // This env injection blocks the agent from using the developer's live
416                // workspace while still allowing p4 reads via P4PORT/P4USER.
417                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        // No .p4config — should not detect (unless P4CONFIG env is set)
452        // We can't control env easily in tests, so just test file detection.
453        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        // Just verify the adapter can be constructed and basic methods work.
460        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        // Without p4 CLI, verify_not_on_protected_target should succeed (degrade gracefully).
476        let dir = tempfile::tempdir().unwrap();
477        let adapter = PerforceAdapter::new(dir.path());
478        // This will either succeed (p4 not installed) or succeed (p4 installed but warns).
479        assert!(adapter.verify_not_on_protected_target().is_ok());
480    }
481}