ggen_cli_lib/cmds/sync.rs
1//! Sync Command - The ONLY command in ggen v26_5_19
2//!
3//! `ggen sync` is the unified code synchronization pipeline that replaces ALL
4//! previous ggen commands. It transforms domain ontologies through inference
5//! rules into typed code via Tera templates.
6//!
7//! ## A2A-RS μ Pipeline
8//!
9//! For A2A-RS integration, the sync command executes the full μ₁-μ₅ pipeline:
10//!
11//! - **μ₁ (CONSTRUCT)**: Normalize RDF ontology from .specify/specs/014-a2a-integration/
12//! - **μ₂ (SELECT)**: Extract bindings for each module (agent, message, task, transport, skill)
13//! - **μ₃ (Tera)**: Generate Rust code from templates
14//! - **μ₄ (Canonicalize)**: Format and organize generated code
15//! - **μ₅ (Receipt)**: Generate cryptographic receipt for verification
16//!
17//! Usage:
18//! ggen sync --audit # Full A2A pipeline with receipt
19//! ggen sync --dry-run # Preview without writing
20//! ggen sync --output crates/ # Custom output directory
21
22#![allow(clippy::unused_unit)] // clap-noun-verb macro generates this
23//!
24//! ## Architecture: Three-Layer Pattern
25//!
26//! - **Layer 3 (CLI)**: Input validation, output formatting, thin routing
27//! - **Layer 2 (Integration)**: Async execution, error handling
28//! - **Layer 1 (Domain)**: Pure generation logic from ggen_core::codegen
29//!
30//! ## Exit Codes
31//!
32//! | Code | Meaning |
33//! |------|---------|
34//! | 0 | Success |
35//! | 1 | Manifest validation error |
36//! | 2 | Ontology load error |
37//! | 3 | SPARQL query error |
38//! | 4 | Template rendering error |
39//! | 5 | File I/O error |
40//! | 6 | Timeout exceeded |
41
42use chrono::Utc;
43use clap_noun_verb::{NounVerbError, Result as VerbResult};
44use clap_noun_verb_macros::verb;
45use ggen_core::codegen::{OutputFormat, SyncExecutor, SyncOptions, SyncResult};
46use ggen_core::receipt::{generate_keypair, hash_data, Receipt};
47use ggen_core::sync::{sync as low_level_sync, SyncConfig, SyncLanguage};
48use serde::Serialize;
49use std::path::PathBuf;
50
51// ============================================================================
52// Output Types (re-exported for CLI compatibility)
53// ============================================================================
54
55/// Output for the `ggen sync` command
56#[derive(Debug, Clone, Serialize)]
57pub struct SyncOutput {
58 /// Overall status: "success" or "error"
59 pub status: String,
60
61 /// Number of files synced
62 pub files_synced: usize,
63
64 /// Total duration in milliseconds
65 pub duration_ms: u64,
66
67 /// Generated files with details
68 pub files: Vec<SyncedFile>,
69
70 /// Number of inference rules executed
71 pub inference_rules_executed: usize,
72
73 /// Number of generation rules executed
74 pub generation_rules_executed: usize,
75
76 /// Audit trail path (if enabled)
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub audit_trail: Option<String>,
79
80 /// Error message (if failed)
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub error: Option<String>,
83
84 /// Machine-parsable recovery steps for AGI remediation
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub recovery: Option<String>,
87
88 /// JSON representation of the TPS Andon signal (if any)
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub andon_signal: Option<serde_json::Value>,
91
92 /// Path to the cryptographic receipt emitted after sync (if generated)
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub receipt_path: Option<String>,
95
96 /// Results of the manufacturing proof gates
97 #[serde(skip_serializing_if = "Vec::is_empty")]
98 pub gates: Vec<GateResult>,
99}
100
101/// Result of a single proof gate
102#[derive(Debug, Clone, serde::Serialize)]
103pub struct GateResult {
104 /// Gate name
105 pub name: String,
106 /// Whether the gate passed
107 pub passed: bool,
108 /// Detailed message
109 pub message: String,
110}
111
112/// Individual file sync result
113#[derive(Debug, Clone, Serialize)]
114pub struct SyncedFile {
115 /// File path relative to output directory
116 pub path: String,
117
118 /// File size in bytes
119 pub size_bytes: usize,
120
121 /// Action taken: "created", "updated", "unchanged"
122 pub action: String,
123
124 /// Rule that generated this file
125 pub rule: String,
126}
127
128impl From<SyncResult> for SyncOutput {
129 fn from(result: SyncResult) -> Self {
130 Self {
131 status: result.status,
132 files_synced: result.files_synced,
133 duration_ms: result.duration_ms,
134 files: result
135 .files
136 .into_iter()
137 .map(|f| SyncedFile {
138 path: f.path,
139 size_bytes: f.size_bytes,
140 action: f.action,
141 rule: f.produced_by,
142 })
143 .collect(),
144 inference_rules_executed: result.inference_rules_executed,
145 generation_rules_executed: result.generation_rules_executed,
146 audit_trail: result.audit_trail,
147 error: result.error,
148 recovery: result.recovery,
149 andon_signal: result.andon_signal,
150 receipt_path: None,
151 gates: Vec::new(),
152 }
153 }
154}
155
156// ============================================================================
157// The ONLY Command: ggen sync
158// ============================================================================
159
160/// Execute the complete code synchronization pipeline from a ggen.toml manifest.
161///
162/// This is THE ONLY command in ggen v26_5_19. It replaces all previous commands
163/// (`ggen generate`, `ggen validate`, `ggen template`, etc.) with a single
164/// unified pipeline.
165///
166/// ## A2A-RS μ Pipeline (μ₁ through μ₅)
167///
168/// When generating A2A-RS code from `.specify/specs/014-a2a-integration/`:
169///
170/// ```text
171/// μ₁ CONSTRUCT: Normalize RDF ontology
172/// Input: .specify/specs/014-a2a-integration/a2a-ontology.ttl
173/// Query: crates/ggen-core/queries/a2a/construct-agents.rq
174/// Output: Normalized A2A RDF with a2a: prefix
175///
176/// μ₂ SELECT: Extract bindings for each module
177/// Queries: crates/ggen-core/queries/a2a/extract-*.rq
178/// - extract-agents.rq → agent bindings
179/// - extract-messages.rq → message bindings
180/// - extract-tasks.rq → task bindings
181/// - extract-transports.rq → transport bindings
182/// - extract-skills.rq → skill bindings
183/// Output: SPARQL result bindings
184///
185/// μ₃ Tera: Generate Rust code
186/// Templates: crates/ggen-core/templates/a2a/*.tera
187/// - agent.rs.tera → crates/a2a-generated/src/agent.rs
188/// - message.rs.tera → crates/a2a-generated/src/message.rs
189/// - task.rs.tera → crates/a2a-generated/src/task.rs
190/// - transport.rs.tera → crates/a2a-generated/src/transport.rs
191/// - skill.rs.tera → crates/a2a-generated/src/skill.rs
192/// - lib.rs.tera → crates/a2a-generated/src/lib.rs
193/// Output: Generated Rust source files
194///
195/// μ₄ Canonicalize: Format and organize
196/// Action: rustfmt, organize imports, verify compilation
197/// Output: Formatted, ready-to-compile code
198///
199/// μ₅ Receipt: Generate cryptographic verification
200/// Output: .ggen/receipts/a2a-{timestamp}.json
201/// Contains: SHA256 hashes, input ontology hash, timestamp
202/// ```
203///
204/// ## Pipeline Flow
205///
206/// ```text
207/// ggen.toml → ontology → CONSTRUCT inference → SELECT → Template → Code
208/// ```
209///
210/// ## Flags
211///
212/// --manifest PATH Path to ggen.toml (default: ./ggen.toml)
213/// --output-dir PATH Override output directory from manifest
214/// --dry-run Preview changes without writing files
215/// --force Overwrite existing files (DESTRUCTIVE - use with --audit)
216/// --audit Create detailed audit trail in .ggen/audit/
217/// --rule NAME Execute only specific generation rule
218/// --verbose Show detailed execution logs
219/// --watch Continuous file monitoring and auto-regeneration
220/// --validate-only Run SHACL/SPARQL validation without generation
221/// --format FORMAT Output format: text, json, yaml (default: text)
222/// --timeout MS Maximum execution time in milliseconds (default: 30000)
223/// --stage STAGE Run specific μ stage only (μ₁, μ₂, μ₃, μ₄, μ₅)
224/// --ontology PATH Override ontology path (default: from manifest)
225///
226/// ## Flag Combinations
227///
228/// Safe workflows:
229/// ggen sync --dry-run --audit Preview with audit
230/// ggen sync --force --audit Destructive overwrite with tracking
231/// ggen sync --watch --validate-only Continuous validation
232///
233/// A2A-specific workflows:
234/// ggen sync --audit Full A2A μ₁-μ₅ pipeline with receipt
235/// ggen sync --stage μ₃ Only run template generation
236/// ggen sync --ontology .specify/specs/014-a2a-integration/a2a-ontology.ttl
237///
238/// CI/CD workflows:
239/// ggen sync --format json Machine-readable output
240/// ggen sync --validate-only Pre-flight checks
241///
242/// Development workflows:
243/// ggen sync --watch --verbose Live feedback
244/// ggen sync --rule structs Focused iteration
245///
246/// ## Progress Reporting (A2A Pipeline)
247///
248/// When running A2A sync, progress is reported for each μ stage:
249///
250/// ```text
251/// [μ₁/5] CONSTRUCT: Normalizing ontology...
252/// Loaded 847 triples from a2a-ontology.ttl
253/// +124 triples from construct-agents.rq
254/// [μ₂/5] SELECT: Extracting bindings...
255/// Agents: 8 bindings
256/// Messages: 12 bindings
257/// Tasks: 15 bindings
258/// Transports: 3 bindings
259/// Skills: 24 bindings
260/// [μ₃/5] Tera: Generating code...
261/// agent.rs (2.4 KB)
262/// message.rs (3.1 KB)
263/// task.rs (2.8 KB)
264/// transport.rs (1.2 KB)
265/// skill.rs (4.5 KB)
266/// lib.rs (1.8 KB)
267/// [μ₄/5] Canonicalizing: Formatting code...
268/// Running rustfmt...
269/// Verifying compilation...
270/// [μ₅/5] Receipt: Generating verification...
271/// Receipt: .ggen/receipts/a2a-20250208-143022.json
272/// Ontology hash: a3f2e1b4...
273/// Total: 6 files, 15.8 KB, 2.34s
274/// ```
275///
276/// ## Flag Precedence
277///
278/// --validate-only overrides --force
279/// --dry-run prevents file writes (--force has no effect)
280/// --watch triggers continuous execution
281/// --stage limits execution to specific μ stage
282///
283/// ## Safety Notes
284///
285/// ⚠️ ALWAYS use --audit with --force to enable rollback
286/// ⚠️ ALWAYS use --dry-run before --force to preview changes
287/// ⚠️ Review docs/features/force-flag.md before using --force
288///
289/// ## Examples
290///
291/// ```bash
292/// # Basic sync (the primary workflow)
293/// ggen sync
294///
295/// # Sync from specific manifest
296/// ggen sync --manifest project/ggen.toml
297///
298/// # Dry-run to preview changes
299/// ggen sync --dry-run
300///
301/// # Sync specific rule only
302/// ggen sync --rule structs
303///
304/// # Force overwrite with audit trail (RECOMMENDED)
305/// ggen sync --force --audit
306///
307/// # Watch mode for development
308/// ggen sync --watch --verbose
309///
310/// # Validate without generating
311/// ggen sync --validate-only
312///
313/// # JSON output for CI/CD
314/// ggen sync --format json
315///
316/// # A2A generation with custom ontology
317/// ggen sync --ontology .specify/specs/014-a2a-integration/a2a-ontology.ttl --audit
318///
319/// # Run specific μ stage only
320/// ggen sync --stage μ₃
321///
322/// # Complex: Watch, audit, verbose
323/// ggen sync --watch --audit --verbose --rule api_endpoints
324/// ```
325///
326/// ## Documentation
327///
328/// Full feature documentation:
329/// - docs/features/audit-trail.md Audit trail format and usage
330/// - docs/features/force-flag.md Safe destructive workflows
331/// - docs/features/merge-mode.md Hybrid manual/generated code
332/// - docs/features/watch-mode.md Continuous regeneration
333/// - docs/features/conditional-execution.md SPARQL ASK conditions
334/// - docs/features/validation.md SHACL/SPARQL constraints
335/// - docs/features/a2a-pipeline.md A2A μ₁-μ₅ pipeline details
336#[allow(clippy::unused_unit, clippy::too_many_arguments)]
337#[verb("sync", "root")]
338pub fn sync(
339 manifest: Option<String>,
340 output_dir: Option<String>,
341 dry_run: Option<bool>,
342 force: Option<bool>,
343 audit: Option<bool>,
344 rule: Option<String>,
345 verbose: Option<bool>,
346 watch: Option<bool>,
347 validate_only: Option<bool>,
348 format: Option<String>,
349 timeout: Option<u64>,
350 stage: Option<String>,
351 ontology: Option<String>,
352 queries: Option<String>, // dir of .rq files — activates ontology-first pipeline (no ggen.toml needed)
353 language: Option<String>, // go, elixir, rust, typescript, python, auto
354 profile: Option<String>, // enforcement profile (e.g. enterprise-strict, permissive)
355 locked: bool, // require exact lock-file match (no implicit upgrades)
356) -> VerbResult<SyncOutput> {
357 check_profile_preconditions(profile.as_deref(), locked)?;
358
359 // When --queries is supplied, bypass the manifest and run the low-level pipeline directly
360 if let Some(ref queries_dir) = queries {
361 return run_low_level_pipeline(
362 ontology,
363 queries_dir.clone(),
364 output_dir,
365 language,
366 dry_run.unwrap_or(false),
367 );
368 }
369
370 run_manifest_pipeline(
371 manifest,
372 output_dir,
373 dry_run,
374 force,
375 audit,
376 rule,
377 verbose,
378 watch,
379 validate_only,
380 format,
381 timeout,
382 stage,
383 ontology,
384 )
385}
386
387/// Check profile and locked preconditions before any pipeline work.
388fn check_profile_preconditions(profile: Option<&str>, locked: bool) -> VerbResult<()> {
389 if profile.is_some() || locked {
390 let workspace =
391 std::env::current_dir().map_err(|e| NounVerbError::execution_error(e.to_string()))?;
392 ggen_core::domain::sync_profile::validate_sync_preconditions(profile, locked, &workspace)
393 .map_err(NounVerbError::execution_error)?;
394 }
395 Ok(())
396}
397
398/// Execute the manifest-driven (ggen.toml) sync pipeline and emit a signed receipt.
399#[allow(clippy::too_many_arguments)]
400fn run_manifest_pipeline(
401 manifest: Option<String>, output_dir: Option<String>, dry_run: Option<bool>,
402 force: Option<bool>, audit: Option<bool>, rule: Option<String>, verbose: Option<bool>,
403 watch: Option<bool>, validate_only: Option<bool>, format: Option<String>, timeout: Option<u64>,
404 stage: Option<String>, ontology: Option<String>,
405) -> VerbResult<SyncOutput> {
406 let installed_packs = read_installed_packs(".ggen/packs.lock");
407
408 let manifest_path = PathBuf::from(manifest.clone().unwrap_or_else(|| "ggen.toml".to_string()));
409
410 let options = SyncOptions {
411 manifest_path: manifest_path.clone(),
412 output_dir: output_dir.map(PathBuf::from),
413 use_cache: true,
414 flags: ggen_core::codegen::executor::SyncFlags {
415 mode: ggen_core::codegen::executor::ModeFlags {
416 validate_only: validate_only.unwrap_or(false),
417 dry_run: dry_run.unwrap_or(false),
418 watch: watch.unwrap_or(false),
419 },
420 behavior: ggen_core::codegen::executor::BehaviorFlags {
421 verbose: verbose.unwrap_or(false),
422 force: force.unwrap_or(false),
423 audit: audit.unwrap_or(false),
424 },
425 },
426 output_format: match format.as_deref() {
427 Some("json") => OutputFormat::Json,
428 _ => OutputFormat::default(),
429 },
430 selected_rules: rule.map(|r| vec![r]),
431 a2a_stage: stage,
432 ontology_path: ontology.map(PathBuf::from),
433 timeout_ms: timeout,
434 ..SyncOptions::default()
435 };
436
437 let sync_result = ggen_core::codegen::executor::SyncExecutor::new(options)
438 .execute()
439 .map_err(|e| clap_noun_verb::NounVerbError::execution_error(e.to_string()))?;
440
441 let files: Vec<SyncedFile> = sync_result
442 .files
443 .iter()
444 .map(|f| SyncedFile {
445 path: f.path.clone(),
446 size_bytes: f.size_bytes,
447 action: f.action.clone(),
448 rule: f.produced_by.clone(),
449 })
450 .collect();
451
452 let synced_file_paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
453 let files_synced = sync_result.files_synced;
454 let duration_ms = sync_result.duration_ms;
455 let generation_rules_executed = sync_result.generation_rules_executed;
456 let inference_rules_executed = sync_result.inference_rules_executed;
457
458 // A dry-run is a pure PREVIEW (sensing), not an actuation: it writes no
459 // artifacts, so it must write no receipt. Emitting a receipt for a preview
460 // would record a consequence that never happened (contract drift).
461 let receipt_file_path = if dry_run.unwrap_or(false) {
462 None
463 } else {
464 Some(
465 emit_sync_receipt(&synced_file_paths, &installed_packs).map_err(|e| {
466 clap_noun_verb::NounVerbError::execution_error(format!("Audit failure: {}", e))
467 })?,
468 )
469 };
470
471 Ok(SyncOutput {
472 status: sync_result.status,
473 files_synced,
474 duration_ms,
475 files,
476 inference_rules_executed,
477 generation_rules_executed,
478 audit_trail: sync_result.audit_trail,
479 error: sync_result.error,
480 recovery: sync_result.recovery,
481 andon_signal: sync_result.andon_signal,
482 receipt_path: receipt_file_path,
483 gates: Vec::new(),
484 })
485}
486
487/// Read the installed packs from a packs.lock JSON file.
488///
489/// Returns a list of `"<id>@<version>"` strings, one per installed pack.
490/// Returns an empty vec if the file is absent or cannot be parsed.
491fn read_installed_packs(lock_path: &str) -> Vec<String> {
492 let path = std::path::Path::new(lock_path);
493 if !path.exists() {
494 return vec![];
495 }
496 let content = match std::fs::read_to_string(path) {
497 Ok(c) => c,
498 Err(e) => {
499 log::warn!("Failed to read lock file {}: {}", lock_path, e);
500 return vec![];
501 }
502 };
503 let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) else {
504 return vec![];
505 };
506 val.get("packs")
507 .and_then(|p| p.as_object())
508 .map(|obj| {
509 obj.iter()
510 .map(|(id, entry)| {
511 let version = entry
512 .get("version")
513 .and_then(|v| v.as_str())
514 .unwrap_or("unknown");
515 format!("{}@{}", id, version)
516 })
517 .collect()
518 })
519 .unwrap_or_default()
520}
521
522/// Emit a cryptographically signed sync receipt to `.ggen/receipts/`.
523///
524/// Writes two files on success:
525/// `.ggen/receipts/sync-<YYYYMMDD-HHMMSS>.json` — timestamped archive copy
526/// `.ggen/receipts/latest.json` — always points at the latest receipt
527///
528/// The receipt captures:
529/// - SHA-256 hash of `ggen.toml` as an input hash
530/// - One entry per installed pack (`pack:<id>@<version>`) as input hashes
531/// - SHA-256 hash of each generated file as output hashes
532/// - Ed25519 signature over the entire payload
533/// - Hash of the previous receipt (chaining)
534///
535/// Signing key is persisted at `.ggen/keys/signing.key` (hex).
536/// A new keypair is generated only when no key file exists; existing keys are
537/// never overwritten.
538///
539/// Returns the path written to `latest.json` on success, or a string error on
540/// failure.
541fn emit_sync_receipt(
542 generated_file_paths: &[String], installed_packs: &[String],
543) -> std::result::Result<String, String> {
544 use std::fs;
545
546 // 1. Ensure .ggen/keys/ directory exists.
547 let keys_dir = std::path::Path::new(".ggen/keys");
548 fs::create_dir_all(keys_dir).map_err(|e| e.to_string())?;
549
550 // 2. Load or generate signing keypair — never overwrite an existing key.
551 let signing_key_path = keys_dir.join("signing.key");
552 let verifying_key_path = keys_dir.join("verifying.key");
553
554 let signing_key = if signing_key_path.exists() {
555 let hex_str = fs::read_to_string(&signing_key_path).map_err(|e| e.to_string())?;
556 let bytes = hex::decode(hex_str.trim()).map_err(|e| e.to_string())?;
557 let sk_bytes: [u8; 32] = bytes
558 .try_into()
559 .map_err(|_| "Invalid signing key length (expected 32 bytes)".to_string())?;
560 ed25519_dalek::SigningKey::from_bytes(&sk_bytes)
561 } else {
562 let (sk, vk) = generate_keypair();
563 fs::write(&signing_key_path, hex::encode(sk.to_bytes())).map_err(|e| e.to_string())?;
564 fs::write(&verifying_key_path, hex::encode(vk.to_bytes())).map_err(|e| e.to_string())?;
565 sk
566 };
567
568 // 3. Load previous receipt for chaining.
569 let receipts_dir = std::path::Path::new(".ggen/receipts");
570 let latest_path = receipts_dir.join("latest.json");
571 let previous_receipt: Option<Receipt> = if latest_path.exists() {
572 let content = fs::read_to_string(&latest_path).ok();
573 content.and_then(|c| serde_json::from_str(&c).ok())
574 } else {
575 None
576 };
577
578 // 4. Build input hashes: the FULL O* closure — every input capable of changing
579 // the artifact, plus the actuator identity. R ⊢ A = μ(O*) is false unless R
580 // binds O*; hashing only the manifest would leave the ontology and templates
581 // (which determine the output) outside the witnessed closure.
582 let mut input_hashes: Vec<String> = Vec::new();
583 // Actuator identity: which μ (and version) produced this.
584 input_hashes.push(format!("actuator:ggen-sync@{}", env!("CARGO_PKG_VERSION")));
585 if let Ok(manifest_content) = std::fs::read_to_string("ggen.toml") {
586 input_hashes.push(format!(
587 "ggen.toml:{}",
588 hash_data(manifest_content.as_bytes())
589 ));
590 // Bind the rest of the closure: ontology + imports + external query/template
591 // files. Inline inference rules, queries, and templates live inside ggen.toml
592 // and are therefore already bound by the manifest hash above.
593 if let Ok(manifest) = ggen_core::manifest::ManifestParser::parse_str(&manifest_content) {
594 let mut closure: Vec<PathBuf> = vec![manifest.ontology.source.clone()];
595 closure.extend(manifest.ontology.imports.iter().cloned());
596 for rule in &manifest.generation.rules {
597 if let ggen_core::manifest::QuerySource::File { file } = &rule.query {
598 closure.push(file.clone());
599 }
600 if let ggen_core::manifest::TemplateSource::File { file } = &rule.template {
601 closure.push(file.clone());
602 }
603 }
604 for p in closure {
605 match std::fs::read(&p) {
606 // Honest: a closure input that cannot be read is recorded as MISSING,
607 // never silently dropped — a verifier must see the gap.
608 Ok(content) => {
609 input_hashes.push(format!("{}:{}", p.display(), hash_data(&content)));
610 }
611 Err(_) => input_hashes.push(format!("{}:MISSING", p.display())),
612 }
613 }
614 }
615 }
616 for pack in installed_packs {
617 input_hashes.push(format!("pack:{}", pack));
618 }
619
620 // 5. Build output hashes from generated file paths.
621 let output_hashes: Vec<String> = generated_file_paths
622 .iter()
623 .filter_map(|path| {
624 fs::read(path)
625 .ok()
626 .map(|content| format!("{}:{}", path, hash_data(&content)))
627 })
628 .collect();
629
630 // 6. Create and sign the receipt.
631 let operation_id = uuid::Uuid::new_v4().to_string();
632 let mut receipt = Receipt::new(operation_id, input_hashes, output_hashes, None);
633
634 // Chaining: link to previous receipt if it exists
635 if let Some(prev) = previous_receipt {
636 receipt = receipt.chain(&prev).map_err(|e| e.to_string())?;
637 }
638
639 let signed_receipt = receipt.sign(&signing_key).map_err(|e| e.to_string())?;
640
641 // 7. Write timestamped archive copy.
642 fs::create_dir_all(receipts_dir).map_err(|e| e.to_string())?;
643 let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
644 let receipt_json = serde_json::to_string_pretty(&signed_receipt).map_err(|e| e.to_string())?;
645 let timestamped_path = receipts_dir.join(format!("sync-{}.json", timestamp));
646 fs::write(×tamped_path, &receipt_json).map_err(|e| e.to_string())?;
647
648 // 8. Overwrite latest.json so the golden-path verify command always works.
649 fs::write(&latest_path, &receipt_json).map_err(|e| e.to_string())?;
650
651 Ok(latest_path.to_string_lossy().into_owned())
652}
653
654/// Invoke the low-level `ggen_core::sync::sync()` pipeline directly.
655///
656/// Activated when the user supplies `--queries`. Bypasses `ggen.toml` entirely.
657///
658/// Usage:
659/// ```bash
660/// ggen sync --ontology ./businessos.ttl --queries ./queries/businessos/ --output ./generated/ --language go
661/// ```
662#[allow(unused_variables)]
663fn run_low_level_pipeline(
664 ontology: Option<String>, queries_dir: String, output_dir: Option<String>,
665 language: Option<String>, dry_run: bool,
666) -> VerbResult<SyncOutput> {
667 log::info!("[μ₁/5] CONSTRUCT: Loading ontology...");
668 log::info!("[μ₂/5] SELECT: Running SPARQL queries...");
669 log::info!("[μ₃/5] Tera: Generating code...");
670 log::info!("[μ₄/5] Canonicalizing: Validating soundness...");
671
672 let ontology_path = PathBuf::from(ontology.unwrap_or_else(|| "ontology.ttl".to_string()));
673 let queries_path = PathBuf::from(queries_dir);
674 let output_path = PathBuf::from(output_dir.unwrap_or_else(|| ".".to_string()));
675
676 let lang: SyncLanguage =
677 language.as_deref().unwrap_or("auto").parse().map_err(
678 |e: ggen_core::sync::SyncError| NounVerbError::execution_error(e.to_string()),
679 )?;
680
681 let config = SyncConfig {
682 ontology_path,
683 queries_dir: queries_path,
684 output_dir: output_path,
685 language: lang,
686 validate: true,
687 dry_run,
688 };
689
690 let result =
691 low_level_sync(config).map_err(|e| NounVerbError::execution_error(e.to_string()))?;
692
693 let files: Vec<SyncedFile> = result
694 .files_generated
695 .iter()
696 .map(|p| SyncedFile {
697 path: p.display().to_string(),
698 size_bytes: if dry_run {
699 0
700 } else {
701 std::fs::metadata(p).map_or(0, |m| m.len() as usize)
702 },
703 action: "created".to_string(),
704 rule: "low-level-generator".to_string(),
705 })
706 .collect();
707
708 let synced_file_paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
709 let installed_packs = read_installed_packs(".ggen/packs.lock");
710
711 // A dry-run is a pure PREVIEW (sensing): no artifacts written ⇒ no receipt.
712 let receipt_file_path = if dry_run {
713 None
714 } else {
715 log::info!("[μ₅/5] Receipt: Generating verification...");
716 Some(
717 emit_sync_receipt(&synced_file_paths, &installed_packs).map_err(|e| {
718 clap_noun_verb::NounVerbError::execution_error(format!("Audit failure: {}", e))
719 })?,
720 )
721 };
722
723 let violation_msg = if result.soundness_violations.is_empty() {
724 None
725 } else {
726 Some(format!(
727 "{} soundness violation(s): {}",
728 result.soundness_violations.len(),
729 result
730 .soundness_violations
731 .iter()
732 .map(|v| v.rule.as_str())
733 .collect::<Vec<_>>()
734 .join(", ")
735 ))
736 };
737
738 Ok(SyncOutput {
739 status: "success".to_string(),
740 files_synced: files.len(),
741 duration_ms: result.elapsed_ms,
742 files,
743 inference_rules_executed: 0,
744 generation_rules_executed: result.files_generated.len(),
745 audit_trail: None,
746 error: violation_msg,
747 recovery: None,
748 andon_signal: None,
749 receipt_path: receipt_file_path,
750 gates: Vec::new(),
751 })
752}
753
754/// Build SyncOptions from CLI arguments
755///
756/// This helper keeps the verb function thin by extracting option building.
757#[allow(clippy::too_many_arguments)]
758#[allow(dead_code)]
759fn build_sync_options(
760 manifest: Option<String>, output_dir: Option<String>, dry_run: Option<bool>,
761 force: Option<bool>, audit: Option<bool>, rule: Option<String>, verbose: Option<bool>,
762 watch: Option<bool>, validate_only: Option<bool>, format: Option<String>, timeout: Option<u64>,
763 stage: Option<String>, ontology: Option<String>,
764) -> Result<SyncOptions, NounVerbError> {
765 let mut options = SyncOptions::new();
766
767 // Set manifest path
768 options.manifest_path = PathBuf::from(manifest.unwrap_or_else(|| "ggen.toml".to_string()));
769
770 // Set optional output directory
771 if let Some(dir) = output_dir {
772 options.output_dir = Some(PathBuf::from(dir));
773 }
774
775 // Set boolean flags (SyncOptions.flags was refactored into mode/behavior sub-structs)
776 options.flags.mode.dry_run = dry_run.unwrap_or(false);
777 options.flags.behavior.force = force.unwrap_or(false);
778 options.flags.behavior.audit = audit.unwrap_or(false);
779 options.flags.behavior.verbose = verbose.unwrap_or(false);
780 options.flags.mode.watch = watch.unwrap_or(false);
781 options.flags.mode.validate_only = validate_only.unwrap_or(false);
782
783 // Set selected rules
784 if let Some(r) = rule {
785 options.selected_rules = Some(vec![r]);
786 }
787
788 // Set output format
789 if let Some(fmt) = format {
790 options.output_format = match fmt.to_lowercase().as_str() {
791 "text" => OutputFormat::Text,
792 "json" => OutputFormat::Json,
793 _ => {
794 return Err(NounVerbError::execution_error(format!(
795 "error[E0005]: Invalid output format '{}'\n |\n = help: Valid formats: text, json",
796 fmt
797 )))
798 }
799 };
800 }
801
802 // Set timeout
803 if let Some(t) = timeout {
804 options.timeout_ms = Some(t);
805 }
806
807 // A2A-specific options
808 if let Some(s) = stage {
809 // Validate stage format: μ₁, μ₂, μ₃, μ₄, μ₅
810 if !matches!(
811 s.as_str(),
812 "μ₁" | "μ₂" | "μ₃" | "μ₄" | "μ₅" | "mu1" | "mu2" | "mu3" | "mu4" | "mu5"
813 ) {
814 return Err(NounVerbError::execution_error(format!(
815 "error[E0006]: Invalid stage '{}'\n |\n = help: Valid stages: μ₁, μ₂, μ₃, μ₄, μ₅ (or mu1-mu5)",
816 s
817 )));
818 }
819 options.a2a_stage = Some(s);
820 }
821
822 if let Some(ont) = ontology {
823 options.ontology_path = Some(PathBuf::from(ont));
824 }
825
826 Ok(options)
827}