1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use lcsa_core::{PrimitiveEvent, PrimitiveEventKind};
5use notify::event::{AccessKind, CreateKind, MetadataKind, ModifyKind, RemoveKind, RenameMode};
6use notify::{Event, EventKind};
7use time::OffsetDateTime;
8use walkdir::WalkDir;
9
10pub fn snapshot_events(
11 path: &Path,
12 include_hidden: bool,
13 skip_path: Option<&Path>,
14) -> Result<Vec<PrimitiveEvent>> {
15 let mut events = Vec::new();
16
17 for entry in WalkDir::new(path)
18 .into_iter()
19 .filter_entry(|entry| include_hidden || !is_hidden_within(entry.path(), Some(path)))
20 {
21 let entry = entry.with_context(|| format!("failed to traverse {}", path.display()))?;
22 let item_path = entry.path();
23
24 if item_path == path {
25 continue;
26 }
27
28 if should_skip(item_path, skip_path) {
29 continue;
30 }
31
32 events.push(PrimitiveEvent::new(
33 "filesystem",
34 PrimitiveEventKind::Created,
35 vec![item_path.to_path_buf()],
36 Some(entry.file_type().is_dir()),
37 OffsetDateTime::now_utc(),
38 ));
39 }
40
41 Ok(events)
42}
43
44pub fn primitive_from_notify_event(
45 event: Event,
46 watch_root: &Path,
47 include_hidden: bool,
48 skip_path: Option<&Path>,
49) -> Option<PrimitiveEvent> {
50 if !include_hidden
51 && event
52 .paths
53 .iter()
54 .any(|path| is_hidden_within(path, Some(watch_root)))
55 {
56 return None;
57 }
58
59 if event.paths.iter().all(|path| should_skip(path, skip_path)) {
60 return None;
61 }
62
63 let kind = map_event_kind(&event.kind);
64 let is_directory = event
65 .paths
66 .first()
67 .and_then(|path| infer_directory_flag(path.as_path()));
68
69 Some(PrimitiveEvent::new(
70 "filesystem",
71 kind,
72 event.paths,
73 is_directory,
74 OffsetDateTime::now_utc(),
75 ))
76}
77
78pub fn should_skip(path: &Path, skip_path: Option<&Path>) -> bool {
79 match skip_path {
80 Some(skip_path) => same_path(path, skip_path),
81 None => false,
82 }
83}
84
85fn map_event_kind(kind: &EventKind) -> PrimitiveEventKind {
86 match kind {
87 EventKind::Create(CreateKind::Any)
88 | EventKind::Create(CreateKind::File)
89 | EventKind::Create(CreateKind::Folder)
90 | EventKind::Create(CreateKind::Other) => PrimitiveEventKind::Created,
91 EventKind::Modify(ModifyKind::Data(_))
92 | EventKind::Modify(ModifyKind::Any)
93 | EventKind::Modify(ModifyKind::Other)
94 | EventKind::Modify(ModifyKind::Name(RenameMode::Any))
95 | EventKind::Modify(ModifyKind::Name(RenameMode::From))
96 | EventKind::Modify(ModifyKind::Name(RenameMode::To)) => PrimitiveEventKind::Modified,
97 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => PrimitiveEventKind::Renamed,
98 EventKind::Modify(ModifyKind::Metadata(
99 MetadataKind::Any
100 | MetadataKind::WriteTime
101 | MetadataKind::Permissions
102 | MetadataKind::Ownership
103 | MetadataKind::Extended
104 | MetadataKind::Other,
105 )) => PrimitiveEventKind::MetadataChanged,
106 EventKind::Remove(RemoveKind::Any)
107 | EventKind::Remove(RemoveKind::File)
108 | EventKind::Remove(RemoveKind::Folder)
109 | EventKind::Remove(RemoveKind::Other) => PrimitiveEventKind::Deleted,
110 EventKind::Access(AccessKind::Any)
111 | EventKind::Access(AccessKind::Open(_))
112 | EventKind::Access(AccessKind::Close(_))
113 | EventKind::Access(AccessKind::Read)
114 | EventKind::Access(AccessKind::Other) => PrimitiveEventKind::Accessed,
115 _ => PrimitiveEventKind::Unknown,
116 }
117}
118
119fn infer_directory_flag(path: &Path) -> Option<bool> {
120 std::fs::metadata(path)
121 .ok()
122 .map(|metadata| metadata.is_dir())
123}
124
125fn is_hidden_within(path: &Path, root: Option<&Path>) -> bool {
126 let relative = root
127 .and_then(|root| path.strip_prefix(root).ok())
128 .unwrap_or(path);
129
130 relative.components().any(|component| {
131 component
132 .as_os_str()
133 .to_str()
134 .map(|segment| segment.starts_with('.') && segment.len() > 1)
135 .unwrap_or(false)
136 })
137}
138
139fn same_path(left: &Path, right: &Path) -> bool {
140 canonicalize_lossy(left)
141 .map(|candidate| candidate == right)
142 .unwrap_or_else(|| left == right)
143}
144
145fn canonicalize_lossy(path: &Path) -> Option<PathBuf> {
146 std::fs::canonicalize(path).ok().or_else(|| {
147 if path.is_absolute() {
148 Some(path.to_path_buf())
149 } else {
150 std::env::current_dir().ok().map(|cwd| cwd.join(path))
151 }
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use std::fs;
158
159 use lcsa_core::normalize_event;
160 use tempfile::tempdir;
161
162 use super::*;
163
164 #[test]
165 fn snapshot_emits_code_signal_for_rust_file() {
166 let dir = tempdir().expect("tempdir");
167 fs::create_dir_all(dir.path().join("src")).expect("create src");
168 fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").expect("write rust file");
169
170 let events = snapshot_events(dir.path(), false, None).expect("snapshot events");
171 let signals = events.iter().map(normalize_event).collect::<Vec<_>>();
172
173 assert!(
174 signals
175 .iter()
176 .any(|signal| signal.event_name() == "code.created")
177 );
178 }
179
180 #[test]
181 fn snapshot_skips_hidden_files_by_default() {
182 let dir = tempdir().expect("tempdir");
183 fs::write(dir.path().join(".env"), "SECRET=1\n").expect("write hidden file");
184 fs::write(dir.path().join("README.md"), "hello\n").expect("write visible file");
185
186 let events = snapshot_events(dir.path(), false, None).expect("snapshot events");
187 let paths = events
188 .iter()
189 .flat_map(|event| event.paths.iter())
190 .map(|path| path.to_string_lossy().to_string())
191 .collect::<Vec<_>>();
192
193 assert!(paths.iter().all(|path| !path.ends_with(".env")));
194 assert!(paths.iter().any(|path| path.ends_with("README.md")));
195 }
196}