Skip to main content

cc_switch_lib/commands/
workspace.rs

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}