telos_agent/knowledge/memory/
profile.rs1use std::io;
2use std::io::ErrorKind;
3use std::path::PathBuf;
4use std::{fs, path::Path};
5
6pub struct ProfileManager {
9 user_profile_path: PathBuf,
10 project_profile_path: PathBuf,
11 active_state_path: PathBuf,
12}
13
14impl ProfileManager {
15 pub fn new(user_dir: PathBuf, project_dir: PathBuf) -> io::Result<Self> {
19 fs::create_dir_all(&user_dir)?;
20 fs::create_dir_all(&project_dir)?;
21 Ok(Self {
22 user_profile_path: user_dir.join("user.md"),
23 project_profile_path: project_dir.join("project.md"),
24 active_state_path: project_dir.join("active.md"),
25 })
26 }
27
28 pub fn user_profile(&self) -> String {
30 self.try_user_profile().unwrap_or_else(|err| {
31 tracing::warn!(
32 path = %self.user_profile_path.display(),
33 error = %err,
34 "failed to read user profile"
35 );
36 String::new()
37 })
38 }
39
40 pub fn try_user_profile(&self) -> io::Result<String> {
42 read_optional_profile(&self.user_profile_path)
43 }
44
45 pub fn project_profile(&self) -> String {
47 self.try_project_profile().unwrap_or_else(|err| {
48 tracing::warn!(
49 path = %self.project_profile_path.display(),
50 error = %err,
51 "failed to read project profile"
52 );
53 String::new()
54 })
55 }
56
57 pub fn try_project_profile(&self) -> io::Result<String> {
59 read_optional_profile(&self.project_profile_path)
60 }
61
62 pub fn active_state(&self) -> String {
64 self.try_active_state().unwrap_or_else(|err| {
65 tracing::warn!(
66 path = %self.active_state_path.display(),
67 error = %err,
68 "failed to read active state"
69 );
70 String::new()
71 })
72 }
73
74 pub fn try_active_state(&self) -> io::Result<String> {
76 read_optional_profile(&self.active_state_path)
77 }
78
79 pub fn set_user_profile(&self, content: &str) -> std::io::Result<()> {
81 fs::write(&self.user_profile_path, content)
82 }
83
84 pub fn set_project_profile(&self, content: &str) -> std::io::Result<()> {
86 fs::write(&self.project_profile_path, content)
87 }
88
89 pub fn update_active_state(&self, summary: &str) -> std::io::Result<()> {
91 let mut content = String::from("## Active Work\n\n");
92 content.push_str(summary);
93 content.push('\n');
94 fs::write(&self.active_state_path, &content)
95 }
96
97 pub fn consolidate_user_profile(&self) -> io::Result<()> {
103 consolidate_profile_file(&self.user_profile_path, "user")
104 }
105
106 pub fn consolidate_project_profile(&self) -> io::Result<()> {
112 consolidate_profile_file(&self.project_profile_path, "project")
113 }
114
115 pub fn render_all(&self) -> String {
117 self.try_render_all().unwrap_or_else(|err| {
118 tracing::warn!(error = %err, "failed to render profiles");
119 String::new()
120 })
121 }
122
123 pub fn try_render_all(&self) -> io::Result<String> {
125 let mut parts = Vec::new();
126
127 let user = self.try_user_profile()?;
128 if !user.is_empty() {
129 parts.push(format!("## User Profile\n\n{user}"));
130 }
131
132 let project = self.try_project_profile()?;
133 if !project.is_empty() {
134 parts.push(format!("## Project Profile\n\n{project}"));
135 }
136
137 let active = self.try_active_state()?;
138 if !active.is_empty() {
139 parts.push(format!("## Active State\n\n{active}"));
140 }
141
142 Ok(parts.join("\n\n"))
143 }
144}
145
146fn read_optional_profile(path: &Path) -> io::Result<String> {
147 match fs::read_to_string(path) {
148 Ok(content) => Ok(content),
149 Err(err) if err.kind() == ErrorKind::NotFound => Ok(String::new()),
150 Err(err) => Err(err),
151 }
152}
153
154fn consolidate_profile_file(path: &Path, profile: &str) -> io::Result<()> {
155 if let Some(parent) = path.parent() {
156 fs::create_dir_all(parent)?;
157 }
158
159 let current = read_optional_profile(path)?;
160 let content = if current.trim().is_empty() {
161 format!("---\ntype: profile\nprofile: {profile}\n---\n")
162 } else {
163 format!("{}\n", current.trim())
164 };
165 fs::write(path, content)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn write_and_read_user_profile() {
174 let dir = tempfile::tempdir().unwrap();
175 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
176 mgr.set_user_profile("Test user profile").unwrap();
177 assert_eq!(mgr.user_profile(), "Test user profile");
178 assert_eq!(mgr.try_user_profile().unwrap(), "Test user profile");
179 }
180
181 #[test]
182 fn update_active_state() {
183 let dir = tempfile::tempdir().unwrap();
184 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
185 mgr.update_active_state("Working on Phase 1").unwrap();
186 assert!(mgr.active_state().contains("Working on Phase 1"));
187 assert!(mgr.active_state().contains("Active Work"));
188 }
189
190 #[test]
191 fn render_all_combines_available_profiles() {
192 let dir = tempfile::tempdir().unwrap();
193 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
194 mgr.set_user_profile("User pref: short names").unwrap();
195 mgr.set_project_profile("Project: Rust 2024").unwrap();
196 mgr.update_active_state("Task: building profiles").unwrap();
197
198 let rendered = mgr.render_all();
199 assert!(rendered.contains("User Profile"));
200 assert!(rendered.contains("User pref: short names"));
201 assert!(rendered.contains("Project Profile"));
202 assert!(rendered.contains("Active State"));
203 }
204
205 #[test]
206 fn empty_profiles_render_empty_strings() {
207 let dir = tempfile::tempdir().unwrap();
208 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
209 assert_eq!(mgr.user_profile(), "");
210 assert_eq!(mgr.project_profile(), "");
211 assert_eq!(mgr.render_all(), "");
212 }
213
214 #[test]
215 fn consolidation_creates_profile_skeletons() {
216 let dir = tempfile::tempdir().unwrap();
217 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
218
219 mgr.consolidate_user_profile().unwrap();
220 mgr.consolidate_project_profile().unwrap();
221
222 assert!(mgr.try_user_profile().unwrap().contains("profile: user"));
223 assert!(mgr.try_project_profile().unwrap().contains("profile: project"));
224 }
225
226 #[test]
227 fn consolidation_preserves_existing_content_and_trims() {
228 let dir = tempfile::tempdir().unwrap();
229 let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
230 mgr.set_user_profile("\n\nExisting profile\n\n").unwrap();
231
232 mgr.consolidate_user_profile().unwrap();
233
234 assert_eq!(mgr.try_user_profile().unwrap(), "Existing profile\n");
235 }
236}