1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum OutputMode {
44 #[default]
46 Json,
47 Pretty,
49 Human,
51 Quiet,
53}
54
55impl OutputMode {
56 #[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 #[must_use]
69 pub const fn is_json(self) -> bool {
70 matches!(self, Self::Json | Self::Pretty)
71 }
72}
73
74#[derive(Clone, Debug, Default)]
80pub struct CommandOutput {
81 mode: OutputMode,
82 path: Option<PathBuf>,
83}
84
85impl CommandOutput {
86 #[must_use]
89 pub fn new(mode: OutputMode, path: Option<PathBuf>) -> Self {
90 Self { mode, path }
91 }
92
93 #[must_use]
95 pub fn json() -> Self {
96 Self::new(OutputMode::Json, None)
97 }
98
99 #[must_use]
101 pub fn human() -> Self {
102 Self::new(OutputMode::Human, None)
103 }
104
105 #[must_use]
107 pub const fn mode(&self) -> OutputMode {
108 self.mode
109 }
110
111 #[must_use]
113 pub fn path(&self) -> Option<&Path> {
114 self.path.as_deref()
115 }
116
117 #[must_use]
119 pub const fn is_quiet(&self) -> bool {
120 matches!(self.mode, OutputMode::Quiet)
121 }
122
123 #[must_use]
125 pub const fn is_json(&self) -> bool {
126 self.mode.is_json()
127 }
128
129 #[must_use]
133 pub const fn allows_progress(&self) -> bool {
134 matches!(self.mode, OutputMode::Human)
135 }
136
137 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 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 pub fn emit_text(&self, text: &str) -> Result<()> {
176 if self.is_quiet() {
177 return Ok(());
178 }
179 self.write(text)
180 }
181
182 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#[derive(Debug, Default)]
396pub struct TextBuilder {
397 text: String,
398}
399
400impl TextBuilder {
401 #[must_use]
403 pub fn new() -> Self {
404 Self::default()
405 }
406
407 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 pub fn blank(&mut self) {
415 self.text.push('\n');
416 }
417
418 #[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}