mantra 0.6.2

`mantra` offers a lightweight approach for requirement tracing and coverage.
Documentation
use std::path::{Path, PathBuf};

use crate::db::{MantraDb, RequirementChanges};

use ignore::{types::TypesBuilder, WalkBuilder};
use mantra_schema::requirements::{Requirement, RequirementSchema};
use regex::Regex;

#[derive(Debug, Clone, clap::Subcommand, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Format {
    FromWiki(WikiConfig),
    FromSchema { filepath: PathBuf },
}

#[derive(Debug, Clone, clap::Args, serde::Serialize, serde::Deserialize)]
pub struct WikiConfig {
    #[arg(alias = "local-path")]
    pub root: PathBuf,
    pub link: String,
    #[arg(long, alias = "version")]
    #[serde(alias = "version", alias = "major-version")]
    pub major_version: Option<usize>,
}

#[derive(Debug, thiserror::Error)]
pub enum RequirementsError {
    #[error("Could not access file '{}'.", .0)]
    CouldNotAccessFile(String),
    #[error("{}", .0)]
    Deserialize(serde_json::Error),
    #[error("{}", .0)]
    DbError(crate::db::DbError),
}

pub async fn collect(db: &MantraDb, fmt: &Format) -> Result<RequirementChanges, RequirementsError> {
    match fmt {
        Format::FromWiki(wiki_cfg) => {
            collect_from_wiki(db, &wiki_cfg.root, &wiki_cfg.link, wiki_cfg.major_version).await
        }
        Format::FromSchema { filepath } => {
            let content = tokio::fs::read_to_string(filepath).await.map_err(|_| {
                RequirementsError::CouldNotAccessFile(filepath.display().to_string())
            })?;
            let schema = serde_json::from_str(&content).map_err(RequirementsError::Deserialize)?;
            collect_from_schema(db, schema).await
        }
    }
}

pub async fn collect_from_schema(
    db: &MantraDb,
    schema: RequirementSchema,
) -> Result<RequirementChanges, RequirementsError> {
    db.add_reqs(schema.requirements)
        .await
        .map_err(RequirementsError::DbError)
}

async fn collect_from_wiki(
    db: &MantraDb,
    root: &Path,
    link: &str,
    version: Option<usize>,
) -> Result<RequirementChanges, RequirementsError> {
    let mut reqs = Vec::new();

    if root.is_dir() {
        let walk = WalkBuilder::new(root)
            .types(
                TypesBuilder::new()
                    .add_defaults()
                    .select("markdown")
                    .build()
                    .expect("Could not create markdown file filter."),
            )
            .build();

        for dir_entry_res in walk {
            let dir_entry = match dir_entry_res {
                Ok(entry) => entry,
                Err(_) => continue,
            };

            if dir_entry
                .file_type()
                .expect("No file type found for given entry. Note: stdin is not supported.")
                .is_file()
            {
                let content = std::fs::read_to_string(dir_entry.path()).map_err(|_| {
                    RequirementsError::CouldNotAccessFile(dir_entry.path().display().to_string())
                })?;

                let file_stem = dir_entry
                    .path()
                    .file_stem()
                    .expect("Filepath is valid filename.")
                    .to_string_lossy()
                    .replace(char::is_whitespace, "-");
                let link = format!("{}/{}", link, file_stem);

                reqs.append(&mut requirements_from_wiki_content(
                    &content, &link, version,
                ));
            }
        }
    } else {
        let content = std::fs::read_to_string(root)
            .map_err(|_| RequirementsError::CouldNotAccessFile(root.display().to_string()))?;

        reqs = requirements_from_wiki_content(&content, link, version);
    }

    if reqs.is_empty() {
        log::warn!("No requirements were found.");

        let changes = RequirementChanges {
            new_generation: db.max_req_generation().await,
            ..Default::default()
        };
        Ok(changes)
    } else {
        db.add_reqs(reqs).await.map_err(RequirementsError::DbError)
    }
}

static REQ_ID_MATCHER: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();

fn requirements_from_wiki_content(
    content: &str,
    link: &str,
    version: Option<usize>,
) -> Vec<Requirement> {
    let lines = content.lines();

    let mut reqs = Vec::new();
    let mut in_verbatim_context = false;

    let regex = REQ_ID_MATCHER.get_or_init(|| {
        Regex::new(
            r"^#{1,6}\s`(?<id>[^\s:]+)`(?:\((?:v(?<version>\d{1,7}):)?(?<marker>[^\)]+)\))?:\s+(?<title>[^\n]+)",
        )
        .expect("Regex to match the requirement ID could **not** be created.")
    });

    for line in lines {
        if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
            in_verbatim_context = !in_verbatim_context;
        }

        if !in_verbatim_context {
            if let Some(captures) = regex.captures(line) {
                let id = captures
                    .name("id")
                    .expect("`id` capture group was not in heading match.")
                    .as_str()
                    .to_string();

                let mut marker = captures.name("marker").map(|c| c.as_str().to_string());
                let extracted_version: Option<usize> = captures.name("version").map(|c| {
                    c.as_str()
                        .parse()
                        .expect("Matched digits must fit into *usize*.")
                });

                if let Some(version) = version {
                    if let Some(extracted_version) = extracted_version {
                        if version < extracted_version {
                            marker = None;
                        }
                    }
                }

                let manual = marker == Some("manual".to_string());
                let deprecated = marker == Some("deprecated".to_string());

                let title = captures
                    .name("title")
                    .expect("`title` capture group was not in heading match.")
                    .as_str()
                    .to_string();

                reqs.push(Requirement {
                    id,
                    title,
                    link: link.to_string(),
                    info: None,
                    manual,
                    deprecated,
                });
            }
        }
    }

    reqs
}