fluidattacks-blends 0.6.0

Blends imperative shell: parsing, AST-graph construction, serialization
Documentation
//! Source content and loading it from disk.

use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

use crate::language::{Language, LanguageExt};

pub mod custom_parsers;

pub const MAX_FILE_SIZE: u64 = 1024 * 500;

#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Content {
    pub bytes: Vec<u8>,
    pub text: String,
    pub language: Language,
    pub path: PathBuf,
}

enum LoadError {
    TooLarge,
    Io,
}

fn read_within_size_limit(path: &Path, max_size: u64) -> Result<Vec<u8>, LoadError> {
    let file = fs::File::open(path).map_err(|_| LoadError::Io)?;

    if file.metadata().map_err(|_| LoadError::Io)?.len() > max_size {
        return Err(LoadError::TooLarge);
    }

    let mut buf = Vec::new();
    file.take(max_size.saturating_add(1))
        .read_to_end(&mut buf)
        .map_err(|_| LoadError::Io)?;

    if u64::try_from(buf.len()).unwrap_or(u64::MAX) > max_size {
        return Err(LoadError::TooLarge);
    }

    Ok(buf)
}

impl Content {
    #[must_use]
    pub fn from_path(path: &Path, max_size: Option<u64>) -> Option<Self> {
        let max_size = max_size.unwrap_or(MAX_FILE_SIZE);

        let Some(language) = Language::from_path(path) else {
            tracing::warn!(path = %path.display(), "skipping file with unsupported extension");
            return None;
        };

        let bytes = match read_within_size_limit(path, max_size) {
            Ok(bytes) => bytes,
            Err(LoadError::TooLarge) => {
                tracing::warn!(path = %path.display(), "file too large, ignoring");
                return None;
            }
            Err(LoadError::Io) => {
                tracing::warn!(path = %path.display(), "unable to read file");
                return None;
            }
        };

        let text = String::from_utf8_lossy(&bytes).into_owned();

        let content = Self {
            bytes,
            text,
            language,
            path: path.to_path_buf(),
        };

        custom_parsers::for_language(language)
            .filter(|cp| cp.validate(&content))
            .find_map(|cp| cp.transform(&content))
            .or(Some(content))
    }
}

#[cfg(test)]
mod tests {
    use super::Content;
    use crate::language::Language;
    use std::fs;
    use std::path::{Path, PathBuf};

    const HELM_JSON: &str = concat!(
        r#"{"apiVersion": "v1", "kind": "Deployment", "metadata": {"name": "test"}, "#,
        r#""spec": {"replicas": {{ .Values.replicas }}}}"#
    );

    const HELM_YAML: &str = concat!(
        "apiVersion: v1\n",
        "kind: Deployment\n",
        "metadata:\n",
        "  name: test\n",
        "spec:\n",
        "  replicas: {{ .Values.replicas }}\n",
    );

    fn write_temp(name: &str, bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(name);
        fs::write(&path, bytes).unwrap();
        (dir, path)
    }

    #[test]
    fn loads_supported_file() {
        let (_dir, path) = write_temp("module.java", b"class A {}");
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Java);
        assert_eq!(content.text, "class A {}");
        assert_eq!(content.bytes, b"class A {}".to_vec());
        assert_eq!(content.path, path);
    }

    #[test]
    fn loads_empty_file() {
        let (_dir, path) = write_temp("empty.java", b"");
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.text, "");
        assert!(content.bytes.is_empty());
    }

    #[test]
    fn loads_multiline_content() {
        let (_dir, path) = write_temp("multi.java", b"line1\nline2\nline3\n");
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.text, "line1\nline2\nline3\n");
    }

    #[test]
    fn loads_special_characters() {
        let source = "print('ñáéíóú中文🚀')";
        let (_dir, path) = write_temp("special.py", source.as_bytes());
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.text, source);
        assert_eq!(content.bytes, source.as_bytes().to_vec());
    }

    #[test]
    fn rejects_unsupported_extension() {
        let (_dir, path) = write_temp("a.unknown", b"whatever");
        assert!(Content::from_path(&path, None).is_none());
    }

    #[test]
    fn rejects_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("missing.java");
        assert!(Content::from_path(&path, None).is_none());
    }

    #[test]
    fn respects_custom_max_size() {
        let (_dir, path) = write_temp("a.java", b"class A {}");

        assert!(Content::from_path(&path, Some(4)).is_none());
        assert!(Content::from_path(&path, Some(1024)).is_some());
    }

    #[test]
    fn json_without_helm_template_is_unchanged() {
        let (_dir, path) = write_temp("config.json", br#"{"key": "value"}"#);
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Json);
        assert_eq!(content.text, r#"{"key": "value"}"#);
    }

    #[test]
    fn yaml_without_helm_template_is_unchanged() {
        let (_dir, path) = write_temp("config.yaml", b"key: value\nother: data");
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Yaml);
        assert_eq!(content.text, "key: value\nother: data");
    }

    #[test]
    fn json_helm_template_outside_templates_dir_is_unchanged() {
        let (_dir, path) = write_temp("deployment.json", HELM_JSON.as_bytes());
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Json);
        assert_eq!(content.text, HELM_JSON);
    }

    #[test]
    fn yaml_helm_template_outside_templates_dir_is_unchanged() {
        let (_dir, path) = write_temp("deployment.yaml", HELM_YAML.as_bytes());
        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Yaml);
        assert_eq!(content.text, HELM_YAML);
    }

    #[test]
    fn json_helm_template_in_templates_dir_is_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        let templates = dir.path().join("templates");
        fs::create_dir(&templates).unwrap();
        let path = templates.join("deployment.json");
        fs::write(&path, HELM_JSON).unwrap();

        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Json);
        assert_eq!(content.text, HELM_JSON);
    }

    #[test]
    fn yaml_helm_template_in_templates_dir_is_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        let templates = dir.path().join("templates");
        fs::create_dir(&templates).unwrap();
        let path = templates.join("deployment.yaml");
        fs::write(&path, HELM_YAML).unwrap();

        let content = Content::from_path(&path, None).unwrap();

        assert_eq!(content.language, Language::Yaml);
        assert_eq!(content.text, HELM_YAML);
    }

    #[test]
    fn fixed_output_matches_python_results() {
        let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/helm_parser");
        let outputs = base.join("outputs");
        fs::create_dir_all(&outputs).unwrap();

        let mut entries: Vec<_> = fs::read_dir(base.join("templates"))
            .unwrap()
            .filter_map(|entry| entry.ok().map(|entry| entry.path()))
            .filter(|path| path.is_file())
            .collect();
        entries.sort();

        let mut mismatches: Vec<String> = Vec::new();
        for path in entries {
            let name = path.file_name().unwrap().to_string_lossy().into_owned();
            let rust = Content::from_path(&path, None).unwrap().text;
            fs::write(outputs.join(&name), &rust).unwrap();

            let expected = fs::read_to_string(base.join("results").join(&name)).unwrap();
            if rust != expected {
                mismatches.push(name);
            }
        }

        assert!(
            mismatches.is_empty(),
            "rust fixed output diverges from python results for: {mismatches:?}"
        );
    }
}