use std::path::Path;
use std::time::Duration;
use clap::Args;
use socket_patch_core::patch::apply_lock::{acquire, LockError};
use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked};
use crate::args::{apply_env_toggles, GlobalArgs};
use crate::json_envelope::{Command, Envelope, EnvelopeError};
#[derive(Args)]
pub struct UnlockArgs {
#[command(flatten)]
pub common: GlobalArgs,
#[arg(long = "release", env = "SOCKET_UNLOCK_RELEASE", default_value_t = false)]
pub release: bool,
}
pub async fn run(args: UnlockArgs) -> i32 {
apply_env_toggles(&args.common);
let socket_dir = args.common.cwd.join(".socket");
let lock_file = socket_dir.join("apply.lock");
let api_token = args.common.api_token.clone();
let org_slug = args.common.org.clone();
if !socket_dir.exists() {
track_patch_unlocked(false, args.release, api_token.as_deref(), org_slug.as_deref()).await;
return emit_free(args.common.json, &lock_file, false, args.release);
}
match acquire(&socket_dir, Duration::ZERO) {
Ok(guard) => {
drop(guard);
if args.release {
match std::fs::remove_file(&lock_file) {
Ok(()) => {
track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref())
.await;
emit_free(args.common.json, &lock_file, true, true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref())
.await;
emit_free(args.common.json, &lock_file, false, true)
}
Err(e) => {
let msg = format!(
"failed to remove lock file at {}: {}",
lock_file.display(),
e
);
track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref())
.await;
emit_error(args.common.json, args.common.silent, "lock_io", &msg);
1
}
}
} else {
track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await;
emit_free(args.common.json, &lock_file, false, false)
}
}
Err(LockError::Held) => {
track_patch_unlock_failed(
"lock held by another process",
api_token.as_deref(),
org_slug.as_deref(),
)
.await;
if args.common.json {
let mut env = Envelope::new(Command::Unlock);
env.mark_error(EnvelopeError::new(
"lock_held",
format!(
"another socket-patch process is operating in {}",
socket_dir.display()
),
));
println!("{}", env.to_pretty_json());
} else if !args.common.silent {
eprintln!(
"Lock is held: another socket-patch process is operating in {}.",
socket_dir.display()
);
if args.release {
eprintln!(
" Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists."
);
} else {
eprintln!(
" Re-run the failing mutating command with --break-lock if you're sure no holder exists."
);
}
}
1
}
Err(LockError::Io { path, source }) => {
let msg = format!(
"failed to open lock file at {}: {}",
path.display(),
source
);
track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
emit_error(args.common.json, args.common.silent, "lock_io", &msg);
1
}
}
}
fn emit_free(json: bool, lock_file: &Path, removed: bool, release: bool) -> i32 {
if json {
let body = serde_json::json!({
"command": "unlock",
"status": "free",
"lockFile": lock_file.display().to_string(),
"released": removed,
});
println!("{}", serde_json::to_string_pretty(&body).unwrap());
} else if release && removed {
println!("Lock is free. Removed {}.", lock_file.display());
} else if release {
println!("Lock is free (no lock file to remove).");
} else {
println!("Lock is free.");
}
0
}
fn emit_error(json: bool, silent: bool, code: &str, message: &str) {
if json {
let mut env = Envelope::new(Command::Unlock);
env.mark_error(EnvelopeError::new(code, message));
println!("{}", env.to_pretty_json());
} else if !silent {
eprintln!("Error: {message}.");
}
}
#[cfg(test)]
mod tests {
use super::*;
use socket_patch_core::patch::apply_lock::acquire as core_acquire;
fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
UnlockArgs {
common: GlobalArgs {
cwd: cwd.to_path_buf(),
json: true, silent: true,
..GlobalArgs::default()
},
release,
}
}
#[tokio::test]
async fn run_reports_free_when_socket_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn run_reports_free_when_socket_dir_clean() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".socket")).unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 0);
}
#[tokio::test]
async fn run_reports_held_when_lock_actively_held() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
let code = run(args_in(dir.path(), false)).await;
assert_eq!(code, 1);
assert!(socket_dir.join("apply.lock").is_file());
}
#[tokio::test]
async fn run_deletes_lock_file_when_release_and_free() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
std::fs::write(socket_dir.join("apply.lock"), b"").unwrap();
assert!(socket_dir.join("apply.lock").is_file());
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 0);
assert!(
!socket_dir.join("apply.lock").exists(),
"--release should have deleted the file"
);
}
#[tokio::test]
async fn run_refuses_release_when_held() {
let dir = tempfile::tempdir().unwrap();
let socket_dir = dir.path().join(".socket");
std::fs::create_dir_all(&socket_dir).unwrap();
let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
let code = run(args_in(dir.path(), true)).await;
assert_eq!(code, 1);
assert!(
socket_dir.join("apply.lock").is_file(),
"lock file should still exist — --release must refuse when held"
);
}
}