Skip to main content

scientific_workflow/execution/
scope.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::clock::utc_now_rfc3339;
6
7use super::error::ExecutionScopeError;
8
9static EXECUTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
10
11/// One created or reopened project-execution directory.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ExecutionScope {
14    directory: PathBuf,
15    created_at_utc: Option<String>,
16}
17
18impl ExecutionScope {
19    /// Creates a uniquely named scope beneath `recording_root`.
20    ///
21    /// The readable UTC component is supplemented by process and sequence
22    /// values. Exclusive directory creation remains the final collision check.
23    pub fn create_generated(recording_root: impl AsRef<Path>) -> Result<Self, ExecutionScopeError> {
24        let recording_root = recording_root.as_ref();
25        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
26            operation: "create recording root for",
27            path: recording_root.to_path_buf(),
28            source,
29        })?;
30        let created_at_utc =
31            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
32        let compact_timestamp = compact_timestamp(&created_at_utc);
33        for _ in 0..1024 {
34            let sequence = EXECUTION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
35            let name = format!(
36                "execution-{compact_timestamp}-{}-{sequence}",
37                std::process::id()
38            );
39            let directory = recording_root.join(name);
40            match fs::create_dir(&directory) {
41                Ok(()) => {
42                    return Ok(Self {
43                        directory,
44                        created_at_utc: Some(created_at_utc),
45                    });
46                }
47                Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => continue,
48                Err(source) => {
49                    return Err(ExecutionScopeError::Io {
50                        operation: "create generated",
51                        path: directory,
52                        source,
53                    });
54                }
55            }
56        }
57        Err(ExecutionScopeError::IdentityExhausted {
58            root: recording_root.to_path_buf(),
59        })
60    }
61
62    /// Creates one caller-named scope beneath `recording_root`.
63    pub fn create_named(
64        recording_root: impl AsRef<Path>,
65        name: impl Into<String>,
66    ) -> Result<Self, ExecutionScopeError> {
67        let recording_root = recording_root.as_ref();
68        let name = name.into();
69        validate_name(&name)?;
70        let created_at_utc =
71            utc_now_rfc3339().map_err(|source| ExecutionScopeError::Timestamp { source })?;
72        fs::create_dir_all(recording_root).map_err(|source| ExecutionScopeError::Io {
73            operation: "create recording root for",
74            path: recording_root.to_path_buf(),
75            source,
76        })?;
77        let directory = recording_root.join(&name);
78        fs::create_dir(&directory).map_err(|source| ExecutionScopeError::Io {
79            operation: "create named",
80            path: directory.clone(),
81            source,
82        })?;
83        Ok(Self {
84            directory,
85            created_at_utc: Some(created_at_utc),
86        })
87    }
88
89    /// Opens an existing execution scope without creating or modifying files.
90    pub fn open_existing(directory: impl Into<PathBuf>) -> Result<Self, ExecutionScopeError> {
91        let directory = directory.into();
92        let metadata = fs::metadata(&directory).map_err(|source| ExecutionScopeError::Io {
93            operation: "inspect existing",
94            path: directory.clone(),
95            source,
96        })?;
97        if !metadata.is_dir() {
98            return Err(ExecutionScopeError::Io {
99                operation: "open non-directory",
100                path: directory.clone(),
101                source: std::io::Error::new(
102                    std::io::ErrorKind::NotADirectory,
103                    "execution scope must be a directory",
104                ),
105            });
106        }
107        Ok(Self {
108            directory,
109            created_at_utc: None,
110        })
111    }
112
113    /// Returns the scope directory.
114    pub fn directory(&self) -> &Path {
115        &self.directory
116    }
117
118    /// Returns the automatically captured creation timestamp when this handle
119    /// created the scope.
120    ///
121    /// Reopened legacy scopes return `None` because no auxiliary scope metadata
122    /// is invented merely to recover a timestamp.
123    pub fn created_at_utc(&self) -> Option<&str> {
124        self.created_at_utc.as_deref()
125    }
126
127    /// Derives the absent recording path reserved for one deterministic task.
128    ///
129    /// The directory is deliberately not created: the recording writer must
130    /// retain exclusive creation and overwrite protection.
131    pub fn task_recording_directory(&self, task_ordinal: u64) -> PathBuf {
132        self.directory.join(format!("task-{task_ordinal:06}"))
133    }
134}
135
136/// Requires a nonempty relative name containing exactly one normal component.
137fn validate_name(name: &str) -> Result<(), ExecutionScopeError> {
138    let path = Path::new(name);
139    let mut components = path.components();
140    let valid = !name.trim().is_empty()
141        && matches!(components.next(), Some(Component::Normal(_)))
142        && components.next().is_none();
143    if valid {
144        Ok(())
145    } else {
146        Err(ExecutionScopeError::InvalidName {
147            name: name.to_owned(),
148        })
149    }
150}
151
152/// Removes RFC 3339 punctuation while retaining ordered UTC date/time digits.
153fn compact_timestamp(timestamp: &str) -> String {
154    timestamp
155        .chars()
156        .filter(|character| character.is_ascii_alphanumeric())
157        .collect()
158}