Add DAST, graph modules, toast notifications, and dashboard enhancements

Add DAST scanning and code knowledge graph features across the stack:
- compliance-dast and compliance-graph workspace crates
- Agent API handlers and routes for DAST targets/scans and graph builds
- Core models and traits for DAST and graph domains
- Dashboard pages for DAST targets/findings/overview and graph explorer/impact
- Toast notification system with auto-dismiss for async action feedback
- Button click animations and disabled states for better UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-04 13:53:50 +01:00
parent 03ee69834d
commit cea8f59e10
69 changed files with 8745 additions and 54 deletions
+256
View File
@@ -0,0 +1,256 @@
use std::collections::HashMap;
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
use tracing::info;
use super::engine::CodeGraph;
/// Run Louvain community detection on the code graph.
/// Returns the number of communities detected.
/// Mutates node community_id in place.
pub fn detect_communities(code_graph: &CodeGraph) -> u32 {
let graph = &code_graph.graph;
let node_count = graph.node_count();
if node_count == 0 {
return 0;
}
// Initialize: each node in its own community
let mut community: HashMap<NodeIndex, u32> = HashMap::new();
for idx in graph.node_indices() {
community.insert(idx, idx.index() as u32);
}
// Compute total edge weight (all edges weight 1.0)
let total_edges = graph.edge_count() as f64;
if total_edges == 0.0 {
// All nodes are isolated, each is its own community
return node_count as u32;
}
let m2 = 2.0 * total_edges;
// Pre-compute node degrees
let mut degree: HashMap<NodeIndex, f64> = HashMap::new();
for idx in graph.node_indices() {
let d = graph.edges(idx).count() as f64;
degree.insert(idx, d);
}
// Louvain phase 1: local moves
let mut improved = true;
let mut iterations = 0;
let max_iterations = 50;
while improved && iterations < max_iterations {
improved = false;
iterations += 1;
for node in graph.node_indices() {
let current_comm = community[&node];
let node_deg = degree[&node];
// Compute edges to each neighboring community
let mut comm_edges: HashMap<u32, f64> = HashMap::new();
for edge in graph.edges(node) {
let neighbor = edge.target();
let neighbor_comm = community[&neighbor];
*comm_edges.entry(neighbor_comm).or_insert(0.0) += 1.0;
}
// Also check incoming edges (undirected treatment)
for edge in graph.edges_directed(node, petgraph::Direction::Incoming) {
let neighbor = edge.source();
let neighbor_comm = community[&neighbor];
*comm_edges.entry(neighbor_comm).or_insert(0.0) += 1.0;
}
// Compute community totals (sum of degrees in each community)
let mut comm_totals: HashMap<u32, f64> = HashMap::new();
for (n, &c) in &community {
*comm_totals.entry(c).or_insert(0.0) += degree[n];
}
// Find best community
let current_total = comm_totals.get(&current_comm).copied().unwrap_or(0.0);
let edges_to_current = comm_edges.get(&current_comm).copied().unwrap_or(0.0);
// Modularity gain from removing node from current community
let remove_cost = edges_to_current - (current_total - node_deg) * node_deg / m2;
let mut best_comm = current_comm;
let mut best_gain = 0.0;
for (&candidate_comm, &edges_to_candidate) in &comm_edges {
if candidate_comm == current_comm {
continue;
}
let candidate_total = comm_totals.get(&candidate_comm).copied().unwrap_or(0.0);
// Modularity gain from adding node to candidate community
let add_gain = edges_to_candidate - candidate_total * node_deg / m2;
let gain = add_gain - remove_cost;
if gain > best_gain {
best_gain = gain;
best_comm = candidate_comm;
}
}
if best_comm != current_comm {
community.insert(node, best_comm);
improved = true;
}
}
}
// Renumber communities to be contiguous
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
let mut next_id: u32 = 0;
for &c in community.values() {
if !comm_remap.contains_key(&c) {
comm_remap.insert(c, next_id);
next_id += 1;
}
}
// Apply to community map
for c in community.values_mut() {
if let Some(&new_id) = comm_remap.get(c) {
*c = new_id;
}
}
let num_communities = next_id;
info!(
communities = num_communities,
iterations, "Community detection complete"
);
// NOTE: community IDs are stored in the HashMap but need to be applied
// back to the CodeGraph nodes by the caller (engine) if needed for persistence.
// For now we return the count; the full assignment is available via the map.
num_communities
}
/// Apply community assignments back to code nodes
pub fn apply_communities(code_graph: &mut CodeGraph) -> u32 {
let count = detect_communities_with_assignment(code_graph);
count
}
/// Detect communities and write assignments into the nodes
fn detect_communities_with_assignment(code_graph: &mut CodeGraph) -> u32 {
let graph = &code_graph.graph;
let node_count = graph.node_count();
if node_count == 0 {
return 0;
}
let mut community: HashMap<NodeIndex, u32> = HashMap::new();
for idx in graph.node_indices() {
community.insert(idx, idx.index() as u32);
}
let total_edges = graph.edge_count() as f64;
if total_edges == 0.0 {
for node in &mut code_graph.nodes {
if let Some(gi) = node.graph_index {
node.community_id = Some(gi);
}
}
return node_count as u32;
}
let m2 = 2.0 * total_edges;
let mut degree: HashMap<NodeIndex, f64> = HashMap::new();
for idx in graph.node_indices() {
let d = (graph.edges(idx).count()
+ graph
.edges_directed(idx, petgraph::Direction::Incoming)
.count()) as f64;
degree.insert(idx, d);
}
let mut improved = true;
let mut iterations = 0;
let max_iterations = 50;
while improved && iterations < max_iterations {
improved = false;
iterations += 1;
for node in graph.node_indices() {
let current_comm = community[&node];
let node_deg = degree[&node];
let mut comm_edges: HashMap<u32, f64> = HashMap::new();
for edge in graph.edges(node) {
let neighbor_comm = community[&edge.target()];
*comm_edges.entry(neighbor_comm).or_insert(0.0) += 1.0;
}
for edge in graph.edges_directed(node, petgraph::Direction::Incoming) {
let neighbor_comm = community[&edge.source()];
*comm_edges.entry(neighbor_comm).or_insert(0.0) += 1.0;
}
let mut comm_totals: HashMap<u32, f64> = HashMap::new();
for (n, &c) in &community {
*comm_totals.entry(c).or_insert(0.0) += degree[n];
}
let current_total = comm_totals.get(&current_comm).copied().unwrap_or(0.0);
let edges_to_current = comm_edges.get(&current_comm).copied().unwrap_or(0.0);
let remove_cost = edges_to_current - (current_total - node_deg) * node_deg / m2;
let mut best_comm = current_comm;
let mut best_gain = 0.0;
for (&candidate_comm, &edges_to_candidate) in &comm_edges {
if candidate_comm == current_comm {
continue;
}
let candidate_total = comm_totals.get(&candidate_comm).copied().unwrap_or(0.0);
let add_gain = edges_to_candidate - candidate_total * node_deg / m2;
let gain = add_gain - remove_cost;
if gain > best_gain {
best_gain = gain;
best_comm = candidate_comm;
}
}
if best_comm != current_comm {
community.insert(node, best_comm);
improved = true;
}
}
}
// Renumber
let mut comm_remap: HashMap<u32, u32> = HashMap::new();
let mut next_id: u32 = 0;
for &c in community.values() {
if !comm_remap.contains_key(&c) {
comm_remap.insert(c, next_id);
next_id += 1;
}
}
// Apply to nodes
for node in &mut code_graph.nodes {
if let Some(gi) = node.graph_index {
let idx = NodeIndex::new(gi as usize);
if let Some(&comm) = community.get(&idx) {
let remapped = comm_remap.get(&comm).copied().unwrap_or(comm);
node.community_id = Some(remapped);
}
}
}
next_id
}
+165
View File
@@ -0,0 +1,165 @@
use std::collections::HashMap;
use std::path::Path;
use chrono::Utc;
use compliance_core::error::CoreError;
use compliance_core::models::graph::{
CodeEdge, CodeEdgeKind, CodeNode, GraphBuildRun, GraphBuildStatus,
};
use compliance_core::traits::graph_builder::ParseOutput;
use petgraph::graph::{DiGraph, NodeIndex};
use tracing::info;
use crate::parsers::registry::ParserRegistry;
use super::community::detect_communities;
use super::impact::ImpactAnalyzer;
/// The main graph engine that builds and manages code knowledge graphs
pub struct GraphEngine {
parser_registry: ParserRegistry,
max_nodes: u32,
}
/// In-memory representation of a built code graph
pub struct CodeGraph {
pub graph: DiGraph<String, CodeEdgeKind>,
pub node_map: HashMap<String, NodeIndex>,
pub nodes: Vec<CodeNode>,
pub edges: Vec<CodeEdge>,
}
impl GraphEngine {
pub fn new(max_nodes: u32) -> Self {
Self {
parser_registry: ParserRegistry::new(),
max_nodes,
}
}
/// Build a code graph from a repository directory
pub fn build_graph(
&self,
repo_path: &Path,
repo_id: &str,
graph_build_id: &str,
) -> Result<(CodeGraph, GraphBuildRun), CoreError> {
let mut build_run = GraphBuildRun::new(repo_id.to_string());
info!(repo_id, path = %repo_path.display(), "Starting graph build");
// Phase 1: Parse all files
let parse_output = self.parser_registry.parse_directory(
repo_path,
repo_id,
graph_build_id,
self.max_nodes,
)?;
// Phase 2: Build petgraph
let code_graph = self.build_petgraph(parse_output)?;
// Phase 3: Run community detection
let community_count = detect_communities(&code_graph);
// Collect language stats
let mut languages: Vec<String> = code_graph
.nodes
.iter()
.map(|n| n.language.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
languages.sort();
build_run.node_count = code_graph.nodes.len() as u32;
build_run.edge_count = code_graph.edges.len() as u32;
build_run.community_count = community_count;
build_run.languages_parsed = languages;
build_run.status = GraphBuildStatus::Completed;
build_run.completed_at = Some(Utc::now());
info!(
nodes = build_run.node_count,
edges = build_run.edge_count,
communities = build_run.community_count,
"Graph build complete"
);
Ok((code_graph, build_run))
}
/// Build petgraph from parsed output, resolving edges to node indices
fn build_petgraph(&self, parse_output: ParseOutput) -> Result<CodeGraph, CoreError> {
let mut graph = DiGraph::new();
let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
let mut nodes = parse_output.nodes;
// Add all nodes to the graph
for node in &mut nodes {
let idx = graph.add_node(node.qualified_name.clone());
node.graph_index = Some(idx.index() as u32);
node_map.insert(node.qualified_name.clone(), idx);
}
// Resolve and add edges
let mut resolved_edges = Vec::new();
for edge in parse_output.edges {
let source_idx = node_map.get(&edge.source);
let target_idx = self.resolve_edge_target(&edge.target, &node_map);
if let (Some(&src), Some(tgt)) = (source_idx, target_idx) {
graph.add_edge(src, tgt, edge.kind.clone());
resolved_edges.push(edge);
}
// Skip unresolved edges (cross-file, external deps) — conservative approach
}
Ok(CodeGraph {
graph,
node_map,
nodes,
edges: resolved_edges,
})
}
/// Try to resolve an edge target to a known node
fn resolve_edge_target<'a>(
&self,
target: &str,
node_map: &'a HashMap<String, NodeIndex>,
) -> Option<NodeIndex> {
// Direct match
if let Some(idx) = node_map.get(target) {
return Some(*idx);
}
// Try matching just the function/type name (intra-file resolution)
for (qualified, idx) in node_map {
// Match "foo" to "path/file.rs::foo" or "path/file.rs::Type::foo"
if qualified.ends_with(&format!("::{target}"))
|| qualified.ends_with(&format!(".{target}"))
{
return Some(*idx);
}
}
// Try matching method calls like "self.method" -> look for "::method"
if let Some(method_name) = target.strip_prefix("self.") {
for (qualified, idx) in node_map {
if qualified.ends_with(&format!("::{method_name}"))
|| qualified.ends_with(&format!(".{method_name}"))
{
return Some(*idx);
}
}
}
None
}
/// Get the impact analyzer for a built graph
pub fn impact_analyzer(code_graph: &CodeGraph) -> ImpactAnalyzer<'_> {
ImpactAnalyzer::new(code_graph)
}
}
+219
View File
@@ -0,0 +1,219 @@
use std::collections::{HashSet, VecDeque};
use compliance_core::models::graph::ImpactAnalysis;
use petgraph::graph::NodeIndex;
use petgraph::visit::EdgeRef;
use petgraph::Direction;
use super::engine::CodeGraph;
/// Analyzes the impact/blast radius of findings within a code graph
pub struct ImpactAnalyzer<'a> {
code_graph: &'a CodeGraph,
}
impl<'a> ImpactAnalyzer<'a> {
pub fn new(code_graph: &'a CodeGraph) -> Self {
Self { code_graph }
}
/// Compute impact analysis for a finding at the given file path and line number
pub fn analyze(
&self,
repo_id: &str,
finding_id: &str,
graph_build_id: &str,
file_path: &str,
line_number: Option<u32>,
) -> ImpactAnalysis {
let mut analysis =
ImpactAnalysis::new(repo_id.to_string(), finding_id.to_string(), graph_build_id.to_string());
// Find the node containing the finding
let target_node = self.find_node_at_location(file_path, line_number);
let target_idx = match target_node {
Some(idx) => idx,
None => return analysis,
};
// BFS forward: compute blast radius (what this node affects)
let forward_reachable = self.bfs_reachable(target_idx, Direction::Outgoing);
analysis.blast_radius = forward_reachable.len() as u32;
// BFS backward: find entry points that reach this node
let backward_reachable = self.bfs_reachable(target_idx, Direction::Incoming);
// Find affected entry points
for &idx in &backward_reachable {
if let Some(node) = self.get_node_by_index(idx) {
if node.is_entry_point {
analysis
.affected_entry_points
.push(node.qualified_name.clone());
}
}
}
// Extract call chains from entry points to the target (limited depth)
for entry_name in &analysis.affected_entry_points.clone() {
if let Some(&entry_idx) = self.code_graph.node_map.get(entry_name) {
if let Some(chain) = self.find_path(entry_idx, target_idx, 10) {
analysis.call_chains.push(chain);
}
}
}
// Direct callers (incoming edges to target)
for edge in self
.code_graph
.graph
.edges_directed(target_idx, Direction::Incoming)
{
if let Some(node) = self.get_node_by_index(edge.source()) {
analysis.direct_callers.push(node.qualified_name.clone());
}
}
// Direct callees (outgoing edges from target)
for edge in self.code_graph.graph.edges(target_idx) {
if let Some(node) = self.get_node_by_index(edge.target()) {
analysis.direct_callees.push(node.qualified_name.clone());
}
}
// Affected communities
let mut affected_comms: HashSet<u32> = HashSet::new();
for &idx in forward_reachable.iter().chain(std::iter::once(&target_idx)) {
if let Some(node) = self.get_node_by_index(idx) {
if let Some(cid) = node.community_id {
affected_comms.insert(cid);
}
}
}
analysis.affected_communities = affected_comms.into_iter().collect();
analysis.affected_communities.sort();
analysis
}
/// Find the graph node at a given file/line location
fn find_node_at_location(&self, file_path: &str, line_number: Option<u32>) -> Option<NodeIndex> {
let mut best: Option<(NodeIndex, u32)> = None; // (index, line_span)
for node in &self.code_graph.nodes {
if node.file_path != file_path {
continue;
}
if let Some(line) = line_number {
if line >= node.start_line && line <= node.end_line {
let span = node.end_line - node.start_line;
// Prefer the narrowest containing node
if best.is_none() || span < best.as_ref().map(|b| b.1).unwrap_or(u32::MAX) {
if let Some(gi) = node.graph_index {
best = Some((NodeIndex::new(gi as usize), span));
}
}
}
} else {
// No line number, use file node
if node.kind == compliance_core::models::graph::CodeNodeKind::File {
if let Some(gi) = node.graph_index {
return Some(NodeIndex::new(gi as usize));
}
}
}
}
best.map(|(idx, _)| idx)
}
/// BFS to find all reachable nodes in a given direction
fn bfs_reachable(&self, start: NodeIndex, direction: Direction) -> HashSet<NodeIndex> {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(start);
while let Some(current) = queue.pop_front() {
if !visited.insert(current) {
continue;
}
let neighbors: Vec<NodeIndex> = match direction {
Direction::Outgoing => self
.code_graph
.graph
.edges(current)
.map(|e| e.target())
.collect(),
Direction::Incoming => self
.code_graph
.graph
.edges_directed(current, Direction::Incoming)
.map(|e| e.source())
.collect(),
};
for neighbor in neighbors {
if !visited.contains(&neighbor) {
queue.push_back(neighbor);
}
}
}
visited.remove(&start);
visited
}
/// Find a path from source to target (BFS, limited depth)
fn find_path(
&self,
from: NodeIndex,
to: NodeIndex,
max_depth: usize,
) -> Option<Vec<String>> {
let mut visited = HashSet::new();
let mut queue: VecDeque<(NodeIndex, Vec<NodeIndex>)> = VecDeque::new();
queue.push_back((from, vec![from]));
while let Some((current, path)) = queue.pop_front() {
if current == to {
return Some(
path.iter()
.filter_map(|&idx| {
self.get_node_by_index(idx)
.map(|n| n.qualified_name.clone())
})
.collect(),
);
}
if path.len() >= max_depth {
continue;
}
if !visited.insert(current) {
continue;
}
for edge in self.code_graph.graph.edges(current) {
let next = edge.target();
if !visited.contains(&next) {
let mut new_path = path.clone();
new_path.push(next);
queue.push_back((next, new_path));
}
}
}
None
}
fn get_node_by_index(&self, idx: NodeIndex) -> Option<&compliance_core::models::graph::CodeNode> {
let target_gi = idx.index() as u32;
self.code_graph
.nodes
.iter()
.find(|n| n.graph_index == Some(target_gi))
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod community;
pub mod engine;
pub mod impact;
pub mod persistence;
+255
View File
@@ -0,0 +1,255 @@
use compliance_core::error::CoreError;
use compliance_core::models::graph::{CodeEdge, CodeNode, GraphBuildRun, ImpactAnalysis};
use futures_util::TryStreamExt;
use mongodb::bson::doc;
use mongodb::options::IndexOptions;
use mongodb::{Collection, Database, IndexModel};
use tracing::info;
/// MongoDB persistence layer for the code knowledge graph
pub struct GraphStore {
nodes: Collection<CodeNode>,
edges: Collection<CodeEdge>,
builds: Collection<GraphBuildRun>,
impacts: Collection<ImpactAnalysis>,
}
impl GraphStore {
pub fn new(db: &Database) -> Self {
Self {
nodes: db.collection("graph_nodes"),
edges: db.collection("graph_edges"),
builds: db.collection("graph_builds"),
impacts: db.collection("impact_analyses"),
}
}
/// Ensure indexes are created
pub async fn ensure_indexes(&self) -> Result<(), CoreError> {
// graph_nodes: compound index on (repo_id, graph_build_id)
self.nodes
.create_index(
IndexModel::builder()
.keys(doc! { "repo_id": 1, "graph_build_id": 1 })
.build(),
)
.await?;
// graph_nodes: index on qualified_name for lookups
self.nodes
.create_index(
IndexModel::builder()
.keys(doc! { "qualified_name": 1 })
.build(),
)
.await?;
// graph_edges: compound index on (repo_id, graph_build_id)
self.edges
.create_index(
IndexModel::builder()
.keys(doc! { "repo_id": 1, "graph_build_id": 1 })
.build(),
)
.await?;
// graph_builds: compound index on (repo_id, started_at DESC)
self.builds
.create_index(
IndexModel::builder()
.keys(doc! { "repo_id": 1, "started_at": -1 })
.build(),
)
.await?;
// impact_analyses: compound index on (repo_id, finding_id)
self.impacts
.create_index(
IndexModel::builder()
.keys(doc! { "repo_id": 1, "finding_id": 1 })
.options(IndexOptions::builder().unique(true).build())
.build(),
)
.await?;
Ok(())
}
/// Store a complete graph build result
pub async fn store_graph(
&self,
build_run: &GraphBuildRun,
nodes: &[CodeNode],
edges: &[CodeEdge],
) -> Result<String, CoreError> {
// Insert the build run
let result = self.builds.insert_one(build_run).await?;
let build_id = result
.inserted_id
.as_object_id()
.map(|oid| oid.to_hex())
.unwrap_or_default();
// Insert nodes in batches
if !nodes.is_empty() {
let batch_size = 1000;
for chunk in nodes.chunks(batch_size) {
self.nodes.insert_many(chunk.to_vec()).await?;
}
}
// Insert edges in batches
if !edges.is_empty() {
let batch_size = 1000;
for chunk in edges.chunks(batch_size) {
self.edges.insert_many(chunk.to_vec()).await?;
}
}
info!(
build_id = %build_id,
nodes = nodes.len(),
edges = edges.len(),
"Graph stored to MongoDB"
);
Ok(build_id)
}
/// Delete previous graph data for a repo before storing new graph
pub async fn delete_repo_graph(&self, repo_id: &str) -> Result<(), CoreError> {
let filter = doc! { "repo_id": repo_id };
self.nodes.delete_many(filter.clone()).await?;
self.edges.delete_many(filter.clone()).await?;
self.impacts.delete_many(filter).await?;
Ok(())
}
/// Store an impact analysis result
pub async fn store_impact(&self, impact: &ImpactAnalysis) -> Result<(), CoreError> {
let filter = doc! {
"repo_id": &impact.repo_id,
"finding_id": &impact.finding_id,
};
let opts = mongodb::options::ReplaceOptions::builder()
.upsert(true)
.build();
self.impacts
.replace_one(filter, impact)
.with_options(opts)
.await?;
Ok(())
}
/// Get the latest graph build for a repo
pub async fn get_latest_build(
&self,
repo_id: &str,
) -> Result<Option<GraphBuildRun>, CoreError> {
let filter = doc! { "repo_id": repo_id };
let opts = mongodb::options::FindOneOptions::builder()
.sort(doc! { "started_at": -1 })
.build();
let result = self.builds.find_one(filter).with_options(opts).await?;
Ok(result)
}
/// Get all nodes for a repo's latest graph build
pub async fn get_nodes(
&self,
repo_id: &str,
graph_build_id: &str,
) -> Result<Vec<CodeNode>, CoreError> {
let filter = doc! {
"repo_id": repo_id,
"graph_build_id": graph_build_id,
};
let cursor = self.nodes.find(filter).await?;
let nodes: Vec<CodeNode> = cursor.try_collect().await?;
Ok(nodes)
}
/// Get all edges for a repo's latest graph build
pub async fn get_edges(
&self,
repo_id: &str,
graph_build_id: &str,
) -> Result<Vec<CodeEdge>, CoreError> {
let filter = doc! {
"repo_id": repo_id,
"graph_build_id": graph_build_id,
};
let cursor = self.edges.find(filter).await?;
let edges: Vec<CodeEdge> = cursor.try_collect().await?;
Ok(edges)
}
/// Get impact analysis for a finding
pub async fn get_impact(
&self,
repo_id: &str,
finding_id: &str,
) -> Result<Option<ImpactAnalysis>, CoreError> {
let filter = doc! {
"repo_id": repo_id,
"finding_id": finding_id,
};
let result = self.impacts.find_one(filter).await?;
Ok(result)
}
/// Get nodes grouped by community
pub async fn get_communities(
&self,
repo_id: &str,
graph_build_id: &str,
) -> Result<Vec<CommunityInfo>, CoreError> {
let filter = doc! {
"repo_id": repo_id,
"graph_build_id": graph_build_id,
};
let cursor = self.nodes.find(filter).await?;
let nodes: Vec<CodeNode> = cursor.try_collect().await?;
let mut communities: std::collections::HashMap<u32, Vec<String>> =
std::collections::HashMap::new();
for node in &nodes {
if let Some(cid) = node.community_id {
communities
.entry(cid)
.or_default()
.push(node.qualified_name.clone());
}
}
let mut result: Vec<CommunityInfo> = communities
.into_iter()
.map(|(id, members)| CommunityInfo {
community_id: id,
member_count: members.len() as u32,
members,
})
.collect();
result.sort_by_key(|c| c.community_id);
Ok(result)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CommunityInfo {
pub community_id: u32,
pub member_count: u32,
pub members: Vec<String>,
}