pub(crate) mod activity;
pub(crate) mod attachments;
pub(crate) mod changes;
pub(crate) mod comments;
pub(crate) mod insights;
mod issues;
pub(crate) mod members;
mod pages;
pub(crate) mod plans;
pub(crate) mod project_groups;
mod projects;
mod resources;
mod search;
pub(crate) mod settings;
pub(crate) mod trash;
pub(crate) mod users;
pub(crate) mod views;
pub(crate) fn unescape_text(s: &str) -> String {
if s.contains('\n') || s.contains('\t') {
return s.to_string();
}
s.replace("\\n", "\n").replace("\\t", "\t")
}
pub(crate) const TOMBSTONE_NOW: &str = "strftime('%Y-%m-%d %H:%M:%f', 'now')";
pub const DEFAULT_PAGE_LIMIT: i64 = 50;
pub const MAX_PAGE_LIMIT: i64 = 500;
pub const NO_LIMIT: i64 = -1;
pub fn page(limit: Option<i64>, offset: Option<i64>) -> (i64, i64) {
page_with(limit, offset, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT)
}
pub fn page_with(
limit: Option<i64>,
offset: Option<i64>,
default_limit: i64,
max_limit: i64,
) -> (i64, i64) {
(
limit.unwrap_or(default_limit).clamp(1, max_limit),
offset.unwrap_or(0).max(0),
)
}
pub fn page_unbounded(limit: Option<i64>, offset: Option<i64>) -> (i64, i64) {
(
limit.map_or(NO_LIMIT, |n| n.clamp(1, MAX_PAGE_LIMIT)),
offset.unwrap_or(0).max(0),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
pub items: Vec<T>,
pub has_more: bool,
}
impl<T> Page<T> {
pub fn from_over_fetch(mut items: Vec<T>, limit: i64) -> Self {
let limit = usize::try_from(limit.max(0)).unwrap_or(usize::MAX);
let has_more = items.len() > limit;
if has_more {
items.truncate(limit);
}
Self { items, has_more }
}
pub fn complete(items: Vec<T>) -> Self {
Self {
items,
has_more: false,
}
}
}
pub fn over_fetch(limit: i64) -> i64 {
match limit {
NO_LIMIT => NO_LIMIT,
n => n.saturating_add(1),
}
}
pub(crate) fn project_visibility_sql(
column: &str,
visible: Option<&std::collections::HashSet<i64>>,
) -> (String, Vec<i64>) {
match visible {
None => ("1=1".into(), Vec::new()),
Some(ids) if ids.is_empty() => ("1=0".into(), Vec::new()),
Some(ids) => (
format!("{column} IN ({})", vec!["?"; ids.len()].join(", ")),
ids.iter().copied().collect(),
),
}
}
pub(crate) fn savepoint<F, T>(
conn: &rusqlite::Connection,
name: &str,
f: F,
) -> Result<T, crate::error::LificError>
where
F: FnOnce() -> Result<T, crate::error::LificError>,
{
conn.execute_batch(&format!("SAVEPOINT {name}"))?;
match f() {
Ok(val) => {
conn.execute_batch(&format!("RELEASE {name}"))?;
Ok(val)
}
Err(e) => {
let _ = conn.execute_batch(&format!("ROLLBACK TO {name}"));
let _ = conn.execute_batch(&format!("RELEASE {name}"));
Err(e)
}
}
}
pub use issues::*;
pub use pages::*;
pub use projects::*;
pub use resources::*;
pub use search::*;
#[cfg(test)]
mod tests {
use super::{
DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, NO_LIMIT, Page, page, page_unbounded, page_with,
unescape_text,
};
#[test]
fn page_defaults_when_nothing_is_supplied() {
assert_eq!(page(None, None), (DEFAULT_PAGE_LIMIT, 0));
}
#[test]
fn page_passes_through_values_inside_the_bounds() {
assert_eq!(page(Some(10), Some(25)), (10, 25));
assert_eq!(page(Some(1), Some(0)), (1, 0));
assert_eq!(page(Some(MAX_PAGE_LIMIT), Some(0)), (MAX_PAGE_LIMIT, 0));
}
#[test]
fn page_floors_zero_and_negative_limits_at_one() {
assert_eq!(page(Some(0), None).0, 1);
assert_eq!(page(Some(-1), None).0, 1);
assert_eq!(page(Some(i64::MIN), None).0, 1);
}
#[test]
fn page_caps_oversized_limits() {
assert_eq!(page(Some(MAX_PAGE_LIMIT + 1), None).0, MAX_PAGE_LIMIT);
assert_eq!(page(Some(i64::MAX), None).0, MAX_PAGE_LIMIT);
}
#[test]
fn page_floors_negative_offsets_at_zero() {
assert_eq!(page(None, Some(-10)).1, 0);
assert_eq!(page(None, Some(i64::MIN)).1, 0);
}
#[test]
fn page_with_honours_a_query_specific_default_and_cap() {
assert_eq!(page_with(None, None, 20, 200), (20, 0));
assert_eq!(page_with(Some(999), None, 20, 200).0, 200);
assert_eq!(page_with(Some(0), Some(-3), 20, 200), (1, 0));
}
#[test]
fn page_unbounded_treats_an_absent_limit_as_no_limit() {
assert_eq!(page_unbounded(None, None), (NO_LIMIT, 0));
assert_eq!(page_unbounded(None, Some(7)), (NO_LIMIT, 7));
}
#[test]
fn page_unbounded_clamps_a_supplied_limit_like_page() {
assert_eq!(page_unbounded(Some(10), None).0, 10);
assert_eq!(page_unbounded(Some(0), None).0, 1);
assert_eq!(page_unbounded(Some(-5), None).0, 1);
assert_eq!(page_unbounded(Some(i64::MAX), None).0, MAX_PAGE_LIMIT);
assert_eq!(page_unbounded(Some(5), Some(-5)).1, 0);
}
#[test]
fn page_from_over_fetch_handles_signed_bounds() {
assert_eq!(
Page::from_over_fetch(vec![1, 2], -1),
Page {
items: Vec::new(),
has_more: true,
}
);
assert_eq!(
Page::from_over_fetch(vec![1, 2], i64::MAX),
Page {
items: vec![1, 2],
has_more: false,
}
);
}
#[test]
fn unescape_text_preserves_literal_newline_escape_in_multiline_content() {
let input = "```c\nprintf(\"\\n\");\n```";
assert_eq!(unescape_text(input), input);
}
#[test]
fn unescape_text_preserves_literal_tab_escape_when_input_has_real_tab() {
let input = "column\tprintf(\"\\t\");";
assert_eq!(unescape_text(input), input);
}
#[test]
fn unescape_text_repairs_single_line_double_escaped_newline() {
assert_eq!(unescape_text("line1\\nline2"), "line1\nline2");
}
}