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, pub git: Option, pub version: Option, } #[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, } #[derive(Debug)] pub enum ManifestError { Io(std::io::Error), Json { path: PathBuf, error: serde_json::Error, }, Validation { path: PathBuf, message: String, pointer: Option, }, } 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 { 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), } } }