src_dst_clarifier/
lib.rs

1use std::{
2    ffi::{OsStr, OsString},
3    fs, io,
4    path::{Path, PathBuf},
5};
6
7use thiserror::Error;
8
9use kalavor::Katetime;
10
11/// Use single hyphen (`-`) as path to indicate IO from Stdio.
12///
13/// # Notes
14///
15/// - Auto time-based unique naming (`auto_tnamed_dst_`) only takes effect when DST is not provided.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct SrcDstConfig {
18    pub allow_from_stdin: bool,
19    pub allow_to_stdout: bool,
20
21    pub auto_tnamed_dst_file: bool,
22    pub auto_tnamed_dst_dir: bool,
23
24    pub default_extension: OsString,
25
26    /// Disallowed by default. There may be a potential to `open` and `create` the same file at the same time.
27    pub allow_inplace: bool,
28}
29
30impl SrcDstConfig {
31    pub fn new<S: AsRef<OsStr>>(default_extension: S) -> Self {
32        Self {
33            allow_from_stdin: true,
34            allow_to_stdout: true,
35            auto_tnamed_dst_file: true,
36            auto_tnamed_dst_dir: true,
37            default_extension: default_extension.as_ref().to_owned(),
38            allow_inplace: false,
39        }
40    }
41
42    pub fn new_with_allow_inplace<S: AsRef<OsStr>>(default_extension: S) -> Self {
43        Self {
44            allow_from_stdin: true,
45            allow_to_stdout: true,
46            auto_tnamed_dst_file: true,
47            auto_tnamed_dst_dir: true,
48            default_extension: default_extension.as_ref().to_owned(),
49            allow_inplace: true,
50        }
51    }
52
53    /// # Possible Combinations
54    ///
55    /// ``` plaintext
56    /// SRC => DST:   Stdout,6   File   Dir     NotProvided
57    /// Stdin,6          1+2      1      1         1+3,5
58    /// File               2      ✓      ✓           3
59    /// Dir                ×      ×      ✓           4
60    /// ```
61    ///
62    /// 1. `allow_from_stdin`.
63    /// 2. `allow_to_stdout`.
64    /// 3. `auto_tnamed_dst_file`.
65    /// 4. `auto_tnamed_dst_dir`.
66    ///     Note: A directory with specified name will not be created automatically
67    ///     (an error will be returned if it does not exist).
68    /// 5. Note that [`std::env::current_dir`] will be used as output directory.
69    /// 6. *Stdio will be always treated as a file.*
70    pub fn parse<P: AsRef<Path>>(
71        &self,
72        src: P,
73        dst: Option<P>,
74    ) -> io::Result<Result<SrcDstPairs, SrcDstError>> {
75        enum InnerSource {
76            Stdin,
77            File(PathBuf),
78            Dir(PathBuf),
79        }
80
81        enum InnerDrain {
82            Stdout,
83            File(PathBuf),
84            Dir(PathBuf),
85            NotExist(PathBuf),
86            NotProvided,
87        }
88
89        let src = src.as_ref();
90        let src = if src.as_os_str() == "-" {
91            InnerSource::Stdin
92        } else if !src.exists() {
93            return Err(io::Error::new(
94                io::ErrorKind::PermissionDenied,
95                format!("SRC '{}' does not exist", src.to_string_lossy()),
96            ));
97        } else {
98            let src = fs::canonicalize(src)?;
99            if src.is_file() {
100                InnerSource::File(src)
101            } else {
102                InnerSource::Dir(src)
103            }
104        };
105
106        let dst = dst.as_ref();
107        let mut dst = match dst {
108            None => InnerDrain::NotProvided,
109            Some(dst) => {
110                let dst = dst.as_ref();
111                if dst.as_os_str() == "-" {
112                    InnerDrain::Stdout
113                } else if !dst.exists() {
114                    InnerDrain::NotExist(dst.to_owned())
115                } else {
116                    let dst = fs::canonicalize(dst)?;
117                    if dst.is_file() {
118                        InnerDrain::File(dst)
119                    } else {
120                        InnerDrain::Dir(dst)
121                    }
122                }
123            }
124        };
125
126        if matches!(src, InnerSource::Stdin) && !self.allow_from_stdin {
127            return Ok(Err(SrcDstError::DisallowFromStdin)); // 1
128        }
129        if matches!(dst, InnerDrain::Stdout) && !self.allow_to_stdout {
130            return Ok(Err(SrcDstError::DisallowToStdout)); // 2
131        }
132        if matches!(dst, InnerDrain::NotProvided) {
133            if matches!(src, InnerSource::Dir(_)) && !self.auto_tnamed_dst_dir {
134                return Ok(Err(SrcDstError::ForbidAutoTnamedDstDir)); // 4
135            } else if !self.auto_tnamed_dst_file {
136                return Ok(Err(SrcDstError::ForbidAutoTnamedDstFile)); // 3
137            }
138        }
139        if let InnerDrain::Dir(parent) = &dst {
140            if let InnerSource::File(src) = &src {
141                if fs::canonicalize(parent)? == fs::canonicalize(src)?.parent().unwrap() {
142                    dst = InnerDrain::NotProvided; // 当 DST-Dir 与 SRC-File所在目录 相同时,切换至 tname
143                }
144            } else if !self.allow_inplace {
145                if let InnerSource::Dir(src) = &src {
146                    if fs::canonicalize(parent)? == fs::canonicalize(src)? {
147                        return Ok(Err(SrcDstError::Inplaced));
148                    }
149                }
150            }
151        }
152
153        let mut tnamed = false;
154        let (src, dst): (Source, Drain) = match src {
155            InnerSource::Stdin | InnerSource::File(_) => {
156                fn dst_parent_src_name(src: &InnerSource, dst: &InnerDrain) -> io::Result<PathBuf> {
157                    let mut parent = match dst {
158                        InnerDrain::Dir(parent) => parent.to_owned(),
159                        InnerDrain::NotProvided => fs::canonicalize(std::env::current_dir()?)?,
160                        _ => unreachable!(),
161                    };
162                    parent.push(match src {
163                        InnerSource::Stdin => OsString::from("stdin"),
164                        InnerSource::File(src) => src.file_name().unwrap().into(), // 在调用这个函数时,SRC 已经规范化了
165                        InnerSource::Dir(_) => unreachable!(),
166                    });
167
168                    Ok(parent)
169                }
170
171                (
172                    match &src {
173                        InnerSource::Stdin => Source::Stdin,
174                        InnerSource::File(src) => Source::File(src.clone()),
175                        InnerSource::Dir(_) => unreachable!(),
176                    },
177                    match dst {
178                        InnerDrain::Stdout => Drain::Stdout,
179                        InnerDrain::File(dst) => Drain::Single(dst),
180                        InnerDrain::Dir(_) => Drain::Single(dst_parent_src_name(&src, &dst)?),
181                        InnerDrain::NotExist(dst) => Drain::Single(dst),
182                        InnerDrain::NotProvided => {
183                            // input.png => input-A01123-0456-0789.png
184                            // input.jpg => input.jpg-A01123-0456-0789.png
185
186                            let mut dst = dst_parent_src_name(&src, &dst)?;
187
188                            dst.extension()
189                                .and_then(|ext| Some(ext == self.default_extension))
190                                .unwrap_or(false)
191                                .then(|| dst.set_extension("")); // 如果后缀不错,那么就去掉
192                            dst.set_file_name(format!(
193                                "{}-{}{}",
194                                dst.as_os_str().to_string_lossy(),
195                                Katetime::now_datetime(),
196                                match self.default_extension.is_empty() {
197                                    true => String::with_capacity(0),
198                                    false =>
199                                        format!(".{}", self.default_extension.to_string_lossy()),
200                                }
201                            ));
202
203                            Drain::Single(dst)
204                        }
205                    },
206                )
207            }
208
209            InnerSource::Dir(src) => {
210                fn shallow_walk<P: AsRef<Path>>(src: P) -> io::Result<Vec<PathBuf>> {
211                    let mut files = fs::read_dir(src)?
212                        .filter_map(Result::ok)
213                        .filter_map(|p| {
214                            p.metadata()
215                                .ok()
216                                .and_then(|m| m.is_file().then(|| p.path()))
217                        })
218                        .collect::<Vec<_>>();
219                    files.sort_unstable_by(|a, b| b.cmp(a));
220                    Ok(files)
221                }
222
223                match dst {
224                    InnerDrain::Stdout => return Ok(Err(SrcDstError::ManyToOne)),
225                    InnerDrain::File(_) => return Ok(Err(SrcDstError::ManyToOne)),
226                    InnerDrain::Dir(dst) => (Source::Files(shallow_walk(src)?), Drain::Single(dst)),
227                    InnerDrain::NotExist(_) => return Ok(Err(SrcDstError::DstDirNotExist)),
228                    InnerDrain::NotProvided => {
229                        // ./inputs => ./inputs-A01123-0456-0789
230                        let mut dst = src
231                            .parent()
232                            .ok_or_else(|| {
233                                io::Error::new(
234                                    io::ErrorKind::PermissionDenied,
235                                    format!("parent directory of {src:?} are unavailable"),
236                                )
237                            })?
238                            .to_owned();
239                        dst.push(format!(
240                            "{}-{}",
241                            src.file_name().unwrap().to_string_lossy(),
242                            Katetime::now_datetime()
243                        ));
244
245                        tnamed = true;
246                        (Source::Files(shallow_walk(src)?), Drain::Single(dst))
247                    }
248                }
249            }
250        };
251
252        Ok(Ok(SrcDstPairs {
253            src,
254            dst,
255            tnamed_dir: tnamed,
256            finished: false,
257        }))
258    }
259}
260
261#[non_exhaustive]
262#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
263pub enum SrcDstError {
264    #[error("disallow read from stdin")]
265    DisallowFromStdin = 1,
266    #[error("disallow write to stdout")]
267    DisallowToStdout,
268    #[error("forbid automatic time-based named DST file")]
269    ForbidAutoTnamedDstFile,
270    #[error("forbid automatic time-based named DST directory")]
271    ForbidAutoTnamedDstDir,
272
273    #[error("there may be a potential to `open` and `create` the same file at the same time")]
274    Inplaced,
275
276    #[error("unable to write multiple files to one file")]
277    ManyToOne,
278    #[error("specified DST directory does not exist")]
279    DstDirNotExist,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Hash)]
283pub enum Src {
284    File(PathBuf),
285    Stdin,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Hash)]
289pub enum Dst {
290    File(PathBuf),
291    Stdout,
292}
293
294#[derive(Debug)]
295pub struct SrcDstPairs {
296    src: Source,
297    dst: Drain,
298
299    tnamed_dir: bool,
300    finished: bool,
301}
302
303impl SrcDstPairs {
304    /// **Before consuming the path pair, call this method to create time-based named directory!**
305    pub fn create_tnamed_dir(&self) -> io::Result<()> {
306        if let Drain::Single(dir) = &self.dst {
307            if self.tnamed_dir {
308                fs::create_dir(dir)?;
309            }
310        }
311        Ok(())
312    }
313
314    pub fn is_batch(&self) -> bool {
315        matches!(self.src, Source::Files(_))
316    }
317}
318
319impl Iterator for SrcDstPairs {
320    type Item = (Src, Dst);
321
322    fn next(&mut self) -> Option<Self::Item> {
323        if self.finished {
324            return None;
325        }
326
327        match &self.dst {
328            Drain::Stdout => match &self.src {
329                Source::Stdin => {
330                    self.finished = true;
331                    Some((Src::Stdin, Dst::Stdout))
332                }
333                Source::File(src) => {
334                    self.finished = true;
335                    Some((Src::File(src.to_owned()), Dst::Stdout))
336                }
337                Source::Files(_) => unreachable!(),
338            },
339
340            Drain::Single(dst) => match &mut self.src {
341                Source::Stdin => {
342                    self.finished = true;
343                    Some((Src::Stdin, Dst::File(dst.to_owned())))
344                }
345                Source::File(src) => {
346                    self.finished = true;
347                    Some((Src::File(src.to_owned()), Dst::File(dst.to_owned())))
348                }
349                Source::Files(srcs) => match srcs.pop() {
350                    None => None,
351                    Some(src) => {
352                        let dst = dst.join(src.file_name().unwrap());
353                        Some((Src::File(src), Dst::File(dst)))
354                    }
355                },
356            },
357        }
358    }
359}
360
361#[derive(Debug)]
362enum Source {
363    Stdin,
364    File(PathBuf),
365    /// 注意文件列表应该是倒过来排序的!这样就能把它们一个个 pop 出来了。
366    Files(Vec<PathBuf>),
367}
368
369#[derive(Debug)]
370enum Drain {
371    Stdout,
372    /// 注意这玩意必须手动拼接!(如果 SRC 是 [`Source::Files`] 的话)也就是文件名相同,但父目录不同。
373    Single(PathBuf),
374}
375
376/// 我该怎么做测试?只是简单跑一下`cargo test -- --nocapture`吗?
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test() {
383        match SrcDstConfig::new("png").parse(".", None) {
384            Err(e) => println!("{e}"),
385            Ok(p) => match p {
386                Err(e) => println!("{e}"),
387                Ok(p) => {
388                    let p = p.collect::<Vec<_>>();
389                    println!("{p:#?}")
390                }
391            },
392        };
393    }
394}