summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTim Oram <dev@mitmaro.ca>2024-02-12 20:58:05 -0330
committerTim Oram <dev@mitmaro.ca>2024-02-15 20:27:06 -0330
commit20fabc4de6baea9a8a07378f4c15f9f14a63dc54 (patch)
tree41419096dd8dd6c3ab97190bbdd722aae803ed4c
parent85fc458214aeac22ae2c38478703eac64b946544 (diff)
Move render testutils to test_helpers
-rw-r--r--src/components/choice/tests.rs3
-rw-r--r--src/components/confirm/tests.rs3
-rw-r--r--src/components/edit/tests.rs2
-rw-r--r--src/components/shared/editable_line.rs3
-rw-r--r--src/modules/confirm_abort.rs2
-rw-r--r--src/modules/list/tests/search.rs2
-rw-r--r--src/modules/list/tests/toggle_break.rs1
-rw-r--r--src/modules/list/tests/visual_mode.rs2
-rw-r--r--src/modules/show_commit/tests.rs3
-rw-r--r--src/test_helpers.rs1
-rw-r--r--src/test_helpers/assertions.rs1
-rw-r--r--src/test_helpers/assertions/assert_rendered_output.rs330
-rw-r--r--src/test_helpers/assertions/assert_rendered_output/patterns.rs396
-rw-r--r--src/test_helpers/assertions/assert_rendered_output/render_style.rs43
-rw-r--r--src/test_helpers/assertions/assert_rendered_output/render_view_data.rs52
-rw-r--r--src/test_helpers/assertions/assert_rendered_output/render_view_line.rs45
-rw-r--r--src/test_helpers/shared.rs10
-rw-r--r--src/test_helpers/shared/replace_invisibles.rs16
-rw-r--r--src/testutil.rs2
-rw-r--r--src/testutil/action_line.rs223
-rw-r--r--src/testutil/module_test.rs2
-rw-r--r--src/testutil/process_test.rs2
-rw-r--r--src/view/render_slice/tests.rs8
-rw-r--r--src/view/testutil.rs27
-rw-r--r--src/view/testutil/assert_rendered_output.rs447
-rw-r--r--src/view/testutil/render_view_line.rs152
-rw-r--r--src/view/thread/state.rs18
27 files changed, 927 insertions, 869 deletions
diff --git a/src/components/choice/tests.rs b/src/components/choice/tests.rs
index 94689b4..1c54b9c 100644
--- a/src/components/choice/tests.rs
+++ b/src/components/choice/tests.rs
@@ -4,7 +4,8 @@ use super::*;
use crate::{
assert_rendered_output,
input::StandardEvent,
- view::testutil::{with_view_state, AssertRenderOptions},
+ test_helpers::assertions::assert_rendered_output::AssertRenderOptions,
+ view::testutil::with_view_state,
};
#[derive(Clone, Debug, PartialEq)]
diff --git a/src/components/confirm/tests.rs b/src/components/confirm/tests.rs
index 609d2e2..d197d80 100644
--- a/src/components/confirm/tests.rs
+++ b/src/components/confirm/tests.rs
@@ -4,8 +4,7 @@ use super::*;
use crate::{
assert_rendered_output,
input::StandardEvent,
- test_helpers::create_test_keybindings,
- view::testutil::AssertRenderOptions,
+ test_helpers::{assertions::assert_rendered_output::AssertRenderOptions, create_test_keybindings},
};
#[test]
diff --git a/src/components/edit/tests.rs b/src/components/edit/tests.rs
index 3991a38..c999f1f 100644
--- a/src/components/edit/tests.rs
+++ b/src/components/edit/tests.rs
@@ -1,5 +1,5 @@
use super::*;
-use crate::{assert_rendered_output, view::testutil::AssertRenderOptions};
+use crate::{assert_rendered_output, test_helpers::assertions::assert_rendered_output::AssertRenderOptions};
#[test]
fn with_before_and_after_build() {
diff --git a/src/components/shared/editable_line.rs b/src/components/shared/editable_line.rs
index 0001879..08088fb 100644
--- a/src/components/shared/editable_line.rs
+++ b/src/components/shared/editable_line.rs
@@ -209,7 +209,8 @@ mod tests {
use super::*;
use crate::{
assert_rendered_output,
- view::{testutil::AssertRenderOptions, ViewData, ViewLine},
+ test_helpers::assertions::assert_rendered_output::AssertRenderOptions,
+ view::{ViewData, ViewLine},
};
macro_rules! view_data_from_editable_line {
diff --git a/src/modules/confirm_abort.rs b/src/modules/confirm_abort.rs
index 32c1b40..5666b3f 100644
--- a/src/modules/confirm_abort.rs
+++ b/src/modules/confirm_abort.rs
@@ -63,8 +63,8 @@ mod tests {
assert_results,
input::{KeyCode, StandardEvent},
process::Artifact,
+ test_helpers::assertions::assert_rendered_output::AssertRenderOptions,
testutil::module_test,
- view::testutil::AssertRenderOptions,
};
fn create_confirm_abort(todo_file: TodoFile) -> ConfirmAbort {
diff --git a/src/modules/list/tests/search.rs b/src/modules/list/tests/search.rs
index 5ba6446..1a33d61 100644
--- a/src/modules/list/tests/search.rs
+++ b/src/modules/list/tests/search.rs
@@ -5,8 +5,8 @@ use crate::{
assert_results,
process::Artifact,
render_line,
+ test_helpers::assertions::assert_rendered_output::AssertRenderOptions,
testutil::module_test,
- view::testutil::AssertRenderOptions,
};
#[test]
diff --git a/src/modules/list/tests/toggle_break.rs b/src/modules/list/tests/toggle_break.rs
index 196fd19..601dbb7 100644
--- a/src/modules/list/tests/toggle_break.rs
+++ b/src/modules/list/tests/toggle_break.rs
@@ -4,7 +4,6 @@ use crate::{
assert_rendered_output,
testutil::module_test,
todo_file::{Action::Pick, ParseError},
- view::testutil::LinePattern,
};
#[test]
diff --git a/src/modules/list/tests/visual_mode.rs b/src/modules/list/tests/visual_mode.rs
index f51b3d2..f74c733 100644
--- a/src/modules/list/tests/visual_mode.rs
+++ b/src/modules/list/tests/visual_mode.rs
@@ -6,8 +6,8 @@ use crate::{
input::KeyCode,
process::Artifact,
render_line,
+ test_helpers::assertions::assert_rendered_output::AssertRenderOptions,
testutil::module_test,
- view::testutil::AssertRenderOptions,
};
fn render_options() -> AssertRenderOptions {
diff --git a/src/modules/show_commit/tests.rs b/src/modules/show_commit/tests.rs
index f370bc6..4ef4c0e 100644
--- a/src/modules/show_commit/tests.rs
+++ b/src/modules/show_commit/tests.rs
@@ -10,11 +10,12 @@ use crate::{
process::Artifact,
render_line,
test_helpers::{
+ assertions::assert_rendered_output::AssertRenderOptions,
builders::{CommitBuilder, CommitDiffBuilder, FileStatusBuilder},
with_temp_repository,
},
testutil::module_test,
- view::{testutil::AssertRenderOptions, ViewLine},
+ view::ViewLine,
};
fn create_show_commit(config: &Config, repository: Repository, todo_file: TodoFile) -> ShowCommit {
diff --git a/src/test_helpers.rs b/src/test_helpers.rs
index 1bebb48..f5b57d7 100644
--- a/src/test_helpers.rs
+++ b/src/test_helpers.rs
@@ -1,3 +1,4 @@
+pub(crate) mod assertions;
pub(crate) mod builders;
mod create_commit;
mod create_event_reader;
diff --git a/src/test_helpers/assertions.rs b/src/test_helpers/assertions.rs
new file mode 100644
index 0000000..afd8845
--- /dev/null
+++ b/src/test_helpers/assertions.rs
@@ -0,0 +1 @@
+pub(crate) mod assert_rendered_output;
diff --git a/src/test_helpers/assertions/assert_rendered_output.rs b/src/test_helpers/assertions/assert_rendered_output.rs
new file mode 100644
index 0000000..9230932
--- /dev/null
+++ b/src/test_helpers/assertions/assert_rendered_output.rs
@@ -0,0 +1,330 @@
+mod patterns;
+mod render_style;
+mod render_view_data;
+mod render_view_line;
+
+use std::fmt::{Debug, Formatter};
+
+use bitflags::bitflags;
+use itertools::Itertools;
+
+pub(crate) use self::{
+ patterns::{
+ ActionPattern,
+ AllPattern,
+ AnyLinePattern,
+ AnyPattern,
+ ContainsPattern,
+ EndsWithPattern,
+ ExactPattern,
+ LinePattern,
+ NotPattern,
+ StartsWithPattern,
+ },
+ render_style::render_style,
+ render_view_data::render_view_data,
+ render_view_line::render_view_line,
+};
+use crate::{
+ test_helpers::shared::replace_invisibles,
+ view::{ViewData, ViewLine},
+};
+
+bitflags! {
+ /// Options for the `assert_rendered_output!` macro
+ #[derive(Default, PartialEq, Eq, Debug, Clone, Copy)]
+ pub(crate) struct AssertRenderOptions: u8 {
+ /// Ignore trailing whitespace
+ const INCLUDE_TRAILING_WHITESPACE = 0b0000_0001;
+ /// Ignore pinned indicator
+ const INCLUDE_PINNED = 0b0000_0010;
+ /// Don't include style information
+ const INCLUDE_STYLE = 0b0000_0100;
+ /// Only render the body, in this mode {BODY} is also not rendered
+ const BODY_ONLY = 0b0000_1000;
+ }
+}
+
+#[allow(clippy::string_slice, clippy::panic)]
+pub(crate) fn _assert_rendered_output(
+ options: AssertRenderOptions,
+ actual: &[String],
+ expected_patterns: &[Box<dyn LinePattern>],
+) {
+ let mut mismatch = false;
+ let mut error_output = vec![
+ String::from("\nUnexpected output!"),
+ String::from("--- Expected"),
+ String::from("+++ Actual"),
+ String::from("=========="),
+ ];
+
+ for (expected_pattern, output_line) in expected_patterns.iter().zip(actual.iter()) {
+ let output = if options.contains(AssertRenderOptions::INCLUDE_TRAILING_WHITESPACE) {
+ output_line.as_str()
+ }
+ else {
+ output_line.trim_end()
+ };
+
+ if expected_pattern.matches(output) {
+ error_output.push(format!(" {}", expected_pattern.expected()));
+ }
+ else {
+ mismatch = true;
+ error_output.push(format!("-{}", expected_pattern.expected()));
+ error_output.push(format!("+{}", expected_pattern.actual(output)));
+ }
+ }
+
+ match expected_patterns.len() {
+ a if a > actual.len() => {
+ mismatch = true;
+ for expected_pattern in expected_patterns.iter().skip(actual.len()) {
+ error_output.push(format!("-{}", expected_pattern.expected().as_str()));
+ }
+ },
+ a if a < actual.len() => {
+ mismatch = true;
+ for line in actual.iter().skip(expected_patterns.len()) {
+ error_output.push(format!("+{}", replace_invisibles(line)));
+ }
+ },
+ _ => {},
+ }
+
+ if mismatch {
+ error_output.push(String::from("==========\n"));
+ panic!("{}", error_output.join("\n"));
+ }
+}
+
+/// Assert the rendered output from a `ViewData`. Generally this function is not used directly,
+/// instead use the `assert_rendered_output!` macro.
+pub(crate) fn _assert_rendered_output_from_view_data(
+ view_data: &ViewData,
+ expected: &[Box<dyn LinePattern>],
+ options: AssertRenderOptions,
+ skip_start: Option<usize>,
+ skip_end: Option<usize>,
+) {
+ let rendered = render_view_data(view_data, options);
+ let mut length = rendered.len();
+ let mut output_iter: Box<dyn Iterator<Item = String>> = Box::new(rendered.into_iter());
+
+ if let Some(skip) = skip_start {
+ length = length.saturating_sub(skip);
+ output_iter = Box::new(output_iter.skip(skip));
+ }
+
+ if let Some(skip) = skip_end {
+ output_iter = Box::new(output_iter.take(length.saturating_sub(skip)));
+ }
+
+ _assert_rendered_output(options, &output_iter.collect::<Vec<String>>(), expected);
+}
+
+/// Create an assertion on a line. For use in `assert_rendered_output` macro.
+#[macro_export]
+macro_rules! render_line {
+ ($line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::ExactPattern::new($line)
+ }};
+ (Line) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::AnyLinePattern::new()
+ }};
+ (StartsWith $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::StartsWithPattern::new($line)
+ }};
+ (Not StartsWith $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::NotPattern::new(
+ Box::new(
+ $crate::test_helpers::assertions::assert_rendered_output::StartsWithPattern::new($line)
+ )
+ )
+ }};
+ (EndsWith $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::EndsWithPattern::new($line)
+ }};
+ (Not EndsWith $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::NotPattern::new(
+ Box::new($crate::test_helpers::assertions::assert_rendered_output::EndsWithPattern::new($line))
+ )
+ }};
+ (Contains $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::ContainsPattern::new($line)
+ }};
+ (Not Contains $line:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::NotPattern::new(
+ Box::new(
+ $crate::test_helpers::assertions::assert_rendered_output::ContainsPattern::new($line)
+ )
+ )
+ }};
+ (Not $pattern:expr) => {{
+ $crate::test_helpers::assertions::assert_rendered_output::NotPattern::new(Box::new($pattern))
+ }};
+ (All $($patterns:expr),*) => {{
+ let patterns: Vec<Box<dyn LinePattern>> = vec![$( Box::new($patterns), )*];
+ $crate::test_helpers::assertions::assert_rendered_output::AllPattern::new(patterns)
+ }};
+ (Any $($patterns:expr),*) => {{
+ let patterns: Vec<Box<dyn LinePattern>> = vec![$( Box::new($patterns), )*];
+ $crate::test_helpers::assertions::assert_rendered_output::AnyPattern::new(patterns)
+ }};
+}
+
+#[macro_export]
+macro_rules! action_line {
+ (Break) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_break(false)
+ }};
+ (Selected Break) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_break(true)
+ }};
+ (Drop $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_drop($hash, $comment, false)
+ }};
+ (Selected Drop $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_drop($hash, $comment, true)
+ }};
+ (Edit $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_edit($hash, $comment, false)
+ }};
+ (Selected Edit $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_edit($hash, $comment, true)
+ }};
+ (Fixup $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_fixup($hash, $comment, false)
+ }};
+ (Selected Fixup $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_fixup($hash, $comment, true)
+ }};
+ (Pick $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_pick($hash, $comment, false)
+ }};
+ (Selected Pick $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_pick($hash, $comment, true)
+ }};
+ (Reword $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_reword($hash, $comment, false)
+ }};
+ (Selected Reword $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_reword($hash, $comment, true)
+ }};
+ (Squash $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_squash($hash, $comment, false)
+ }};
+ (Selected Squash $hash:expr, $comment:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_squash($hash, $comment, true)
+ }};
+ (Exec $command:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_exec($command, false)
+ }};
+ (Selected Exec $command:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_exec($command, true)
+ }};
+ (Label $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_label($reference, false)
+ }};
+ (Selected Label $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_label($reference, true)
+ }};
+ (Reset $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_reset($reference, false)
+ }};
+ (Selected Reset $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_reset($reference, true)
+ }};
+ (Merge $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_merge($reference, false)
+ }};
+ (Selected Merge $reference:expr) => {{
+ use $crate::test_helpers::assertions::assert_rendered_output::ActionPattern;
+ ActionPattern::new_merge($reference, true)
+ }};
+}
+
+/// Assert the rendered output from a `ViewData`.
+#[macro_export]
+macro_rules! assert_rendered_output {
+ ($view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), None, None, $view_data, $($arg),*
+ )
+ };
+ (Body $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, None, None, $view_data, $($arg),*
+ )
+ };
+ (Style $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::INCLUDE_STYLE, None, None, $view_data, $($arg),*
+ )
+ };
+ (Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::default(), Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, None, None, $view_data, $($arg),*
+ )
+ };
+ (Body, Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Body, Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base AssertRenderOptions::BODY_ONLY, Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, Skip $start:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, Some($start), None, $view_data, $($arg),*
+ )
+ };
+ (Options $options:expr, Skip $start:expr;$end:expr, $view_data:expr, $($arg:expr),*) => {
+ assert_rendered_output!(
+ @base $options, Some($start), Some($end), $view_data, $($arg),*
+ )
+ };
+ (@base $options:expr, $start:expr, $end:expr, $view_data:expr, $($arg:expr),*) => {
+ use $crate::test_helpers::assertions::assert_rendered_output::{
+ _assert_rendered_output_from_view_data,
+ AssertRenderOptions,LinePattern
+ };
+ let expected: Vec<Box<dyn LinePattern>> = vec![$( Box::new($arg), )*];
+ _assert_rendered_output_from_view_data($view_data, &expected, $options, $start, $end);
+ };
+}
diff --git a/src/test_helpers/assertions/assert_rendered_output/patterns.rs b/src/test_helpers/assertions/assert_rendered_output/patterns.rs
new file mode 100644
index 0000000..2ad0d74
--- /dev/null
+++ b/src/test_helpers/assertions/assert_rendered_output/patterns.rs
@@ -0,0 +1,396 @@
+use std::fmt::{Debug, Formatter};
+
+use itertools::Itertools;
+use lazy_static::lazy_static;
+use regex::Regex;
+
+use crate::{
+ test_helpers::shared::replace_invisibles,
+ todo_file::{Line, ParseError},
+};
+
+/// A pattern matcher for a rendered line
+pub(crate) trait LinePattern: Debug {
+ /// Check if the rendered line matches the matchers pattern
+ fn matches(&self, rendered: &str) -> bool;
+
+ /// A formatted expected value for the matcher
+ fn expected(&self) -> String;
+
+ /// A formatted actual value for the matcher
+ #[must_use]
+ fn actual(&self, rendered: &str) -> String {
+ replace_invisibles(rendered)
+ }
+
+ /// Does this matcher use styles for matching
+ fn use_styles(&self) -> bool {
+ true
+ }
+}
+
+impl LinePattern for String {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == self
+ }
+
+ fn expected(&self) -> String {
+ replace_invisibles(self.as_str())
+ }
+}
+
+impl LinePattern for &str {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == *self
+ }
+
+ fn expected(&self) -> String {
+ replace_invisibles(self)
+ }
+}
+
+/// A pattern matcher that will match any line
+#[derive(Debug, Copy, Clone)]
+#[non_exhaustive]
+pub(crate) struct AnyLinePattern;
+
+impl AnyLinePattern {
+ /// Create a new instance
+ #[must_use]
+ pub(crate) fn new() -> Self {
+ Self
+ }
+}
+
+impl LinePattern for AnyLinePattern {
+ fn matches(&self, _: &str) -> bool {
+ true
+ }
+
+ fn expected(&self) -> String {
+ String::from("{{Any}}")
+ }
+
+ fn actual(&self, _: &str) -> String {
+ String::from("{{Any}}")
+ }
+}
+
+/// A pattern matcher that matches that a rendered line is an exact match
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub(crate) struct ExactPattern(String);
+
+impl ExactPattern {
+ /// Create a new matcher against a line pattern
+ #[must_use]
+ pub(crate) fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for ExactPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered == self.0
+ }
+
+ fn expected(&self) -> String {
+ replace_invisibles(self.0.as_str())
+ }
+}
+
+/// A pattern that matches that a rendered line starts with a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub(crate) struct StartsWithPattern(String);
+
+impl StartsWithPattern {
+ /// Create a new matcher with a pattern
+ #[must_use]
+ pub(crate) fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for StartsWithPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.starts_with(self.0.as_str())
+ }
+
+ fn expected(&self) -> String {
+ format!("StartsWith {}", replace_invisibles(self.0.as_str()))
+ }
+
+ fn actual(&self, rendered: &str) -> String {
+ format!(
+ " {}",
+ replace_invisibles(rendered.chars().take(self.0.len()).collect::<String>().as_str())
+ )
+ }
+}
+
+/// A pattern that matches that a rendered line ends with a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub(crate) struct EndsWithPattern(String);
+
+impl EndsWithPattern {
+ /// Create a new matcher with a pattern
+ #[must_use]
+ pub(crate) fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+impl LinePattern for EndsWithPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.ends_with(self.0.as_str())
+ }
+
+ fn expected(&self) -> String {
+ format!("EndsWith {}", replace_invisibles(self.0.as_str()))
+ }
+
+ #[allow(clippy::string_slice)]
+ fn actual(&self, rendered: &str) -> String {
+ format!(
+ " {}",
+ replace_invisibles(&rendered[rendered.len() - self.0.len() + 2..])
+ )
+ }
+}
+
+/// A pattern that matches that a rendered line contains a pattern
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub(crate) struct ContainsPattern(String);
+
+impl ContainsPattern {
+ /// Create a new matcher with a pattern
+ #[must_use]
+ pub(crate) fn new(pattern: &str) -> Self {
+ Self(String::from(pattern))
+ }
+}
+
+/// A pattern that matches that a rendered line matches all patterns
+#[derive(Debug)]
+#[non_exhaustive]
+pub(crate) struct NotPattern(Box<dyn LinePattern>);
+
+impl LinePattern for ContainsPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ rendered.contains(self.0.as_str())
+ }
+
+ fn expected(&self) -> String {
+ format!("Contains {}", replace_invisibles(self.0.as_str()))
+ }
+
+ #[allow(clippy::string_slice)]
+ fn actual(&self, rendered: &str) -> String {
+ format!(" {}", replace_invisibles(rendered))
+ }
+}
+
+impl NotPattern {
+ /// Create a new matcher with a pattern
+ #[must_use]
+ pub(crate) fn new(pattern: Box<dyn LinePattern>) -> Self {
+ Self(pattern)
+ }
+}
+
+impl LinePattern for NotPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ !self.0.matches(rendered)
+ }
+
+ fn expected(&self) -> String {
+ format!("Not({})", self.0.expected())
+ }
+
+ fn actual(&self, rendered: &str) -> String {
+ format!("Not({})", self.0.actual(rendered))
+ }
+}
+
+/// A pattern that matches that a rendered line matches all of a set of patterns
+#[non_exhaustive]
+pub(crate) struct AllPattern(Vec<Box<dyn LinePattern>>);
+
+impl AllPattern {
+ /// Create a new matcher with patterns
+ #[must_use]
+ pub(crate) fn new(patterns: Vec<Box<dyn LinePattern>>) -> Self {
+ Self(patterns)
+ }
+}
+
+impl LinePattern for AllPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ self.0.iter().all(|pattern| pattern.matches(rendered))
+ }
+
+ fn expected(&self) -> String {
+ format!("All({})", self.0.iter().map(|p| { p.expected() }).join(", "))
+ }
+}
+
+impl Debug for AllPattern {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "All({})", self.0.iter().map(|p| format!("{p:?}")).join(", "))
+ }
+}
+
+/// A pattern that matches that a rendered line matches any of a set of patterns
+#[non_exhaustive]
+pub(crate) struct AnyPattern(Vec<Box<dyn LinePattern>>);
+
+impl AnyPattern {
+ /// Create a new matcher with patterns
+ #[must_use]
+ pub(crate) fn new(patterns: Vec<Box<dyn LinePattern>>) -> Self {
+ Self(patterns)
+ }
+}
+
+impl LinePattern for AnyPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ self.0.iter().any(|pattern| pattern.matches(rendered))
+ }
+
+ fn expected(&self) -> String {
+ format!("Any({})", self.0.iter().map(|p| { p.expected() }).join(", "))
+ }
+}
+
+impl Debug for AnyPattern {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "Any({})", self.0.iter().map(|p| format!("{p:?}")).join(", "))
+ }
+}
+
+lazy_static! {
+ pub(crate) static ref FORMAT_REGEX: Regex = Regex::new(r"\{.*?}").unwrap();
+}
+
+fn parse_rendered_action_line(rendered: &str) -> Result<Line, ParseError> {
+ let cleaned_line = FORMAT_REGEX.replace_all(rendered, "").replace(" > ", "");
+ Line::parse(cleaned_line.as_ref())
+}
+
+#[derive(Debug)]
+pub(crate) struct ActionPattern {
+ line: Line,
+ selected: bool,
+}
+
+impl ActionPattern {
+ fn new(line: &str, selected: bool) -> Self {
+ Self {
+ line: Line::parse(line).expect("Expected valid pick"),
+ selected,
+ }
+ }
+
+ pub(crate) fn new_break(selected: bool) -> Self {
+ Self::new("break", selected)
+ }
+
+ pub(crate) fn new_drop(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("drop {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_edit(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("edit {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_fixup(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("fixup {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_pick(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("pick {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_reword(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("reword {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_squash(hash: &str, comment: &str, selected: bool) -> Self {
+ Self::new(format!("squash {hash} {comment}").as_str(), selected)
+ }
+
+ pub(crate) fn new_exec(command: &str, selected: bool) -> Self {
+ Self::new(format!("exec {command}").as_str(), selected)
+ }
+
+ pub(crate) fn new_label(reference: &str, selected: bool) -> Self {
+ Self::new(format!("label {reference}").as_str(), selected)
+ }
+
+ pub(crate) fn new_reset(reference: &str, selected: bool) -> Self {
+ Self::new(format!("reset {reference}").as_str(), selected)
+ }
+
+ pub(crate) fn new_merge(reference: &str, selected: bool) -> Self {
+ Self::new(format!("merge {reference}").as_str(), selected)
+ }
+}
+
+impl LinePattern for ActionPattern {
+ fn matches(&self, rendered: &str) -> bool {
+ if rendered.contains("{Selected}") {
+ if !self.selected {
+ return false;
+ }
+ }
+ else if self.selected {
+ return false;
+ }