Skip to main content

xtalforge_modules/
workspace.rs

1//! On-disk workspace of runs (Task 3.12).
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use xtalforge_core::Error;
7
8/// One run record.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct RunRecord {
11    /// Stable run id.
12    pub id: String,
13    /// Module or command that produced it.
14    pub module: String,
15    /// Input summary.
16    pub input: String,
17    /// Status.
18    pub status: RunStatus,
19    /// Output file names relative to the run directory.
20    pub outputs: Vec<String>,
21    /// Free-form parameters and results.
22    pub data: serde_json::Value,
23    /// Unix timestamp in seconds.
24    pub created: u64,
25}
26
27/// Run status.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum RunStatus {
30    /// Queued.
31    Pending,
32    /// Running.
33    Running,
34    /// Finished successfully.
35    Done,
36    /// Failed with a message in `data`.
37    Failed,
38}
39
40/// A workspace directory with one subdirectory per run.
41#[derive(Debug, Clone)]
42pub struct Workspace {
43    root: PathBuf,
44}
45
46impl Workspace {
47    /// Opens or creates a workspace.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`Error::InvalidInput`] when the directory cannot be created.
52    #[cfg(not(target_arch = "wasm32"))]
53    pub fn open(root: impl Into<PathBuf>) -> Result<Self, Error> {
54        let root = root.into();
55        std::fs::create_dir_all(&root).map_err(|error| {
56            Error::InvalidInput(format!(
57                "could not create workspace {}: {error}",
58                root.display()
59            ))
60        })?;
61        Ok(Self { root })
62    }
63
64    /// Workspace root.
65    #[must_use]
66    pub fn root(&self) -> &Path {
67        &self.root
68    }
69
70    /// Directory of one run.
71    #[must_use]
72    pub fn run_dir(&self, id: &str) -> PathBuf {
73        self.root.join("runs").join(id)
74    }
75
76    /// Writes a run record and creates its directory.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`Error::InvalidInput`] when the directory or record cannot be
81    /// written.
82    #[cfg(not(target_arch = "wasm32"))]
83    pub fn write_run(&self, record: &RunRecord) -> Result<PathBuf, Error> {
84        let directory = self.run_dir(&record.id);
85        std::fs::create_dir_all(&directory).map_err(|error| {
86            Error::InvalidInput(format!(
87                "could not create run {}: {error}",
88                directory.display()
89            ))
90        })?;
91        let path = directory.join("run.json");
92        let text = serde_json::to_string_pretty(record)
93            .map_err(|error| Error::InvalidInput(format!("could not serialize run: {error}")))?;
94        std::fs::write(&path, text).map_err(|error| {
95            Error::InvalidInput(format!("could not write {}: {error}", path.display()))
96        })?;
97        Ok(path)
98    }
99
100    /// Reads a run record.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`Error::InvalidInput`] when the record is missing or invalid.
105    #[cfg(not(target_arch = "wasm32"))]
106    pub fn read_run(&self, id: &str) -> Result<RunRecord, Error> {
107        let path = self.run_dir(id).join("run.json");
108        let text = std::fs::read_to_string(&path).map_err(|error| {
109            Error::InvalidInput(format!("could not read {}: {error}", path.display()))
110        })?;
111        serde_json::from_str(&text).map_err(|error| {
112            Error::InvalidInput(format!("invalid run record {}: {error}", path.display()))
113        })
114    }
115
116    /// Lists run ids in directory order.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`Error::InvalidInput`] when the runs directory cannot be read
121    /// (a missing directory is an empty list).
122    #[cfg(not(target_arch = "wasm32"))]
123    pub fn runs(&self) -> Result<Vec<String>, Error> {
124        let directory = self.root.join("runs");
125        let entries = match std::fs::read_dir(&directory) {
126            Ok(entries) => entries,
127            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
128            Err(error) => {
129                return Err(Error::InvalidInput(format!(
130                    "could not read {}: {error}",
131                    directory.display()
132                )));
133            }
134        };
135        let mut ids = Vec::new();
136        for entry in entries.flatten() {
137            if entry.path().is_dir()
138                && let Some(name) = entry.file_name().to_str()
139            {
140                ids.push(name.to_owned());
141            }
142        }
143        ids.sort();
144        Ok(ids)
145    }
146}
147
148/// Serializes run records into an in-memory ZIP (the WASM workspace).
149///
150/// # Errors
151///
152/// Returns [`Error::InvalidInput`] when a record cannot be serialized or the
153/// archive cannot be written.
154pub fn archive_records(records: &[RunRecord]) -> Result<Vec<u8>, Error> {
155    use std::io::Write;
156    let mut buffer = std::io::Cursor::new(Vec::new());
157    {
158        let mut archive = zip::ZipWriter::new(&mut buffer);
159        let options = zip::write::SimpleFileOptions::default()
160            .compression_method(zip::CompressionMethod::Deflated)
161            .unix_permissions(0o644);
162        for record in records {
163            let text = serde_json::to_vec_pretty(record).map_err(|error| {
164                Error::InvalidInput(format!("could not serialize run {}: {error}", record.id))
165            })?;
166            archive
167                .start_file(format!("runs/{}/run.json", record.id), options)
168                .map_err(archive_error)?;
169            archive.write_all(&text).map_err(archive_error)?;
170        }
171        archive.finish().map_err(archive_error)?;
172    }
173    Ok(buffer.into_inner())
174}
175
176/// Reads run records from an in-memory ZIP.
177///
178/// # Errors
179///
180/// Returns [`Error::InvalidInput`] for a corrupt archive or an invalid record.
181pub fn read_archived_records(bytes: &[u8]) -> Result<Vec<RunRecord>, Error> {
182    use std::io::Read;
183    let cursor = std::io::Cursor::new(bytes);
184    let mut archive = zip::ZipArchive::new(cursor).map_err(archive_error)?;
185    let mut records: Vec<RunRecord> = Vec::new();
186    for index in 0..archive.len() {
187        let mut file = archive.by_index(index).map_err(archive_error)?;
188        let name = file.name().map_err(archive_error)?;
189        if !name.ends_with("/run.json") {
190            continue;
191        }
192        let mut text = String::new();
193        file.read_to_string(&mut text).map_err(archive_error)?;
194        records.push(
195            serde_json::from_str(&text)
196                .map_err(|error| Error::InvalidInput(format!("invalid run record: {error}")))?,
197        );
198    }
199    records.sort_by(|first, second| first.id.cmp(&second.id));
200    Ok(records)
201}
202
203fn archive_error(error: impl std::fmt::Display) -> Error {
204    Error::InvalidInput(format!("workspace archive: {error}"))
205}