Skip to main content

tuitbot_core/source/
local_fs.rs

1//! Local filesystem content source provider.
2//!
3//! Wraps existing directory walking and file reading logic behind the
4//! `ContentSourceProvider` trait. The Watchtower still uses `notify` for
5//! real-time watching — this provider handles scan and read operations.
6
7use std::path::{Path, PathBuf};
8
9use async_trait::async_trait;
10use sha2::{Digest, Sha256};
11
12use super::{ContentSourceProvider, SourceError, SourceFile};
13use crate::automation::watchtower::matches_patterns;
14
15/// Local filesystem content source.
16pub struct LocalFsProvider {
17    base_path: PathBuf,
18}
19
20impl LocalFsProvider {
21    pub fn new(base_path: PathBuf) -> Self {
22        Self { base_path }
23    }
24}
25
26#[async_trait]
27impl ContentSourceProvider for LocalFsProvider {
28    fn source_type(&self) -> &str {
29        "local_fs"
30    }
31
32    async fn scan_for_changes(
33        &self,
34        _since_cursor: Option<&str>,
35        patterns: &[String],
36    ) -> Result<Vec<SourceFile>, SourceError> {
37        let base = self.base_path.clone();
38        let patterns = patterns.to_vec();
39
40        // Walk directory synchronously (matches existing WatchtowerLoop behaviour).
41        let files = tokio::task::spawn_blocking(move || walk_and_hash(&base, &base, &patterns))
42            .await
43            .map_err(|e| SourceError::Io(std::io::Error::other(e)))??;
44
45        Ok(files)
46    }
47
48    async fn read_content(&self, file_id: &str) -> Result<String, SourceError> {
49        let full_path = self.base_path.join(file_id);
50        tokio::fs::read_to_string(&full_path)
51            .await
52            .map_err(SourceError::Io)
53    }
54}
55
56/// Recursively walk a directory, returning `SourceFile` entries for matching files.
57fn walk_and_hash(
58    base: &Path,
59    current: &Path,
60    patterns: &[String],
61) -> Result<Vec<SourceFile>, SourceError> {
62    let mut out = Vec::new();
63    let entries = std::fs::read_dir(current)?;
64
65    for entry in entries {
66        let entry = entry?;
67        let ft = entry.file_type()?;
68        let path = entry.path();
69
70        if ft.is_dir() {
71            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
72                if name.starts_with('.') {
73                    continue;
74                }
75            }
76            out.extend(walk_and_hash(base, &path, patterns)?);
77        } else if ft.is_file() && matches_patterns(&path, patterns) {
78            if let Ok(rel) = path.strip_prefix(base) {
79                let content = std::fs::read(&path)?;
80                let hash = format!("{:x}", Sha256::digest(&content));
81                let modified = std::fs::metadata(&path)?
82                    .modified()
83                    .ok()
84                    .map(|t| {
85                        let dt: chrono::DateTime<chrono::Utc> = t.into();
86                        dt.to_rfc3339()
87                    })
88                    .unwrap_or_default();
89
90                out.push(SourceFile {
91                    provider_id: rel.to_string_lossy().to_string(),
92                    display_name: path
93                        .file_name()
94                        .unwrap_or_default()
95                        .to_string_lossy()
96                        .to_string(),
97                    content_hash: hash,
98                    modified_at: modified,
99                });
100            }
101        }
102    }
103    Ok(out)
104}