upstream-rs 2.5.0

Fetch package updates directly from the source.
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownSection {
    pub level: usize,
    pub heading: String,
    pub body: String,
    pub ordinal: usize,
}

#[derive(Debug, Clone)]
struct Heading {
    level: usize,
    text: String,
    line_index: usize,
}

pub fn parse_sections(markdown: &str) -> Vec<MarkdownSection> {
    let lines = markdown.lines().collect::<Vec<_>>();
    let headings = lines
        .iter()
        .enumerate()
        .filter_map(|(line_index, line)| {
            parse_heading(line).map(|(level, text)| Heading {
                level,
                text,
                line_index,
            })
        })
        .collect::<Vec<_>>();

    headings
        .iter()
        .enumerate()
        .map(|(ordinal, heading)| {
            let body_start = heading.line_index + 1;
            let body_end = next_sibling_or_parent_index(&headings, ordinal).unwrap_or(lines.len());
            let body = lines[body_start..body_end].join("\n").trim().to_string();

            MarkdownSection {
                level: heading.level,
                heading: heading.text.clone(),
                body,
                ordinal,
            }
        })
        .collect()
}

fn parse_heading(line: &str) -> Option<(usize, String)> {
    let trimmed = line.trim_start();
    let level = trimmed.chars().take_while(|ch| *ch == '#').count();
    if level == 0 || level > 6 {
        return None;
    }

    let rest = &trimmed[level..];
    if !rest.starts_with(char::is_whitespace) {
        return None;
    }

    let heading = rest.trim().trim_end_matches('#').trim();
    (!heading.is_empty()).then(|| (level, heading.to_string()))
}

fn next_sibling_or_parent_index(headings: &[Heading], current: usize) -> Option<usize> {
    let current_level = headings[current].level;
    headings
        .iter()
        .skip(current + 1)
        .find(|heading| heading.level <= current_level)
        .map(|heading| heading.line_index)
}

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

    #[test]
    fn parses_markdown_sections_with_nested_body_content() {
        let markdown = "\
# Project
intro

## Usage
basic usage

### Flags
flag details

## Install
install notes
";

        let sections = parse_sections(markdown);

        assert_eq!(sections.len(), 4);
        assert_eq!(sections[1].heading, "Usage");
        assert!(sections[1].body.contains("basic usage"));
        assert!(sections[1].body.contains("### Flags"));
        assert!(sections[1].body.contains("flag details"));
        assert!(!sections[1].body.contains("## Install"));
    }

    #[test]
    fn parses_this_projects_readme() {
        let sections = parse_sections(include_str!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/README.md"
        )));

        assert!(sections.iter().any(|section| section.heading == "Upstream"));
        assert!(
            sections
                .iter()
                .any(|section| section.heading == "Command Overview")
        );
        assert!(
            sections
                .iter()
                .any(|section| section.heading == "Documentation")
        );
    }
}