use crate::protocol::{ApiError, ApiErrorCode, ErrorDetails};
use crate::state::OperationState;
use std::ffi::OsString;
use std::path::Path;
use std::process::Output;
#[derive(Debug, Clone)]
pub struct DomainError {
code: ApiErrorCode,
message: String,
details: ErrorDetails,
retryable: bool,
suggested_command: Option<String>,
}
impl DomainError {
pub fn invalid_request(message: impl Into<String>) -> Self {
let message = message.into();
Self::new(
ApiErrorCode::InvalidRequest,
message.clone(),
ErrorDetails::Request {
field: None,
issue: message,
},
)
}
pub fn repository_not_found(message: impl Into<String>) -> Self {
Self::new(
ApiErrorCode::RepositoryNotFound,
message,
ErrorDetails::None,
)
}
pub fn manifest_invalid(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::ManifestInvalid, message, ErrorDetails::None)
}
pub fn dirty_worktree(paths: Vec<String>) -> Self {
Self::new(
ApiErrorCode::DirtyWorktree,
"worktree is not clean",
ErrorDetails::Paths { patch: None, paths },
)
}
pub fn active_patch_required() -> Self {
Self::new(
ApiErrorCode::ActivePatchRequired,
"an active patch is required",
ErrorDetails::Patch {
requested: None,
available: Vec::new(),
active: None,
},
)
.suggest("forkctl patch create NAME ... or forkctl patch select NAME")
}
pub fn active_patch_exists(active: String) -> Self {
Self::new(
ApiErrorCode::ActivePatchExists,
format!("an active patch already exists: {active}"),
ErrorDetails::Patch {
requested: None,
available: Vec::new(),
active: Some(active),
},
)
.suggest("forkctl patch finish")
}
pub fn patch_not_found(
requested: impl Into<String>,
available: Vec<String>,
active: Option<String>,
) -> Self {
let requested = requested.into();
Self::new(
ApiErrorCode::PatchNotFound,
format!("patch not found: {requested}"),
ErrorDetails::Patch {
requested: Some(requested),
available,
active,
},
)
.suggest("forkctl patch list")
}
pub fn staged_scope_violation(patch: String, paths: Vec<String>) -> Self {
Self::new(
ApiErrorCode::StagedScopeViolation,
format!(
"captured paths are outside patch {patch}: {}",
paths.join(", ")
),
ErrorDetails::Paths {
patch: Some(patch),
paths,
},
)
.suggest("forkctl patch edit --add-scope GLOB")
}
pub fn capture_conflict(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::CaptureConflict, message, ErrorDetails::None)
}
pub fn operation_in_progress(operation: &OperationState) -> Self {
Self::new(
ApiErrorCode::OperationInProgress,
format!("operation {} is in progress", operation.id),
ErrorDetails::Operation {
operation_id: operation.id.clone(),
kind: format!("{:?}", operation.kind).to_lowercase(),
phase: operation.phase.clone(),
next_actions: operation.next_actions.clone(),
},
)
.suggest("forkctl operation status")
}
pub fn check_failed(message: impl Into<String>) -> Self {
Self::new(ApiErrorCode::CheckFailed, message, ErrorDetails::None)
}
pub fn operation_conflict(
message: impl Into<String>,
operation: Option<&OperationState>,
) -> Self {
let details = operation.map_or(ErrorDetails::None, |operation| ErrorDetails::Operation {
operation_id: operation.id.clone(),
kind: format!("{:?}", operation.kind).to_lowercase(),
phase: operation.phase.clone(),
next_actions: operation.next_actions.clone(),
});
Self::new(ApiErrorCode::OperationConflict, message, details)
.suggest("forkctl operation status")
}
pub fn remote_advanced(
remote: String,
git_ref: String,
expected: String,
actual: String,
) -> Self {
Self::new(
ApiErrorCode::RemoteAdvanced,
format!("remote {git_ref} advanced to {actual}; expected {expected}"),
ErrorDetails::Remote {
remote,
git_ref,
expected: Some(expected),
actual: Some(actual),
stderr: String::new(),
},
)
.retryable()
}
pub fn publication_rejected(error: &Self) -> Self {
Self::new(
ApiErrorCode::PublicationRejected,
"remote rejected atomic publication",
error.details.clone(),
)
}
pub fn publication_ref_mismatch(
remote: String,
git_ref: String,
expected: String,
actual: String,
) -> Self {
Self::new(
ApiErrorCode::PublicationRejected,
format!("remote {git_ref} points to {actual}, expected {expected}"),
ErrorDetails::Remote {
remote,
git_ref,
expected: Some(expected),
actual: Some(actual),
stderr: String::new(),
},
)
}
pub fn subprocess(program: &str, args: &[OsString], cwd: &Path, output: &Output) -> Self {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let args = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let invocation = if args.is_empty() {
program.to_string()
} else {
format!("{program} {}", args.join(" "))
};
let message = if stderr.is_empty() {
format!("{invocation} failed with {}", output.status)
} else {
format!("{invocation}: {stderr}")
};
Self::new(
ApiErrorCode::SubprocessFailed,
message,
ErrorDetails::Subprocess {
program: program.to_string(),
args,
cwd: cwd.display().to_string(),
exit_code: output.status.code(),
stderr,
},
)
}
fn new(code: ApiErrorCode, message: impl Into<String>, details: ErrorDetails) -> Self {
Self {
code,
message: message.into(),
details,
retryable: false,
suggested_command: None,
}
}
fn retryable(mut self) -> Self {
self.retryable = true;
self
}
fn suggest(mut self, command: impl Into<String>) -> Self {
self.suggested_command = Some(command.into());
self
}
pub fn to_api_error(&self, causes: Vec<String>) -> ApiError {
ApiError {
code: self.code,
message: self.message.clone(),
causes,
details: self.details.clone(),
retryable: self.retryable,
suggested_command: self.suggested_command.clone(),
}
}
}
impl std::fmt::Display for DomainError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for DomainError {}