Skip to main content

ggen_cli_lib/cmds/
inverse_sync.rs

1//! Inverse Sync Command — μ⁻¹ Pipeline CLI
2//!
3//! `ggen inverse-sync` recovers RDF ontologies from generated artifacts,
4//! validates coherence, and emits a cryptographic ProvenanceEnvelope.
5//!
6//! Usage:
7//!   ggen inverse-sync --source-dir <PATH> --ontology <PATH>
8//!   ggen inverse-sync --source-dir <PATH> --ontology <PATH> --signing-key <PATH>
9//!   ggen inverse-sync --source-dir <PATH> --ontology <PATH> --output-envelope <PATH>
10//!
11//! This command executes the full μ⁻¹ inverse pipeline:
12//!   - μ⁻¹₁ Scan: enumerate artifact files by language
13//!   - μ⁻¹₂ Extract: parse AST/text to recover service definitions
14//!   - μ⁻¹₃ Convert: transform service definitions into RDF Turtle
15//!   - μ⁻¹₄ Validate: verify recovered RDF is well-formed
16//!   - μ⁻¹₅ Emit: produce signed InverseReceipt
17//!
18//! Then validates coherence (O ≅ A ≅ L) and emits ProvenanceEnvelope.
19
20use 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// ============================================================================
33// Output Types
34// ============================================================================
35
36/// Output from `ggen inverse-sync`
37#[derive(Debug, Clone, Serialize)]
38pub struct InverseSyncOutput {
39    /// Overall status: "success" or "error"
40    pub status: String,
41
42    /// Number of source files scanned
43    pub files_scanned: usize,
44
45    /// Number of recovered triple count
46    pub recovered_triples: usize,
47
48    /// Inverse operation ID
49    pub inverse_operation_id: String,
50
51    /// Whether the coherence check passed (admitted)
52    pub coherence_admitted: bool,
53
54    /// Path to the output envelope
55    pub envelope_path: String,
56
57    /// Error message (if failed)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub error: Option<String>,
60
61    /// Coherence drift details (if incoherent)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub coherence_drifts: Option<Vec<String>>,
64}
65
66// ============================================================================
67// Domain Helpers (thin layer for complexity management)
68// ============================================================================
69
70/// Load and validate the signing key from disk.
71fn 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
83/// Resolve the signing key path: explicit arg or default to .ggen/keys/signing.key
84fn 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
90/// Resolve the output envelope path: explicit arg or default to .ggen/envelopes/latest.json
91fn 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
97/// Collect all artifact files from the source directory recursively.
98fn 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            // Recursively collect from subdirectories.
116            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            // Include known source file types: .rs, .ex, .exs, .go
120            if matches!(ext, "rs" | "ex" | "exs" | "go") {
121                files.push(path);
122            }
123        }
124    }
125
126    Ok(files)
127}
128
129/// Load ontology from file and compute its BLAKE3 hash.
130fn 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    // Compute BLAKE3 hash of the ontology content.
135    let mut hasher = blake3::Hasher::new();
136    hasher.update(content.as_bytes());
137    let hash = hasher.finalize().to_hex().to_string();
138
139    // Count non-empty, non-prefix lines as approximate triple count.
140    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
148/// Execute the inverse sync pipeline and coherence check.
149fn 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    // === Step 1: Collect artifact files ===
159    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    // === Step 2: Load signing key ===
177    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    // === Step 3: Run inverse pipeline μ⁻¹₁–μ⁻¹₅ ===
186    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    // === Step 4: Load expected ontology and compute hash ===
190    let (ontology_hash, ontology_triple_count) =
191        load_ontology_hash(&ontology_path).map_err(|e| NounVerbError::execution_error(e))?;
192
193    // === Step 5: Emit OCEL pack lifecycle events and compute L pole hash ===
194    // For this task, we simulate OCEL events from the inverse receipt metadata.
195    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    // === Step 6: Compute A pole hash from recovered files ===
217    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    // === Step 7: Create O pole from expected ontology hash ===
231    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    // === Step 8: Check coherence with expectations ===
239    let mut expectations = HashMap::new();
240    expectations.insert(Pole::Ontology, ontology_hash);
241
242    // Capture the A-pole hash before the poles are moved into the coherence check.
243    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    // === Step 9: Create ProvenanceEnvelope ===
261    // Note: CoherenceReport from ggen_graph is used for the semantic check,
262    // but ProvenanceEnvelope expects a different structure. We convert the ggen_graph
263    // CoherenceReport to the envelope's CoherenceReport.
264    let mut envelope = ProvenanceEnvelope::from_inverse(inverse_receipt.clone());
265
266    // Build the envelope's CoherenceReport from the ggen_graph coherence check
267    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    // === Step 10: Serialize envelope to JSON ===
285    let envelope_json = envelope.to_json().map_err(|e| {
286        NounVerbError::execution_error(format!("Failed to serialize envelope: {}", e))
287    })?;
288
289    // === Step 11: Write envelope to disk ===
290    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    // === Step 12: Return result ===
305    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// ============================================================================
328// Verbs (thin wrappers; complexity ≤ 5 per Poka-Yoke gate FM-1.1)
329// ============================================================================
330
331/// Recover RDF ontology from generated artifacts via the μ⁻¹ inverse pipeline.
332///
333/// Scans artifact files (Rust, Elixir, Go), extracts service definitions,
334/// converts to RDF Turtle, validates coherence against an expected ontology,
335/// and emits a signed ProvenanceEnvelope.
336///
337/// Required arguments:
338///   --source-dir <PATH>      Directory containing artifact files to recover from
339///   --ontology <PATH>        Path to expected RDF ontology (for coherence check)
340///
341/// Optional arguments:
342///   --signing-key <PATH>     Ed25519 signing key (default: .ggen/keys/signing.key)
343///   --output-envelope <PATH> Where to save the envelope (default: .ggen/envelopes/latest.json)
344///
345/// Exit codes:
346///   0  Success — coherence check passed, envelope written
347///   1  Failure — coherence check failed or pipeline error
348#[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}