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}