use std::path::Path;
use std::process::Command;
use ta_changeset::DraftPackage;
use ta_goal::GoalRun;
use crate::adapter::{
CommitResult, MergeResult, PushResult, Result, ReviewResult, ReviewStatus, SavedVcsState,
SourceAdapter, SubmitError, SyncResult,
};
use crate::config::SubmitConfig;
#[derive(Debug, Clone)]
struct PerforceState {
client: String,
changelist: Option<String>,
}
pub struct PerforceAdapter {
work_dir: std::path::PathBuf,
}
impl PerforceAdapter {
pub fn new(work_dir: impl Into<std::path::PathBuf>) -> Self {
Self {
work_dir: work_dir.into(),
}
}
fn p4_cmd(&self, args: &[&str]) -> Result<String> {
let output = Command::new("p4")
.args(args)
.current_dir(&self.work_dir)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SubmitError::VcsError(format!(
"p4 {} failed: {}",
args.join(" "),
stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn detect(project_root: &Path) -> bool {
if std::env::var("P4CONFIG").is_ok() {
return true;
}
project_root.join(".p4config").exists()
}
}
impl SourceAdapter for PerforceAdapter {
fn prepare(&self, goal: &GoalRun, _config: &SubmitConfig) -> Result<()> {
tracing::info!(
"PerforceAdapter: creating pending changelist for goal {}",
goal.goal_run_id
);
let spec = self.p4_cmd(&["change", "-o"])?;
let new_desc = format!("TA Goal: {} [{}]", goal.title, goal.goal_run_id);
let modified_spec = spec
.lines()
.map(|line| {
if line.starts_with("\t<enter description here>") {
format!("\t{}", new_desc)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let output = Command::new("p4")
.args(["change", "-i"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.current_dir(&self.work_dir)
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(modified_spec.as_bytes())?;
}
child.wait_with_output()
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SubmitError::VcsError(format!(
"p4 change -i failed: {}",
stderr
)));
}
tracing::info!("PerforceAdapter: changelist created");
Ok(())
}
fn commit(&self, goal: &GoalRun, _pr: &DraftPackage, message: &str) -> Result<CommitResult> {
tracing::info!("PerforceAdapter: reconciling and shelving changes");
let _ = self.p4_cmd(&["reconcile", "..."]);
let shelve_output = self.p4_cmd(&["shelve", "-c", "default"])?;
let cl = shelve_output
.split_whitespace()
.find(|w| w.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("unknown")
.to_string();
Ok(CommitResult {
commit_id: format!("cl:{}", cl),
message: format!("{} (shelved in changelist {})", message, cl),
metadata: [
("changelist".to_string(), cl),
("goal_id".to_string(), goal.goal_run_id.to_string()),
]
.into_iter()
.collect(),
ignored_artifacts: vec![],
})
}
fn push(&self, _goal: &GoalRun) -> Result<PushResult> {
tracing::info!("PerforceAdapter: submitting changelist");
let output = self.p4_cmd(&["submit", "-c", "default"])?;
Ok(PushResult {
remote_ref: "p4://submitted".to_string(),
message: format!("Submitted: {}", output.lines().next().unwrap_or("ok")),
metadata: Default::default(),
})
}
fn open_review(&self, goal: &GoalRun, _pr: &DraftPackage) -> Result<ReviewResult> {
tracing::debug!(
"PerforceAdapter: open_review() — shelved changelist serves as review (use Helix Swarm for web UI)"
);
Ok(ReviewResult {
review_url: format!("p4://shelved/{}", goal.goal_run_id),
review_id: format!("p4-{}", goal.goal_run_id),
message: "Changes shelved. If Helix Swarm is configured, the review is available in the Swarm web UI.".to_string(),
metadata: Default::default(),
})
}
fn sync_upstream(&self) -> Result<SyncResult> {
tracing::info!("PerforceAdapter: running p4 sync");
match self.p4_cmd(&["sync"]) {
Ok(output) => {
let file_count = output.lines().count();
Ok(SyncResult {
updated: file_count > 0,
conflicts: vec![],
new_commits: file_count as u32,
message: format!("p4 sync completed: {} file(s) updated.", file_count),
metadata: Default::default(),
})
}
Err(e) => Err(SubmitError::SyncError(format!("p4 sync failed: {}", e))),
}
}
fn name(&self) -> &str {
"perforce"
}
fn exclude_patterns(&self) -> Vec<String> {
vec![".p4config".to_string(), ".p4ignore".to_string()]
}
fn save_state(&self) -> Result<Option<SavedVcsState>> {
let client = self
.p4_cmd(&["set", "P4CLIENT"])
.unwrap_or_else(|_| "unknown".to_string());
let changelist = self.p4_cmd(&["changes", "-s", "pending", "-m", "1"]).ok();
let state = PerforceState { client, changelist };
tracing::debug!(?state, "PerforceAdapter: saved state");
Ok(Some(SavedVcsState {
adapter: "perforce".to_string(),
data: Box::new(state),
}))
}
fn restore_state(&self, state: Option<SavedVcsState>) -> Result<()> {
let state = match state {
Some(s) => s,
None => return Ok(()),
};
if state.adapter != "perforce" {
return Err(SubmitError::InvalidState(format!(
"Cannot restore state from adapter '{}' in PerforceAdapter",
state.adapter
)));
}
if let Ok(p4_state) = state.data.downcast::<PerforceState>() {
tracing::info!(
client = %p4_state.client,
changelist = ?p4_state.changelist,
"PerforceAdapter: state restored"
);
}
Ok(())
}
fn revision_id(&self) -> Result<String> {
let output = self.p4_cmd(&["changes", "-m", "1", "...#have"])?;
let cl = output
.split_whitespace()
.nth(1) .unwrap_or("unknown")
.to_string();
Ok(format!("@{}", cl))
}
fn protected_submit_targets(&self) -> Vec<String> {
vec!["//depot/main/...".to_string()]
}
fn verify_not_on_protected_target(&self) -> Result<()> {
let p4_available = std::process::Command::new("p4")
.arg("-V")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !p4_available {
tracing::warn!(
"PerforceAdapter: p4 CLI not found — cannot verify protected targets. \
Ensure your depot paths are not in: {:?}",
self.protected_submit_targets()
);
return Ok(());
}
match self.p4_cmd(&["info"]) {
Ok(info) => {
let client_root = info
.lines()
.find(|l| l.starts_with("Client root:"))
.map(|l| l.trim_start_matches("Client root:").trim().to_string())
.unwrap_or_default();
let protected = self.protected_submit_targets();
for target in &protected {
tracing::debug!(
client_root = %client_root,
protected_target = %target,
"PerforceAdapter: protected target check (informational)"
);
}
Ok(())
}
Err(e) => {
tracing::warn!(
error = %e,
"PerforceAdapter: could not run `p4 info` for protected target check"
);
Ok(()) }
}
}
fn check_review(&self, review_id: &str) -> Result<Option<ReviewStatus>> {
let cl = review_id
.strip_prefix("cl:")
.or_else(|| review_id.strip_prefix('@'))
.unwrap_or(review_id);
match self.p4_cmd(&["change", "-o", cl]) {
Ok(spec) => {
let state = spec
.lines()
.find(|l| l.starts_with("Status:"))
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("unknown")
.to_lowercase();
let mapped_state = match state.as_str() {
"submitted" => "merged",
"pending" | "shelved" => "open",
other => other,
};
Ok(Some(ReviewStatus {
state: mapped_state.to_string(),
checks_passing: None,
}))
}
Err(_) => Ok(None),
}
}
fn merge_review(&self, review_id: &str) -> Result<MergeResult> {
let cl = review_id
.strip_prefix("cl:")
.or_else(|| review_id.strip_prefix('@'))
.unwrap_or(review_id);
tracing::info!(cl = %cl, "PerforceAdapter: submitting shelved changelist");
match self.p4_cmd(&["submit", "-c", cl]) {
Ok(output) => {
let submitted_cl = output
.lines()
.find(|l| l.contains("Submitted as change"))
.and_then(|l| l.split_whitespace().last())
.map(|s| s.trim_end_matches('.').to_string());
Ok(MergeResult {
merged: true,
merge_commit: submitted_cl.clone(),
message: format!(
"Changelist {} submitted to depot{}.",
cl,
submitted_cl
.as_ref()
.map(|n| format!(" as change {}", n))
.unwrap_or_default()
),
metadata: [
("changelist".to_string(), cl.to_string()),
("submitted_cl".to_string(), submitted_cl.unwrap_or_default()),
]
.into_iter()
.collect(),
})
}
Err(e) => Err(SubmitError::ReviewError(format!(
"p4 submit -c {} failed: {}. \
Resolve any conflicts, then re-run `ta draft merge <id>` or submit manually.",
cl, e
))),
}
}
fn stage_env(
&self,
_staging_dir: &std::path::Path,
config: &crate::config::VcsAgentConfig,
) -> crate::adapter::Result<std::collections::HashMap<String, String>> {
let mut env = std::collections::HashMap::new();
match config.p4_mode.as_str() {
"inherit" => {
}
"read-only" => {
env.insert("P4CLIENT".to_string(), String::new());
}
_ => {
env.insert("P4CLIENT".to_string(), String::new());
tracing::info!(
"Perforce staging mode: shelve — P4CLIENT cleared for agent isolation"
);
}
}
Ok(env)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_perforce_adapter_name() {
let dir = tempfile::tempdir().unwrap();
let adapter = PerforceAdapter::new(dir.path());
assert_eq!(adapter.name(), "perforce");
}
#[test]
fn test_perforce_adapter_exclude_patterns() {
let dir = tempfile::tempdir().unwrap();
let adapter = PerforceAdapter::new(dir.path());
let patterns = adapter.exclude_patterns();
assert!(patterns.contains(&".p4config".to_string()));
assert!(patterns.contains(&".p4ignore".to_string()));
}
#[test]
fn test_perforce_adapter_detect_p4config_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".p4config"), "P4PORT=ssl:perforce:1666\n").unwrap();
assert!(PerforceAdapter::detect(dir.path()));
}
#[test]
fn test_perforce_adapter_push_result() {
let dir = tempfile::tempdir().unwrap();
let adapter = PerforceAdapter::new(dir.path());
assert_eq!(adapter.name(), "perforce");
}
#[test]
fn test_perforce_adapter_protected_targets() {
let dir = tempfile::tempdir().unwrap();
let adapter = PerforceAdapter::new(dir.path());
let targets = adapter.protected_submit_targets();
assert!(targets.contains(&"//depot/main/...".to_string()));
}
#[test]
fn test_perforce_adapter_verify_degrades_without_p4() {
let dir = tempfile::tempdir().unwrap();
let adapter = PerforceAdapter::new(dir.path());
assert!(adapter.verify_not_on_protected_target().is_ok());
}
}