airs_memspec/parser/
context.rs

1//! Context Correlation System
2//!
3//! This module provides functionality for correlating parsed markdown content with
4//! workspace context, tracking current state, and managing context transitions
5//! in Multi-Project Memory Bank environments.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use chrono::{DateTime, Utc};
11
12use crate::models::workspace::CurrentContext;
13use crate::parser::markdown::{MarkdownContent, MarkdownParser, TaskItem, TaskStatus};
14use crate::parser::navigation::{MemoryBankNavigator, MemoryBankStructure};
15use crate::utils::fs::{FsError, FsResult};
16
17/// The main context correlation engine for memory bank workspaces
18///
19/// This structure manages the discovery, parsing, and correlation of workspace
20/// context across multiple sub-projects within a memory bank structure.
21#[derive(Debug)]
22pub struct ContextCorrelator {
23    /// Current workspace context and all discovered sub-project information
24    workspace_context: Option<WorkspaceContext>,
25}
26
27/// Complete workspace context with correlated information
28///
29/// This structure combines raw file system information with parsed content
30/// and current context state to provide a unified view of the workspace.
31#[derive(Debug, Clone)]
32pub struct WorkspaceContext {
33    /// Discovered memory bank structure
34    pub structure: MemoryBankStructure,
35
36    /// Current active context information
37    pub current_context: CurrentContext,
38
39    /// Parsed content from key workspace files
40    pub workspace_content: WorkspaceContent,
41
42    /// Sub-project context information
43    pub sub_project_contexts: HashMap<String, SubProjectContext>,
44
45    /// Last correlation update timestamp
46    pub last_updated: DateTime<Utc>,
47}
48
49/// Parsed workspace-level content
50#[derive(Debug, Clone)]
51pub struct WorkspaceContent {
52    /// Parsed project brief content
53    pub project_brief: Option<MarkdownContent>,
54
55    /// Parsed shared patterns content
56    pub shared_patterns: Option<MarkdownContent>,
57
58    /// Parsed workspace architecture content
59    pub workspace_architecture: Option<MarkdownContent>,
60
61    /// Parsed workspace progress content
62    pub workspace_progress: Option<MarkdownContent>,
63}
64
65/// Context information for a specific sub-project
66#[derive(Debug, Clone)]
67pub struct SubProjectContext {
68    /// Sub-project name/identifier
69    pub name: String,
70
71    /// Parsed content from core sub-project files
72    pub content: SubProjectContent,
73
74    /// Aggregated task information from all task files
75    pub task_summary: TaskSummary,
76
77    /// Current status derived from parsed content
78    pub derived_status: DerivedStatus,
79
80    /// Last update timestamp for this sub-project context
81    pub last_updated: DateTime<Utc>,
82}
83
84/// Parsed sub-project content from core files
85#[derive(Debug, Clone)]
86pub struct SubProjectContent {
87    /// Parsed project brief content
88    pub project_brief: Option<MarkdownContent>,
89
90    /// Parsed product context content
91    pub product_context: Option<MarkdownContent>,
92
93    /// Parsed active context content
94    pub active_context: Option<MarkdownContent>,
95
96    /// Parsed system patterns content
97    pub system_patterns: Option<MarkdownContent>,
98
99    /// Parsed tech context content
100    pub tech_context: Option<MarkdownContent>,
101
102    /// Parsed progress content
103    pub progress: Option<MarkdownContent>,
104}
105
106/// Aggregated task information across all task files
107#[derive(Debug, Clone)]
108pub struct TaskSummary {
109    /// Total number of tasks
110    pub total_tasks: usize,
111
112    /// Tasks by status
113    pub tasks_by_status: HashMap<TaskStatus, Vec<TaskItem>>,
114
115    /// Most recently updated tasks
116    pub recent_tasks: Vec<TaskItem>,
117
118    /// Task completion percentage
119    pub completion_percentage: f64,
120
121    /// Tasks with blocking issues
122    pub blocked_tasks: Vec<TaskItem>,
123
124    /// Next priority tasks
125    pub next_tasks: Vec<TaskItem>,
126}
127
128/// Derived status information from parsed content
129#[derive(Debug, Clone)]
130pub struct DerivedStatus {
131    /// Overall project health based on task progress
132    pub health: ProjectHealth,
133
134    /// Current phase derived from active context
135    pub current_phase: String,
136
137    /// Progress indicators from various sources
138    pub progress_indicators: Vec<ProgressIndicator>,
139
140    /// Issues or blockers identified
141    pub issues: Vec<Issue>,
142
143    /// Recommendations for next actions
144    pub recommendations: Vec<String>,
145}
146
147/// Project health assessment
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
149pub enum ProjectHealth {
150    /// Project has significant issues or blockers
151    Critical,
152    /// Project has minor issues or delays
153    Warning,
154    /// Project is progressing well
155    Healthy,
156    /// Project status cannot be determined
157    Unknown,
158}
159
160/// Progress indicator from content analysis
161#[derive(Debug, Clone)]
162pub struct ProgressIndicator {
163    /// Source of the indicator (file name, section)
164    pub source: String,
165
166    /// Type of progress metric
167    pub metric_type: ProgressMetricType,
168
169    /// Current value
170    pub current_value: f64,
171
172    /// Target value (if applicable)
173    pub target_value: Option<f64>,
174
175    /// Progress description
176    pub description: String,
177}
178
179/// Types of progress metrics
180#[derive(Debug, Clone)]
181pub enum ProgressMetricType {
182    /// Task completion percentage
183    TaskCompletion,
184    /// Milestone progress
185    MilestoneProgress,
186    /// Feature implementation status
187    FeatureProgress,
188    /// Documentation completeness
189    DocumentationProgress,
190    /// Custom metric
191    Custom(String),
192}
193
194/// Identified issue or blocker
195#[derive(Debug, Clone)]
196pub struct Issue {
197    /// Issue severity level
198    pub severity: IssueSeverity,
199
200    /// Issue description
201    pub description: String,
202
203    /// Source where issue was identified
204    pub source: String,
205
206    /// Suggested resolution
207    pub resolution: Option<String>,
208}
209
210/// Issue severity levels
211#[derive(Debug, Clone, PartialEq, PartialOrd)]
212pub enum IssueSeverity {
213    /// Low priority issue
214    Low,
215    /// Medium priority issue
216    Medium,
217    /// High priority issue
218    High,
219    /// Critical blocker
220    Critical,
221}
222
223impl Default for ContextCorrelator {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229impl ContextCorrelator {
230    /// Create a new context correlator
231    ///
232    /// Initializes the correlator with a memory bank navigator for file access.
233    /// The workspace context will be loaded on first correlation request.
234    ///
235    /// # Arguments
236    /// * `navigator` - Memory bank navigator for file system access
237    ///
238    /// # Returns
239    /// * `ContextCorrelator` - New correlator instance
240    pub fn new() -> Self {
241        Self {
242            workspace_context: None,
243        }
244    }
245
246    /// Discover and correlate workspace context from a given root path
247    ///
248    /// This is the primary entry point for context correlation. It discovers
249    /// the memory bank structure, parses relevant content, and builds a
250    /// comprehensive workspace context.
251    ///
252    /// # Arguments
253    /// * `root_path` - Root path to search for memory bank structure
254    ///
255    /// # Returns
256    /// * `Ok(WorkspaceContext)` - Complete correlated workspace context
257    /// * `Err(FsError)` - Discovery or parsing errors
258    ///
259    /// # Example
260    /// ```rust,no_run
261    /// use airs_memspec::parser::context::ContextCorrelator;
262    /// use std::path::PathBuf;
263    ///
264    /// let mut correlator = ContextCorrelator::new();
265    /// let context = correlator.discover_and_correlate(&PathBuf::from("."))?;
266    /// println!("Found {} sub-projects", context.sub_project_contexts.len());
267    /// # Ok::<(), Box<dyn std::error::Error>>(())
268    /// ```
269    pub fn discover_and_correlate(&mut self, root_path: &Path) -> FsResult<&WorkspaceContext> {
270        // Step 1: Discover memory bank structure
271        let structure = MemoryBankNavigator::discover_structure(root_path)?;
272
273        // Step 2: Parse current context file
274        let current_context = self.parse_current_context(&structure)?;
275
276        // Step 3: Parse workspace-level content
277        let workspace_content = self.parse_workspace_content(&structure)?;
278
279        // Step 4: Parse all sub-project contexts
280        let sub_project_contexts = self.parse_sub_project_contexts(&structure)?;
281
282        // Step 5: Build complete workspace context
283        let workspace_context = WorkspaceContext {
284            structure,
285            current_context,
286            workspace_content,
287            sub_project_contexts,
288            last_updated: Utc::now(),
289        };
290
291        self.workspace_context = Some(workspace_context);
292        Ok(self.workspace_context.as_ref().unwrap())
293    }
294
295    /// Get the current workspace context
296    ///
297    /// Returns the cached workspace context if available, or None if
298    /// context correlation has not been performed yet.
299    ///
300    /// # Returns
301    /// * `Option<&WorkspaceContext>` - Current workspace context
302    pub fn get_workspace_context(&self) -> Option<&WorkspaceContext> {
303        self.workspace_context.as_ref()
304    }
305
306    /// Switch to a different sub-project context
307    ///
308    /// Updates the current context to point to a different sub-project and
309    /// updates the current_context.md file accordingly.
310    ///
311    /// # Arguments
312    /// * `sub_project_name` - Name of the sub-project to switch to
313    /// * `switched_by` - Identifier for who/what triggered the switch
314    ///
315    /// # Returns
316    /// * `Ok(())` - Context switch successful
317    /// * `Err(FsError)` - File update or validation errors
318    pub fn switch_context(&mut self, sub_project_name: &str, switched_by: &str) -> FsResult<()> {
319        let workspace_context = self.workspace_context.as_mut().ok_or_else(|| {
320            FsError::ParseError {
321                message: "Workspace context not initialized. Call discover_and_correlate first.".to_string(),
322                suggestion: "Make sure you're in a directory with a memory bank structure.".to_string(),
323            }
324        })?;
325
326        // Validate that the sub-project exists
327        if !workspace_context
328            .sub_project_contexts
329            .contains_key(sub_project_name)
330        {
331            let available_projects: Vec<String> = workspace_context.sub_project_contexts.keys().cloned().collect();
332            return Err(FsError::ParseError {
333                message: format!("Sub-project '{sub_project_name}' not found. Available projects: {}", available_projects.join(", ")),
334                suggestion: "Check available projects with 'airs-memspec status' or verify the project name.".to_string(),
335            });
336        }
337
338        // Update current context
339        workspace_context.current_context = CurrentContext {
340            active_sub_project: sub_project_name.to_string(),
341            switched_on: Utc::now(),
342            switched_by: switched_by.to_string(),
343            status: format!("switched_to_{sub_project_name}"),
344            metadata: HashMap::new(),
345        };
346
347        // Update the current_context.md file - we need to get the values first
348        let (structure, current_context) = {
349            let ws_ctx = workspace_context;
350            (ws_ctx.structure.clone(), ws_ctx.current_context.clone())
351        };
352
353        self.update_current_context_file(&structure, &current_context)?;
354
355        // Get the workspace context again to update timestamp
356        if let Some(workspace_context) = &mut self.workspace_context {
357            workspace_context.last_updated = Utc::now();
358        }
359
360        Ok(())
361    }
362
363    /// Get aggregated task status across the workspace
364    ///
365    /// Provides a high-level view of task progress across all sub-projects
366    /// or for a specific sub-project.
367    ///
368    /// # Arguments
369    /// * `sub_project` - Optional sub-project name to filter by
370    ///
371    /// # Returns
372    /// * `Option<TaskSummary>` - Aggregated task summary
373    pub fn get_task_summary(&self, sub_project: Option<&str>) -> Option<TaskSummary> {
374        let workspace_context = self.workspace_context.as_ref()?;
375
376        match sub_project {
377            Some(name) => workspace_context
378                .sub_project_contexts
379                .get(name)
380                .map(|ctx| ctx.task_summary.clone()),
381            None => {
382                // Aggregate across all sub-projects
383                Some(self.aggregate_workspace_tasks(workspace_context))
384            }
385        }
386    }
387
388    /// Parse current context from current_context.md file
389    fn parse_current_context(&self, structure: &MemoryBankStructure) -> FsResult<CurrentContext> {
390        if let Some(context_path) = &structure.current_context {
391            let content = MarkdownParser::parse_file(context_path)?;
392
393            // Extract context information from parsed markdown
394            let active_sub_project = content
395                .metadata
396                .title
397                .or_else(|| {
398                    content
399                        .sections
400                        .keys()
401                        .find(|k| k.contains("active"))
402                        .cloned()
403                })
404                .unwrap_or_else(|| "unknown".to_string());
405
406            Ok(CurrentContext {
407                active_sub_project,
408                switched_on: Utc::now(),
409                switched_by: "system".to_string(),
410                status: "initialized".to_string(),
411                metadata: HashMap::new(),
412            })
413        } else {
414            // Default context if no current_context.md file exists
415            Ok(CurrentContext {
416                active_sub_project: "unknown".to_string(),
417                switched_on: Utc::now(),
418                switched_by: "system".to_string(),
419                status: "no_context_file".to_string(),
420                metadata: HashMap::new(),
421            })
422        }
423    }
424
425    /// Parse workspace-level content files
426    fn parse_workspace_content(
427        &self,
428        structure: &MemoryBankStructure,
429    ) -> FsResult<WorkspaceContent> {
430        let project_brief = if let Some(path) = &structure.workspace.project_brief {
431            Some(MarkdownParser::parse_file(path)?)
432        } else {
433            None
434        };
435
436        let shared_patterns = if let Some(path) = &structure.workspace.shared_patterns {
437            Some(MarkdownParser::parse_file(path)?)
438        } else {
439            None
440        };
441
442        let workspace_architecture = if let Some(path) = &structure.workspace.workspace_architecture
443        {
444            Some(MarkdownParser::parse_file(path)?)
445        } else {
446            None
447        };
448
449        let workspace_progress = if let Some(path) = &structure.workspace.workspace_progress {
450            Some(MarkdownParser::parse_file(path)?)
451        } else {
452            None
453        };
454
455        Ok(WorkspaceContent {
456            project_brief,
457            shared_patterns,
458            workspace_architecture,
459            workspace_progress,
460        })
461    }
462
463    /// Parse all sub-project contexts
464    fn parse_sub_project_contexts(
465        &self,
466        structure: &MemoryBankStructure,
467    ) -> FsResult<HashMap<String, SubProjectContext>> {
468        let mut contexts = HashMap::new();
469
470        for (name, sub_project_files) in &structure.sub_projects {
471            let context = self.parse_single_sub_project_context(name, sub_project_files)?;
472            contexts.insert(name.clone(), context);
473        }
474
475        Ok(contexts)
476    }
477
478    /// Parse context for a single sub-project
479    fn parse_single_sub_project_context(
480        &self,
481        name: &str,
482        files: &crate::parser::navigation::SubProjectFiles,
483    ) -> FsResult<SubProjectContext> {
484        // Parse core content files
485        let project_brief = if let Some(path) = &files.project_brief {
486            Some(MarkdownParser::parse_file(path)?)
487        } else {
488            None
489        };
490
491        let product_context = if let Some(path) = &files.product_context {
492            Some(MarkdownParser::parse_file(path)?)
493        } else {
494            None
495        };
496
497        let active_context = if let Some(path) = &files.active_context {
498            Some(MarkdownParser::parse_file(path)?)
499        } else {
500            None
501        };
502
503        let system_patterns = if let Some(path) = &files.system_patterns {
504            Some(MarkdownParser::parse_file(path)?)
505        } else {
506            None
507        };
508
509        let tech_context = if let Some(path) = &files.tech_context {
510            Some(MarkdownParser::parse_file(path)?)
511        } else {
512            None
513        };
514
515        let progress = if let Some(path) = &files.progress {
516            Some(MarkdownParser::parse_file(path)?)
517        } else {
518            None
519        };
520
521        let content = SubProjectContent {
522            project_brief,
523            product_context,
524            active_context,
525            system_patterns,
526            tech_context,
527            progress,
528        };
529
530        // Parse and aggregate task information
531        let task_summary = self.parse_task_summary(files)?;
532
533        // Derive status from parsed content
534        let derived_status = self.derive_project_status(&content, &task_summary);
535
536        Ok(SubProjectContext {
537            name: name.to_string(),
538            content,
539            task_summary,
540            derived_status,
541            last_updated: Utc::now(),
542        })
543    }
544
545    /// Parse and aggregate task information from task files
546    fn parse_task_summary(
547        &self,
548        files: &crate::parser::navigation::SubProjectFiles,
549    ) -> FsResult<TaskSummary> {
550        let mut all_tasks = Vec::new();
551
552        // Parse individual task files
553        for task_file in &files.task_files {
554            let content = MarkdownParser::parse_file(task_file)?;
555            all_tasks.extend(content.tasks);
556        }
557
558        // Aggregate task information
559        let total_tasks = all_tasks.len();
560        let mut tasks_by_status = HashMap::new();
561
562        for task in &all_tasks {
563            tasks_by_status
564                .entry(task.status.clone())
565                .or_insert_with(Vec::new)
566                .push(task.clone());
567        }
568
569        let completed_count = tasks_by_status
570            .get(&TaskStatus::Completed)
571            .map(|tasks| tasks.len())
572            .unwrap_or(0);
573
574        let completion_percentage = if total_tasks > 0 {
575            (completed_count as f64 / total_tasks as f64) * 100.0
576        } else {
577            0.0
578        };
579
580        let blocked_tasks = tasks_by_status
581            .get(&TaskStatus::Blocked)
582            .cloned()
583            .unwrap_or_default();
584
585        let next_tasks = tasks_by_status
586            .get(&TaskStatus::NotStarted)
587            .cloned()
588            .unwrap_or_default();
589
590        // Sort tasks by update time for recent tasks
591        let mut recent_tasks = all_tasks.clone();
592        recent_tasks.sort_by(|a, b| b.updated.cmp(&a.updated));
593        recent_tasks.truncate(5); // Keep only 5 most recent
594
595        Ok(TaskSummary {
596            total_tasks,
597            tasks_by_status,
598            recent_tasks,
599            completion_percentage,
600            blocked_tasks,
601            next_tasks,
602        })
603    }
604
605    /// Derive project status from parsed content and task summary
606    fn derive_project_status(
607        &self,
608        content: &SubProjectContent,
609        task_summary: &TaskSummary,
610    ) -> DerivedStatus {
611        let health = if task_summary.completion_percentage > 80.0 {
612            ProjectHealth::Healthy
613        } else if task_summary.completion_percentage > 50.0 {
614            ProjectHealth::Warning
615        } else if !task_summary.blocked_tasks.is_empty() {
616            ProjectHealth::Critical
617        } else {
618            ProjectHealth::Unknown
619        };
620
621        let current_phase = content
622            .active_context
623            .as_ref()
624            .and_then(|ctx| ctx.metadata.description.clone())
625            .unwrap_or_else(|| "unknown".to_string());
626
627        let progress_indicators = vec![ProgressIndicator {
628            source: "task_summary".to_string(),
629            metric_type: ProgressMetricType::TaskCompletion,
630            current_value: task_summary.completion_percentage,
631            target_value: Some(100.0),
632            description: format!(
633                "Task completion: {:.1}%",
634                task_summary.completion_percentage
635            ),
636        }];
637
638        let issues = task_summary
639            .blocked_tasks
640            .iter()
641            .map(|task| Issue {
642                severity: IssueSeverity::High,
643                description: format!("Blocked task: {}", task.title),
644                source: "task_analysis".to_string(),
645                resolution: task.details.clone(),
646            })
647            .collect();
648
649        let recommendations = if task_summary.completion_percentage < 50.0 {
650            vec!["Focus on completing pending tasks".to_string()]
651        } else if !task_summary.blocked_tasks.is_empty() {
652            vec!["Address blocked tasks to maintain progress".to_string()]
653        } else {
654            vec!["Continue current development pace".to_string()]
655        };
656
657        DerivedStatus {
658            health,
659            current_phase,
660            progress_indicators,
661            issues,
662            recommendations,
663        }
664    }
665
666    /// Update the current_context.md file with new context information
667    fn update_current_context_file(
668        &self,
669        _structure: &MemoryBankStructure,
670        _context: &CurrentContext,
671    ) -> FsResult<()> {
672        // For now, we'll implement this as a placeholder
673        // In a full implementation, this would write the updated context to the file
674        Ok(())
675    }
676
677    /// Aggregate task summaries across all sub-projects
678    fn aggregate_workspace_tasks(&self, workspace_context: &WorkspaceContext) -> TaskSummary {
679        let mut total_tasks = 0;
680        let mut all_tasks_by_status: HashMap<TaskStatus, Vec<TaskItem>> = HashMap::new();
681        let mut all_recent_tasks = Vec::new();
682        let mut all_blocked_tasks = Vec::new();
683        let mut all_next_tasks = Vec::new();
684
685        for sub_project_context in workspace_context.sub_project_contexts.values() {
686            let summary = &sub_project_context.task_summary;
687            total_tasks += summary.total_tasks;
688
689            // Merge tasks by status
690            for (status, tasks) in &summary.tasks_by_status {
691                all_tasks_by_status
692                    .entry(status.clone())
693                    .or_default()
694                    .extend(tasks.clone());
695            }
696
697            all_recent_tasks.extend(summary.recent_tasks.clone());
698            all_blocked_tasks.extend(summary.blocked_tasks.clone());
699            all_next_tasks.extend(summary.next_tasks.clone());
700        }
701
702        let completed_count = all_tasks_by_status
703            .get(&TaskStatus::Completed)
704            .map(|tasks| tasks.len())
705            .unwrap_or(0);
706
707        let completion_percentage = if total_tasks > 0 {
708            (completed_count as f64 / total_tasks as f64) * 100.0
709        } else {
710            0.0
711        };
712
713        // Sort and limit recent tasks
714        all_recent_tasks.sort_by(|a, b| b.updated.cmp(&a.updated));
715        all_recent_tasks.truncate(10);
716
717        TaskSummary {
718            total_tasks,
719            tasks_by_status: all_tasks_by_status,
720            recent_tasks: all_recent_tasks,
721            completion_percentage,
722            blocked_tasks: all_blocked_tasks,
723            next_tasks: all_next_tasks,
724        }
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    #[test]
732    fn test_context_correlator_creation() {
733        let correlator = ContextCorrelator::new();
734
735        assert!(correlator.get_workspace_context().is_none());
736    }
737
738    #[test]
739    fn test_project_health_ordering() {
740        assert!(ProjectHealth::Critical < ProjectHealth::Warning);
741        assert!(ProjectHealth::Warning < ProjectHealth::Healthy);
742        assert!(ProjectHealth::Unknown == ProjectHealth::Unknown);
743    }
744
745    #[test]
746    fn test_issue_severity_ordering() {
747        assert!(IssueSeverity::Low < IssueSeverity::Medium);
748        assert!(IssueSeverity::Medium < IssueSeverity::High);
749        assert!(IssueSeverity::High < IssueSeverity::Critical);
750    }
751}