use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ManifestError {
#[error("Failed to read manifest file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse manifest YAML: {0}")]
ParseError(#[from] serde_yaml::Error),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Path escapes workspace boundary: {0}")]
PathTraversal(String),
#[error("Gripspace error: {0}")]
GripspaceError(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PlatformType {
#[default]
#[serde(rename = "github")]
GitHub,
#[serde(rename = "gitlab")]
GitLab,
#[serde(rename = "azure-devops")]
AzureDevOps,
#[serde(rename = "bitbucket")]
Bitbucket,
}
impl std::fmt::Display for PlatformType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlatformType::GitHub => write!(f, "github"),
PlatformType::GitLab => write!(f, "gitlab"),
PlatformType::AzureDevOps => write!(f, "azure-devops"),
PlatformType::Bitbucket => write!(f, "bitbucket"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlatformConfig {
#[serde(rename = "type")]
pub platform_type: PlatformType,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopyFileConfig {
pub src: String,
pub dest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkFileConfig {
pub src: String,
pub dest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GripspaceConfig {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeFilePart {
pub src: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gripspace: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeFileConfig {
pub dest: String,
pub parts: Vec<ComposeFilePart>,
#[serde(skip_serializing_if = "Option::is_none")]
pub separator: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RepoAgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentContextTarget {
pub format: String,
pub dest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub compose_with: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceAgentConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conventions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflows: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub targets: Option<Vec<AgentContextTarget>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
pub fetch: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CloneStrategy {
#[default]
Clone,
Worktree,
}
impl std::fmt::Display for CloneStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CloneStrategy::Clone => write!(f, "clone"),
CloneStrategy::Worktree => write!(f, "worktree"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
pub path: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "default_branch"
)]
pub revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_remote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push_remote: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyfile: Option<Vec<CopyFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkfile: Option<Vec<LinkFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<PlatformConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub reference: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<RepoAgentConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_strategy: Option<CloneStrategy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestRepoConfig {
pub url: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "default_branch"
)]
pub revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyfile: Option<Vec<CopyFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkfile: Option<Vec<LinkFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub composefile: Option<Vec<ComposeFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<PlatformConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MergeStrategy {
#[default]
AllOrNothing,
Independent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestSettings {
#[serde(default = "default_pr_prefix")]
pub pr_prefix: String,
#[serde(default)]
pub merge_strategy: MergeStrategy,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "default_branch"
)]
pub revision: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_remote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push_remote: Option<String>,
#[serde(default)]
pub clone_strategy: CloneStrategy,
}
fn default_pr_prefix() -> String {
"[cross-repo]".to_string()
}
impl Default for ManifestSettings {
fn default() -> Self {
Self {
pr_prefix: default_pr_prefix(),
merge_strategy: MergeStrategy::default(),
revision: None,
target: None,
sync_remote: None,
push_remote: None,
clone_strategy: CloneStrategy::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptStep {
pub name: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceScript {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steps: Option<Vec<ScriptStep>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HookCondition {
#[default]
Always,
Changed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookCommand {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repos: Option<Vec<String>>,
#[serde(default)]
pub condition: HookCondition,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceHooks {
#[serde(rename = "post-sync", skip_serializing_if = "Option::is_none")]
pub post_sync: Option<Vec<HookCommand>>,
#[serde(rename = "post-checkout", skip_serializing_if = "Option::is_none")]
pub post_checkout: Option<Vec<HookCommand>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiStep {
pub name: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub continue_on_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiPipeline {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub steps: Vec<CiStep>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CiConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub pipelines: Option<HashMap<String, CiPipeline>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionFileConfig {
pub path: String,
pub pattern: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReleaseConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub version_files: Option<Vec<VersionFileConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub changelog: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_release: Option<Vec<HookCommand>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scripts: Option<HashMap<String, WorkspaceScript>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<WorkspaceHooks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<CiConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<WorkspaceAgentConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<ReleaseConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remotes: Option<HashMap<String, RemoteConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gripspaces: Option<Vec<GripspaceConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<ManifestRepoConfig>,
pub repos: HashMap<String, RepoConfig>,
#[serde(default)]
pub settings: ManifestSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<WorkspaceConfig>,
}
fn default_version() -> u32 {
2
}
impl Manifest {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content)
}
pub fn parse_raw(yaml: &str) -> Result<Self, ManifestError> {
let mut manifest: Manifest = serde_yaml::from_str(yaml)?;
manifest.migrate_v1();
Ok(manifest)
}
fn migrate_v1(&mut self) {
if self.version > 1 {
return;
}
if let Some(ref target) = self.settings.target {
if let Some((remote, branch)) = target.split_once('/') {
if remote != "origin" {
self.settings.sync_remote = Some(remote.to_string());
}
self.settings.target = Some(branch.to_string());
}
}
for repo in self.repos.values_mut() {
if let Some(ref target) = repo.target {
if let Some((remote, branch)) = target.split_once('/') {
if remote != "origin" && repo.sync_remote.is_none() {
repo.sync_remote = Some(remote.to_string());
}
repo.target = Some(branch.to_string());
}
}
}
self.version = 2;
}
pub fn parse(yaml: &str) -> Result<Self, ManifestError> {
let manifest = Self::parse_raw(yaml)?;
manifest.validate()?;
Ok(manifest)
}
pub fn effective_clone_strategy(&self, repo: &RepoConfig) -> CloneStrategy {
if repo.reference {
return CloneStrategy::Clone;
}
repo.clone_strategy.unwrap_or(self.settings.clone_strategy)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.repos.is_empty() {
return Err(ManifestError::ValidationError(
"Manifest must have at least one repository".to_string(),
));
}
for (name, repo) in &self.repos {
self.validate_repo_config(name, repo)?;
}
if let Some(ref manifest_config) = self.manifest {
self.validate_file_configs(
"manifest",
&manifest_config.copyfile,
&manifest_config.linkfile,
)?;
if let Some(ref composefiles) = manifest_config.composefile {
self.validate_composefiles(composefiles)?;
}
}
if let Some(ref gripspaces) = self.gripspaces {
for gs in gripspaces {
if gs.url.is_empty() {
return Err(ManifestError::ValidationError(
"Gripspace has empty URL".to_string(),
));
}
}
}
if let Some(ref workspace) = self.workspace {
self.validate_workspace_config(workspace)?;
}
Ok(())
}
pub fn lint_absolute_paths(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(ref workspace) = self.workspace {
if let Some(ref hooks) = workspace.hooks {
let all_hooks = hooks
.post_sync
.iter()
.flatten()
.chain(hooks.post_checkout.iter().flatten());
for hook in all_hooks {
if hook.command.starts_with('/') || is_windows_absolute(&hook.command) {
warnings.push(format!(
"Hook command contains absolute path: {}",
hook.command.chars().take(80).collect::<String>()
));
}
}
}
if let Some(ref env) = workspace.env {
for (key, val) in env {
if val.starts_with('/') || is_windows_absolute(val) {
warnings.push(format!(
"Env var {} contains absolute path: {}",
key,
val.chars().take(80).collect::<String>()
));
}
}
}
}
warnings
}
pub fn validate_as_gripspace(&self) -> Result<(), ManifestError> {
for (name, repo) in &self.repos {
self.validate_repo_config(name, repo)?;
}
if let Some(ref manifest_config) = self.manifest {
self.validate_file_configs(
"manifest",
&manifest_config.copyfile,
&manifest_config.linkfile,
)?;
if let Some(ref composefiles) = manifest_config.composefile {
self.validate_composefiles(composefiles)?;
}
}
if let Some(ref gripspaces) = self.gripspaces {
for gs in gripspaces {
if gs.url.is_empty() {
return Err(ManifestError::ValidationError(
"Gripspace has empty URL".to_string(),
));
}
}
}
if let Some(ref workspace) = self.workspace {
self.validate_workspace_config(workspace)?;
}
Ok(())
}
fn validate_repo_config(&self, name: &str, repo: &RepoConfig) -> Result<(), ManifestError> {
let has_url = repo.url.as_ref().is_some_and(|u| !u.is_empty());
let has_remote = repo.remote.as_ref().is_some_and(|r| !r.is_empty());
if !has_url && !has_remote {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' must have either a 'url' or 'remote'",
name
)));
}
if let Some(ref remote_name) = repo.remote {
if !remote_name.is_empty() {
let remote_exists = self
.remotes
.as_ref()
.is_some_and(|r| r.contains_key(remote_name));
if !remote_exists {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' references remote '{}' which is not defined in top-level remotes",
name, remote_name
)));
}
}
}
if repo.path.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' must have a path",
name
)));
}
if path_escapes_boundary(&repo.path) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' path escapes workspace boundary: {}",
name, repo.path
)));
}
if repo.reference
&& repo
.clone_strategy
.is_some_and(|s| s == CloneStrategy::Worktree)
{
return Err(ManifestError::ValidationError(format!(
"Repository '{}' is a reference repo and cannot use clone_strategy 'worktree'",
name
)));
}
self.validate_file_configs(name, &repo.copyfile, &repo.linkfile)?;
Ok(())
}
fn validate_file_configs(
&self,
repo_name: &str,
copyfile: &Option<Vec<CopyFileConfig>>,
linkfile: &Option<Vec<LinkFileConfig>>,
) -> Result<(), ManifestError> {
if let Some(ref copyfiles) = copyfile {
for cf in copyfiles {
if cf.src.is_empty() || cf.dest.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' has copyfile with empty src or dest",
repo_name
)));
}
if path_escapes_boundary(&cf.src) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' copyfile src escapes boundary: {}",
repo_name, cf.src
)));
}
if path_escapes_boundary(&cf.dest) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' copyfile dest escapes boundary: {}",
repo_name, cf.dest
)));
}
}
}
if let Some(ref linkfiles) = linkfile {
for lf in linkfiles {
if lf.src.is_empty() || lf.dest.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Repository '{}' has linkfile with empty src or dest",
repo_name
)));
}
if path_escapes_boundary(&lf.src) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' linkfile src escapes boundary: {}",
repo_name, lf.src
)));
}
if path_escapes_boundary(&lf.dest) {
return Err(ManifestError::PathTraversal(format!(
"Repository '{}' linkfile dest escapes boundary: {}",
repo_name, lf.dest
)));
}
}
}
Ok(())
}
fn validate_workspace_config(&self, workspace: &WorkspaceConfig) -> Result<(), ManifestError> {
if let Some(ref scripts) = workspace.scripts {
for (name, script) in scripts {
match (&script.command, &script.steps) {
(Some(_), Some(_)) => {
return Err(ManifestError::ValidationError(format!(
"Script '{}' cannot have both 'command' and 'steps'",
name
)));
}
(None, None) => {
return Err(ManifestError::ValidationError(format!(
"Script '{}' must have either 'command' or 'steps'",
name
)));
}
(None, Some(steps)) => {
for step in steps {
if step.name.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Script '{}' has a step with empty name",
name
)));
}
if step.command.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Script '{}' step '{}' has empty command",
name, step.name
)));
}
}
}
(Some(_), None) => {
}
}
}
}
Ok(())
}
fn validate_composefiles(
&self,
composefiles: &[ComposeFileConfig],
) -> Result<(), ManifestError> {
for cf in composefiles {
if cf.dest.is_empty() {
return Err(ManifestError::ValidationError(
"Composefile has empty dest".to_string(),
));
}
if path_escapes_boundary(&cf.dest) {
return Err(ManifestError::PathTraversal(format!(
"Composefile dest escapes boundary: {}",
cf.dest
)));
}
if cf.parts.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Composefile '{}' has no parts",
cf.dest
)));
}
for part in &cf.parts {
if part.src.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Composefile '{}' has a part with empty src",
cf.dest
)));
}
if path_escapes_boundary(&part.src) {
return Err(ManifestError::PathTraversal(format!(
"Composefile '{}' part src escapes boundary: {}",
cf.dest, part.src
)));
}
if let Some(ref gs_name) = part.gripspace {
if gs_name.is_empty() {
return Err(ManifestError::ValidationError(format!(
"Composefile '{}' has a part with empty gripspace name",
cf.dest
)));
}
if gs_name.contains("..") || gs_name.contains('/') || gs_name.contains('\\') {
return Err(ManifestError::PathTraversal(format!(
"Composefile '{}' gripspace name contains invalid characters: {}",
cf.dest, gs_name
)));
}
}
}
}
Ok(())
}
}
fn is_windows_absolute(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
|| path.starts_with("\\\\")
}
fn path_escapes_boundary(path: &str) -> bool {
let normalized = path.replace('\\', "/");
if normalized.starts_with("..")
|| normalized.starts_with('/')
|| normalized.contains("/../")
|| normalized.ends_with("/..")
|| is_windows_absolute(path)
{
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_manifest() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 1);
assert!(manifest.repos.contains_key("myrepo"));
}
#[test]
fn test_parse_full_manifest() {
let yaml = r#"
version: 1
manifest:
url: git@github.com:user/manifest.git
default_branch: main
repos:
app:
url: git@github.com:user/app.git
path: app
default_branch: main
copyfile:
- src: README.md
dest: APP_README.md
linkfile:
- src: config.yaml
dest: app-config.yaml
settings:
pr_prefix: "[multi-repo]"
merge_strategy: all-or-nothing
workspace:
env:
NODE_ENV: development
scripts:
build:
description: Build all packages
command: npm run build
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.version, 2); assert!(manifest.manifest.is_some());
assert_eq!(manifest.repos.len(), 1);
assert_eq!(manifest.settings.pr_prefix, "[multi-repo]");
}
#[test]
fn test_empty_repos_fails() {
let yaml = r#"
repos: {}
"#;
let result = Manifest::parse(yaml);
assert!(result.is_err());
}
#[test]
fn test_path_traversal_fails() {
let yaml = r#"
repos:
evil:
url: git@github.com:user/repo.git
path: ../outside
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_absolute_path_fails() {
let yaml = r#"
repos:
evil:
url: git@github.com:user/repo.git
path: /etc/passwd
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_script_with_both_command_and_steps_fails() {
let yaml = r#"
repos:
app:
url: git@github.com:user/app.git
path: app
workspace:
scripts:
bad:
command: echo hello
steps:
- name: step1
command: echo step
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_path_escapes_boundary() {
assert!(path_escapes_boundary(".."));
assert!(path_escapes_boundary("../foo"));
assert!(path_escapes_boundary("/etc"));
assert!(path_escapes_boundary("foo/../../../etc"));
assert!(path_escapes_boundary("foo/.."));
assert!(path_escapes_boundary("foo/bar/.."));
assert!(path_escapes_boundary("C:\\Windows\\System32\\drivers\\etc"));
assert!(path_escapes_boundary("C:/Windows/System32/drivers/etc"));
assert!(path_escapes_boundary("C:relative\\path"));
assert!(path_escapes_boundary("\\\\server\\share\\folder"));
assert!(!path_escapes_boundary("foo"));
assert!(!path_escapes_boundary("foo/bar"));
assert!(!path_escapes_boundary("./foo"));
}
#[test]
fn test_reference_repos() {
let yaml = r#"
repos:
main-repo:
url: git@github.com:user/main.git
path: main
ref-repo:
url: https://github.com/other/reference.git
path: ./ref/reference
reference: true
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 2);
let main_repo = manifest.repos.get("main-repo").unwrap();
assert!(!main_repo.reference);
let ref_repo = manifest.repos.get("ref-repo").unwrap();
assert!(ref_repo.reference);
}
#[test]
fn test_manifest_groups_parse() {
let yaml = r#"
repos:
frontend:
url: git@github.com:user/frontend.git
path: frontend
groups: [core, ui]
backend:
url: git@github.com:user/backend.git
path: backend
groups: [core, api]
docs:
url: git@github.com:user/docs.git
path: docs
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.repos.len(), 3);
let frontend = manifest.repos.get("frontend").unwrap();
assert_eq!(frontend.groups, vec!["core", "ui"]);
let backend = manifest.repos.get("backend").unwrap();
assert_eq!(backend.groups, vec!["core", "api"]);
let docs = manifest.repos.get("docs").unwrap();
assert!(docs.groups.is_empty());
}
#[test]
fn test_repos_without_groups_default_empty() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
assert!(repo.groups.is_empty());
}
#[test]
fn test_reference_default_false() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
assert!(!repo.reference); }
#[test]
fn test_parse_gripspaces() {
let yaml = r#"
gripspaces:
- url: https://github.com/user/base-gripspace.git
rev: main
- url: https://github.com/user/other-gripspace.git
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let gripspaces = manifest.gripspaces.unwrap();
assert_eq!(gripspaces.len(), 2);
assert_eq!(
gripspaces[0].url,
"https://github.com/user/base-gripspace.git"
);
assert_eq!(gripspaces[0].rev.as_deref(), Some("main"));
assert_eq!(
gripspaces[1].url,
"https://github.com/user/other-gripspace.git"
);
assert!(gripspaces[1].rev.is_none());
}
#[test]
fn test_parse_composefile() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: CLAUDE.md
parts:
- gripspace: base-gripspace
src: CODI.md
- src: PRIVATE_DOCS.md
separator: "\n\n---\n\n"
- dest: envsetup.sh
parts:
- gripspace: base-gripspace
src: envsetup.sh
- src: private-envsetup.sh
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let manifest_config = manifest.manifest.unwrap();
let composefiles = manifest_config.composefile.unwrap();
assert_eq!(composefiles.len(), 2);
let cf1 = &composefiles[0];
assert_eq!(cf1.dest, "CLAUDE.md");
assert_eq!(cf1.parts.len(), 2);
assert_eq!(cf1.parts[0].gripspace.as_deref(), Some("base-gripspace"));
assert_eq!(cf1.parts[0].src, "CODI.md");
assert!(cf1.parts[1].gripspace.is_none());
assert_eq!(cf1.parts[1].src, "PRIVATE_DOCS.md");
assert_eq!(cf1.separator.as_deref(), Some("\n\n---\n\n"));
let cf2 = &composefiles[1];
assert_eq!(cf2.dest, "envsetup.sh");
assert_eq!(cf2.parts.len(), 2);
}
#[test]
fn test_parse_raw_does_not_validate() {
let yaml = r#"
repos: {}
"#;
let manifest = Manifest::parse_raw(yaml).unwrap();
assert!(manifest.repos.is_empty());
let result = Manifest::parse(yaml);
assert!(result.is_err());
}
#[test]
fn test_gripspace_empty_url_fails() {
let yaml = r#"
gripspaces:
- url: ""
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_composefile_empty_dest_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: ""
parts:
- src: file.md
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_composefile_empty_parts_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: output.md
parts: []
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_composefile_path_traversal_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: ../outside.md
parts:
- src: file.md
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_composefile_gripspace_name_traversal_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: output.md
parts:
- gripspace: "../evil"
src: file.md
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_composefile_empty_gripspace_name_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: output.md
parts:
- gripspace: ""
src: file.md
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_validate_as_gripspace_composefile_empty_parts_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: output.md
parts: []
repos: {}
"#;
let manifest = Manifest::parse_raw(yaml).unwrap();
let result = manifest.validate_as_gripspace();
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_validate_as_gripspace_composefile_invalid_gripspace_name_fails() {
let yaml = r#"
manifest:
url: git@github.com:user/manifest.git
composefile:
- dest: output.md
parts:
- gripspace: "../evil"
src: file.md
repos: {}
"#;
let manifest = Manifest::parse_raw(yaml).unwrap();
let result = manifest.validate_as_gripspace();
assert!(matches!(result, Err(ManifestError::PathTraversal(_))));
}
#[test]
fn test_parse_repo_agent_config() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
agent:
description: "Rust CLI tool"
language: rust
build: "cargo build"
test: "cargo test"
lint: "cargo clippy"
format: "cargo fmt"
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
let agent = repo.agent.as_ref().unwrap();
assert_eq!(agent.description.as_deref(), Some("Rust CLI tool"));
assert_eq!(agent.language.as_deref(), Some("rust"));
assert_eq!(agent.build.as_deref(), Some("cargo build"));
assert_eq!(agent.test.as_deref(), Some("cargo test"));
assert_eq!(agent.lint.as_deref(), Some("cargo clippy"));
assert_eq!(agent.format.as_deref(), Some("cargo fmt"));
}
#[test]
fn test_parse_workspace_agent_config() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
workspace:
agent:
description: "Multi-repo workspace"
conventions:
- "Use conventional commits"
- "All PRs require review"
workflows:
deploy: "./scripts/deploy.sh"
release: "gr run release"
"#;
let manifest = Manifest::parse(yaml).unwrap();
let workspace = manifest.workspace.as_ref().unwrap();
let agent = workspace.agent.as_ref().unwrap();
assert_eq!(agent.description.as_deref(), Some("Multi-repo workspace"));
assert_eq!(agent.conventions.len(), 2);
assert_eq!(agent.conventions[0], "Use conventional commits");
assert_eq!(agent.conventions[1], "All PRs require review");
let workflows = agent.workflows.as_ref().unwrap();
assert_eq!(workflows.get("deploy").unwrap(), "./scripts/deploy.sh");
assert_eq!(workflows.get("release").unwrap(), "gr run release");
}
#[test]
fn test_agent_config_optional() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
assert!(repo.agent.is_none());
assert!(manifest.workspace.is_none());
}
#[test]
fn test_agent_config_serialization_roundtrip() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
agent:
description: "Test repo"
language: rust
build: "cargo build"
test: "cargo test"
workspace:
agent:
description: "Test workspace"
conventions:
- "convention one"
workflows:
deploy: "deploy.sh"
"#;
let manifest = Manifest::parse(yaml).unwrap();
let serialized = serde_yaml::to_string(&manifest).unwrap();
let reparsed = Manifest::parse(&serialized).unwrap();
let orig_agent = manifest
.repos
.get("myrepo")
.unwrap()
.agent
.as_ref()
.unwrap();
let re_agent = reparsed
.repos
.get("myrepo")
.unwrap()
.agent
.as_ref()
.unwrap();
assert_eq!(orig_agent.description, re_agent.description);
assert_eq!(orig_agent.language, re_agent.language);
assert_eq!(orig_agent.build, re_agent.build);
assert_eq!(orig_agent.test, re_agent.test);
let orig_ws_agent = manifest.workspace.as_ref().unwrap().agent.as_ref().unwrap();
let re_ws_agent = reparsed.workspace.as_ref().unwrap().agent.as_ref().unwrap();
assert_eq!(orig_ws_agent.description, re_ws_agent.description);
assert_eq!(orig_ws_agent.conventions, re_ws_agent.conventions);
assert_eq!(orig_ws_agent.workflows, re_ws_agent.workflows);
}
#[test]
fn test_repo_agent_config_partial() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
agent:
language: python
test: "pytest"
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = manifest.repos.get("myrepo").unwrap();
let agent = repo.agent.as_ref().unwrap();
assert!(agent.description.is_none());
assert_eq!(agent.language.as_deref(), Some("python"));
assert!(agent.build.is_none());
assert_eq!(agent.test.as_deref(), Some("pytest"));
assert!(agent.lint.is_none());
assert!(agent.format.is_none());
}
#[test]
fn test_manifest_with_no_gripspaces() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert!(manifest.gripspaces.is_none());
}
#[test]
fn test_clone_strategy_defaults_to_clone() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
"#;
let manifest = Manifest::parse(yaml).unwrap();
let repo = &manifest.repos["myrepo"];
assert_eq!(manifest.settings.clone_strategy, CloneStrategy::Clone);
assert!(repo.clone_strategy.is_none());
assert_eq!(
manifest.effective_clone_strategy(repo),
CloneStrategy::Clone
);
}
#[test]
fn test_clone_strategy_per_repo_override() {
let yaml = r#"
repos:
cloned:
url: git@github.com:user/a.git
path: a
worktree:
url: git@github.com:user/b.git
path: b
clone_strategy: worktree
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(
manifest.effective_clone_strategy(&manifest.repos["cloned"]),
CloneStrategy::Clone
);
assert_eq!(
manifest.effective_clone_strategy(&manifest.repos["worktree"]),
CloneStrategy::Worktree
);
}
#[test]
fn test_clone_strategy_global_override() {
let yaml = r#"
repos:
myrepo:
url: git@github.com:user/repo.git
path: repo
settings:
clone_strategy: worktree
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.settings.clone_strategy, CloneStrategy::Worktree);
assert_eq!(
manifest.effective_clone_strategy(&manifest.repos["myrepo"]),
CloneStrategy::Worktree
);
}
#[test]
fn test_reference_repo_always_clone() {
let yaml = r#"
repos:
refonly:
url: https://github.com/other/repo.git
path: reference/repo
reference: true
clone_strategy: worktree
settings:
clone_strategy: worktree
"#;
let result = Manifest::parse(yaml);
assert!(matches!(result, Err(ManifestError::ValidationError(_))));
}
#[test]
fn test_reference_repo_effective_strategy_always_clone() {
let yaml = r#"
repos:
refonly:
url: https://github.com/other/repo.git
path: reference/repo
reference: true
settings:
clone_strategy: worktree
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(
manifest.effective_clone_strategy(&manifest.repos["refonly"]),
CloneStrategy::Clone
);
}
#[test]
fn test_clone_strategy_backward_compat_no_field() {
let yaml = r#"
version: 2
repos:
synapt:
url: git@github.com:user/synapt.git
path: ./synapt
revision: main
settings:
pr_prefix: "[cross-repo]"
merge_strategy: independent
"#;
let manifest = Manifest::parse(yaml).unwrap();
assert_eq!(manifest.settings.clone_strategy, CloneStrategy::Clone);
assert_eq!(
manifest.effective_clone_strategy(&manifest.repos["synapt"]),
CloneStrategy::Clone
);
}
#[test]
fn test_lint_catches_absolute_paths_in_hooks() {
let yaml = r#"
repos:
myrepo:
url: https://github.com/test/repo.git
path: ./myrepo
workspace:
hooks:
post-sync:
- command: "/usr/local/bin/setup.sh"
post-checkout:
- command: "echo ok"
env:
HOME_DIR: "/Users/layne/Development"
"#;
let manifest: Manifest = serde_yaml::from_str(yaml).unwrap();
let warnings = manifest.lint_absolute_paths();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].contains("Hook command"));
assert!(warnings[1].contains("Env var"));
}
#[test]
fn test_lint_no_warnings_for_relative_paths() {
let yaml = r#"
repos:
myrepo:
url: https://github.com/test/repo.git
path: ./myrepo
workspace:
hooks:
post-sync:
- command: "./scripts/setup.sh"
env:
PROJECT: "myproject"
"#;
let manifest: Manifest = serde_yaml::from_str(yaml).unwrap();
let warnings = manifest.lint_absolute_paths();
assert!(warnings.is_empty());
}
}