Skip to main content

tfparser_core/exporter/
manifest.rs

1//! `workspace.manifest.json` writer.
2//!
3//! Per [20-parquet-exporter.md § 3.1] — a tiny machine-readable manifest that
4//! lets callers tell which Parquet artefacts came from which parse run.
5//! It is the *only* non-Parquet output and is always written.
6//!
7//! [20-parquet-exporter.md § 3.1]: ../../../specs/20-parquet-exporter.md
8
9use std::{
10    fs::{self, OpenOptions},
11    io::Write as _,
12    path::Path,
13    sync::Arc,
14};
15
16use serde::{Deserialize, Serialize};
17
18use super::ExportError;
19
20/// Top-level manifest structure.
21///
22/// Wire format is canonical JSON with stable key order. Field names are
23/// `snake_case` for downstream tooling compatibility — both consumers
24/// (CLI scripts, security audits) parse with `jq`.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[non_exhaustive]
27#[serde(deny_unknown_fields)]
28pub struct Manifest {
29    /// `tfparser-core` semver.
30    pub tfparser_version: String,
31    /// Parquet schema major.
32    pub schema_major: u32,
33    /// Parquet schema minor.
34    pub schema_minor: u32,
35    /// Timestamp the manifest was produced, ms since UNIX epoch (UTC).
36    pub generated_at_ms: i64,
37    /// Workspace root the parse ran against (absolute, canonical).
38    pub workspace_root: String,
39    /// Verbatim CLI command line (or empty when the library was used
40    /// directly).
41    pub command_line: String,
42    /// One entry per file the run produced.
43    pub files: Vec<ManifestFile>,
44}
45
46/// Per-file entry in the manifest.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[non_exhaustive]
49#[serde(deny_unknown_fields)]
50pub struct ManifestFile {
51    /// File name (relative to the output dir).
52    pub name: String,
53    /// Row count.
54    pub rows: u64,
55    /// On-disk byte size.
56    pub bytes: u64,
57    /// Lowercase hex SHA-256 of the file's bytes.
58    pub sha256: String,
59}
60
61/// Write the manifest atomically (`.partial` → rename). Returns the byte
62/// size on disk after the rename.
63///
64/// # Errors
65///
66/// Returns [`ExportError::Io`] for I/O failures, [`ExportError::Manifest`]
67/// for serialisation failures, and [`ExportError::OutputExists`] when the
68/// manifest already exists and `overwrite` is `false`.
69pub fn write_manifest(
70    manifest: &Manifest,
71    final_path: &Path,
72    overwrite: bool,
73) -> Result<u64, ExportError> {
74    if final_path.exists() && !overwrite {
75        return Err(ExportError::OutputExists(Arc::from(final_path)));
76    }
77
78    let bytes = serde_json::to_vec_pretty(manifest).map_err(|source| ExportError::Manifest {
79        path: Arc::from(final_path),
80        source,
81    })?;
82    let mut partial: std::ffi::OsString = final_path.as_os_str().to_os_string();
83    partial.push(".partial");
84    let partial = std::path::PathBuf::from(partial);
85    if partial.exists() {
86        fs::remove_file(&partial).map_err(|source| ExportError::Io {
87            path: Arc::from(partial.as_path()),
88            source,
89        })?;
90    }
91    let mut file = OpenOptions::new()
92        .write(true)
93        .create_new(true)
94        .open(&partial)
95        .map_err(|source| ExportError::Io {
96            path: Arc::from(partial.as_path()),
97            source,
98        })?;
99    file.write_all(&bytes).map_err(|source| ExportError::Io {
100        path: Arc::from(partial.as_path()),
101        source,
102    })?;
103    file.sync_all().map_err(|source| ExportError::Io {
104        path: Arc::from(partial.as_path()),
105        source,
106    })?;
107    drop(file);
108
109    fs::rename(&partial, final_path).map_err(|source| ExportError::Io {
110        path: Arc::from(partial.as_path()),
111        source,
112    })?;
113    let on_disk = fs::metadata(final_path)
114        .map(|m| m.len())
115        .map_err(|source| ExportError::Io {
116            path: Arc::from(final_path),
117            source,
118        })?;
119    Ok(on_disk)
120}
121
122#[cfg(test)]
123#[allow(
124    clippy::unwrap_used,
125    clippy::expect_used,
126    clippy::panic,
127    clippy::indexing_slicing
128)]
129mod tests {
130    use super::*;
131
132    fn fake_manifest() -> Manifest {
133        Manifest {
134            tfparser_version: "0.1.0".into(),
135            schema_major: 0,
136            schema_minor: 1,
137            generated_at_ms: 1_700_000_000_000,
138            workspace_root: "/tmp/repo".into(),
139            command_line: "tfparser parse /tmp/repo".into(),
140            files: vec![ManifestFile {
141                name: "resources.parquet".into(),
142                rows: 42,
143                bytes: 1024,
144                sha256: "deadbeef".into(),
145            }],
146        }
147    }
148
149    #[test]
150    fn test_should_round_trip_manifest_via_serde() {
151        let m = fake_manifest();
152        let s = serde_json::to_string(&m).unwrap();
153        let back: Manifest = serde_json::from_str(&s).unwrap();
154        assert_eq!(m, back);
155    }
156
157    #[test]
158    fn test_should_write_manifest_and_return_byte_count() {
159        let tmp = tempfile::tempdir().unwrap();
160        let path = tmp.path().join("workspace.manifest.json");
161        let bytes = write_manifest(&fake_manifest(), &path, false).unwrap();
162        let on_disk = fs::read(&path).unwrap();
163        assert_eq!(on_disk.len() as u64, bytes);
164        let s = String::from_utf8(on_disk).unwrap();
165        assert!(s.contains("\"resources.parquet\""), "{s}");
166    }
167
168    #[test]
169    fn test_should_refuse_overwrite_without_flag() {
170        let tmp = tempfile::tempdir().unwrap();
171        let path = tmp.path().join("workspace.manifest.json");
172        fs::write(&path, b"existing").unwrap();
173        let err = write_manifest(&fake_manifest(), &path, false).unwrap_err();
174        assert!(matches!(err, ExportError::OutputExists(_)));
175    }
176
177    #[test]
178    fn test_should_overwrite_when_flag_set() {
179        let tmp = tempfile::tempdir().unwrap();
180        let path = tmp.path().join("workspace.manifest.json");
181        fs::write(&path, b"existing").unwrap();
182        let _bytes = write_manifest(&fake_manifest(), &path, true).unwrap();
183        let s = fs::read_to_string(&path).unwrap();
184        assert!(s.contains("\"resources.parquet\""), "{s}");
185    }
186}