1use anyhow::{bail, Context, Result};
12use serde::{Deserialize, Serialize};
13use std::fs::{self, File};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::materializer::stream::atomic_publish;
19use crate::testing::ValidationResult;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum WapPhase {
25 Staged,
26 AuditedOk,
27 AuditedFail,
28 Published,
29 RolledBack,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WapAuditLog {
34 pub run_id: String,
35 pub model: String,
36 pub stage_path: String,
37 pub final_path: String,
38 pub phase: WapPhase,
39 pub rows: usize,
40 pub assertion_errors: Vec<String>,
41 pub published_at_ms: Option<u64>,
42}
43
44#[derive(Debug, Clone)]
46pub struct WapModelPaths {
47 pub run_id: String,
48 pub stage_path: PathBuf,
49 pub final_path: PathBuf,
50 pub audit_log_path: PathBuf,
51}
52
53impl WapModelPaths {
54 pub fn for_model(
55 project_dir: &Path,
56 run_id: &str,
57 model_name: &str,
58 final_path: &Path,
59 ) -> Self {
60 let stage_root = project_dir.join(".wap").join(run_id);
61 let ext = final_path
62 .extension()
63 .and_then(|e| e.to_str())
64 .unwrap_or("parquet");
65 let stage_path = stage_root.join(format!("{model_name}.{ext}"));
66 let audit_log_path = stage_root.join(format!("{model_name}.audit.json"));
67 Self {
68 run_id: run_id.to_string(),
69 stage_path,
70 final_path: final_path.to_path_buf(),
71 audit_log_path,
72 }
73 }
74}
75
76pub fn new_wap_run_id() -> String {
77 let ms = SystemTime::now()
78 .duration_since(UNIX_EPOCH)
79 .map(|d| d.as_millis())
80 .unwrap_or(0);
81 format!("wap_{ms}")
82}
83
84pub fn wap_publish(
86 paths: &WapModelPaths,
87 model: &str,
88 rows: usize,
89 validation: &ValidationResult,
90) -> Result<WapAuditLog> {
91 if validation.failed_assertions > 0 {
92 let log = WapAuditLog {
93 run_id: paths.run_id.clone(),
94 model: model.to_string(),
95 stage_path: paths.stage_path.display().to_string(),
96 final_path: paths.final_path.display().to_string(),
97 phase: WapPhase::AuditedFail,
98 rows,
99 assertion_errors: validation.errors.clone(),
100 published_at_ms: None,
101 };
102 write_audit_log(paths, &log)?;
103 bail!(
104 "E_RBT_WAP_AUDIT: model '{model}' failed {} assertion(s); production dest left unchanged: {}",
105 validation.failed_assertions,
106 validation.errors.join("; ")
107 );
108 }
109
110 if !paths.stage_path.exists() {
111 bail!(
112 "E_RBT_WAP_PUBLISH: stage missing {}",
113 paths.stage_path.display()
114 );
115 }
116 atomic_publish(&paths.stage_path, &paths.final_path).with_context(|| {
117 format!(
118 "E_RBT_WAP_PUBLISH: rename {} → {}",
119 paths.stage_path.display(),
120 paths.final_path.display()
121 )
122 })?;
123
124 let log = WapAuditLog {
125 run_id: paths.run_id.clone(),
126 model: model.to_string(),
127 stage_path: paths.stage_path.display().to_string(),
128 final_path: paths.final_path.display().to_string(),
129 phase: WapPhase::Published,
130 rows,
131 assertion_errors: Vec::new(),
132 published_at_ms: Some(
133 SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .map(|d| d.as_millis() as u64)
136 .unwrap_or(0),
137 ),
138 };
139 write_audit_log(paths, &log)?;
140 tracing::info!(
141 "WAP published model '{}' ({} rows) → {}",
142 model,
143 rows,
144 paths.final_path.display()
145 );
146 Ok(log)
147}
148
149fn write_audit_log(paths: &WapModelPaths, log: &WapAuditLog) -> Result<()> {
150 if let Some(parent) = paths.audit_log_path.parent() {
151 fs::create_dir_all(parent)?;
152 }
153 let mut f = File::create(&paths.audit_log_path).with_context(|| {
154 format!(
155 "E_RBT_WAP: write audit log {}",
156 paths.audit_log_path.display()
157 )
158 })?;
159 writeln!(f, "{}", serde_json::to_string_pretty(log)?)?;
160 Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::testing::ValidationResult;
167
168 #[test]
169 fn wap_publish_atomic() -> Result<()> {
170 let temp = tempfile::tempdir()?;
171 let final_p = temp.path().join("out.parquet");
172 let paths = WapModelPaths::for_model(temp.path(), "wap_1", "m", &final_p);
173 fs::create_dir_all(paths.stage_path.parent().unwrap())?;
174 fs::write(&paths.stage_path, b"staged-data")?;
175 fs::write(&final_p, b"old")?;
176 let ok = ValidationResult {
177 total_rows: 1,
178 passed_assertions: 1,
179 failed_assertions: 0,
180 errors: Vec::new(),
181 };
182 wap_publish(&paths, "m", 1, &ok)?;
183 assert_eq!(fs::read(&final_p)?, b"staged-data");
184 assert!(!paths.stage_path.exists());
185 assert!(paths.audit_log_path.exists());
186 Ok(())
187 }
188
189 #[test]
190 fn wap_fail_keeps_production() -> Result<()> {
191 let temp = tempfile::tempdir()?;
192 let final_p = temp.path().join("out.parquet");
193 fs::write(&final_p, b"production")?;
194 let paths = WapModelPaths::for_model(temp.path(), "wap_2", "m", &final_p);
195 fs::create_dir_all(paths.stage_path.parent().unwrap())?;
196 fs::write(&paths.stage_path, b"bad")?;
197 let bad = ValidationResult {
198 total_rows: 1,
199 passed_assertions: 0,
200 failed_assertions: 1,
201 errors: vec!["dup".into()],
202 };
203 let err = wap_publish(&paths, "m", 1, &bad).unwrap_err().to_string();
204 assert!(err.contains("E_RBT_WAP_AUDIT"));
205 assert_eq!(fs::read(&final_p)?, b"production");
206 Ok(())
207 }
208}