2026-02-09 20:55:40 +01:00
|
|
|
use crate::error::Error;
|
2026-02-05 20:34:57 +01:00
|
|
|
|
2026-02-09 20:55:40 +01:00
|
|
|
pub mod executor;
|
|
|
|
|
|
|
|
|
|
/// Trait for executing jj operations
|
|
|
|
|
///
|
|
|
|
|
/// All methods are async for native jj-lib compatibility.
|
|
|
|
|
#[async_trait::async_trait]
|
|
|
|
|
pub trait JjExecutor: Send + Sync {
|
|
|
|
|
/// Check if current directory is within a jj repository
|
|
|
|
|
async fn is_repository(&self) -> Result<bool, Error>;
|
|
|
|
|
|
|
|
|
|
/// Set the description of the current change
|
|
|
|
|
async fn describe(&self, message: &str) -> Result<(), Error>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
/// Mock implementation of JjExecutor for testing
|
|
|
|
|
///
|
|
|
|
|
/// This mock allows configuring responses for each method
|
|
|
|
|
/// and tracks method calls for verification.
|
|
|
|
|
struct MockJjExecutor {
|
|
|
|
|
/// Response to return from is_repository()
|
|
|
|
|
is_repo_response: Result<bool, Error>,
|
|
|
|
|
/// Response to return from describe()
|
|
|
|
|
describe_response: Result<(), Error>,
|
|
|
|
|
/// Track calls to is_repository()
|
|
|
|
|
is_repo_called: std::sync::atomic::AtomicBool,
|
|
|
|
|
/// Track calls to describe() with the message passed
|
|
|
|
|
describe_calls: std::sync::Mutex<Vec<String>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MockJjExecutor {
|
|
|
|
|
/// Create a new mock with default success responses
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
is_repo_response: Ok(true),
|
|
|
|
|
describe_response: Ok(()),
|
|
|
|
|
is_repo_called: std::sync::atomic::AtomicBool::new(false),
|
|
|
|
|
describe_calls: std::sync::Mutex::new(Vec::new()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Configure is_repository() to return a specific value
|
|
|
|
|
fn with_is_repo_response(mut self, response: Result<bool, Error>) -> Self {
|
|
|
|
|
self.is_repo_response = response;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Configure describe() to return a specific value
|
|
|
|
|
fn with_describe_response(mut self, response: Result<(), Error>) -> Self {
|
|
|
|
|
self.describe_response = response;
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if is_repository() was called
|
|
|
|
|
fn was_is_repo_called(&self) -> bool {
|
|
|
|
|
self.is_repo_called.load(std::sync::atomic::Ordering::SeqCst)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get all messages passed to describe()
|
|
|
|
|
fn describe_messages(&self) -> Vec<String> {
|
|
|
|
|
self.describe_calls.lock().unwrap().clone()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait::async_trait]
|
|
|
|
|
impl JjExecutor for MockJjExecutor {
|
|
|
|
|
async fn is_repository(&self) -> Result<bool, Error> {
|
|
|
|
|
self.is_repo_called
|
|
|
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
|
|
|
match &self.is_repo_response {
|
|
|
|
|
Ok(v) => Ok(*v),
|
|
|
|
|
Err(e) => Err(match e {
|
|
|
|
|
Error::NotARepository => Error::NotARepository,
|
|
|
|
|
Error::JjOperation { context } => Error::JjOperation {
|
|
|
|
|
context: context.clone(),
|
|
|
|
|
},
|
|
|
|
|
Error::RepositoryLocked => Error::RepositoryLocked,
|
|
|
|
|
_ => Error::JjOperation {
|
|
|
|
|
context: "mock error".to_string(),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn describe(&self, message: &str) -> Result<(), Error> {
|
|
|
|
|
self.describe_calls
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.push(message.to_string());
|
|
|
|
|
match &self.describe_response {
|
|
|
|
|
Ok(()) => Ok(()),
|
|
|
|
|
Err(e) => Err(match e {
|
|
|
|
|
Error::NotARepository => Error::NotARepository,
|
|
|
|
|
Error::JjOperation { context } => Error::JjOperation {
|
|
|
|
|
context: context.clone(),
|
|
|
|
|
},
|
|
|
|
|
Error::RepositoryLocked => Error::RepositoryLocked,
|
|
|
|
|
_ => Error::JjOperation {
|
|
|
|
|
context: "mock error".to_string(),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that JjExecutor trait definition compiles
|
|
|
|
|
///
|
|
|
|
|
/// This test verifies:
|
|
|
|
|
/// - The trait is properly defined with async methods
|
|
|
|
|
/// - The trait has correct Send + Sync bounds
|
|
|
|
|
/// - The trait can be used as a type bound
|
|
|
|
|
#[test]
|
|
|
|
|
fn trait_definition_compiles() {
|
|
|
|
|
// If this compiles, the trait definition is valid
|
|
|
|
|
fn _accepts_executor<E: JjExecutor>(_executor: E) {}
|
|
|
|
|
|
|
|
|
|
// Verify trait bounds compile
|
|
|
|
|
fn _accepts_executor_ref<E: JjExecutor>(_executor: &E) {}
|
|
|
|
|
fn _accepts_executor_box(_executor: Box<dyn JjExecutor>) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that JjExecutor can be used with trait objects
|
|
|
|
|
///
|
|
|
|
|
/// This verifies the trait is object-safe
|
|
|
|
|
#[test]
|
|
|
|
|
fn trait_is_object_safe() {
|
|
|
|
|
// This compiles only if the trait is object-safe
|
|
|
|
|
let _: Option<Box<dyn JjExecutor>> = None;
|
|
|
|
|
let _: Option<&dyn JjExecutor> = None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that JjExecutor requires Send + Sync bounds
|
|
|
|
|
///
|
|
|
|
|
/// This verifies implementors must be thread-safe
|
|
|
|
|
#[test]
|
|
|
|
|
fn trait_requires_send_sync() {
|
|
|
|
|
fn _assert_send<T: Send>() {}
|
|
|
|
|
fn _assert_sync<T: Sync>() {}
|
|
|
|
|
|
|
|
|
|
// MockJjExecutor implements the trait, so it must satisfy Send + Sync
|
|
|
|
|
_assert_send::<MockJjExecutor>();
|
|
|
|
|
_assert_sync::<MockJjExecutor>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that mock can implement JjExecutor trait
|
|
|
|
|
///
|
|
|
|
|
/// This is a compile-time check that the mock properly implements the trait
|
|
|
|
|
#[test]
|
|
|
|
|
fn mock_implements_trait() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
// If this compiles, the mock implements the trait
|
|
|
|
|
fn _accepts_executor(_e: impl JjExecutor) {}
|
|
|
|
|
_accepts_executor(mock);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock is_repository() returns configured true response
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_is_repository_returns_true() {
|
|
|
|
|
let mock = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
|
|
|
|
let result = mock.is_repository().await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
assert!(result.unwrap());
|
|
|
|
|
assert!(mock.was_is_repo_called());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock is_repository() returns configured false response
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_is_repository_returns_false() {
|
|
|
|
|
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
|
|
|
|
|
let result = mock.is_repository().await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
assert!(!result.unwrap());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock is_repository() returns configured error
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_is_repository_returns_error() {
|
|
|
|
|
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
|
|
|
|
|
let result = mock.is_repository().await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock describe() records the message
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_records_message() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
let result = mock.describe("test message").await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let messages = mock.describe_messages();
|
|
|
|
|
assert_eq!(messages.len(), 1);
|
|
|
|
|
assert_eq!(messages[0], "test message");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock describe() records multiple messages
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_records_multiple_messages() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
mock.describe("first message").await.unwrap();
|
|
|
|
|
mock.describe("second message").await.unwrap();
|
|
|
|
|
let messages = mock.describe_messages();
|
|
|
|
|
assert_eq!(messages.len(), 2);
|
|
|
|
|
assert_eq!(messages[0], "first message");
|
|
|
|
|
assert_eq!(messages[1], "second message");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock describe() returns configured error
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_returns_error() {
|
|
|
|
|
let mock = MockJjExecutor::new().with_describe_response(Err(Error::RepositoryLocked));
|
|
|
|
|
let result = mock.describe("test").await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(matches!(result.unwrap_err(), Error::RepositoryLocked));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock describe() returns JjOperation error with context
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_returns_jj_operation_error() {
|
|
|
|
|
let mock = MockJjExecutor::new().with_describe_response(Err(Error::JjOperation {
|
|
|
|
|
context: "transaction failed".to_string(),
|
|
|
|
|
}));
|
|
|
|
|
let result = mock.describe("test").await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
match result.unwrap_err() {
|
|
|
|
|
Error::JjOperation { context } => {
|
|
|
|
|
assert_eq!(context, "transaction failed");
|
|
|
|
|
}
|
|
|
|
|
_ => panic!("Expected JjOperation error"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock can be used through trait object
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_works_as_trait_object() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
let executor: Box<dyn JjExecutor> = Box::new(mock);
|
|
|
|
|
let result = executor.is_repository().await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock can be used through trait reference
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_works_as_trait_reference() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
let executor: &dyn JjExecutor = &mock;
|
|
|
|
|
let result = executor.is_repository().await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock satisfies Send + Sync for concurrent use
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_is_thread_safe() {
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
let mock = Arc::new(MockJjExecutor::new());
|
|
|
|
|
let mock_clone = Arc::clone(&mock);
|
|
|
|
|
|
|
|
|
|
// Spawn a task that uses the mock
|
|
|
|
|
let handle = tokio::spawn(async move { mock_clone.is_repository().await });
|
|
|
|
|
|
|
|
|
|
// Use the mock from the main task
|
|
|
|
|
let result1 = mock.is_repository().await;
|
|
|
|
|
let result2 = handle.await.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(result1.is_ok());
|
|
|
|
|
assert!(result2.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that empty message can be passed to describe
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_accepts_empty_message() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
let result = mock.describe("").await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
assert_eq!(mock.describe_messages()[0], "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test that long message can be passed to describe
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_describe_accepts_long_message() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
let long_message = "a".repeat(1000);
|
|
|
|
|
let result = mock.describe(&long_message).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
assert_eq!(mock.describe_messages()[0].len(), 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Test mock tracks is_repository calls
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_tracks_is_repository_call() {
|
|
|
|
|
let mock = MockJjExecutor::new();
|
|
|
|
|
assert!(!mock.was_is_repo_called());
|
|
|
|
|
mock.is_repository().await.unwrap();
|
|
|
|
|
assert!(mock.was_is_repo_called());
|
|
|
|
|
}
|
|
|
|
|
}
|