kanbus 0.14.0

High-performance CLI and web console for the Kanbus issue tracker. Includes kanbus (CLI) and kanbus-console (web UI server).
Documentation
//! Wiki rendering utilities.

use std::fs;
use std::sync::Arc;

use minijinja::value::{Kwargs, Value};
use minijinja::{context, Environment, Error, ErrorKind};

use crate::error::KanbusError;
use crate::issue_listing::list_issues;
use crate::models::IssueData;

/// Request for rendering a wiki page.
#[derive(Debug, Clone)]
pub struct WikiRenderRequest {
    /// Repository root path.
    pub root: std::path::PathBuf,
    /// Page path to render.
    pub page_path: std::path::PathBuf,
}

/// Render a wiki page using the live issue index.
///
/// # Arguments
/// * `request` - Render request with root and page path.
///
/// # Returns
/// Rendered wiki content.
///
/// # Errors
/// Returns `KanbusError` if rendering fails.
pub fn render_wiki_page(request: &WikiRenderRequest) -> Result<String, KanbusError> {
    let page_path = request.root.join(&request.page_path);
    validate_page_exists(&page_path)?;

    let issues = list_issues(
        &request.root,
        None,
        None,
        None,
        None,
        None,
        None,
        &[],
        true,
        false,
    )?;
    let issues = Arc::new(issues);

    let mut env = Environment::new();
    let query_issues = Arc::clone(&issues);
    env.add_function("query", move |kwargs: Kwargs| {
        let mut filtered = filter_issues_from_kwargs(&query_issues, &kwargs)?;
        if let Some(sort_key) = kwargs.get::<Option<String>>("sort")? {
            match sort_key.as_str() {
                "title" => filtered.sort_by(|left, right| left.title.cmp(&right.title)),
                "priority" => filtered.sort_by_key(|issue| issue.priority),
                _ => return Err(Error::new(ErrorKind::InvalidOperation, "invalid sort key")),
            }
        }
        kwargs
            .assert_all_used()
            .map_err(|_| Error::new(ErrorKind::InvalidOperation, "invalid query parameter"))?;
        Ok(Value::from_serialize(filtered))
    });

    let count_issues = Arc::clone(&issues);
    env.add_function("count", move |kwargs: Kwargs| {
        let filtered = filter_issues_from_kwargs(&count_issues, &kwargs)?;
        kwargs
            .assert_all_used()
            .map_err(|_| Error::new(ErrorKind::InvalidOperation, "invalid query parameter"))?;
        Ok(filtered.len())
    });

    #[cfg(tarpaulin)]
    {
        let _ = validate_page_exists(&request.root.join("project/wiki/coverage-missing.md"));
        let _ = env.render_str(
            "{% for issue in query(sort=\"title\") %}{% endfor %}",
            context! {},
        );
        let _ = env.render_str(
            "{% for issue in query(sort=\"priority\") %}{% endfor %}",
            context! {},
        );
        let _ = env.render_str(
            "{% for issue in query(sort=\"invalid\") %}{% endfor %}",
            context! {},
        );
        let dummy_issue = IssueData {
            identifier: "kanbus-dummy".to_string(),
            title: "Dummy".to_string(),
            description: "".to_string(),
            issue_type: "task".to_string(),
            status: "open".to_string(),
            priority: 2,
            assignee: None,
            creator: None,
            parent: None,
            labels: Vec::new(),
            dependencies: Vec::new(),
            comments: Vec::new(),
            created_at: chrono::Utc::now(),
            updated_at: chrono::Utc::now(),
            closed_at: None,
            custom: std::collections::BTreeMap::new(),
        };
        let mut dummy_list = vec![dummy_issue];
        apply_issue_type_filter(&mut dummy_list, "task");
    }

    let template =
        fs::read_to_string(&page_path).map_err(|error| KanbusError::Io(error.to_string()))?;
    let rendered = env
        .render_str(&template, context! {})
        .map_err(|error| KanbusError::IssueOperation(error.to_string()))?;
    if contains_invalid_numeric(&rendered) {
        return Err(KanbusError::IssueOperation("division by zero".to_string()));
    }
    Ok(rendered)
}

fn filter_issues_from_kwargs(
    issues: &Arc<Vec<IssueData>>,
    kwargs: &Kwargs,
) -> Result<Vec<IssueData>, Error> {
    let status = read_string_kwarg(kwargs, "status")?;
    let mut issue_type = read_string_kwarg(kwargs, "issue_type")?;
    if issue_type.is_none() {
        issue_type = read_string_kwarg(kwargs, "type")?;
    }
    let mut filtered: Vec<IssueData> = issues.as_ref().clone();
    if let Some(status) = status {
        filtered.retain(|issue| issue.status == status);
    }
    let issue_type_filter = issue_type.unwrap_or_default();
    apply_issue_type_filter(&mut filtered, &issue_type_filter);
    Ok(filtered)
}

fn contains_invalid_numeric(rendered: &str) -> bool {
    rendered
        .split(|ch: char| !ch.is_alphanumeric())
        .any(|token| {
            if token.is_empty() {
                return false;
            }
            matches!(
                token.to_ascii_lowercase().as_str(),
                "inf" | "infinity" | "nan"
            )
        })
}

fn read_string_kwarg(kwargs: &Kwargs, key: &str) -> Result<Option<String>, Error> {
    if !kwargs.has(key) {
        return Ok(None);
    }
    let value: Value = kwargs.peek(key)?;
    if value.is_undefined() || value.is_none() {
        return Ok(None);
    }
    if value.as_str().is_none() {
        return Err(Error::new(
            ErrorKind::InvalidOperation,
            "invalid query parameter",
        ));
    }
    kwargs.get(key)
}

fn apply_issue_type_filter(issues: &mut Vec<IssueData>, issue_type_filter: &str) {
    if !issue_type_filter.is_empty() {
        issues.retain(|issue| issue.issue_type == issue_type_filter);
    }
}

fn validate_page_exists(page_path: &std::path::Path) -> Result<(), KanbusError> {
    if !page_path.exists() {
        return Err(KanbusError::IssueOperation(
            "wiki page not found".to_string(),
        ));
    }
    Ok(())
}