Skip to main content

anyback_reader/cli/
output.rs

1/*
2 * anyback_reader - backup command output contract
3 * github.com/stevelr/anytype
4 *
5 * SPDX-FileCopyrightText: 2025-2026 Steve Schoettler
6 * SPDX-License-Identifier: Apache-2.0
7 */
8//! Presentation contract shared between `anyback` commands and the parent CLI
9//! that embeds them (`anyr backup ...`).
10//!
11//! A non-interactive backup command produces one result document.
12//! [`CommandOutput`] decides how that document is rendered (compact JSON,
13//! indented JSON, human text, or nothing at all) and where it is written
14//! (stdout or a file). Only the result document travels through this type;
15//! diagnostics, progress, and errors stay on stderr so that stdout remains
16//! machine-parseable. Interactive commands render their own terminal UI.
17
18use std::fs::{self, OpenOptions};
19use std::io::{self, Write};
20use std::path::{Component, Path, PathBuf};
21
22use anyhow::{Context, Result, bail};
23use serde::Serialize;
24
25use super::deadline::PublicationCommit;
26
27pub(super) enum PreparedOutput {
28    Quiet,
29    Stdout(String),
30    File { path: PathBuf, stage: PathBuf },
31}
32
33impl Drop for PreparedOutput {
34    fn drop(&mut self) {
35        if let Self::File { stage, .. } = self {
36            let _ = fs::remove_file(stage);
37        }
38    }
39}
40
41/// How a backup command result should be presented.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum OutputMode {
44    /// Compact single-line JSON. This is the default machine-readable form.
45    #[default]
46    Json,
47    /// Indented, human-readable JSON.
48    Pretty,
49    /// Plain text summaries and tables intended for a terminal.
50    Human,
51    /// Suppress all normal output; only the exit status reports the outcome.
52    Quiet,
53}
54
55impl OutputMode {
56    /// Returns the flag-style name of the mode, for diagnostics and errors.
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::Json => "json",
61            Self::Pretty => "pretty",
62            Self::Human => "table",
63            Self::Quiet => "quiet",
64        }
65    }
66
67    /// Returns true when the mode renders JSON that callers may parse.
68    #[must_use]
69    pub const fn is_json(self) -> bool {
70        matches!(self, Self::Json | Self::Pretty)
71    }
72}
73
74/// Destination and presentation contract for a backup command result.
75///
76/// A successful command emits at most once. File output is replaced only when
77/// the result is ready, so validation and command failures preserve any
78/// pre-existing destination.
79#[derive(Clone, Debug, Default)]
80pub struct CommandOutput {
81    mode: OutputMode,
82    path: Option<PathBuf>,
83}
84
85impl CommandOutput {
86    /// Creates an output contract for `mode`, writing to `path` when supplied
87    /// and to stdout otherwise.
88    #[must_use]
89    pub fn new(mode: OutputMode, path: Option<PathBuf>) -> Self {
90        Self { mode, path }
91    }
92
93    /// Creates a compact-JSON contract writing to stdout.
94    #[must_use]
95    pub fn json() -> Self {
96        Self::new(OutputMode::Json, None)
97    }
98
99    /// Creates a human-text contract writing to stdout.
100    #[must_use]
101    pub fn human() -> Self {
102        Self::new(OutputMode::Human, None)
103    }
104
105    /// The requested presentation mode.
106    #[must_use]
107    pub const fn mode(&self) -> OutputMode {
108        self.mode
109    }
110
111    /// The result destination file, or `None` for stdout.
112    #[must_use]
113    pub fn path(&self) -> Option<&Path> {
114        self.path.as_deref()
115    }
116
117    /// True when no normal output may be produced.
118    #[must_use]
119    pub const fn is_quiet(&self) -> bool {
120        matches!(self.mode, OutputMode::Quiet)
121    }
122
123    /// True when the result is rendered as JSON.
124    #[must_use]
125    pub const fn is_json(&self) -> bool {
126        self.mode.is_json()
127    }
128
129    /// True when interactive progress reporting is appropriate: only for
130    /// human output going to a terminal that is not competing with a
131    /// machine-readable result document.
132    #[must_use]
133    pub const fn allows_progress(&self) -> bool {
134        matches!(self.mode, OutputMode::Human)
135    }
136
137    /// Rejects a result destination that aliases a command input or artifact.
138    ///
139    /// Existing files are compared by filesystem identity, including hard
140    /// links. Non-existing paths are compared after resolving their nearest
141    /// existing ancestor, so relative paths and symlinked parent directories
142    /// cannot bypass the check.
143    pub fn ensure_distinct_from(&self, other: &Path, description: &str) -> Result<()> {
144        let Some(output_path) = self.path.as_deref() else {
145            return Ok(());
146        };
147        if self.is_quiet() {
148            return Ok(());
149        }
150        if paths_alias(output_path, other)? {
151            bail!(
152                "result output path {} aliases {description} {}",
153                output_path.display(),
154                other.display()
155            );
156        }
157        Ok(())
158    }
159
160    /// Renders `value` as JSON, honoring compact/indented/quiet modes.
161    ///
162    /// Use [`Self::emit`] when the command also has a human rendering.
163    pub fn emit_json<T: Serialize + ?Sized>(&self, value: &T) -> Result<()> {
164        if self.is_quiet() {
165            return Ok(());
166        }
167        let text = match self.mode {
168            OutputMode::Pretty | OutputMode::Human => serde_json::to_string_pretty(value)?,
169            _ => serde_json::to_string(value)?,
170        };
171        self.write(&text)
172    }
173
174    /// Writes already-rendered text, honoring quiet mode and file routing.
175    pub fn emit_text(&self, text: &str) -> Result<()> {
176        if self.is_quiet() {
177            return Ok(());
178        }
179        self.write(text)
180    }
181
182    /// Emits the result document: `value` in JSON modes, otherwise the text
183    /// produced by `render_human`.
184    ///
185    /// `render_human` is only evaluated when a human rendering is needed, so
186    /// expensive formatting is skipped for JSON and quiet output.
187    pub fn emit<T, F>(&self, value: &T, render_human: F) -> Result<()>
188    where
189        T: Serialize + ?Sized,
190        F: FnOnce() -> String,
191    {
192        match self.mode {
193            OutputMode::Quiet => Ok(()),
194            OutputMode::Json | OutputMode::Pretty => self.emit_json(value),
195            OutputMode::Human => self.write(&render_human()),
196        }
197    }
198
199    pub(super) fn render<T, F>(&self, value: &T, render_human: F) -> Result<Option<String>>
200    where
201        T: Serialize + ?Sized,
202        F: FnOnce() -> String,
203    {
204        let text = match self.mode {
205            OutputMode::Quiet => return Ok(None),
206            OutputMode::Json => serde_json::to_string(value)?,
207            OutputMode::Pretty => serde_json::to_string_pretty(value)?,
208            OutputMode::Human => render_human(),
209        };
210        Ok(Some(text))
211    }
212
213    pub(super) fn prepare_rendered(&self, data: String) -> Result<PreparedOutput> {
214        let mut text = data;
215        if !text.ends_with('\n') {
216            text.push('\n');
217        }
218        let Some(path) = self.path.as_deref() else {
219            return Ok(PreparedOutput::Stdout(text));
220        };
221
222        let (mut stage, stage_path) = create_staging_file(path)?;
223        let staged = stage
224            .write_all(text.as_bytes())
225            .and_then(|()| stage.sync_all());
226        drop(stage);
227        if let Err(error) = staged {
228            let _ = fs::remove_file(&stage_path);
229            return Err(error)
230                .with_context(|| format!("failed to stage output file {}", path.display()));
231        }
232        Ok(PreparedOutput::File {
233            path: path.to_path_buf(),
234            stage: stage_path,
235        })
236    }
237
238    pub(super) fn commit_prepared(
239        mut prepared: PreparedOutput,
240        authority: PublicationCommit,
241    ) -> Result<()> {
242        authority.commit(|| match &mut prepared {
243            PreparedOutput::Quiet => Ok(()),
244            PreparedOutput::Stdout(text) => {
245                let mut stdout = io::stdout().lock();
246                stdout
247                    .write_all(text.as_bytes())
248                    .context("failed to write backup result to stdout")?;
249                stdout
250                    .flush()
251                    .context("failed to flush backup result to stdout")
252            }
253            PreparedOutput::File { path, stage } => replace_file(stage, path)
254                .with_context(|| format!("failed to publish output file {}", path.display())),
255        })
256    }
257
258    fn write(&self, data: &str) -> Result<()> {
259        let mut text = data.to_string();
260        if !text.ends_with('\n') {
261            text.push('\n');
262        }
263
264        let Some(path) = self.path.as_deref() else {
265            let mut stdout = io::stdout().lock();
266            stdout
267                .write_all(text.as_bytes())
268                .context("failed to write backup result to stdout")?;
269            return stdout
270                .flush()
271                .context("failed to flush backup result to stdout");
272        };
273
274        fs::write(path, text.as_bytes())
275            .with_context(|| format!("failed to write output file {}", path.display()))
276    }
277}
278
279#[cfg(not(windows))]
280fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
281    fs::rename(source, destination)
282}
283
284#[cfg(windows)]
285fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
286    use std::os::windows::ffi::OsStrExt as _;
287    use windows_sys::Win32::Storage::FileSystem::{
288        MOVE_FILE_FLAGS, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
289    };
290
291    let source = source
292        .as_os_str()
293        .encode_wide()
294        .chain(std::iter::once(0))
295        .collect::<Vec<_>>();
296    let destination = destination
297        .as_os_str()
298        .encode_wide()
299        .chain(std::iter::once(0))
300        .collect::<Vec<_>>();
301    let flags: MOVE_FILE_FLAGS = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
302    if unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), flags) } == 0 {
303        return Err(io::Error::last_os_error());
304    }
305    Ok(())
306}
307
308fn create_staging_file(destination: &Path) -> Result<(fs::File, PathBuf)> {
309    let parent = destination
310        .parent()
311        .filter(|path| !path.as_os_str().is_empty())
312        .unwrap_or_else(|| Path::new("."));
313    let name = destination
314        .file_name()
315        .ok_or_else(|| anyhow::anyhow!("output destination must name a file"))?;
316    for nonce in 0..100_u32 {
317        let path = parent.join(format!(
318            ".{}.anyback-stage-{}-{nonce}",
319            name.to_string_lossy(),
320            std::process::id()
321        ));
322        match OpenOptions::new().write(true).create_new(true).open(&path) {
323            Ok(file) => return Ok((file, path)),
324            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
325            Err(error) => {
326                return Err(error)
327                    .with_context(|| format!("failed to stage output file {}", path.display()));
328            }
329        }
330    }
331    bail!("failed to allocate output staging file")
332}
333
334fn paths_alias(left: &Path, right: &Path) -> Result<bool> {
335    if left.exists() && right.exists() {
336        return same_file::is_same_file(left, right).with_context(|| {
337            format!(
338                "failed to compare output path {} with {}",
339                left.display(),
340                right.display()
341            )
342        });
343    }
344
345    Ok(path_identity(left)? == path_identity(right)?)
346}
347
348fn path_identity(path: &Path) -> Result<PathBuf> {
349    let absolute = if path.is_absolute() {
350        path.to_path_buf()
351    } else {
352        std::env::current_dir()
353            .context("failed to resolve current directory for output validation")?
354            .join(path)
355    };
356    let normalized = normalize_lexically(&absolute);
357
358    let mut ancestor = normalized.as_path();
359    while !ancestor.exists() {
360        ancestor = ancestor
361            .parent()
362            .ok_or_else(|| anyhow::anyhow!("path has no existing ancestor: {}", path.display()))?;
363    }
364    let canonical_ancestor = fs::canonicalize(ancestor)
365        .with_context(|| format!("failed to resolve path ancestor {}", ancestor.display()))?;
366    let suffix = normalized.strip_prefix(ancestor).with_context(|| {
367        format!(
368            "failed to normalize output comparison path {}",
369            path.display()
370        )
371    })?;
372    Ok(canonical_ancestor.join(suffix))
373}
374
375fn normalize_lexically(path: &Path) -> PathBuf {
376    let mut normalized = PathBuf::new();
377    for component in path.components() {
378        match component {
379            Component::CurDir => {}
380            Component::ParentDir => {
381                normalized.pop();
382            }
383            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
384                normalized.push(component.as_os_str());
385            }
386        }
387    }
388    normalized
389}
390
391/// Accumulates human-readable lines for a single result document.
392///
393/// Handlers build their whole human rendering before emitting so that output
394/// file routing writes one complete document instead of interleaved fragments.
395#[derive(Debug, Default)]
396pub struct TextBuilder {
397    text: String,
398}
399
400impl TextBuilder {
401    /// Creates an empty builder.
402    #[must_use]
403    pub fn new() -> Self {
404        Self::default()
405    }
406
407    /// Appends one line.
408    pub fn line(&mut self, line: impl AsRef<str>) {
409        self.text.push_str(line.as_ref());
410        self.text.push('\n');
411    }
412
413    /// Appends an empty separator line.
414    pub fn blank(&mut self) {
415        self.text.push('\n');
416    }
417
418    /// Consumes the builder and returns the rendered text.
419    #[must_use]
420    pub fn finish(self) -> String {
421        self.text
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn json_mode_is_compact_and_pretty_mode_is_indented() {
431        let value = serde_json::json!({"archive": "a.zip", "exported": 2});
432        let dir = tempfile::tempdir().expect("tempdir");
433
434        let compact_path = dir.path().join("compact.json");
435        CommandOutput::new(OutputMode::Json, Some(compact_path.clone()))
436            .emit_json(&value)
437            .expect("emit compact");
438        let compact = fs::read_to_string(&compact_path).expect("read compact");
439        assert_eq!(compact.trim(), r#"{"archive":"a.zip","exported":2}"#);
440
441        let pretty_path = dir.path().join("pretty.json");
442        CommandOutput::new(OutputMode::Pretty, Some(pretty_path.clone()))
443            .emit_json(&value)
444            .expect("emit pretty");
445        let pretty = fs::read_to_string(&pretty_path).expect("read pretty");
446        assert!(
447            pretty.contains("\n  \"archive\""),
448            "pretty output: {pretty}"
449        );
450    }
451
452    #[test]
453    fn quiet_mode_writes_nothing() {
454        let dir = tempfile::tempdir().expect("tempdir");
455        let path = dir.path().join("quiet.json");
456        let output = CommandOutput::new(OutputMode::Quiet, Some(path.clone()));
457        output
458            .emit_json(&serde_json::json!({"a": 1}))
459            .expect("json");
460        output.emit_text("human text").expect("text");
461        output
462            .emit(&serde_json::json!({"a": 1}), || "human".to_string())
463            .expect("emit");
464        assert!(!path.exists(), "quiet mode must not create the output file");
465    }
466
467    #[test]
468    fn human_mode_uses_the_text_rendering() {
469        let dir = tempfile::tempdir().expect("tempdir");
470        let path = dir.path().join("human.txt");
471        CommandOutput::new(OutputMode::Human, Some(path.clone()))
472            .emit(&serde_json::json!({"a": 1}), || {
473                "archive: a.zip".to_string()
474            })
475            .expect("emit");
476        let text = fs::read_to_string(&path).expect("read human");
477        assert_eq!(text, "archive: a.zip\n");
478    }
479
480    #[test]
481    fn each_emit_replaces_the_previous_result() {
482        let dir = tempfile::tempdir().expect("tempdir");
483        let path = dir.path().join("result.txt");
484        let output = CommandOutput::new(OutputMode::Human, Some(path.clone()));
485        output.emit_text("first").expect("first");
486        output.emit_text("second").expect("second");
487        let text = fs::read_to_string(&path).expect("read result");
488        assert_eq!(text, "second\n");
489    }
490
491    #[test]
492    fn output_file_is_preserved_until_a_result_is_emitted() {
493        let dir = tempfile::tempdir().expect("tempdir");
494        let path = dir.path().join("result.txt");
495        fs::write(&path, "stale\n").expect("seed");
496        let output = CommandOutput::new(OutputMode::Human, Some(path.clone()));
497        assert_eq!(fs::read_to_string(&path).expect("read"), "stale\n");
498        output.emit_text("fresh").expect("emit");
499        assert_eq!(fs::read_to_string(&path).expect("read"), "fresh\n");
500    }
501
502    #[test]
503    fn alias_validation_detects_relative_and_hard_link_aliases() {
504        let dir = tempfile::tempdir().expect("tempdir");
505        let input = dir.path().join("archive.zip");
506        fs::write(&input, "archive").expect("seed archive");
507
508        let relative_alias = dir.path().join("nested").join("..").join("archive.zip");
509        CommandOutput::new(OutputMode::Json, Some(relative_alias))
510            .ensure_distinct_from(&input, "input archive")
511            .expect_err("relative alias must fail");
512
513        let hard_link = dir.path().join("archive-link.zip");
514        fs::hard_link(&input, &hard_link).expect("hard link");
515        CommandOutput::new(OutputMode::Json, Some(hard_link))
516            .ensure_distinct_from(&input, "input archive")
517            .expect_err("hard-link alias must fail");
518    }
519
520    #[test]
521    fn progress_is_only_allowed_for_human_output() {
522        assert!(CommandOutput::human().allows_progress());
523        assert!(!CommandOutput::json().allows_progress());
524        assert!(!CommandOutput::new(OutputMode::Pretty, None).allows_progress());
525        assert!(!CommandOutput::new(OutputMode::Quiet, None).allows_progress());
526    }
527
528    #[test]
529    fn missing_output_directory_reports_the_path() {
530        let output = CommandOutput::new(
531            OutputMode::Json,
532            Some(PathBuf::from("/nonexistent-anyback-dir/out.json")),
533        );
534        let err = output
535            .emit_json(&serde_json::json!({"a": 1}))
536            .expect_err("write must fail");
537        assert!(
538            err.to_string()
539                .contains("/nonexistent-anyback-dir/out.json"),
540            "error should name the path: {err}"
541        );
542    }
543}