use clap::Subcommand;
use sqlx::Row as _;
use rustio_admin::{auth, Db};
#[derive(Subcommand)]
pub enum Action {
Create {
#[arg(long)]
name: String,
#[arg(long, default_value = "")]
description: String,
},
List,
Show {
#[arg(long)]
name: String,
},
AddUser {
#[arg(long)]
email: String,
#[arg(long)]
group: String,
},
RemoveUser {
#[arg(long)]
email: String,
#[arg(long)]
group: String,
},
}
pub async fn run(action: Action) -> Result<(), String> {
let db = crate::db().await?;
match action {
Action::Create { name, description } => create(db, name, description).await,
Action::List => list(db).await,
Action::Show { name } => show(db, name).await,
Action::AddUser { email, group } => add_user(db, email, group).await,
Action::RemoveUser { email, group } => remove_user(db, email, group).await,
}
}
async fn create(db: Db, name: String, description: String) -> Result<(), String> {
auth::init_permission_tables(&db)
.await
.map_err(|e| format!("init: {e}"))?;
let id = auth::create_group(&db, &name, &description)
.await
.map_err(|e| format!("create_group: {e}"))?;
println!("Created group id={id} name={name}");
Ok(())
}
async fn list(db: Db) -> Result<(), String> {
let rows = sqlx::query(
"SELECT g.id,
g.name,
g.description,
COUNT(ug.user_id) AS member_count
FROM rustio_groups g
LEFT JOIN rustio_user_groups ug ON ug.group_id = g.id
GROUP BY g.id, g.name, g.description
ORDER BY g.name ASC",
)
.fetch_all(db.pool())
.await
.map_err(|e| format!("query: {e}"))?;
if rows.is_empty() {
println!("No groups.");
return Ok(());
}
println!("{:>4} {:<24} {:>7} DESCRIPTION", "ID", "NAME", "MEMBERS");
for r in rows {
let id: i64 = r.try_get("id").unwrap_or(0);
let name: String = r.try_get("name").unwrap_or_default();
let desc: String = r.try_get("description").unwrap_or_default();
let count: i64 = r.try_get("member_count").unwrap_or(0);
println!("{id:>4} {name:<24} {count:>7} {desc}");
}
Ok(())
}
async fn add_user(db: Db, email: String, group: String) -> Result<(), String> {
let user = auth::find_user_by_email(&db, &email)
.await
.map_err(|e| format!("lookup user: {e}"))?
.ok_or_else(|| format!("no user with email {email}"))?;
let gid = lookup_group(&db, &group).await?;
auth::add_user_to_group(&db, user.id, gid)
.await
.map_err(|e| format!("add_user_to_group: {e}"))?;
println!("Added {email} to group {group}");
Ok(())
}
async fn remove_user(db: Db, email: String, group: String) -> Result<(), String> {
let user = auth::find_user_by_email(&db, &email)
.await
.map_err(|e| format!("lookup user: {e}"))?
.ok_or_else(|| format!("no user with email {email}"))?;
let gid = lookup_group(&db, &group).await?;
auth::remove_user_from_group(&db, user.id, gid)
.await
.map_err(|e| format!("remove_user_from_group: {e}"))?;
println!("Removed {email} from group {group}");
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GroupReport {
id: i64,
name: String,
description: String,
members: Vec<GroupMember>,
permissions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GroupMember {
email: String,
role: String,
is_active: bool,
}
async fn show(db: Db, name: String) -> Result<(), String> {
let row = sqlx::query("SELECT id, name, description FROM rustio_groups WHERE name = $1")
.bind(&name)
.fetch_optional(db.pool())
.await
.map_err(|e| format!("query group: {e}"))?
.ok_or_else(|| format!("no group named {name}"))?;
let id: i64 = row.try_get("id").map_err(|e| format!("group id: {e}"))?;
let group_name: String = row.try_get("name").unwrap_or_default();
let description: String = row.try_get("description").unwrap_or_default();
let member_rows = sqlx::query(
"SELECT u.email, u.role, u.is_active
FROM rustio_users u
JOIN rustio_user_groups ug ON ug.user_id = u.id
WHERE ug.group_id = $1
ORDER BY u.email ASC",
)
.bind(id)
.fetch_all(db.pool())
.await
.map_err(|e| format!("members query: {e}"))?;
let members: Vec<GroupMember> = member_rows
.into_iter()
.map(|r| GroupMember {
email: r.try_get("email").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
is_active: r.try_get("is_active").unwrap_or(false),
})
.collect();
let perm_rows = sqlx::query(
"SELECT p.name
FROM rustio_permissions p
JOIN rustio_group_permissions gp ON gp.permission_id = p.id
WHERE gp.group_id = $1
ORDER BY p.name ASC",
)
.bind(id)
.fetch_all(db.pool())
.await
.map_err(|e| format!("perms query: {e}"))?;
let permissions: Vec<String> = perm_rows
.into_iter()
.filter_map(|r| r.try_get::<String, _>("name").ok())
.collect();
let report = GroupReport {
id,
name: group_name,
description,
members,
permissions,
};
print!("{}", format_group_show(&report));
Ok(())
}
fn format_group_show(r: &GroupReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "Group: {}", r.name);
let _ = writeln!(out, "ID: {}", r.id);
if r.description.is_empty() {
let _ = writeln!(out, "Description: (none)");
} else {
let _ = writeln!(out, "Description: {}", r.description);
}
out.push('\n');
let _ = writeln!(out, "Members ({}):", r.members.len());
if r.members.is_empty() {
let _ = writeln!(out, " (none)");
} else {
let max_email = r.members.iter().map(|m| m.email.len()).max().unwrap_or(0);
for m in &r.members {
let _ = writeln!(
out,
" {:<ew$} {:<14} {}",
m.email,
m.role,
if m.is_active { "active" } else { "inactive" },
ew = max_email,
);
}
}
out.push('\n');
let _ = writeln!(
out,
"Permissions granted via this group ({}):",
r.permissions.len()
);
if r.permissions.is_empty() {
let _ = writeln!(out, " (none)");
} else {
for p in &r.permissions {
let _ = writeln!(out, " {p}");
}
}
out
}
async fn lookup_group(db: &Db, name: &str) -> Result<i64, String> {
let row = sqlx::query("SELECT id FROM rustio_groups WHERE name = $1")
.bind(name)
.fetch_optional(db.pool())
.await
.map_err(|e| format!("query group: {e}"))?;
let row = row.ok_or_else(|| format!("no group named {name}"))?;
row.try_get::<i64, _>("id")
.map_err(|e| format!("group id: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> GroupReport {
GroupReport {
id: 3,
name: "Editors".into(),
description: "Content team".into(),
members: vec![],
permissions: vec![],
}
}
fn member(email: &str, role: &str, is_active: bool) -> GroupMember {
GroupMember {
email: email.into(),
role: role.into(),
is_active,
}
}
#[test]
fn empty_group_uses_none_markers() {
let r = base();
let out = format_group_show(&r);
assert!(out.contains("Group: Editors"));
assert!(out.contains("ID: 3"));
assert!(out.contains("Description: Content team"));
assert!(out.contains("Members (0):\n (none)"));
assert!(out.contains("Permissions granted via this group (0):\n (none)"));
}
#[test]
fn empty_description_uses_none_marker() {
let r = GroupReport {
description: String::new(),
..base()
};
let out = format_group_show(&r);
assert!(out.contains("Description: (none)"));
}
#[test]
fn members_render_with_role_and_active_status() {
let r = GroupReport {
members: vec![
member("alice@example.test", "editor", true),
member("bob@example.test", "user", false),
],
..base()
};
let out = format_group_show(&r);
assert!(out.contains("Members (2):"));
assert!(out.contains("alice@example.test"));
assert!(out.contains("editor"));
assert!(out.contains("active"));
assert!(out.contains("bob@example.test"));
assert!(out.contains("user"));
assert!(out.contains("inactive"));
}
#[test]
fn permissions_section_lists_grants() {
let r = GroupReport {
permissions: vec!["posts.view_post".into(), "posts.change_post".into()],
..base()
};
let out = format_group_show(&r);
assert!(out.contains("Permissions granted via this group (2):"));
assert!(out.contains("posts.view_post"));
assert!(out.contains("posts.change_post"));
}
#[test]
fn member_email_column_auto_widens() {
let r = GroupReport {
members: vec![
member("a@x", "editor", true),
member("very-long-email@example.test", "user", true),
],
..base()
};
let out = format_group_show(&r);
for line in out.lines() {
if line.trim_start().starts_with("a@x") {
assert!(line.contains("editor"));
}
if line.trim_start().starts_with("very-long-email") {
assert!(line.contains("user"));
}
}
}
#[test]
fn group_with_no_permissions_but_members_still_renders_cleanly() {
let r = GroupReport {
members: vec![member("alice@example.test", "editor", true)],
..base()
};
let out = format_group_show(&r);
assert!(out.contains("Members (1):"));
assert!(out.contains("alice@example.test"));
assert!(out.contains("Permissions granted via this group (0):\n (none)"));
}
}