Skip to main content

standout_dispatch/
artifact.rs

1//! Compound artifacts: owned bytes plus an application-owned semantic report.
2//!
3//! # Why this exists
4//!
5//! A command that produces a file — an export, a bundle, a database dump —
6//! has to keep four things apart:
7//!
8//! 1. the artifact bytes, which must survive byte-for-byte;
9//! 2. metadata about where the bytes would like to land;
10//! 3. application-owned facts about the operation (counts, warnings,
11//!    partial-success notes);
12//! 4. the success report, which can only be truthful once the concrete
13//!    destination is known.
14//!
15//! [`Output::Binary`](crate::Output::Binary) carries (1) and a filename hint,
16//! but has nowhere to put (3) and renders nothing after the write. [`Artifact`]
17//! carries all four: the application produces exact bytes and domain facts, and
18//! the framework selects the destination, performs the final write, and only
19//! then renders the report enriched with an [`ArtifactReceipt`] naming the
20//! destination that actually completed.
21//!
22//! # Ownership boundary
23//!
24//! | Concern | Owner |
25//! |---------|-------|
26//! | Artifact bytes | Application |
27//! | Suggested destination | Application (a *suggestion*) |
28//! | Semantic report / warning taxonomy | Application |
29//! | Destination selection | Framework |
30//! | The final write and its failure | Framework |
31//! | Receipt (completed destination) | Framework |
32//!
33//! # Destination policy
34//!
35//! The framework selects the destination deterministically, in this order:
36//!
37//! 1. an explicit output-file override (`--output-file-path`, when the app
38//!    enabled that flag);
39//! 2. the artifact's suggested destination, if the application opted in with
40//!    [`Artifact::suggest_destination`];
41//! 3. stdout, if the application opted in with [`Artifact::allow_stdout`].
42//!
43//! If none applies the run fails with
44//! [`RunErrorKind::FinalWrite(OutputKind::Artifact)`](crate::RunErrorKind::FinalWrite)
45//! rather than inventing a file or silently discarding the bytes. Every step
46//! shares that single framework-owned failure path.
47//!
48//! Note that a suggested destination on `Output::Binary` does *not* authorize a
49//! write: only [`Artifact::suggest_destination`] does. The opt-in is the
50//! difference between the two shapes.
51//!
52//! # Report channel
53//!
54//! Mixing the report into the artifact bytes would corrupt them, so the channel
55//! follows the destination:
56//!
57//! | Artifact destination | Report goes to |
58//! |----------------------|----------------|
59//! | File | stdout |
60//! | Stdout | stderr (the diagnostic channel) |
61//!
62//! # Example
63//!
64//! ```rust
65//! use serde::Serialize;
66//! use standout_dispatch::{Artifact, HandlerResult, Output};
67//!
68//! #[derive(Serialize)]
69//! struct ExportReport {
70//!     entries: usize,
71//!     warnings: Vec<String>,
72//! }
73//!
74//! fn export() -> HandlerResult<ExportReport> {
75//!     let bytes = b"id,title\n1,buy milk\n".to_vec();
76//!     Ok(Output::Artifact(
77//!         Artifact::new(bytes)
78//!             .suggest_destination("export.csv")
79//!             .with_report(ExportReport {
80//!                 entries: 1,
81//!                 warnings: vec!["1 entry had no due date".into()],
82//!             }),
83//!     ))
84//! }
85//! ```
86
87use std::path::{Path, PathBuf};
88
89use serde::ser::SerializeStruct;
90use serde::{Serialize, Serializer};
91
92/// The label used for the stdout destination in serialized receipts.
93const STDOUT_LABEL: &str = "-";
94
95/// A destination the framework can complete an artifact write to.
96///
97/// Produced by the framework's destination policy; applications only ever
98/// *suggest* a destination (see [`Artifact::suggest_destination`]).
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ArtifactDestination {
101    /// The bytes go to standard output.
102    Stdout,
103    /// The bytes go to this file.
104    File(PathBuf),
105}
106
107impl ArtifactDestination {
108    /// Returns the file path, or `None` for stdout.
109    pub fn path(&self) -> Option<&Path> {
110        match self {
111            ArtifactDestination::Stdout => None,
112            ArtifactDestination::File(path) => Some(path),
113        }
114    }
115
116    /// Returns true if this destination is standard output.
117    pub fn is_stdout(&self) -> bool {
118        matches!(self, ArtifactDestination::Stdout)
119    }
120
121    /// Returns the display label: the path, or `-` for stdout.
122    ///
123    /// This is the value templates see as `{{ receipt.destination }}`.
124    pub fn label(&self) -> String {
125        match self {
126            ArtifactDestination::Stdout => STDOUT_LABEL.to_string(),
127            ArtifactDestination::File(path) => path.display().to_string(),
128        }
129    }
130}
131
132impl Serialize for ArtifactDestination {
133    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
134        serializer.serialize_str(&self.label())
135    }
136}
137
138/// The framework's record of a completed artifact write.
139///
140/// A receipt only exists once the write succeeded, so its destination is a
141/// fact, not an intention. It is serialized into the report envelope under
142/// `receipt` as:
143///
144/// ```json
145/// { "destination": "/tmp/export.csv", "stdout": false, "byte_count": 20 }
146/// ```
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ArtifactReceipt {
149    destination: ArtifactDestination,
150    byte_count: usize,
151}
152
153impl ArtifactReceipt {
154    /// Records a completed write of `byte_count` bytes to `destination`.
155    ///
156    /// Framework-owned: applications receive receipts, they do not mint them.
157    pub fn new(destination: ArtifactDestination, byte_count: usize) -> Self {
158        Self {
159            destination,
160            byte_count,
161        }
162    }
163
164    /// Returns the destination the write completed to.
165    pub fn destination(&self) -> &ArtifactDestination {
166        &self.destination
167    }
168
169    /// Returns the written file's path, or `None` if the bytes went to stdout.
170    pub fn path(&self) -> Option<&Path> {
171        self.destination.path()
172    }
173
174    /// Returns true if the bytes went to standard output.
175    pub fn is_stdout(&self) -> bool {
176        self.destination.is_stdout()
177    }
178
179    /// Returns the number of artifact bytes the write covered.
180    pub fn byte_count(&self) -> usize {
181        self.byte_count
182    }
183}
184
185impl Serialize for ArtifactReceipt {
186    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
187        let mut state = serializer.serialize_struct("ArtifactReceipt", 3)?;
188        state.serialize_field("destination", &self.destination)?;
189        state.serialize_field("stdout", &self.destination.is_stdout())?;
190        state.serialize_field("byte_count", &self.byte_count)?;
191        state.end()
192    }
193}
194
195/// Owned artifact bytes with an optional destination suggestion and report.
196///
197/// Returned from a handler as [`Output::Artifact`](crate::Output::Artifact).
198/// See the [module docs](self) for the destination policy and report channel.
199///
200/// Bytes are owned: streaming is deliberately not part of this contract.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct Artifact<T> {
203    bytes: Vec<u8>,
204    suggested_destination: Option<PathBuf>,
205    stdout_fallback: bool,
206    report: Option<T>,
207}
208
209impl<T> Artifact<T> {
210    /// Creates an artifact from owned bytes, with no destination suggestion,
211    /// no stdout fallback, and no report.
212    ///
213    /// Such an artifact is only writable under an explicit output-file
214    /// override; without one the run fails rather than guessing a destination.
215    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
216        Self {
217            bytes: bytes.into(),
218            suggested_destination: None,
219            stdout_fallback: false,
220            report: None,
221        }
222    }
223
224    /// Suggests where the bytes should land, and authorizes the framework to
225    /// write there when no explicit override is given.
226    ///
227    /// This opt-in is what separates an artifact from `Output::Binary`: a
228    /// filename on binary output is a hint to the caller, never a license to
229    /// touch the filesystem.
230    pub fn suggest_destination(mut self, destination: impl Into<PathBuf>) -> Self {
231        self.suggested_destination = Some(destination.into());
232        self
233    }
234
235    /// Authorizes stdout as the last-resort destination.
236    ///
237    /// Without this, an artifact with no override and no suggestion is a typed
238    /// failure. With it, the report is routed to stderr so it can never
239    /// contaminate the bytes.
240    pub fn allow_stdout(mut self) -> Self {
241        self.stdout_fallback = true;
242        self
243    }
244
245    /// Attaches the application-owned semantic report rendered after the write.
246    ///
247    /// The report is the application's own type: counts, warnings, partial
248    /// success — whatever taxonomy it owns. Standout only transports it.
249    pub fn with_report(mut self, report: T) -> Self {
250        self.report = Some(report);
251        self
252    }
253
254    /// Returns the artifact bytes.
255    pub fn bytes(&self) -> &[u8] {
256        &self.bytes
257    }
258
259    /// Returns the suggested destination, if the application opted in.
260    pub fn suggested_destination(&self) -> Option<&Path> {
261        self.suggested_destination.as_deref()
262    }
263
264    /// Returns true if the application authorized the stdout fallback.
265    pub fn stdout_allowed(&self) -> bool {
266        self.stdout_fallback
267    }
268
269    /// Returns the semantic report, if any.
270    pub fn report(&self) -> Option<&T> {
271        self.report.as_ref()
272    }
273
274    /// Splits the artifact into its bytes, suggestion, stdout opt-in, and report.
275    pub fn into_parts(self) -> (Vec<u8>, Option<PathBuf>, bool, Option<T>) {
276        (
277            self.bytes,
278            self.suggested_destination,
279            self.stdout_fallback,
280            self.report,
281        )
282    }
283}
284
285/// A completed artifact run: the framework's outcome for [`Output::Artifact`](crate::Output::Artifact).
286///
287/// Carried by [`RunResult::Artifact`](crate::RunResult::Artifact). The receipt
288/// is authoritative for a file destination — the bytes are already on disk. For
289/// the stdout destination the byte write is deferred to the framework's stdout
290/// writer, which is why `run()` can still report a typed stdout write failure.
291#[derive(Debug, Clone)]
292pub struct ArtifactRun {
293    bytes: Vec<u8>,
294    suggested_destination: Option<PathBuf>,
295    receipt: ArtifactReceipt,
296    report: Option<String>,
297}
298
299impl ArtifactRun {
300    /// Records a completed artifact run.
301    ///
302    /// Framework-owned: `report` is the already-rendered (or serialized)
303    /// success report, which the framework only produces after the write.
304    pub fn new(
305        bytes: Vec<u8>,
306        suggested_destination: Option<PathBuf>,
307        receipt: ArtifactReceipt,
308        report: Option<String>,
309    ) -> Self {
310        Self {
311            bytes,
312            suggested_destination,
313            receipt,
314            report,
315        }
316    }
317
318    /// Returns the artifact bytes, byte-for-byte as the handler produced them.
319    pub fn bytes(&self) -> &[u8] {
320        &self.bytes
321    }
322
323    /// Returns what the application suggested, which may differ from where the
324    /// bytes actually went.
325    pub fn suggested_destination(&self) -> Option<&Path> {
326        self.suggested_destination.as_deref()
327    }
328
329    /// Returns the framework's receipt for the write.
330    pub fn receipt(&self) -> &ArtifactReceipt {
331        &self.receipt
332    }
333
334    /// Returns the destination the framework selected and completed.
335    pub fn destination(&self) -> &ArtifactDestination {
336        self.receipt.destination()
337    }
338
339    /// Returns the rendered success report, or `None` if the artifact carried
340    /// no report.
341    pub fn report(&self) -> Option<&str> {
342        self.report.as_deref()
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use serde::Serialize;
350
351    #[derive(Serialize, Debug, PartialEq, Eq, Clone)]
352    struct Report {
353        entries: usize,
354    }
355
356    #[test]
357    fn artifact_defaults_to_no_destination_authorization() {
358        let artifact = Artifact::<Report>::new(vec![1, 2, 3]);
359        assert_eq!(artifact.bytes(), &[1, 2, 3]);
360        assert_eq!(artifact.suggested_destination(), None);
361        assert!(!artifact.stdout_allowed());
362        assert!(artifact.report().is_none());
363    }
364
365    #[test]
366    fn artifact_builder_records_every_opt_in() {
367        let artifact = Artifact::new(b"data".to_vec())
368            .suggest_destination("out.bin")
369            .allow_stdout()
370            .with_report(Report { entries: 2 });
371
372        assert_eq!(artifact.bytes(), b"data");
373        assert_eq!(artifact.suggested_destination(), Some(Path::new("out.bin")));
374        assert!(artifact.stdout_allowed());
375        assert_eq!(artifact.report(), Some(&Report { entries: 2 }));
376    }
377
378    #[test]
379    fn artifact_into_parts_round_trips() {
380        let artifact = Artifact::new(vec![7u8])
381            .suggest_destination("a.bin")
382            .with_report(Report { entries: 1 });
383        let (bytes, suggested, stdout, report) = artifact.into_parts();
384        assert_eq!(bytes, vec![7u8]);
385        assert_eq!(suggested, Some(PathBuf::from("a.bin")));
386        assert!(!stdout);
387        assert_eq!(report, Some(Report { entries: 1 }));
388    }
389
390    #[test]
391    fn file_destination_exposes_path() {
392        let dest = ArtifactDestination::File(PathBuf::from("/tmp/x.zip"));
393        assert_eq!(dest.path(), Some(Path::new("/tmp/x.zip")));
394        assert!(!dest.is_stdout());
395        assert_eq!(dest.label(), "/tmp/x.zip");
396    }
397
398    #[test]
399    fn stdout_destination_has_no_path_and_dash_label() {
400        let dest = ArtifactDestination::Stdout;
401        assert_eq!(dest.path(), None);
402        assert!(dest.is_stdout());
403        assert_eq!(dest.label(), "-");
404    }
405
406    #[test]
407    fn file_receipt_serializes_destination_and_count() {
408        let receipt = ArtifactReceipt::new(ArtifactDestination::File(PathBuf::from("/tmp/x")), 12);
409        let value = serde_json::to_value(&receipt).unwrap();
410        assert_eq!(
411            value,
412            serde_json::json!({"destination": "/tmp/x", "stdout": false, "byte_count": 12})
413        );
414        assert_eq!(receipt.path(), Some(Path::new("/tmp/x")));
415        assert_eq!(receipt.byte_count(), 12);
416        assert!(!receipt.is_stdout());
417    }
418
419    #[test]
420    fn stdout_receipt_serializes_dash_and_flag() {
421        let receipt = ArtifactReceipt::new(ArtifactDestination::Stdout, 3);
422        let value = serde_json::to_value(&receipt).unwrap();
423        assert_eq!(
424            value,
425            serde_json::json!({"destination": "-", "stdout": true, "byte_count": 3})
426        );
427        assert!(receipt.is_stdout());
428    }
429
430    #[test]
431    fn artifact_run_exposes_bytes_suggestion_receipt_and_report() {
432        let run = ArtifactRun::new(
433            vec![1, 2],
434            Some(PathBuf::from("s.bin")),
435            ArtifactReceipt::new(ArtifactDestination::File(PathBuf::from("o.bin")), 2),
436            Some("wrote 2 bytes".to_string()),
437        );
438
439        assert_eq!(run.bytes(), &[1, 2]);
440        assert_eq!(run.suggested_destination(), Some(Path::new("s.bin")));
441        assert_eq!(
442            run.destination(),
443            &ArtifactDestination::File(PathBuf::from("o.bin"))
444        );
445        assert_eq!(run.receipt().byte_count(), 2);
446        assert_eq!(run.report(), Some("wrote 2 bytes"));
447    }
448
449    #[test]
450    fn artifact_run_without_report_is_none() {
451        let run = ArtifactRun::new(
452            vec![],
453            None,
454            ArtifactReceipt::new(ArtifactDestination::Stdout, 0),
455            None,
456        );
457        assert_eq!(run.report(), None);
458        assert_eq!(run.suggested_destination(), None);
459    }
460}