perspt_core/
memory.rs

1//! PERSPT.md Parser - Project Memory
2//!
3//! Parses hierarchical project memory files inspired by CLAUDE.md.
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Project memory configuration from PERSPT.md
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct ProjectMemory {
13    /// Project name
14    pub name: Option<String>,
15    /// Project description
16    pub description: Option<String>,
17    /// Tech stack information
18    pub tech_stack: Vec<String>,
19    /// Design patterns to follow
20    pub design_patterns: Vec<String>,
21    /// Architectural constraints
22    pub constraints: Vec<String>,
23    /// Custom agent instructions
24    pub agent_instructions: HashMap<String, String>,
25    /// File patterns to ignore
26    pub ignore_patterns: Vec<String>,
27}
28
29impl ProjectMemory {
30    /// Load project memory from a PERSPT.md file
31    pub fn load(path: &Path) -> Result<Self> {
32        if !path.exists() {
33            log::info!("No PERSPT.md found at {:?}, using defaults", path);
34            return Ok(Self::default());
35        }
36
37        let content = std::fs::read_to_string(path)?;
38        Self::parse(&content)
39    }
40
41    /// Parse PERSPT.md content
42    pub fn parse(content: &str) -> Result<Self> {
43        let mut memory = Self::default();
44        let mut current_section: Option<&str> = None;
45
46        for line in content.lines() {
47            let trimmed = line.trim();
48
49            // Skip empty lines
50            if trimmed.is_empty() {
51                continue;
52            }
53
54            // Detect section headers
55            if let Some(name) = trimmed.strip_prefix("# ") {
56                memory.name = Some(name.to_string());
57                continue;
58            }
59
60            if trimmed.starts_with("## ") {
61                current_section = Some(match trimmed.to_lowercase().as_str() {
62                    s if s.contains("description") => "description",
63                    s if s.contains("tech") || s.contains("stack") => "tech_stack",
64                    s if s.contains("pattern") || s.contains("design") => "design_patterns",
65                    s if s.contains("constraint") || s.contains("rule") => "constraints",
66                    s if s.contains("ignore") => "ignore",
67                    s if s.contains("agent") || s.contains("instruction") => "agent_instructions",
68                    _ => "unknown",
69                });
70                continue;
71            }
72
73            // Parse content based on current section
74            if let Some(section) = current_section {
75                if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
76                    let item = trimmed[2..].to_string();
77                    match section {
78                        "tech_stack" => memory.tech_stack.push(item),
79                        "design_patterns" => memory.design_patterns.push(item),
80                        "constraints" => memory.constraints.push(item),
81                        "ignore" => memory.ignore_patterns.push(item),
82                        _ => {}
83                    }
84                } else if section == "description" && memory.description.is_none() {
85                    memory.description = Some(trimmed.to_string());
86                }
87            }
88        }
89
90        log::info!("Loaded project memory: {:?}", memory.name);
91        Ok(memory)
92    }
93
94    /// Get the project name or a default
95    pub fn get_name(&self) -> &str {
96        self.name.as_deref().unwrap_or("Unnamed Project")
97    }
98
99    /// Build a context string for LLM prompts
100    pub fn to_context_string(&self) -> String {
101        let mut parts = Vec::new();
102
103        if let Some(ref desc) = self.description {
104            parts.push(format!("Project Description: {}", desc));
105        }
106
107        if !self.tech_stack.is_empty() {
108            parts.push(format!("Tech Stack: {}", self.tech_stack.join(", ")));
109        }
110
111        if !self.design_patterns.is_empty() {
112            parts.push(format!(
113                "Design Patterns: {}",
114                self.design_patterns.join(", ")
115            ));
116        }
117
118        if !self.constraints.is_empty() {
119            parts.push(format!("Constraints: {}", self.constraints.join("; ")));
120        }
121
122        parts.join("\n")
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_parse_empty() {
132        let memory = ProjectMemory::parse("").unwrap();
133        assert!(memory.name.is_none());
134    }
135
136    #[test]
137    fn test_parse_basic() {
138        let content = r#"# My Project
139
140## Description
141A sample project for testing.
142
143## Tech Stack
144- Rust
145- Tokio
146- PostgreSQL
147
148## Constraints
149- No unsafe code
150- Must be async
151"#;
152        let memory = ProjectMemory::parse(content).unwrap();
153        assert_eq!(memory.name, Some("My Project".to_string()));
154        assert_eq!(memory.tech_stack.len(), 3);
155        assert_eq!(memory.constraints.len(), 2);
156    }
157}