1use clap_noun_verb::{NounVerbError, Result};
21use clap_noun_verb_macros::verb;
22use ed25519_dalek::SigningKey;
23use ggen_core::receipt::{generate_keypair, ProvenanceEnvelope};
24use ggen_core::reverse_sync::inverse_pipeline::InversePipeline;
25use ggen_graph::{CoherenceChecker, Pole, PoleState};
26use serde::Serialize;
27use std::collections::HashMap;
28use std::fs;
29use std::path::PathBuf;
30use uuid::Uuid;
31
32#[derive(Debug, Clone, Serialize)]
38pub struct InverseSyncOutput {
39 pub status: String,
41
42 pub files_scanned: usize,
44
45 pub recovered_triples: usize,
47
48 pub inverse_operation_id: String,
50
51 pub coherence_admitted: bool,
53
54 pub envelope_path: String,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub error: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub coherence_drifts: Option<Vec<String>>,
64}
65
66fn load_signing_key(key_path: &PathBuf) -> std::result::Result<SigningKey, String> {
72 let key_content =
73 fs::read_to_string(key_path).map_err(|e| format!("Failed to read signing key: {}", e))?;
74 let key_bytes = hex::decode(key_content.trim())
75 .map_err(|e| format!("Failed to decode signing key hex: {}", e))?;
76 let key_array: [u8; 32] = key_bytes
77 .as_slice()
78 .try_into()
79 .map_err(|_| "Signing key must be exactly 32 bytes".to_string())?;
80 Ok(SigningKey::from_bytes(&key_array))
81}
82
83fn resolve_signing_key_path(signing_key: Option<String>) -> PathBuf {
85 signing_key
86 .map(PathBuf::from)
87 .unwrap_or_else(|| PathBuf::from(".ggen/keys/signing.key"))
88}
89
90fn resolve_envelope_path(output_envelope: Option<String>) -> PathBuf {
92 output_envelope
93 .map(PathBuf::from)
94 .unwrap_or_else(|| PathBuf::from(".ggen/envelopes/latest.json"))
95}
96
97fn collect_artifact_files(source_dir: &PathBuf) -> std::result::Result<Vec<PathBuf>, String> {
99 if !source_dir.exists() {
100 return Err(format!(
101 "Source directory not found: {}",
102 source_dir.display()
103 ));
104 }
105
106 let mut files = Vec::new();
107
108 for entry in
109 fs::read_dir(source_dir).map_err(|e| format!("Failed to read source directory: {}", e))?
110 {
111 let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
112 let path = entry.path();
113
114 if path.is_dir() {
115 let sub_files = collect_artifact_files(&path)?;
117 files.extend(sub_files);
118 } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
119 if matches!(ext, "rs" | "ex" | "exs" | "go") {
121 files.push(path);
122 }
123 }
124 }
125
126 Ok(files)
127}
128
129fn load_ontology_hash(ontology_path: &PathBuf) -> std::result::Result<(String, usize), String> {
131 let content =
132 fs::read_to_string(ontology_path).map_err(|e| format!("Failed to read ontology: {}", e))?;
133
134 let mut hasher = blake3::Hasher::new();
136 hasher.update(content.as_bytes());
137 let hash = hasher.finalize().to_hex().to_string();
138
139 let triple_count = content
141 .lines()
142 .filter(|l| !l.trim().is_empty() && !l.starts_with("@prefix"))
143 .count();
144
145 Ok((hash, triple_count))
146}
147
148fn do_inverse_sync(
150 source_dir: String, ontology: String, signing_key: Option<String>,
151 output_envelope: Option<String>,
152) -> std::result::Result<InverseSyncOutput, NounVerbError> {
153 let source_dir = PathBuf::from(&source_dir);
154 let ontology_path = PathBuf::from(&ontology);
155 let signing_key_path = resolve_signing_key_path(signing_key);
156 let envelope_path = resolve_envelope_path(output_envelope);
157
158 let artifact_files = collect_artifact_files(&source_dir).map_err(|e| {
160 NounVerbError::execution_error(format!("Failed to collect artifacts: {}", e))
161 })?;
162
163 if artifact_files.is_empty() {
164 return Ok(InverseSyncOutput {
165 status: "error".to_string(),
166 files_scanned: 0,
167 recovered_triples: 0,
168 inverse_operation_id: String::new(),
169 coherence_admitted: false,
170 envelope_path: envelope_path.to_string_lossy().to_string(),
171 error: Some("No artifact files found in source directory".to_string()),
172 coherence_drifts: None,
173 });
174 }
175
176 let signing_key_obj = load_signing_key(&signing_key_path).map_err(|e| {
178 NounVerbError::execution_error(format!(
179 "Failed to load signing key from {}: {}",
180 signing_key_path.display(),
181 e
182 ))
183 })?;
184
185 let inverse_receipt = InversePipeline::run_signed(&artifact_files, &signing_key_obj)
187 .map_err(|e| NounVerbError::execution_error(format!("Inverse pipeline failed: {}", e)))?;
188
189 let (ontology_hash, ontology_triple_count) =
191 load_ontology_hash(&ontology_path).map_err(|e| NounVerbError::execution_error(e))?;
192
193 let ocel_events = vec![
196 format!(
197 r#"{{"activity":"inverse-scan","timestamp":"{}","files":{}}}"#,
198 chrono::Utc::now().to_rfc3339(),
199 artifact_files.len()
200 ),
201 format!(
202 r#"{{"activity":"inverse-extract","timestamp":"{}","triples":{}}}"#,
203 chrono::Utc::now().to_rfc3339(),
204 inverse_receipt.recovered_triple_count
205 ),
206 format!(
207 r#"{{"activity":"inverse-validate","timestamp":"{}","valid":{}}}"#,
208 chrono::Utc::now().to_rfc3339(),
209 inverse_receipt.shacl_valid
210 ),
211 ];
212
213 let ocel_event_refs: Vec<&str> = ocel_events.iter().map(|s| s.as_str()).collect();
214 let l_pole = CoherenceChecker::fingerprint_event_log(&ocel_event_refs);
215
216 let artifact_paths: Vec<(String, u64)> = artifact_files
218 .iter()
219 .map(|p| {
220 let size = fs::metadata(p).map(|m| m.len()).unwrap_or(0);
221 (p.to_string_lossy().into_owned(), size)
222 })
223 .collect();
224 let artifact_data: Vec<(&str, u64)> = artifact_paths
225 .iter()
226 .map(|(s, n)| (s.as_str(), *n))
227 .collect();
228 let a_pole = CoherenceChecker::fingerprint_artifacts(&artifact_data);
229
230 let o_pole = PoleState {
232 pole: Pole::Ontology,
233 hash: ontology_hash.clone(),
234 item_count: ontology_triple_count,
235 timestamp: chrono::Utc::now(),
236 };
237
238 let mut expectations = HashMap::new();
240 expectations.insert(Pole::Ontology, ontology_hash);
241
242 let a_pole_hash = a_pole.hash.clone();
244 let coherence_report =
245 CoherenceChecker::check_with_expectations(&[o_pole, a_pole, l_pole], &expectations);
246 let coherence_admitted = coherence_report.admitted;
247
248 let coherence_drifts: Option<Vec<String>> = if !coherence_report.drifts.is_empty() {
249 Some(
250 coherence_report
251 .drifts
252 .iter()
253 .map(|d| format!("{:?}: {}", d.kind, d.detail))
254 .collect(),
255 )
256 } else {
257 None
258 };
259
260 let mut envelope = ProvenanceEnvelope::from_inverse(inverse_receipt.clone());
265
266 let envelope_coherence_report = {
268 use ggen_core::receipt::provenance_envelope::CoherenceReport as EnvelopeCoherenceReport;
269 EnvelopeCoherenceReport::new(
270 Uuid::new_v4().to_string(),
271 a_pole_hash,
272 inverse_receipt.output_hash.clone(),
273 coherence_admitted,
274 if coherence_admitted {
275 None
276 } else {
277 coherence_drifts.as_ref().map(|d| d.join("; "))
278 },
279 )
280 };
281
282 envelope = envelope.add_coherence(envelope_coherence_report);
283
284 let envelope_json = envelope.to_json().map_err(|e| {
286 NounVerbError::execution_error(format!("Failed to serialize envelope: {}", e))
287 })?;
288
289 if let Some(parent) = envelope_path.parent() {
291 fs::create_dir_all(parent).map_err(|e| {
292 NounVerbError::execution_error(format!("Failed to create envelope directory: {}", e))
293 })?;
294 }
295
296 fs::write(&envelope_path, &envelope_json).map_err(|e| {
297 NounVerbError::execution_error(format!(
298 "Failed to write envelope to {}: {}",
299 envelope_path.display(),
300 e
301 ))
302 })?;
303
304 let status = if coherence_admitted && inverse_receipt.shacl_valid {
306 "success".to_string()
307 } else {
308 "incoherent".to_string()
309 };
310
311 Ok(InverseSyncOutput {
312 status,
313 files_scanned: artifact_files.len(),
314 recovered_triples: inverse_receipt.recovered_triple_count,
315 inverse_operation_id: inverse_receipt.operation_id,
316 coherence_admitted,
317 envelope_path: envelope_path.to_string_lossy().to_string(),
318 error: if !coherence_admitted {
319 Some("Coherence check failed".to_string())
320 } else {
321 None
322 },
323 coherence_drifts,
324 })
325}
326
327#[verb]
349pub fn inverse_sync(
350 source_dir: String, ontology: String, signing_key: Option<String>,
351 output_envelope: Option<String>,
352) -> Result<InverseSyncOutput> {
353 do_inverse_sync(source_dir, ontology, signing_key, output_envelope)
354}