405 lines
13 KiB
Rust
405 lines
13 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ManifestKind {
|
|
App,
|
|
Lib,
|
|
System,
|
|
}
|
|
|
|
impl Default for ManifestKind {
|
|
fn default() -> Self {
|
|
Self::App
|
|
}
|
|
}
|
|
|
|
pub type Alias = String;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(untagged)]
|
|
pub enum DependencySpec {
|
|
Path(String),
|
|
Full(FullDependencySpec),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct FullDependencySpec {
|
|
pub path: Option<String>,
|
|
pub git: Option<String>,
|
|
pub version: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct Manifest {
|
|
pub name: String,
|
|
pub version: String,
|
|
#[serde(default)]
|
|
pub kind: ManifestKind,
|
|
#[serde(default)]
|
|
pub dependencies: BTreeMap<Alias, DependencySpec>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ManifestError {
|
|
Io(std::io::Error),
|
|
Json {
|
|
path: PathBuf,
|
|
error: serde_json::Error,
|
|
},
|
|
Validation {
|
|
path: PathBuf,
|
|
message: String,
|
|
pointer: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl std::fmt::Display for ManifestError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ManifestError::Io(e) => write!(f, "IO error: {}", e),
|
|
ManifestError::Json { path, error } => {
|
|
write!(f, "JSON error in {}: {}", path.display(), error)
|
|
}
|
|
ManifestError::Validation { path, message, pointer } => {
|
|
write!(f, "Validation error in {}: {}", path.display(), message)?;
|
|
if let Some(p) = pointer {
|
|
write!(f, " (at {})", p)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ManifestError {}
|
|
|
|
pub fn load_manifest(project_root: &Path) -> Result<Manifest, ManifestError> {
|
|
let manifest_path = project_root.join("prometeu.json");
|
|
let content = fs::read_to_string(&manifest_path).map_err(ManifestError::Io)?;
|
|
let manifest: Manifest = serde_json::from_str(&content).map_err(|e| ManifestError::Json {
|
|
path: manifest_path.clone(),
|
|
error: e,
|
|
})?;
|
|
|
|
validate_manifest(&manifest, &manifest_path)?;
|
|
|
|
Ok(manifest)
|
|
}
|
|
|
|
fn validate_manifest(manifest: &Manifest, path: &Path) -> Result<(), ManifestError> {
|
|
// Validate name
|
|
if manifest.name.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: "Project name cannot be empty".into(),
|
|
pointer: Some("/name".into()),
|
|
});
|
|
}
|
|
if manifest.name.chars().any(|c| c.is_whitespace()) {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: "Project name cannot contain whitespace".into(),
|
|
pointer: Some("/name".into()),
|
|
});
|
|
}
|
|
|
|
// Validate version (basic check, could be more thorough if we want to enforce semver now)
|
|
if manifest.version.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: "Project version cannot be empty".into(),
|
|
pointer: Some("/version".into()),
|
|
});
|
|
}
|
|
|
|
// Validate dependencies
|
|
for (alias, spec) in &manifest.dependencies {
|
|
if alias.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: "Dependency alias cannot be empty".into(),
|
|
pointer: Some("/dependencies".into()), // Best effort pointer
|
|
});
|
|
}
|
|
if alias.chars().any(|c| c.is_whitespace()) {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Dependency alias '{}' cannot contain whitespace", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
|
|
match spec {
|
|
DependencySpec::Path(p) => {
|
|
if p.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Path for dependency '{}' cannot be empty", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
}
|
|
DependencySpec::Full(full) => {
|
|
match (full.path.as_ref(), full.git.as_ref()) {
|
|
(Some(_), Some(_)) => {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Dependency '{}' must specify exactly one source (path or git), but both were found", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
(None, None) => {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Dependency '{}' must specify exactly one source (path or git), but none were found", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
(Some(p), None) => {
|
|
if p.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Path for dependency '{}' cannot be empty", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
}
|
|
(None, Some(g)) => {
|
|
if g.trim().is_empty() {
|
|
return Err(ManifestError::Validation {
|
|
path: path.to_path_buf(),
|
|
message: format!("Git URL for dependency '{}' cannot be empty", alias),
|
|
pointer: Some(format!("/dependencies/{}", alias)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_parse_minimal_manifest() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "my_project",
|
|
"version": "0.1.0"
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let manifest = load_manifest(dir.path()).unwrap();
|
|
assert_eq!(manifest.name, "my_project");
|
|
assert_eq!(manifest.version, "0.1.0");
|
|
assert_eq!(manifest.kind, ManifestKind::App);
|
|
assert!(manifest.dependencies.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_full_manifest() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "full_project",
|
|
"version": "1.2.3",
|
|
"kind": "lib",
|
|
"dependencies": {
|
|
"std": "../std",
|
|
"core": {
|
|
"git": "https://github.com/prometeu/core",
|
|
"version": "v1.0"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let manifest = load_manifest(dir.path()).unwrap();
|
|
assert_eq!(manifest.name, "full_project");
|
|
assert_eq!(manifest.version, "1.2.3");
|
|
assert_eq!(manifest.kind, ManifestKind::Lib);
|
|
assert_eq!(manifest.dependencies.len(), 2);
|
|
|
|
match manifest.dependencies.get("std").unwrap() {
|
|
DependencySpec::Path(p) => assert_eq!(p, "../std"),
|
|
_ => panic!("Expected path dependency"),
|
|
}
|
|
|
|
match manifest.dependencies.get("core").unwrap() {
|
|
DependencySpec::Full(full) => {
|
|
assert_eq!(full.git.as_ref().unwrap(), "https://github.com/prometeu/core");
|
|
assert_eq!(full.version.as_ref().unwrap(), "v1.0");
|
|
assert!(full.path.is_none());
|
|
}
|
|
_ => panic!("Expected full dependency"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_name_error() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"version": "0.1.0"
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Json { .. }) => {}
|
|
_ => panic!("Expected JSON error due to missing name, got {:?}", result),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_name_error() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "my project",
|
|
"version": "0.1.0"
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Validation { message, pointer, .. }) => {
|
|
assert!(message.contains("whitespace"));
|
|
assert_eq!(pointer.unwrap(), "/name");
|
|
}
|
|
_ => panic!("Expected validation error due to invalid name, got {:?}", result),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_dependency_shape_both_sources() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "test",
|
|
"version": "0.1.0",
|
|
"dependencies": {
|
|
"bad": {
|
|
"path": "./here",
|
|
"git": "https://there"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Validation { message, pointer, .. }) => {
|
|
assert!(message.contains("exactly one source"));
|
|
assert_eq!(pointer.unwrap(), "/dependencies/bad");
|
|
}
|
|
_ => panic!("Expected validation error due to both sources, got {:?}", result),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_dependency_shape_no_source() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "test",
|
|
"version": "0.1.0",
|
|
"dependencies": {
|
|
"bad": {
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Validation { message, pointer, .. }) => {
|
|
assert!(message.contains("exactly one source"));
|
|
assert_eq!(pointer.unwrap(), "/dependencies/bad");
|
|
}
|
|
_ => panic!("Expected validation error due to no source, got {:?}", result),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_dependency_empty_path() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "test",
|
|
"version": "0.1.0",
|
|
"dependencies": {
|
|
"empty": ""
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Validation { message, .. }) => {
|
|
assert!(message.contains("cannot be empty"));
|
|
}
|
|
_ => panic!("Expected validation error due to empty path, got {:?}", result),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_dependency_alias_whitespace() {
|
|
let dir = tempdir().unwrap();
|
|
let manifest_path = dir.path().join("prometeu.json");
|
|
fs::write(
|
|
&manifest_path,
|
|
r#"{
|
|
"name": "test",
|
|
"version": "0.1.0",
|
|
"dependencies": {
|
|
"bad alias": "../std"
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = load_manifest(dir.path());
|
|
match result {
|
|
Err(ManifestError::Validation { message, .. }) => {
|
|
assert!(message.contains("whitespace"));
|
|
}
|
|
_ => panic!("Expected validation error due to whitespace in alias, got {:?}", result),
|
|
}
|
|
}
|
|
}
|