use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{Result, ScoopError};
pub const MANIFEST_FILE: &str = ".scoop.toml";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ScoopManifest {
pub environment: Environment,
#[serde(default)]
pub packages: Packages,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Environment {
pub name: String,
pub python: String,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Packages {
#[serde(default)]
pub default: Vec<String>,
#[serde(flatten)]
pub groups: BTreeMap<String, Vec<String>>,
}
impl ScoopManifest {
pub fn parse(content: &str) -> Result<Self> {
let manifest: ScoopManifest =
toml::from_str(content).map_err(|e| ScoopError::InvalidArgument {
message: format!(".scoop.toml: {}", e.message()),
})?;
manifest.validate()?;
Ok(manifest)
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn packages_for(&self, extra_groups: &[String]) -> Vec<String> {
let mut all = self.packages.default.clone();
for g in extra_groups {
if let Some(pkgs) = self.packages.groups.get(g) {
all.extend(pkgs.iter().cloned());
}
}
let mut seen = std::collections::HashSet::new();
all.into_iter().filter(|p| seen.insert(p.clone())).collect()
}
pub fn group(&self, name: &str) -> Option<&Vec<String>> {
if name == "default" {
return Some(&self.packages.default);
}
self.packages.groups.get(name)
}
fn validate(&self) -> Result<()> {
if !crate::validate::is_valid_env_name(&self.environment.name) {
return Err(ScoopError::InvalidEnvName {
name: self.environment.name.clone(),
reason: "invalid name in .scoop.toml [environment]".to_string(),
});
}
if self.environment.python.trim().is_empty() {
return Err(ScoopError::InvalidPythonVersion {
version: self.environment.python.clone(),
});
}
Ok(())
}
}
pub fn find_manifest(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
let candidate = current.join(MANIFEST_FILE);
if candidate.is_file() {
return Some(candidate);
}
if !current.pop() {
return None;
}
}
}
pub fn find_manifest_from_cwd() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|d| find_manifest(&d))
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use tempfile::TempDir;
#[rstest]
#[case::minimal(
r#"
[environment]
name = "myproject"
python = "3.12"
"#,
"myproject",
"3.12",
0
)]
#[case::with_default_packages(
r#"
[environment]
name = "webapp"
python = "3.11"
[packages]
default = ["fastapi", "uvicorn"]
"#,
"webapp",
"3.11",
2
)]
#[case::with_extra_groups(
r#"
[environment]
name = "ds"
python = "3.12"
[packages]
default = ["numpy"]
dev = ["ipython", "pytest"]
docs = ["mkdocs"]
"#,
"ds",
"3.12",
1
)]
fn parse_valid(
#[case] toml_src: &str,
#[case] expected_name: &str,
#[case] expected_python: &str,
#[case] expected_default_count: usize,
) {
let m = ScoopManifest::parse(toml_src).expect("valid manifest");
assert_eq!(m.environment.name, expected_name);
assert_eq!(m.environment.python, expected_python);
assert_eq!(m.packages.default.len(), expected_default_count);
}
#[test]
fn parse_rejects_missing_environment_section() {
let err = ScoopManifest::parse(
r#"
[packages]
default = ["pytest"]
"#,
)
.unwrap_err();
assert!(
matches!(err, ScoopError::InvalidArgument { .. }),
"expected InvalidArgument, got {err:?}"
);
}
#[test]
fn parse_rejects_missing_python_field() {
let err = ScoopManifest::parse(
r#"
[environment]
name = "myproject"
"#,
)
.unwrap_err();
assert!(matches!(err, ScoopError::InvalidArgument { .. }));
}
#[test]
fn parse_rejects_reserved_env_name() {
let err = ScoopManifest::parse(
r#"
[environment]
name = "list"
python = "3.12"
"#,
)
.unwrap_err();
assert!(matches!(err, ScoopError::InvalidEnvName { .. }));
}
#[test]
fn parse_rejects_empty_python_version() {
let err = ScoopManifest::parse(
r#"
[environment]
name = "myproject"
python = ""
"#,
)
.unwrap_err();
assert!(matches!(err, ScoopError::InvalidPythonVersion { .. }));
}
#[test]
fn parse_rejects_unknown_top_level_key() {
let err = ScoopManifest::parse(
r#"
[environment]
name = "myproject"
python = "3.12"
[hooks]
post-create = "echo hi"
"#,
)
.unwrap_err();
assert!(matches!(err, ScoopError::InvalidArgument { .. }));
}
fn manifest_with_groups() -> ScoopManifest {
ScoopManifest::parse(
r#"
[environment]
name = "ds"
python = "3.12"
[packages]
default = ["numpy", "pandas"]
dev = ["ipython", "pytest"]
docs = ["mkdocs"]
"#,
)
.unwrap()
}
#[test]
fn packages_for_default_only() {
let m = manifest_with_groups();
assert_eq!(m.packages_for(&[]), vec!["numpy", "pandas"]);
}
#[test]
fn packages_for_includes_extra_group() {
let m = manifest_with_groups();
assert_eq!(
m.packages_for(&["dev".to_string()]),
vec!["numpy", "pandas", "ipython", "pytest"]
);
}
#[test]
fn packages_for_combines_multiple_groups() {
let m = manifest_with_groups();
let pkgs = m.packages_for(&["dev".to_string(), "docs".to_string()]);
assert_eq!(pkgs, vec!["numpy", "pandas", "ipython", "pytest", "mkdocs"]);
}
#[test]
fn packages_for_dedups_first_seen_order() {
let m = ScoopManifest::parse(
r#"
[environment]
name = "x"
python = "3.12"
[packages]
default = ["pkg-a", "pkg-b"]
extra = ["pkg-b", "pkg-c"]
"#,
)
.unwrap();
assert_eq!(
m.packages_for(&["extra".to_string()]),
vec!["pkg-a", "pkg-b", "pkg-c"]
);
}
#[test]
fn packages_for_silently_skips_unknown_group() {
let m = manifest_with_groups();
assert_eq!(
m.packages_for(&["ghost".to_string()]),
vec!["numpy", "pandas"]
);
}
#[test]
fn group_lookup_returns_default() {
let m = manifest_with_groups();
assert_eq!(
m.group("default"),
Some(&vec!["numpy".to_string(), "pandas".to_string()])
);
}
#[test]
fn group_lookup_returns_named_group() {
let m = manifest_with_groups();
assert!(m.group("dev").is_some());
assert!(m.group("ghost").is_none());
}
#[test]
fn find_manifest_locates_in_current_dir() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(MANIFEST_FILE), "").unwrap();
assert_eq!(
find_manifest(dir.path()),
Some(dir.path().join(MANIFEST_FILE))
);
}
#[test]
fn find_manifest_walks_to_parent() {
let root = TempDir::new().unwrap();
let child = root.path().join("nested").join("deep");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(root.path().join(MANIFEST_FILE), "").unwrap();
let found = find_manifest(&child).expect("should walk up to root");
assert_eq!(found, root.path().join(MANIFEST_FILE));
}
#[test]
fn find_manifest_walks_terminate_at_root() {
let _ = find_manifest(Path::new("/"));
}
}