1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum BaselineSourceKind {
9 GitCommit,
11 WorkingTree,
13 CachedRun,
15 Explicit,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BaselineDescriptor {
22 pub source_kind: BaselineSourceKind,
23 pub commit_sha: Option<String>,
25 pub dirty: bool,
27 pub untracked_count: u32,
29 pub lockfile_hash: Option<String>,
31 pub rustc_version: String,
33 pub cargo_version: String,
35 pub target_triple: String,
37 pub env_fingerprint: String,
39 pub submodule_state: Vec<(String, String)>,
41}
42
43impl BaselineDescriptor {
44 pub fn fingerprint(&self) -> String {
46 let content = serde_json::to_string(self).unwrap_or_default();
47 blake3::hash(content.as_bytes()).to_hex().to_string()
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct WorkspacePolicy {
54 #[serde(default = "default_max_workspace_bytes")]
56 pub max_workspace_bytes: u64,
57 #[serde(default = "default_max_retained")]
59 pub max_retained_workspaces: u32,
60 #[serde(default)]
62 pub retain_failed_workspaces: bool,
63 #[serde(default = "default_cleanup_ttl")]
65 pub workspace_cleanup_ttl_secs: u64,
66}
67
68fn default_max_workspace_bytes() -> u64 {
69 2_000_000_000 }
71fn default_max_retained() -> u32 {
72 8
73}
74fn default_cleanup_ttl() -> u64 {
75 3600 }
77
78impl Default for WorkspacePolicy {
79 fn default() -> Self {
80 Self {
81 max_workspace_bytes: default_max_workspace_bytes(),
82 max_retained_workspaces: default_max_retained(),
83 retain_failed_workspaces: false,
84 workspace_cleanup_ttl_secs: default_cleanup_ttl(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ComparabilityPolicy {
92 #[serde(default = "default_true")]
94 pub require_fingerprint_match: bool,
95 #[serde(default)]
97 pub timing_admissible: bool,
98 #[serde(default = "default_min_trials")]
100 pub min_trials: u32,
101}
102
103fn default_true() -> bool {
104 true
105}
106fn default_min_trials() -> u32 {
107 3
108}
109
110impl Default for ComparabilityPolicy {
111 fn default() -> Self {
112 Self {
113 require_fingerprint_match: true,
114 timing_admissible: false,
115 min_trials: default_min_trials(),
116 }
117 }
118}
119
120pub async fn capture_baseline_provenance(
124 workspace_dir: &std::path::Path,
125) -> crate::error::ForgeResult<BaselineDescriptor> {
126 use tokio::process::Command;
127
128 let commit_sha = Command::new("git")
129 .args(["rev-parse", "HEAD"])
130 .current_dir(workspace_dir)
131 .output()
132 .await
133 .ok()
134 .filter(|o| o.status.success())
135 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
136
137 let dirty = Command::new("git")
138 .args(["diff", "--quiet", "HEAD"])
139 .current_dir(workspace_dir)
140 .output()
141 .await
142 .map(|o| !o.status.success())
143 .unwrap_or(true);
144
145 let untracked_count = Command::new("git")
146 .args(["ls-files", "--others", "--exclude-standard"])
147 .current_dir(workspace_dir)
148 .output()
149 .await
150 .ok()
151 .map(|o| String::from_utf8_lossy(&o.stdout).lines().count() as u32)
152 .unwrap_or(0);
153
154 let lockfile_hash = {
155 let lockfile = workspace_dir.join("Cargo.lock");
156 if lockfile.exists() {
157 let content = tokio::fs::read(&lockfile).await.unwrap_or_default();
158 Some(blake3::hash(&content).to_hex().to_string())
159 } else {
160 None
161 }
162 };
163
164 let rustc_version = Command::new("rustc")
165 .arg("--version")
166 .output()
167 .await
168 .ok()
169 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
170 .unwrap_or_else(|| "unknown".to_string());
171
172 let cargo_version = Command::new("cargo")
173 .arg("--version")
174 .output()
175 .await
176 .ok()
177 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
178 .unwrap_or_else(|| "unknown".to_string());
179
180 let target_triple = Command::new("rustc")
181 .args(["-vV"])
182 .output()
183 .await
184 .ok()
185 .and_then(|o| {
186 let text = String::from_utf8_lossy(&o.stdout);
187 text.lines()
188 .find(|l| l.starts_with("host:"))
189 .map(|l| l.trim_start_matches("host:").trim().to_string())
190 })
191 .unwrap_or_else(|| "unknown".to_string());
192
193 let env_fingerprint = {
195 let mut relevant: Vec<String> = std::env::vars()
196 .filter(|(k, _)| crate::exec::is_env_allowed(k))
197 .map(|(k, v)| format!("{k}={v}"))
198 .collect();
199 relevant.sort();
200 blake3::hash(relevant.join("\n").as_bytes())
201 .to_hex()
202 .to_string()
203 };
204
205 let submodule_state = Command::new("git")
206 .args(["submodule", "status"])
207 .current_dir(workspace_dir)
208 .output()
209 .await
210 .ok()
211 .map(|o| {
212 String::from_utf8_lossy(&o.stdout)
213 .lines()
214 .filter_map(|line| {
215 let parts: Vec<&str> = line.split_whitespace().collect();
216 if parts.len() >= 2 {
217 Some((
218 parts[1].to_string(),
219 parts[0].trim_start_matches(['+', '-', 'U']).to_string(),
220 ))
221 } else {
222 None
223 }
224 })
225 .collect()
226 })
227 .unwrap_or_default();
228
229 let source_kind = if commit_sha.is_some() && !dirty {
230 BaselineSourceKind::GitCommit
231 } else {
232 BaselineSourceKind::WorkingTree
233 };
234
235 Ok(BaselineDescriptor {
236 source_kind,
237 commit_sha,
238 dirty,
239 untracked_count,
240 lockfile_hash,
241 rustc_version,
242 cargo_version,
243 target_triple,
244 env_fingerprint,
245 submodule_state,
246 })
247}