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