Skip to main content

file_engine/
analyze.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use tokio_stream::wrappers::UnboundedReceiverStream;
5use tokio_util::sync::CancellationToken;
6
7use crate::engine::FileEngine;
8use crate::error::{FileEngineError, Result};
9use crate::handle::Handle;
10use crate::progress::Progress;
11
12#[derive(Debug, Clone)]
13pub enum FileKind {
14    File,
15    Directory,
16    Symlink,
17    Other,
18}
19
20#[derive(Debug, Clone)]
21pub struct FileInfo {
22    pub path: PathBuf,
23    pub kind: FileKind,
24    pub size: u64,
25    /// MIME type sniffed from the first bytes of the file's content, via
26    /// `infer`. `None` for directories, symlinks, and files whose content
27    /// doesn't match any known signature.
28    pub content_type: Option<&'static str>,
29    #[cfg(feature = "checksum")]
30    pub hash: Option<String>,
31}
32
33pub struct AnalyzeBuilder {
34    path: PathBuf,
35    recursive: bool,
36    follow_symlinks: bool,
37    #[cfg(feature = "checksum")]
38    with_hash: bool,
39    cancel_token: Option<CancellationToken>,
40}
41
42impl AnalyzeBuilder {
43    pub(crate) fn new(path: PathBuf, follow_symlinks: bool) -> Self {
44        Self {
45            path,
46            recursive: true,
47            follow_symlinks,
48            #[cfg(feature = "checksum")]
49            with_hash: false,
50            cancel_token: None,
51        }
52    }
53
54    pub fn recursive(mut self, enabled: bool) -> Self {
55        self.recursive = enabled;
56        self
57    }
58
59    pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
60        self.cancel_token = Some(token);
61        self
62    }
63
64    pub fn start(self) -> Result<Handle<Vec<FileInfo>>> {
65        let cancel_token = self.cancel_token.unwrap_or_default();
66        let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel();
67
68        let task_cancel_token = cancel_token.clone();
69        let path = self.path;
70        let recursive = self.recursive;
71        let follow_symlinks = self.follow_symlinks;
72        #[cfg(feature = "checksum")]
73        let with_hash = self.with_hash;
74
75        let join = tokio::task::spawn_blocking(move || {
76            let mut results = Vec::new();
77
78            for (index, entry) in walk_blocking(&path, recursive, follow_symlinks).enumerate() {
79                if task_cancel_token.is_cancelled() {
80                    return Err(FileEngineError::Cancelled);
81                }
82
83                let entry = entry.map_err(walkdir_error)?;
84                let metadata = entry.metadata().map_err(walkdir_error)?;
85
86                let kind = if metadata.file_type().is_symlink() {
87                    FileKind::Symlink
88                } else if metadata.is_dir() {
89                    FileKind::Directory
90                } else if metadata.is_file() {
91                    FileKind::File
92                } else {
93                    FileKind::Other
94                };
95
96                let size = metadata.len();
97                let is_regular_file = matches!(kind, FileKind::File);
98
99                let content_type = if is_regular_file {
100                    sniff_content_type(entry.path())
101                } else {
102                    None
103                };
104
105                #[cfg(feature = "checksum")]
106                let hash = if with_hash && is_regular_file {
107                    Some(hash_file(entry.path())?)
108                } else {
109                    None
110                };
111
112                let files_done = index as u64 + 1;
113                let _ = progress_tx.send(Progress {
114                    bytes_done: files_done,
115                    bytes_total: 0,
116                    files_done,
117                    files_total: 0,
118                    current_file: Some(entry.path().to_path_buf()),
119                });
120
121                results.push(FileInfo {
122                    path: entry.into_path(),
123                    kind,
124                    size,
125                    content_type,
126                    #[cfg(feature = "checksum")]
127                    hash,
128                });
129            }
130
131            Ok(results)
132        });
133
134        Ok(Handle {
135            join,
136            progress_rx: UnboundedReceiverStream::new(progress_rx),
137            cancel_token,
138        })
139    }
140}
141
142#[cfg(feature = "checksum")]
143impl AnalyzeBuilder {
144    pub fn with_hash(mut self, enabled: bool) -> Self {
145        self.with_hash = enabled;
146        self
147    }
148}
149
150/// Shared walk configuration, also used by `sync.rs` (legal: `sync`
151/// requires `analyze` at the manifest level, §4 of the design doc) to diff
152/// two directory trees with the exact same traversal rules `analyze` uses.
153pub(crate) fn walk_blocking(
154    path: &Path,
155    recursive: bool,
156    follow_symlinks: bool,
157) -> impl Iterator<Item = walkdir::Result<walkdir::DirEntry>> {
158    let mut walker = walkdir::WalkDir::new(path).follow_links(follow_symlinks);
159    if !recursive {
160        walker = walker.max_depth(1);
161    }
162    walker.into_iter()
163}
164
165pub(crate) fn walkdir_error(err: walkdir::Error) -> FileEngineError {
166    let path = err.path().map(Path::to_path_buf).unwrap_or_default();
167    match err.into_io_error() {
168        Some(io_err) => crate::error::from_io(path, io_err),
169        None => FileEngineError::Io {
170            path,
171            source: std::io::Error::other("walk error with no underlying io::Error"),
172        },
173    }
174}
175
176fn sniff_content_type(path: &Path) -> Option<&'static str> {
177    let mut file = std::fs::File::open(path).ok()?;
178    let mut buf = [0u8; 8192];
179    let n = file.read(&mut buf).ok()?;
180    infer::get(&buf[..n]).map(|kind| kind.mime_type())
181}
182
183/// `pub(crate)` so `sync.rs`'s `compare_by_hash` (§8.5) can reuse it —
184/// legal since that method only exists under `#[cfg(all(feature = "sync",
185/// feature = "checksum"))]`, i.e. only when this is also compiled.
186#[cfg(feature = "checksum")]
187pub(crate) fn hash_file(path: &Path) -> Result<String> {
188    let mut file =
189        std::fs::File::open(path).map_err(|e| crate::error::from_io(path.to_path_buf(), e))?;
190    let mut hasher = blake3::Hasher::new();
191    hasher
192        .update_reader(&mut file)
193        .map_err(|e| crate::error::from_io(path.to_path_buf(), e))?;
194    Ok(hasher.finalize().to_hex().to_string())
195}
196
197#[cfg(feature = "analyze")]
198impl FileEngine {
199    pub fn analyze(&self, path: impl Into<PathBuf>) -> AnalyzeBuilder {
200        AnalyzeBuilder::new(path.into(), self.options().follow_symlinks)
201    }
202}