ricecoder_research/
architectural_intent.rs

1//! Architectural intent tracking and analysis
2//!
3//! This module provides functionality to track and understand architectural decisions,
4//! infer architectural styles from code structure, and parse Architecture Decision Records (ADRs).
5
6use crate::models::{ArchitecturalDecision, ArchitecturalIntent, ArchitecturalStyle};
7use crate::ResearchError;
8use chrono::Utc;
9use std::path::Path;
10
11/// Tracks and understands architectural decisions and patterns
12#[derive(Debug, Clone)]
13pub struct ArchitecturalIntentTracker {
14    /// Minimum confidence threshold for style inference (0.0 to 1.0)
15    pub confidence_threshold: f32,
16}
17
18impl ArchitecturalIntentTracker {
19    /// Creates a new ArchitecturalIntentTracker with default settings
20    pub fn new() -> Self {
21        Self {
22            confidence_threshold: 0.5,
23        }
24    }
25
26    /// Creates a new ArchitecturalIntentTracker with a custom confidence threshold
27    pub fn with_threshold(confidence_threshold: f32) -> Self {
28        Self {
29            confidence_threshold: confidence_threshold.clamp(0.0, 1.0),
30        }
31    }
32
33    /// Analyzes code structure to infer architectural style
34    ///
35    /// # Arguments
36    ///
37    /// * `root` - The root directory of the project
38    ///
39    /// # Returns
40    ///
41    /// The inferred architectural style, or an error if analysis fails
42    pub fn infer_style(&self, root: &Path) -> Result<ArchitecturalStyle, ResearchError> {
43        // Analyze directory structure and module organization
44        let style = self.infer_from_structure(root)?;
45        Ok(style)
46    }
47
48    /// Infers architectural style from directory structure
49    fn infer_from_structure(&self, root: &Path) -> Result<ArchitecturalStyle, ResearchError> {
50        // Check for common architectural patterns in directory structure
51
52        // Check for layered architecture (domain, application, infrastructure, interfaces)
53        if self.has_layered_structure(root)? {
54            return Ok(ArchitecturalStyle::Layered);
55        }
56
57        // Check for microservices pattern (multiple service directories)
58        if self.has_microservices_structure(root)? {
59            return Ok(ArchitecturalStyle::Microservices);
60        }
61
62        // Check for event-driven pattern (event handlers, pub/sub)
63        if self.has_event_driven_structure(root)? {
64            return Ok(ArchitecturalStyle::EventDriven);
65        }
66
67        // Check for serverless pattern (functions, handlers)
68        if self.has_serverless_structure(root)? {
69            return Ok(ArchitecturalStyle::Serverless);
70        }
71
72        // Default to monolithic if no specific pattern detected
73        Ok(ArchitecturalStyle::Monolithic)
74    }
75
76    /// Checks if the project has a layered architecture structure
77    fn has_layered_structure(&self, root: &Path) -> Result<bool, ResearchError> {
78        // Look for common layered architecture directories
79        let layered_dirs = ["domain", "application", "infrastructure", "interfaces"];
80
81        for dir in &layered_dirs {
82            let path = root.join(dir);
83            if path.exists() && path.is_dir() {
84                return Ok(true);
85            }
86        }
87
88        // Also check for src subdirectories with layer names
89        let src_path = root.join("src");
90        if src_path.exists() && src_path.is_dir() {
91            for dir in &layered_dirs {
92                let path = src_path.join(dir);
93                if path.exists() && path.is_dir() {
94                    return Ok(true);
95                }
96            }
97        }
98
99        Ok(false)
100    }
101
102    /// Checks if the project has a microservices structure
103    fn has_microservices_structure(&self, root: &Path) -> Result<bool, ResearchError> {
104        // Look for multiple service directories
105        let services_path = root.join("services");
106        if services_path.exists() && services_path.is_dir() {
107            if let Ok(entries) = std::fs::read_dir(&services_path) {
108                let service_count = entries
109                    .filter_map(|e| e.ok())
110                    .filter(|e| e.path().is_dir())
111                    .count();
112
113                if service_count >= 2 {
114                    return Ok(true);
115                }
116            }
117        }
118
119        // Check for microservices in crates (Rust workspace)
120        let crates_path = root.join("crates");
121        if crates_path.exists() && crates_path.is_dir() {
122            if let Ok(entries) = std::fs::read_dir(&crates_path) {
123                let crate_count = entries
124                    .filter_map(|e| e.ok())
125                    .filter(|e| e.path().is_dir())
126                    .count();
127
128                if crate_count >= 2 {
129                    return Ok(true);
130                }
131            }
132        }
133
134        Ok(false)
135    }
136
137    /// Checks if the project has an event-driven structure
138    fn has_event_driven_structure(&self, root: &Path) -> Result<bool, ResearchError> {
139        // Look for event-related directories
140        let event_dirs = ["events", "handlers", "subscribers", "listeners"];
141
142        for dir in &event_dirs {
143            let path = root.join(dir);
144            if path.exists() && path.is_dir() {
145                return Ok(true);
146            }
147        }
148
149        // Check in src directory
150        let src_path = root.join("src");
151        if src_path.exists() && src_path.is_dir() {
152            for dir in &event_dirs {
153                let path = src_path.join(dir);
154                if path.exists() && path.is_dir() {
155                    return Ok(true);
156                }
157            }
158        }
159
160        Ok(false)
161    }
162
163    /// Checks if the project has a serverless structure
164    fn has_serverless_structure(&self, root: &Path) -> Result<bool, ResearchError> {
165        // Look for serverless-specific directories
166        let serverless_dirs = ["functions", "handlers", "lambdas"];
167
168        for dir in &serverless_dirs {
169            let path = root.join(dir);
170            if path.exists() && path.is_dir() {
171                return Ok(true);
172            }
173        }
174
175        // Check for serverless.yml or serverless.yaml
176        if root.join("serverless.yml").exists() || root.join("serverless.yaml").exists() {
177            return Ok(true);
178        }
179
180        Ok(false)
181    }
182
183    /// Parses Architecture Decision Record (ADR) files
184    ///
185    /// # Arguments
186    ///
187    /// * `root` - The root directory to search for ADR files
188    ///
189    /// # Returns
190    ///
191    /// A vector of parsed architectural decisions, or an error if parsing fails
192    pub fn parse_adrs(&self, root: &Path) -> Result<Vec<ArchitecturalDecision>, ResearchError> {
193        let mut decisions = Vec::new();
194
195        // Look for ADR files in common locations
196        let adr_dirs = vec![
197            root.join("docs/adr"),
198            root.join("docs/decisions"),
199            root.join("adr"),
200            root.join("decisions"),
201            root.join("architecture/decisions"),
202        ];
203
204        for adr_dir in adr_dirs {
205            if adr_dir.exists() && adr_dir.is_dir() {
206                if let Ok(entries) = std::fs::read_dir(&adr_dir) {
207                    for entry in entries.filter_map(|e| e.ok()) {
208                        let path = entry.path();
209                        if path.is_file() && (path.extension().is_some_and(|ext| ext == "md")) {
210                            if let Ok(decision) = self.parse_adr_file(&path) {
211                                decisions.push(decision);
212                            }
213                        }
214                    }
215                }
216            }
217        }
218
219        Ok(decisions)
220    }
221
222    /// Parses a single ADR file
223    fn parse_adr_file(&self, path: &Path) -> Result<ArchitecturalDecision, ResearchError> {
224        let content = std::fs::read_to_string(path).map_err(|e| ResearchError::IoError {
225            reason: format!("Failed to read ADR file: {}", e),
226        })?;
227
228        // Extract ADR metadata from filename and content
229        let filename = path
230            .file_name()
231            .and_then(|n| n.to_str())
232            .unwrap_or("unknown");
233
234        // Parse ADR ID from filename (e.g., "0001-use-rust.md" -> "0001")
235        let id = filename.split('-').next().unwrap_or("unknown").to_string();
236
237        // Extract title from filename
238        let title = filename
239            .strip_prefix(&format!("{}-", id))
240            .and_then(|s| s.strip_suffix(".md"))
241            .unwrap_or(filename)
242            .replace('-', " ");
243
244        // Parse sections from content
245        let (context, decision, consequences) = self.parse_adr_sections(&content);
246
247        Ok(ArchitecturalDecision {
248            id,
249            title,
250            context,
251            decision,
252            consequences,
253            date: Utc::now(),
254        })
255    }
256
257    /// Parses ADR sections from markdown content
258    fn parse_adr_sections(&self, content: &str) -> (String, String, Vec<String>) {
259        let mut context = String::new();
260        let mut decision = String::new();
261        let mut consequences = Vec::new();
262
263        let lines: Vec<&str> = content.lines().collect();
264        let mut current_section = "";
265
266        for line in lines {
267            let lower = line.to_lowercase();
268
269            if lower.contains("## context") || lower.contains("# context") {
270                current_section = "context";
271            } else if lower.contains("## decision") || lower.contains("# decision") {
272                current_section = "decision";
273            } else if lower.contains("## consequences") || lower.contains("# consequences") {
274                current_section = "consequences";
275            } else if line.starts_with('#') {
276                current_section = "";
277            } else if !line.trim().is_empty() {
278                match current_section {
279                    "context" => {
280                        context.push_str(line);
281                        context.push('\n');
282                    }
283                    "decision" => {
284                        decision.push_str(line);
285                        decision.push('\n');
286                    }
287                    "consequences" => {
288                        if line.trim().starts_with('-') || line.trim().starts_with('*') {
289                            consequences
290                                .push(line.trim_start_matches(['-', '*']).trim().to_string());
291                        }
292                    }
293                    _ => {}
294                }
295            }
296        }
297
298        (
299            context.trim().to_string(),
300            decision.trim().to_string(),
301            consequences,
302        )
303    }
304
305    /// Builds complete architectural intent from analysis
306    ///
307    /// # Arguments
308    ///
309    /// * `root` - The root directory of the project
310    ///
311    /// # Returns
312    ///
313    /// The complete architectural intent, or an error if analysis fails
314    pub fn build_intent(&self, root: &Path) -> Result<ArchitecturalIntent, ResearchError> {
315        let style = self.infer_style(root)?;
316        let decisions = self.parse_adrs(root)?;
317
318        // Extract principles and constraints from decisions
319        let principles = self.extract_principles(&decisions);
320        let constraints = self.extract_constraints(&decisions);
321
322        Ok(ArchitecturalIntent {
323            style,
324            principles,
325            constraints,
326            decisions,
327        })
328    }
329
330    /// Extracts architectural principles from decisions
331    fn extract_principles(&self, decisions: &[ArchitecturalDecision]) -> Vec<String> {
332        let mut principles = Vec::new();
333
334        for decision in decisions {
335            // Extract principles from decision context and decision text
336            if decision.context.to_lowercase().contains("principle") {
337                principles.push(decision.context.clone());
338            }
339            if decision.decision.to_lowercase().contains("principle") {
340                principles.push(decision.decision.clone());
341            }
342        }
343
344        principles.sort();
345        principles.dedup();
346        principles
347    }
348
349    /// Extracts architectural constraints from decisions
350    fn extract_constraints(&self, decisions: &[ArchitecturalDecision]) -> Vec<String> {
351        let mut constraints = Vec::new();
352
353        for decision in decisions {
354            // Extract constraints from consequences
355            for consequence in &decision.consequences {
356                if consequence.to_lowercase().contains("constraint")
357                    || consequence.to_lowercase().contains("must")
358                    || consequence.to_lowercase().contains("require")
359                {
360                    constraints.push(consequence.clone());
361                }
362            }
363        }
364
365        constraints.sort();
366        constraints.dedup();
367        constraints
368    }
369}
370
371impl Default for ArchitecturalIntentTracker {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::fs;
381    use tempfile::TempDir;
382
383    #[test]
384    fn test_architectural_intent_tracker_creation() {
385        let tracker = ArchitecturalIntentTracker::new();
386        assert_eq!(tracker.confidence_threshold, 0.5);
387    }
388
389    #[test]
390    fn test_architectural_intent_tracker_with_threshold() {
391        let tracker = ArchitecturalIntentTracker::with_threshold(0.7);
392        assert_eq!(tracker.confidence_threshold, 0.7);
393    }
394
395    #[test]
396    fn test_threshold_clamping() {
397        let tracker_low = ArchitecturalIntentTracker::with_threshold(-0.5);
398        assert_eq!(tracker_low.confidence_threshold, 0.0);
399
400        let tracker_high = ArchitecturalIntentTracker::with_threshold(1.5);
401        assert_eq!(tracker_high.confidence_threshold, 1.0);
402    }
403
404    #[test]
405    fn test_detect_layered_structure() -> Result<(), Box<dyn std::error::Error>> {
406        let temp_dir = TempDir::new()?;
407        let root = temp_dir.path();
408
409        // Create layered directories
410        fs::create_dir(root.join("domain"))?;
411        fs::create_dir(root.join("application"))?;
412        fs::create_dir(root.join("infrastructure"))?;
413
414        let tracker = ArchitecturalIntentTracker::new();
415        let has_layered = tracker.has_layered_structure(root)?;
416        assert!(has_layered);
417
418        Ok(())
419    }
420
421    #[test]
422    fn test_detect_microservices_structure() -> Result<(), Box<dyn std::error::Error>> {
423        let temp_dir = TempDir::new()?;
424        let root = temp_dir.path();
425
426        // Create services directories
427        let services = root.join("services");
428        fs::create_dir(&services)?;
429        fs::create_dir(services.join("service1"))?;
430        fs::create_dir(services.join("service2"))?;
431
432        let tracker = ArchitecturalIntentTracker::new();
433        let has_microservices = tracker.has_microservices_structure(root)?;
434        assert!(has_microservices);
435
436        Ok(())
437    }
438
439    #[test]
440    fn test_detect_event_driven_structure() -> Result<(), Box<dyn std::error::Error>> {
441        let temp_dir = TempDir::new()?;
442        let root = temp_dir.path();
443
444        // Create event-driven directories
445        fs::create_dir(root.join("events"))?;
446        fs::create_dir(root.join("handlers"))?;
447
448        let tracker = ArchitecturalIntentTracker::new();
449        let has_event_driven = tracker.has_event_driven_structure(root)?;
450        assert!(has_event_driven);
451
452        Ok(())
453    }
454
455    #[test]
456    fn test_parse_adr_sections() {
457        let tracker = ArchitecturalIntentTracker::new();
458        let content = r#"
459# ADR-001: Use Rust
460
461## Context
462We need a performant language.
463
464## Decision
465We will use Rust for core components.
466
467## Consequences
468- Steep learning curve
469- Better performance
470"#;
471
472        let (context, decision, consequences) = tracker.parse_adr_sections(content);
473
474        assert!(context.contains("performant"));
475        assert!(decision.contains("Rust"));
476        assert_eq!(consequences.len(), 2);
477    }
478
479    #[test]
480    fn test_extract_principles() {
481        let decisions = vec![ArchitecturalDecision {
482            id: "001".to_string(),
483            title: "Test Decision".to_string(),
484            context: "Following the principle of separation of concerns".to_string(),
485            decision: "We will use layered architecture".to_string(),
486            consequences: vec![],
487            date: Utc::now(),
488        }];
489
490        let tracker = ArchitecturalIntentTracker::new();
491        let principles = tracker.extract_principles(&decisions);
492
493        assert!(!principles.is_empty());
494    }
495
496    #[test]
497    fn test_extract_constraints() {
498        let decisions = vec![ArchitecturalDecision {
499            id: "001".to_string(),
500            title: "Test Decision".to_string(),
501            context: "".to_string(),
502            decision: "".to_string(),
503            consequences: vec![
504                "Must use HTTPS for all communication".to_string(),
505                "Constraint: Maximum response time 100ms".to_string(),
506            ],
507            date: Utc::now(),
508        }];
509
510        let tracker = ArchitecturalIntentTracker::new();
511        let constraints = tracker.extract_constraints(&decisions);
512
513        assert_eq!(constraints.len(), 2);
514    }
515
516    #[test]
517    fn test_infer_style_layered() -> Result<(), Box<dyn std::error::Error>> {
518        let temp_dir = TempDir::new()?;
519        let root = temp_dir.path();
520
521        // Create layered architecture
522        fs::create_dir(root.join("domain"))?;
523        fs::create_dir(root.join("application"))?;
524
525        let tracker = ArchitecturalIntentTracker::new();
526        let style = tracker.infer_style(root)?;
527
528        assert_eq!(style, ArchitecturalStyle::Layered);
529        Ok(())
530    }
531
532    #[test]
533    fn test_infer_style_microservices() -> Result<(), Box<dyn std::error::Error>> {
534        let temp_dir = TempDir::new()?;
535        let root = temp_dir.path();
536
537        // Create microservices structure
538        let services = root.join("services");
539        fs::create_dir(&services)?;
540        fs::create_dir(services.join("auth-service"))?;
541        fs::create_dir(services.join("api-service"))?;
542
543        let tracker = ArchitecturalIntentTracker::new();
544        let style = tracker.infer_style(root)?;
545
546        assert_eq!(style, ArchitecturalStyle::Microservices);
547        Ok(())
548    }
549
550    #[test]
551    fn test_infer_style_event_driven() -> Result<(), Box<dyn std::error::Error>> {
552        let temp_dir = TempDir::new()?;
553        let root = temp_dir.path();
554
555        // Create event-driven structure
556        fs::create_dir(root.join("events"))?;
557        fs::create_dir(root.join("handlers"))?;
558
559        let tracker = ArchitecturalIntentTracker::new();
560        let style = tracker.infer_style(root)?;
561
562        assert_eq!(style, ArchitecturalStyle::EventDriven);
563        Ok(())
564    }
565
566    #[test]
567    fn test_infer_style_serverless() -> Result<(), Box<dyn std::error::Error>> {
568        let temp_dir = TempDir::new()?;
569        let root = temp_dir.path();
570
571        // Create serverless.yml
572        fs::write(root.join("serverless.yml"), "service: test")?;
573
574        let tracker = ArchitecturalIntentTracker::new();
575        let style = tracker.infer_style(root)?;
576
577        assert_eq!(style, ArchitecturalStyle::Serverless);
578        Ok(())
579    }
580
581    #[test]
582    fn test_infer_style_monolithic() -> Result<(), Box<dyn std::error::Error>> {
583        let temp_dir = TempDir::new()?;
584        let root = temp_dir.path();
585
586        // Create a simple structure with no specific pattern
587        fs::create_dir(root.join("src"))?;
588
589        let tracker = ArchitecturalIntentTracker::new();
590        let style = tracker.infer_style(root)?;
591
592        assert_eq!(style, ArchitecturalStyle::Monolithic);
593        Ok(())
594    }
595
596    #[test]
597    fn test_build_intent() -> Result<(), Box<dyn std::error::Error>> {
598        let temp_dir = TempDir::new()?;
599        let root = temp_dir.path();
600
601        // Create layered structure
602        fs::create_dir(root.join("domain"))?;
603        fs::create_dir(root.join("application"))?;
604
605        let tracker = ArchitecturalIntentTracker::new();
606        let intent = tracker.build_intent(root)?;
607
608        assert_eq!(intent.style, ArchitecturalStyle::Layered);
609        assert!(intent.decisions.is_empty()); // No ADR files
610        Ok(())
611    }
612
613    #[test]
614    fn test_parse_adr_file() -> Result<(), Box<dyn std::error::Error>> {
615        let temp_dir = TempDir::new()?;
616        let root = temp_dir.path();
617
618        // Create ADR directory and file
619        let adr_dir = root.join("docs/adr");
620        fs::create_dir_all(&adr_dir)?;
621
622        let adr_content = r#"# ADR-001: Use Rust
623
624## Context
625We need a performant language for core components.
626
627## Decision
628We will use Rust for all core infrastructure.
629
630## Consequences
631- Steep learning curve for team
632- Better performance and memory safety
633- Longer compilation times
634"#;
635
636        fs::write(adr_dir.join("0001-use-rust.md"), adr_content)?;
637
638        let tracker = ArchitecturalIntentTracker::new();
639        let decisions = tracker.parse_adrs(root)?;
640
641        assert_eq!(decisions.len(), 1);
642        assert_eq!(decisions[0].id, "0001");
643        // Title is extracted from filename, replacing hyphens with spaces
644        assert_eq!(decisions[0].title, "use rust");
645        assert_eq!(decisions[0].consequences.len(), 3);
646        Ok(())
647    }
648
649    #[test]
650    fn test_parse_multiple_adrs() -> Result<(), Box<dyn std::error::Error>> {
651        let temp_dir = TempDir::new()?;
652        let root = temp_dir.path();
653
654        // Create ADR directory with multiple files
655        let adr_dir = root.join("adr");
656        fs::create_dir(&adr_dir)?;
657
658        fs::write(
659            adr_dir.join("0001-use-rust.md"),
660            "# ADR-001\n## Context\nTest\n## Decision\nUse Rust\n## Consequences\n- Good",
661        )?;
662        fs::write(adr_dir.join("0002-use-postgres.md"), "# ADR-002\n## Context\nDatabase\n## Decision\nUse PostgreSQL\n## Consequences\n- Reliable")?;
663
664        let tracker = ArchitecturalIntentTracker::new();
665        let decisions = tracker.parse_adrs(root)?;
666
667        assert_eq!(decisions.len(), 2);
668        Ok(())
669    }
670
671    #[test]
672    fn test_default_tracker() {
673        let tracker = ArchitecturalIntentTracker::default();
674        assert_eq!(tracker.confidence_threshold, 0.5);
675    }
676
677    #[test]
678    fn test_parse_adr_sections_with_markdown_variations() {
679        let tracker = ArchitecturalIntentTracker::new();
680
681        // Test with different markdown heading levels
682        let content = r#"
683# ADR-001
684
685# Context
686This is the context section.
687
688# Decision
689This is the decision section.
690
691# Consequences
692- Consequence 1
693- Consequence 2
694"#;
695
696        let (context, decision, consequences) = tracker.parse_adr_sections(content);
697
698        assert!(context.contains("context section"));
699        assert!(decision.contains("decision section"));
700        assert_eq!(consequences.len(), 2);
701    }
702
703    #[test]
704    fn test_parse_adr_sections_empty_content() {
705        let tracker = ArchitecturalIntentTracker::new();
706        let content = "";
707
708        let (context, decision, consequences) = tracker.parse_adr_sections(content);
709
710        assert!(context.is_empty());
711        assert!(decision.is_empty());
712        assert!(consequences.is_empty());
713    }
714
715    #[test]
716    fn test_extract_principles_deduplication() {
717        let decisions = vec![
718            ArchitecturalDecision {
719                id: "001".to_string(),
720                title: "Test".to_string(),
721                context: "Following the principle of separation of concerns".to_string(),
722                decision: "".to_string(),
723                consequences: vec![],
724                date: Utc::now(),
725            },
726            ArchitecturalDecision {
727                id: "002".to_string(),
728                title: "Test".to_string(),
729                context: "Following the principle of separation of concerns".to_string(),
730                decision: "".to_string(),
731                consequences: vec![],
732                date: Utc::now(),
733            },
734        ];
735
736        let tracker = ArchitecturalIntentTracker::new();
737        let principles = tracker.extract_principles(&decisions);
738
739        // Should be deduplicated
740        assert_eq!(principles.len(), 1);
741    }
742
743    #[test]
744    fn test_extract_constraints_deduplication() {
745        let decisions = vec![ArchitecturalDecision {
746            id: "001".to_string(),
747            title: "Test".to_string(),
748            context: "".to_string(),
749            decision: "".to_string(),
750            consequences: vec!["Must use HTTPS".to_string(), "Must use HTTPS".to_string()],
751            date: Utc::now(),
752        }];
753
754        let tracker = ArchitecturalIntentTracker::new();
755        let constraints = tracker.extract_constraints(&decisions);
756
757        // Should be deduplicated
758        assert_eq!(constraints.len(), 1);
759    }
760}