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