Skip to main content

shiplog_bundle/
layout.rs

1//! Canonical output layout contracts for shiplog run artifacts.
2
3use std::path::{Path, PathBuf};
4
5/// Canonical artifact filenames emitted by the shiplog pipeline.
6pub const FILE_PACKET_MD: &str = "packet.md";
7pub const FILE_LEDGER_EVENTS_JSONL: &str = "ledger.events.jsonl";
8pub const FILE_COVERAGE_MANIFEST_JSON: &str = "coverage.manifest.json";
9pub const FILE_BUNDLE_MANIFEST_JSON: &str = "bundle.manifest.json";
10pub const FILE_REDACTION_ALIASES_JSON: &str = "redaction.aliases.json";
11
12/// Canonical directory names used by profile-based outputs.
13pub const DIR_PROFILES: &str = "profiles";
14pub const PROFILE_INTERNAL: &str = "internal";
15pub const PROFILE_MANAGER: &str = "manager";
16pub const PROFILE_PUBLIC: &str = "public";
17
18/// Paths for a complete shiplog run output directory.
19///
20/// `out_dir` is `pub(crate)` rather than `pub` because the
21/// bundle-paths protected seam (`cpf-0002` in
22/// [`policy/clippy-protected-fields.toml`](../../../../policy/clippy-protected-fields.toml))
23/// guards against producer-local paths leaking into manifests.
24/// External callers reach the run-directory components via the
25/// accessor methods on this type (`packet_md`, `ledger_events`,
26/// `coverage_manifest`, `bundle_manifest`, `redaction_aliases`,
27/// `profile_packet`). The post-#206 audit confirmed zero external
28/// callers read this field directly, so tightening visibility is a
29/// no-behavior-change refactor.
30#[derive(Debug, Clone)]
31pub struct RunArtifactPaths {
32    pub(crate) out_dir: PathBuf,
33}
34
35impl RunArtifactPaths {
36    /// Construct a path helper for a given run output directory.
37    pub fn new(out_dir: impl Into<PathBuf>) -> Self {
38        Self {
39            out_dir: out_dir.into(),
40        }
41    }
42
43    /// `packet.md`
44    pub fn packet_md(&self) -> PathBuf {
45        self.out_dir.join(FILE_PACKET_MD)
46    }
47
48    /// `ledger.events.jsonl`
49    pub fn ledger_events(&self) -> PathBuf {
50        self.out_dir.join(FILE_LEDGER_EVENTS_JSONL)
51    }
52
53    /// `coverage.manifest.json`
54    pub fn coverage_manifest(&self) -> PathBuf {
55        self.out_dir.join(FILE_COVERAGE_MANIFEST_JSON)
56    }
57
58    /// `bundle.manifest.json`
59    pub fn bundle_manifest(&self) -> PathBuf {
60        self.out_dir.join(FILE_BUNDLE_MANIFEST_JSON)
61    }
62
63    /// `profiles/<profile>/packet.md`
64    pub fn profile_packet(&self, profile: impl AsRef<str>) -> PathBuf {
65        self.out_dir
66            .join(DIR_PROFILES)
67            .join(profile.as_ref())
68            .join(FILE_PACKET_MD)
69    }
70}
71
72/// Compute the zip file path for a run profile.
73/// - `"internal"` -> `<run_dir>.zip`
74/// - any other value -> `<run_dir>.<profile>.zip`
75pub fn zip_path_for_profile(out_dir: &Path, profile: &str) -> PathBuf {
76    if profile == PROFILE_INTERNAL {
77        return out_dir.with_extension("zip");
78    }
79
80    let stem = out_dir.file_name().unwrap_or_default().to_string_lossy();
81    out_dir.with_file_name(format!("{}.{}.zip", stem, profile))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn artifact_paths_are_stable() {
90        let paths = RunArtifactPaths::new("/tmp/run_01");
91        assert_eq!(paths.packet_md(), PathBuf::from("/tmp/run_01/packet.md"));
92        assert_eq!(
93            paths.ledger_events(),
94            PathBuf::from("/tmp/run_01/ledger.events.jsonl")
95        );
96        assert_eq!(
97            paths.coverage_manifest(),
98            PathBuf::from("/tmp/run_01/coverage.manifest.json")
99        );
100        assert_eq!(
101            paths.bundle_manifest(),
102            PathBuf::from("/tmp/run_01/bundle.manifest.json")
103        );
104        assert_eq!(
105            paths.profile_packet(PROFILE_MANAGER),
106            PathBuf::from("/tmp/run_01/profiles/manager/packet.md")
107        );
108    }
109
110    #[test]
111    fn artifact_zip_path_depends_on_profile() {
112        let internal = zip_path_for_profile(Path::new("/tmp/run_01"), PROFILE_INTERNAL);
113        let manager = zip_path_for_profile(Path::new("/tmp/run_01"), PROFILE_MANAGER);
114        assert_eq!(internal, Path::new("/tmp/run_01.zip"));
115        assert_eq!(manager, Path::new("/tmp/run_01.manager.zip"));
116    }
117
118    // --- Constant value tests ---
119
120    #[test]
121    fn file_constants_have_expected_values() {
122        assert_eq!(FILE_PACKET_MD, "packet.md");
123        assert_eq!(FILE_LEDGER_EVENTS_JSONL, "ledger.events.jsonl");
124        assert_eq!(FILE_COVERAGE_MANIFEST_JSON, "coverage.manifest.json");
125        assert_eq!(FILE_BUNDLE_MANIFEST_JSON, "bundle.manifest.json");
126        assert_eq!(FILE_REDACTION_ALIASES_JSON, "redaction.aliases.json");
127    }
128
129    #[test]
130    fn profile_constants_have_expected_values() {
131        assert_eq!(DIR_PROFILES, "profiles");
132        assert_eq!(PROFILE_INTERNAL, "internal");
133        assert_eq!(PROFILE_MANAGER, "manager");
134        assert_eq!(PROFILE_PUBLIC, "public");
135    }
136
137    // --- RunArtifactPaths edge cases ---
138
139    #[test]
140    fn paths_from_string() {
141        let paths = RunArtifactPaths::new(String::from("/data/output"));
142        assert_eq!(paths.out_dir, PathBuf::from("/data/output"));
143    }
144
145    #[test]
146    fn paths_from_pathbuf() {
147        let pb = PathBuf::from("/data/output");
148        let paths = RunArtifactPaths::new(pb.clone());
149        assert_eq!(paths.out_dir, pb);
150    }
151
152    #[test]
153    fn paths_relative_dir() {
154        let paths = RunArtifactPaths::new("out/run_01");
155        assert_eq!(paths.packet_md(), PathBuf::from("out/run_01/packet.md"));
156        assert_eq!(
157            paths.ledger_events(),
158            PathBuf::from("out/run_01/ledger.events.jsonl")
159        );
160    }
161
162    #[test]
163    fn paths_current_dir() {
164        let paths = RunArtifactPaths::new(".");
165        assert_eq!(paths.packet_md(), PathBuf::from("./packet.md"));
166    }
167
168    #[test]
169    fn profile_packet_internal() {
170        let paths = RunArtifactPaths::new("/run");
171        assert_eq!(
172            paths.profile_packet(PROFILE_INTERNAL),
173            PathBuf::from("/run/profiles/internal/packet.md")
174        );
175    }
176
177    #[test]
178    fn profile_packet_public() {
179        let paths = RunArtifactPaths::new("/run");
180        assert_eq!(
181            paths.profile_packet(PROFILE_PUBLIC),
182            PathBuf::from("/run/profiles/public/packet.md")
183        );
184    }
185
186    #[test]
187    fn profile_packet_custom_profile() {
188        let paths = RunArtifactPaths::new("/run");
189        assert_eq!(
190            paths.profile_packet("custom"),
191            PathBuf::from("/run/profiles/custom/packet.md")
192        );
193    }
194
195    #[test]
196    fn all_paths_share_out_dir_prefix() {
197        let paths = RunArtifactPaths::new("/base");
198        let base = PathBuf::from("/base");
199        assert!(paths.packet_md().starts_with(&base));
200        assert!(paths.ledger_events().starts_with(&base));
201        assert!(paths.coverage_manifest().starts_with(&base));
202        assert!(paths.bundle_manifest().starts_with(&base));
203        assert!(paths.profile_packet("any").starts_with(&base));
204    }
205
206    #[test]
207    fn clone_produces_equal_paths() {
208        let paths = RunArtifactPaths::new("/run");
209        let cloned = paths.clone();
210        assert_eq!(paths.packet_md(), cloned.packet_md());
211        assert_eq!(paths.ledger_events(), cloned.ledger_events());
212    }
213
214    #[test]
215    fn debug_impl_not_empty() {
216        let paths = RunArtifactPaths::new("/run");
217        let debug = format!("{:?}", paths);
218        assert!(!debug.is_empty());
219        assert!(debug.contains("RunArtifactPaths"));
220    }
221
222    // --- zip_path_for_profile edge cases ---
223
224    #[test]
225    fn zip_path_public_profile() {
226        let p = zip_path_for_profile(Path::new("/tmp/run_01"), PROFILE_PUBLIC);
227        assert_eq!(p, Path::new("/tmp/run_01.public.zip"));
228    }
229
230    #[test]
231    fn zip_path_custom_profile() {
232        let p = zip_path_for_profile(Path::new("/out/my_run"), "custom");
233        assert_eq!(p, Path::new("/out/my_run.custom.zip"));
234    }
235
236    #[test]
237    fn zip_path_internal_always_just_zip() {
238        let p = zip_path_for_profile(Path::new("/a/b/c"), PROFILE_INTERNAL);
239        assert_eq!(p, Path::new("/a/b/c.zip"));
240    }
241
242    #[test]
243    fn zip_path_relative_dir() {
244        let p = zip_path_for_profile(Path::new("out/run"), PROFILE_MANAGER);
245        assert_eq!(p, Path::new("out/run.manager.zip"));
246    }
247
248    #[test]
249    fn zip_path_relative_internal() {
250        let p = zip_path_for_profile(Path::new("out/run"), PROFILE_INTERNAL);
251        assert_eq!(p, Path::new("out/run.zip"));
252    }
253
254    #[test]
255    fn zip_path_profiles_are_distinct() {
256        let base = Path::new("/run");
257        let internal = zip_path_for_profile(base, PROFILE_INTERNAL);
258        let manager = zip_path_for_profile(base, PROFILE_MANAGER);
259        let public = zip_path_for_profile(base, PROFILE_PUBLIC);
260        // All three are distinct
261        assert_ne!(internal, manager);
262        assert_ne!(internal, public);
263        assert_ne!(manager, public);
264    }
265
266    #[test]
267    fn zip_path_all_end_with_zip() {
268        let base = Path::new("/run");
269        for profile in &[PROFILE_INTERNAL, PROFILE_MANAGER, PROFILE_PUBLIC, "custom"] {
270            let p = zip_path_for_profile(base, profile);
271            assert!(
272                p.to_string_lossy().ends_with(".zip"),
273                "expected .zip suffix for profile '{}', got {:?}",
274                profile,
275                p
276            );
277        }
278    }
279
280    // --- Property tests ---
281
282    mod prop {
283        use super::*;
284        use proptest::prelude::*;
285
286        fn arb_dir() -> impl Strategy<Value = String> {
287            // Generate well-formed directory paths avoiding edge cases
288            // like bare "/" or "//" which produce UNC paths on Windows
289            proptest::string::string_regex("[a-z]{1,5}(/[a-z]{1,5}){0,3}").unwrap()
290        }
291
292        proptest! {
293            #[test]
294            fn packet_md_always_ends_with_filename(dir in arb_dir()) {
295                let paths = RunArtifactPaths::new(&dir);
296                let p = paths.packet_md();
297                prop_assert!(p.ends_with(FILE_PACKET_MD));
298            }
299
300            #[test]
301            fn ledger_events_always_ends_with_filename(dir in arb_dir()) {
302                let paths = RunArtifactPaths::new(&dir);
303                let p = paths.ledger_events();
304                prop_assert!(p.ends_with(FILE_LEDGER_EVENTS_JSONL));
305            }
306
307            #[test]
308            fn coverage_manifest_always_ends_with_filename(dir in arb_dir()) {
309                let paths = RunArtifactPaths::new(&dir);
310                let p = paths.coverage_manifest();
311                prop_assert!(p.ends_with(FILE_COVERAGE_MANIFEST_JSON));
312            }
313
314            #[test]
315            fn bundle_manifest_always_ends_with_filename(dir in arb_dir()) {
316                let paths = RunArtifactPaths::new(&dir);
317                let p = paths.bundle_manifest();
318                prop_assert!(p.ends_with(FILE_BUNDLE_MANIFEST_JSON));
319            }
320
321            #[test]
322            fn profile_packet_always_contains_profiles_dir(
323                dir in arb_dir(),
324                profile in "[a-z]{1,10}",
325            ) {
326                let paths = RunArtifactPaths::new(&dir);
327                let p = paths.profile_packet(&profile);
328                let p_str = p.to_string_lossy();
329                prop_assert!(p_str.contains(DIR_PROFILES));
330                prop_assert!(p_str.contains(&profile));
331                prop_assert!(p.ends_with(FILE_PACKET_MD));
332            }
333
334            #[test]
335            fn zip_path_always_ends_with_zip(
336                dir in arb_dir(),
337                profile in "[a-z]{1,10}",
338            ) {
339                let p = zip_path_for_profile(Path::new(&dir), &profile);
340                prop_assert!(
341                    p.to_string_lossy().ends_with(".zip"),
342                    "expected .zip suffix, got {:?}", p
343                );
344            }
345
346            #[test]
347            fn zip_path_internal_never_contains_profile_name(dir in arb_dir()) {
348                let p = zip_path_for_profile(Path::new(&dir), PROFILE_INTERNAL);
349                let name = p.file_name().unwrap().to_string_lossy();
350                // For internal, the filename should NOT contain "internal"
351                prop_assert!(!name.contains("internal"));
352            }
353
354            #[test]
355            fn zip_path_non_internal_contains_profile_name(
356                dir in arb_dir(),
357                profile in "[a-z]{1,10}",
358            ) {
359                prop_assume!(profile != PROFILE_INTERNAL);
360                let p = zip_path_for_profile(Path::new(&dir), &profile);
361                let name = p.file_name().unwrap().to_string_lossy();
362                prop_assert!(
363                    name.contains(&profile),
364                    "expected profile '{}' in filename '{}'", profile, name
365                );
366            }
367
368            #[test]
369            fn all_artifact_paths_start_with_out_dir(dir in arb_dir()) {
370                let paths = RunArtifactPaths::new(&dir);
371                let base = PathBuf::from(&dir);
372                prop_assert!(paths.packet_md().starts_with(&base));
373                prop_assert!(paths.ledger_events().starts_with(&base));
374                prop_assert!(paths.coverage_manifest().starts_with(&base));
375                prop_assert!(paths.bundle_manifest().starts_with(&base));
376            }
377        }
378    }
379}