Nilton Constantino ada072805e
pr 61.2
2026-02-02 20:16:58 +00:00

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),
}
}
}