Skip to main content

scientific_workflow/execution/
scope.rs

1//! Directory-scope internals for execution boundaries.
2//!
3//! This module enforces one boundary contract: safe path component validation plus
4//! deterministic scope naming. It is an internal implementation detail used by
5//! `ExecutionScope`; consumers should treat `ExecutionScope` as the public entry.
6
7use std::fs;
8use std::path::{Component, Path, PathBuf};
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use crate::clock::utc_now_rfc3339;
12
13use super::error::ExecutionScopeError;
14
15static EXECUTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
16
17/// One created or reopened study-execution directory.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ExecutionScope {
20    directory: PathBuf,
21    created_at_utc: Option<String>,
22}
23
24impl ExecutionScope {
25    /// Uses the recording root itself as the deterministic execution scope.
26    ///
27    /// This is intended for application-owned semantic task names where a
28    /// repeated configuration must resolve to the same output path instead of
29    /// receiving a timestamped execution wrapper.
30    pub fn open_or_create(recording_root: impl AsRef<Path>) -> Result<Self, ExecutionScopeError> {
31        let directory = recording_root.as_ref().to_path_buf();
32        fs::create_dir_all(&directory).map_err(|source| ExecutionScopeError::Io {
33            operation: "create deterministic",
34            path: directory.clone(),
35            source,
36        })?;
37        Ok(Self {
38            directory,
39            created_at_utc: None,
40        })
41    }
42
43    /// Creates a uniquely named scope beneath `recording_root`.
44    ///
45    /// The readable UTC component is supplemented by process and sequence
46    /// values. Exclusive directory creation remains the final collision check.
47    pub fn create_generated(recording_root: impl AsRef<Path>) -> Result<Self, ExecutionScopeError> {
48        let recording_root = recording_root.as_ref();
49        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
50            operation: "create recording root for",
51            path: recording_root.to_path_buf(),
52            source,
53        })?;
54        let created_at_utc =
55            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
56        let compact_timestamp = compact_timestamp(&created_at_utc);
57        for _ in 0..1024 {
58            let sequence = EXECUTION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
59            let name = format!(
60                "execution-{compact_timestamp}-{}-{sequence}",
61                std::process::id()
62            );
63            let directory = recording_root.join(name);
64            match fs::create_dir(&directory) {
65                Ok(()) => {
66                    return Ok(Self {
67                        directory,
68                        created_at_utc: Some(created_at_utc),
69                    });
70                }
71                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue,
72                Err(source) => {
73                    return Err(ExecutionScopeError::Io {
74                        operation: "create generated",
75                        path: directory,
76                        source,
77                    });
78                }
79            }
80        }
81        Err(ExecutionScopeError::IdentityExhausted {
82            root: recording_root.to_path_buf(),
83        })
84    }
85
86    /// Creates one caller-named scope beneath `recording_root`.
87    pub fn create_named(
88        recording_root: impl AsRef<Path>,
89        name: impl Into<String>,
90    ) -> Result<Self, ExecutionScopeError> {
91        let recording_root = recording_root.as_ref();
92        let name = name.into();
93        validate_name(&name)?;
94        let created_at_utc =
95            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
96        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
97            operation: "create recording root for",
98            path: recording_root.to_path_buf(),
99            source,
100        })?;
101        let directory = recording_root.join(&name);
102        fs::create_dir(&directory).map_err(|source| ExecutionScopeError::Io {
103            operation: "create named",
104            path: directory.clone(),
105            source,
106        })?;
107        Ok(Self {
108            directory,
109            created_at_utc: Some(created_at_utc),
110        })
111    }
112
113    /// Opens an existing execution scope without creating or modifying files.
114    pub fn open_existing(directory: impl Into<PathBuf>) -> Result<Self, ExecutionScopeError> {
115        let directory = directory.into();
116        let metadata = fs::metadata(&directory).map_err(|source| ExecutionScopeError::Io {
117            operation: "inspect existing",
118            path: directory.clone(),
119            source,
120        })?;
121        if !metadata.is_dir() {
122            return Err(ExecutionScopeError::Io {
123                operation: "open non-directory",
124                path: directory.clone(),
125                source: std::io::Error::new(
126                    std::io::ErrorKind::NotADirectory,
127                    "execution scope must be a directory",
128                ),
129            });
130        }
131        Ok(Self {
132            directory,
133            created_at_utc: None,
134        })
135    }
136
137    /// Returns the scope directory.
138    pub fn directory(&self) -> &Path {
139        &self.directory
140    }
141
142    /// Returns the automatically captured creation timestamp when this handle
143    /// created the scope.
144    ///
145    /// Reopened legacy scopes return `None` because no auxiliary scope metadata
146    /// is invented merely to recover a timestamp.
147    pub fn created_at_utc(&self) -> Option<&str> {
148        self.created_at_utc.as_deref()
149    }
150
151    /// Derives the absent recording path reserved for one deterministic task.
152    ///
153    /// The directory is deliberately not created: the recording writer must
154    /// retain exclusive creation and overwrite protection.
155    pub fn task_recording_directory(&self, ordinal: u64) -> PathBuf {
156        self.directory.join(format!("task-{ordinal:06}"))
157    }
158
159    /// Derives the absent recording path reserved for one semantic relative path.
160    pub fn named_task_recording_directory(
161        &self,
162        name: &str,
163    ) -> Result<PathBuf, ExecutionScopeError> {
164        validate_relative_name(name)?;
165        Ok(self.directory.join(name))
166    }
167}
168
169/// Requires a nonempty relative name containing exactly one normal component.
170fn validate_name(name: &str) -> Result<(), ExecutionScopeError> {
171    validate_path_components(name, false)
172}
173
174/// Requires a nonempty relative path made only from normal components.
175fn validate_relative_name(name: &str) -> Result<(), ExecutionScopeError> {
176    validate_path_components(name, true)
177}
178
179fn validate_path_components(
180    name: &str,
181    allow_relative: bool,
182) -> Result<(), ExecutionScopeError> {
183    let path = Path::new(name);
184    let mut components = path.components();
185    let valid = !name.trim().is_empty()
186        && matches!(components.next(), Some(Component::Normal(_)))
187        && if allow_relative {
188            components.all(|component| matches!(component, Component::Normal(_)))
189        } else {
190            components.next().is_none()
191        };
192    if valid {
193        Ok(())
194    } else {
195        Err(ExecutionScopeError::InvalidName {
196            name: name.to_owned(),
197        })
198    }
199}
200
201/// Removes RFC 3339 punctuation while retaining ordered UTC date/time digits.
202fn compact_timestamp(timestamp: &str) -> String {
203    timestamp
204        .chars()
205        .filter(|character| character.is_ascii_alphanumeric())
206        .collect()
207}