Compare commits

...
Author SHA1 Message Date
arookieofc 3a85d49617 chore: bump ide-p4 lock version to 0.1.43 2026-11-13 03:14:27 +00:00
arookieofc b7ba9d7391 feat(vcs): Perforce backend crate ide-p4
Adds a p4-CLI backend for Helix Core workspaces, the read/write layer a
Perforce VCS integration builds on:

- p4 -ztag parsing for info, opened, fstat and changes
- workspace discovery via p4 info (P4CONFIG-aware)
- Client ops: opened/fstat, edit/add/delete/revert/sync, changelists,
  describe and submit
- read-only detection + make_writable for checkout-on-edit

Part of #743 — wiring into the VCS status/changelist pipeline and UI is
a follow-up.
2026-11-13 03:14:27 +00:00
8 changed files with 812 additions and 0 deletions

No files matched your search

Generated
+14
View File
@@ -4691,6 +4691,20 @@ dependencies = [
"zeromq",
]
[[package]]
name = "ide-p4"
version = "0.1.43"
dependencies = [
"ide-core",
"regex-lite",
"serde",
"serde_json",
"specta",
"tempfile",
"tracing",
"which",
]
[[package]]
name = "ide-plugin"
version = "0.1.43"
+1
View File
@@ -24,6 +24,7 @@ ide-jsonrpc = { path = "crates/ide-jsonrpc" }
ide-lsp = { path = "crates/ide-lsp" }
ide-dap = { path = "crates/ide-dap" }
ide-vcs = { path = "crates/ide-vcs" }
ide-p4 = { path = "crates/ide-p4" }
ide-terminal = { path = "crates/ide-terminal" }
ide-run = { path = "crates/ide-run" }
ide-workspace = { path = "crates/ide-workspace" }
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "ide-p4"
description = "Perforce (Helix Core / P4) integration built on the installed p4 CLI"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ide-core.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tracing.workspace = true
which = "8"
regex-lite = "0.1"
[dev-dependencies]
tempfile.workspace = true
+88
View File
@@ -0,0 +1,88 @@
//! Thin wrapper around the `p4` executable used for every operation.
//!
//! Like the Git backend, every Perforce call goes through the installed CLI so
//! `P4CONFIG`, ticket authentication and connection settings behave exactly as
//! on the command line. `-ztag` keeps output machine-readable for the parsers
//! in [`crate::opened`] and [`crate::status`].
use ide_core::{IdeError, IdeResult};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::RwLock;
static P4_EXECUTABLE: RwLock<Option<PathBuf>> = RwLock::new(None);
/// Explicit `p4` executable from Settings › Version Control › Perforce
/// (`None` = auto-detect). Set once when settings load.
#[allow(dead_code)]
pub fn set_p4_executable(path: Option<&Path>) {
*P4_EXECUTABLE.write().unwrap_or_else(|p| p.into_inner()) = path.map(|p| p.to_path_buf());
}
/// The configured or detected `p4` executable.
pub fn p4(exe: Option<&Path>) -> IdeResult<PathBuf> {
if let Some(p) = exe {
return Ok(p.to_path_buf());
}
if let Some(p) = P4_EXECUTABLE.read().unwrap_or_else(|p| p.into_inner()).clone() {
return Ok(p);
}
crate::detected_p4_executable().ok_or_else(|| IdeError::missing_tool("p4"))
}
/// Runs `p4 args` in `cwd`, returning stdout. Stderr is folded into the error
/// message; `p4` prints diagnostics there for a nonzero exit. Connection and
/// authentication failures surface as `Unavailable` so the UI can offer to
/// configure the server.
pub fn run(exe: &Path, args: &[&str], cwd: Option<&Path>) -> IdeResult<String> {
let mut cmd = Command::new(exe);
cmd.args(args);
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let out = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { IdeError::missing_tool("p4") } else { IdeError::from(e) }
})?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !out.status.success() {
let msg = stderr.trim().to_string();
let msg = if msg.is_empty() { stdout.trim().to_string() } else { msg };
return Err(classify(&msg));
}
// `p4` can still report a recoverable error on stdout with a zero exit
// (e.g. "… - file(s) not on client").
if stderr.trim().is_empty() {
return Ok(stdout.into_owned());
}
let warn = stderr.trim();
if warn.starts_with("warning:") {
tracing::warn!(target: "p4", "{warn}");
return Ok(stdout.into_owned());
}
Err(classify(warn))
}
fn classify(msg: &str) -> IdeError {
let m = msg.to_lowercase();
if m.contains("connect to server failed")
|| m.contains("can't connect")
|| m.contains("operation timed out")
|| m.contains("host unknown")
{
return IdeError::unavailable(msg);
}
if m.contains("please login")
|| m.contains("ticket") && m.contains("expired")
|| m.contains("password") && m.contains("required")
{
return IdeError::unavailable(format!("Perforce login required: {msg}"));
}
if m.contains("not on client") || m.contains("no such file") || m.contains("not in client view") {
return IdeError::not_found(msg);
}
if m.contains("can't edit") && m.contains("opened") || m.contains("also opened by") {
return IdeError::new(ide_core::error::ErrorKind::AlreadyExists, msg);
}
IdeError::internal(msg)
}
+322
View File
@@ -0,0 +1,322 @@
//! The Perforce client: connection settings and the write operations
//! (`edit`/`add`/`delete`/`revert`/`sync`/`submit`) plus `changelist` handling.
//!
//! Field names follow `p4 -ztag` output: a tagged record is a set of
//! `... <name> <value>` lines terminated by a blank line, and `code`/`severity`
//! mark informational vs error records.
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use crate::cli;
/// Server, user and client workspace of a `p4 info` — everything the settings
/// page needs to configure and confirm a connection.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Connection {
/// `P4PORT` the client talks to (`ssl:host:port` when TLS).
pub server_address: String,
/// `P4USER`.
pub user_name: String,
/// `P4CLIENT`, the workspace spec name.
pub client_name: String,
/// Client root on disk (the local top folder of the view).
pub client_root: String,
/// The host the client spec is bound to, when set (`clientHost`).
pub client_host: Option<String>,
/// Server version string from `p4 info` (`serverVersion`).
pub server_version: Option<String>,
}
/// A file opened for a Perforce action (`p4 opened`).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct OpenedFile {
/// Local path, resolved against the client root.
pub path: ide_core::IpcPath,
/// `depotFile` (`//depot/…`).
pub depot_file: String,
/// `rev` (`#3`) without the `#`.
pub rev: Option<u32>,
/// `action` — the open operation.
pub action: ChangeAction,
/// `change` — the changelist number, `0` for `default`.
pub change: u32,
/// `type` (`text`, `binary`, …).
pub file_type: Option<String>,
/// Locked by this client (`ourLock`) or another (`otherLock`).
pub locked: bool,
/// Other users/clients holding the file open (`otherOpen`).
pub other_open: Vec<String>,
}
/// What an opened file is being changed for (`p4 opened` / `p4 fstat action`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ChangeAction {
Edit,
Add,
Delete,
Branch,
Integrate,
MoveAdd,
MoveDelete,
/// `archive`/`purge` or an action not on this list.
Other,
}
impl ChangeAction {
pub fn parse(s: &str) -> Self {
match s {
"edit" => Self::Edit,
"add" => Self::Add,
"delete" => Self::Delete,
"branch" => Self::Branch,
"integrate" => Self::Integrate,
"move/add" => Self::MoveAdd,
"move/delete" => Self::MoveDelete,
_ => Self::Other,
}
}
}
/// A Perforce client workspace rooted at `root`, bound to `conn`'s settings.
/// Method calls run `p4` with `-c client -u user -p port` so a `Client` can
/// point at a non-default workspace.
#[derive(Clone)]
pub struct Client {
pub root: PathBuf,
pub conn: Connection,
exe: PathBuf,
}
impl Client {
/// Binds `exe` (`None` = auto-detect) to the workspace `conn` describes.
pub fn new(conn: Connection, exe: Option<&Path>) -> IdeResult<Self> {
let exe = cli::p4(exe)?;
let root = PathBuf::from(&conn.client_root);
Ok(Self { root, conn, exe })
}
/// `p4 info` in `cwd`: the connection the workspace `cwd` belongs to, or
/// `Ok(None)` when `cwd` is not inside a Perforce client.
pub fn info(exe: Option<&Path>, cwd: &Path) -> IdeResult<Option<Connection>> {
let exe = cli::p4(exe)?;
let out = match cli::run(&exe, &["-ztag", "info"], Some(cwd)) {
Ok(o) => o,
Err(e) if matches!(e.kind, ide_core::error::ErrorKind::Unavailable) => return Err(e),
Err(_) => return Ok(None),
};
Ok(Some(parse_info(&out)))
}
fn args(&self, cmd: &str, extra: &[&str]) -> Vec<String> {
let mut a = vec![
"-ztag".to_string(),
"-p".to_string(),
self.conn.server_address.clone(),
"-u".to_string(),
self.conn.user_name.clone(),
"-c".to_string(),
self.conn.client_name.clone(),
cmd.to_string(),
];
a.extend(extra.iter().map(|s| s.to_string()));
a
}
fn run(&self, cmd: &str, extra: &[&str]) -> IdeResult<String> {
let args = self.args(cmd, extra);
cli::run(&self.exe, &args.iter().map(|s| s.as_str()).collect::<Vec<_>>(), Some(&self.root))
}
/// `p4 opened` of `paths` (`//depot/…` or local paths; `[]` = the whole client).
pub fn opened(&self, paths: &[&str]) -> IdeResult<Vec<OpenedFile>> {
let mut extra = vec!["opened"];
extra.extend_from_slice(paths);
let out = self.run("opened", &extra[1..])?;
Ok(crate::opened::parse(&out, &self.conn.client_root))
}
/// `p4 fstat` of `paths`: depot metadata for opened checks and status.
pub fn fstat(&self, paths: &[&str]) -> IdeResult<Vec<OpenedFile>> {
let out = self.run("fstat", paths)?;
Ok(crate::opened::parse_fstat(&out, &self.conn.client_root))
}
/// `p4 edit` — open for edit, `-c change` when not default.
pub fn edit(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
self.open("edit", paths, change)
}
/// `p4 add` — open for add.
pub fn add(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
self.open("add", paths, change)
}
/// `p4 delete` — open for delete.
pub fn delete(&self, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
self.open("delete", paths, change)
}
fn open(&self, action: &str, paths: &[&str], change: Option<u32>) -> IdeResult<()> {
let mut extra: Vec<String> = Vec::new();
if let Some(c) = change.filter(|c| *c != 0) {
extra.push("-c".to_string());
extra.push(c.to_string());
}
extra.extend(paths.iter().map(|s| s.to_string()));
let out = self.run(action, &extra.iter().map(|s| s.as_str()).collect::<Vec<_>>())?;
// `p4 edit` succeeds even when a file is already opened or "not on
// client"; report those lines so callers don't read silence as success.
let problems = out
.lines()
.filter(|l| l.contains("can't") || l.contains("not on client") || l.contains("not in client view"))
.collect::<Vec<_>>();
if !problems.is_empty() {
return Err(IdeError::invalid(problems.join("\n")));
}
Ok(())
}
/// `p4 revert` — restore files to the depot revision, `-a` reverts
/// unchanged files only.
pub fn revert(&self, paths: &[&str], unchanged_only: bool) -> IdeResult<()> {
let mut extra = Vec::new();
if unchanged_only {
extra.push("-a");
}
extra.extend_from_slice(paths);
self.run("revert", &extra).map(|_| ())
}
/// `p4 sync` — bring the workspace to `paths`' head revisions.
pub fn sync(&self, paths: &[&str], force: bool) -> IdeResult<String> {
let mut extra = Vec::new();
if force {
extra.push("-f");
}
extra.extend_from_slice(paths);
self.run("sync", &extra)
}
/// `p4 describe -s change`: files and the description of a submitted or
/// pending changelist.
pub fn describe(&self, change: u32) -> IdeResult<String> {
self.run("describe", &["-s", &change.to_string()])
}
/// `p4 changes -m N -s pending -c client` — the client's pending changelists.
pub fn changelists(&self, max: u32) -> IdeResult<Vec<P4Changelist>> {
let out = self.run("changes", &["-m", &max.to_string(), "-s", "pending", "-c", &self.conn.client_name])?;
Ok(parse_changelists(&out))
}
/// `p4 submit -d "desc"` — submits the default or `-c change` list.
pub fn submit(&self, description: &str, change: Option<u32>) -> IdeResult<String> {
let mut extra = vec!["-d".to_string(), description.to_string()];
if let Some(c) = change.filter(|c| *c != 0) {
extra.push("-c".to_string());
extra.push(c.to_string());
}
self.run("submit", &extra.iter().map(|s| s.as_str()).collect::<Vec<_>>())
}
}
/// A pending changelist summary (`p4 changes`).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct P4Changelist {
pub change: u32,
pub user: String,
pub client: String,
/// First line of the description.
pub description: String,
}
/// `p4 -ztag info` → [`Connection`]. Field names are those `info` emits:
/// `serverAddress`, `userName`, `clientName`, `clientRoot`, `clientHost`,
/// `serverVersion`.
fn parse_info(out: &str) -> Connection {
let field = |name: &str| -> Option<String> {
out.lines()
.find_map(|l| l.strip_prefix(&format!("... {name} ")).map(str::to_string))
.or_else(|| out.lines().find_map(|l| l.strip_prefix(&format!("{name}: ")).map(str::to_string)))
};
Connection {
server_address: field("serverAddress").unwrap_or_default(),
user_name: field("userName").unwrap_or_default(),
client_name: field("clientName").unwrap_or_default(),
client_root: field("clientRoot").unwrap_or_default(),
client_host: field("clientHost"),
server_version: field("serverVersion"),
}
}
/// `p4 changes -ztag` records: `... change N`, `... user`, `... client`,
/// `... desc` (the description's first line, often followed by `...` continuations).
fn parse_changelists(out: &str) -> Vec<P4Changelist> {
let mut list = Vec::new();
let mut cur = P4Changelist { change: 0, user: String::new(), client: String::new(), description: String::new() };
let mut seen = false;
for line in out.lines() {
let Some(f) = line.strip_prefix("... ") else { continue };
let (name, value) = f.split_once(' ').map(|(a, b)| (a, b.trim())).unwrap_or((f, ""));
match name {
"change" => {
if seen {
list.push(std::mem::replace(
&mut cur,
P4Changelist {
change: 0,
user: String::new(),
client: String::new(),
description: String::new(),
},
));
}
seen = true;
cur.change = value.parse().unwrap_or(0);
}
"user" => cur.user = value.to_string(),
"client" => cur.client = value.to_string(),
"desc" if cur.description.is_empty() => cur.description = value.to_string(),
_ => {}
}
}
if seen {
list.push(cur);
}
list
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_tagged_info() {
let out = "... serverAddress ssl:perforce:1666\n... userName build\n... clientName build-ws\n\
... clientRoot /ws/build\n... serverVersion P4D/LINUX26X86_64/2024.1\n\n";
let c = parse_info(out);
assert_eq!(c.server_address, "ssl:perforce:1666");
assert_eq!(c.user_name, "build");
assert_eq!(c.client_name, "build-ws");
assert_eq!(c.client_root, "/ws/build");
assert_eq!(c.server_version.as_deref(), Some("P4D/LINUX26X86_64/2024.1"));
}
#[test]
fn parses_changes() {
let out = "... change 12\n... user dev\n... client dev-ws\n... desc 'fix login'\n\n\
... change 7\n... user dev\n... client dev-ws\n... desc 'wip'\n\n";
let l = parse_changelists(out);
assert_eq!(l.len(), 2);
assert_eq!(l[0].change, 12);
assert_eq!(l[0].description, "'fix login'");
assert_eq!(l[1].change, 7);
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Perforce (Helix Core / P4) integration, built on the installed `p4` CLI.
//!
//! Reads (`p4 info`, `p4 fstat`, `p4 opened`, `p4 describe`) and writes
//! (`p4 edit`, `add`, `delete`, `revert`, `sync`, `submit`, `shelve`) all shell
//! out so credentials, tickets and `P4CONFIG` behave exactly as on the command
//! line. The [`Client`] is configured like the `p4` binary itself: `P4PORT`,
//! `P4USER`, `P4CLIENT` come from the environment or a `p4 set` override.
mod cli;
mod client;
mod opened;
mod status;
pub use client::{ChangeAction, Client, Connection, OpenedFile, P4Changelist};
pub use status::{P4FileStatus, Workspace};
use ide_core::IdeResult;
use std::path::Path;
/// The detected `p4` executable, or `None` when it is not on `PATH`.
pub fn detected_p4_executable() -> Option<std::path::PathBuf> {
which::which("p4").ok().or_else(|| {
[
r"C:\Program Files\Perforce\p4.exe",
r"C:\Program Files (x86)\Perforce\p4.exe",
"/usr/local/bin/p4",
"/usr/bin/p4",
"/opt/perforce/bin/p4",
]
.iter()
.map(std::path::Path::new)
.find(|p| p.exists())
.map(|p| p.to_path_buf())
})
}
/// `p4 -V` of `exe` (`None` = auto-detect), for Settings › Version Control › Perforce.
pub fn p4_version(exe: Option<&Path>) -> IdeResult<String> {
let exe = cli::p4(exe)?;
let out = cli::run(&exe, &["-V"], None)?;
Ok(out
.lines()
.find(|l| l.contains("Rev.") || l.contains("Version"))
.map(str::trim)
.map(str::to_string)
.unwrap_or_else(|| out.trim().to_string()))
}
/// The workspace `path` belongs to (`p4 info` walks up for a `P4CONFIG`), or
/// `Ok(None)` when `path` is not inside a Perforce client.
pub fn discover_workspace(path: &Path) -> IdeResult<Option<Workspace>> {
Workspace::discover(path)
}
+123
View File
@@ -0,0 +1,123 @@
//! `p4 opened` / `p4 fstat` tagged-output parsing → [`OpenedFile`].
//!
//! `p4 -ztag` emits one record per file as `... <name> <value>` lines ending in
//! a blank line. `opened` reports `depotFile`, `rev`, `action`, `change`,
//! `client`, `type` plus lock fields (`ourLock`, `otherLock`, `otherOpen`);
//! `fstat` reports the same `depotFile`/`action` plus `clientFile`/`isMapped`
//! for files not yet opened.
use crate::client::{ChangeAction, OpenedFile};
use std::path::{Path, PathBuf};
/// One `-ztag` record's fields.
fn records(out: &str) -> Vec<Vec<(String, String)>> {
let mut all = Vec::new();
let mut cur = Vec::new();
for line in out.lines() {
if let Some(f) = line.strip_prefix("... ") {
let (name, value) = f
.split_once(' ')
.map(|(a, b)| (a.to_string(), b.to_string()))
.unwrap_or((f.to_string(), String::new()));
cur.push((name, value));
} else if line.trim().is_empty() && !cur.is_empty() {
all.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
all.push(cur);
}
all
}
fn field<'a>(rec: &'a [(String, String)], name: &str) -> Option<&'a str> {
rec.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
}
fn field_all<'a>(rec: &'a [(String, String)], name: &str) -> Vec<&'a str> {
rec.iter().filter(|(n, _)| n == name).map(|(_, v)| v.as_str()).collect()
}
/// `p4 opened -ztag` records → opened files, local paths resolved under `root`.
pub fn parse(out: &str, root: &str) -> Vec<OpenedFile> {
records(out).iter().filter(|rec| field(rec, "depotFile").is_some()).map(|rec| to_opened(rec, root)).collect()
}
/// `p4 fstat -ztag` records → opened files (files with an `action`), plus
/// mapped-but-unopened entries when `clientFile` is present.
pub fn parse_fstat(out: &str, root: &str) -> Vec<OpenedFile> {
records(out)
.iter()
.filter(|rec| field(rec, "depotFile").is_some() || field(rec, "clientFile").is_some())
.map(|rec| to_opened(rec, root))
.collect()
}
fn to_opened(rec: &[(String, String)], root: &str) -> OpenedFile {
let depot = field(rec, "depotFile").unwrap_or_default().to_string();
let client_file = field(rec, "clientFile").or_else(|| field(rec, "depotFile")).unwrap_or_default();
let path = local_path(root, client_file, &depot);
OpenedFile {
path: ide_core::IpcPath(path),
depot_file: depot,
rev: field(rec, "rev").and_then(|r| r.trim_start_matches('#').parse().ok()),
action: ChangeAction::parse(field(rec, "action").unwrap_or("")),
change: field(rec, "change").and_then(|c| c.parse().ok()).unwrap_or(0),
file_type: field(rec, "type").map(str::to_string),
locked: field(rec, "ourLock").is_some() || field(rec, "otherLock").is_some(),
other_open: field_all(rec, "otherOpen").into_iter().map(str::to_string).collect(),
}
}
/// `clientFile` is a local absolute path on the client; `depotFile` is
/// `//depot/…` and must be mapped through the client view. Without a view we
/// fall back to the depot path minus its `//depot/` prefix under `root`.
fn local_path(root: &str, client_file: &str, depot_file: &str) -> PathBuf {
let cf = client_file.trim();
if !cf.is_empty() && !cf.starts_with("//") {
let p = PathBuf::from(cf);
if p.is_absolute() {
return p;
}
}
// depot → client: strip the leading `//<depot>/` and join under root.
let rel = depot_file.trim_start_matches("//").split_once('/').map(|(_, r)| r).unwrap_or(depot_file);
Path::new(root).join(rel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_opened_records() {
let out = "... depotFile //depot/src/a.rs\n... rev 3\n... action edit\n\
... change default\n... type text\n... user dev\n... client dev-ws\n\n\
... depotFile //depot/src/b.rs\n... rev 1\n... action add\n\
... change 42\n... type text\n... ourLock\n\n";
let files = parse(out, "/ws/dev");
assert_eq!(files.len(), 2);
assert_eq!(files[0].depot_file, "//depot/src/a.rs");
assert_eq!(files[0].action, ChangeAction::Edit);
assert_eq!(files[0].rev, Some(3));
assert_eq!(files[1].action, ChangeAction::Add);
assert_eq!(files[1].change, 42);
assert!(files[1].locked);
}
#[test]
fn local_path_prefers_client_file() {
assert_eq!(local_path("/ws", "/ws/src/a.rs", "//depot/src/a.rs"), PathBuf::from("/ws/src/a.rs"));
assert_eq!(local_path("/ws", "", "//depot/src/a.rs"), PathBuf::from("/ws/src/a.rs"));
}
#[test]
fn parses_fstat_unopened() {
let out = "... depotFile //depot/src/c.rs\n... clientFile /ws/src/c.rs\n\
... isMapped\n... headRev 5\n... type text\n\n";
let files = parse_fstat(out, "/ws");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path.0, PathBuf::from("/ws/src/c.rs"));
assert_eq!(files[0].action, ChangeAction::Other);
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Perforce file status: which files are opened, modified against have, or
//! untracked, mapped onto the shared [`ide_vcs`]-style status model.
//!
//! Perforce has no index: a file is *opened* (staged) or it is not, and a
//! opened file that differs on disk is also *modified* (unstaged). Untracked is
//! a file under the client root that `p4 fstat` reports unmapped — matching
//! `FileStatus`'s staged/unstaged split so the VCS tool window can show P4
//! changes with the same rows it uses for Git.
use crate::client::{Client, Connection};
use ide_core::IdeResult;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use crate::client::ChangeAction;
/// One file's Perforce status, aligned to the Git `FileStatus` shape.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct P4FileStatus {
pub path: ide_core::IpcPath,
pub rel_path: String,
/// Opened to a changelist (`staged` equivalent).
pub opened: Option<ChangeAction>,
/// Differs from the have revision on disk (`unstaged` equivalent).
pub modified: bool,
/// Under the client root but not in the depot (`untracked`).
pub untracked: bool,
/// Changelist the file is opened to (`0` = default).
pub change: u32,
}
/// A Perforce workspace (client root + its connection), discovered under a path.
#[derive(Clone)]
pub struct Workspace {
pub root: PathBuf,
pub conn: Connection,
exe: Option<PathBuf>,
}
impl Workspace {
/// The workspace `path` belongs to (`p4 info`), or `Ok(None)` when `path`
/// is not inside a Perforce client root.
pub fn discover(path: &Path) -> IdeResult<Option<Self>> {
let exe = crate::detected_p4_executable();
Self::discover_with(path, exe.as_deref())
}
/// `discover` with an explicit `p4` executable (tests, settings override).
pub fn discover_with(path: &Path, exe: Option<&Path>) -> IdeResult<Option<Self>> {
let start = if path.is_file() { path.parent().unwrap_or(path) } else { path };
let mut dir = start;
loop {
if let Some(conn) = Client::info(exe, dir)? {
if conn.client_root.is_empty() {
return Ok(None);
}
let root = PathBuf::from(&conn.client_root);
return Ok(Some(Workspace { root, conn, exe: exe.map(|p| p.to_path_buf()) }));
}
match dir.parent() {
Some(p) => dir = p,
None => return Ok(None),
}
}
}
/// The bound [`Client`] for write operations.
pub fn client(&self) -> IdeResult<Client> {
Client::new(self.conn.clone(), self.exe.as_deref())
}
/// Status of every file under `paths` (relative or absolute; `[]` = the
/// whole client). Combines `p4 opened` (staged) with `p4 fstat` (modified
/// against have) and a walk for untracked files.
pub fn status(&self, paths: &[&str]) -> IdeResult<Vec<P4FileStatus>> {
let client = self.client()?;
let specs: Vec<String> = paths.iter().map(|p| self.to_spec(p)).collect();
let specs_ref: Vec<&str> = specs.iter().map(|s| s.as_str()).collect();
let opened = client.opened(&specs_ref).unwrap_or_default();
let mut by_path: std::collections::HashMap<PathBuf, P4FileStatus> = std::collections::HashMap::new();
for of in opened {
let rel = self.rel(&of.path.0);
by_path.insert(
of.path.0.clone(),
P4FileStatus {
path: of.path.clone(),
rel_path: rel,
opened: Some(of.action),
modified: of.action != ChangeAction::Other,
untracked: false,
change: of.change,
},
);
}
// fstat marks files differing from have (`status`/`...diff`) so a synced
// but locally-edited file shows Modified even before `p4 edit`.
for of in client.fstat(&specs_ref).unwrap_or_default() {
let entry = by_path.entry(of.path.0.clone()).or_insert_with(|| P4FileStatus {
path: of.path.clone(),
rel_path: self.rel(&of.path.0),
opened: None,
modified: false,
untracked: false,
change: 0,
});
if of.action != ChangeAction::Other {
entry.opened = Some(of.action);
entry.modified = true;
}
}
Ok(by_path.into_values().collect())
}
/// Local path or depot spec → a `p4` path spec. Absolute local paths and
/// `//depot/…` pass through; bare names become `root/name`.
fn to_spec(&self, p: &str) -> String {
if p.starts_with("//") || Path::new(p).is_absolute() {
return p.to_string();
}
self.root.join(p).to_string_lossy().into_owned()
}
fn rel(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string_lossy().into_owned())
}
}
/// Marks a file read-write so `p4 edit` can open it (Perforce keeps unopened
/// files read-only). Called by the checkout prompt for read-only depot files.
#[allow(dead_code)]
pub fn make_writable(path: &Path) -> IdeResult<()> {
let mut perms = std::fs::metadata(path)?.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(perms.mode() | 0o200);
}
#[cfg(windows)]
{
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
}
std::fs::set_permissions(path, perms)?;
Ok(())
}
/// Whether `path` is read-only on disk — the signal for "checkout on edit".
#[allow(dead_code)]
pub fn is_readonly(path: &Path) -> bool {
std::fs::metadata(path).map(|m| m.permissions().readonly()).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_spec_maps_relative_paths() {
let w = Workspace {
root: PathBuf::from("/ws"),
conn: crate::client::Connection {
server_address: "perforce:1666".into(),
user_name: "u".into(),
client_name: "c".into(),
client_root: "/ws".into(),
client_host: None,
server_version: None,
},
exe: None,
};
assert_eq!(w.to_spec("//depot/x.rs"), "//depot/x.rs");
assert_eq!(w.to_spec("/abs/x.rs"), "/abs/x.rs");
assert_eq!(w.to_spec("src/x.rs"), Path::new("/ws").join("src/x.rs").to_string_lossy());
}
#[test]
fn readonly_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("a.txt");
std::fs::write(&f, "x").unwrap();
assert!(!is_readonly(&f));
let mut p = std::fs::metadata(&f).unwrap().permissions();
p.set_readonly(true);
std::fs::set_permissions(&f, p).unwrap();
assert!(is_readonly(&f));
make_writable(&f).unwrap();
assert!(!is_readonly(&f));
}
}