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 Nones.
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