Skip to main content

lcsa_daemon/
lib.rs

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}