// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! `fabrica group` — the nested, user-owned repository hierarchy. use std::path::Path; use std::process::ExitCode; use clap::Subcommand; use model::Group; use store::Store; use crate::context::{block_on, open_store}; use crate::prompt::confirm; /// Actions under `fabrica group`. #[derive(Debug, Subcommand)] pub(crate) enum GroupCommand { /// Create a group path, creating any missing intermediate groups. Add { /// The owning user. user: String, /// The group path (`backend/service`). path: String, }, /// Delete a group. Descendant groups are removed too; repositories filed under /// it become ungrouped rather than being deleted. Del { /// The owning user. user: String, /// The group path. path: String, /// Skip the confirmation prompt (required when stdin is not a terminal). #[arg(long)] yes: bool, }, /// List a user's groups. List { /// The user whose groups to list. user: String, }, } /// Dispatch a `group` subcommand. pub(crate) fn run( command: &GroupCommand, config_path: Option<&Path>, json: bool, ) -> anyhow::Result { match command { GroupCommand::Add { user, path } => block_on(add(config_path, user, path)), GroupCommand::Del { user, path, yes } => block_on(del(config_path, user, path, *yes)), GroupCommand::List { user } => block_on(list(config_path, user, json)), } } /// Look up a user's id by name, or error cleanly. async fn require_user_id(store: &Store, name: &str) -> anyhow::Result { store .user_by_username(name) .await? .map(|u| u.id) .ok_or_else(|| anyhow::anyhow!("no such user {name:?}")) } async fn add(config_path: Option<&Path>, user: &str, path: &str) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let group = store.ensure_group_path(&owner_id, path).await?; println!("created group {user}/{}", group.path); Ok(ExitCode::SUCCESS) } async fn del( config_path: Option<&Path>, user: &str, path: &str, yes: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let group = store .group_by_owner_path(&owner_id, path) .await? .ok_or_else(|| anyhow::anyhow!("no such group {user}/{path}"))?; if !confirm( &format!("Delete group {user}/{} and its subgroups?", group.path), yes, )? { println!("aborted"); return Ok(ExitCode::SUCCESS); } store.delete_group(&group.id).await?; println!("deleted group {user}/{}", group.path); Ok(ExitCode::SUCCESS) } async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let groups = store.groups_by_owner(&owner_id).await?; if json { let view: Vec<_> = groups.iter().map(group_json).collect(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(ExitCode::SUCCESS); } if groups.is_empty() { println!("{user} has no groups"); return Ok(ExitCode::SUCCESS); } for group in &groups { println!("{}", group.path); } Ok(ExitCode::SUCCESS) } /// Render a group for `--json`. fn group_json(group: &Group) -> serde_json::Value { serde_json::json!({ "id": group.id, "name": group.name, "path": group.path, "description": group.description, "created_at": group.created_at, }) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; use crate::testutil::env; #[test] fn add_creates_path_and_del_removes_subtree() { let env = env(); block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); block_on(add(env.path(), "ada", "team/backend")).unwrap(); let groups = block_on(async { let store = env.store().await; let owner = require_user_id(&store, "ada").await?; anyhow::Ok(store.groups_by_owner(&owner).await?) }) .unwrap(); let paths: Vec<&str> = groups.iter().map(|g| g.path.as_str()).collect(); assert_eq!(paths, ["team", "team/backend"]); block_on(del(env.path(), "ada", "team", true)).unwrap(); let after = block_on(async { let store = env.store().await; let owner = require_user_id(&store, "ada").await?; anyhow::Ok(store.groups_by_owner(&owner).await?) }) .unwrap(); assert!( after.is_empty(), "deleting the top group removes the subtree" ); } }