foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Small helper utilities shared between adapters, built-in commands
//! and user-defined bots.

/// Capitalise the first character of a string (`"telegram"` -> `"Telegram"`).
///
/// Handles multibyte UTF-8 correctly. Leaves an empty string as-is.
pub fn capitalize(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
        None => String::new(),
    }
}

/// Render a 0..=total progress as a width-cell bar using filled ▰ and
/// empty ▱ glyphs. `total` of 0 is treated as 1 to avoid divide-by-zero.
///
/// ```
/// use foukoapi::util::progress_bar;
/// assert_eq!(progress_bar(3, 10, 10), "▰▰▰▱▱▱▱▱▱▱");
/// ```
pub fn progress_bar(done: u64, total: u64, width: usize) -> String {
    let width = width.max(1);
    let total = total.max(1);
    let filled = ((done as f64 / total as f64) * width as f64).round() as usize;
    let filled = filled.min(width);
    let empty = width - filled;
    let mut s = String::with_capacity(width * 3);
    s.push_str(&"\u{25B0}".repeat(filled));
    s.push_str(&"\u{25B1}".repeat(empty));
    s
}

/// Split `text` into chunks no longer than `limit` characters, breaking on
/// line boundaries where possible (and on word boundaries as a fallback) so
/// a long reply reads naturally when an adapter has to send it as several
/// messages. A single line longer than `limit` is hard-split.
///
/// Returns an empty vec for empty input; never produces empty chunks.
pub fn split_chunks(text: &str, limit: usize) -> Vec<String> {
    let limit = limit.max(1);
    if text.chars().count() <= limit {
        return if text.is_empty() {
            Vec::new()
        } else {
            vec![text.to_owned()]
        };
    }

    let mut chunks = Vec::new();
    let mut current = String::new();

    for line in text.split_inclusive('\n') {
        // A line that alone exceeds the limit gets hard-wrapped.
        if line.chars().count() > limit {
            if !current.is_empty() {
                chunks.push(std::mem::take(&mut current));
            }
            for piece in hard_wrap(line, limit) {
                chunks.push(piece);
            }
            continue;
        }
        if current.chars().count() + line.chars().count() > limit {
            chunks.push(std::mem::take(&mut current));
        }
        current.push_str(line);
    }
    if !current.is_empty() {
        chunks.push(current);
    }
    chunks
        .into_iter()
        .map(|c| c.trim_end_matches('\n').to_owned())
        .filter(|c| !c.is_empty())
        .collect()
}

/// First chunk of `text` no longer than `limit` characters - what a
/// message edit can fit, since edits can't spill into extra messages the
/// way sends chunk. Empty input yields an empty string.
pub fn truncate_chunk(text: &str, limit: usize) -> String {
    split_chunks(text, limit)
        .into_iter()
        .next()
        .unwrap_or_default()
}

/// Hard-wrap a single overlong line, preferring to break on spaces.
fn hard_wrap(line: &str, limit: usize) -> Vec<String> {
    let mut out = Vec::new();
    let mut current = String::new();
    for word in line.split(' ') {
        if word.chars().count() > limit {
            // A monster word: chop it into limit-sized pieces.
            if !current.is_empty() {
                out.push(std::mem::take(&mut current));
            }
            let mut buf = String::new();
            for ch in word.chars() {
                if buf.chars().count() == limit {
                    out.push(std::mem::take(&mut buf));
                }
                buf.push(ch);
            }
            if !buf.is_empty() {
                current = buf;
            }
            continue;
        }
        let extra = if current.is_empty() { 0 } else { 1 };
        if current.chars().count() + extra + word.chars().count() > limit {
            out.push(std::mem::take(&mut current));
        }
        if !current.is_empty() {
            current.push(' ');
        }
        current.push_str(word);
    }
    if !current.is_empty() {
        out.push(current);
    }
    out
}

/// Minimal URL-encoder that keeps A-Z / a-z / 0-9 / - _ . ~ as-is and
/// percent-encodes every other byte. Good enough for city names and
/// other short query-string arguments without pulling in another crate.
pub fn urlencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for byte in s.as_bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(*byte as char);
            }
            _ => out.push_str(&format!("%{:02X}", byte)),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn capitalize_ascii() {
        assert_eq!(capitalize("telegram"), "Telegram");
        assert_eq!(capitalize(""), "");
    }

    #[test]
    fn capitalize_unicode() {
        assert_eq!(capitalize("ёлка"), "Ёлка");
    }

    #[test]
    fn progress_bar_edges() {
        assert_eq!(progress_bar(0, 10, 4), "▱▱▱▱");
        assert_eq!(progress_bar(10, 10, 4), "▰▰▰▰");
        assert_eq!(progress_bar(5, 0, 4), "▰▰▰▰"); // total=0 => 1 => fully filled
    }

    #[test]
    fn split_short_text_stays_whole() {
        assert_eq!(split_chunks("hi", 100), vec!["hi".to_owned()]);
        assert!(split_chunks("", 100).is_empty());
    }

    #[test]
    fn split_breaks_on_lines() {
        let text = "aaaa\nbbbb\ncccc";
        let chunks = split_chunks(text, 9);
        assert!(chunks.iter().all(|c| c.chars().count() <= 9));
        // Reassembling with newlines recovers the original content.
        assert_eq!(chunks.join("\n"), text);
    }

    #[test]
    fn split_hard_wraps_a_long_line() {
        let text = "a".repeat(25);
        let chunks = split_chunks(&text, 10);
        assert_eq!(chunks.len(), 3);
        assert!(chunks.iter().all(|c| c.chars().count() <= 10));
        assert_eq!(chunks.concat(), text);
    }

    #[test]
    fn split_prefers_word_boundaries() {
        let chunks = split_chunks("one two three four", 9);
        assert!(chunks.iter().all(|c| c.chars().count() <= 9));
        assert!(chunks.iter().all(|c| !c.starts_with(' ')));
    }

    #[test]
    fn truncate_chunk_short_text_is_unchanged() {
        assert_eq!(truncate_chunk("hi", 100), "hi");
        assert_eq!(truncate_chunk("", 100), "");
    }

    #[test]
    fn truncate_chunk_cuts_to_first_chunk() {
        let text = "a".repeat(25);
        let cut = truncate_chunk(&text, 10);
        assert_eq!(cut.chars().count(), 10);
        assert!(text.starts_with(&cut));
    }

    #[test]
    fn truncate_chunk_prefers_line_boundary() {
        assert_eq!(truncate_chunk("aaaa\nbbbb\ncccc", 9), "aaaa");
        assert_eq!(truncate_chunk("aa\nbb\ncccc", 6), "aa\nbb");
    }

    #[test]
    fn urlencode_basic() {
        assert_eq!(urlencode("hello"), "hello");
        assert_eq!(urlencode("hello world"), "hello%20world");
        assert_eq!(urlencode("a&b=c"), "a%26b%3Dc");
    }
}