//! `pic groups` — manage the org-tree groups (Phase 2). //! //! Wraps `/api/v1/admin/groups*`. Structural mutations are gated //! server-side: create/reparent/delete need group-admin (reparent at both //! source and destination parent); the slug is frozen at creation. use std::collections::BTreeMap; use anyhow::Result; use picloud_shared::{AppRole, Group}; use crate::client::{Client, CreateGroupBody}; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; pub async fn ls(mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let groups = client.groups_list_with_owner().await?; let mut table = Table::new(["slug", "name", "parent", "owner", "created_at"]); let by_id: BTreeMap<_, _> = groups .iter() .map(|g| (g.group.id, g.group.slug.clone())) .collect(); for g in &groups { let parent = g .group .parent_id .and_then(|p| by_id.get(&p).cloned()) .unwrap_or_else(|| "-".into()); table.row([ g.group.slug.clone(), g.group.name.clone(), parent, g.owner.clone().unwrap_or_else(|| "—".into()), g.group.created_at.to_rfc3339(), ]); } table.print(mode); Ok(()) } /// `pic groups tree` — render the hierarchy as an indented tree (text /// mode); falls back to the flat list for `--output json`. pub async fn tree(mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let groups = client.groups_list().await?; if matches!(mode, OutputMode::Json) { // Machine consumers get the flat list; the shape carries parent_id. println!("{}", serde_json::to_string_pretty(&groups)?); return Ok(()); } // children-by-parent, then DFS from the roots. let mut children: BTreeMap, Vec<&Group>> = BTreeMap::new(); for g in &groups { children.entry(g.parent_id).or_default().push(g); } for kids in children.values_mut() { kids.sort_by(|a, b| a.name.cmp(&b.name)); } print_subtree(&children, None, 0); Ok(()) } fn print_subtree( children: &BTreeMap, Vec<&Group>>, parent: Option, depth: usize, ) { let Some(kids) = children.get(&parent) else { return; }; for g in kids { println!("{}{} ({})", " ".repeat(depth), g.name, g.slug); print_subtree(children, Some(g.id), depth + 1); } } pub async fn create( slug: &str, name: Option<&str>, description: Option<&str>, parent: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let body = CreateGroupBody { slug, name: name.unwrap_or(slug), description, parent, }; let group = client.groups_create(&body).await?; print_group(&group, mode); Ok(()) } pub async fn show(ident: &str, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let detail = client.groups_get(ident).await?; let path = detail .path .iter() .map(|g| g.slug.as_str()) .collect::>() .join(" / "); let mut block = KvBlock::new(); block .field("id", detail.group.id.to_string()) .field("slug", detail.group.slug.clone()) .field("name", detail.group.name.clone()) .field("path", if path.is_empty() { "-".into() } else { path }) .field( "subgroups", detail .subgroups .iter() .map(|g| g.slug.as_str()) .collect::>() .join(", "), ) .field( "apps", detail .apps .iter() .map(|a| a.slug.as_str()) .collect::>() .join(", "), ); block.print(mode); Ok(()) } pub async fn rename( ident: &str, name: Option<&str>, description: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let group = client.groups_rename(ident, name, description).await?; print_group(&group, mode); Ok(()) } pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let group = client.groups_reparent(ident, to).await?; print_group(&group, mode); Ok(()) } /// `pic groups rm `. The server enforces delete=RESTRICT (409 on a /// non-empty group); `--recursive` expands the delete into ordered, /// leaf-first child deletions (groups + apps) the operator opted into. pub async fn rm(ident: &str, recursive: bool) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; if !recursive { client.groups_delete(ident).await?; println!("Deleted group {ident}"); return Ok(()); } // Recursive: delete the subtree leaf-first so each DB delete sees an // empty node (the FK stays RESTRICT — we never cascade implicitly). let detail = client.groups_get(ident).await?; if let Some(app) = detail.apps.first() { anyhow::bail!( "group {ident} contains app {:?}; move or delete apps before a recursive group delete \ (apps are never auto-deleted)", app.slug ); } for sub in &detail.subgroups { Box::pin(rm(&sub.slug, true)).await?; } client.groups_delete(ident).await?; println!("Deleted group {ident}"); Ok(()) } // --- members --------------------------------------------------------------- pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let members = client.group_members_list(group).await?; let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]); for m in members { table.row([ m.user_id.to_string(), m.username, m.role.as_str().to_string(), format!("{:?}", m.instance_role).to_lowercase(), m.is_active.to_string(), ]); } table.print(mode); Ok(()) } pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let m = client .group_members_grant(group, user_id, parse_role(role)?) .await?; print_member(&m, mode); Ok(()) } pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let m = client .group_members_set_role(group, user_id, parse_role(role)?) .await?; print_member(&m, mode); Ok(()) } pub async fn members_rm(group: &str, user_id: &str) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; client.group_members_remove(group, user_id).await?; println!("Removed {user_id} from {group}"); Ok(()) } fn print_group(g: &Group, mode: OutputMode) { let mut block = KvBlock::new(); block .field("id", g.id.to_string()) .field("slug", g.slug.clone()) .field("name", g.name.clone()) .field( "parent_id", g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()), ) .field("created_at", g.created_at.to_rfc3339()); block.print(mode); } fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) { let mut block = KvBlock::new(); block .field("user_id", m.user_id.to_string()) .field("username", m.username.clone()) .field("role", m.role.as_str().to_string()); block.print(mode); } fn parse_role(role: &str) -> Result { AppRole::from_db_str(role) .ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer")) }