1use std::path::{Path, PathBuf};
4
5pub 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
12pub 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#[derive(Debug, Clone)]
31pub struct RunArtifactPaths {
32 pub(crate) out_dir: PathBuf,
33}
34
35impl RunArtifactPaths {
36 pub fn new(out_dir: impl Into<PathBuf>) -> Self {
38 Self {
39 out_dir: out_dir.into(),
40 }
41 }
42
43 pub fn packet_md(&self) -> PathBuf {
45 self.out_dir.join(FILE_PACKET_MD)
46 }
47
48 pub fn ledger_events(&self) -> PathBuf {
50 self.out_dir.join(FILE_LEDGER_EVENTS_JSONL)
51 }
52
53 pub fn coverage_manifest(&self) -> PathBuf {
55 self.out_dir.join(FILE_COVERAGE_MANIFEST_JSON)
56 }
57
58 pub fn bundle_manifest(&self) -> PathBuf {
60 self.out_dir.join(FILE_BUNDLE_MANIFEST_JSON)
61 }
62
63 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
72pub 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 #[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 #[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 #[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 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 mod prop {
283 use super::*;
284 use proptest::prelude::*;
285
286 fn arb_dir() -> impl Strategy<Value = String> {
287 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 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}