1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::OnceLock;
5use std::time::UNIX_EPOCH;
6
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use crate::config::write_text_file;
11use crate::openclaw_config::get_openclaw_dir;
12
13pub const ALLOWED_FILES: &[&str] = &[
14 "AGENTS.md",
15 "SOUL.md",
16 "USER.md",
17 "IDENTITY.md",
18 "TOOLS.md",
19 "MEMORY.md",
20 "HEARTBEAT.md",
21 "BOOTSTRAP.md",
22 "BOOT.md",
23];
24
25const PREVIEW_CHAR_LIMIT: usize = 120;
26const SNIPPET_CONTEXT_CHARS: usize = 40;
27
28#[doc(hidden)]
29pub type AppHandle = ();
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub struct DailyMemoryFileInfo {
34 pub filename: String,
35 pub date: String,
36 pub size_bytes: u64,
37 pub modified_at: u64,
38 pub preview: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct DailyMemorySearchResult {
44 pub filename: String,
45 pub date: String,
46 pub size_bytes: u64,
47 pub modified_at: u64,
48 pub snippet: String,
49 pub match_count: usize,
50}
51
52struct DailyMemoryEntry {
53 filename: String,
54 content: Option<String>,
55 size_bytes: u64,
56 modified_at: u64,
57}
58
59pub fn read_workspace_file(filename: String) -> Result<Option<String>, String> {
60 validate_workspace_filename(&filename)?;
61 ensure_workspace_root_safe()?;
62
63 let path = workspace_dir().join(filename);
64 if is_symlink(&path)? {
65 return Err(format!(
66 "Refusing to read workspace file symlink: {}",
67 path.display()
68 ));
69 }
70 if !path.exists() {
71 return Ok(None);
72 }
73
74 fs::read_to_string(&path)
75 .map(Some)
76 .map_err(|error| format!("Failed to read workspace file {}: {error}", path.display()))
77}
78
79pub fn workspace_file_exists(filename: String) -> Result<bool, String> {
80 validate_workspace_filename(&filename)?;
81 ensure_workspace_root_safe()?;
82
83 let path = workspace_dir().join(filename);
84 if is_symlink(&path)? {
85 return Ok(false);
86 }
87
88 Ok(path.exists())
89}
90
91pub fn write_workspace_file(filename: String, content: String) -> Result<(), String> {
92 validate_workspace_filename(&filename)?;
93 ensure_workspace_root_safe()?;
94
95 let path = workspace_dir().join(filename);
96 write_text_file(&path, &content)
97 .map_err(|error| format!("Failed to write workspace file {}: {error}", path.display()))
98}
99
100pub fn open_workspace_directory(_handle: AppHandle, subdir: String) -> Result<bool, String> {
101 open_workspace_directory_core(&subdir)
102}
103
104fn open_workspace_directory_core(subdir: &str) -> Result<bool, String> {
105 let target_dir = if subdir == "memory" {
106 ensure_daily_memory_root_safe()?;
107 daily_memory_dir()
108 } else {
109 ensure_workspace_root_safe()?;
110 workspace_dir()
111 };
112
113 fs::create_dir_all(&target_dir).map_err(|error| {
114 format!(
115 "Failed to create workspace directory {}: {error}",
116 target_dir.display()
117 )
118 })?;
119
120 if std::env::var_os("CC_SWITCH_TEST_DISABLE_OPEN").is_some() {
121 return Ok(true);
122 }
123
124 open_directory(&target_dir)
125}
126
127pub fn list_daily_memory_files() -> Result<Vec<DailyMemoryFileInfo>, String> {
128 let mut entries = read_daily_memory_entries()?;
129 entries.sort_by(|left, right| right.filename.cmp(&left.filename));
130
131 Ok(entries
132 .into_iter()
133 .map(|entry| DailyMemoryFileInfo {
134 date: daily_memory_date(&entry.filename),
135 filename: entry.filename,
136 size_bytes: entry.size_bytes,
137 modified_at: entry.modified_at,
138 preview: entry
139 .content
140 .as_deref()
141 .map(preview_text)
142 .unwrap_or_default(),
143 })
144 .collect())
145}
146
147pub fn read_daily_memory_file(filename: String) -> Result<Option<String>, String> {
148 validate_daily_memory_filename(&filename)?;
149 ensure_daily_memory_root_safe()?;
150
151 let path = daily_memory_dir().join(filename);
152 if is_symlink(&path)? {
153 return Err(format!(
154 "Refusing to read daily memory symlink: {}",
155 path.display()
156 ));
157 }
158 if !path.exists() {
159 return Ok(None);
160 }
161
162 fs::read_to_string(&path).map(Some).map_err(|error| {
163 format!(
164 "Failed to read daily memory file {}: {error}",
165 path.display()
166 )
167 })
168}
169
170pub fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> {
171 validate_daily_memory_filename(&filename)?;
172 ensure_daily_memory_root_safe()?;
173
174 let path = daily_memory_dir().join(filename);
175 write_text_file(&path, &content).map_err(|error| {
176 format!(
177 "Failed to write daily memory file {}: {error}",
178 path.display()
179 )
180 })
181}
182
183pub fn search_daily_memory_files(query: String) -> Result<Vec<DailyMemorySearchResult>, String> {
184 let trimmed_query = query.trim();
185 if trimmed_query.is_empty() {
186 return Ok(Vec::new());
187 }
188
189 let query_lower = trimmed_query.to_lowercase();
190 let mut results = Vec::new();
191
192 for entry in read_daily_memory_entries()? {
193 let date = daily_memory_date(&entry.filename);
194 let date_matches = date.to_lowercase().contains(&query_lower);
195 let content_match_count = entry
196 .content
197 .as_deref()
198 .map(|content| count_case_insensitive_matches(content, trimmed_query))
199 .unwrap_or(0);
200
201 if !date_matches && content_match_count == 0 {
202 continue;
203 }
204
205 let snippet = entry
206 .content
207 .as_deref()
208 .and_then(|content| {
209 first_case_insensitive_match_range(content, trimmed_query)
210 .map(|(start, end)| snippet_text(content, start, end))
211 .or_else(|| date_matches.then(|| preview_text(content)))
212 })
213 .unwrap_or_default();
214
215 results.push(DailyMemorySearchResult {
216 filename: entry.filename,
217 date,
218 size_bytes: entry.size_bytes,
219 modified_at: entry.modified_at,
220 snippet,
221 match_count: content_match_count,
222 });
223 }
224
225 results.sort_by(|left, right| right.filename.cmp(&left.filename));
226 Ok(results)
227}
228
229pub fn delete_daily_memory_file(filename: String) -> Result<(), String> {
230 validate_daily_memory_filename(&filename)?;
231 ensure_daily_memory_root_safe()?;
232
233 let path = daily_memory_dir().join(filename);
234 if !path.exists() {
235 return Ok(());
236 }
237
238 fs::remove_file(&path).map_err(|error| {
239 format!(
240 "Failed to delete daily memory file {}: {error}",
241 path.display()
242 )
243 })
244}
245
246fn validate_workspace_filename(filename: &str) -> Result<(), String> {
247 if ALLOWED_FILES.contains(&filename) {
248 Ok(())
249 } else {
250 Err(format!("Invalid workspace filename: {filename}"))
251 }
252}
253
254fn validate_daily_memory_filename(filename: &str) -> Result<(), String> {
255 if daily_memory_filename_regex().is_match(filename) {
256 Ok(())
257 } else {
258 Err(format!("Invalid daily memory filename: {filename}"))
259 }
260}
261
262fn workspace_dir() -> PathBuf {
263 get_openclaw_dir().join("workspace")
264}
265
266fn daily_memory_dir() -> PathBuf {
267 workspace_dir().join("memory")
268}
269
270fn daily_memory_filename_regex() -> &'static Regex {
271 static REGEX: OnceLock<Regex> = OnceLock::new();
272 REGEX.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").expect("valid regex"))
273}
274
275fn read_daily_memory_entries() -> Result<Vec<DailyMemoryEntry>, String> {
276 ensure_daily_memory_root_safe()?;
277
278 let dir = daily_memory_dir();
279 if !dir.exists() {
280 return Ok(Vec::new());
281 }
282
283 let mut entries = Vec::new();
284 let read_dir = fs::read_dir(&dir).map_err(|error| {
285 format!(
286 "Failed to read daily memory directory {}: {error}",
287 dir.display()
288 )
289 })?;
290
291 for entry in read_dir {
292 let entry = entry.map_err(|error| {
293 format!(
294 "Failed to read daily memory directory entry {}: {error}",
295 dir.display()
296 )
297 })?;
298
299 let file_type = match entry.file_type() {
300 Ok(file_type) => file_type,
301 Err(_) => continue,
302 };
303 if !file_type.is_file() {
304 continue;
305 }
306
307 let filename = entry.file_name().to_string_lossy().to_string();
308 if validate_daily_memory_filename(&filename).is_err() {
309 continue;
310 }
311
312 let path = entry.path();
313 let metadata = match entry.metadata() {
314 Ok(metadata) => metadata,
315 Err(_) => continue,
316 };
317 let content = fs::read_to_string(&path).ok();
318
319 entries.push(DailyMemoryEntry {
320 filename,
321 content,
322 size_bytes: metadata.len(),
323 modified_at: modified_at_seconds(&metadata),
324 });
325 }
326
327 Ok(entries)
328}
329
330fn daily_memory_date(filename: &str) -> String {
331 filename.trim_end_matches(".md").to_string()
332}
333
334fn modified_at_seconds(metadata: &fs::Metadata) -> u64 {
335 metadata
336 .modified()
337 .ok()
338 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
339 .map(|duration| duration.as_secs())
340 .unwrap_or_default()
341}
342
343fn preview_text(content: &str) -> String {
344 truncate_chars(content, PREVIEW_CHAR_LIMIT)
345}
346
347fn snippet_text(content: &str, match_start: usize, match_end: usize) -> String {
348 let total_chars = content.chars().count();
349 let match_start_chars = content[..match_start].chars().count();
350 let match_end_chars = content[..match_end].chars().count();
351
352 let snippet_start_chars = match_start_chars.saturating_sub(SNIPPET_CONTEXT_CHARS);
353 let snippet_end_chars = (match_end_chars + SNIPPET_CONTEXT_CHARS).min(total_chars);
354
355 let start_byte = byte_index_for_char(content, snippet_start_chars);
356 let end_byte = byte_index_for_char(content, snippet_end_chars);
357 let mut snippet = String::new();
358
359 if snippet_start_chars > 0 {
360 snippet.push_str("...");
361 }
362 snippet.push_str(&content[start_byte..end_byte]);
363 if snippet_end_chars < total_chars {
364 snippet.push_str("...");
365 }
366
367 snippet
368}
369
370fn truncate_chars(content: &str, char_limit: usize) -> String {
371 let total_chars = content.chars().count();
372 if total_chars <= char_limit {
373 return content.to_string();
374 }
375
376 let end_byte = byte_index_for_char(content, char_limit);
377 format!("{}...", &content[..end_byte])
378}
379
380fn byte_index_for_char(content: &str, char_index: usize) -> usize {
381 if char_index == 0 {
382 return 0;
383 }
384
385 content
386 .char_indices()
387 .nth(char_index)
388 .map(|(byte_index, _)| byte_index)
389 .unwrap_or(content.len())
390}
391
392fn count_case_insensitive_matches(content: &str, query: &str) -> usize {
393 let query_lower = query.to_lowercase();
394 if query_lower.is_empty() {
395 return 0;
396 }
397
398 let (content_lower, _, _) = lowercase_with_byte_map(content);
399 content_lower.match_indices(&query_lower).count()
400}
401
402fn first_case_insensitive_match_range(content: &str, query: &str) -> Option<(usize, usize)> {
403 let query_lower = query.to_lowercase();
404 if query_lower.is_empty() {
405 return None;
406 }
407
408 let (content_lower, lower_starts, lower_ends) = lowercase_with_byte_map(content);
409 let lower_start = content_lower.find(&query_lower)?;
410 let lower_end = lower_start + query_lower.len();
411 let original_start = lower_starts
412 .get(lower_start)
413 .copied()
414 .unwrap_or(content.len());
415 let original_end = lower_end
416 .checked_sub(1)
417 .and_then(|idx| lower_ends.get(idx).copied())
418 .unwrap_or(content.len());
419
420 Some((original_start, original_end))
421}
422
423fn lowercase_with_byte_map(content: &str) -> (String, Vec<usize>, Vec<usize>) {
424 let mut lowered = String::new();
425 let mut lower_starts = Vec::new();
426 let mut lower_ends = Vec::new();
427 let mut chars = content.char_indices().peekable();
428
429 while let Some((start, ch)) = chars.next() {
430 let end = chars
431 .peek()
432 .map(|(next_start, _)| *next_start)
433 .unwrap_or(content.len());
434 let lower = ch.to_lowercase().to_string();
435
436 for _ in 0..lower.len() {
437 lower_starts.push(start);
438 lower_ends.push(end);
439 }
440
441 lowered.push_str(&lower);
442 }
443
444 (lowered, lower_starts, lower_ends)
445}
446
447fn open_directory(path: &Path) -> Result<bool, String> {
448 #[cfg(target_os = "macos")]
449 let mut command = {
450 let mut command = Command::new("open");
451 command.arg(path);
452 command
453 };
454
455 #[cfg(target_os = "linux")]
456 let mut command = {
457 let mut command = Command::new("xdg-open");
458 command.arg(path);
459 command
460 };
461
462 #[cfg(target_os = "windows")]
463 let mut command = {
464 let mut command = Command::new("explorer");
465 command.arg(path);
466 command
467 };
468
469 let status = command
470 .status()
471 .map_err(|error| format!("Failed to open directory {}: {error}", path.display()))?;
472
473 if status.success() {
474 Ok(true)
475 } else {
476 Err(format!(
477 "Failed to open directory {}: opener exited with status {status}",
478 path.display()
479 ))
480 }
481}
482
483fn is_symlink(path: &Path) -> Result<bool, String> {
484 match fs::symlink_metadata(path) {
485 Ok(metadata) => Ok(metadata.file_type().is_symlink()),
486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
487 Err(error) => Err(format!(
488 "Failed to inspect file metadata {}: {error}",
489 path.display()
490 )),
491 }
492}
493
494fn ensure_workspace_root_safe() -> Result<(), String> {
495 ensure_path_not_symlink(&workspace_dir(), "workspace directory")
496}
497
498fn ensure_daily_memory_root_safe() -> Result<(), String> {
499 ensure_workspace_root_safe()?;
500 ensure_path_not_symlink(&daily_memory_dir(), "daily memory directory")
501}
502
503fn ensure_path_not_symlink(path: &Path, label: &str) -> Result<(), String> {
504 if is_symlink(path)? {
505 Err(format!(
506 "Refusing to use symlinked {label}: {}",
507 path.display()
508 ))
509 } else {
510 Ok(())
511 }
512}