- Overview
- Architecture Principles
- System Architecture
- Design Patterns
- Data Flow
- Configuration System
- AI Integration
- Testing Strategy
- Performance Considerations
- Security Model
- Extension Points
- Decision Records
smart-hooks is a sophisticated git hook system designed to intelligently select and execute tests based on code changes. The system combines static analysis, configurable pattern matching, and optional AI-powered semantic analysis to provide efficient, targeted test execution for any project.
- Intelligence: Understand code changes and select relevant tests
- Efficiency: Minimize test execution time while maintaining coverage
- Flexibility: Work across different project domains and structures
- Reliability: Provide consistent, predictable behavior
- Extensibility: Support future enhancements and customizations
- Domain-specific logic: Avoid hard-coding business rules
- Test execution: Focus on selection, not running tests directly
- Git operations: Delegate git operations to external tools
- Complex AI: Keep AI integration optional and bounded
Every module has exactly one reason to change:
analysis/bdd_detector.rs → Static BDD pattern detection (240 LOC)
analysis/config.rs → Configuration management (372 LOC)
analysis/bdd_feature_selector.rs → Feature selection logic (456 LOC)
execution/plan_executor.rs → Test execution coordination (87 LOC)
Constraint: Each module ≤300 LOC (target), ≤500 LOC (hard limit)
All behavioral patterns are externalized to configuration:
// ❌ WRONG: Hard-coded patterns
if file_path.contains("payment") || file_path.contains("billing") {
return vec!["payment_tests.feature"];
}
// ✅ RIGHT: Configuration-driven
if config.matches_structural_pattern(file_path) {
return config.get_cucumber_tags_for_file(file_path);
}The system recognizes software architecture patterns, not business domains:
# ✅ Architectural patterns (reusable)
core_business_logic = ["/core/", "/service/", "/domain/"]
api_interfaces = ["/api/", "/controller/", "/endpoint/"]
# ❌ Domain patterns (not reusable)
payment_logic = ["/payment/", "/billing/", "/invoice/"]The system provides multiple fallback layers:
1. Configuration-based patterns → Fast, predictable
2. Static code analysis → Moderate intelligence
3. Claude AI analysis → High intelligence (optional)
Data flows through pure functions without side effects:
FileChange → Analysis → TestPlan → Execution
↓ ↓ ↓ ↓
Immutable Pure Func Immutable Effect┌─────────────────────────────────────────────────────┐
│ CLI Layer │
│ smart_test_selector.rs │ claude_bdd_selector.rs │
├─────────────────────────────────────────────────────┤
│ Execution Layer │
│ plan_executor.rs │ test_runner.rs │
├─────────────────────────────────────────────────────┤
│ Analysis Layer │
│ ┌─────────────────┬─────────────────┬──────────────┐ │
│ │ Static Analysis │ Config Analysis │ AI Analysis │ │
│ │ bdd_detector │ config.rs │ claude_bdd │ │
│ │ file_analyzer │ dependency_map │ detector │ │
│ └─────────────────┴─────────────────┴──────────────┘ │
├─────────────────────────────────────────────────────┤
│ Utilities Layer │
│ file_utils.rs │ impact_analyzer.rs │ module_utils │
└─────────────────────────────────────────────────────┘
bdd_detector.rs: Static pattern detection using regex and heuristicsclaude_bdd_detector.rs: AI-powered semantic analysis via Claude CLIbdd_feature_selector.rs: Intelligent feature selection algorithmsconfig.rs: Configuration parsing and structural pattern matchingdependency_mapper.rs: Test dependency analysis and plan creationfile_analyzer.rs: File content analysis and functionality detection
plan_executor.rs: Coordinates test plan executiontest_runner.rs: Executes cargo commands safely
file_utils.rs: Safe file operations and content readingimpact_analyzer.rs: Change impact assessmentmodule_utils.rs: Rust module name extraction
pub struct TestSelectorConfig {
/// Pattern-based test selection rules
pub unit_test_patterns: HashMap<String, String>,
pub integration_test_patterns: Vec<String>,
pub bdd_test_patterns: Vec<String>,
/// Structural analysis patterns
pub bdd_structural_patterns: Option<BddStructuralPatterns>,
pub bdd_pattern_tags: Option<HashMap<String, Vec<String>>>,
/// Analysis configuration
pub enable_content_analysis: bool,
}
pub struct BddStructuralPatterns {
pub core_business_logic: Vec<String>,
pub application_logic: Vec<String>,
pub error_handling: Vec<String>,
pub api_interfaces: Vec<String>,
pub behavioral_patterns: Vec<String>,
}
pub struct BddFeatureSelection {
pub should_run_bdd: bool,
pub confidence: f32,
pub reasoning: String,
pub selected_features: Vec<SelectedFeature>,
pub suggested_test_focus: Vec<String>,
pub risk_areas: Vec<String>,
}
pub struct TestPlan {
pub unit_tests: Vec<String>,
pub integration_tests: bool,
pub bdd_tests: bool,
pub impact_level: ImpactLevel,
}pub struct FileChange {
pub file_path: String,
pub change_type: String, // "modified", "added", "deleted"
pub diff_summary: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ImpactLevel {
None, // No testing needed
Low, // Unit tests only
Medium, // Unit + integration tests
High, // Full test suite including BDD
Dependency, // Reverse dependency analysis needed
}Multiple analysis strategies with common interface:
trait BddAnalyzer {
fn analyze(&self, files: &[FileChange]) -> Result<BddFeatureSelection>;
}
struct StaticAnalyzer { config: TestSelectorConfig }
struct ClaudeAnalyzer { config: TestSelectorConfig }
struct HybridAnalyzer { static_analyzer: StaticAnalyzer, claude_analyzer: ClaudeAnalyzer }Configuration construction:
let config = TestSelectorConfig::default()
.with_content_analysis()
.with_claude_integration()
.with_custom_patterns(patterns);Test execution as commands:
pub struct CargoCommand {
pub command: String,
pub args: Vec<String>,
pub working_dir: Option<PathBuf>,
}
impl CargoCommand {
pub fn execute(&self) -> Result<CommandResult> { ... }
}Analysis strategy creation:
pub struct AnalyzerFactory;
impl AnalyzerFactory {
pub fn create_analyzer(config: &TestSelectorConfig) -> Box<dyn BddAnalyzer> {
if config.claude_enabled() {
Box::new(HybridAnalyzer::new(config))
} else {
Box::new(StaticAnalyzer::new(config))
}
}
}Test plan execution workflow:
pub trait TestExecutor {
fn validate_plan(&self, plan: &TestPlan) -> Result<()>;
fn execute_unit_tests(&self, tests: &[String]) -> Result<()>;
fn execute_integration_tests(&self) -> Result<()>;
fn execute_bdd_tests(&self) -> Result<()>;
// Template method
fn execute_plan(&self, plan: &TestPlan) -> Result<()> {
self.validate_plan(plan)?;
if !plan.unit_tests.is_empty() {
self.execute_unit_tests(&plan.unit_tests)?;
}
if plan.integration_tests {
self.execute_integration_tests()?;
}
if plan.bdd_tests {
self.execute_bdd_tests()?;
}
Ok(())
}
}Git Changes → File List → FileChange Structs
↓
[git diff --cached --name-status]
↓
["M src/core/payment.rs", "A src/api/users.rs"]
↓
[FileChange { file_path: "src/core/payment.rs", change_type: "modified" }]
FileChange[] → Configuration → Pattern Analysis → Feature Selection
↓ ↓ ↓ ↓
Input Files TOML Config Structural BddFeatureSelection
Patterns
Analysis Results → Test Plan → Execution Commands → Results
↓ ↓ ↓ ↓
BddFeatureSelection TestPlan CargoCommand[] TestResults
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Git Changes │───→│ File Scanner │───→│ Change Analyzer │
└─────────────┘ └──────────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Test Executor │◄───│ Plan Generator │◄───│ Config Loader│
└─────────────────┘ └─────────────────┘ └──────────────┘
│
▼
┌─────────────────────────┐
│ Analysis Engine │
├─────────┬───────┬───────┤
│ Static │Config │Claude │
│Analysis │Pattern│ AI │
└─────────┴───────┴───────┘
- Default Configuration (in code)
- Project Configuration (
config.toml) - User Configuration (
~/.smart-hooks/config.toml) - Environment Variables (
SMART_HOOKS_*) - Command Line Arguments (
--config-file)
# Basic settings
enable_content_analysis = false
claude_integration = true
# File pattern mappings
[unit_test_patterns]
"processor.rs" = "processor"
"validator.rs" = "validator"
integration_test_patterns = ["/core/", "/types.rs"]
bdd_test_patterns = ["/apply/", "/strategy/"]
# Structural patterns for domain-agnostic analysis
[bdd_structural_patterns]
core_business_logic = ["/core/", "/service/", "/domain/"]
application_logic = ["/apply/", "/strategy/", "/handler/"]
error_handling = ["/error", "/validate", "/types"]
api_interfaces = ["/api/", "/controller/", "/endpoint/"]
behavioral_patterns = ["/command/", "/event/", "/aggregate/"]
# Cucumber tag mapping for semantic test selection
[bdd_pattern_tags]
"/core/" = ["@business-logic", "@critical"]
"/service/" = ["@business-logic", "@integration"]
"/api/" = ["@api", "@external"]
"/error" = ["@error-handling", "@robustness"]impl TestSelectorConfig {
pub fn validate(&self) -> Result<()> {
// Validate pattern syntax
for pattern in &self.bdd_test_patterns {
validate_pattern(pattern)?;
}
// Check for conflicting settings
if self.enable_content_analysis && self.unit_test_patterns.is_empty() {
warn!("Content analysis enabled but no unit test patterns defined");
}
// Validate tag mapping
if let Some(tags) = &self.bdd_pattern_tags {
for (pattern, tag_list) in tags {
validate_pattern(pattern)?;
validate_tags(tag_list)?;
}
}
Ok(())
}
}┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Claude CLI │◄───│ JSON Protocol │◄───│ Rust Process │
│ (External) │ │ (stdin/stdout) │ │ (Our Code) │
└─────────────────┘ └─────────────────┘ └──────────────┘
The system uses structured prompts for consistent AI analysis:
fn build_claude_prompt(file_path: &Path, content: &str, context: &str) -> String {
format!(r#"
Analyze this Rust code file ({}) for Behavior-Driven Development (BDD) testing needs.
Context: {}
Code:
```rust
{}Please analyze and return JSON with this structure: {{ "should_have_bdd_tests": boolean, "confidence": float (0.0-1.0), "reasoning": "explanation", "suggested_scenarios": ["scenario1", "scenario2"], "user_facing_features": ["feature descriptions"], "business_rules": ["rule descriptions"], "integration_points": ["external dependencies"] }}
Focus on identifying code that represents BEHAVIOR rather than implementation details. Consider: user-facing APIs, business logic, state mutations, error handling, integrations. "#, file_path.display(), context, content) }
### Fallback Strategy
```rust
pub async fn analyze_hybrid(file_path: &Path, content: &str) -> Result<BddAnalysis> {
// 1. Try Claude AI analysis
#[cfg(feature = "claude-ai")]
{
match analyze_with_claude(file_path, content).await {
Ok(analysis) => return Ok(analysis),
Err(e) => {
warn!("Claude AI analysis failed, falling back to static: {}", e);
}
}
}
// 2. Fallback to static analysis
analyze_with_static_patterns(file_path, content)
}
#[derive(Debug)]
pub enum ClaudeError {
CommandNotFound, // Claude CLI not installed
InvalidResponse(String), // Malformed JSON response
RateLimited, // API rate limiting
NetworkError(String), // Connection issues
ParseError(serde_json::Error), // JSON parsing failure
}
impl ClaudeError {
pub fn is_recoverable(&self) -> bool {
matches!(self, ClaudeError::RateLimited | ClaudeError::NetworkError(_))
}
}Unit Tests (44 tests)
├── analysis/
│ ├── bdd_detector (6 tests)
│ ├── config (7 tests)
│ ├── bdd_feature_selector (4 tests)
│ └── dependency_mapper (8 tests)
├── execution/ (2 tests)
├── utilities/ (17 tests)
└── integration/ (0 tests - using parent crate)
- Pure function testing - No side effects
- Mock external dependencies - Claude CLI, file system
- Comprehensive coverage - All code paths
- Fast execution - <100ms total
- End-to-end workflows - File changes → Test selection
- Configuration integration - TOML parsing → behavior
- Command generation - Config → cucumber commands
- Pattern matching - Verify patterns match expected files
- Configuration validation - Invalid configs rejected
- Tag generation - File paths → consistent tag sets
// Test fixture creation
pub fn create_test_config() -> TestSelectorConfig {
TestSelectorConfig {
bdd_structural_patterns: Some(BddStructuralPatterns {
core_business_logic: vec!["/core/".to_string()],
// ... other patterns
}),
// ... other fields
}
}
// Mock file changes
pub fn mock_file_changes() -> Vec<FileChange> {
vec![
FileChange {
file_path: "src/core/processor.rs".to_string(),
change_type: "modified".to_string(),
diff_summary: Some("Added validation logic".to_string()),
},
]
}
// Assert test plan contents
pub fn assert_test_plan_contains_bdd(plan: &TestPlan) {
assert!(plan.bdd_tests, "Expected BDD tests to be enabled");
assert_eq!(plan.impact_level, ImpactLevel::High);
}// Lazy evaluation for expensive operations
pub struct LazyBddAnalyzer {
static_analyzer: OnceCell<StaticAnalyzer>,
claude_analyzer: OnceCell<ClaudeAnalyzer>,
}
impl LazyBddAnalyzer {
pub fn analyze(&self, files: &[FileChange]) -> Result<BddFeatureSelection> {
// Try fast static analysis first
let static_result = self.static_analyzer()
.get_or_init(|| StaticAnalyzer::new(&self.config))
.analyze(files)?;
// Only use Claude if static analysis is uncertain
if static_result.confidence < 0.7 {
return self.claude_analyzer()
.get_or_init(|| ClaudeAnalyzer::new(&self.config))
.analyze(files);
}
Ok(static_result)
}
}use std::collections::HashMap;
use std::time::{Duration, SystemTime};
pub struct AnalysisCache {
cache: HashMap<String, CachedResult>,
ttl: Duration,
}
struct CachedResult {
result: BddFeatureSelection,
timestamp: SystemTime,
file_hash: u64, // For invalidation
}
impl AnalysisCache {
pub fn get(&self, file_path: &str, content_hash: u64) -> Option<BddFeatureSelection> {
let cached = self.cache.get(file_path)?;
// Check if cache is still valid
if cached.file_hash != content_hash ||
cached.timestamp.elapsed().ok()? > self.ttl {
return None;
}
Some(cached.result.clone())
}
}- Streaming file processing - Don't load all files into memory
- Pattern compilation - Compile regex patterns once
- Reference sharing - Use
Arc<T>for shared configuration - Bounded collections - Limit analysis result sizes
pub struct BoundedAnalysis {
pub max_features: usize,
pub max_scenarios: usize,
pub max_reasoning_length: usize,
}
impl BoundedAnalysis {
pub fn apply_limits(&self, mut selection: BddFeatureSelection) -> BddFeatureSelection {
selection.selected_features.truncate(self.max_features);
selection.reasoning.truncate(self.max_reasoning_length);
for feature in &mut selection.selected_features {
feature.scenarios.truncate(self.max_scenarios);
}
selection
}
}pub fn validate_file_path(path: &str) -> Result<()> {
// Prevent directory traversal
if path.contains("..") || path.starts_with("/") {
return Err(SecurityError::InvalidPath(path.to_string()));
}
// Ensure it's a source file
if !path.ends_with(".rs") {
return Err(SecurityError::UnsupportedFileType(path.to_string()));
}
// Check path length
if path.len() > 512 {
return Err(SecurityError::PathTooLong(path.len()));
}
Ok(())
}pub struct SafeCargoCommand {
subcommand: CargoSubcommand,
args: Vec<SafeArg>,
}
#[derive(Debug, Clone)]
pub enum CargoSubcommand {
Test, Check, Clippy, Doc
}
#[derive(Debug, Clone)]
pub struct SafeArg {
value: String,
validated: bool,
}
impl SafeCargoCommand {
pub fn test() -> Self {
Self {
subcommand: CargoSubcommand::Test,
args: Vec::new(),
}
}
pub fn with_package(mut self, package: &str) -> Result<Self> {
validate_package_name(package)?;
self.args.push(SafeArg {
value: format!("--package={}", package),
validated: true,
});
Ok(self)
}
}pub fn sanitize_claude_response(response: &str) -> Result<String> {
// Remove potential script injection
let cleaned = response
.replace("<script>", "")
.replace("</script>", "")
.replace("javascript:", "");
// Validate JSON structure
let _: serde_json::Value = serde_json::from_str(&cleaned)?;
Ok(cleaned)
}pub struct SecureFileAccess {
allowed_paths: HashSet<PathBuf>,
max_file_size: usize,
}
impl SecureFileAccess {
pub fn read_file(&self, path: &Path) -> Result<String> {
// Check if path is allowed
let canonical_path = path.canonicalize()?;
if !self.is_allowed_path(&canonical_path) {
return Err(SecurityError::UnauthorizedAccess(path.to_path_buf()));
}
// Check file size
let metadata = std::fs::metadata(&canonical_path)?;
if metadata.len() > self.max_file_size as u64 {
return Err(SecurityError::FileTooLarge(metadata.len()));
}
// Read with timeout
std::fs::read_to_string(&canonical_path)
.map_err(SecurityError::FileReadError)
}
}pub trait CustomAnalyzer: Send + Sync {
fn name(&self) -> &'static str;
fn analyze(&self, changes: &[FileChange]) -> Result<AnalysisResult>;
fn priority(&self) -> u8; // Higher = runs first
}
pub struct AnalyzerRegistry {
analyzers: Vec<Box<dyn CustomAnalyzer>>,
}
impl AnalyzerRegistry {
pub fn register<A: CustomAnalyzer + 'static>(&mut self, analyzer: A) {
self.analyzers.push(Box::new(analyzer));
}
pub fn analyze_all(&self, changes: &[FileChange]) -> Result<Vec<AnalysisResult>> {
self.analyzers
.iter()
.map(|analyzer| analyzer.analyze(changes))
.collect()
}
}// Plugin trait
pub trait TestSelectorPlugin {
fn initialize(&mut self, config: &TestSelectorConfig) -> Result<()>;
fn on_file_changed(&self, change: &FileChange) -> Result<Option<TestRecommendation>>;
fn on_analysis_complete(&self, results: &AnalysisResults) -> Result<()>;
}
// Plugin manager
pub struct PluginManager {
plugins: Vec<Box<dyn TestSelectorPlugin>>,
}
impl PluginManager {
pub fn load_plugin<P: TestSelectorPlugin + 'static>(&mut self, plugin: P) {
self.plugins.push(Box::new(plugin));
}
pub fn notify_file_changed(&self, change: &FileChange) -> Result<Vec<TestRecommendation>> {
self.plugins
.iter()
.filter_map(|plugin| plugin.on_file_changed(change).transpose())
.collect()
}
}// Custom configuration sections
#[derive(Debug, Serialize, Deserialize)]
pub struct ExtendedConfig {
#[serde(flatten)]
pub base: TestSelectorConfig,
pub plugins: Option<HashMap<String, toml::Value>>,
pub custom_analyzers: Option<Vec<String>>,
pub notification_hooks: Option<Vec<NotificationConfig>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NotificationConfig {
pub trigger: String, // "test_failure", "analysis_complete"
pub action: String, // "slack_message", "email", "webhook"
pub config: toml::Value, // Action-specific config
}Decision: Each module must have exactly one responsibility and ≤300 LOC.
Context: Initial code was monolithic and hard to maintain.
Consequences:
- ✅ Easy to understand and modify individual modules
- ✅ Better test coverage and isolation
- ✅ Clear ownership and responsibilities
- ❌ More files to navigate
- ❌ Potential over-engineering for simple cases
Decision: All behavioral patterns must be configurable via TOML files.
Context: Hard-coded patterns made the system domain-specific and inflexible.
Consequences:
- ✅ Reusable across different project types
- ✅ Customizable without code changes
- ✅ Domain-agnostic design
- ❌ More complex initial setup
- ❌ Configuration validation overhead
Decision: Use cucumber tags rather than feature names for test selection.
Context: Feature names are too specific; tags provide semantic meaning.
Consequences:
- ✅ Semantic test categorization
- ✅ Flexible test combinations
- ✅ Better cucumber integration
- ✅ Scalable tag taxonomy
- ❌ Requires cucumber knowledge
- ❌ Tag maintenance overhead
Decision: Make Claude AI analysis optional with graceful fallback.
Context: Not all users have Claude CLI access; system must work without it.
Consequences:
- ✅ Works in all environments
- ✅ No external dependencies required
- ✅ Incremental intelligence adoption
- ❌ More complex conditional compilation
- ❌ Multiple code paths to maintain
Decision: Use immutable data structures throughout the analysis pipeline.
Context: Ensure predictable behavior and easy testing.
Consequences:
- ✅ Thread-safe by default
- ✅ Predictable behavior
- ✅ Easy to test and reason about
- ❌ Higher memory usage
- ❌ Performance overhead for large data sets
Decision: Implement comprehensive error recovery with detailed error types.
Context: Pre-commit hooks should never block development workflow.
Consequences:
- ✅ Robust error handling
- ✅ Clear error messages
- ✅ Graceful degradation
- ❌ More complex error handling code
- ❌ Potential over-engineering
Document Version: 1.0 Last Updated: 2024-11-13 Authors: smart-hooks development team