use crate::utils::error::Result;
use glob::glob;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct PackConventions {
pub template_patterns: &'static [&'static str],
pub rdf_patterns: &'static [&'static str],
pub query_patterns: &'static [&'static str],
pub shape_patterns: &'static [&'static str],
}
impl Default for PackConventions {
fn default() -> Self {
Self {
template_patterns: &["templates/**/*.tmpl", "templates/**/*.tera"],
rdf_patterns: &[
"templates/**/graphs/*.ttl",
"templates/**/graphs/*.rdf",
"templates/**/graphs/*.jsonld",
],
query_patterns: &["templates/**/queries/*.rq", "templates/**/queries/*.sparql"],
shape_patterns: &[
"templates/**/graphs/shapes/*.shacl.ttl",
"templates/**/shapes/*.ttl",
],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GpackManifest {
#[serde(rename = "gpack")]
pub metadata: GpackMetadata,
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
#[serde(default)]
pub templates: TemplatesConfig,
#[serde(default)]
pub macros: MacrosConfig,
#[serde(default)]
pub rdf: RdfConfig,
#[serde(default)]
pub queries: QueriesConfig,
#[serde(default)]
pub shapes: ShapesConfig,
#[serde(default)]
pub preset: PresetConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GpackMetadata {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub license: String,
pub ggen_compat: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct TemplatesConfig {
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub includes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct MacrosConfig {
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct RdfConfig {
#[serde(default)]
pub base: Option<String>,
#[serde(default)]
pub prefixes: BTreeMap<String, String>,
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub inline: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct QueriesConfig {
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ShapesConfig {
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct PresetConfig {
#[serde(default)]
pub config: Option<PathBuf>,
#[serde(default)]
pub vars: BTreeMap<String, String>,
}
fn discover_files(base_path: &Path, patterns: &[&str]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for pattern in patterns {
let full_pattern = base_path.join(pattern);
for entry in glob(&full_pattern.to_string_lossy())
.map_err(|e| crate::utils::error::Error::new(&format!("Invalid glob pattern: {}", e)))?
{
files.push(
entry
.map_err(|e| crate::utils::error::Error::new(&format!("Glob error: {}", e)))?,
);
}
}
files.sort(); Ok(files)
}
impl GpackManifest {
pub fn load_from_file(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
let manifest: GpackManifest = toml::from_str(&content)?;
Ok(manifest)
}
pub fn discover_templates(&self, base_path: &Path) -> Result<Vec<PathBuf>> {
let patterns = if self.templates.patterns.is_empty() {
PackConventions::default().template_patterns
} else {
&self
.templates
.patterns
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
};
discover_files(base_path, patterns)
}
pub fn discover_rdf_files(&self, base_path: &Path) -> Result<Vec<PathBuf>> {
let patterns = if self.rdf.patterns.is_empty() {
PackConventions::default().rdf_patterns
} else {
&self
.rdf
.patterns
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
};
discover_files(base_path, patterns)
}
pub fn discover_query_files(&self, base_path: &Path) -> Result<Vec<PathBuf>> {
let patterns = if self.queries.patterns.is_empty() {
PackConventions::default().query_patterns
} else {
&self
.queries
.patterns
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
};
discover_files(base_path, patterns)
}
pub fn discover_shape_files(&self, base_path: &Path) -> Result<Vec<PathBuf>> {
let patterns = if self.shapes.patterns.is_empty() {
PackConventions::default().shape_patterns
} else {
&self
.shapes
.patterns
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
};
discover_files(base_path, patterns)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_manifest_parsing() {
let toml_content = r#"
[gpack]
id = "io.ggen.rust.cli-subcommand"
name = "Rust CLI subcommand"
version = "0.1.0"
description = "Generate clap subcommands"
license = "MIT"
ggen_compat = ">=0.1 <0.2"
[dependencies]
"io.ggen.macros.std" = "^0.1"
[templates]
patterns = ["cli/subcommand/*.tmpl"]
includes = ["macros/**/*.tera"]
[rdf]
base = "http://example.org/"
prefixes.ex = "http://example.org/"
patterns = ["templates/**/graphs/*.ttl"]
inline = ["@prefix ex: <http://example.org/> . ex:Foo a ex:Type ."]
[queries]
patterns = ["../queries/*.rq"]
aliases.component_by_name = "../queries/component_by_name.rq"
[shapes]
patterns = ["../shapes/*.ttl"]
[preset]
config = "../preset/ggen.toml"
vars = { author = "Acme", license = "MIT" }
"#;
let manifest: GpackManifest = toml::from_str(toml_content).unwrap();
assert_eq!(manifest.metadata.id, "io.ggen.rust.cli-subcommand");
assert_eq!(manifest.metadata.name, "Rust CLI subcommand");
assert_eq!(manifest.metadata.version, "0.1.0");
assert_eq!(manifest.templates.patterns.len(), 1);
assert_eq!(manifest.rdf.patterns.len(), 1);
assert_eq!(manifest.queries.aliases.len(), 1);
}
#[test]
fn test_manifest_load_from_file() {
let mut temp_file = NamedTempFile::new().unwrap();
let toml_content = r#"
[gpack]
id = "test"
name = "Test"
version = "0.1.0"
description = "Test"
license = "MIT"
ggen_compat = ">=0.1 <0.2"
"#;
temp_file.write_all(toml_content.as_bytes()).unwrap();
let manifest = GpackManifest::load_from_file(&temp_file.path().to_path_buf()).unwrap();
assert_eq!(manifest.metadata.id, "test");
}
#[cfg(feature = "proptest")]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn gpack_manifest_parsing_idempotent(
pack_id in r"[a-zA-Z0-9_\-\.]+",
pack_name in r"[a-zA-Z0-9_\s\-\.]+",
pack_version in r"[0-9]+\.[0-9]+\.[0-9]+",
pack_description in r"[a-zA-Z0-9_\s\-\.\/\?\!]+",
pack_license in r"[a-zA-Z0-9_\s\-\.]+",
ggen_compat in r"[><=0-9\.\s]+"
) {
if pack_id.is_empty() || pack_name.is_empty() || pack_description.is_empty() {
return Ok(());
}
const MAX_GPACK_ID_LEN: usize = 100;
const MAX_GPACK_NAME_LEN: usize = 200;
const MAX_GPACK_DESCRIPTION_LEN: usize = 500;
if pack_id.len() > MAX_GPACK_ID_LEN || pack_name.len() > MAX_GPACK_NAME_LEN || pack_description.len() > MAX_GPACK_DESCRIPTION_LEN {
return Ok(());
}
let manifest = GpackManifest {
metadata: GpackMetadata {
id: pack_id.clone(),
name: pack_name.clone(),
version: pack_version.clone(),
description: pack_description.clone(),
license: pack_license.clone(),
ggen_compat: ggen_compat.clone(),
},
dependencies: BTreeMap::new(),
templates: TemplatesConfig::default(),
macros: MacrosConfig::default(),
rdf: RdfConfig::default(),
queries: QueriesConfig::default(),
shapes: ShapesConfig::default(),
preset: PresetConfig::default(),
};
let toml_str = toml::to_string(&manifest).unwrap();
let parsed_manifest: GpackManifest = toml::from_str(&toml_str).unwrap();
assert_eq!(manifest.metadata.id, parsed_manifest.metadata.id);
assert_eq!(manifest.metadata.name, parsed_manifest.metadata.name);
assert_eq!(manifest.metadata.version, parsed_manifest.metadata.version);
assert_eq!(manifest.metadata.description, parsed_manifest.metadata.description);
assert_eq!(manifest.metadata.license, parsed_manifest.metadata.license);
assert_eq!(manifest.metadata.ggen_compat, parsed_manifest.metadata.ggen_compat);
}
#[test]
fn gpack_metadata_validation(
pack_id in r"[a-zA-Z0-9_\-\.]+",
pack_name in r"[a-zA-Z0-9_\s\-\.]+",
pack_version in r"[0-9]+\.[0-9]+\.[0-9]+",
pack_description in r"[a-zA-Z0-9_\s\-\.\/\?\!]+"
) {
if pack_id.is_empty() || pack_name.is_empty() || pack_description.is_empty() {
return Ok(());
}
const MAX_GPACK_ID_LEN: usize = 100;
const MAX_GPACK_NAME_LEN: usize = 200;
const MAX_GPACK_DESCRIPTION_LEN: usize = 500;
if pack_id.len() > MAX_GPACK_ID_LEN || pack_name.len() > MAX_GPACK_NAME_LEN || pack_description.len() > MAX_GPACK_DESCRIPTION_LEN {
return Ok(());
}
let metadata = GpackMetadata {
id: pack_id.clone(),
name: pack_name.clone(),
version: pack_version.clone(),
description: pack_description.clone(),
license: "MIT".to_string(),
ggen_compat: ">=0.1 <0.2".to_string(),
};
assert!(!metadata.id.is_empty());
assert!(!metadata.name.is_empty());
assert!(!metadata.version.is_empty());
assert!(!metadata.description.is_empty());
assert!(!metadata.license.is_empty());
assert!(!metadata.ggen_compat.is_empty());
if metadata.id.contains('.') {
assert!(
metadata.id.split('.').count() >= 2,
"ID should have at least 2 parts separated by dots"
);
}
let version_result = semver::Version::parse(&metadata.version);
match version_result {
Ok(_) => {
},
Err(_) => {
}
}
}
#[test]
fn template_patterns_validation(
pattern_count in 0..10usize,
pattern in r"[a-zA-Z0-9_\-\.\/\*\?\[\]]+"
) {
const MAX_TEMPLATE_PATTERN_LEN: usize = 100;
if pattern.is_empty() || pattern.len() > MAX_TEMPLATE_PATTERN_LEN {
return Ok(());
}
let mut patterns = Vec::new();
for i in 0..pattern_count {
patterns.push(format!("{}-{}", pattern, i));
}
let config = TemplatesConfig {
patterns: patterns.clone(),
includes: Vec::new(),
};
assert_eq!(config.patterns.len(), pattern_count);
for (i, pattern_item) in config.patterns.iter().enumerate() {
let expected_pattern = format!("{}-{}", pattern, i);
assert_eq!(pattern_item, &expected_pattern);
}
}
#[test]
fn rdf_prefixes_validation(
prefix_count in 0..10usize,
prefix_name in r"[a-zA-Z0-9_\-]+",
prefix_uri in r"https: ) {
if prefix_name.is_empty() || prefix_uri.is_empty() {
return Ok(());
}
const MAX_PREFIX_NAME_LEN: usize = 50;
const MAX_PREFIX_URI_LEN: usize = 200;
if prefix_name.len() > MAX_PREFIX_NAME_LEN || prefix_uri.len() > MAX_PREFIX_URI_LEN {
return Ok(());
}
let mut prefixes = BTreeMap::new();
for i in 0..prefix_count {
let name = format!("{}-{}", prefix_name, i);
let uri = format!("{}-{}", prefix_uri, i);
prefixes.insert(name, uri);
}
let config = RdfConfig {
base: Some("https://example.org/".to_string()),
prefixes: prefixes.clone(),
patterns: Vec::new(),
inline: Vec::new(),
};
assert_eq!(config.prefixes.len(), prefix_count);
for (name, uri) in &config.prefixes {
assert!(name.contains(&prefix_name));
assert!(uri.contains("https://"));
}
}
}
}
}