Skip to main content

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(&timestamped_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}