1use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct ProjectMemory {
13 pub name: Option<String>,
15 pub description: Option<String>,
17 pub tech_stack: Vec<String>,
19 pub design_patterns: Vec<String>,
21 pub constraints: Vec<String>,
23 pub agent_instructions: HashMap<String, String>,
25 pub ignore_patterns: Vec<String>,
27}
28
29impl ProjectMemory {
30 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 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 if trimmed.is_empty() {
51 continue;
52 }
53
54 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 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 pub fn get_name(&self) -> &str {
96 self.name.as_deref().unwrap_or("Unnamed Project")
97 }
98
99 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}