Skip to main content

retro_core/
observer.rs

1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4/// A session file that has been modified since last check.
5#[derive(Debug, Clone)]
6pub struct ModifiedSession {
7    pub path: PathBuf,
8    pub mtime: SystemTime,
9}
10
11/// Encode a project path to a Claude Code session directory name.
12/// Claude replaces `/` with `-`, e.g. `/Users/iman/repos/app` → `-Users-iman-repos-app`.
13fn encode_project_path(project_path: &str) -> String {
14    project_path.replace('/', "-")
15}
16
17/// Scan Claude Code session directories for modified session files.
18///
19/// If `project_paths` is non-empty, only scans session directories matching
20/// those project paths (prevents accessing macOS-protected directories like
21/// ~/Downloads or ~/Documents). If empty, scans all session directories.
22///
23/// If `since` is None, returns all session files.
24pub fn find_modified_sessions(
25    claude_dir: &Path,
26    since: Option<SystemTime>,
27    project_paths: &[String],
28) -> Vec<ModifiedSession> {
29    let projects_dir = claude_dir.join("projects");
30    if !projects_dir.exists() {
31        return Vec::new();
32    }
33
34    let patterns: Vec<String> = if project_paths.is_empty() {
35        // Fallback: scan everything (backward compat for single-project mode)
36        vec![format!("{}/**/*.jsonl", projects_dir.display())]
37    } else {
38        // Only scan directories matching registered project paths
39        project_paths
40            .iter()
41            .map(|path| {
42                let encoded = encode_project_path(path);
43                format!("{}/{}*/**/*.jsonl", projects_dir.display(), encoded)
44            })
45            .collect()
46    };
47
48    let mut results = Vec::new();
49    for pattern in &patterns {
50        for entry in glob::glob(pattern).unwrap_or_else(|_| glob::glob("").unwrap()) {
51            if let Ok(path) = entry {
52                if let Ok(metadata) = std::fs::metadata(&path) {
53                    if let Ok(mtime) = metadata.modified() {
54                        let dominated = match since {
55                            Some(since_time) => mtime > since_time,
56                            None => true,
57                        };
58                        if dominated {
59                            results.push(ModifiedSession { path, mtime });
60                        }
61                    }
62                }
63            }
64        }
65    }
66    results
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use std::fs;
73    use tempfile::TempDir;
74
75    #[test]
76    fn test_encode_project_path() {
77        assert_eq!(
78            encode_project_path("/Users/iman/repos/app"),
79            "-Users-iman-repos-app"
80        );
81        assert_eq!(encode_project_path("/tmp/test"), "-tmp-test");
82    }
83
84    #[test]
85    fn test_find_modified_sessions_detects_new_files() {
86        let dir = TempDir::new().unwrap();
87        let sessions_dir = dir.path().join("projects").join("-tmp-my-project");
88        fs::create_dir_all(&sessions_dir).unwrap();
89
90        let session_file = sessions_dir.join("session1.jsonl");
91        fs::write(&session_file, "{}\n").unwrap();
92
93        // With matching project path
94        let modified = find_modified_sessions(
95            dir.path(),
96            None,
97            &["/tmp/my-project".to_string()],
98        );
99        assert_eq!(modified.len(), 1);
100        assert!(modified[0].path.ends_with("session1.jsonl"));
101    }
102
103    #[test]
104    fn test_find_modified_sessions_skips_unregistered() {
105        let dir = TempDir::new().unwrap();
106
107        // Create sessions for two projects
108        let registered_dir = dir.path().join("projects").join("-tmp-registered");
109        fs::create_dir_all(&registered_dir).unwrap();
110        fs::write(registered_dir.join("session1.jsonl"), "{}\n").unwrap();
111
112        let unregistered_dir = dir.path().join("projects").join("-Users-iman-Downloads-stuff");
113        fs::create_dir_all(&unregistered_dir).unwrap();
114        fs::write(unregistered_dir.join("session2.jsonl"), "{}\n").unwrap();
115
116        // Only scan registered project
117        let modified = find_modified_sessions(
118            dir.path(),
119            None,
120            &["/tmp/registered".to_string()],
121        );
122        assert_eq!(modified.len(), 1);
123        assert!(modified[0].path.to_str().unwrap().contains("registered"));
124    }
125
126    #[test]
127    fn test_find_modified_sessions_empty_paths_scans_all() {
128        let dir = TempDir::new().unwrap();
129        let sessions_dir = dir.path().join("projects").join("any-project");
130        fs::create_dir_all(&sessions_dir).unwrap();
131        fs::write(sessions_dir.join("session1.jsonl"), "{}\n").unwrap();
132
133        let modified = find_modified_sessions(dir.path(), None, &[]);
134        assert_eq!(modified.len(), 1);
135    }
136
137    #[test]
138    fn test_find_modified_sessions_skips_unchanged() {
139        let dir = TempDir::new().unwrap();
140        let sessions_dir = dir.path().join("projects").join("-tmp-project");
141        fs::create_dir_all(&sessions_dir).unwrap();
142
143        let session_file = sessions_dir.join("session1.jsonl");
144        fs::write(&session_file, "{}\n").unwrap();
145
146        let mtime = fs::metadata(&session_file).unwrap().modified().unwrap();
147
148        let modified = find_modified_sessions(
149            dir.path(),
150            Some(mtime),
151            &["/tmp/project".to_string()],
152        );
153        assert_eq!(modified.len(), 0);
154    }
155}