feat: add interactive conventional commit workflow with jj-lib backend
Replace CLI executor with jj-lib integration, implement full interactive commit workflow via prompts, and add mock infrastructure for testing. Add CLI integration tests and error handling tests.
This commit is contained in:
@@ -1,458 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use jj_lib::config::StackedConfig;
|
||||
use jj_lib::repo::{Repo, StoreFactories};
|
||||
use jj_lib::settings::UserSettings;
|
||||
use jj_lib::workspace::{Workspace, default_working_copy_factories};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::jj::JjExecutor;
|
||||
|
||||
/// JjLib provides jj repository operations using jj-lib
|
||||
///
|
||||
/// This implementation uses the jj-lib crate directly for all operations,
|
||||
/// providing native Rust integration with Jujutsu repositories.
|
||||
pub struct JjLib {
|
||||
/// The working directory path for repository operations
|
||||
working_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl JjLib {
|
||||
/// Create a new JjLib instance using the current working directory
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
working_dir: std::env::current_dir().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new JjLib instance with a specific working directory
|
||||
pub fn with_working_dir(path: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
working_dir: path.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JjLib {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl JjExecutor for JjLib {
|
||||
async fn is_repository(&self) -> Result<bool, Error> {
|
||||
let config = StackedConfig::with_defaults();
|
||||
let settings = UserSettings::from_config(config)?;
|
||||
let store_factories = StoreFactories::default();
|
||||
let wc_factories = default_working_copy_factories();
|
||||
|
||||
// Check if the directory exists first
|
||||
if !self.working_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Try to load workspace from the working directory
|
||||
// Walk up the directory tree until we find a repository or reach the root
|
||||
let mut current_dir = self.working_dir.clone();
|
||||
loop {
|
||||
match Workspace::load(&settings, ¤t_dir, &store_factories, &wc_factories) {
|
||||
Ok(_) => return Ok(true),
|
||||
Err(_) => {
|
||||
// Move up to parent directory
|
||||
if !current_dir.pop() {
|
||||
// Reached root directory
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn describe(&self, message: &str) -> Result<(), Error> {
|
||||
// Load the repository
|
||||
let config = StackedConfig::with_defaults();
|
||||
let settings = UserSettings::from_config(config)?;
|
||||
let store_factories = StoreFactories::default();
|
||||
let wc_factories = default_working_copy_factories();
|
||||
|
||||
let workspace = Workspace::load(
|
||||
&settings,
|
||||
&self.working_dir,
|
||||
&store_factories,
|
||||
&wc_factories,
|
||||
)
|
||||
.map_err(|_| Error::NotARepository)?;
|
||||
|
||||
let repo = workspace.repo_loader().load_at_head()?;
|
||||
|
||||
// Start a transaction
|
||||
let mut tx = repo.start_transaction();
|
||||
tx.set_tag("args".to_string(), "jj-cz describe".to_string());
|
||||
|
||||
// Get the current working copy commit (equivalent to @ revset)
|
||||
let view = tx.repo().view();
|
||||
let wc_commit_ids = view.wc_commit_ids();
|
||||
|
||||
if wc_commit_ids.is_empty() {
|
||||
return Err(Error::JjOperation {
|
||||
context: "No working copy commit found".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get the first working copy commit (usually there's only one)
|
||||
let wc_commit_id = wc_commit_ids.values().next().unwrap();
|
||||
let wc_commit = tx.repo().store().get_commit(wc_commit_id)?;
|
||||
|
||||
// Rewrite the working copy commit with the new description
|
||||
let commit_builder = tx
|
||||
.repo_mut()
|
||||
.rewrite_commit(&wc_commit)
|
||||
.set_description(message);
|
||||
|
||||
// Write the modified commit
|
||||
let _new_commit = commit_builder.write()?;
|
||||
|
||||
// Rebase descendants after the rewrite
|
||||
tx.repo_mut().rebase_descendants()?;
|
||||
|
||||
// Finish the transaction
|
||||
tx.commit("jj-cz: update commit description")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use assert_fs::prelude::*;
|
||||
use std::process::Command;
|
||||
|
||||
/// Initialize a jj repository in the given directory using `jj git init`
|
||||
fn init_jj_repo(dir: &std::path::Path) -> std::io::Result<()> {
|
||||
let output = Command::new("jj")
|
||||
.args(["git", "init"])
|
||||
.current_dir(dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(
|
||||
format!(
|
||||
"jj git init failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current commit description from a jj repository
|
||||
fn get_commit_description(dir: &std::path::Path) -> std::io::Result<String> {
|
||||
let output = Command::new("jj")
|
||||
.args(["log", "-r", "@", "--no-graph", "-T", "description"])
|
||||
.current_dir(dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(
|
||||
format!("jj log failed: {}", String::from_utf8_lossy(&output.stderr)),
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Test that is_repository() returns true inside a jj repository
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Creates a temporary directory
|
||||
/// 2. Initializes a jj repository with `jj git init`
|
||||
/// 3. Verifies is_repository() returns Ok(true)
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_true_inside_jj_repo() {
|
||||
// Create a temporary directory
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
|
||||
// Initialize a jj repository
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
// Create JjLib pointing to the temp directory
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
// Verify is_repository returns true
|
||||
let result = jj_lib.is_repository().await;
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
assert!(
|
||||
result.unwrap(),
|
||||
"Expected true for directory inside jj repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that is_repository() returns true from a subdirectory of a jj repo
|
||||
///
|
||||
/// This verifies that jj-lib correctly walks up the directory tree
|
||||
/// to find the repository root.
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_true_from_subdirectory() {
|
||||
// Create a temporary directory with a subdirectory
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
let sub_dir = temp_dir.child("subdir/nested");
|
||||
sub_dir.create_dir_all().unwrap();
|
||||
|
||||
// Initialize jj repo at the root
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
// Create JjLib pointing to the subdirectory
|
||||
let jj_lib = JjLib::with_working_dir(sub_dir.path());
|
||||
|
||||
// Verify is_repository returns true from subdirectory
|
||||
let result = jj_lib.is_repository().await;
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
assert!(
|
||||
result.unwrap(),
|
||||
"Expected true for subdirectory inside jj repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that is_repository() returns false outside a jj repository
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Creates an empty temporary directory (no jj init)
|
||||
/// 2. Verifies is_repository() returns Ok(false)
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_false_outside_jj_repo() {
|
||||
// Create an empty temporary directory (not a jj repo)
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
|
||||
// Create JjLib pointing to the temp directory
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
// Verify is_repository returns false
|
||||
let result = jj_lib.is_repository().await;
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
assert!(
|
||||
!result.unwrap(),
|
||||
"Expected false for directory outside jj repo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that is_repository() returns false for non-existent directory
|
||||
///
|
||||
/// This verifies graceful handling of invalid paths
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_false_for_nonexistent_directory() {
|
||||
// Create a path that doesn't exist
|
||||
let nonexistent = std::path::PathBuf::from("/tmp/jj_cz_nonexistent_test_dir_12345");
|
||||
|
||||
// Make sure it doesn't exist
|
||||
if nonexistent.exists() {
|
||||
std::fs::remove_dir_all(&nonexistent).ok();
|
||||
}
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(&nonexistent);
|
||||
|
||||
// Verify is_repository returns false (not an error)
|
||||
let result = jj_lib.is_repository().await;
|
||||
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
|
||||
assert!(
|
||||
!result.unwrap(),
|
||||
"Expected false for non-existent directory"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that describe() updates the commit description
|
||||
///
|
||||
/// This test:
|
||||
/// 1. Creates a temp jj repo
|
||||
/// 2. Calls describe() with a test message
|
||||
/// 3. Verifies the commit description was updated via `jj log`
|
||||
#[tokio::test]
|
||||
async fn describe_updates_commit_description() {
|
||||
// Create a temporary directory and init jj repo
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
// Create JjLib pointing to the temp directory
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
// Call describe with a test message
|
||||
let test_message = "feat(scope): add new feature";
|
||||
let result = jj_lib.describe(test_message).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"describe() should succeed, got {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Verify the commit description was updated
|
||||
let actual_description =
|
||||
get_commit_description(temp_dir.path()).expect("Failed to get commit description");
|
||||
|
||||
assert_eq!(
|
||||
actual_description, test_message,
|
||||
"Commit description should match the message passed to describe()"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that describe() handles multiline messages
|
||||
#[tokio::test]
|
||||
async fn describe_handles_multiline_message() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let multiline_message =
|
||||
"feat: add feature\n\nThis is the body of the commit.\nIt spans multiple lines.";
|
||||
let result = jj_lib.describe(multiline_message).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"describe() should succeed with multiline message"
|
||||
);
|
||||
|
||||
let actual_description = get_commit_description(temp_dir.path()).unwrap();
|
||||
assert_eq!(actual_description, multiline_message);
|
||||
}
|
||||
|
||||
/// Test that describe() handles empty message (clears description)
|
||||
#[tokio::test]
|
||||
async fn describe_handles_empty_message() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
// First set a description
|
||||
jj_lib
|
||||
.describe("initial message")
|
||||
.await
|
||||
.expect("First describe should succeed");
|
||||
|
||||
// Then clear it with empty message
|
||||
let result = jj_lib.describe("").await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"describe() should succeed with empty message"
|
||||
);
|
||||
|
||||
let actual_description = get_commit_description(temp_dir.path()).unwrap();
|
||||
assert_eq!(actual_description, "", "Description should be cleared");
|
||||
}
|
||||
|
||||
/// Test that describe() handles special characters in message
|
||||
#[tokio::test]
|
||||
async fn describe_handles_special_characters() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let special_message = "fix: handle \"quotes\" and 'apostrophes' & <brackets>";
|
||||
let result = jj_lib.describe(special_message).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"describe() should handle special characters"
|
||||
);
|
||||
|
||||
let actual_description = get_commit_description(temp_dir.path()).unwrap();
|
||||
assert_eq!(actual_description, special_message);
|
||||
}
|
||||
|
||||
/// Test that describe() handles unicode characters
|
||||
#[tokio::test]
|
||||
async fn describe_handles_unicode() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let unicode_message = "feat: support émojis 🚀 and ünïcödé characters 中文";
|
||||
let result = jj_lib.describe(unicode_message).await;
|
||||
|
||||
assert!(result.is_ok(), "describe() should handle unicode");
|
||||
|
||||
let actual_description = get_commit_description(temp_dir.path()).unwrap();
|
||||
assert_eq!(actual_description, unicode_message);
|
||||
}
|
||||
|
||||
/// Test that describe() returns NotARepository error outside jj repo
|
||||
#[tokio::test]
|
||||
async fn describe_fails_outside_repository() {
|
||||
// Create an empty temp directory (not a jj repo)
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let result = jj_lib.describe("test message").await;
|
||||
|
||||
assert!(result.is_err(), "describe() should fail outside repository");
|
||||
assert!(
|
||||
matches!(result.unwrap_err(), Error::NotARepository),
|
||||
"Expected NotARepository error"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that describe() can be called multiple times
|
||||
#[tokio::test]
|
||||
async fn describe_can_be_called_multiple_times() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let jj_lib = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
// Call describe multiple times
|
||||
jj_lib.describe("first").await.unwrap();
|
||||
jj_lib.describe("second").await.unwrap();
|
||||
jj_lib.describe("third").await.unwrap();
|
||||
|
||||
// Only the last description should persist
|
||||
let actual_description = get_commit_description(temp_dir.path()).unwrap();
|
||||
assert_eq!(actual_description, "third");
|
||||
}
|
||||
|
||||
/// Test that JjLib::new() creates instance with current directory
|
||||
#[test]
|
||||
fn new_uses_current_directory() {
|
||||
let jj_lib = JjLib::new();
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
assert_eq!(jj_lib.working_dir, current_dir);
|
||||
}
|
||||
|
||||
/// Test that JjLib::with_working_dir() uses specified directory
|
||||
#[test]
|
||||
fn with_working_dir_uses_specified_directory() {
|
||||
let custom_path = std::path::PathBuf::from("/tmp/custom");
|
||||
let jj_lib = JjLib::with_working_dir(&custom_path);
|
||||
assert_eq!(jj_lib.working_dir, custom_path);
|
||||
}
|
||||
|
||||
/// Test that JjLib implements Default
|
||||
#[test]
|
||||
fn jjlib_implements_default() {
|
||||
let jj_lib = JjLib::default();
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
assert_eq!(jj_lib.working_dir, current_dir);
|
||||
}
|
||||
|
||||
/// Test that JjLib implements JjExecutor trait
|
||||
#[test]
|
||||
fn jjlib_implements_jj_executor() {
|
||||
fn _accepts_executor<E: JjExecutor>(_e: E) {}
|
||||
let jj_lib = JjLib::new();
|
||||
_accepts_executor(jj_lib);
|
||||
}
|
||||
|
||||
/// Test that JjLib is Send + Sync
|
||||
#[test]
|
||||
fn jjlib_is_send_sync() {
|
||||
fn _assert_send<T: Send>() {}
|
||||
fn _assert_sync<T: Sync>() {}
|
||||
_assert_send::<JjLib>();
|
||||
_assert_sync::<JjLib>();
|
||||
}
|
||||
}
|
||||
288
src/jj/lib_executor.rs
Normal file
288
src/jj/lib_executor.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! jj-lib implementation of JjExecutor
|
||||
//!
|
||||
//! This implementation uses jj-lib 0.39.0 directly for repository detection
|
||||
//! and commit description, replacing the earlier shell-out approach.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use jj_lib::{
|
||||
config::StackedConfig,
|
||||
ref_name::WorkspaceName,
|
||||
repo::{Repo, StoreFactories},
|
||||
settings::UserSettings,
|
||||
workspace::{Workspace, default_working_copy_factories},
|
||||
};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::jj::JjExecutor;
|
||||
|
||||
/// JjLib provides jj repository operations via jj-lib 0.39.0
|
||||
#[derive(Debug)]
|
||||
pub struct JjLib {
|
||||
working_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl JjLib {
|
||||
/// Create a new JjLib instance using the current working directory
|
||||
pub fn new() -> Result<Self, Error> {
|
||||
let working_dir = std::env::current_dir()?;
|
||||
Ok(Self { working_dir })
|
||||
}
|
||||
|
||||
/// Create a new JjLib instance with a specific working directory
|
||||
pub fn with_working_dir(path: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
working_dir: path.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_settings() -> Result<UserSettings, Error> {
|
||||
let config = StackedConfig::with_defaults();
|
||||
UserSettings::from_config(config).map_err(|_| Error::FailedReadingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl JjExecutor for JjLib {
|
||||
async fn is_repository(&self) -> Result<bool, Error> {
|
||||
let settings = Self::load_settings()?;
|
||||
let store_factories = StoreFactories::default();
|
||||
let wc_factories = default_working_copy_factories();
|
||||
Ok(Workspace::load(
|
||||
&settings,
|
||||
&self.working_dir,
|
||||
&store_factories,
|
||||
&wc_factories,
|
||||
)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
async fn describe(&self, message: &str) -> Result<(), Error> {
|
||||
let settings = Self::load_settings()?;
|
||||
let store_factories = StoreFactories::default();
|
||||
let wc_factories = default_working_copy_factories();
|
||||
|
||||
let workspace = Workspace::load(
|
||||
&settings,
|
||||
&self.working_dir,
|
||||
&store_factories,
|
||||
&wc_factories,
|
||||
)
|
||||
.map_err(|_| Error::NotARepository)?;
|
||||
|
||||
let repo =
|
||||
workspace
|
||||
.repo_loader()
|
||||
.load_at_head()
|
||||
.await
|
||||
.map_err(|e| Error::JjOperation {
|
||||
context: e.to_string(),
|
||||
})?;
|
||||
|
||||
let mut tx = repo.start_transaction();
|
||||
|
||||
let wc_commit_id = tx
|
||||
.repo()
|
||||
.view()
|
||||
.get_wc_commit_id(WorkspaceName::DEFAULT)
|
||||
.ok_or_else(|| Error::JjOperation {
|
||||
context: "No working copy commit found".to_string(),
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let wc_commit =
|
||||
tx.repo()
|
||||
.store()
|
||||
.get_commit(&wc_commit_id)
|
||||
.map_err(|e| Error::JjOperation {
|
||||
context: e.to_string(),
|
||||
})?;
|
||||
|
||||
tx.repo_mut()
|
||||
.rewrite_commit(&wc_commit)
|
||||
.set_description(message)
|
||||
.write()
|
||||
.await
|
||||
.map_err(|e| Error::JjOperation {
|
||||
context: e.to_string(),
|
||||
})?;
|
||||
|
||||
tx.repo_mut()
|
||||
.rebase_descendants()
|
||||
.await
|
||||
.map_err(|e| Error::JjOperation {
|
||||
context: format!("{e:?}"),
|
||||
})?;
|
||||
|
||||
tx.commit("jj-cz: update commit description")
|
||||
.await
|
||||
.map_err(|e| Error::JjOperation {
|
||||
context: e.to_string(),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
|
||||
/// Initialize a jj repository in the given directory using `jj git init`
|
||||
fn init_jj_repo(dir: &Path) -> std::io::Result<()> {
|
||||
let output = Command::new("jj")
|
||||
.args(["git", "init"])
|
||||
.current_dir(dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"jj git init failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current commit description from a jj repository
|
||||
fn get_commit_description(dir: &Path) -> std::io::Result<String> {
|
||||
let output = Command::new("jj")
|
||||
.args(["log", "-r", "@", "--no-graph", "-T", "description"])
|
||||
.current_dir(dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"jj log failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_true_inside_jj_repo() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
let result = executor.is_repository().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_repository_returns_false_outside_jj_repo() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
let result = executor.is_repository().await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(!result.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_updates_commit_description() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let test_message = "test: initial commit";
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let result = executor.describe(test_message).await;
|
||||
assert!(result.is_ok(), "describe failed: {result:?}");
|
||||
|
||||
let actual = get_commit_description(temp_dir.path()).expect("Failed to get description");
|
||||
assert_eq!(actual, test_message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_handles_special_characters() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let test_message = "feat: add feature with special chars !@#$%^&*()";
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let result = executor.describe(test_message).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let actual = get_commit_description(temp_dir.path()).expect("Failed to get description");
|
||||
assert_eq!(actual, test_message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_handles_unicode() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let test_message = "docs: add unicode support 🎉 🚀";
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let result = executor.describe(test_message).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let actual = get_commit_description(temp_dir.path()).expect("Failed to get description");
|
||||
assert_eq!(actual, test_message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_handles_multiline_message() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let test_message = "feat: add feature\n\nThis is a multiline\ndescription";
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
let result = executor.describe(test_message).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let actual = get_commit_description(temp_dir.path()).expect("Failed to get description");
|
||||
assert_eq!(actual, test_message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_fails_outside_repo() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
let result = executor.describe("test: should fail").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_can_be_called_multiple_times() {
|
||||
let temp_dir = assert_fs::TempDir::new().unwrap();
|
||||
init_jj_repo(temp_dir.path()).expect("Failed to init jj repo");
|
||||
|
||||
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||
|
||||
executor
|
||||
.describe("feat: first commit")
|
||||
.await
|
||||
.expect("First describe failed");
|
||||
let desc1 =
|
||||
get_commit_description(temp_dir.path()).expect("Failed to get first description");
|
||||
assert_eq!(desc1, "feat: first commit");
|
||||
|
||||
executor
|
||||
.describe("feat: updated commit")
|
||||
.await
|
||||
.expect("Second describe failed");
|
||||
let desc2 =
|
||||
get_commit_description(temp_dir.path()).expect("Failed to get second description");
|
||||
assert_eq!(desc2, "feat: updated commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jj_lib_implements_jj_executor_trait() {
|
||||
let lib = JjLib::with_working_dir(std::path::Path::new("."));
|
||||
fn accepts_executor(_: impl JjExecutor) {}
|
||||
accepts_executor(lib);
|
||||
}
|
||||
}
|
||||
234
src/jj/mock.rs
Normal file
234
src/jj/mock.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Mock implementation of JjExecutor for testing
|
||||
//!
|
||||
//! This mock allows configuring responses for each method and tracks method calls
|
||||
//! for verification. It's used extensively in workflow tests.
|
||||
|
||||
use super::JjExecutor;
|
||||
use crate::error::Error;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Mutex, atomic::AtomicBool};
|
||||
|
||||
/// Mock implementation of JjExecutor for testing
|
||||
#[derive(Debug)]
|
||||
pub 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: AtomicBool,
|
||||
/// Track calls to describe() with the message passed
|
||||
describe_calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for MockJjExecutor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
is_repo_response: Ok(true),
|
||||
describe_response: Ok(()),
|
||||
is_repo_called: AtomicBool::new(false),
|
||||
describe_calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MockJjExecutor {
|
||||
/// Create a new mock with default success responses
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Configure is_repository() to return a specific value
|
||||
pub 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
|
||||
pub fn with_describe_response(mut self, response: Result<(), Error>) -> Self {
|
||||
self.describe_response = response;
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if is_repository() was called
|
||||
pub fn was_is_repo_called(&self) -> bool {
|
||||
self.is_repo_called
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Get all messages passed to describe()
|
||||
pub fn describe_messages(&self) -> Vec<String> {
|
||||
self.describe_calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
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(e.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
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(e.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
|
||||
/// Test that mock can implement JjExecutor trait
|
||||
#[test]
|
||||
fn mock_implements_trait() {
|
||||
let mock = MockJjExecutor::new();
|
||||
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 bounds (compile-time check)
|
||||
///
|
||||
/// JjExecutor requires Send + Sync on implementors even though returned
|
||||
/// futures are !Send (due to jj-lib internals).
|
||||
#[test]
|
||||
fn mock_is_send_sync() {
|
||||
fn assert_send<T: Send>() {}
|
||||
fn assert_sync<T: Sync>() {}
|
||||
assert_send::<MockJjExecutor>();
|
||||
assert_sync::<MockJjExecutor>();
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
245
src/jj/mod.rs
245
src/jj/mod.rs
@@ -1,11 +1,14 @@
|
||||
use crate::error::Error;
|
||||
|
||||
pub mod executor;
|
||||
pub mod lib_executor;
|
||||
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub mod mock;
|
||||
|
||||
/// Trait for executing jj operations
|
||||
///
|
||||
/// All methods are async for native jj-lib compatibility.
|
||||
#[async_trait::async_trait]
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait JjExecutor: Send + Sync {
|
||||
/// Check if current directory is within a jj repository
|
||||
async fn is_repository(&self) -> Result<bool, Error>;
|
||||
@@ -18,97 +21,6 @@ pub trait JjExecutor: Send + Sync {
|
||||
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:
|
||||
@@ -144,8 +56,8 @@ mod tests {
|
||||
fn _assert_sync<T: Sync>() {}
|
||||
|
||||
// MockJjExecutor implements the trait, so it must satisfy Send + Sync
|
||||
_assert_send::<MockJjExecutor>();
|
||||
_assert_sync::<MockJjExecutor>();
|
||||
_assert_send::<mock::MockJjExecutor>();
|
||||
_assert_sync::<mock::MockJjExecutor>();
|
||||
}
|
||||
|
||||
/// Test that mock can implement JjExecutor trait
|
||||
@@ -153,150 +65,9 @@ mod tests {
|
||||
/// This is a compile-time check that the mock properly implements the trait
|
||||
#[test]
|
||||
fn mock_implements_trait() {
|
||||
let mock = MockJjExecutor::new();
|
||||
let mock = 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user