There’s a bug in our program.
If we have a line that is just a hashtag and a space after it: (#
), then we should get None
for the title, but we don’t, we get Some("")
.
Add this new test
#[test]
fn title_from_content_no_title() {
assert_eq!(title_from_content("# "), None);
}
and run it to see it fail.
❯ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.03s
Running unittests src/lib.rs (target/debug/deps/garden-531b700807a0ce1b)
running 3 tests
test tests::title_from_content_string ... ok
test tests::title_from_empty_string ... ok
test tests::title_from_content_no_title ... FAILED
failures:
---- tests::title_from_content_no_title stdout ----
thread 'tests::title_from_content_no_title' panicked at 'assertion failed: `(left == right)`
left: `Some("")`,
right: `None`', src/lib.rs:146:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::title_from_content_no_title
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
If you have cargo-watch installed, you can run this command to re-run just this test over and over while we write new code:
cargo watch -x "test title_from_content_no_title"
Looking at our code, we have the input from the file, which we iterate over using .lines()
. Each line is then run through find
until we find one that starts with the hash-space string. When we find one that satisfies this condition, it gets returned as an Option
that we can map over and remove the starting string from.
input.lines().find(|v| v.starts_with("# ")).map(
|line| line.trim_start_matches("# ").to_string(),
)
This is a really good opportunity to learn about the functions that more or less combine other functions.
We’re running .find
here and then .map
, but we actually don’t care about the whole content. To use .find
in the way we’re using it here, we’d have to add another test. Something like v.len() != 2
.
input
.lines()
.find(|v| v.starts_with("# ") && v.len() != 2)
.map(|line| {
line.trim_start_matches("# ").to_string()
})
and while this would work, there’s there’s another function called .find_map
that lets us find a value and change what value is going to be returned at the same time.
Now instead of testing to see if the start of the line matches our hash-space, we can try to chop it off right away and see if that worked.
.strip_prefix
will return Some
if the prefix was chopped off and None
if it didn’t exist.
Since find_map
uses an Option
instead of a bool
to know if the item is the right one to “find”, this Option
from strip_prefix
fits perfectly.
fn title_from_content(input: &str) -> Option<String> {
input.lines().find_map(|line| {
line.strip_prefix("# ").and_then(|title| {
if title.is_empty() {
None
} else {
Some(title.to_string())
}
})
})
}
Option::and_then
is a super useful function for us that allows us to continue processing if there’s a value (that is, if strip_prefix
returned Some
), or early-return None
if strip_prefix
was None
.
Once we have the “rest of the line”, which I’ve named title
, we can test to see if title
is empty. This is the same as testing to see if title == ""
.
If its empty, then return None
, otherwise return the title
as a String
wrapped in Some
.
This all rolls back up into the find_map
, which will “find” any Some
value we return, and pass by any None
s.
and then_some
using an if expression is fine, but writing out the extra None
is a bit noisier than we need to be.
then_some
is a function on boolean values that will return the value we give it if the boolean is true, and return None
if its false.
fn title_from_content(input: &str) -> Option<String> {
input.lines().find_map(|line| {
line.strip_prefix("# ").and_then(|title| {
(!title.is_empty()).then_some(title.to_string())
})
})
}
The extra parentheses are necessary for that !
operator to apply to is_empty()
though.
We can fix that by applying the !
operator inline alongside our other function calls though.
The std::ops::Not
trait offers us the not
function, which does exactly the same thing as !
, but doesn’t require special syntax.
fn title_from_content(input: &str) -> Option<String> {
input.lines().find_map(|line| {
line.strip_prefix("# ").and_then(|title| {
title
.is_empty()
.not()
.then_some(title.to_string())
})
})
}
Don’t forget to bring Not
into scope to get access to the functions associated with this trait!
use std::ops::Not;
and now all our tests are passing and we’ve fixed a bug!
❯ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.03s
Running unittests src/lib.rs (target/debug/deps/garden-531b700807a0ce1b)
running 3 tests
test tests::title_from_content_no_title ... ok
test tests::title_from_content_string ... ok
test tests::title_from_empty_string ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/garden-dd0d368687185699)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests garden
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s