//! Tickets: the rules a ticket and its labels live by, and the store that
//! keeps their rows.
//!
//! The rules sit here rather than in the handlers because everything that
//! speaks about a ticket has to agree on them: what a summary must be, what a
//! label may be called, who may change a ticket, and what a search box means
//! once it reaches FTS5.
pub mod store;
pub use crate::entities::tickets::State;
/// The labels every repository starts with.
///
/// These are the kinds of work a ticket is usually about; a repository whose
/// owner wants more makes them from the settings page.
pub const DEFAULT_LABELS: [&str; 2] = ["bug", "improvement"];
/// The state a ticket starts in.
///
/// A ticket with no state at all would be missing from the default list.
pub const DEFAULT_STATE: &str = "open";
/// The state the query string asks for, or [`State::Open`] when it
/// holds nothing. Returns `None` when the parameter is explicitly empty,
/// meaning "show all states".
pub fn parse_state(requested: Option<&str>) -> Option<State> {
match requested {
None => Some(State::Open), // Default to open when parameter is absent
Some(s) => match s.trim() {
"" => None, // Empty parameter means "show all"
"closed" => Some(State::Closed),
_ => Some(State::Open),
},
}
}
/// The query-string value for a state: what the address holds.
pub fn state_string(state: State) -> String {
match state {
State::Open => "open".to_string(),
State::Closed => "closed".to_string(),
}
}
/// How long a summary may be, which is the width of the column it is kept
/// in: a longer one is refused here rather than by SQLite in words of its
/// own.
pub const MAX_SUMMARY_LEN: usize = 255;
/// How long a label's name may be, for the same reason in a narrower column.
pub const MAX_LABEL_NAME_LEN: usize = 64;
/// The default maximum size of an attachment in bytes (2 MB).
pub const DEFAULT_MAX_ATTACHMENT_SIZE: usize = 2 * 1024 * 1024;
/// The default maximum number of attachments per ticket.
pub const DEFAULT_MAX_ATTACHMENTS_PER_TICKET: usize = 4;
/// Environment variable for the maximum attachment size in bytes.
const MAX_ATTACHMENT_SIZE_VAR: &str = "FROGUE_MAX_ATTACHMENT_SIZE";
/// Environment variable for the maximum number of attachments per ticket.
const MAX_ATTACHMENTS_PER_TICKET_VAR: &str = "FROGUE_MAX_ATTACHMENTS_PER_TICKET";
/// Returns the maximum attachment size in bytes, read from the environment or
/// the default.
pub fn max_attachment_size() -> usize {
std::env::var(MAX_ATTACHMENT_SIZE_VAR)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_ATTACHMENT_SIZE)
}
/// Returns the maximum number of attachments per ticket, read from the
/// environment or the default.
pub fn max_attachments_per_ticket() -> usize {
std::env::var(MAX_ATTACHMENTS_PER_TICKET_VAR)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_ATTACHMENTS_PER_TICKET)
}
/// The summary a ticket may be saved with: the text with the space around it
/// taken off, or the reason it cannot be one.
///
/// The summary is the one thing a ticket cannot do without - it is what a
/// list is scanned for - so it is asked for in words of ours rather than
/// left to a `NOT NULL` to say no to.
pub fn checked_summary(summary: &str) -> Result<&str, String> {
let summary = summary.trim();
if summary.is_empty() {
return Err("a ticket needs a summary".to_string());
}
if summary.chars().count() > MAX_SUMMARY_LEN {
return Err(format!(
"a summary may be at most {MAX_SUMMARY_LEN} characters long"
));
}
Ok(summary)
}
/// The name a label may be made with: the text with the space around it
/// taken off, or the reason it cannot be one.
///
/// A comma is refused rather than escaped: it is what the labels of one
/// filter are joined by in the address, so a label holding one could not be
/// told apart from two labels.
pub fn checked_label_name(name: &str) -> Result<&str, String> {
let name = name.trim();
if name.is_empty() {
return Err("a label needs a name".to_string());
}
if name.contains(',') {
return Err(
"a label's name may not hold a comma: that is what the labels of \
one filter are joined by"
.to_string(),
);
}
if name.chars().count() > MAX_LABEL_NAME_LEN {
return Err(format!(
"a label's name may be at most {MAX_LABEL_NAME_LEN} characters long"
));
}
Ok(name)
}
/// Whether `viewer` may edit or delete a ticket opened by `creator`.
///
/// Anybody who may read a repository may open a ticket on it - complaints
/// are usually somebody else's - but changing one is another matter: the
/// person who wrote it, and the person whose repository it is, and nobody
/// else. If the ticket was created by a remote actor (creator is None), only
/// the repository owner may change it via the WebUI.
pub fn may_change(repository_owner: i64, creator: Option<i64>, viewer: i64) -> bool {
creator.map_or(false, |c| viewer == c) || viewer == repository_owner
}
/// The labels the list shows: the ones `requested` names - which is what the
/// address holds - or nothing when the address holds no filter at all.
///
/// An empty list shows everything.
pub fn selected_labels(requested: Option<&str>) -> Vec<String> {
match requested {
Some(raw) => filter(raw),
None => Vec::new(),
}
}
/// The label names a filter parameter holds, joined by commas.
///
/// Space around each name is dropped, because that is what writing a label's
/// name in an address tends to leave behind, and the empty names a comma
/// with nothing after it leaves are not labels at all.
pub fn filter(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
}
/// The FTS5 expression for `query`, or `None` when it holds nothing to
/// search for.
///
/// A search box is somebody's typing rather than a piece of FTS5 syntax, so
/// only its runs of letters and digits are taken as words - the rest of what
/// was typed may be a stray quote, a `*`, or `NEAR/(` for all it matters.
/// Each word is quoted, which is what stops the engine from reading it as
/// its own syntax whether it spells `AND` or `NEAR`, and followed by `*` so
/// that `sauce` also finds `sauces` and `cran` also finds `cranberry`.
///
/// Nothing worth searching for searches for nothing: an empty list, not a
/// fault - the engine has opinions about an empty expression, and they are
/// all errors.
pub fn search_expression(query: &str) -> Option<String> {
let mut words: Vec<String> = Vec::new();
let mut word = String::new();
for character in query.chars() {
if character.is_alphanumeric() {
word.push(character);
} else if !word.is_empty() {
words.push(std::mem::take(&mut word));
}
}
if !word.is_empty() {
words.push(word);
}
if words.is_empty() {
return None;
}
Some(
words
.iter()
.map(|word| format!("\"{word}\"*"))
.collect::<Vec<_>>()
.join(" AND "),
)
}
/// Percent-encodes `value` for a query string, keeping the characters that
/// may stand for themselves.
///
/// A label's name is written into the address of its filter, and a name with
/// a space or an `&` in it would otherwise be two things by the time it came
/// back.
pub fn encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
b' ' => encoded.push_str("%20"),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_summary_is_the_text_with_the_space_around_it_gone() {
assert_eq!(
checked_summary(" Cranberry sauce \n"),
Ok("Cranberry sauce")
);
}
#[test]
fn a_summary_of_nothing_is_refused_in_words_of_ours() {
assert_eq!(
checked_summary(""),
Err("a ticket needs a summary".to_string())
);
assert_eq!(
checked_summary(" "),
Err("a ticket needs a summary".to_string())
);
assert_eq!(
checked_summary("\n\t"),
Err("a ticket needs a summary".to_string())
);
}
#[test]
fn a_summary_longer_than_the_column_holds_says_how_long_is_too_long() {
let long = "x".repeat(MAX_SUMMARY_LEN + 1);
assert_eq!(
checked_summary(&long).unwrap_err(),
"a summary may be at most 255 characters long"
);
// The width is counted in characters rather than bytes, so a summary
// of accents fills the column the same way the column counts it.
assert!(checked_summary(&"é".repeat(MAX_SUMMARY_LEN)).is_ok());
}
#[test]
fn a_label_name_is_refused_for_being_empty_or_holding_a_comma() {
assert_eq!(
checked_label_name(" "),
Err("a label needs a name".to_string())
);
assert!(checked_label_name("one,two").unwrap_err().contains("comma"));
// A comma anywhere is one comma too many, not only at the ends.
assert!(checked_label_name(",urgent").unwrap_err().contains("comma"));
assert_eq!(checked_label_name(" help wanted "), Ok("help wanted"));
}
#[test]
fn a_ticket_is_changed_by_the_one_who_opened_it_or_the_owner_of_the_repository() {
assert!(may_change(7, Some(3), 3), "the creator may change it");
assert!(may_change(7, Some(3), 7), "the owner may change it");
assert!(!may_change(7, Some(3), 9), "somebody else may not");
// Remote-created tickets (creator is None) can only be changed by the
// owner
assert!(
may_change(7, None, 7),
"the owner may change remote-created tickets"
);
assert!(
!may_change(7, None, 3),
"non-owners may not change remote-created tickets"
);
}
#[test]
fn the_list_opens_with_no_label_filter_and_reads_a_filter_it_is_given() {
// The list as it opens shows every ticket; state filtering is separate.
assert_eq!(selected_labels(None), Vec::<String>::new());
// A filter that was cleared shows every ticket rather than falling
// back to the default one.
assert_eq!(selected_labels(Some("")), Vec::<String>::new());
assert_eq!(
selected_labels(Some("bug,improvement")),
vec!["bug", "improvement"]
);
// Space around the names is what writing a label's name by hand leaves
// behind.
assert_eq!(
selected_labels(Some(" bug , improvement ")),
vec!["bug", "improvement"]
);
}
#[test]
fn a_search_is_words_and_never_the_engines_own_syntax() {
assert_eq!(
search_expression("sauce"),
Some("\"sauce\"*".to_string()),
"one word is searched for as a prefix of itself"
);
assert_eq!(
search_expression("fruity rich"),
Some("\"fruity\"* AND \"rich\"*".to_string())
);
// Words the engine has opinions of its own about are quoted into
// being the words they spell.
assert_eq!(
search_expression("AND OR NEAR"),
Some("\"AND\"* AND \"OR\"* AND \"NEAR\"*".to_string())
);
// A stray quote, a star and a slash are somebody's typing, not a
// query, and searching for nothing is an empty result rather than a
// fault the request is answered with.
assert_eq!(search_expression("\""), None);
assert_eq!(search_expression("*"), None);
assert_eq!(search_expression("NEAR/("), Some("\"NEAR\"*".to_string()));
assert_eq!(search_expression(" "), None);
assert_eq!(search_expression(""), None);
}
#[test]
fn a_label_name_survives_a_query_string() {
assert_eq!(encode("help wanted"), "help%20wanted");
assert_eq!(encode("urgent"), "urgent");
assert_eq!(encode("a&b=c"), "a%26b%3Dc");
// Letters outside ASCII are bytes here, and each one is escaped as
// the byte it is, which is what a query string asks for.
assert_eq!(encode("é"), "%C3%A9");
}
}