I'm building a Rust library that supports multiple analysis reports, all implementing a common Report trait. I'm considering two design approaches and would appreciate a code review focusing on maintainability, extensibility, and clean design.
The main decision point is how to store and construct multiple types implementing the Report trait.
Structure 1: Type-Based Plugin System Using TypeId
This design uses TypeId and stores factory functions in a HashSet for dynamic report registration.
report.rs
use std::any::{Any, TypeId};
use std::fmt::Debug;
pub trait Report: Debug + Any {
fn process(&mut self, match_: &str, index: usize);
fn as_any(&self) -> &dyn Any;
fn boxed() -> Box<dyn Report>
where
Self: Sized;
}
analysis.rs
#[derive(Debug, Default)]
pub struct AnalysisConfig {
config: HashSet<fn() -> Box<dyn Report>>,
}
#[derive(Debug, Default)]
pub struct Analysis {
analysis_config: AnalysisConfig,
reports: HashMap<TypeId, Box<dyn Report>>,
}
impl AnalysisConfig {
pub fn add<T: Report>(&mut self) {
// each type T that implements Report has a boxed fn
self.config.insert(|| T::boxed());
}
pub fn remove<T: Report>(&mut self) {
// FIX: now remove option is tough due to closure
}
pub fn build(&self) {
// return HashMap<TypeId, Box<dyn Report>> by calling items in self.config
}
}
impl Analysis {
pub fn from_analysis_options(options: AnalysisOptions) -> Analysis {
let mut reports = HashMap::new();
for option in &options.options {
let report = report::from_analysis_option(option);
reports.insert((&*report).type_id(), report);
}
Analysis {
analysis_options: options,
reports,
}
}
pub fn report<T: report::Report>(&self) -> Option<&T> {
let r = self.reports.get(&TypeId::of::<T>());
if let Some(r) = r {
return r.as_any().downcast_ref::<T>();
}
None
}
}
Structure 2: Enum-Based Static Registration
This approach uses an enum to represent all known reports and a function to match each variant to a concrete implementation.
report.rs
pub trait Report: Debug + Any {
fn process(&mut self, match_: &str, index: usize);
fn as_any(&self) -> &dyn Any;
}
pub fn from_analysis_option(option: &AnalysisOption) -> Box<dyn Report> {
match option {
AnalysisOption::UniqueMatches => Box::new(UniqueMatchesReport::new()),
AnalysisOption::MatchFrequency => Box::new(MatchFrequencyReport::new()),
AnalysisOption::MatchIndices => Box::new(MatchIndicesReport::new()),
AnalysisOption::MatchCount => Box::new(MatchCountReport::new()),
}
}
analysis.rs
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum AnalysisOption {
UniqueMatches,
MatchFrequency,
MatchIndices,
MatchCount,
}
pub struct AnalysisOptions {
options: HashSet<AnalysisOption>,
}
impl Analysis {
pub fn from_analysis_options(options: AnalysisOptions) -> Analysis {
let mut reports = HashMap::new();
for option in &options.options {
let report = report::from_analysis_option(option);
reports.insert((&*report).type_id(), report);
}
Analysis {
analysis_options: options,
reports,
}
}
pub fn report<T: report::Report>(&self) -> Option<&T> {
let r = self.reports.get(&TypeId::of::<T>());
if let Some(r) = r {
return r.as_any().downcast_ref::<T>();
}
None
}
}
Which approach is more idiomatic in Rust for plugin-style architectures?
Is there a clean way to overcome the downsides of both designs—supporting extensibility while keeping maintainable keys?