provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use crate::wiki::model::{PageId, PageLink, RecordKind};
use provenance_core::{Requirement, Resolution, Rule, Source};

pub(super) fn requirement_link(requirement: &Requirement) -> PageLink {
    PageLink {
        target: PageId::new(RecordKind::Requirement, requirement.id.as_str()),
        title: reader_title(&requirement.statement),
    }
}

pub(super) fn resolution_link(resolution: &Resolution) -> PageLink {
    PageLink {
        target: PageId::new(RecordKind::Resolution, resolution.id.as_str()),
        title: resolution.title.clone(),
    }
}

pub(super) fn rule_link(rule: &Rule) -> PageLink {
    PageLink {
        target: PageId::new(RecordKind::Rule, rule.id.as_str()),
        title: rule_title(rule),
    }
}

pub(super) fn rule_title(rule: &Rule) -> String {
    rule.name
        .as_deref()
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(str::to_string)
        .or_else(|| {
            let title = reader_title(&rule.statement);
            (!title.is_empty()).then_some(title)
        })
        .unwrap_or_else(|| desnaked_id(rule.id.as_str()))
}

fn desnaked_id(id: &str) -> String {
    let words = id.replace(['_', '-'], " ");
    let words = words.split_whitespace().collect::<Vec<_>>().join(" ");
    let mut chars = words.chars();
    chars.next().map_or_else(
        || words.clone(),
        |first| first.to_uppercase().collect::<String>() + chars.as_str(),
    )
}

pub(super) fn source_link(source: &Source) -> PageLink {
    PageLink {
        target: PageId::new(RecordKind::Source, source.id.as_str()),
        title: source.name.clone(),
    }
}

pub(super) fn display_identifier(identifier: &str) -> String {
    let words = identifier
        .trim()
        .split(['_', '-'])
        .filter(|word| !word.is_empty())
        .collect::<Vec<_>>()
        .join(" ");
    let mut chars = words.chars();
    chars.next().map_or_else(String::new, |first| {
        first.to_uppercase().collect::<String>() + chars.as_str()
    })
}

pub(super) fn reader_title(text: &str) -> String {
    const MAX_CHARS: usize = 70;
    let text = text.trim();
    let clause_end = text
        .char_indices()
        .find_map(|(index, character)| match character {
            ';' | '\n' => Some(index),
            '.' if sentence_period(text, index) => Some(index),
            _ => None,
        });
    let clause = clause_end.map_or(text, |end| &text[..end]).trim();
    if clause.chars().count() <= MAX_CHARS {
        return clause.to_string();
    }

    let byte_limit = clause
        .char_indices()
        .nth(MAX_CHARS - 1)
        .map_or(clause.len(), |(index, _)| index);
    let candidate = &clause[..byte_limit];
    let shortened = candidate
        .rfind(char::is_whitespace)
        .map_or(candidate, |space| &candidate[..space])
        .trim_end();
    format!("{shortened}…")
}

fn sentence_period(text: &str, index: usize) -> bool {
    if index == 0 {
        return false;
    }
    let after = &text[index + 1..];
    if after
        .chars()
        .next()
        .is_some_and(|next| !next.is_whitespace())
    {
        return false;
    }
    let token = text[..index]
        .rsplit_once(char::is_whitespace)
        .map_or(&text[..index], |(_, token)| token);
    !token.contains('.')
}