clio/
output.rs

1use crate::path::{ClioPathEnum, InOut};
2use crate::{
3    assert_is_dir, assert_not_dir, assert_writeable, impl_try_from, is_fifo, ClioPath, Error,
4    Result,
5};
6
7use is_terminal::IsTerminal;
8use std::convert::TryFrom;
9use std::ffi::OsStr;
10use std::fmt::{self, Debug, Display};
11use std::fs::{File, OpenOptions};
12use std::io::{self, Result as IoResult, Seek, Stderr, Stdout, Write};
13use std::path::Path;
14use tempfile::NamedTempFile;
15
16#[derive(Debug)]
17enum OutputStream {
18    /// a [`Stdout`] when the path was `-`
19    Stdout(Stdout),
20    /// a [`Stderr`]
21    Stderr(Stderr),
22    /// a [`File`] representing the named pipe e.g. crated with `mkfifo`
23    Pipe(File),
24    /// a normal [`File`] opened from the path
25    File(File),
26    /// A normal [`File`] opened from the path that will be written to atomically
27    AtomicFile(NamedTempFile),
28    #[cfg(feature = "http")]
29    #[cfg_attr(docsrs, doc(cfg(feature = "http")))]
30    /// a writer that will upload the body the the HTTP server
31    Http(Box<HttpWriter>),
32}
33
34#[cfg(feature = "http")]
35use crate::http::HttpWriter;
36/// A struct that represents a command line output stream,
37/// either [`Stdout`] or a [`File`] along with it's path
38///
39/// It is designed to be used with the [`clap` crate](https://docs.rs/clap/latest) when taking a file name as an
40/// argument to CLI app
41/// ```
42/// # #[cfg(feature="clap-parse")]{
43/// use clap::Parser;
44/// use clio::Output;
45///
46/// #[derive(Parser)]
47/// struct Opt {
48///     /// path to file, use '-' for stdout
49///     #[clap(value_parser)]
50///     output_file: Output,
51///
52///     /// default name for file is user passes in a directory
53///     #[clap(value_parser = clap::value_parser!(Output).default_name("run.log"))]
54///     log_file: Output,
55///
56///     /// Write output atomically using temp file and atomic rename
57///     #[clap(value_parser = clap::value_parser!(Output).atomic())]
58///     config_file: Output,
59/// }
60/// # }
61/// ```
62#[derive(Debug)]
63pub struct Output {
64    path: ClioPath,
65    stream: OutputStream,
66}
67
68/// A builder for [Output](crate::Output) that validates the path but
69/// defers creating it until you call the [create](crate::OutputPath::create) method.
70///
71/// The [create_with_len](crate::OutputPath::create_with_len) allows setting the size before writing.
72/// This is mostly useful with the "http" feature for setting the Content-Length header
73///
74/// It is designed to be used with the [`clap` crate](https://docs.rs/clap/latest) when taking a file name as an
75/// argument to CLI app
76/// ```
77/// # #[cfg(feature="clap-parse")]{
78/// use clap::Parser;
79/// use clio::OutputPath;
80///
81/// #[derive(Parser)]
82/// struct Opt {
83///     /// path to file, use '-' for stdout
84///     #[clap(value_parser)]
85///     output_file: OutputPath,
86/// }
87/// # }
88/// ```
89#[derive(Debug, PartialEq, Eq, Clone)]
90pub struct OutputPath {
91    path: ClioPath,
92}
93
94impl OutputStream {
95    /// Constructs a new output either by opening/creating the file or for '-' returning stdout
96    fn new(path: &ClioPath, size: Option<u64>) -> Result<Self> {
97        Ok(match &path.path {
98            ClioPathEnum::Std(_) => OutputStream::Stdout(io::stdout()),
99            ClioPathEnum::Local(local_path) => {
100                if path.atomic && !path.is_fifo() {
101                    assert_not_dir(path)?;
102                    if let Some(parent) = path.safe_parent() {
103                        assert_is_dir(parent)?;
104                        let tmp = tempfile::Builder::new()
105                            .prefix(".atomicwrite")
106                            .tempfile_in(parent)?;
107                        OutputStream::AtomicFile(tmp)
108                    } else {
109                        return Err(Error::not_found_error());
110                    }
111                } else {
112                    let file = open_rw(local_path)?;
113                    if is_fifo(&file.metadata()?) {
114                        OutputStream::Pipe(file)
115                    } else {
116                        if let Some(size) = size {
117                            file.set_len(size)?;
118                        }
119                        OutputStream::File(file)
120                    }
121                }
122            }
123            #[cfg(feature = "http")]
124            ClioPathEnum::Http(url) => {
125                OutputStream::Http(Box::new(HttpWriter::new(url.as_str(), size)?))
126            }
127        })
128    }
129}
130
131impl Output {
132    /// Constructs a new output either by opening/creating the file or for '-' returning stdout
133    pub fn new<S: TryInto<ClioPath>>(path: S) -> Result<Self>
134    where
135        crate::Error: From<<S as TryInto<ClioPath>>::Error>,
136    {
137        Output::maybe_with_len(path.try_into()?, None)
138    }
139
140    /// Convert to an normal [`Output`] setting the length of the file to size if it is `Some`
141    pub(crate) fn maybe_with_len(path: ClioPath, size: Option<u64>) -> Result<Self> {
142        Ok(Output {
143            stream: OutputStream::new(&path, size)?,
144            path,
145        })
146    }
147
148    /// Constructs a new output for stdout
149    pub fn std() -> Self {
150        Output {
151            path: ClioPath::std().with_direction(InOut::Out),
152            stream: OutputStream::Stdout(io::stdout()),
153        }
154    }
155
156    /// Constructs a new output for stdout
157    pub fn std_err() -> Self {
158        Output {
159            path: ClioPath::std().with_direction(InOut::Out),
160            stream: OutputStream::Stderr(io::stderr()),
161        }
162    }
163
164    /// Returns true if this Output is stout
165    pub fn is_std(&self) -> bool {
166        matches!(self.stream, OutputStream::Stdout(_))
167    }
168
169    /// Returns true if this is stdout and it is connected to a tty
170    pub fn is_tty(&self) -> bool {
171        self.is_std() && std::io::stdout().is_terminal()
172    }
173
174    /// Returns true if this Output is on the local file system,
175    /// as opposed to point to stdin/stout or a URL
176    pub fn is_local(&self) -> bool {
177        self.path.is_local()
178    }
179
180    /// Constructs a new output either by opening/creating the file or for '-' returning stdout
181    ///
182    /// The error is converted to a [`OsString`](std::ffi::OsString) so that [stuctopt](https://docs.rs/structopt/latest/structopt/#custom-string-parsers) can show it to the user.
183    ///
184    /// It is recommended that you use [`TryFrom::try_from`] and [clap 3.0](https://docs.rs/clap/latest/clap/index.html) instead.
185    pub fn try_from_os_str(path: &OsStr) -> std::result::Result<Self, std::ffi::OsString> {
186        TryFrom::try_from(path).map_err(|e: Error| e.to_os_string(path))
187    }
188
189    /// Syncs the file to disk or closes any HTTP connections and returns any errors
190    /// or on the file if a regular file
191    /// For atomic files this must be called to perform the final atomic swap
192    pub fn finish(mut self) -> Result<()> {
193        self.flush()?;
194        match self.stream {
195            OutputStream::Stdout(_) => Ok(()),
196            OutputStream::Stderr(_) => Ok(()),
197            OutputStream::Pipe(_) => Ok(()),
198            OutputStream::File(file) => Ok(file.sync_data()?),
199            OutputStream::AtomicFile(tmp) => {
200                tmp.persist(self.path.path())?;
201                Ok(())
202            }
203            #[cfg(feature = "http")]
204            OutputStream::Http(http) => Ok(http.finish()?),
205        }
206    }
207
208    /// If the output is std out [locks](std::io::Stdout::lock) it.
209    /// useful in multithreaded context to write lines consistently
210    ///
211    /// # Examples
212    ///
213    /// ```no_run
214    /// # fn main() -> Result<(), clio::Error> {
215    /// let mut file = clio::Output::new("-")?;
216    ///
217    /// writeln!(file.lock(), "hello world")?;
218    /// # Ok(())
219    /// # }
220    /// ```
221    pub fn lock<'a>(&'a mut self) -> Box<dyn Write + 'a> {
222        match &mut self.stream {
223            OutputStream::Stdout(stdout) => Box::new(stdout.lock()),
224            OutputStream::Stderr(stderr) => Box::new(stderr.lock()),
225            OutputStream::Pipe(pipe) => Box::new(pipe),
226            OutputStream::File(file) => Box::new(file),
227            OutputStream::AtomicFile(file) => Box::new(file),
228            #[cfg(feature = "http")]
229            OutputStream::Http(http) => Box::new(http),
230        }
231    }
232
233    /// If output is a file, returns a reference to the file,
234    /// otherwise if output is stdout or a pipe returns none.
235    pub fn get_file(&mut self) -> Option<&mut File> {
236        match &mut self.stream {
237            OutputStream::File(file) => Some(file),
238            OutputStream::AtomicFile(file) => Some(file.as_file_mut()),
239            _ => None,
240        }
241    }
242
243    /// The original path used to create this [`Output`]
244    pub fn path(&self) -> &ClioPath {
245        &self.path
246    }
247
248    /// Returns `true` if this [`Output`] is a file,
249    /// and `false` if this [`Output`] is std out or a pipe
250    pub fn can_seek(&self) -> bool {
251        matches!(
252            self.stream,
253            OutputStream::File(_) | OutputStream::AtomicFile(_)
254        )
255    }
256}
257
258impl_try_from!(Output);
259
260impl Write for Output {
261    fn flush(&mut self) -> IoResult<()> {
262        match &mut self.stream {
263            OutputStream::Stdout(stdout) => stdout.flush(),
264            OutputStream::Stderr(stderr) => stderr.flush(),
265            OutputStream::Pipe(pipe) => pipe.flush(),
266            OutputStream::File(file) => file.flush(),
267            OutputStream::AtomicFile(file) => file.flush(),
268            #[cfg(feature = "http")]
269            OutputStream::Http(http) => http.flush(),
270        }
271    }
272    fn write(&mut self, buf: &[u8]) -> IoResult<usize> {
273        match &mut self.stream {
274            OutputStream::Stdout(stdout) => stdout.write(buf),
275            OutputStream::Stderr(stderr) => stderr.write(buf),
276            OutputStream::Pipe(pipe) => pipe.write(buf),
277            OutputStream::File(file) => file.write(buf),
278            OutputStream::AtomicFile(file) => file.write(buf),
279            #[cfg(feature = "http")]
280            OutputStream::Http(http) => http.write(buf),
281        }
282    }
283}
284
285impl Seek for Output {
286    fn seek(&mut self, pos: io::SeekFrom) -> IoResult<u64> {
287        match &mut self.stream {
288            OutputStream::File(file) => file.seek(pos),
289            OutputStream::AtomicFile(file) => file.seek(pos),
290            _ => Err(Error::seek_error().into()),
291        }
292    }
293}
294
295impl OutputPath {
296    /// Construct a new [`OutputPath`] from an string
297    ///
298    /// It checks if an output file could plausibly be created at that path
299    pub fn new<S: TryInto<ClioPath>>(path: S) -> Result<Self>
300    where
301        crate::Error: From<<S as TryInto<ClioPath>>::Error>,
302    {
303        let path: ClioPath = path.try_into()?.with_direction(InOut::Out);
304        if path.is_local() {
305            if path.is_file() && !path.atomic {
306                assert_writeable(&path)?;
307            } else {
308                #[cfg(target_os = "linux")]
309                if path.ends_with_slash() {
310                    return Err(Error::dir_error());
311                }
312                assert_not_dir(&path)?;
313                if let Some(parent) = path.safe_parent() {
314                    assert_is_dir(parent)?;
315                    assert_writeable(parent)?;
316                } else {
317                    return Err(Error::not_found_error());
318                }
319            }
320        }
321        Ok(OutputPath { path })
322    }
323
324    /// Constructs a new [`OutputPath`] of `"-"` for stdout
325    pub fn std() -> Self {
326        OutputPath {
327            path: ClioPath::std().with_direction(InOut::Out),
328        }
329    }
330
331    /// convert to an normal [`Output`] setting the length of the file to size if it is `Some`
332    pub fn maybe_with_len(self, size: Option<u64>) -> Result<Output> {
333        Output::maybe_with_len(self.path, size)
334    }
335
336    /// Create the file with a predetermined length, either using [`File::set_len`] or as the `content-length` header of the http put
337    pub fn create_with_len(self, size: u64) -> Result<Output> {
338        self.maybe_with_len(Some(size))
339    }
340
341    /// Create an [`Output`] without setting the length
342    pub fn create(self) -> Result<Output> {
343        self.maybe_with_len(None)
344    }
345
346    /// The original path represented by this [`OutputPath`]
347    pub fn path(&self) -> &ClioPath {
348        &self.path
349    }
350
351    /// Returns true if this [`Output`] is stdout
352    pub fn is_std(&self) -> bool {
353        self.path.is_std()
354    }
355
356    /// Returns true if this is stdout and it is connected to a tty
357    pub fn is_tty(&self) -> bool {
358        self.is_std() && std::io::stdout().is_terminal()
359    }
360
361    /// Returns true if this [`Output`] is on the local file system,
362    /// as opposed to point to stout or a URL
363    pub fn is_local(&self) -> bool {
364        self.path.is_local()
365    }
366
367    /// Returns `true` if this [`OutputPath`] points to a file,
368    /// and `false` if this [`OutputPath`] is std out or points to a pipe.
369    /// Note that the file is not opened yet, so there are possible when you
370    /// open the file it might have changed.
371    pub fn can_seek(&self) -> bool {
372        self.path.is_local() && !self.path.is_fifo()
373    }
374}
375
376impl_try_from!(OutputPath: Clone);
377
378fn open_rw(path: &Path) -> io::Result<File> {
379    OpenOptions::new()
380        .read(true)
381        .write(true)
382        .create(true)
383        .truncate(true)
384        .open(path)
385        .or_else(|_| File::create(path))
386}