initial commit
This commit is contained in:
6
helpers/common/Cargo.toml
Normal file
6
helpers/common/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
15
helpers/common/src/lib.rs
Normal file
15
helpers/common/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub fn overly_long_description() -> String {
|
||||
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.".into()
|
||||
}
|
||||
|
||||
pub fn overly_long_title() -> String {
|
||||
"A title that's definitely longer than what should be allowed in a development ticket".into()
|
||||
}
|
||||
|
||||
pub fn valid_title() -> String {
|
||||
"A title".into()
|
||||
}
|
||||
|
||||
pub fn valid_description() -> String {
|
||||
"A description".into()
|
||||
}
|
||||
12
helpers/json2redirects.sh
Executable file
12
helpers/json2redirects.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ensure the JSON file is provided as an argument
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "Usage: $0 <input_json_file>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
input_file=$1
|
||||
|
||||
# Use jq to parse the JSON and format the output
|
||||
jq -r 'to_entries[] | "/" + .value + " " + .key' "$input_file"
|
||||
11
helpers/mdbook-exercise-linker/Cargo.toml
Normal file
11
helpers/mdbook-exercise-linker/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "mdbook-exercise-linker"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
clap = "4.5.4"
|
||||
mdbook = "0.4.40"
|
||||
semver = "1.0.23"
|
||||
serde_json = "1.0.117"
|
||||
75
helpers/mdbook-exercise-linker/src/lib.rs
Normal file
75
helpers/mdbook-exercise-linker/src/lib.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use anyhow::{Context, Error};
|
||||
use mdbook::book::Book;
|
||||
use mdbook::preprocess::{Preprocessor, PreprocessorContext};
|
||||
use mdbook::BookItem;
|
||||
|
||||
pub struct ExerciseLinker;
|
||||
|
||||
impl ExerciseLinker {
|
||||
pub fn new() -> ExerciseLinker {
|
||||
ExerciseLinker
|
||||
}
|
||||
}
|
||||
|
||||
impl Preprocessor for ExerciseLinker {
|
||||
fn name(&self) -> &str {
|
||||
"exercise-linker"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
|
||||
let config = ctx
|
||||
.config
|
||||
.get_preprocessor(self.name())
|
||||
.context("Failed to get preprocessor configuration")?;
|
||||
let key = String::from("exercise_root_url");
|
||||
let root_url = config
|
||||
.get(&key)
|
||||
.context("Failed to get `exercise_root_url`")?;
|
||||
let root_url = root_url
|
||||
.as_str()
|
||||
.context("`exercise_root_url` is not a string")?
|
||||
.to_owned();
|
||||
|
||||
book.sections
|
||||
.iter_mut()
|
||||
.for_each(|i| process_book_item(i, &ctx.renderer, &root_url));
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
fn supports_renderer(&self, _renderer: &str) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn process_book_item(item: &mut BookItem, renderer: &str, root_url: &str) {
|
||||
match item {
|
||||
BookItem::Chapter(chapter) => {
|
||||
chapter.sub_items.iter_mut().for_each(|item| {
|
||||
process_book_item(item, renderer, root_url);
|
||||
});
|
||||
|
||||
let Some(source_path) = &chapter.source_path else {
|
||||
return;
|
||||
};
|
||||
let source_path = source_path.display().to_string();
|
||||
|
||||
// Ignore non-exercise chapters
|
||||
if !source_path.chars().take(2).all(|c| c.is_digit(10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let exercise_path = source_path.strip_suffix(".md").unwrap();
|
||||
let link_section = format!(
|
||||
"\n## Exercise\n\nThe exercise for this section is located in [`{exercise_path}`]({})\n",
|
||||
format!("{}/{}", root_url, exercise_path)
|
||||
);
|
||||
chapter.content.push_str(&link_section);
|
||||
|
||||
if renderer == "pandoc" {
|
||||
chapter.content.push_str("`\\newpage`{=latex}\n");
|
||||
}
|
||||
}
|
||||
BookItem::Separator => {}
|
||||
BookItem::PartTitle(_) => {}
|
||||
}
|
||||
}
|
||||
67
helpers/mdbook-exercise-linker/src/main.rs
Normal file
67
helpers/mdbook-exercise-linker/src/main.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::io;
|
||||
use std::process;
|
||||
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use mdbook::errors::Error;
|
||||
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
|
||||
use semver::{Version, VersionReq};
|
||||
|
||||
use mdbook_exercise_linker::ExerciseLinker;
|
||||
|
||||
pub fn make_app() -> Command {
|
||||
Command::new("exercise-linker").subcommand(
|
||||
Command::new("supports")
|
||||
.arg(Arg::new("renderer").required(true))
|
||||
.about("Check whether a renderer is supported by this preprocessor"),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let matches = make_app().get_matches();
|
||||
|
||||
// Users will want to construct their own preprocessor here
|
||||
let preprocessor = ExerciseLinker::new();
|
||||
|
||||
if let Some(sub_args) = matches.subcommand_matches("supports") {
|
||||
handle_supports(&preprocessor, sub_args);
|
||||
} else if let Err(e) = handle_preprocessing(&preprocessor) {
|
||||
eprintln!("{}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
|
||||
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
|
||||
|
||||
let book_version = Version::parse(&ctx.mdbook_version)?;
|
||||
let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?;
|
||||
|
||||
if !version_req.matches(&book_version) {
|
||||
eprintln!(
|
||||
"Warning: The {} plugin was built against version {} of mdbook, \
|
||||
but we're being called from version {}",
|
||||
pre.name(),
|
||||
mdbook::MDBOOK_VERSION,
|
||||
ctx.mdbook_version
|
||||
);
|
||||
}
|
||||
|
||||
let processed_book = pre.run(&ctx, book)?;
|
||||
serde_json::to_writer(io::stdout(), &processed_book)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_supports(pre: &dyn Preprocessor, sub_args: &ArgMatches) -> ! {
|
||||
let renderer = sub_args
|
||||
.get_one::<String>("renderer")
|
||||
.expect("Required argument");
|
||||
let supported = pre.supports_renderer(renderer);
|
||||
|
||||
// Signal whether the renderer is supported by exiting with 1 or 0.
|
||||
if supported {
|
||||
process::exit(0);
|
||||
} else {
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
15
helpers/mdbook-link-shortener/Cargo.toml
Normal file
15
helpers/mdbook-link-shortener/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "mdbook-link-shortener"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
bimap = { version = "0.6.3", features = ["serde"] }
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
itertools = "0.13.0"
|
||||
mdbook = "0.4.40"
|
||||
pulldown-cmark = "0.11.0"
|
||||
pulldown-cmark-to-cmark = "15"
|
||||
semver = "1.0.23"
|
||||
serde_json = "1.0.117"
|
||||
224
helpers/mdbook-link-shortener/src/lib.rs
Normal file
224
helpers/mdbook-link-shortener/src/lib.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use anyhow::{Context, Error};
|
||||
use bimap::BiHashMap;
|
||||
use itertools::Itertools;
|
||||
use mdbook::book::{Book, Chapter};
|
||||
use mdbook::preprocess::{Preprocessor, PreprocessorContext};
|
||||
use mdbook::BookItem;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub struct LinkShortener;
|
||||
|
||||
struct AliasGenerator {
|
||||
cursors: [usize; 3],
|
||||
}
|
||||
|
||||
impl AliasGenerator {
|
||||
const ALPHABET: &'static [u8] = b"f2z4x6v8bnm3q5w7e9rtyuplkshgjdca";
|
||||
|
||||
fn new() -> AliasGenerator {
|
||||
AliasGenerator { cursors: [0, 0, 0] }
|
||||
}
|
||||
|
||||
/// Generate a 4 alphanumeric long alias, starting from "aaaa" and incrementing by one each time
|
||||
/// until "9999", using only lowercase letters and numbers.
|
||||
/// We skip ambiguous characters like "0", "o", "1", "l".
|
||||
fn next(&mut self) -> String {
|
||||
let mut alias = String::with_capacity(4);
|
||||
for cursor in &mut self.cursors {
|
||||
alias.push(Self::ALPHABET[*cursor] as char);
|
||||
}
|
||||
|
||||
for cursor in self.cursors.iter_mut().rev() {
|
||||
if *cursor == Self::ALPHABET.len() - 1 {
|
||||
*cursor = 0;
|
||||
} else {
|
||||
*cursor += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
alias
|
||||
}
|
||||
|
||||
/// Generate a unique alias that is not already used by the `link2alias` map.
|
||||
fn next_until_unique(&mut self, link2alias: &BiHashMap<String, String>) -> String {
|
||||
let mut alias = self.next();
|
||||
while link2alias.contains_right(&alias) {
|
||||
alias = self.next();
|
||||
}
|
||||
alias
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkShortener {
|
||||
pub fn new() -> LinkShortener {
|
||||
LinkShortener
|
||||
}
|
||||
}
|
||||
|
||||
impl Preprocessor for LinkShortener {
|
||||
fn name(&self) -> &str {
|
||||
"link-shortener"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
|
||||
let config = ctx
|
||||
.config
|
||||
.get_preprocessor(self.name())
|
||||
.context("Failed to get preprocessor configuration")?;
|
||||
let root_url = {
|
||||
let root_url = config.get("base_url").context("Failed to get `base_url`")?;
|
||||
root_url
|
||||
.as_str()
|
||||
.context("`base_url` is not a string")?
|
||||
.to_owned()
|
||||
};
|
||||
let mapping = {
|
||||
let mapping = config.get("mapping").context("Failed to get `mapping`")?;
|
||||
let mapping = mapping
|
||||
.as_str()
|
||||
.context("`mapping` is not a string")?
|
||||
.to_owned();
|
||||
PathBuf::from_str(&mapping).context("Failed to parse `mapping` as a path")?
|
||||
};
|
||||
let mut link2alias = {
|
||||
match File::open(&mapping) {
|
||||
Ok(file) => {
|
||||
serde_json::from_reader(file).context("Failed to parse existing mapping")?
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
BiHashMap::new()
|
||||
} else {
|
||||
return Err(e).context("Failed to open existing mapping");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let verify = config
|
||||
.get("verify")
|
||||
.context("Failed to get `verify`")?
|
||||
.as_bool()
|
||||
.context("`verify` is not a boolean")?;
|
||||
// Env var overrides config
|
||||
let verify = std::env::var("LINK_SHORTENER_VERIFY")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(verify);
|
||||
|
||||
let mut alias_gen = AliasGenerator::new();
|
||||
|
||||
book.sections.iter_mut().for_each(|i| {
|
||||
if let BookItem::Chapter(c) = i {
|
||||
c.content = replace_anchors(c, &root_url, &mut alias_gen, &mut link2alias, verify)
|
||||
.expect("Error converting links for chapter");
|
||||
for i in c.sub_items.iter_mut() {
|
||||
if let BookItem::Chapter(sub_chapter) = i {
|
||||
sub_chapter.content = replace_anchors(
|
||||
sub_chapter,
|
||||
&root_url,
|
||||
&mut alias_gen,
|
||||
&mut link2alias,
|
||||
verify,
|
||||
)
|
||||
.expect("Error converting links for subchapter");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !verify {
|
||||
std::fs::create_dir_all(mapping.parent().expect("Mapping file path has no parent"))?;
|
||||
let mut file = File::create(&mapping).context("Failed to upsert mapping file")?;
|
||||
let ordered = link2alias.iter().collect::<BTreeMap<_, _>>();
|
||||
serde_json::to_writer_pretty(&mut file, &ordered)?;
|
||||
}
|
||||
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
fn supports_renderer(&self, _renderer: &str) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_anchors(
|
||||
chapter: &mut Chapter,
|
||||
root_url: &str,
|
||||
alias_gen: &mut AliasGenerator,
|
||||
link2alias: &mut BiHashMap<String, String>,
|
||||
verify: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag};
|
||||
use pulldown_cmark_to_cmark::cmark;
|
||||
|
||||
let mut buf = String::with_capacity(chapter.content.len());
|
||||
|
||||
let mut unshortened_links = BTreeSet::new();
|
||||
let events = Parser::new_ext(&chapter.content, Options::all())
|
||||
.map(|e| {
|
||||
let Event::Start(Tag::Link {
|
||||
link_type,
|
||||
dest_url,
|
||||
title,
|
||||
id,
|
||||
}) = &e
|
||||
else {
|
||||
return e;
|
||||
};
|
||||
|
||||
match link_type {
|
||||
LinkType::Autolink
|
||||
| LinkType::Shortcut
|
||||
| LinkType::Inline
|
||||
| LinkType::Reference
|
||||
| LinkType::Collapsed => {
|
||||
if dest_url.starts_with("http") {
|
||||
let alias = if let Some(alias) = link2alias.get_by_left(dest_url.as_ref()) {
|
||||
alias.to_owned()
|
||||
} else {
|
||||
if verify {
|
||||
unshortened_links.insert(dest_url.to_string());
|
||||
return e;
|
||||
}
|
||||
let alias = alias_gen.next_until_unique(&link2alias);
|
||||
alias
|
||||
};
|
||||
link2alias.insert(dest_url.to_string(), alias.clone());
|
||||
|
||||
Event::Start(Tag::Link {
|
||||
link_type: link_type.to_owned(),
|
||||
dest_url: CowStr::from(format!(
|
||||
"{root_url}/{alias}",
|
||||
root_url = root_url,
|
||||
alias = alias
|
||||
)),
|
||||
title: title.clone(),
|
||||
id: id.clone(),
|
||||
})
|
||||
} else {
|
||||
e
|
||||
}
|
||||
}
|
||||
LinkType::Email
|
||||
| LinkType::ReferenceUnknown
|
||||
| LinkType::CollapsedUnknown
|
||||
| LinkType::ShortcutUnknown => e,
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
if verify && !unshortened_links.is_empty() {
|
||||
let unshortened_links = unshortened_links.iter().join(", ");
|
||||
return Err(anyhow::anyhow!(
|
||||
"The following links are not shortened: {unshortened_links}\nRun again with `LINK_SHORTENER_VERIFY=false` to update the mapping \
|
||||
with the shortened links."
|
||||
));
|
||||
}
|
||||
|
||||
cmark(events.into_iter(), &mut buf)
|
||||
.map(|_| buf)
|
||||
.map_err(|err| anyhow::anyhow!("Markdown serialization failed: {err}"))
|
||||
}
|
||||
66
helpers/mdbook-link-shortener/src/main.rs
Normal file
66
helpers/mdbook-link-shortener/src/main.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use clap::Parser;
|
||||
use mdbook::errors::Error;
|
||||
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
|
||||
use mdbook_link_shortener::LinkShortener;
|
||||
use semver::{Version, VersionReq};
|
||||
use std::io;
|
||||
use std::process;
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
sub: Option<SubCommand>,
|
||||
}
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
pub enum SubCommand {
|
||||
#[clap(name = "supports")]
|
||||
Supports(Supports),
|
||||
}
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
pub struct Supports {
|
||||
#[arg(long)]
|
||||
renderer: String,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
let cli = Cli::parse();
|
||||
let preprocessor = LinkShortener::new();
|
||||
|
||||
if let Some(SubCommand::Supports(Supports { renderer })) = cli.sub {
|
||||
let code = if preprocessor.supports_renderer(&renderer) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
process::exit(code);
|
||||
}
|
||||
|
||||
handle_preprocessing(&preprocessor)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
|
||||
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
|
||||
|
||||
let book_version = Version::parse(&ctx.mdbook_version)?;
|
||||
let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?;
|
||||
|
||||
if !version_req.matches(&book_version) {
|
||||
eprintln!(
|
||||
"Warning: The {} plugin was built against version {} of mdbook, \
|
||||
but we're being called from version {}",
|
||||
pre.name(),
|
||||
mdbook::MDBOOK_VERSION,
|
||||
ctx.mdbook_version
|
||||
);
|
||||
}
|
||||
|
||||
let processed_book = pre.run(&ctx, book)?;
|
||||
serde_json::to_writer(io::stdout(), &processed_book)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
8
helpers/ticket_fields/Cargo.toml
Normal file
8
helpers/ticket_fields/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ticket_fields"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
thiserror = "1.0.59"
|
||||
73
helpers/ticket_fields/src/description.rs
Normal file
73
helpers/ticket_fields/src/description.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
#[derive(Debug, PartialEq, Clone, Eq)]
|
||||
pub struct TicketDescription(String);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TicketDescriptionError {
|
||||
#[error("The description cannot be empty")]
|
||||
Empty,
|
||||
#[error("The description cannot be longer than 500 bytes")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for TicketDescription {
|
||||
type Error = TicketDescriptionError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for TicketDescription {
|
||||
type Error = TicketDescriptionError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
validate(value)?;
|
||||
Ok(Self(value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(description: &str) -> Result<(), TicketDescriptionError> {
|
||||
if description.is_empty() {
|
||||
Err(TicketDescriptionError::Empty)
|
||||
} else if description.len() > 500 {
|
||||
Err(TicketDescriptionError::TooLong)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::{overly_long_description, valid_description};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
#[test]
|
||||
fn test_try_from_string() {
|
||||
let input = valid_description();
|
||||
let description = TicketDescription::try_from(input.clone()).unwrap();
|
||||
assert_eq!(description.0, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_empty_string() {
|
||||
let err = TicketDescription::try_from("".to_string()).unwrap_err();
|
||||
assert_eq!(err.to_string(), "The description cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_long_string() {
|
||||
let err = TicketDescription::try_from(overly_long_description()).unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"The description cannot be longer than 500 bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_str() {
|
||||
let description = TicketDescription::try_from("A description").unwrap();
|
||||
assert_eq!(description.0, "A description");
|
||||
}
|
||||
}
|
||||
6
helpers/ticket_fields/src/lib.rs
Normal file
6
helpers/ticket_fields/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod description;
|
||||
pub mod test_helpers;
|
||||
mod title;
|
||||
|
||||
pub use description::TicketDescription;
|
||||
pub use title::TicketTitle;
|
||||
14
helpers/ticket_fields/src/test_helpers.rs
Normal file
14
helpers/ticket_fields/src/test_helpers.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::{TicketDescription, TicketTitle};
|
||||
use common::{valid_description, valid_title};
|
||||
|
||||
/// A function to generate a valid ticket title,
|
||||
/// for test purposes.
|
||||
pub fn ticket_title() -> TicketTitle {
|
||||
valid_title().try_into().unwrap()
|
||||
}
|
||||
|
||||
/// A function to generate a valid ticket description,
|
||||
/// for test purposes.
|
||||
pub fn ticket_description() -> TicketDescription {
|
||||
valid_description().try_into().unwrap()
|
||||
}
|
||||
72
helpers/ticket_fields/src/title.rs
Normal file
72
helpers/ticket_fields/src/title.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::convert::TryFrom;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Eq)]
|
||||
pub struct TicketTitle(String);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TicketTitleError {
|
||||
#[error("The title cannot be empty")]
|
||||
Empty,
|
||||
#[error("The title cannot be longer than 50 bytes")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for TicketTitle {
|
||||
type Error = TicketTitleError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
validate(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for TicketTitle {
|
||||
type Error = TicketTitleError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
validate(value)?;
|
||||
Ok(Self(value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(title: &str) -> Result<(), TicketTitleError> {
|
||||
if title.is_empty() {
|
||||
Err(TicketTitleError::Empty)
|
||||
} else if title.len() > 50 {
|
||||
Err(TicketTitleError::TooLong)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::{overly_long_title, valid_title};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
#[test]
|
||||
fn test_try_from_string() {
|
||||
let input = valid_title();
|
||||
let title = TicketTitle::try_from(input.clone()).unwrap();
|
||||
assert_eq!(title.0, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_empty_string() {
|
||||
let err = TicketTitle::try_from("".to_string()).unwrap_err();
|
||||
assert_eq!(err.to_string(), "The title cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_long_string() {
|
||||
let err = TicketTitle::try_from(overly_long_title()).unwrap_err();
|
||||
assert_eq!(err.to_string(), "The title cannot be longer than 50 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_from_str() {
|
||||
let title = TicketTitle::try_from("A title").unwrap();
|
||||
assert_eq!(title.0, "A title");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user