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:
586
Cargo.lock
generated
586
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -15,18 +15,25 @@ path = "src/lib.rs"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
name = "jj-cz"
|
name = "jj-cz"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
## Exposes MockJjExecutor and MockPrompts for use in integration tests.
|
||||||
|
## Enable with: cargo test --features test-utils
|
||||||
|
test-utils = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
assert_cmd = "2.1.2"
|
|
||||||
assert_fs = "1.1.3"
|
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
clap = { version = "4.5.57", features = ["derive"] }
|
clap = { version = "4.5.57", features = ["derive"] }
|
||||||
git-conventional = "0.12.9"
|
git-conventional = "0.12.9"
|
||||||
inquire = "0.9.2"
|
inquire = "0.9.2"
|
||||||
jj-lib = { version = "0.38.0", features = ["testing"] }
|
jj-lib = "0.39.0"
|
||||||
lazy-regex = { version = "3.5.1", features = ["lite"] }
|
lazy-regex = { version = "3.5.1", features = ["lite"] }
|
||||||
predicates = "3.1.3"
|
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2.1.2"
|
||||||
|
assert_fs = "1.1.3"
|
||||||
|
predicates = "3.1.3"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ ignore = []
|
|||||||
allow = [
|
allow = [
|
||||||
"Apache-2.0 WITH LLVM-exception",
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
"Apache-2.0",
|
"Apache-2.0",
|
||||||
|
"BSD-3-Clause",
|
||||||
"MIT",
|
"MIT",
|
||||||
|
"MPL-2.0",
|
||||||
"Unicode-3.0",
|
"Unicode-3.0",
|
||||||
|
"Zlib",
|
||||||
]
|
]
|
||||||
confidence-threshold = 0.8
|
confidence-threshold = 0.8
|
||||||
exceptions = []
|
exceptions = []
|
||||||
@@ -22,9 +25,9 @@ registries = []
|
|||||||
|
|
||||||
[bans]
|
[bans]
|
||||||
multiple-versions = "allow"
|
multiple-versions = "allow"
|
||||||
wildcards = "allow"
|
wildcards = "deny"
|
||||||
highlight = "all"
|
highlight = "all"
|
||||||
workspace-default-features = "allow"
|
workspace-default-features = "deny"
|
||||||
external-default-features = "allow"
|
external-default-features = "allow"
|
||||||
allow = []
|
allow = []
|
||||||
deny = []
|
deny = []
|
||||||
|
|||||||
10
justfile
10
justfile
@@ -22,21 +22,21 @@ build-release:
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
cargo clippy --all-targets
|
cargo clippy --all-targets --features test-utils
|
||||||
|
|
||||||
lint-report:
|
lint-report:
|
||||||
cargo clippy --all-targets --message-format=json > coverage/clippy.json 2> /dev/null
|
cargo clippy --all-targets --features test-utils --message-format=json > coverage/clippy.json 2> /dev/null
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cargo test
|
cargo test --features test-utils
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
mkdir -p coverage
|
mkdir -p coverage
|
||||||
cargo tarpaulin --config .tarpaulin.local.toml
|
cargo tarpaulin --config .tarpaulin.local.toml --features test-utils
|
||||||
|
|
||||||
coverage-ci:
|
coverage-ci:
|
||||||
mkdir -p coverage
|
mkdir -p coverage
|
||||||
cargo tarpaulin --config .tarpaulin.ci.toml
|
cargo tarpaulin --config .tarpaulin.ci.toml --features test-utils
|
||||||
|
|
||||||
check-all: format-check lint coverage audit
|
check-all: format-check lint coverage audit
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,17 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
/// Interactive conventional commit tool for Jujutsu
|
||||||
|
///
|
||||||
|
/// Guides you through creating a properly formatted conventional commit message
|
||||||
|
/// and applies it to the current change in your Jujutsu repository.
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "jj-cz",
|
||||||
|
version,
|
||||||
|
about = "Interactive conventional commit tool for Jujutsu",
|
||||||
|
long_about = "Guides you through creating a properly formatted conventional \
|
||||||
|
commit message and applies it to the current change in your \
|
||||||
|
Jujutsu repository.\n\n\
|
||||||
|
This tool requires an interactive terminal (TTY)."
|
||||||
|
)]
|
||||||
|
pub struct Cli;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub enum CommitType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CommitType {
|
impl CommitType {
|
||||||
pub fn all() -> &'static [Self] {
|
pub const fn all() -> &'static [Self] {
|
||||||
&[
|
&[
|
||||||
Self::Feat,
|
Self::Feat,
|
||||||
Self::Fix,
|
Self::Fix,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
pub struct Description(String);
|
pub struct Description(String);
|
||||||
|
|
||||||
impl Description {
|
impl Description {
|
||||||
|
/// Soft limit for description length.
|
||||||
|
///
|
||||||
|
/// Descriptions over this length are warned about at the prompt layer but
|
||||||
|
/// are not rejected here — the hard limit is the 72-character total first
|
||||||
|
/// line enforced by [`ConventionalCommit`].
|
||||||
pub const MAX_LENGTH: usize = 50;
|
pub const MAX_LENGTH: usize = 50;
|
||||||
|
|
||||||
/// Parse and validate a description string
|
/// Parse and validate a description string
|
||||||
@@ -10,17 +15,14 @@ impl Description {
|
|||||||
/// # Validation
|
/// # Validation
|
||||||
/// - Trims leading/trailing whitespace
|
/// - Trims leading/trailing whitespace
|
||||||
/// - Rejects empty or whitespace-only input
|
/// - Rejects empty or whitespace-only input
|
||||||
/// - Validates maximum length (50 chars after trim - soft limit)
|
///
|
||||||
|
/// The 50-character soft limit is enforced at the prompt layer with a
|
||||||
|
/// warning rather than here, to allow descriptions slightly over the
|
||||||
|
/// limit where the 72-character total first-line limit is still satisfied.
|
||||||
pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
|
pub fn parse(value: impl Into<String>) -> Result<Self, DescriptionError> {
|
||||||
let value = value.into().trim().to_owned();
|
let value = value.into().trim().to_owned();
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
return Err(DescriptionError::Empty);
|
Err(DescriptionError::Empty)
|
||||||
}
|
|
||||||
if value.len() > Self::MAX_LENGTH {
|
|
||||||
Err(DescriptionError::TooLong {
|
|
||||||
actual: value.len(),
|
|
||||||
max: Self::MAX_LENGTH,
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
Ok(Self(value))
|
Ok(Self(value))
|
||||||
}
|
}
|
||||||
@@ -32,15 +34,13 @@ impl Description {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the length in characters
|
/// Returns the length in characters
|
||||||
|
///
|
||||||
|
/// `is_empty()` is intentionally absent: `Description` is guaranteed
|
||||||
|
/// non-empty by its constructor, so the concept does not apply.
|
||||||
|
#[allow(clippy::len_without_is_empty)]
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.0.len()
|
self.0.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Always returns false for a valid `Description`
|
|
||||||
/// (included for API completeness, but logically always false)
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.0.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<str> for Description {
|
impl AsRef<str> for Description {
|
||||||
@@ -59,9 +59,6 @@ impl std::fmt::Display for Description {
|
|||||||
pub enum DescriptionError {
|
pub enum DescriptionError {
|
||||||
#[error("Description cannot be empty")]
|
#[error("Description cannot be empty")]
|
||||||
Empty,
|
Empty,
|
||||||
|
|
||||||
#[error("Description too long ({actual} characters, maximum is {max})")]
|
|
||||||
TooLong { actual: usize, max: usize },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -180,49 +177,21 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap().as_str(), "add multiple spaces");
|
assert_eq!(result.unwrap().as_str(), "add multiple spaces");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test that 72 characters (old limit) is now rejected
|
/// Test that descriptions over the 50-char soft limit are accepted
|
||||||
|
///
|
||||||
|
/// The 50-char limit is enforced as a prompt-layer warning only.
|
||||||
|
/// The hard limit is the 72-char total first line (ConventionalCommit).
|
||||||
#[test]
|
#[test]
|
||||||
fn seventy_two_characters_now_rejected() {
|
fn description_over_soft_limit_accepted() {
|
||||||
let desc_72 = "a".repeat(72);
|
|
||||||
let result = Description::parse(&desc_72);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert_eq!(
|
|
||||||
result.unwrap_err(),
|
|
||||||
DescriptionError::TooLong {
|
|
||||||
actual: 72,
|
|
||||||
max: 50
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test that 51 characters is rejected (boundary)
|
|
||||||
#[test]
|
|
||||||
fn fifty_one_characters_rejected() {
|
|
||||||
let desc_51 = "a".repeat(51);
|
let desc_51 = "a".repeat(51);
|
||||||
let result = Description::parse(&desc_51);
|
let result = Description::parse(&desc_51);
|
||||||
assert!(result.is_err());
|
assert!(result.is_ok());
|
||||||
assert_eq!(
|
assert_eq!(result.unwrap().len(), 51);
|
||||||
result.unwrap_err(),
|
|
||||||
DescriptionError::TooLong {
|
|
||||||
actual: 51,
|
|
||||||
max: 50
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test that 100 characters is rejected
|
let desc_72 = "a".repeat(72);
|
||||||
#[test]
|
let result = Description::parse(&desc_72);
|
||||||
fn hundred_characters_rejected() {
|
assert!(result.is_ok());
|
||||||
let desc_100 = "a".repeat(100);
|
assert_eq!(result.unwrap().len(), 72);
|
||||||
let result = Description::parse(&desc_100);
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert_eq!(
|
|
||||||
result.unwrap_err(),
|
|
||||||
DescriptionError::TooLong {
|
|
||||||
actual: 100,
|
|
||||||
max: 50
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test that length is checked after trimming
|
/// Test that length is checked after trimming
|
||||||
@@ -264,13 +233,6 @@ mod tests {
|
|||||||
assert_eq!(desc.len(), 5);
|
assert_eq!(desc.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test is_empty() always returns false for valid Description
|
|
||||||
#[test]
|
|
||||||
fn is_empty_always_false_for_valid() {
|
|
||||||
let desc = Description::parse("x").unwrap();
|
|
||||||
assert!(!desc.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test Display trait implementation
|
/// Test Display trait implementation
|
||||||
#[test]
|
#[test]
|
||||||
fn display_outputs_inner_string() {
|
fn display_outputs_inner_string() {
|
||||||
@@ -321,19 +283,6 @@ mod tests {
|
|||||||
assert!(msg.contains("cannot be empty"));
|
assert!(msg.contains("cannot be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test DescriptionError::TooLong displays correctly
|
|
||||||
#[test]
|
|
||||||
fn too_long_error_display() {
|
|
||||||
let err = DescriptionError::TooLong {
|
|
||||||
actual: 51,
|
|
||||||
max: 50,
|
|
||||||
};
|
|
||||||
let msg = format!("{}", err);
|
|
||||||
assert!(msg.contains("too long"));
|
|
||||||
assert!(msg.contains("51"));
|
|
||||||
assert!(msg.contains("50"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test description with only whitespace after trim becomes empty
|
/// Test description with only whitespace after trim becomes empty
|
||||||
#[test]
|
#[test]
|
||||||
fn whitespace_after_trim_is_empty() {
|
fn whitespace_after_trim_is_empty() {
|
||||||
@@ -361,19 +310,14 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap().len(), 50);
|
assert_eq!(result.unwrap().len(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test description just over boundary after trimming
|
/// Test description just over soft limit is accepted after trimming
|
||||||
|
///
|
||||||
|
/// 51 chars (trimmed) is over the soft limit but still valid as a Description.
|
||||||
#[test]
|
#[test]
|
||||||
fn over_boundary_after_trim() {
|
fn over_soft_limit_after_trim_accepted() {
|
||||||
// 51 chars + spaces = should fail even after trim
|
|
||||||
let desc = format!(" {} ", "x".repeat(51));
|
let desc = format!(" {} ", "x".repeat(51));
|
||||||
let result = Description::parse(&desc);
|
let result = Description::parse(&desc);
|
||||||
assert!(result.is_err());
|
assert!(result.is_ok());
|
||||||
assert_eq!(
|
assert_eq!(result.unwrap().len(), 51);
|
||||||
result.unwrap_err(),
|
|
||||||
DescriptionError::TooLong {
|
|
||||||
actual: 51,
|
|
||||||
max: 50
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ pub enum CommitMessageError {
|
|||||||
/// The complete first line exceeds the maximum allowed length
|
/// The complete first line exceeds the maximum allowed length
|
||||||
#[error("first line too long: {actual} characters (max {max})")]
|
#[error("first line too long: {actual} characters (max {max})")]
|
||||||
FirstLineTooLong { actual: usize, max: usize },
|
FirstLineTooLong { actual: usize, max: usize },
|
||||||
|
|
||||||
|
/// The formatted message is not parseable as a conventional commit
|
||||||
|
///
|
||||||
|
/// This should never occur in normal use — it indicates a bug in the
|
||||||
|
/// formatting logic.
|
||||||
|
#[error("output failed git-conventional validation: {reason}")]
|
||||||
|
InvalidConventionalFormat { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -46,6 +53,12 @@ impl ConventionalCommit {
|
|||||||
max: Self::FIRST_LINE_MAX_LENGTH,
|
max: Self::FIRST_LINE_MAX_LENGTH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let formatted = commit.format();
|
||||||
|
git_conventional::Commit::parse(&formatted).map_err(|e| {
|
||||||
|
CommitMessageError::InvalidConventionalFormat {
|
||||||
|
reason: e.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
Ok(commit)
|
Ok(commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +84,23 @@ impl ConventionalCommit {
|
|||||||
/// Returns `type(scope): description` if scope is non-empty, or
|
/// Returns `type(scope): description` if scope is non-empty, or
|
||||||
/// `type: description` if scope is empty
|
/// `type: description` if scope is empty
|
||||||
pub fn format(&self) -> String {
|
pub fn format(&self) -> String {
|
||||||
if self.scope.is_empty() {
|
Self::format_preview(self.commit_type, &self.scope, &self.description)
|
||||||
format!("{}: {}", self.commit_type, self.description)
|
}
|
||||||
|
|
||||||
|
/// Format a preview of the commit message without creating a validated instance
|
||||||
|
///
|
||||||
|
/// This is useful for showing what the message would look like before validation
|
||||||
|
/// Returns `type(scope): description` if scope is non-empty, or
|
||||||
|
/// `type: description` if scope is empty
|
||||||
|
pub fn format_preview(
|
||||||
|
commit_type: CommitType,
|
||||||
|
scope: &Scope,
|
||||||
|
description: &Description,
|
||||||
|
) -> String {
|
||||||
|
if scope.is_empty() {
|
||||||
|
format!("{}: {}", commit_type, description)
|
||||||
} else {
|
} else {
|
||||||
format!("{}({}): {}", self.commit_type, self.scope, self.description)
|
format!("{}({}): {}", commit_type, scope, description)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,4 +733,49 @@ mod tests {
|
|||||||
// Just verify it's a Result by using is_ok()
|
// Just verify it's a Result by using is_ok()
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test that all valid commits produce messages parseable by git-conventional (SC-002)
|
||||||
|
///
|
||||||
|
/// This verifies that 100% of commit messages produced by this tool conform to
|
||||||
|
/// the conventional commit specification.
|
||||||
|
#[test]
|
||||||
|
fn all_valid_commits_parse_with_git_conventional() {
|
||||||
|
let cases: &[(&str, Option<&str>)] = &[
|
||||||
|
("add new feature", None),
|
||||||
|
("fix critical bug", Some("api")),
|
||||||
|
("update README", Some("docs")),
|
||||||
|
("remove unused code", Some("core")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for commit_type in CommitType::all() {
|
||||||
|
for (desc_str, scope_str) in cases {
|
||||||
|
let scope = match scope_str {
|
||||||
|
Some(s) => Scope::parse(*s).unwrap(),
|
||||||
|
None => Scope::empty(),
|
||||||
|
};
|
||||||
|
let desc = Description::parse(*desc_str).unwrap();
|
||||||
|
let commit = ConventionalCommit::new(*commit_type, scope, desc);
|
||||||
|
// new() itself calls git_conventional::Commit::parse internally, so
|
||||||
|
// if this is Ok, SC-002 is satisfied for this case.
|
||||||
|
assert!(
|
||||||
|
commit.is_ok(),
|
||||||
|
"git-conventional rejected {:?}/{:?}/{:?}",
|
||||||
|
commit_type,
|
||||||
|
scope_str,
|
||||||
|
desc_str
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test InvalidConventionalFormat error displays correctly
|
||||||
|
#[test]
|
||||||
|
fn invalid_conventional_format_error_display() {
|
||||||
|
let err = CommitMessageError::InvalidConventionalFormat {
|
||||||
|
reason: "missing type".to_string(),
|
||||||
|
};
|
||||||
|
let msg = format!("{}", err);
|
||||||
|
assert!(msg.contains("git-conventional"));
|
||||||
|
assert!(msg.contains("missing type"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ mod description;
|
|||||||
pub use description::{Description, DescriptionError};
|
pub use description::{Description, DescriptionError};
|
||||||
|
|
||||||
mod message;
|
mod message;
|
||||||
pub use message::CommitMessageError;
|
pub use message::{CommitMessageError, ConventionalCommit};
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
pub struct Scope(String);
|
pub struct Scope(String);
|
||||||
@@ -26,7 +24,7 @@ impl Scope {
|
|||||||
max: Self::MAX_LENGTH,
|
max: Self::MAX_LENGTH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
match lazy_regex::regex_find!(r"[^a-zA-Z0-9_/-]", &value) {
|
match lazy_regex::regex_find!(r"[^-a-zA-Z0-9_/]", &value) {
|
||||||
Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())),
|
Some(val) => Err(ScopeError::InvalidCharacter(val.chars().next().unwrap())),
|
||||||
None => Ok(Self(value)),
|
None => Ok(Self(value)),
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/error.rs
32
src/error.rs
@@ -1,6 +1,6 @@
|
|||||||
use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError};
|
use crate::commit::types::{CommitMessageError, DescriptionError, ScopeError};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
// Domain errors
|
// Domain errors
|
||||||
#[error("Invalid scope: {0}")]
|
#[error("Invalid scope: {0}")]
|
||||||
@@ -50,33 +50,3 @@ impl From<std::io::Error> for Error {
|
|||||||
Self::FailedGettingCurrentDir
|
Self::FailedGettingCurrentDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<jj_lib::config::ConfigGetError> for Error {
|
|
||||||
fn from(_: jj_lib::config::ConfigGetError) -> Self {
|
|
||||||
Self::FailedReadingConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<jj_lib::repo::RepoLoaderError> for Error {
|
|
||||||
fn from(error: jj_lib::repo::RepoLoaderError) -> Self {
|
|
||||||
Self::JjOperation {
|
|
||||||
context: format!("Failed to load repository: {}", error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<jj_lib::backend::BackendError> for Error {
|
|
||||||
fn from(error: jj_lib::backend::BackendError) -> Self {
|
|
||||||
Self::JjOperation {
|
|
||||||
context: format!("Backend operation failed: {}", error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<jj_lib::transaction::TransactionCommitError> for Error {
|
|
||||||
fn from(error: jj_lib::transaction::TransactionCommitError) -> Self {
|
|
||||||
Self::JjOperation {
|
|
||||||
context: format!("Transaction commit failed: {}", error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
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
|
/// Trait for executing jj operations
|
||||||
///
|
///
|
||||||
/// All methods are async for native jj-lib compatibility.
|
/// All methods are async for native jj-lib compatibility.
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait(?Send)]
|
||||||
pub trait JjExecutor: Send + Sync {
|
pub trait JjExecutor: Send + Sync {
|
||||||
/// Check if current directory is within a jj repository
|
/// Check if current directory is within a jj repository
|
||||||
async fn is_repository(&self) -> Result<bool, Error>;
|
async fn is_repository(&self) -> Result<bool, Error>;
|
||||||
@@ -18,97 +21,6 @@ pub trait JjExecutor: Send + Sync {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Test that JjExecutor trait definition compiles
|
||||||
///
|
///
|
||||||
/// This test verifies:
|
/// This test verifies:
|
||||||
@@ -144,8 +56,8 @@ mod tests {
|
|||||||
fn _assert_sync<T: Sync>() {}
|
fn _assert_sync<T: Sync>() {}
|
||||||
|
|
||||||
// MockJjExecutor implements the trait, so it must satisfy Send + Sync
|
// MockJjExecutor implements the trait, so it must satisfy Send + Sync
|
||||||
_assert_send::<MockJjExecutor>();
|
_assert_send::<mock::MockJjExecutor>();
|
||||||
_assert_sync::<MockJjExecutor>();
|
_assert_sync::<mock::MockJjExecutor>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test that mock can implement JjExecutor trait
|
/// 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
|
/// This is a compile-time check that the mock properly implements the trait
|
||||||
#[test]
|
#[test]
|
||||||
fn mock_implements_trait() {
|
fn mock_implements_trait() {
|
||||||
let mock = MockJjExecutor::new();
|
let mock = mock::MockJjExecutor::new();
|
||||||
// If this compiles, the mock implements the trait
|
// If this compiles, the mock implements the trait
|
||||||
fn _accepts_executor(_e: impl JjExecutor) {}
|
fn _accepts_executor(_e: impl JjExecutor) {}
|
||||||
_accepts_executor(mock);
|
_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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/lib.rs
19
src/lib.rs
@@ -3,3 +3,22 @@ mod commit;
|
|||||||
mod error;
|
mod error;
|
||||||
mod jj;
|
mod jj;
|
||||||
mod prompts;
|
mod prompts;
|
||||||
|
|
||||||
|
pub use crate::{
|
||||||
|
commit::types::{
|
||||||
|
CommitMessageError, CommitType, ConventionalCommit, Description, DescriptionError, Scope,
|
||||||
|
ScopeError,
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
jj::{JjExecutor, lib_executor::JjLib},
|
||||||
|
prompts::{CommitWorkflow, Prompter},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Test utilities: mock implementations for `JjExecutor` and `MockPrompts`.
|
||||||
|
///
|
||||||
|
/// Enable with `--features test-utils` (e.g. `cargo test --features test-utils`).
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
pub use crate::{
|
||||||
|
jj::mock::MockJjExecutor,
|
||||||
|
prompts::mock::MockPrompts,
|
||||||
|
};
|
||||||
|
|||||||
75
src/main.rs
75
src/main.rs
@@ -1,3 +1,74 @@
|
|||||||
fn main() {
|
mod cli;
|
||||||
println!("Hello World!");
|
|
||||||
|
use clap::Parser as _;
|
||||||
|
use jj_cz::{CommitWorkflow, Error, JjLib};
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
/// Exit codes used by jj-cz
|
||||||
|
const EXIT_SUCCESS: i32 = 0;
|
||||||
|
const EXIT_CANCELLED: i32 = 130; // Same as SIGINT (Ctrl+C)
|
||||||
|
const EXIT_ERROR: i32 = 1;
|
||||||
|
|
||||||
|
/// Map application errors to appropriate exit codes
|
||||||
|
fn error_to_exit_code(error: &Error) -> i32 {
|
||||||
|
match error {
|
||||||
|
Error::Cancelled => EXIT_CANCELLED,
|
||||||
|
Error::NotARepository => EXIT_ERROR,
|
||||||
|
Error::RepositoryLocked => EXIT_ERROR,
|
||||||
|
Error::JjOperation { .. } => EXIT_ERROR,
|
||||||
|
Error::InvalidScope(_) => EXIT_ERROR,
|
||||||
|
Error::InvalidDescription(_) => EXIT_ERROR,
|
||||||
|
Error::InvalidCommitMessage(_) => EXIT_ERROR,
|
||||||
|
Error::NonInteractive => EXIT_ERROR,
|
||||||
|
Error::FailedGettingCurrentDir => EXIT_ERROR,
|
||||||
|
Error::FailedReadingConfig => EXIT_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we're running in an interactive terminal
|
||||||
|
fn is_interactive_terminal() -> bool {
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Parse CLI arguments; --help and --version are handled automatically by clap
|
||||||
|
cli::Cli::parse();
|
||||||
|
|
||||||
|
if !is_interactive_terminal() {
|
||||||
|
eprintln!("❌ Error: jj-cz requires an interactive terminal (TTY)");
|
||||||
|
eprintln!(" This tool cannot be used in non-interactive mode or when piping input.");
|
||||||
|
eprintln!(" Use --help for usage information.");
|
||||||
|
process::exit(EXIT_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the jj executor
|
||||||
|
let executor = match JjLib::new() {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("❌ Error: {}", e);
|
||||||
|
process::exit(EXIT_ERROR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create and run the workflow
|
||||||
|
let workflow = CommitWorkflow::new(executor);
|
||||||
|
let result = workflow.run().await;
|
||||||
|
|
||||||
|
// Handle the result
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("✅ Commit message applied successfully!");
|
||||||
|
process::exit(EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
Err(Error::Cancelled) => {
|
||||||
|
println!("🟡 Operation cancelled by user.");
|
||||||
|
process::exit(EXIT_CANCELLED);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("❌ Error: {}", e);
|
||||||
|
process::exit(error_to_exit_code(&e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
284
src/prompts/mock.rs
Normal file
284
src/prompts/mock.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! Mock implementation of [`Prompter`] for testing
|
||||||
|
//!
|
||||||
|
//! This module is gated via `#[cfg(any(test, feature = "test-utils"))]` on its
|
||||||
|
//! declaration in `mod.rs`, so it is never compiled into production binaries.
|
||||||
|
//!
|
||||||
|
//! [`Prompter`]: super::prompter::Prompter
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commit::types::{CommitType, Description, Scope},
|
||||||
|
error::Error,
|
||||||
|
prompts::prompter::Prompter,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Enum representing different types of mock responses
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum MockResponse {
|
||||||
|
CommitType(CommitType),
|
||||||
|
Scope(Scope),
|
||||||
|
Description(Description),
|
||||||
|
Confirm(bool),
|
||||||
|
Error(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mock implementation of [`Prompter`] for testing
|
||||||
|
///
|
||||||
|
/// This struct allows configuring responses for each prompt type and tracks
|
||||||
|
/// which prompts were called during test execution.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct MockPrompts {
|
||||||
|
/// Queue of responses to return for each prompt call
|
||||||
|
responses: Arc<Mutex<Vec<MockResponse>>>,
|
||||||
|
/// Track which prompts were called (for verification)
|
||||||
|
prompts_called: Arc<Mutex<Vec<String>>>,
|
||||||
|
/// Messages emitted via emit_message() for test assertion
|
||||||
|
messages: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockPrompts {
|
||||||
|
/// Create a new MockPrompts with empty response queue
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return a specific commit type
|
||||||
|
pub fn with_commit_type(self, commit_type: CommitType) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::CommitType(commit_type));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return a specific scope
|
||||||
|
pub fn with_scope(self, scope: Scope) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::Scope(scope));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return a specific description
|
||||||
|
pub fn with_description(self, description: Description) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::Description(description));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return a specific confirmation response
|
||||||
|
pub fn with_confirm(self, confirm: bool) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::Confirm(confirm));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the mock to return an error
|
||||||
|
pub fn with_error(self, error: Error) -> Self {
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(MockResponse::Error(error));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if select_commit_type was called
|
||||||
|
pub fn was_commit_type_called(&self) -> bool {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"select_commit_type".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if input_scope was called
|
||||||
|
pub fn was_scope_called(&self) -> bool {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"input_scope".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if input_description was called
|
||||||
|
pub fn was_description_called(&self) -> bool {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"input_description".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if confirm_apply was called
|
||||||
|
pub fn was_confirm_called(&self) -> bool {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"confirm_apply".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all messages emitted via emit_message()
|
||||||
|
pub fn emitted_messages(&self) -> Vec<String> {
|
||||||
|
self.messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prompter for MockPrompts {
|
||||||
|
fn select_commit_type(&self) -> Result<CommitType, Error> {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("select_commit_type".to_string());
|
||||||
|
|
||||||
|
match self.responses.lock().unwrap().remove(0) {
|
||||||
|
MockResponse::CommitType(ct) => Ok(ct),
|
||||||
|
MockResponse::Error(e) => Err(e),
|
||||||
|
_ => panic!("MockPrompts: Expected CommitType response, got different type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_scope(&self) -> Result<Scope, Error> {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("input_scope".to_string());
|
||||||
|
|
||||||
|
match self.responses.lock().unwrap().remove(0) {
|
||||||
|
MockResponse::Scope(scope) => Ok(scope),
|
||||||
|
MockResponse::Error(e) => Err(e),
|
||||||
|
_ => panic!("MockPrompts: Expected Scope response, got different type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_description(&self) -> Result<Description, Error> {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("input_description".to_string());
|
||||||
|
|
||||||
|
match self.responses.lock().unwrap().remove(0) {
|
||||||
|
MockResponse::Description(desc) => Ok(desc),
|
||||||
|
MockResponse::Error(e) => Err(e),
|
||||||
|
_ => panic!("MockPrompts: Expected Description response, got different type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_apply(&self, _message: &str) -> Result<bool, Error> {
|
||||||
|
self.prompts_called
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push("confirm_apply".to_string());
|
||||||
|
|
||||||
|
match self.responses.lock().unwrap().remove(0) {
|
||||||
|
MockResponse::Confirm(confirm) => Ok(confirm),
|
||||||
|
MockResponse::Error(e) => Err(e),
|
||||||
|
_ => panic!("MockPrompts: Expected Confirm response, got different type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_message(&self, msg: &str) {
|
||||||
|
self.messages.lock().unwrap().push(msg.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::commit::types::{CommitType, Description, Scope};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_prompts_creation() {
|
||||||
|
let mock = MockPrompts::new();
|
||||||
|
assert!(matches!(mock, MockPrompts { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_prompts_implements_trait() {
|
||||||
|
let mock = MockPrompts::new();
|
||||||
|
fn _accepts_prompter(_p: impl Prompter) {}
|
||||||
|
_accepts_prompter(mock);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_select_commit_type() {
|
||||||
|
let mock = MockPrompts::new().with_commit_type(CommitType::Feat);
|
||||||
|
let result = mock.select_commit_type();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), CommitType::Feat);
|
||||||
|
assert!(mock.was_commit_type_called());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_input_scope() {
|
||||||
|
let scope = Scope::parse("test-scope").unwrap();
|
||||||
|
let mock = MockPrompts::new().with_scope(scope.clone());
|
||||||
|
let result = mock.input_scope();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), scope);
|
||||||
|
assert!(mock.was_scope_called());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_input_description() {
|
||||||
|
let desc = Description::parse("test description").unwrap();
|
||||||
|
let mock = MockPrompts::new().with_description(desc.clone());
|
||||||
|
let result = mock.input_description();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), desc);
|
||||||
|
assert!(mock.was_description_called());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_confirm_apply() {
|
||||||
|
let mock = MockPrompts::new().with_confirm(true);
|
||||||
|
let result = mock.confirm_apply("test message");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap());
|
||||||
|
assert!(mock.was_confirm_called());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_error_response() {
|
||||||
|
let mock = MockPrompts::new().with_error(Error::Cancelled);
|
||||||
|
let result = mock.select_commit_type();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_tracks_prompt_calls() {
|
||||||
|
let mock = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Fix)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
mock.select_commit_type().unwrap();
|
||||||
|
mock.input_scope().unwrap();
|
||||||
|
mock.input_description().unwrap();
|
||||||
|
mock.confirm_apply("test").unwrap();
|
||||||
|
|
||||||
|
assert!(mock.was_commit_type_called());
|
||||||
|
assert!(mock.was_scope_called());
|
||||||
|
assert!(mock.was_description_called());
|
||||||
|
assert!(mock.was_confirm_called());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_emit_message_records_messages() {
|
||||||
|
let mock = MockPrompts::new();
|
||||||
|
mock.emit_message("hello");
|
||||||
|
mock.emit_message("world");
|
||||||
|
let msgs = mock.emitted_messages();
|
||||||
|
assert_eq!(msgs, vec!["hello", "world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mock_emit_message_starts_empty() {
|
||||||
|
let mock = MockPrompts::new();
|
||||||
|
assert!(mock.emitted_messages().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,7 @@
|
|||||||
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
|
pub mod mock;
|
||||||
|
pub mod prompter;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
|
pub use prompter::Prompter;
|
||||||
|
pub use workflow::CommitWorkflow;
|
||||||
|
|||||||
184
src/prompts/prompter.rs
Normal file
184
src/prompts/prompter.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! Prompt abstraction for the interactive commit workflow
|
||||||
|
//!
|
||||||
|
//! This module provides the [`Prompter`] trait and its production
|
||||||
|
//! implementation [`RealPrompts`]. The trait is the seam that allows
|
||||||
|
//! [`CommitWorkflow`](super::CommitWorkflow) to use real interactive prompts
|
||||||
|
//! in production while accepting mock implementations in tests.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commit::types::{CommitType, Description, Scope},
|
||||||
|
error::Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Abstraction over prompt operations used by the commit workflow
|
||||||
|
///
|
||||||
|
/// Implement this trait to supply a custom front-end (interactive TUI, mock,
|
||||||
|
/// headless, etc.) to [`CommitWorkflow`](super::CommitWorkflow).
|
||||||
|
pub trait Prompter: Send + Sync {
|
||||||
|
/// Prompt the user to select a commit type
|
||||||
|
fn select_commit_type(&self) -> Result<CommitType, Error>;
|
||||||
|
|
||||||
|
/// Prompt the user to input an optional scope
|
||||||
|
fn input_scope(&self) -> Result<Scope, Error>;
|
||||||
|
|
||||||
|
/// Prompt the user to input a required description
|
||||||
|
fn input_description(&self) -> Result<Description, Error>;
|
||||||
|
|
||||||
|
/// Prompt the user to confirm applying the commit message
|
||||||
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error>;
|
||||||
|
|
||||||
|
/// Display a message to the user (errors, feedback, status)
|
||||||
|
///
|
||||||
|
/// In production this prints to stdout. In tests, implementations
|
||||||
|
/// typically record the message for later assertion.
|
||||||
|
fn emit_message(&self, msg: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production implementation of [`Prompter`] using the `inquire` crate
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RealPrompts;
|
||||||
|
|
||||||
|
impl Prompter for RealPrompts {
|
||||||
|
fn select_commit_type(&self) -> Result<CommitType, Error> {
|
||||||
|
use inquire::Select;
|
||||||
|
|
||||||
|
let options: Vec<_> = CommitType::all()
|
||||||
|
.iter()
|
||||||
|
.map(|ct| format!("{}: {}", ct, ct.description()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let answer = Select::new("Select commit type:", options)
|
||||||
|
.with_page_size(11)
|
||||||
|
.with_help_message(
|
||||||
|
"Use arrow keys to navigate, Enter to select. See https://www.conventionalcommits.org/ for details.",
|
||||||
|
)
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| Error::Cancelled)?;
|
||||||
|
|
||||||
|
// Extract the commit type from the selected option
|
||||||
|
let selected_type = answer
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::JjOperation {
|
||||||
|
context: "Failed to parse selected commit type".to_string(),
|
||||||
|
})?
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
CommitType::all()
|
||||||
|
.iter()
|
||||||
|
.find(|ct| ct.as_str() == selected_type)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| Error::JjOperation {
|
||||||
|
context: format!("Unknown commit type: {}", selected_type),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_scope(&self) -> Result<Scope, Error> {
|
||||||
|
use inquire::Text;
|
||||||
|
|
||||||
|
let answer = Text::new("Enter scope (optional):")
|
||||||
|
.with_help_message(
|
||||||
|
"Scope is optional. If provided, it should be a noun representing the section of code affected (e.g., 'api', 'ui', 'config'). Max 30 characters.",
|
||||||
|
)
|
||||||
|
.with_placeholder("Leave empty if no scope")
|
||||||
|
.prompt_skippable()
|
||||||
|
.map_err(|_| Error::Cancelled)?;
|
||||||
|
|
||||||
|
// Empty input is valid (no scope)
|
||||||
|
let answer_str = match answer {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(Scope::empty()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if answer_str.trim().is_empty() {
|
||||||
|
return Ok(Scope::empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the scope
|
||||||
|
Scope::parse(answer_str.trim()).map_err(|e| Error::InvalidScope(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_description(&self) -> Result<Description, Error> {
|
||||||
|
use inquire::Text;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let answer = Text::new("Enter description (required):")
|
||||||
|
.with_help_message(
|
||||||
|
"Description is required. Short summary in imperative mood \
|
||||||
|
(e.g., 'add feature', 'fix bug'). Soft limit: 50 characters.",
|
||||||
|
)
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| Error::Cancelled)?;
|
||||||
|
|
||||||
|
let trimmed = answer.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
println!("❌ Description cannot be empty. Please provide a description.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse() only fails on empty — already handled above
|
||||||
|
let Ok(desc) = Description::parse(trimmed) else {
|
||||||
|
println!("❌ Description cannot be empty. Please provide a description.");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Soft limit warning: over 50 chars is allowed but may push the
|
||||||
|
// combined first line over 72 characters.
|
||||||
|
if desc.len() > Description::MAX_LENGTH {
|
||||||
|
println!(
|
||||||
|
"⚠️ Description is {} characters (soft limit is {}). \
|
||||||
|
The combined commit line must still be ≤ 72 characters.",
|
||||||
|
desc.len(),
|
||||||
|
Description::MAX_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_apply(&self, message: &str) -> Result<bool, Error> {
|
||||||
|
use inquire::Confirm;
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
println!();
|
||||||
|
println!("📝 Commit Message Preview:");
|
||||||
|
println!(
|
||||||
|
"┌─────────────────────────────────────────────────────────────────────────────────────────────────┐"
|
||||||
|
);
|
||||||
|
println!("│ {}│", message);
|
||||||
|
// Pad with spaces to fill the box
|
||||||
|
let padding = 72_usize.saturating_sub(message.chars().count());
|
||||||
|
if padding > 0 {
|
||||||
|
println!("│{:padding$}│", "");
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"└─────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Get confirmation
|
||||||
|
Confirm::new("Apply this commit message?")
|
||||||
|
.with_default(true)
|
||||||
|
.with_help_message("Select 'No' to cancel and start over")
|
||||||
|
.prompt()
|
||||||
|
.map_err(|_| Error::Cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_message(&self, msg: &str) {
|
||||||
|
println!("{}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test RealPrompts implements Prompter trait
|
||||||
|
#[test]
|
||||||
|
fn real_prompts_implements_trait() {
|
||||||
|
let real = RealPrompts;
|
||||||
|
fn _accepts_prompter(_p: impl Prompter) {}
|
||||||
|
_accepts_prompter(real);
|
||||||
|
}
|
||||||
|
}
|
||||||
512
src/prompts/workflow.rs
Normal file
512
src/prompts/workflow.rs
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
//! Interactive commit workflow orchestration
|
||||||
|
//!
|
||||||
|
//! This module provides the CommitWorkflow struct that guides users through
|
||||||
|
//! creating a conventional commit message using interactive prompts.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commit::types::{CommitMessageError, CommitType, ConventionalCommit, Description, Scope},
|
||||||
|
error::Error,
|
||||||
|
jj::JjExecutor,
|
||||||
|
prompts::prompter::{Prompter, RealPrompts},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Orchestrates the interactive commit workflow
|
||||||
|
///
|
||||||
|
/// This struct handles the complete user interaction flow:
|
||||||
|
/// 1. Check if we're in a jj repository
|
||||||
|
/// 2. Select commit type from 11 options
|
||||||
|
/// 3. Optionally input scope (validated)
|
||||||
|
/// 4. Input required description (validated)
|
||||||
|
/// 5. Preview formatted message and confirm
|
||||||
|
/// 6. Apply the message to the current change
|
||||||
|
///
|
||||||
|
/// Uses dependency injection for prompts to enable testing without TUI.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitWorkflow<J: JjExecutor, P: Prompter = RealPrompts> {
|
||||||
|
executor: J,
|
||||||
|
prompts: P,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<J: JjExecutor> CommitWorkflow<J> {
|
||||||
|
/// Create a new CommitWorkflow with the given executor
|
||||||
|
///
|
||||||
|
/// Uses RealPrompts by default for interactive TUI prompts.
|
||||||
|
pub fn new(executor: J) -> Self {
|
||||||
|
Self::with_prompts(executor, RealPrompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<J: JjExecutor, P: Prompter> CommitWorkflow<J, P> {
|
||||||
|
/// Create a new CommitWorkflow with custom prompts
|
||||||
|
///
|
||||||
|
/// This allows using MockPrompts in tests to avoid TUI hanging.
|
||||||
|
pub fn with_prompts(executor: J, prompts: P) -> Self {
|
||||||
|
Self { executor, prompts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the complete interactive workflow
|
||||||
|
///
|
||||||
|
/// Returns Ok(()) on successful completion, or an error if:
|
||||||
|
/// - Not in a jj repository
|
||||||
|
/// - User cancels the workflow
|
||||||
|
/// - Repository operation fails
|
||||||
|
/// - Message validation fails
|
||||||
|
pub async fn run(&self) -> Result<(), Error> {
|
||||||
|
// Verify we're in a jj repository
|
||||||
|
if !self.executor.is_repository().await? {
|
||||||
|
return Err(Error::NotARepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Select commit type (kept across retries)
|
||||||
|
let commit_type = self.type_selection().await?;
|
||||||
|
|
||||||
|
// Steps 2–4 loop: re-prompt scope and description when the combined
|
||||||
|
// first line would exceed 72 characters (issue 3.4).
|
||||||
|
loop {
|
||||||
|
// Step 2: Input scope (optional)
|
||||||
|
let scope = self.scope_input().await?;
|
||||||
|
|
||||||
|
// Step 3: Input description (required)
|
||||||
|
let description = self.description_input().await?;
|
||||||
|
|
||||||
|
// Step 4: Preview and confirm
|
||||||
|
match self
|
||||||
|
.preview_and_confirm(commit_type, scope, description)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(conventional_commit) => {
|
||||||
|
// Step 5: Apply the message
|
||||||
|
self.executor
|
||||||
|
.describe(&conventional_commit.to_string())
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(Error::InvalidCommitMessage(_)) => {
|
||||||
|
// The scope/description combination exceeds 72 characters.
|
||||||
|
// The user has already been shown the error via emit_message.
|
||||||
|
// Loop back to re-prompt scope and description (type is kept).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt user to select a commit type from the 11 available options
|
||||||
|
async fn type_selection(&self) -> Result<CommitType, Error> {
|
||||||
|
self.prompts.select_commit_type()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt user to input an optional scope
|
||||||
|
///
|
||||||
|
/// Returns Ok(Scope) with the validated scope, or Error::Cancelled if user cancels
|
||||||
|
async fn scope_input(&self) -> Result<Scope, Error> {
|
||||||
|
self.prompts.input_scope()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt user to input a required description
|
||||||
|
///
|
||||||
|
/// Returns Ok(Description) with the validated description, or Error::Cancelled if user cancels
|
||||||
|
async fn description_input(&self) -> Result<Description, Error> {
|
||||||
|
self.prompts.input_description()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview the formatted conventional commit message and get user confirmation
|
||||||
|
///
|
||||||
|
/// This method also validates that the complete first line doesn't exceed 72 characters
|
||||||
|
async fn preview_and_confirm(
|
||||||
|
&self,
|
||||||
|
commit_type: CommitType,
|
||||||
|
scope: Scope,
|
||||||
|
description: Description,
|
||||||
|
) -> Result<ConventionalCommit, Error> {
|
||||||
|
// Format the message for preview
|
||||||
|
let message = ConventionalCommit::format_preview(commit_type, &scope, &description);
|
||||||
|
|
||||||
|
// Try to build the conventional commit (this validates the 72-char limit)
|
||||||
|
let conventional_commit: ConventionalCommit = match ConventionalCommit::new(
|
||||||
|
commit_type,
|
||||||
|
scope.clone(),
|
||||||
|
description.clone(),
|
||||||
|
) {
|
||||||
|
Ok(cc) => cc,
|
||||||
|
Err(CommitMessageError::FirstLineTooLong { actual, max }) => {
|
||||||
|
self.prompts.emit_message("❌ Message too long!");
|
||||||
|
self.prompts.emit_message(&format!(
|
||||||
|
"The complete first line must be ≤ {} characters.",
|
||||||
|
max
|
||||||
|
));
|
||||||
|
self.prompts
|
||||||
|
.emit_message(&format!("Current length: {} characters", actual));
|
||||||
|
self.prompts.emit_message("");
|
||||||
|
self.prompts.emit_message("Formatted message would be:");
|
||||||
|
self.prompts.emit_message(&message);
|
||||||
|
self.prompts.emit_message("");
|
||||||
|
self.prompts
|
||||||
|
.emit_message("Please try again with a shorter scope or description.");
|
||||||
|
return Err(Error::InvalidCommitMessage(format!(
|
||||||
|
"First line too long: {} > {}",
|
||||||
|
actual, max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(CommitMessageError::InvalidConventionalFormat { reason }) => {
|
||||||
|
return Err(Error::InvalidCommitMessage(format!(
|
||||||
|
"Internal error: generated message failed conventional commit validation: {}",
|
||||||
|
reason
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get confirmation from user
|
||||||
|
let confirmed = self.prompts.confirm_apply(&message)?;
|
||||||
|
|
||||||
|
if confirmed {
|
||||||
|
Ok(conventional_commit)
|
||||||
|
} else {
|
||||||
|
Err(Error::Cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::jj::mock::MockJjExecutor;
|
||||||
|
use crate::prompts::mock::MockPrompts;
|
||||||
|
|
||||||
|
/// Test that CommitWorkflow can be created with a mock executor
|
||||||
|
#[test]
|
||||||
|
fn workflow_creation() {
|
||||||
|
let mock = MockJjExecutor::new();
|
||||||
|
let workflow = CommitWorkflow::new(mock);
|
||||||
|
// If this compiles, the workflow is properly typed
|
||||||
|
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow returns NotARepository when is_repository() returns false
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workflow_returns_not_a_repository() {
|
||||||
|
let mock = MockJjExecutor::new().with_is_repo_response(Ok(false));
|
||||||
|
let workflow = CommitWorkflow::new(mock);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow returns NotARepository when is_repository() returns error
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workflow_returns_repository_error() {
|
||||||
|
let mock = MockJjExecutor::new().with_is_repo_response(Err(Error::NotARepository));
|
||||||
|
let workflow = CommitWorkflow::new(mock);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::NotARepository));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that type_selection returns a valid CommitType
|
||||||
|
#[tokio::test]
|
||||||
|
async fn type_selection_returns_valid_type() {
|
||||||
|
// Updated to use mock prompts to avoid TUI hanging
|
||||||
|
let mock_executor = MockJjExecutor::new();
|
||||||
|
let mock_prompts = MockPrompts::new().with_commit_type(CommitType::Feat);
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
|
||||||
|
// Now we can actually test the method with mock prompts
|
||||||
|
let result = workflow.type_selection().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), CommitType::Feat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that scope_input returns a valid Scope
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scope_input_returns_valid_scope() {
|
||||||
|
let mock_executor = MockJjExecutor::new();
|
||||||
|
let mock_prompts = MockPrompts::new().with_scope(Scope::parse("test").unwrap());
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
|
||||||
|
let result = workflow.scope_input().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), Scope::parse("test").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that description_input returns a valid Description
|
||||||
|
#[tokio::test]
|
||||||
|
async fn description_input_returns_valid_description() {
|
||||||
|
let mock_executor = MockJjExecutor::new();
|
||||||
|
let mock_prompts = MockPrompts::new().with_description(Description::parse("test").unwrap());
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
|
||||||
|
let result = workflow.description_input().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), Description::parse("test").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that preview_and_confirm returns a ConventionalCommit
|
||||||
|
#[tokio::test]
|
||||||
|
async fn preview_and_confirm_returns_conventional_commit() {
|
||||||
|
let mock_executor = MockJjExecutor::new();
|
||||||
|
let mock_prompts = MockPrompts::new().with_confirm(true);
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
|
||||||
|
let commit_type = CommitType::Feat;
|
||||||
|
let scope = Scope::empty();
|
||||||
|
let description = Description::parse("test description").unwrap();
|
||||||
|
|
||||||
|
let result = workflow
|
||||||
|
.preview_and_confirm(commit_type, scope, description)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow error handling for describe failure
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workflow_handles_describe_error() {
|
||||||
|
// Test the mock executor methods directly
|
||||||
|
let mock = MockJjExecutor::new()
|
||||||
|
.with_is_repo_response(Ok(true))
|
||||||
|
.with_describe_response(Err(Error::RepositoryLocked));
|
||||||
|
|
||||||
|
// Verify the mock behaves as expected
|
||||||
|
assert!(mock.is_repository().await.is_ok());
|
||||||
|
assert!(mock.describe("test").await.is_err());
|
||||||
|
|
||||||
|
// Also test with a working mock
|
||||||
|
let working_mock = MockJjExecutor::new();
|
||||||
|
let workflow = CommitWorkflow::new(working_mock);
|
||||||
|
// We can't complete the full workflow without mocking prompts,
|
||||||
|
// but we can verify the workflow was created successfully
|
||||||
|
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that workflow implements Debug trait
|
||||||
|
#[test]
|
||||||
|
fn workflow_implements_debug() {
|
||||||
|
let mock = MockJjExecutor::new();
|
||||||
|
let workflow = CommitWorkflow::new(mock);
|
||||||
|
let debug_output = format!("{:?}", workflow);
|
||||||
|
assert!(debug_output.contains("CommitWorkflow"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test complete workflow with mock prompts (happy path)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_complete_workflow_happy_path() {
|
||||||
|
// Create mock executor that returns true for is_repository
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
// Create mock prompts with successful responses
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("add new feature").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
// Create workflow with both mocks
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
|
||||||
|
// Run the workflow - should succeed
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow cancellation at type selection
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workflow_cancellation_at_type_selection() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
let mock_prompts = MockPrompts::new().with_error(Error::Cancelled);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow cancellation at confirmation
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workflow_cancellation_at_confirmation() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Fix)
|
||||||
|
.with_scope(Scope::parse("api").unwrap())
|
||||||
|
.with_description(Description::parse("fix bug").unwrap())
|
||||||
|
.with_confirm(false); // User cancels at confirmation
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::Cancelled));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow loops back on line length error, re-prompting scope and description
|
||||||
|
///
|
||||||
|
/// "feat(very-long-scope-name): " + 45 'a's = 4+1+20+3+45 = 73 chars → too long (first pass)
|
||||||
|
/// "feat: short description" = 4+2+17 = 23 chars → fine (second pass)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workflow_line_length_validation() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
// First iteration: scope + description exceed 72 chars combined
|
||||||
|
.with_scope(Scope::parse("very-long-scope-name").unwrap())
|
||||||
|
.with_description(Description::parse("a".repeat(45)).unwrap())
|
||||||
|
// Second iteration: short enough to succeed
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("short description").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
// Clone before moving into workflow so we can inspect emitted messages after
|
||||||
|
let mock_prompts_handle = mock_prompts.clone();
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
|
||||||
|
// Should succeed after the retry
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Workflow should succeed after retry, got: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error messages about the line being too long must have been emitted
|
||||||
|
// (via emit_message, not bare println) during the first iteration
|
||||||
|
let messages = mock_prompts_handle.emitted_messages();
|
||||||
|
assert!(
|
||||||
|
messages.iter().any(|m| m.contains("too long")),
|
||||||
|
"Expected a 'too long' message, got: {:?}",
|
||||||
|
messages
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
messages.iter().any(|m| m.contains("72")),
|
||||||
|
"Expected a message about the 72-char limit, got: {:?}",
|
||||||
|
messages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow with invalid scope
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workflow_invalid_scope() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
// Create mock prompts that would return invalid scope
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Docs)
|
||||||
|
.with_error(Error::InvalidScope(
|
||||||
|
"Invalid characters in scope".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::InvalidScope(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow with invalid description
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_workflow_invalid_description() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Refactor)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_error(Error::InvalidDescription(
|
||||||
|
"Description cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), Error::InvalidDescription(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that mock prompts track method calls correctly
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mock_prompts_track_calls() {
|
||||||
|
let mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
// We don't need to run the full workflow, just verify the mock was created correctly
|
||||||
|
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow with all commit types
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_all_commit_types() {
|
||||||
|
let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
for commit_type in CommitType::all() {
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(*commit_type)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
|
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||||
|
mock_prompts,
|
||||||
|
);
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_ok(), "Failed for commit type: {:?}", commit_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test workflow with various scope formats
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_various_scope_formats() {
|
||||||
|
let _mock_executor = MockJjExecutor::new().with_is_repo_response(Ok(true));
|
||||||
|
|
||||||
|
// Test empty scope
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
|
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||||
|
mock_prompts,
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test valid scope
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::parse("api").unwrap())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(
|
||||||
|
MockJjExecutor::new().with_is_repo_response(Ok(true)),
|
||||||
|
mock_prompts,
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let result: Result<(), Error> = workflow.run().await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test that workflow can be used with trait objects for both executor and prompts
|
||||||
|
#[test]
|
||||||
|
fn workflow_works_with_trait_objects() {
|
||||||
|
let mock_executor = MockJjExecutor::new();
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
|
||||||
|
assert!(matches!(workflow, CommitWorkflow { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/cli.rs
Normal file
99
tests/cli.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use assert_fs::TempDir;
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
use jj_cz::{CommitType, Description, MockPrompts, Scope};
|
||||||
|
use jj_cz::{CommitWorkflow, Error, JjLib};
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Helper to initialize a temporary jj repository
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
fn init_jj_repo(temp_dir: &TempDir) {
|
||||||
|
let status = Command::new("jj")
|
||||||
|
.args(["git", "init"])
|
||||||
|
.current_dir(temp_dir)
|
||||||
|
.status()
|
||||||
|
.expect("Failed to initialize jj repository");
|
||||||
|
assert!(status.success(), "jj git init failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_happy_path_integration() {
|
||||||
|
// T037: Happy path integration test
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
init_jj_repo(&temp_dir);
|
||||||
|
|
||||||
|
// Create mock prompts that simulate a successful workflow
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("add new feature").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
|
||||||
|
// Create a mock executor that tracks calls
|
||||||
|
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||||
|
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
|
||||||
|
|
||||||
|
let result = workflow.run().await;
|
||||||
|
|
||||||
|
// The workflow should complete successfully
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Workflow should complete successfully: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_not_in_repo() {
|
||||||
|
// T038: Not-in-repo integration test
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
// Don't initialize jj repo
|
||||||
|
|
||||||
|
// Create executor with the temp directory (which is not a jj repo)
|
||||||
|
let executor = JjLib::with_working_dir(temp_dir.path());
|
||||||
|
let workflow = CommitWorkflow::new(executor);
|
||||||
|
|
||||||
|
let result = workflow.run().await;
|
||||||
|
|
||||||
|
// Should fail with NotARepository error
|
||||||
|
assert!(matches!(result, Err(Error::NotARepository)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-utils")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cancellation() {
|
||||||
|
// T039: Cancellation integration test
|
||||||
|
// This is tricky to test directly without a TTY
|
||||||
|
// We'll test the error handling path instead
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
init_jj_repo(&temp_dir);
|
||||||
|
|
||||||
|
// Create a mock executor that simulates cancellation
|
||||||
|
struct CancelMock;
|
||||||
|
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl jj_cz::JjExecutor for CancelMock {
|
||||||
|
async fn is_repository(&self) -> Result<bool, Error> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn describe(&self, _message: &str) -> Result<(), Error> {
|
||||||
|
Err(Error::Cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let executor = CancelMock;
|
||||||
|
let mock_prompts = MockPrompts::new()
|
||||||
|
.with_commit_type(CommitType::Feat)
|
||||||
|
.with_scope(Scope::empty())
|
||||||
|
.with_description(Description::parse("test").unwrap())
|
||||||
|
.with_confirm(true);
|
||||||
|
let workflow = CommitWorkflow::with_prompts(executor, mock_prompts);
|
||||||
|
|
||||||
|
let result = workflow.run().await;
|
||||||
|
|
||||||
|
// Should fail with Cancelled error
|
||||||
|
assert!(matches!(result, Err(Error::Cancelled)));
|
||||||
|
}
|
||||||
135
tests/error_tests.rs
Normal file
135
tests/error_tests.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Comprehensive tests for error handling
|
||||||
|
//!
|
||||||
|
//! These tests ensure all error variants are properly handled
|
||||||
|
//! and that error conversions work correctly.
|
||||||
|
|
||||||
|
use jj_cz::{CommitMessageError, DescriptionError, Error, ScopeError};
|
||||||
|
|
||||||
|
/// Test that all error variants can be created and displayed
|
||||||
|
#[test]
|
||||||
|
fn test_all_error_variants() {
|
||||||
|
// Domain errors
|
||||||
|
let invalid_scope = Error::InvalidScope("test".to_string());
|
||||||
|
let _invalid_desc = Error::InvalidDescription("test".to_string());
|
||||||
|
let _invalid_msg = Error::InvalidCommitMessage("test".to_string());
|
||||||
|
|
||||||
|
// Infrastructure errors
|
||||||
|
let not_repo = Error::NotARepository;
|
||||||
|
let _jj_op = Error::JjOperation {
|
||||||
|
context: "test".to_string(),
|
||||||
|
};
|
||||||
|
let _repo_locked = Error::RepositoryLocked;
|
||||||
|
let _failed_dir = Error::FailedGettingCurrentDir;
|
||||||
|
let _failed_config = Error::FailedReadingConfig;
|
||||||
|
|
||||||
|
// Application errors
|
||||||
|
let cancelled = Error::Cancelled;
|
||||||
|
let _non_interactive = Error::NonInteractive;
|
||||||
|
|
||||||
|
// Verify all variants can be displayed
|
||||||
|
assert_eq!(format!("{}", invalid_scope), "Invalid scope: test");
|
||||||
|
assert_eq!(format!("{}", not_repo), "Not a Jujutsu repository");
|
||||||
|
assert_eq!(format!("{}", cancelled), "Operation cancelled by user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error conversions from domain types
|
||||||
|
#[test]
|
||||||
|
fn test_error_conversions() {
|
||||||
|
// ScopeError -> Error::InvalidScope
|
||||||
|
let scope_err = ScopeError::TooLong {
|
||||||
|
actual: 31,
|
||||||
|
max: 30,
|
||||||
|
};
|
||||||
|
let error: Error = scope_err.into();
|
||||||
|
assert!(matches!(error, Error::InvalidScope(_)));
|
||||||
|
|
||||||
|
// DescriptionError -> Error::InvalidDescription
|
||||||
|
let desc_err = DescriptionError::Empty;
|
||||||
|
let error: Error = desc_err.into();
|
||||||
|
assert!(matches!(error, Error::InvalidDescription(_)));
|
||||||
|
|
||||||
|
// CommitMessageError -> Error::InvalidCommitMessage
|
||||||
|
let msg_err = CommitMessageError::FirstLineTooLong {
|
||||||
|
actual: 73,
|
||||||
|
max: 72,
|
||||||
|
};
|
||||||
|
let error: Error = msg_err.into();
|
||||||
|
assert!(matches!(error, Error::InvalidCommitMessage(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error equality and partial equality
|
||||||
|
#[test]
|
||||||
|
fn test_error_equality() {
|
||||||
|
let err1 = Error::NotARepository;
|
||||||
|
let err2 = Error::NotARepository;
|
||||||
|
assert_eq!(err1, err2);
|
||||||
|
|
||||||
|
let err3 = Error::Cancelled;
|
||||||
|
assert_ne!(err1, err3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error debugging
|
||||||
|
#[test]
|
||||||
|
fn test_error_debug() {
|
||||||
|
let error = Error::JjOperation {
|
||||||
|
context: "test operation".to_string(),
|
||||||
|
};
|
||||||
|
let debug_str = format!("{:?}", error);
|
||||||
|
assert!(debug_str.contains("JjOperation"));
|
||||||
|
assert!(debug_str.contains("test operation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error cloning
|
||||||
|
#[test]
|
||||||
|
fn test_error_clone() {
|
||||||
|
let original = Error::JjOperation {
|
||||||
|
context: "original".to_string(),
|
||||||
|
};
|
||||||
|
let cloned = original.clone();
|
||||||
|
assert_eq!(original, cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error send and sync traits
|
||||||
|
#[test]
|
||||||
|
fn test_error_send_sync() {
|
||||||
|
fn assert_send<T: Send>() {}
|
||||||
|
fn assert_sync<T: Sync>() {}
|
||||||
|
|
||||||
|
let _error = Error::NotARepository;
|
||||||
|
assert_send::<Error>();
|
||||||
|
assert_sync::<Error>();
|
||||||
|
|
||||||
|
// Test with owned data
|
||||||
|
let _owned_error = Error::JjOperation {
|
||||||
|
context: "test".to_string(),
|
||||||
|
};
|
||||||
|
assert_send::<Error>();
|
||||||
|
assert_sync::<Error>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error matching patterns
|
||||||
|
#[test]
|
||||||
|
fn test_error_matching() {
|
||||||
|
let error = Error::Cancelled;
|
||||||
|
|
||||||
|
match error {
|
||||||
|
Error::Cancelled => {}
|
||||||
|
Error::NotARepository => panic!("Should not match"),
|
||||||
|
Error::JjOperation { context } => panic!("Should not match: {}", context),
|
||||||
|
_ => panic!("Should not match other variants"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test error context extraction
|
||||||
|
#[test]
|
||||||
|
fn test_jj_operation_context() {
|
||||||
|
let error = Error::JjOperation {
|
||||||
|
context: "repository locked".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Error::JjOperation { context } = error {
|
||||||
|
assert_eq!(context, "repository locked");
|
||||||
|
} else {
|
||||||
|
panic!("Expected JjOperation variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user