diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 276fa09..b0f1bf8 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -224,6 +224,99 @@ pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result Ok(()) } +pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can create collections"); + } + + let mut collections = vault.load_collections()?; + if collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` already exists"); + } + if slug.is_empty() || slug.contains('/') || slug.contains('.') { + anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string"); + } + + collections.collections.push(CollectionDef { + slug: slug.to_string(), + display_name: display_name.to_string(), + created_by: caller.member_id.clone(), + created_at: relicario_core::now_unix(), + }); + vault.save_collections(&collections)?; + + let commit_msg = format!( + "org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}", + caller.display_name, caller.member_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Created collection `{slug}`"); + Ok(()) +} + +pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can grant collection access"); + } + + let collections = vault.load_collections()?; + if !collections.contains_slug(slug) { + anyhow::bail!("collection `{slug}` does not exist — create it first"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if target.collections.contains(&slug.to_string()) { + anyhow::bail!("member already has access to `{slug}`"); + } + target.collections.push(slug.to_string()); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Granted `{slug}` to {}", target_id.as_str()); + Ok(()) +} + +pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + if !caller.role.can_manage_members() { + anyhow::bail!("only owners and admins can revoke collection access"); + } + + let mut members = vault.load_members()?; + let target_id = resolve_member_id(&members, member_id_prefix)?; + let target = members.find_by_id_mut(&target_id).unwrap(); + if !target.collections.contains(&slug.to_string()) { + anyhow::bail!("member does not have access to `{slug}`"); + } + target.collections.retain(|s| s != slug); + vault.save_members(&members)?; + + let commit_msg = format!( + "org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}", + target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?; + + println!("Revoked `{slug}` from {}", target_id.as_str()); + Ok(()) +} + /// Resolve a member_id prefix (or full ID) to a MemberId. fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { let hits: Vec<_> = members.members.iter() @@ -264,4 +357,32 @@ mod tests { } assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin); } + + #[test] + fn grant_adds_slug_to_member_collections() { + let mut members = OrgMembers::new(); + let a = alice(); + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + if !m.collections.contains(&"prod".to_string()) { + m.collections.push("prod".to_string()); + } + assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + } + + #[test] + fn revoke_removes_slug_from_member_collections() { + let mut members = OrgMembers::new(); + let mut a = alice(); + a.collections = vec!["prod".into(), "dev".into()]; + let id = a.member_id.clone(); + members.members.push(a); + + let m = members.find_by_id_mut(&id).unwrap(); + m.collections.retain(|s| s != "prod"); + assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string())); + assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); + } }