use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum ManifestError {
#[error("failed to read graphcal.toml: {message}")]
IoError { message: String },
#[error("invalid TOML in graphcal.toml: {message}")]
TomlParseError { message: String },
#[error("missing required field [package].name in graphcal.toml")]
MissingPackageName,
#[error("invalid package name '{name}': must be lower_snake_case")]
InvalidPackageName { name: String },
#[error(
"invalid source_dir '{dir}': must be a relative path inside the \
project root (no absolute paths or `..` components)"
)]
InvalidSourceDir { dir: String },
}
#[derive(Debug, Clone)]
pub struct Manifest {
pub package_name: String,
pub source_dir: PathBuf,
}
pub fn parse_manifest(path: &Path) -> Result<Manifest, ManifestError> {
let content = std::fs::read_to_string(path).map_err(|e| ManifestError::IoError {
message: e.to_string(),
})?;
parse_manifest_str(&content)
}
pub fn parse_manifest_str(content: &str) -> Result<Manifest, ManifestError> {
let arena = toml_spanner::Arena::new();
let root = toml_spanner::parse(content, &arena).map_err(|e| ManifestError::TomlParseError {
message: e.to_string(),
})?;
let name = root["package"]["name"]
.as_str()
.ok_or(ManifestError::MissingPackageName)?;
if !is_valid_package_name(name) {
return Err(ManifestError::InvalidPackageName {
name: name.to_string(),
});
}
let source_dir_str = root["package"]["source_dir"].as_str().unwrap_or("src");
let source_dir = PathBuf::from(source_dir_str);
let escapes_root = source_dir.is_absolute()
|| source_dir.components().any(|c| {
!matches!(
c,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
});
if escapes_root {
return Err(ManifestError::InvalidSourceDir {
dir: source_dir_str.to_string(),
});
}
Ok(Manifest {
package_name: name.to_string(),
source_dir,
})
}
fn is_valid_package_name(s: &str) -> bool {
!s.is_empty()
&& s.starts_with(|c: char| c.is_ascii_lowercase())
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_manifest() {
let manifest = parse_manifest_str("[package]\nname = \"my_package\"\n").unwrap();
assert_eq!(manifest.package_name, "my_package");
assert_eq!(manifest.source_dir, PathBuf::from("src"));
}
#[test]
fn parse_manifest_with_custom_source_dir() {
let manifest =
parse_manifest_str("[package]\nname = \"my_package\"\nsource_dir = \"lib\"\n").unwrap();
assert_eq!(manifest.package_name, "my_package");
assert_eq!(manifest.source_dir, PathBuf::from("lib"));
}
#[test]
fn missing_package_section() {
let result = parse_manifest_str("");
assert!(matches!(result, Err(ManifestError::MissingPackageName)));
}
#[test]
fn missing_package_name() {
let result = parse_manifest_str("[package]\nsource_dir = \"src\"\n");
assert!(matches!(result, Err(ManifestError::MissingPackageName)));
}
#[test]
fn invalid_package_name_uppercase() {
let result = parse_manifest_str("[package]\nname = \"MyPackage\"\n");
assert!(matches!(
result,
Err(ManifestError::InvalidPackageName { .. })
));
}
#[test]
fn invalid_package_name_hyphen() {
let result = parse_manifest_str("[package]\nname = \"my-package\"\n");
assert!(matches!(
result,
Err(ManifestError::InvalidPackageName { .. })
));
}
#[test]
fn valid_package_names() {
assert!(is_valid_package_name("my_package"));
assert!(is_valid_package_name("package"));
assert!(is_valid_package_name("package_v2"));
assert!(is_valid_package_name("p"));
}
#[test]
fn invalid_package_names() {
assert!(!is_valid_package_name("MyPackage"));
assert!(!is_valid_package_name("PACKAGE"));
assert!(!is_valid_package_name("_package"));
assert!(!is_valid_package_name("2package"));
assert!(!is_valid_package_name("my-package"));
assert!(!is_valid_package_name(""));
}
#[test]
fn invalid_toml() {
let result = parse_manifest_str("this is not valid toml [[[");
assert!(matches!(result, Err(ManifestError::TomlParseError { .. })));
}
#[test]
fn empty_manifest_is_missing_package() {
let result = parse_manifest_str("");
assert!(matches!(result, Err(ManifestError::MissingPackageName)));
}
#[test]
fn source_dir_escaping_the_root_is_rejected() {
for dir in ["../elsewhere", "/etc", "a/../../b", "./../x"] {
let toml = format!("[package]\nname = \"pkg\"\nsource_dir = \"{dir}\"\n");
assert!(
matches!(
parse_manifest_str(&toml),
Err(ManifestError::InvalidSourceDir { .. })
),
"source_dir `{dir}` must be rejected"
);
}
}
#[test]
fn relative_source_dir_is_accepted() {
let toml = "[package]\nname = \"pkg\"\nsource_dir = \"lib/nested\"\n";
let manifest = parse_manifest_str(toml).unwrap();
assert_eq!(manifest.source_dir, std::path::PathBuf::from("lib/nested"));
}
}