pray-core 1.12.1

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use pray_core::manifest::{
    parse_manifest, DestinationEntry, DestinationMode, ExportRole, Manifest,
};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn corpus_root() -> PathBuf {
    workspace_root().join("testdata/shared/manifest")
}

fn invalid_corpus_root() -> PathBuf {
    workspace_root().join("testdata/shared/manifest-invalid")
}

#[derive(Debug, Deserialize)]
struct ExpectedCorpus {
    targets: Vec<ExpectedTarget>,
    packages: Vec<ExpectedPackage>,
    local: Vec<ExpectedLocal>,
}

#[derive(Debug, Deserialize)]
struct ExpectedTarget {
    name: String,
    mode: String,
    scoped: bool,
    #[serde(default)]
    outputs: Vec<String>,
    #[serde(default)]
    skills: Vec<String>,
    entries: Vec<ExpectedEntry>,
}

#[derive(Debug, Deserialize)]
struct ExpectedEntry {
    kind: String,
    name: Option<String>,
    path: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ExpectedPackage {
    name: String,
    bound: bool,
    roles: Vec<String>,
    #[serde(default)]
    file: Option<String>,
    #[serde(default)]
    path: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ExpectedLocal {
    path: String,
    bound: bool,
}

fn assert_matches_expected(manifest: &Manifest, expected: &ExpectedCorpus) {
    assert_eq!(manifest.targets.len(), expected.targets.len());
    for (target, want) in manifest.targets.iter().zip(expected.targets.iter()) {
        assert_eq!(target.name, want.name);
        let mode = match want.mode.as_str() {
            "compose" => DestinationMode::Compose,
            "tree" => DestinationMode::Tree,
            "legacy" => DestinationMode::Legacy,
            other => panic!("unknown mode {other}"),
        };
        assert_eq!(target.mode, mode);
        assert_eq!(target.scoped, want.scoped);
        assert_eq!(target.outputs, want.outputs);
        assert_eq!(target.skills, want.skills);
        assert_eq!(target.entries.len(), want.entries.len());
        for (entry, want_entry) in target.entries.iter().zip(want.entries.iter()) {
            match (entry, want_entry.kind.as_str()) {
                (DestinationEntry::Local { path }, "local") => {
                    assert_eq!(path, want_entry.path.as_deref().unwrap_or_default());
                }
                (DestinationEntry::Package { name }, "package") => {
                    assert_eq!(name, want_entry.name.as_deref().unwrap_or_default());
                }
                _ => panic!("entry kind mismatch: {entry:?} vs {}", want_entry.kind),
            }
        }
    }

    assert_eq!(manifest.packages.len(), expected.packages.len());
    for (package, want) in manifest.packages.iter().zip(expected.packages.iter()) {
        assert_eq!(package.name, want.name);
        assert_eq!(package.bound, want.bound);
        assert_eq!(package.file, want.file);
        assert_eq!(package.path, want.path);
        let roles: Vec<String> = package
            .roles
            .iter()
            .map(|role| match role {
                ExportRole::Fragment => "fragment".to_string(),
                ExportRole::Folder => "folder".to_string(),
                ExportRole::File => "file".to_string(),
            })
            .collect();
        assert_eq!(roles, want.roles);
    }

    assert_eq!(manifest.local.len(), expected.local.len());
    for (local, want) in manifest.local.iter().zip(expected.local.iter()) {
        assert_eq!(local.path, want.path);
        assert_eq!(local.bound, want.bound);
    }
}

#[test]
fn shared_manifest_corpus_cases_parse() {
    let root = corpus_root();
    let mut cases = Vec::new();
    for entry in fs::read_dir(&root).expect("corpus root") {
        let entry = entry.expect("corpus entry");
        if entry.file_type().expect("type").is_dir() {
            cases.push(entry.file_name());
        }
    }
    cases.sort();
    assert!(
        !cases.is_empty(),
        "expected at least one shared corpus case under {}",
        root.display()
    );

    for case in cases {
        let dir = root.join(&case);
        let text = fs::read_to_string(dir.join("Prayfile"))
            .unwrap_or_else(|error| panic!("Prayfile in {:?}: {error}", case));
        let expected_text = fs::read_to_string(dir.join("expected.json"))
            .unwrap_or_else(|error| panic!("expected.json in {:?}: {error}", case));
        let expected: ExpectedCorpus = serde_json::from_str(&expected_text)
            .unwrap_or_else(|error| panic!("expected.json parses in {:?}: {error}", case));
        let manifest = parse_manifest(&text)
            .unwrap_or_else(|error| panic!("manifest parses in {:?}: {error}", case));
        assert_matches_expected(&manifest, &expected);
    }
}

#[test]
fn shared_manifest_corpus_rejects_unsafe_paths() {
    let root = invalid_corpus_root();
    let mut cases = Vec::new();
    for entry in fs::read_dir(&root).expect("invalid corpus root") {
        let entry = entry.expect("invalid corpus entry");
        if entry.file_type().expect("type").is_dir() {
            cases.push(entry.file_name());
        }
    }
    cases.sort();
    assert!(!cases.is_empty(), "expected shared invalid corpus cases");

    for case in cases {
        let text = fs::read_to_string(root.join(&case).join("Prayfile"))
            .unwrap_or_else(|error| panic!("Prayfile in {case:?}: {error}"));
        assert!(
            parse_manifest(&text).is_err(),
            "manifest in {case:?} should be rejected"
        );
    }
}