1use chrono::{Duration, Local};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SessionType {
18 Main,
21 Group,
24 Isolated,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkspaceContextConfig {
32 #[serde(default = "default_true")]
34 pub enabled: bool,
35
36 #[serde(default = "default_true")]
38 pub inject_soul: bool,
39
40 #[serde(default = "default_true")]
42 pub inject_agents: bool,
43
44 #[serde(default = "default_true")]
46 pub inject_tools: bool,
47
48 #[serde(default = "default_true")]
50 pub inject_identity: bool,
51
52 #[serde(default = "default_true")]
54 pub inject_user: bool,
55
56 #[serde(default = "default_true")]
58 pub inject_memory: bool,
59
60 #[serde(default = "default_true")]
62 pub inject_heartbeat: bool,
63
64 #[serde(default = "default_true")]
66 pub inject_daily: bool,
67
68 #[serde(default = "default_daily_lookback")]
70 pub daily_lookback_days: u32,
71}
72
73fn default_true() -> bool {
74 true
75}
76
77fn default_daily_lookback() -> u32 {
78 1 }
80
81impl Default for WorkspaceContextConfig {
82 fn default() -> Self {
83 Self {
84 enabled: true,
85 inject_soul: true,
86 inject_agents: true,
87 inject_tools: true,
88 inject_identity: true,
89 inject_user: true,
90 inject_memory: true,
91 inject_heartbeat: true,
92 inject_daily: true,
93 daily_lookback_days: default_daily_lookback(),
94 }
95 }
96}
97
98struct WorkspaceFile {
100 path: &'static str,
102 header: &'static str,
104 main_only: bool,
106 config_field: ConfigField,
108}
109
110enum ConfigField {
112 Soul,
113 Agents,
114 Tools,
115 Identity,
116 User,
117 Memory,
118 Heartbeat,
119}
120
121const WORKSPACE_FILES: &[WorkspaceFile] = &[
122 WorkspaceFile {
123 path: "SOUL.md",
124 header: "SOUL.md",
125 main_only: false,
126 config_field: ConfigField::Soul,
127 },
128 WorkspaceFile {
129 path: "AGENTS.md",
130 header: "AGENTS.md",
131 main_only: false,
132 config_field: ConfigField::Agents,
133 },
134 WorkspaceFile {
135 path: "TOOLS.md",
136 header: "TOOLS.md",
137 main_only: false,
138 config_field: ConfigField::Tools,
139 },
140 WorkspaceFile {
141 path: "IDENTITY.md",
142 header: "IDENTITY.md",
143 main_only: false,
144 config_field: ConfigField::Identity,
145 },
146 WorkspaceFile {
147 path: "USER.md",
148 header: "USER.md",
149 main_only: true, config_field: ConfigField::User,
151 },
152 WorkspaceFile {
153 path: "MEMORY.md",
154 header: "MEMORY.md",
155 main_only: true, config_field: ConfigField::Memory,
157 },
158 WorkspaceFile {
159 path: "HEARTBEAT.md",
160 header: "HEARTBEAT.md",
161 main_only: false,
162 config_field: ConfigField::Heartbeat,
163 },
164];
165
166pub struct WorkspaceContext {
171 workspace_dir: PathBuf,
172 config: WorkspaceContextConfig,
173}
174
175impl WorkspaceContext {
176 pub fn new(workspace_dir: PathBuf) -> Self {
178 Self {
179 workspace_dir,
180 config: WorkspaceContextConfig::default(),
181 }
182 }
183
184 pub fn with_config(workspace_dir: PathBuf, config: WorkspaceContextConfig) -> Self {
186 Self {
187 workspace_dir,
188 config,
189 }
190 }
191
192 fn should_include(&self, file: &WorkspaceFile, session_type: SessionType) -> bool {
194 if file.main_only && session_type != SessionType::Main {
196 return false;
197 }
198
199 match file.config_field {
201 ConfigField::Soul => self.config.inject_soul,
202 ConfigField::Agents => self.config.inject_agents,
203 ConfigField::Tools => self.config.inject_tools,
204 ConfigField::Identity => self.config.inject_identity,
205 ConfigField::User => self.config.inject_user,
206 ConfigField::Memory => self.config.inject_memory,
207 ConfigField::Heartbeat => self.config.inject_heartbeat,
208 }
209 }
210
211 pub fn build_context(&self, session_type: SessionType) -> String {
216 if !self.config.enabled {
217 return String::new();
218 }
219
220 let mut sections = Vec::new();
221
222 for file in WORKSPACE_FILES {
224 if !self.should_include(file, session_type) {
225 continue;
226 }
227
228 let path = self.workspace_dir.join(file.path);
229
230 if let Ok(content) = fs::read_to_string(&path) {
231 let content = content.trim();
232 if !content.is_empty() {
233 sections.push(format!("## {}\n{}", file.header, content));
234 }
235 }
236 }
237
238 if session_type == SessionType::Main && self.config.inject_daily {
240 if let Some(daily) = self.load_daily_memory() {
241 sections.push(daily);
242 }
243 }
244
245 if sections.is_empty() {
246 String::new()
247 } else {
248 format!(
249 "# Project Context\n\
250 The following project context files have been loaded:\n\n{}",
251 sections.join("\n\n---\n\n")
252 )
253 }
254 }
255
256 fn load_daily_memory(&self) -> Option<String> {
258 let today = Local::now().date_naive();
259 let mut daily_sections = Vec::new();
260
261 for i in 0..=self.config.daily_lookback_days {
262 let date = today - Duration::days(i as i64);
263 let filename = format!("memory/{}.md", date.format("%Y-%m-%d"));
264 let path = self.workspace_dir.join(&filename);
265
266 if let Ok(content) = fs::read_to_string(&path) {
267 let content = content.trim();
268 if !content.is_empty() {
269 daily_sections.push(format!("### {}\n{}", filename, content));
270 }
271 }
272 }
273
274 if daily_sections.is_empty() {
275 None
276 } else {
277 Some(format!(
278 "## Recent Daily Notes\n{}",
279 daily_sections.join("\n\n")
280 ))
281 }
282 }
283
284 pub fn audit_files(&self, session_type: SessionType) -> Vec<(String, bool)> {
288 WORKSPACE_FILES
289 .iter()
290 .filter(|f| self.should_include(f, session_type))
291 .map(|f| {
292 let exists = self.workspace_dir.join(f.path).exists();
293 (f.path.to_string(), exists)
294 })
295 .collect()
296 }
297
298 pub fn workspace_dir(&self) -> &Path {
300 &self.workspace_dir
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::fs;
308 use tempfile::TempDir;
309
310 fn setup_workspace() -> TempDir {
311 let dir = TempDir::new().unwrap();
312 fs::write(dir.path().join("SOUL.md"), "Be helpful and concise.").unwrap();
313 fs::write(dir.path().join("MEMORY.md"), "User prefers Rust.").unwrap();
314 fs::write(dir.path().join("AGENTS.md"), "Follow instructions.").unwrap();
315 fs::create_dir(dir.path().join("memory")).unwrap();
316
317 let today = Local::now().format("%Y-%m-%d").to_string();
318 fs::write(
319 dir.path().join(format!("memory/{}.md", today)),
320 "# Today\nWorked on RustyClaw.",
321 )
322 .unwrap();
323 dir
324 }
325
326 #[test]
327 fn test_main_session_includes_memory() {
328 let workspace = setup_workspace();
329 let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
330
331 let prompt = ctx.build_context(SessionType::Main);
332 assert!(prompt.contains("SOUL.md"));
333 assert!(prompt.contains("MEMORY.md"));
334 assert!(prompt.contains("User prefers Rust"));
335 }
336
337 #[test]
338 fn test_group_session_excludes_memory() {
339 let workspace = setup_workspace();
340 let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
341
342 let prompt = ctx.build_context(SessionType::Group);
343 assert!(prompt.contains("SOUL.md"));
344 assert!(!prompt.contains("MEMORY.md"));
345 assert!(!prompt.contains("User prefers Rust"));
346 }
347
348 #[test]
349 fn test_isolated_session_excludes_memory() {
350 let workspace = setup_workspace();
351 let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
352
353 let prompt = ctx.build_context(SessionType::Isolated);
354 assert!(prompt.contains("SOUL.md"));
355 assert!(!prompt.contains("MEMORY.md"));
356 }
357
358 #[test]
359 fn test_daily_memory_loading() {
360 let workspace = setup_workspace();
361 let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
362
363 let prompt = ctx.build_context(SessionType::Main);
364 assert!(prompt.contains("Recent Daily Notes"));
365 assert!(prompt.contains("Worked on RustyClaw"));
366 }
367
368 #[test]
369 fn test_disabled_context() {
370 let workspace = setup_workspace();
371 let config = WorkspaceContextConfig {
372 enabled: false,
373 ..Default::default()
374 };
375 let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
376
377 let prompt = ctx.build_context(SessionType::Main);
378 assert!(prompt.is_empty());
379 }
380
381 #[test]
382 fn test_selective_injection() {
383 let workspace = setup_workspace();
384 let config = WorkspaceContextConfig {
385 enabled: true,
386 inject_soul: true,
387 inject_memory: false, ..Default::default()
389 };
390 let ctx = WorkspaceContext::with_config(workspace.path().to_path_buf(), config);
391
392 let prompt = ctx.build_context(SessionType::Main);
393 assert!(prompt.contains("SOUL.md"));
394 assert!(!prompt.contains("MEMORY.md"));
395 }
396
397 #[test]
398 fn test_audit_files() {
399 let workspace = setup_workspace();
400 let ctx = WorkspaceContext::new(workspace.path().to_path_buf());
401
402 let audit = ctx.audit_files(SessionType::Main);
403
404 assert!(audit.iter().any(|(p, e)| p == "SOUL.md" && *e));
406 assert!(audit.iter().any(|(p, e)| p == "MEMORY.md" && *e));
408 assert!(audit.iter().any(|(p, e)| p == "TOOLS.md" && !*e));
410 }
411}