fabrica

hanna/fabrica

5140 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica group` — the nested, user-owned repository hierarchy.
6
7use std::path::Path;
8use std::process::ExitCode;
9
10use clap::Subcommand;
11use model::Group;
12use store::Store;
13
14use crate::context::{block_on, open_store};
15use crate::prompt::confirm;
16
17/// Actions under `fabrica group`.
18#[derive(Debug, Subcommand)]
19pub(crate) enum GroupCommand {
20 /// Create a group path, creating any missing intermediate groups.
21 Add {
22 /// The owning user.
23 user: String,
24 /// The group path (`backend/service`).
25 path: String,
26 },
27 /// Delete a group. Descendant groups are removed too; repositories filed under
28 /// it become ungrouped rather than being deleted.
29 Del {
30 /// The owning user.
31 user: String,
32 /// The group path.
33 path: String,
34 /// Skip the confirmation prompt (required when stdin is not a terminal).
35 #[arg(long)]
36 yes: bool,
37 },
38 /// List a user's groups.
39 List {
40 /// The user whose groups to list.
41 user: String,
42 },
43}
44
45/// Dispatch a `group` subcommand.
46pub(crate) fn run(
47 command: &GroupCommand,
48 config_path: Option<&Path>,
49 json: bool,
50) -> anyhow::Result<ExitCode> {
51 match command {
52 GroupCommand::Add { user, path } => block_on(add(config_path, user, path)),
53 GroupCommand::Del { user, path, yes } => block_on(del(config_path, user, path, *yes)),
54 GroupCommand::List { user } => block_on(list(config_path, user, json)),
55 }
56}
57
58/// Look up a user's id by name, or error cleanly.
59async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
60 store
61 .user_by_username(name)
62 .await?
63 .map(|u| u.id)
64 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
65}
66
67async fn add(config_path: Option<&Path>, user: &str, path: &str) -> anyhow::Result<ExitCode> {
68 let (_config, store) = open_store(config_path).await?;
69 let owner_id = require_user_id(&store, user).await?;
70 let group = store.ensure_group_path(&owner_id, path).await?;
71 println!("created group {user}/{}", group.path);
72 Ok(ExitCode::SUCCESS)
73}
74
75async fn del(
76 config_path: Option<&Path>,
77 user: &str,
78 path: &str,
79 yes: bool,
80) -> anyhow::Result<ExitCode> {
81 let (_config, store) = open_store(config_path).await?;
82 let owner_id = require_user_id(&store, user).await?;
83 let group = store
84 .group_by_owner_path(&owner_id, path)
85 .await?
86 .ok_or_else(|| anyhow::anyhow!("no such group {user}/{path}"))?;
87
88 if !confirm(
89 &format!("Delete group {user}/{} and its subgroups?", group.path),
90 yes,
91 )? {
92 println!("aborted");
93 return Ok(ExitCode::SUCCESS);
94 }
95 store.delete_group(&group.id).await?;
96 println!("deleted group {user}/{}", group.path);
97 Ok(ExitCode::SUCCESS)
98}
99
100async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
101 let (_config, store) = open_store(config_path).await?;
102 let owner_id = require_user_id(&store, user).await?;
103 let groups = store.groups_by_owner(&owner_id).await?;
104
105 if json {
106 let view: Vec<_> = groups.iter().map(group_json).collect();
107 println!("{}", serde_json::to_string_pretty(&view)?);
108 return Ok(ExitCode::SUCCESS);
109 }
110 if groups.is_empty() {
111 println!("{user} has no groups");
112 return Ok(ExitCode::SUCCESS);
113 }
114 for group in &groups {
115 println!("{}", group.path);
116 }
117 Ok(ExitCode::SUCCESS)
118}
119
120/// Render a group for `--json`.
121fn group_json(group: &Group) -> serde_json::Value {
122 serde_json::json!({
123 "id": group.id,
124 "name": group.name,
125 "path": group.path,
126 "description": group.description,
127 "created_at": group.created_at,
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 #![allow(clippy::unwrap_used)]
134
135 use super::*;
136 use crate::testutil::env;
137
138 #[test]
139 fn add_creates_path_and_del_removes_subtree() {
140 let env = env();
141 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
142
143 block_on(add(env.path(), "ada", "team/backend")).unwrap();
144 let groups = block_on(async {
145 let store = env.store().await;
146 let owner = require_user_id(&store, "ada").await?;
147 anyhow::Ok(store.groups_by_owner(&owner).await?)
148 })
149 .unwrap();
150 let paths: Vec<&str> = groups.iter().map(|g| g.path.as_str()).collect();
151 assert_eq!(paths, ["team", "team/backend"]);
152
153 block_on(del(env.path(), "ada", "team", true)).unwrap();
154 let after = block_on(async {
155 let store = env.store().await;
156 let owner = require_user_id(&store, "ada").await?;
157 anyhow::Ok(store.groups_by_owner(&owner).await?)
158 })
159 .unwrap();
160 assert!(
161 after.is_empty(),
162 "deleting the top group removes the subtree"
163 );
164 }
165}