diff --git a/nix/rust-version.nix b/nix/rust-version.nix deleted file mode 100644 index aa84d34..0000000 --- a/nix/rust-version.nix +++ /dev/null @@ -1,11 +0,0 @@ -{ - rust-overlay, - inputs, - system, - ... -}: let - overlays = [(import rust-overlay)]; -in rec { - pkgs = import inputs.nixpkgs {inherit system overlays;}; - version = pkgs.rust-bin.stable.latest.default; -} diff --git a/nix/shell.nix b/nix/shell.nix index f0d628e..86ce32e 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -23,22 +23,22 @@ inputs.devenv.lib.mkShell { just ]; - processes.run.exec = "cargo watch -x run"; + # processes.run.exec = "cargo watch -x run"; - enterShell = '' - echo "" - echo "Rust development environment loaded!" - echo "Rust version: $(rustc --version)" - echo "Cargo version: $(cargo --version)" - echo "" - echo "Available tools:" - echo " - rust-analyzer (LSP)" - echo " - clippy (linter)" - echo " - rustfmt (formatter)" - echo " - bacon (continuous testing/linting)" - echo " - cargo-deny (dependency checker)" - echo " - cargo-tarpaulin (code coverage)" - ''; + # enterShell = '' + # echo "" + # echo "Rust development environment loaded!" + # echo "Rust version: $(rustc --version)" + # echo "Cargo version: $(cargo --version)" + # echo "" + # echo "Available tools:" + # echo " - rust-analyzer (LSP)" + # echo " - clippy (linter)" + # echo " - rustfmt (formatter)" + # echo " - bacon (continuous testing/linting)" + # echo " - cargo-deny (dependency checker)" + # echo " - cargo-tarpaulin (code coverage)" + # ''; } ]; } diff --git a/src/error.rs b/src/error.rs index f86ea77..76be22f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,10 @@ pub enum Error { JjOperation { context: String }, #[error("Repository is locked by another process")] RepositoryLocked, + #[error("Could not get current directory")] + FailedGettingCurrentDir, + #[error("Could not load Jujutsu configuration")] + FailedReadingConfig, // Application errors #[error("Operation cancelled by user")] Cancelled, @@ -40,3 +44,15 @@ impl From for Error { Self::InvalidCommitMessage(value.to_string()) } } + +impl From for Error { + fn from(_value: std::io::Error) -> Self { + Self::FailedGettingCurrentDir + } +} + +impl From for Error { + fn from(_: jj_lib::config::ConfigGetError) -> Self { + Self::FailedReadingConfig + } +} diff --git a/src/jj/executor.rs b/src/jj/executor.rs new file mode 100644 index 0000000..ab2f9f0 --- /dev/null +++ b/src/jj/executor.rs @@ -0,0 +1,382 @@ +use std::path::Path; + +use jj_lib::config::StackedConfig; +use jj_lib::settings::UserSettings; +use jj_lib::workspace::{Workspace, default_working_copy_factories}; +use jj_lib::repo::{Repo, StoreFactories}; + +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) -> 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 { + let config = StackedConfig::with_defaults(); + let settings = UserSettings::from_config(config)?; + let store_factories = StoreFactories::default(); + let wc_factories = default_working_copy_factories(); + match Workspace::load(&settings, &std::env::current_dir()?, &store_factories, &wc_factories) { + Ok(_) => Ok(true), + Err(_) => Ok(false) + } + } + + async fn describe(&self, _message: &str) -> Result<(), Error> { + // TODO: T018/T019 - Implement using jj-lib transactions + todo!("T018/T019: Implement describe() using jj-lib") + } +} + +#[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::new( + std::io::ErrorKind::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 { + 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::new( + std::io::ErrorKind::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' & "; + 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: 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() {} + fn _assert_sync() {} + _assert_send::(); + _assert_sync::(); + } +} diff --git a/src/jj/mod.rs b/src/jj/mod.rs index 8b13789..f994eb2 100644 --- a/src/jj/mod.rs +++ b/src/jj/mod.rs @@ -1 +1,301 @@ +use crate::error::Error; +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; + + /// 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, + /// 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>, + } + + 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) -> 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 { + self.describe_calls.lock().unwrap().clone() + } + } + + #[async_trait::async_trait] + impl JjExecutor for MockJjExecutor { + async fn is_repository(&self) -> Result { + 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(_executor: E) {} + + // Verify trait bounds compile + fn _accepts_executor_ref(_executor: &E) {} + fn _accepts_executor_box(_executor: Box) {} + } + + /// 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> = 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() {} + fn _assert_sync() {} + + // MockJjExecutor implements the trait, so it must satisfy Send + Sync + _assert_send::(); + _assert_sync::(); + } + + /// 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 = 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()); + } +}