runmat-runtime 0.5.4

Core runtime for RunMat with builtins, BLAS/LAPACK integration, and execution APIs
Documentation
use std::path::{Path, PathBuf};

use runmat_builtins::{CellArray, Value};
use runmat_filesystem::OpenFileDialogFilter;

use crate::{BuiltinResult, RuntimeError};

pub(crate) type ErrorMapper = fn(String) -> RuntimeError;

pub(crate) fn parse_filter_spec(
    value: &Value,
    invalid_argument: ErrorMapper,
) -> BuiltinResult<Vec<OpenFileDialogFilter>> {
    match value {
        Value::Cell(cell) => parse_filter_cell(cell, invalid_argument),
        other => {
            let text = scalar_text(other, "filter", invalid_argument)?;
            Ok(vec![filter_from_pattern(&text, None)])
        }
    }
}

fn parse_filter_cell(
    cell: &CellArray,
    invalid_argument: ErrorMapper,
) -> BuiltinResult<Vec<OpenFileDialogFilter>> {
    if cell.data.is_empty() {
        return Ok(default_filters());
    }
    if cell.cols == 0 || cell.cols > 2 {
        return Err(invalid_argument(
            "filter cell array must have one or two columns".to_string(),
        ));
    }
    let mut filters = Vec::with_capacity(cell.rows);
    for row in 0..cell.rows {
        let pattern_value = cell.get(row, 0).map_err(invalid_argument)?;
        let pattern = scalar_text(&pattern_value, "filter pattern", invalid_argument)?;
        let description = if cell.cols == 2 {
            let description_value = cell.get(row, 1).map_err(invalid_argument)?;
            Some(scalar_text(
                &description_value,
                "filter description",
                invalid_argument,
            )?)
        } else {
            None
        };
        filters.push(filter_from_pattern(&pattern, description));
    }
    Ok(filters)
}

fn filter_from_pattern(pattern: &str, description: Option<String>) -> OpenFileDialogFilter {
    let patterns = split_filter_patterns(pattern);
    OpenFileDialogFilter {
        patterns,
        description,
    }
}

fn split_filter_patterns(pattern: &str) -> Vec<String> {
    let mut patterns = pattern
        .split(';')
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(str::to_string)
        .collect::<Vec<_>>();
    if patterns.is_empty() {
        patterns.push("*.*".to_string());
    }
    patterns
}

pub(crate) fn default_filters() -> Vec<OpenFileDialogFilter> {
    vec![OpenFileDialogFilter {
        patterns: vec!["*.*".to_string()],
        description: Some("All Files".to_string()),
    }]
}

pub(crate) fn try_scalar_text(value: &Value) -> Option<String> {
    match value {
        Value::String(text) => Some(text.clone()),
        Value::CharArray(chars) if chars.rows == 1 => Some(chars.data.iter().collect()),
        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
        _ => None,
    }
}

pub(crate) fn scalar_text(
    value: &Value,
    context: &str,
    invalid_argument: ErrorMapper,
) -> BuiltinResult<String> {
    try_scalar_text(value).ok_or_else(|| {
        invalid_argument(format!(
            "{context} must be a character vector or string scalar"
        ))
    })
}

pub(crate) struct SelectedPathParts {
    pub directory: String,
    pub file_name: String,
}

pub(crate) fn selected_path_parts(
    path: &Path,
    invalid_selection: ErrorMapper,
) -> BuiltinResult<SelectedPathParts> {
    let text = path.to_string_lossy();
    if text.is_empty() {
        return Err(invalid_selection(
            "selected path has no file name".to_string(),
        ));
    }

    let separator = text
        .char_indices()
        .rev()
        .find(|(_, ch)| *ch == '/' || *ch == '\\');

    let (directory, file_name) = match separator {
        Some((index, separator)) => {
            let file_start = index + separator.len_utf8();
            if file_start >= text.len() {
                return Err(invalid_selection(
                    "selected path has no file name".to_string(),
                ));
            }
            (
                text[..file_start].to_string(),
                text[file_start..].to_string(),
            )
        }
        None => (String::new(), text.into_owned()),
    };

    Ok(SelectedPathParts {
        directory,
        file_name,
    })
}

pub(crate) fn ensure_same_directory(
    paths: &[PathBuf],
    expected: &str,
    invalid_selection: ErrorMapper,
) -> BuiltinResult<()> {
    let expected = normalized_directory_key(expected);
    for path in paths.iter().skip(1) {
        let actual = selected_path_parts(path, invalid_selection)?.directory;
        if normalized_directory_key(&actual) != expected {
            return Err(invalid_selection(
                "multiple selected files must be in the same directory".to_string(),
            ));
        }
    }
    Ok(())
}

fn normalized_directory_key(directory: &str) -> String {
    let mut key = directory.replace('\\', "/");
    while key.len() > 1 && key.ends_with('/') {
        key.pop();
    }
    #[cfg(windows)]
    key.make_ascii_lowercase();
    key
}