Skip to main content

bijux_dag_artifacts/
lib.rs

1//! Artifact identity, persistence, integrity, and lifecycle helpers for Bijux DAG.
2//!
3//! Prefer [`stable`] when browsing the long-lived artifact surface,
4//! [`prelude`] for the common read/write workflow, and crate-root imports only
5//! when you already know the exact item you need. Broad compatibility
6//! re-exports remain callable for focused imports, but they are intentionally
7//! hidden from the default docs surface. The `experimental-public-api` feature
8//! exposes opt-in contract helpers that are excluded from the default docs
9//! surface.
10//!
11#![allow(
12    clippy::if_not_else,
13    clippy::missing_errors_doc,
14    clippy::must_use_candidate,
15    clippy::pedantic
16)]
17#[doc(hidden)]
18#[path = "io/fs.rs"]
19pub mod fs;
20#[doc(hidden)]
21#[path = "storage/hardening.rs"]
22pub mod hardening;
23#[doc(hidden)]
24#[path = "integrity/hash.rs"]
25pub mod hash;
26#[doc(hidden)]
27#[path = "integrity/index.rs"]
28pub mod index;
29#[cfg(feature = "experimental-public-api")]
30#[path = "integrity/lifecycle_and_cache_contracts.rs"]
31mod lifecycle_and_cache_contracts;
32#[doc(hidden)]
33#[path = "lifecycle/lineage.rs"]
34pub mod lineage;
35#[doc(hidden)]
36#[path = "storage/models.rs"]
37pub mod models;
38#[doc(hidden)]
39#[path = "layout/paths.rs"]
40pub mod paths;
41#[doc(hidden)]
42#[path = "layout/platform.rs"]
43pub mod platform;
44#[doc(hidden)]
45#[path = "lifecycle/promotion.rs"]
46pub mod promotion;
47#[doc(hidden)]
48#[path = "integrity/proof.rs"]
49pub mod proof;
50#[doc(hidden)]
51#[path = "lifecycle/retention.rs"]
52pub mod retention;
53#[cfg(feature = "experimental-public-api")]
54#[path = "integrity/run_layout_contracts.rs"]
55mod run_layout_contracts;
56#[doc(hidden)]
57#[path = "integrity/schema.rs"]
58pub mod schema;
59#[doc(hidden)]
60#[path = "storage/services.rs"]
61pub mod services;
62#[doc(hidden)]
63#[path = "io/store.rs"]
64pub mod store;
65
66#[doc(hidden)]
67pub use hardening::{
68    build_cleanup_plan, finalize_run_manifest, finalize_run_manifest_with_mode, verify_run_dir,
69    write_incomplete_run_marker, write_json_atomic_durable, ArtifactCleanupPlan, RunDirAuditReport,
70    RunFinalizationMode, VerificationMode,
71};
72#[doc(hidden)]
73pub use hash::sha256_hex;
74#[doc(hidden)]
75pub use index::{
76    dedup_metrics_for_hashes, normalize_metadata_pairs, ArtifactId, ArtifactPackManifest,
77};
78#[doc(hidden)]
79pub use lineage::{write_lineage_snapshot, ArtifactLineageEdge, ArtifactLineageSnapshot};
80#[doc(hidden)]
81pub use models::*;
82#[doc(hidden)]
83pub use paths::is_normalized_relative_path;
84#[doc(hidden)]
85pub use platform::{
86    compact_lineage, explain_lineage_safe_gc, lineage_dependencies, lineage_dependents,
87};
88#[doc(hidden)]
89pub use promotion::{
90    append_promotion_record, append_promotion_summary, build_promoted_output_summary,
91    promotion_record_path, ArtifactPromotionIndex, ArtifactPromotionRecord, PromotionEnvironment,
92    PromotionLineageSummary,
93};
94#[doc(hidden)]
95pub use proof::{ArtifactIntegrityProof, CorruptionDetectionResult, CorruptionRepairPolicy};
96#[doc(hidden)]
97pub use retention::RetentionPolicy;
98#[doc(hidden)]
99pub use schema::{
100    validate_output_schema_descriptor, ArtifactSchemaDescriptor, SchemaValidationMode,
101};
102#[doc(hidden)]
103pub use services::{RunArtifactStore, RunArtifactVerifier};
104
105use serde::Serialize;
106use std::fs as std_fs;
107use std::io::{self, Write};
108use std::path::{Path, PathBuf};
109use std::sync::atomic::{AtomicU64, Ordering};
110use std::time::{SystemTime, UNIX_EPOCH};
111
112/// Explicit long-lived artifact storage, integrity, and lifecycle surface.
113pub mod stable {
114    pub use crate::{
115        artifact_size_bytes, compact_lineage, dedup_metrics_for_hashes, explain_lineage_safe_gc,
116        lineage_dependencies, lineage_dependents, normalize_metadata_pairs, sha256_artifact_path,
117        sha256_hex, validate_output_schema_descriptor, verify_run_dir, write_inputs_index,
118        write_lineage_snapshot, write_outputs_index, ArtifactError, ArtifactId,
119        ArtifactIntegrityProof, ArtifactLineageEdge, ArtifactLineageSnapshot, ArtifactPackManifest,
120        ArtifactSchemaDescriptor, CorruptionDetectionResult, CorruptionRepairPolicy,
121        RetentionPolicy, RunArtifactStore, RunArtifactVerifier, RunDir, RunDirLayout,
122        SchemaValidationMode,
123    };
124}
125
126/// Common imports for reading, writing, and validating run artifacts.
127pub mod prelude {
128    pub use crate::stable::{
129        artifact_size_bytes, sha256_artifact_path, sha256_hex, validate_output_schema_descriptor,
130        verify_run_dir, write_inputs_index, write_outputs_index, ArtifactError,
131        ArtifactSchemaDescriptor, RunDir, RunDirLayout, SchemaValidationMode,
132    };
133}
134
135/// Opt-in artifact contracts and advisory helpers that are outside the stable lane.
136#[cfg(feature = "experimental-public-api")]
137pub mod experimental {
138    pub mod run_layout {
139        pub use crate::run_layout_contracts::*;
140    }
141    pub mod lifecycle_and_cache {
142        pub use crate::lifecycle_and_cache_contracts::*;
143    }
144}
145
146#[derive(Debug, thiserror::Error)]
147pub enum ArtifactError {
148    #[error("io error: {0}")]
149    Io(#[from] io::Error),
150    #[error("json error: {0}")]
151    Json(#[from] serde_json::Error),
152    #[error("path violation: {0}")]
153    PathViolation(String),
154    #[error("missing output: {0}")]
155    MissingOutput(String),
156}
157
158#[derive(Debug, Clone)]
159pub struct RunDir {
160    staging_path: PathBuf,
161    final_path: PathBuf,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct RunDirLayout {
166    pub run_id: String,
167    pub staging_path: PathBuf,
168    pub final_path: PathBuf,
169}
170
171impl RunDirLayout {
172    pub fn preview(
173        out_base: impl AsRef<Path>,
174        run_id: Option<&str>,
175    ) -> Result<Self, ArtifactError> {
176        let run_id = match run_id {
177            Some(run_id) => normalize_run_id(run_id)?,
178            None => generate_run_id(),
179        };
180        Ok(Self {
181            staging_path: out_base.as_ref().join(format!("run.tmp-{}", run_id)),
182            final_path: out_base.as_ref().join(format!("run-{}", run_id)),
183            run_id,
184        })
185    }
186
187    pub fn node_dir(&self, node_id: &str) -> PathBuf {
188        self.staging_path.join("nodes").join(node_id)
189    }
190
191    pub fn node_outputs_dir(&self, node_id: &str) -> PathBuf {
192        self.node_dir(node_id).join("outputs")
193    }
194
195    pub fn node_inputs_dir(&self, node_id: &str) -> PathBuf {
196        self.node_dir(node_id).join("inputs")
197    }
198
199    pub fn node_work_dir(&self, node_id: &str) -> PathBuf {
200        self.node_dir(node_id).join("work")
201    }
202
203    pub fn node_temp_dir(&self, node_id: &str) -> PathBuf {
204        self.node_work_dir(node_id).join("temp")
205    }
206
207    pub fn stop_request_path(&self) -> PathBuf {
208        self.staging_path.join("run.stop-request.json")
209    }
210}
211
212impl RunDir {
213    pub fn create(out_base: impl AsRef<Path>) -> Result<Self, ArtifactError> {
214        let layout = RunDirLayout::preview(out_base, None)?;
215        Self::create_with_layout(layout)
216    }
217
218    pub fn create_with_id(out_base: impl AsRef<Path>, run_id: &str) -> Result<Self, ArtifactError> {
219        let layout = RunDirLayout::preview(out_base, Some(run_id))?;
220        Self::create_with_layout(layout)
221    }
222
223    pub fn resume_with_id(out_base: impl AsRef<Path>, run_id: &str) -> Result<Self, ArtifactError> {
224        let layout = RunDirLayout::preview(out_base, Some(run_id))?;
225        Self::resume_with_layout(layout)
226    }
227
228    pub fn staging_path(&self) -> &Path {
229        &self.staging_path
230    }
231
232    pub fn final_path(&self) -> &Path {
233        &self.final_path
234    }
235
236    pub fn write_manifest(&self, manifest: &Manifest) -> Result<(), ArtifactError> {
237        let path = self.staging_path.join("manifest.json");
238        write_json_atomic(path, manifest)
239    }
240
241    pub fn write_graph_snapshot(&self, graph_json: &str) -> Result<(), ArtifactError> {
242        let path = self.staging_path.join("graph.snapshot.json");
243        write_bytes_atomic(path, graph_json.as_bytes())
244    }
245
246    pub fn node_dir(&self, node_id: &str) -> PathBuf {
247        self.staging_path.join("nodes").join(node_id)
248    }
249
250    pub fn node_outputs_dir(&self, node_id: &str) -> PathBuf {
251        self.node_dir(node_id).join("outputs")
252    }
253
254    pub fn node_inputs_dir(&self, node_id: &str) -> PathBuf {
255        self.node_dir(node_id).join("inputs")
256    }
257
258    pub fn node_work_dir(&self, node_id: &str) -> PathBuf {
259        self.node_dir(node_id).join("work")
260    }
261
262    pub fn node_temp_dir(&self, node_id: &str) -> PathBuf {
263        self.node_work_dir(node_id).join("temp")
264    }
265
266    pub fn node_stdout_path(&self, node_id: &str) -> PathBuf {
267        self.node_dir(node_id).join("stdout.log")
268    }
269
270    pub fn node_stderr_path(&self, node_id: &str) -> PathBuf {
271        self.node_dir(node_id).join("stderr.log")
272    }
273
274    pub fn node_trace_path(&self, node_id: &str) -> PathBuf {
275        self.node_dir(node_id).join("trace.json")
276    }
277
278    pub fn node_resolved_params_path(&self, node_id: &str) -> PathBuf {
279        self.node_dir(node_id).join("resolved_params.json")
280    }
281
282    pub fn node_attempts_path(&self, node_id: &str) -> PathBuf {
283        self.node_dir(node_id).join("attempts.json")
284    }
285
286    pub fn node_attempt_dir(&self, node_id: &str, attempt: u32) -> PathBuf {
287        self.node_dir(node_id).join("attempts").join(attempt.to_string())
288    }
289
290    pub fn node_attempt_stdout_path(&self, node_id: &str, attempt: u32) -> PathBuf {
291        self.node_attempt_dir(node_id, attempt).join("stdout.log")
292    }
293
294    pub fn node_attempt_stderr_path(&self, node_id: &str, attempt: u32) -> PathBuf {
295        self.node_attempt_dir(node_id, attempt).join("stderr.log")
296    }
297
298    pub fn run_log_path(&self) -> PathBuf {
299        self.staging_path.join("run.log.jsonl")
300    }
301
302    pub fn run_outputs_index_path(&self) -> PathBuf {
303        self.staging_path.join("outputs").join("index.json")
304    }
305
306    pub fn provenance_path(&self) -> PathBuf {
307        self.staging_path.join("provenance.json")
308    }
309
310    pub fn stop_request_path(&self) -> PathBuf {
311        self.staging_path.join("run.stop-request.json")
312    }
313
314    pub fn node_outputs_index_path(&self, node_id: &str) -> PathBuf {
315        self.node_outputs_dir(node_id).join("index.json")
316    }
317
318    pub fn node_output_relpath(&self, node_id: &str, file: &str) -> String {
319        paths::node_output_relpath(node_id, file)
320    }
321
322    pub fn node_inputs_index_path(&self, node_id: &str) -> PathBuf {
323        self.node_inputs_dir(node_id).join("index.json")
324    }
325
326    pub fn finalize(self) -> Result<PathBuf, ArtifactError> {
327        if let Some(parent) = self.final_path.parent() {
328            std_fs::create_dir_all(parent)?;
329        }
330        std_fs::rename(&self.staging_path, &self.final_path)?;
331        Ok(self.final_path)
332    }
333}
334
335impl RunDir {
336    fn create_with_layout(layout: RunDirLayout) -> Result<Self, ArtifactError> {
337        ensure_run_path_absent(&layout.staging_path, "staging run directory")?;
338        ensure_run_path_absent(&layout.final_path, "final run directory")?;
339        std_fs::create_dir_all(layout.staging_path.join("nodes"))?;
340        Ok(Self { staging_path: layout.staging_path, final_path: layout.final_path })
341    }
342
343    fn resume_with_layout(layout: RunDirLayout) -> Result<Self, ArtifactError> {
344        let staging_exists = layout.staging_path.exists();
345        let final_exists = layout.final_path.exists();
346        match (staging_exists, final_exists) {
347            (true, false) => Ok(Self {
348                staging_path: layout.staging_path,
349                final_path: layout.final_path,
350            }),
351            (false, true) => {
352                if let Some(parent) = layout.staging_path.parent() {
353                    std_fs::create_dir_all(parent)?;
354                }
355                std_fs::rename(&layout.final_path, &layout.staging_path)?;
356                Ok(Self {
357                    staging_path: layout.staging_path,
358                    final_path: layout.final_path,
359                })
360            }
361            (false, false) => Err(io::Error::new(
362                io::ErrorKind::NotFound,
363                format!(
364                    "resume run directory missing: {}",
365                    layout.final_path.display()
366                ),
367            )
368            .into()),
369            (true, true) => Err(io::Error::new(
370                io::ErrorKind::AlreadyExists,
371                format!(
372                    "resume run directory is ambiguous because both staging and final paths exist: {} and {}",
373                    layout.staging_path.display(),
374                    layout.final_path.display()
375                ),
376            )
377            .into()),
378        }
379    }
380}
381
382fn ensure_run_path_absent(path: &Path, label: &str) -> Result<(), ArtifactError> {
383    if path.exists() {
384        return Err(io::Error::new(
385            io::ErrorKind::AlreadyExists,
386            format!("{label} already exists: {}", path.display()),
387        )
388        .into());
389    }
390    Ok(())
391}
392
393fn write_json<T: Serialize>(path: impl AsRef<Path>, value: &T) -> Result<(), ArtifactError> {
394    let data = serde_json::to_vec_pretty(value)?;
395    let mut f = std_fs::File::create(path)?;
396    f.write_all(&data)?;
397    Ok(())
398}
399
400fn write_json_atomic<T: Serialize>(path: impl AsRef<Path>, value: &T) -> Result<(), ArtifactError> {
401    let path = path.as_ref();
402    let tmp = path.with_extension("tmp");
403    write_json(&tmp, value)?;
404    std_fs::rename(tmp, path)?;
405    Ok(())
406}
407
408fn write_bytes_atomic(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), ArtifactError> {
409    let path = path.as_ref();
410    let tmp = path.with_extension("tmp");
411    let mut file = std_fs::File::create(&tmp)?;
412    file.write_all(bytes)?;
413    file.sync_all()?;
414    std_fs::rename(tmp, path)?;
415    Ok(())
416}
417
418pub fn write_outputs_index(
419    dir: impl AsRef<Path>,
420    node_id: &str,
421    node_fingerprint: &str,
422    declared_outputs: &[DeclaredOutputArtifact],
423) -> Result<(), ArtifactError> {
424    let mut files = Vec::new();
425    for output in declared_outputs {
426        let rel = &output.path;
427        if !paths::is_normalized_relative_path(rel) {
428            return Err(ArtifactError::PathViolation(format!(
429                "output path must be normalized relative path: {rel}"
430            )));
431        }
432        let path = dir.as_ref().join(rel);
433        if !path.exists() {
434            return Err(ArtifactError::MissingOutput(rel.clone()));
435        }
436        let size_bytes = artifact_size_bytes(&path)?;
437        let sha = sha256_artifact_path(&path)?;
438        files.push(OutputFile {
439            name: output.name.clone(),
440            path: rel.clone(),
441            kind: output.kind.clone(),
442            media_type: output.media_type.clone(),
443            size_bytes,
444            sha256: sha,
445            node_id: node_id.to_string(),
446            node_fingerprint: node_fingerprint.to_string(),
447            promotable: output.promotable,
448        });
449    }
450    files.sort_by(|a, b| a.path.cmp(&b.path));
451    let index = OutputsIndex { files };
452    write_json(dir.as_ref().join("index.json"), &index)
453}
454
455pub fn sha256_artifact_path(path: impl AsRef<Path>) -> Result<String, ArtifactError> {
456    sha256_artifact_path_inner(path.as_ref(), path.as_ref())
457}
458
459pub fn artifact_size_bytes(path: impl AsRef<Path>) -> Result<u64, ArtifactError> {
460    artifact_size_bytes_inner(path.as_ref())
461}
462
463fn sha256_artifact_path_inner(root: &Path, path: &Path) -> Result<String, ArtifactError> {
464    let metadata = std_fs::symlink_metadata(path)?;
465    if metadata.file_type().is_symlink() {
466        return Err(ArtifactError::PathViolation(format!(
467            "artifact path must not be a symlink: {}",
468            path.display()
469        )));
470    }
471    if metadata.is_file() {
472        return Ok(sha256_bytes(&std_fs::read(path)?));
473    }
474    if metadata.is_dir() {
475        let mut entries = Vec::new();
476        for entry in std_fs::read_dir(path)? {
477            let entry = entry?;
478            entries.push(entry.path());
479        }
480        entries.sort();
481        let mut payload = Vec::new();
482        for child in entries {
483            let relative = child
484                .strip_prefix(root)
485                .map_err(|_| {
486                    ArtifactError::PathViolation("artifact path escaped root".to_string())
487                })?
488                .to_string_lossy()
489                .replace('\\', "/");
490            payload.extend_from_slice(relative.as_bytes());
491            payload.push(b'\n');
492            let child_hash = sha256_artifact_path_inner(root, &child)?;
493            payload.extend_from_slice(child_hash.as_bytes());
494            payload.push(b'\n');
495        }
496        return Ok(sha256_bytes(&payload));
497    }
498    Err(ArtifactError::PathViolation(format!(
499        "artifact path must be a regular file or directory: {}",
500        path.display()
501    )))
502}
503
504fn artifact_size_bytes_inner(path: &Path) -> Result<u64, ArtifactError> {
505    let metadata = std_fs::symlink_metadata(path)?;
506    if metadata.file_type().is_symlink() {
507        return Err(ArtifactError::PathViolation(format!(
508            "artifact path must not be a symlink: {}",
509            path.display()
510        )));
511    }
512    if metadata.is_file() {
513        return Ok(metadata.len());
514    }
515    if metadata.is_dir() {
516        let mut total = 0u64;
517        for entry in std_fs::read_dir(path)? {
518            let child = entry?.path();
519            total = total.checked_add(artifact_size_bytes_inner(&child)?).ok_or_else(|| {
520                ArtifactError::PathViolation(format!(
521                    "artifact size exceeded u64 accounting: {}",
522                    path.display()
523                ))
524            })?;
525        }
526        return Ok(total);
527    }
528    Err(ArtifactError::PathViolation(format!(
529        "artifact path must be a regular file or directory: {}",
530        path.display()
531    )))
532}
533
534pub fn write_run_outputs_index(
535    dir: impl AsRef<Path>,
536    index: &RunOutputsIndex,
537) -> Result<(), ArtifactError> {
538    let dir = dir.as_ref();
539    std_fs::create_dir_all(dir)?;
540    write_json_atomic(dir.join("index.json"), index)
541}
542
543pub fn write_provenance(path: impl AsRef<Path>, prov: &Provenance) -> Result<(), ArtifactError> {
544    write_json_atomic(path, prov)
545}
546
547pub fn write_run_schema_index(
548    path: impl AsRef<Path>,
549    schema: &RunDirSchemaIndex,
550) -> Result<(), ArtifactError> {
551    write_json_atomic(path, schema)
552}
553
554pub fn write_inputs_index(dir: impl AsRef<Path>, index: &InputsIndex) -> Result<(), ArtifactError> {
555    write_json_atomic(dir.as_ref().join("index.json"), index)
556}
557
558pub fn now_unix_ms() -> u128 {
559    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()
560}
561
562pub fn build_artifact_identity(
563    run_id: &str,
564    node_id: &str,
565    output_path: &str,
566    node_fingerprint: &str,
567    artifact_sha256: &str,
568) -> ArtifactIdentity {
569    let output_name = Path::new(output_path)
570        .file_name()
571        .and_then(|value| value.to_str())
572        .unwrap_or(output_path)
573        .to_string();
574    let legacy_artifact_id = format!("{node_id}:{output_name}");
575    let canonical_artifact_id =
576        format!("run={run_id};node={node_id};path={output_path};sha256={artifact_sha256}");
577    ArtifactIdentity {
578        canonical_artifact_id,
579        legacy_artifact_id,
580        run_id: run_id.to_string(),
581        node_id: node_id.to_string(),
582        output_name,
583        output_path: output_path.to_string(),
584        node_fingerprint: node_fingerprint.to_string(),
585        artifact_sha256: artifact_sha256.to_string(),
586    }
587}
588
589fn generate_run_id() -> String {
590    static RUN_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
591    let seq = RUN_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
592    format!("{}-{}-{:06}", now_unix_ms(), std::process::id(), seq % 1_000_000)
593}
594
595fn normalize_run_id(run_id: &str) -> Result<String, ArtifactError> {
596    let trimmed = run_id.trim();
597    if trimmed.is_empty() {
598        return Err(ArtifactError::PathViolation("run id must not be empty".to_string()));
599    }
600    let normalized = trimmed.strip_prefix("run-").unwrap_or(trimmed);
601    if normalized.is_empty()
602        || normalized.contains('/')
603        || normalized.contains('\\')
604        || normalized.contains("..")
605        || !normalized.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
606    {
607        return Err(ArtifactError::PathViolation(format!("invalid run id: {run_id}")));
608    }
609    Ok(normalized.to_string())
610}
611
612fn sha256_bytes(bytes: &[u8]) -> String {
613    hash::sha256_hex(bytes)
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn create_and_finalize() {
622        let dir = tempfile::tempdir().unwrap();
623        let run = RunDir::create(dir.path()).unwrap();
624        assert!(run.staging_path().exists());
625        let final_path = run.finalize().unwrap();
626        assert!(final_path.exists());
627    }
628
629    #[test]
630    fn generated_run_ids_do_not_collide_within_process() {
631        let first = generate_run_id();
632        let second = generate_run_id();
633        assert_ne!(first, second);
634        assert!(first.contains('-'));
635        assert!(second.contains('-'));
636    }
637
638    #[test]
639    fn graph_snapshot_writes_atomically() {
640        let dir = tempfile::tempdir().unwrap();
641        let run = RunDir::create(dir.path()).unwrap();
642        run.write_graph_snapshot("{\"graph\":\"first\"}").unwrap();
643        run.write_graph_snapshot("{\"graph\":\"second\"}").unwrap();
644        let snapshot =
645            std_fs::read_to_string(run.staging_path().join("graph.snapshot.json")).unwrap();
646        assert_eq!(snapshot, "{\"graph\":\"second\"}");
647        assert!(!run.staging_path().join("graph.snapshot.tmp").exists());
648    }
649
650    #[test]
651    fn explicit_run_ids_are_normalized_and_validated() {
652        let dir = tempfile::tempdir().unwrap();
653        let run = RunDir::create_with_id(dir.path(), "run-2026_04").unwrap();
654        assert!(run.final_path().ends_with("run-2026_04"));
655
656        let err = RunDir::create_with_id(dir.path(), "../escape").unwrap_err();
657        assert!(err.to_string().contains("invalid run id"));
658    }
659
660    #[test]
661    fn run_dir_layout_previews_paths_without_materializing_directories() {
662        let dir = tempfile::tempdir().unwrap();
663        let layout = RunDirLayout::preview(dir.path(), Some("path-preview")).unwrap();
664        assert_eq!(layout.run_id, "path-preview");
665        assert!(layout.staging_path.ends_with("run.tmp-path-preview"));
666        assert!(layout.final_path.ends_with("run-path-preview"));
667        assert_eq!(
668            layout.node_outputs_dir("align"),
669            dir.path().join("run.tmp-path-preview").join("nodes").join("align").join("outputs")
670        );
671        assert_eq!(
672            layout.node_temp_dir("align"),
673            dir.path()
674                .join("run.tmp-path-preview")
675                .join("nodes")
676                .join("align")
677                .join("work")
678                .join("temp")
679        );
680        assert!(!layout.staging_path.exists());
681    }
682
683    #[test]
684    fn run_dir_layout_exposes_stop_request_path() {
685        let dir = tempfile::tempdir().unwrap();
686        let layout = RunDirLayout::preview(dir.path(), Some("run-stop")).unwrap();
687        assert_eq!(
688            layout.stop_request_path(),
689            dir.path().join("run.tmp-stop").join("run.stop-request.json")
690        );
691    }
692
693    #[test]
694    fn run_dir_creation_rejects_existing_paths() {
695        let dir = tempfile::tempdir().unwrap();
696        let staging = dir.path().join("run.tmp-fixed");
697        std_fs::create_dir_all(staging.join("nodes")).unwrap();
698        let err = RunDir::create_with_id(dir.path(), "fixed").unwrap_err();
699        assert!(err.to_string().contains("staging run directory already exists"));
700
701        std_fs::remove_dir_all(&staging).unwrap();
702        std_fs::create_dir_all(dir.path().join("run-fixed")).unwrap();
703        let err = RunDir::create_with_id(dir.path(), "fixed").unwrap_err();
704        assert!(err.to_string().contains("final run directory already exists"));
705    }
706
707    #[test]
708    fn run_dir_resume_moves_final_path_back_to_staging() {
709        let dir = tempfile::tempdir().unwrap();
710        let final_path = dir.path().join("run-resume-ready");
711        std_fs::create_dir_all(final_path.join("nodes")).unwrap();
712
713        let run_dir = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap();
714
715        assert_eq!(run_dir.staging_path(), dir.path().join("run.tmp-resume-ready").as_path());
716        assert_eq!(run_dir.final_path(), dir.path().join("run-resume-ready").as_path());
717        assert!(run_dir.staging_path().exists());
718        assert!(!run_dir.final_path().exists());
719    }
720
721    #[test]
722    fn run_dir_resume_reuses_existing_staging_path() {
723        let dir = tempfile::tempdir().unwrap();
724        let staging_path = dir.path().join("run.tmp-resume-ready");
725        std_fs::create_dir_all(staging_path.join("nodes")).unwrap();
726
727        let run_dir = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap();
728
729        assert_eq!(run_dir.staging_path(), staging_path.as_path());
730        assert!(!run_dir.final_path().exists());
731    }
732
733    #[test]
734    fn run_dir_resume_rejects_missing_or_ambiguous_paths() {
735        let dir = tempfile::tempdir().unwrap();
736        let missing = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap_err();
737        assert!(missing.to_string().contains("resume run directory missing"));
738
739        let staging_path = dir.path().join("run.tmp-resume-ready");
740        let final_path = dir.path().join("run-resume-ready");
741        std_fs::create_dir_all(staging_path.join("nodes")).unwrap();
742        std_fs::create_dir_all(final_path.join("nodes")).unwrap();
743
744        let ambiguous = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap_err();
745        assert!(ambiguous.to_string().contains("resume run directory is ambiguous"));
746    }
747
748    #[test]
749    fn provenance_and_indexes_replace_atomically() {
750        let dir = tempfile::tempdir().unwrap();
751        let run = RunDir::create(dir.path()).unwrap();
752        let outputs_dir = run.staging_path().join("outputs");
753        let inputs_dir = run.node_inputs_dir("node");
754        let provenance = Provenance {
755            os: "linux".to_string(),
756            arch: "x86_64".to_string(),
757            rustc: "rustc".to_string(),
758            tool_version: "0.1.0".to_string(),
759            planner_contract_version: Some("bijux-dag-planner/v1".to_string()),
760            graph_fingerprint: None,
761            planner_fingerprint: None,
762            execution_fingerprint: None,
763            evidence_fingerprint: None,
764            runtime_fingerprint: None,
765            policy_fingerprint: None,
766            adapters: Vec::new(),
767            policy: PolicyInfo {
768                deny_network: true,
769                deny_env: true,
770                deny_clock: true,
771                clean_env: true,
772                container_image_reference_policy: ContainerImageReferencePolicy::RequireDigest,
773            },
774            time_source: "system_clock".to_string(),
775        };
776        let run_outputs = RunOutputsIndex { files: Vec::new() };
777        let inputs = InputsIndex { collections: Vec::new(), files: Vec::new() };
778
779        write_provenance(run.provenance_path(), &provenance).unwrap();
780        write_run_outputs_index(&outputs_dir, &run_outputs).unwrap();
781        std_fs::create_dir_all(&inputs_dir).unwrap();
782        write_inputs_index(&inputs_dir, &inputs).unwrap();
783
784        assert!(!run.staging_path().join("provenance.tmp").exists());
785        assert!(!outputs_dir.join("index.tmp").exists());
786        assert!(!inputs_dir.join("index.tmp").exists());
787    }
788}