1use crate::models::{ArchitecturalDecision, ArchitecturalIntent, ArchitecturalStyle};
7use crate::ResearchError;
8use chrono::Utc;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
13pub struct ArchitecturalIntentTracker {
14 pub confidence_threshold: f32,
16}
17
18impl ArchitecturalIntentTracker {
19 pub fn new() -> Self {
21 Self {
22 confidence_threshold: 0.5,
23 }
24 }
25
26 pub fn with_threshold(confidence_threshold: f32) -> Self {
28 Self {
29 confidence_threshold: confidence_threshold.clamp(0.0, 1.0),
30 }
31 }
32
33 pub fn infer_style(&self, root: &Path) -> Result<ArchitecturalStyle, ResearchError> {
43 let style = self.infer_from_structure(root)?;
45 Ok(style)
46 }
47
48 fn infer_from_structure(&self, root: &Path) -> Result<ArchitecturalStyle, ResearchError> {
50 if self.has_layered_structure(root)? {
54 return Ok(ArchitecturalStyle::Layered);
55 }
56
57 if self.has_microservices_structure(root)? {
59 return Ok(ArchitecturalStyle::Microservices);
60 }
61
62 if self.has_event_driven_structure(root)? {
64 return Ok(ArchitecturalStyle::EventDriven);
65 }
66
67 if self.has_serverless_structure(root)? {
69 return Ok(ArchitecturalStyle::Serverless);
70 }
71
72 Ok(ArchitecturalStyle::Monolithic)
74 }
75
76 fn has_layered_structure(&self, root: &Path) -> Result<bool, ResearchError> {
78 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 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 fn has_microservices_structure(&self, root: &Path) -> Result<bool, ResearchError> {
104 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 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 fn has_event_driven_structure(&self, root: &Path) -> Result<bool, ResearchError> {
139 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 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 fn has_serverless_structure(&self, root: &Path) -> Result<bool, ResearchError> {
165 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 if root.join("serverless.yml").exists() || root.join("serverless.yaml").exists() {
177 return Ok(true);
178 }
179
180 Ok(false)
181 }
182
183 pub fn parse_adrs(&self, root: &Path) -> Result<Vec<ArchitecturalDecision>, ResearchError> {
193 let mut decisions = Vec::new();
194
195 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 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 let filename = path
230 .file_name()
231 .and_then(|n| n.to_str())
232 .unwrap_or("unknown");
233
234 let id = filename.split('-').next().unwrap_or("unknown").to_string();
236
237 let title = filename
239 .strip_prefix(&format!("{}-", id))
240 .and_then(|s| s.strip_suffix(".md"))
241 .unwrap_or(filename)
242 .replace('-', " ");
243
244 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 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 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 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 fn extract_principles(&self, decisions: &[ArchitecturalDecision]) -> Vec<String> {
332 let mut principles = Vec::new();
333
334 for decision in decisions {
335 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 fn extract_constraints(&self, decisions: &[ArchitecturalDecision]) -> Vec<String> {
351 let mut constraints = Vec::new();
352
353 for decision in decisions {
354 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 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 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 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 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 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 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 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 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 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()); 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 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 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 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 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 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 assert_eq!(constraints.len(), 1);
759 }
760}