Skip to main content

graphforge_cli/
lib.rs

1//! Reusable GraphForge command-line interface.
2#![forbid(unsafe_code)]
3
4use std::collections::BTreeMap;
5use std::io::{self, Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use arrow::datatypes::DataType;
11use arrow::ipc::writer::StreamWriter;
12use arrow::record_batch::RecordBatch;
13use clap::{Args, Parser, Subcommand, ValueEnum, error::ErrorKind};
14use graphforge_api::{
15    CheckpointDiffDetail, CheckpointDiffScope, CheckpointRequest, CheckpointSelector,
16    DeleteCheckpointRequest, DiffCheckpointsRequest, ExecutionResult, GraphForge,
17    ListCheckpointsRequest, OperationId, PageRequest, PageToken, PortableExportRequest,
18    PortableExportResult, PortableImportRequest, PortableImportResult, PortableSelection,
19    PreviewRevertCheckpointRequest, RepositoryContext, RepositorySyncRequest, RepositorySyncStatus,
20    RevertCheckpointPreview, RevertCheckpointRequest, ShowCheckpointRequest,
21};
22use uuid::Uuid;
23
24include!(concat!(env!("OUT_DIR"), "/project_skills.rs"));
25
26mod hub_clone;
27pub mod hub_fixture_artifacts;
28mod hub_http;
29mod hub_publish;
30mod maintenance_cli;
31mod ontology_cli;
32mod portable_cli;
33mod research_cli;
34mod research_versions_cli;
35mod saved_queries_cli;
36mod source_artifact_cli;
37mod storage_attribution_cli;
38
39const MAX_SKILL_MANIFEST_BYTES: u64 = 256 * 1024;
40const MAX_SKILL_FILE_BYTES: u64 = 4 * 1024 * 1024;
41const MAX_SKILL_BUNDLE_BYTES: u64 = 8 * 1024 * 1024;
42const MAX_SKILL_BUNDLE_FILES: usize = 256;
43const MAX_SKILL_PATH_BYTES: usize = 1024;
44const OUT_OF_SYNC_EXIT_CODE: i32 = 4;
45
46fn project_skill_bundle() -> graphforge_api::SkillBundle<'static> {
47    graphforge_api::SkillBundle {
48        manifest: PROJECT_SKILL_MANIFEST,
49        files: PROJECT_SKILL_FILES,
50    }
51}
52
53struct OwnedSkillFile {
54    path: String,
55    bytes: Vec<u8>,
56}
57
58struct OwnedSkillBundle {
59    manifest: Vec<u8>,
60    files: Vec<OwnedSkillFile>,
61}
62
63fn read_bounded_file(
64    path: &Path,
65    maximum: u64,
66    message: &'static str,
67) -> Result<Vec<u8>, graphforge_api::GfError> {
68    let file = std::fs::File::open(path)
69        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
70    let metadata = file
71        .metadata()
72        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
73    if !metadata.is_file() || metadata.len() > maximum {
74        return Err(graphforge_api::GfError::Validation(message.into()));
75    }
76    let capacity = usize::try_from(metadata.len())
77        .map_err(|_| graphforge_api::GfError::Validation(message.into()))?;
78    let mut bytes = Vec::with_capacity(capacity);
79    file.take(maximum + 1)
80        .read_to_end(&mut bytes)
81        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
82    if bytes.len() as u64 > maximum {
83        return Err(graphforge_api::GfError::Validation(message.into()));
84    }
85    Ok(bytes)
86}
87
88fn load_skill_file(
89    root: &Path,
90    entry: &serde_json::Value,
91) -> Result<(String, PathBuf, u64), graphforge_api::GfError> {
92    let path = entry
93        .get("path")
94        .and_then(serde_json::Value::as_str)
95        .ok_or_else(|| {
96            graphforge_api::GfError::Validation("project skill file path is required".into())
97        })?;
98    if path.is_empty() || path.len() > MAX_SKILL_PATH_BYTES {
99        return Err(graphforge_api::GfError::Validation(
100            "project skill file path exceeds byte bound".into(),
101        ));
102    }
103    let relative = Path::new(path);
104    if relative.is_absolute()
105        || relative
106            .components()
107            .any(|part| !matches!(part, std::path::Component::Normal(_)))
108    {
109        return Err(graphforge_api::GfError::Validation(
110            "project skill file path is not contained".into(),
111        ));
112    }
113    let mut candidate = root.to_path_buf();
114    for component in relative.components() {
115        candidate.push(component);
116        let component_metadata = std::fs::symlink_metadata(&candidate)
117            .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
118        if component_metadata.file_type().is_symlink() {
119            return Err(graphforge_api::GfError::Validation(
120                "packaged project skill paths must not contain symlinks".into(),
121            ));
122        }
123    }
124    let file_metadata = std::fs::symlink_metadata(&candidate)
125        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
126    if file_metadata.file_type().is_symlink() || !file_metadata.is_file() {
127        return Err(graphforge_api::GfError::Validation(
128            "packaged project skill must be a real file".into(),
129        ));
130    }
131    if file_metadata.len() > MAX_SKILL_FILE_BYTES {
132        return Err(graphforge_api::GfError::Validation(
133            "packaged project skill exceeds per-file byte bound".into(),
134        ));
135    }
136    let canonical = candidate
137        .canonicalize()
138        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
139    if !canonical.starts_with(root) {
140        return Err(graphforge_api::GfError::Validation(
141            "project skill file path escaped its bundle".into(),
142        ));
143    }
144    Ok((path.to_owned(), canonical, file_metadata.len()))
145}
146
147fn load_skill_bundle(root: &Path) -> Result<OwnedSkillBundle, graphforge_api::GfError> {
148    let metadata = std::fs::symlink_metadata(root)
149        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
150    if metadata.file_type().is_symlink() || !metadata.is_dir() {
151        return Err(graphforge_api::GfError::Validation(
152            "packaged skill bundle root must be a real directory".into(),
153        ));
154    }
155    let root = root
156        .canonicalize()
157        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
158    let manifest_path = root.join("manifest.json");
159    let manifest_metadata = std::fs::symlink_metadata(&manifest_path)
160        .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
161    if manifest_metadata.file_type().is_symlink() || !manifest_metadata.is_file() {
162        return Err(graphforge_api::GfError::Validation(
163            "packaged skill manifest must be a real file".into(),
164        ));
165    }
166    if manifest_metadata.len() > MAX_SKILL_MANIFEST_BYTES {
167        return Err(graphforge_api::GfError::Validation(
168            "packaged skill manifest exceeds byte bound".into(),
169        ));
170    }
171    let manifest = read_bounded_file(
172        &manifest_path,
173        MAX_SKILL_MANIFEST_BYTES,
174        "packaged skill manifest exceeds byte bound",
175    )?;
176    let value: serde_json::Value = serde_json::from_slice(&manifest).map_err(|error| {
177        graphforge_api::GfError::Validation(format!("invalid project skill manifest: {error}"))
178    })?;
179    let entries = value
180        .get("files")
181        .and_then(serde_json::Value::as_array)
182        .ok_or_else(|| {
183            graphforge_api::GfError::Validation("project skill manifest files are required".into())
184        })?;
185    if entries.len() > MAX_SKILL_BUNDLE_FILES {
186        return Err(graphforge_api::GfError::Validation(
187            "project skill bundle exceeds file bound".into(),
188        ));
189    }
190    let mut prepared = Vec::with_capacity(entries.len());
191    let mut total_bytes = 0_u64;
192    for entry in entries {
193        let file = load_skill_file(&root, entry)?;
194        total_bytes = total_bytes.checked_add(file.2).ok_or_else(|| {
195            graphforge_api::GfError::Validation(
196                "packaged project skill bundle exceeds total byte bound".into(),
197            )
198        })?;
199        if total_bytes > MAX_SKILL_BUNDLE_BYTES {
200            return Err(graphforge_api::GfError::Validation(
201                "packaged project skill bundle exceeds total byte bound".into(),
202            ));
203        }
204        prepared.push(file);
205    }
206    let mut files = Vec::with_capacity(prepared.len());
207    let mut read_bytes = 0_u64;
208    for (path, canonical, _) in prepared {
209        let bytes = read_bounded_file(
210            &canonical,
211            MAX_SKILL_FILE_BYTES,
212            "packaged project skill exceeds per-file byte bound",
213        )?;
214        read_bytes = read_bytes
215            .checked_add(bytes.len() as u64)
216            .filter(|total| *total <= MAX_SKILL_BUNDLE_BYTES)
217            .ok_or_else(|| {
218                graphforge_api::GfError::Validation(
219                    "packaged project skill bundle exceeds total byte bound".into(),
220                )
221            })?;
222        files.push(OwnedSkillFile { path, bytes });
223    }
224    Ok(OwnedSkillBundle { manifest, files })
225}
226
227/// GraphForge command-line interface.
228#[derive(Parser)]
229#[command(name = "graphforge", version, about = "GraphForge CLI")]
230struct Cli {
231    /// Print version info and exit.
232    #[arg(long)]
233    info: bool,
234
235    /// Persistent GraphForge project directory.
236    #[arg(long, global = true)]
237    project: Option<PathBuf>,
238
239    /// Code repository root; defaults to discovery from the current directory.
240    #[arg(long, global = true)]
241    project_dir: Option<PathBuf>,
242
243    #[command(flatten)]
244    diagnostics: storage_attribution_cli::DiagnosticsArgs,
245
246    /// Emit the stable machine-readable JSON result when supported.
247    #[arg(long, global = true)]
248    json: bool,
249
250    /// Internal packaged project-skill asset root used by distribution wrappers.
251    #[arg(long, global = true, hide = true)]
252    skills_bundle_dir: Option<PathBuf>,
253
254    #[command(subcommand)]
255    command: Option<Command>,
256}
257
258#[derive(Subcommand)]
259enum Command {
260    /// Validate and exercise the Rust-owned telemetry lifecycle.
261    Telemetry(TelemetryArgs),
262    Clone(hub_clone::CloneArgs),
263    Publish(hub_publish::PublishArgs),
264    /// Initialize repository-local GraphForge definitions and state.
265    Init(InitArgs),
266    /// Compare or reconcile declared definitions and source digests without ingesting data.
267    Sync(SyncArgs),
268    /// Remove only repository-local runtime state.
269    Remove(RemoveArgs),
270    /// Validate or resolve repository configuration.
271    Config {
272        #[command(subcommand)]
273        command: ConfigCommand,
274    },
275    /// Validate provider-neutral infrastructure intent without provisioning.
276    Infra {
277        #[command(subcommand)]
278        command: InfraCommand,
279    },
280    /// Manage project-local GraphForge agent skills.
281    Skills {
282        #[command(subcommand)]
283        command: SkillsCommand,
284    },
285    /// Restore the complete workspace from a checkpoint.
286    Revert(RevertArgs),
287    /// Export one immutable generation as a portable envelope.
288    Export(ExportArgs),
289    /// Import a portable envelope into a new or empty project.
290    Import(ImportArgs),
291    /// Portable-v2 preview, export, verify, import, and OCI promotion.
292    Portable {
293        #[command(subcommand)]
294        command: portable_cli::PortableCommand,
295    },
296    /// Composable ontology module, bridge, activation, and composition lifecycle.
297    Ontology {
298        #[command(subcommand)]
299        command: ontology_cli::OntologyCommand,
300    },
301    /// Stream a Cypher result to Parquet or Arrow IPC without full materialization.
302    Query(portable_cli::QueryArgs),
303    /// Save, inspect, edit, and explicitly execute reusable project queries.
304    SavedQuery {
305        #[command(subcommand)]
306        command: saved_queries_cli::SavedQueryCommand,
307    },
308    /// Staged Arrow/Parquet graph-import sessions.
309    ImportSession {
310        #[command(subcommand)]
311        command: portable_cli::ImportSessionCommand,
312    },
313    /// Manage immutable named workspace checkpoints.
314    Checkpoint {
315        #[command(subcommand)]
316        command: CheckpointCommand,
317    },
318    /// Research Project metadata and bounded local discovery.
319    Research {
320        #[command(subcommand)]
321        command: research_cli::ResearchCommand,
322    },
323    /// Source and Artifact lifecycle: registration, lineage, preference, and impact.
324    SourceArtifact {
325        #[command(subcommand)]
326        command: source_artifact_cli::SourceArtifactCommand,
327    },
328    /// Explicit multi-mutation transactions (Rust-owned lifecycle).
329    Transaction {
330        #[command(subcommand)]
331        command: maintenance_cli::TransactionCommand,
332    },
333    /// Retention/GC and graph-delta compaction maintenance.
334    Maintenance {
335        #[command(subcommand)]
336        command: maintenance_cli::MaintenanceCommand,
337    },
338    /// Emit safe recovery-on-open evidence for the project.
339    Recovery,
340    /// Emit authenticated, identity-free retained storage attribution.
341    StorageAttribution(storage_attribution_cli::StorageAttributionArgs),
342}
343
344#[derive(Args)]
345struct TelemetryArgs {
346    /// disabled, in_memory, or otlp_http_json.
347    #[arg(long, default_value = "disabled")]
348    mode: String,
349    /// Explicit OTLP/HTTP collector base URL.
350    #[arg(long)]
351    endpoint: Option<String>,
352    /// OTLP header as lowercase-name=value; values are never printed.
353    #[arg(long = "header")]
354    headers: Vec<String>,
355    #[arg(long, default_value_t = 256)]
356    queue_capacity: usize,
357    #[arg(long, default_value_t = 64)]
358    batch_size: usize,
359    #[arg(long, default_value_t = 3_000)]
360    export_timeout_ms: u64,
361    #[arg(long, default_value_t = 5_000)]
362    lifecycle_timeout_ms: u64,
363    #[arg(long, default_value_t = 2)]
364    max_retries: u8,
365}
366
367fn run_telemetry(
368    args: TelemetryArgs,
369    output: &mut dyn Write,
370) -> Result<i32, graphforge_api::GfError> {
371    use graphforge_api::telemetry::{OtlpConfig, TelemetryConfig, TelemetryMode, TelemetryRuntime};
372    let mode = match args.mode.as_str() {
373        "disabled" => TelemetryMode::Disabled,
374        "in_memory" => TelemetryMode::InMemory,
375        "otlp_http_json" => TelemetryMode::OtlpHttpJson,
376        _ => {
377            return Err(graphforge_api::GfError::Validation(
378                "GF_TELEMETRY_INVALID_MODE".into(),
379            ));
380        }
381    };
382    let mut headers = BTreeMap::new();
383    for header in args.headers {
384        let Some((name, value)) = header.split_once('=') else {
385            return Err(graphforge_api::GfError::Validation(
386                "GF_TELEMETRY_INVALID_HEADER".into(),
387            ));
388        };
389        headers.insert(name.to_owned(), value.to_owned());
390    }
391    let otlp = args
392        .endpoint
393        .map(|endpoint| OtlpConfig { endpoint, headers });
394    let runtime = TelemetryRuntime::new(TelemetryConfig {
395        mode,
396        queue_capacity: args.queue_capacity,
397        batch_size: args.batch_size,
398        export_timeout: Duration::from_millis(args.export_timeout_ms),
399        lifecycle_timeout: Duration::from_millis(args.lifecycle_timeout_ms),
400        max_retries: args.max_retries,
401        otlp,
402        ..TelemetryConfig::default()
403    })
404    .map_err(|error| graphforge_api::GfError::Validation(error.code.as_str().to_owned()))?;
405    let flush = runtime.force_flush();
406    let shutdown = runtime.shutdown();
407    writeln!(
408        output,
409        "{}",
410        serde_json::json!({
411            "semantic_contract_version": graphforge_api::telemetry::SEMANTIC_CONTRACT_VERSION,
412            "scope": graphforge_api::telemetry::INSTRUMENTATION_SCOPE,
413            "mode": args.mode,
414            "flush": flush.as_str(),
415            "shutdown": shutdown.as_str()
416        })
417    )
418    .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
419    Ok(0)
420}
421
422#[derive(Args)]
423struct InitArgs {
424    /// Do not install missing project-local GraphForge agent skills.
425    #[arg(long)]
426    no_skills: bool,
427}
428
429#[derive(Args)]
430struct SyncArgs {
431    /// Compare declared repository state without publishing a generation.
432    #[arg(long, conflicts_with_all = ["idempotency_key", "actor_uuid"])]
433    check: bool,
434    /// Caller-owned operation identity, required only when applying drift.
435    #[arg(long)]
436    idempotency_key: Option<String>,
437    /// Optional caller-owned actor identity for a published snapshot.
438    #[arg(long, requires = "idempotency_key")]
439    actor_uuid: Option<String>,
440}
441
442#[derive(Subcommand)]
443enum SkillsCommand {
444    /// Install the packaged project-local skills.
445    Install(SkillMutationArgs),
446    /// Inspect managed skill provenance and user edits.
447    Status,
448    /// Update managed skills to the packaged bundle.
449    Update(SkillMutationArgs),
450    /// Remove only managed project-local skills.
451    Remove(SkillMutationArgs),
452}
453
454#[derive(Args)]
455struct SkillMutationArgs {
456    /// Explicitly resolve conflicts by replacing edited managed files.
457    #[arg(long)]
458    force: bool,
459}
460
461#[derive(Args)]
462struct RemoveArgs {
463    /// Required explicit non-interactive confirmation.
464    #[arg(long)]
465    yes: bool,
466}
467
468#[derive(Subcommand)]
469enum ConfigCommand {
470    /// Parse and validate graphforge.yaml.
471    Validate,
472    /// Emit deterministic, secret-free resolved configuration.
473    Resolve,
474}
475
476#[derive(Subcommand)]
477enum InfraCommand {
478    /// Validate one named target without network access or mutation.
479    Validate(InfraValidateArgs),
480}
481
482#[derive(Args)]
483struct InfraValidateArgs {
484    /// Stable target identifier from graphforge.yaml.
485    #[arg(long)]
486    target: String,
487}
488
489#[derive(Subcommand)]
490enum CheckpointCommand {
491    /// Create a checkpoint of the current complete workspace.
492    Create(CreateArgs),
493    /// List active checkpoints in canonical order.
494    List(PageArgs),
495    /// Show authoritative metadata for one active checkpoint.
496    Show(ShowArgs),
497    /// Execute one read-only Cypher query against a checkpoint.
498    Open(OpenArgs),
499    /// Delete an active checkpoint reference.
500    Delete(DeleteArgs),
501    /// Diff two checkpoint/current endpoints.
502    Diff(DiffArgs),
503    /// Restore the complete workspace from a checkpoint.
504    Revert(RevertArgs),
505}
506
507#[derive(Args)]
508struct CreateArgs {
509    name: String,
510    #[arg(long)]
511    description: Option<String>,
512    #[arg(long)]
513    idempotency_key: String,
514    #[arg(long)]
515    actor_uuid: Option<String>,
516}
517
518#[derive(Args)]
519struct PageArgs {
520    #[arg(long, default_value_t = 100)]
521    limit: u32,
522    #[arg(long)]
523    after: Option<String>,
524}
525
526#[derive(Args)]
527struct ShowArgs {
528    name: String,
529}
530
531#[derive(Args)]
532struct OpenArgs {
533    name: String,
534    /// Read-only Cypher after `--`.
535    #[arg(last = true, required = true, num_args = 1..)]
536    query: Vec<String>,
537}
538
539#[derive(Args)]
540struct DeleteArgs {
541    name: String,
542    #[arg(long)]
543    idempotency_key: String,
544    #[arg(long)]
545    actor_uuid: Option<String>,
546}
547
548#[derive(Clone, Copy, ValueEnum)]
549enum ScopeArg {
550    Summary,
551    Graph,
552    Ontology,
553    Configuration,
554    Capabilities,
555    Provenance,
556    Knowledge,
557    Epistemic,
558    All,
559}
560
561#[derive(Clone, Copy, ValueEnum)]
562enum DetailArg {
563    Summary,
564    Records,
565}
566
567#[derive(Args)]
568struct DiffArgs {
569    /// Named checkpoint used as the earlier endpoint.
570    #[arg(long, conflicts_with = "from_current")]
571    from: Option<String>,
572    /// Use the current generation as the earlier endpoint.
573    #[arg(long, conflicts_with = "from")]
574    from_current: bool,
575    /// Named checkpoint used as the later endpoint.
576    #[arg(long, conflicts_with = "to_current")]
577    to: Option<String>,
578    /// Use the current generation as the later endpoint.
579    #[arg(long, conflicts_with = "to")]
580    to_current: bool,
581    #[arg(long, value_enum)]
582    scope: ScopeArg,
583    #[arg(long, value_enum)]
584    detail: DetailArg,
585    #[command(flatten)]
586    page: PageArgs,
587}
588
589#[derive(Args)]
590struct RevertArgs {
591    name: String,
592    /// Required bounded audit reason.
593    #[arg(long, required_unless_present = "preview")]
594    reason: Option<String>,
595    /// Required canonical UUID used for idempotent replay.
596    #[arg(long, required_unless_present = "preview")]
597    idempotency_key: Option<String>,
598    #[arg(long)]
599    actor_uuid: Option<String>,
600    /// Explicitly authorize this destructive operation in automation.
601    #[arg(long, conflicts_with = "preview")]
602    yes: bool,
603    /// Inspect the checkpoint and current generation without mutation.
604    #[arg(long)]
605    preview: bool,
606}
607
608#[derive(Args)]
609struct ExportArgs {
610    /// Export the generation committed as CURRENT when the command starts.
611    #[arg(
612        long,
613        required_unless_present = "checkpoint",
614        conflicts_with = "checkpoint"
615    )]
616    current: bool,
617    /// Export the generation pinned by this active checkpoint.
618    #[arg(long, required_unless_present = "current", conflicts_with = "current")]
619    checkpoint: Option<String>,
620    /// Portable envelope destination.
621    #[arg(long)]
622    output: PathBuf,
623}
624
625#[derive(Args)]
626struct ImportArgs {
627    /// Portable envelope source.
628    #[arg(long)]
629    input: PathBuf,
630    /// Required canonical UUID used for idempotent replay.
631    #[arg(long)]
632    idempotency_key: String,
633}
634
635pub(crate) fn canonical_uuid(value: &str) -> Result<Uuid, graphforge_api::GfError> {
636    let parsed = Uuid::parse_str(value)
637        .map_err(|_| graphforge_api::GfError::Validation("expected canonical UUID".into()))?;
638    if parsed.hyphenated().to_string() != value {
639        return Err(graphforge_api::GfError::Validation(
640            "expected canonical lowercase hyphenated UUID".into(),
641        ));
642    }
643    Ok(parsed)
644}
645
646fn actor(value: Option<&str>) -> Result<Option<Uuid>, graphforge_api::GfError> {
647    value.map(canonical_uuid).transpose()
648}
649
650fn page(args: &PageArgs) -> Result<PageRequest, graphforge_api::GfError> {
651    Ok(PageRequest {
652        limit: args.limit,
653        after: args.after.as_deref().map(PageToken::parse).transpose()?,
654        cancellation: None,
655    })
656}
657
658fn selector(
659    value: Option<String>,
660    current: bool,
661    endpoint: &str,
662) -> Result<CheckpointSelector, graphforge_api::GfError> {
663    match (value, current) {
664        (Some(name), false) => Ok(CheckpointSelector::Named(name)),
665        (None, true) => Ok(CheckpointSelector::Current),
666        _ => Err(graphforge_api::GfError::Validation(format!(
667            "exactly one of --{endpoint} or --{endpoint}-current is required"
668        ))),
669    }
670}
671
672fn write_result(
673    result: &ExecutionResult,
674    output: &mut dyn Write,
675) -> Result<(), graphforge_api::GfError> {
676    // The CLI IPC contract is one logical result batch. Engine/storage batches
677    // remain streaming internally; normalize only at this serialization edge.
678    let logical = if result.batches.is_empty() {
679        RecordBatch::new_empty(Arc::clone(&result.schema))
680    } else {
681        arrow::compute::concat_batches(&result.schema, &result.batches)
682            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
683    };
684    {
685        let mut writer = StreamWriter::try_new(&mut *output, result.schema.as_ref())
686            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
687        writer
688            .write(&logical)
689            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
690        writer
691            .finish()
692            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
693    }
694    output
695        .flush()
696        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))
697}
698
699#[derive(serde::Serialize)]
700struct JsonResultColumn<'a> {
701    name: &'a str,
702    data_type: String,
703    nullable: bool,
704}
705
706#[derive(serde::Serialize)]
707struct JsonExecutionResult<'a> {
708    contract: &'static str,
709    columns: Vec<JsonResultColumn<'a>>,
710    metadata: std::collections::BTreeMap<&'a str, &'a str>,
711    rows: Vec<Vec<serde_json::Value>>,
712}
713
714fn canonical_json_value(
715    value: serde_json::Value,
716    data_type: &DataType,
717) -> Result<serde_json::Value, graphforge_api::GfError> {
718    match (value, data_type) {
719        (serde_json::Value::String(hex), DataType::FixedSizeBinary(16)) => {
720            if hex.len() != 32 {
721                return Err(graphforge_api::GfError::Execution(
722                    "invalid 16-byte JSON result value".into(),
723                ));
724            }
725            let bytes = (0..hex.len())
726                .step_by(2)
727                .map(|index| u8::from_str_radix(&hex[index..index + 2], 16))
728                .collect::<Result<Vec<_>, _>>()
729                .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
730            Ok(serde_json::Value::String(
731                Uuid::from_slice(&bytes)
732                    .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
733                    .hyphenated()
734                    .to_string(),
735            ))
736        }
737        (value, _) => Ok(value),
738    }
739}
740
741fn write_json_result(
742    result: &ExecutionResult,
743    output: &mut dyn Write,
744) -> Result<(), graphforge_api::GfError> {
745    let mut encoded = arrow::json::ArrayWriter::new(Vec::new());
746    let batches = result.batches.iter().collect::<Vec<_>>();
747    encoded
748        .write_batches(&batches)
749        .and_then(|()| encoded.finish())
750        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
751    let objects: Vec<serde_json::Map<String, serde_json::Value>> =
752        serde_json::from_slice(&encoded.into_inner())
753            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
754    let fields = result.schema.fields();
755    let mut rows = Vec::with_capacity(objects.len());
756    for mut object in objects {
757        let mut row = Vec::with_capacity(fields.len());
758        for field in fields {
759            let value = object
760                .remove(field.name())
761                .unwrap_or(serde_json::Value::Null);
762            row.push(canonical_json_value(value, field.data_type())?);
763        }
764        rows.push(row);
765    }
766    let value = JsonExecutionResult {
767        contract: "graphforge-cli-result/1",
768        columns: fields
769            .iter()
770            .map(|field| JsonResultColumn {
771                name: field.name(),
772                data_type: field.data_type().to_string(),
773                nullable: field.is_nullable(),
774            })
775            .collect(),
776        metadata: result
777            .schema
778            .metadata()
779            .iter()
780            .map(|(key, value)| (key.as_str(), value.as_str()))
781            .collect(),
782        rows,
783    };
784    serde_json::to_writer(&mut *output, &value)
785        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
786    writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))
787}
788
789fn write_execution_result(
790    result: &ExecutionResult,
791    json: bool,
792    output: &mut dyn Write,
793) -> Result<(), graphforge_api::GfError> {
794    if json {
795        write_json_result(result, output)
796    } else {
797        write_result(result, output)
798    }
799}
800
801fn write_export_result(
802    result: &PortableExportResult,
803    json: bool,
804    output: &mut dyn Write,
805) -> Result<(), graphforge_api::GfError> {
806    if json {
807        writeln!(
808            output,
809            "{}",
810            serde_json::to_string(&serde_json::json!({
811                "contract": result.contract,
812                "source": result.source,
813                "checkpoint": result.checkpoint,
814                "generation_uuid": result.generation_uuid,
815                "envelope_sha256": result.envelope_sha256,
816                "byte_length": result.byte_length,
817                "participant_count": result.participant_count,
818            }))
819            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
820        )
821        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
822    } else {
823        writeln!(
824            output,
825            "exported {} generation {} to {} (sha256 {}, {} bytes, {} participants)",
826            result.source,
827            result.generation_uuid,
828            result.output.display(),
829            result.envelope_sha256,
830            result.byte_length,
831            result.participant_count
832        )
833        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
834    }
835    Ok(())
836}
837
838fn write_import_result(
839    result: &PortableImportResult,
840    json: bool,
841    output: &mut dyn Write,
842) -> Result<(), graphforge_api::GfError> {
843    if json {
844        writeln!(
845            output,
846            "{}",
847            serde_json::to_string(result)
848                .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
849        )
850        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
851    } else {
852        let outcome = if result.idempotent_replay {
853            "replayed"
854        } else {
855            "imported"
856        };
857        writeln!(
858            output,
859            "{outcome} source generation {} as {} (sha256 {})",
860            result.source_generation_uuid, result.generation_uuid, result.envelope_sha256
861        )
862        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
863    }
864    Ok(())
865}
866
867fn run_import(
868    args: ImportArgs,
869    path: &Path,
870    json: bool,
871    output: &mut dyn Write,
872) -> Result<(), graphforge_api::GfError> {
873    let result = GraphForge::import_portable(
874        path,
875        &PortableImportRequest {
876            input: args.input,
877            operation_id: OperationId(canonical_uuid(&args.idempotency_key)?),
878        },
879    )?;
880    write_import_result(&result, json, output)
881}
882
883fn run_export(
884    graph: &GraphForge,
885    args: ExportArgs,
886    json: bool,
887    output: &mut dyn Write,
888) -> Result<(), graphforge_api::GfError> {
889    let selection = match args.checkpoint {
890        Some(name) => PortableSelection::Checkpoint(name),
891        None if args.current => PortableSelection::Current,
892        None => unreachable!("clap requires exactly one export selector"),
893    };
894    let result = graph.export_portable(PortableExportRequest {
895        selection,
896        output: args.output,
897    })?;
898    write_export_result(&result, json, output)
899}
900
901#[derive(serde::Serialize)]
902struct JsonRevertPreview<'a> {
903    contract: &'static str,
904    checkpoint_uuid: Uuid,
905    source_generation_uuid: Uuid,
906    source_manifest_sha256: &'a str,
907    current_generation_uuid: Uuid,
908}
909
910fn write_revert_preview(
911    preview: &RevertCheckpointPreview,
912    json: bool,
913    output: &mut dyn Write,
914) -> Result<(), graphforge_api::GfError> {
915    if json {
916        serde_json::to_writer(
917            &mut *output,
918            &JsonRevertPreview {
919                contract: "graphforge-revert-preview/1",
920                checkpoint_uuid: preview.checkpoint_uuid,
921                source_generation_uuid: preview.source_generation_uuid,
922                source_manifest_sha256: &preview.source_manifest_sha256,
923                current_generation_uuid: preview.current_generation_uuid,
924            },
925        )
926        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
927        writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
928    } else {
929        writeln!(
930            output,
931            "checkpoint {} pins generation {} (sha256 {}); current generation is {}",
932            preview.checkpoint_uuid,
933            preview.source_generation_uuid,
934            preview.source_manifest_sha256,
935            preview.current_generation_uuid
936        )
937        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
938    }
939    Ok(())
940}
941
942fn run_revert_preview(
943    path: &Path,
944    name: String,
945    json: bool,
946    output: &mut dyn Write,
947) -> Result<(), graphforge_api::GfError> {
948    let preview =
949        GraphForge::preview_revert_to_checkpoint(path, PreviewRevertCheckpointRequest { name })?;
950    write_revert_preview(&preview, json, output)
951}
952
953fn run_revert(
954    graph: &mut GraphForge,
955    args: RevertArgs,
956    json: bool,
957    output: &mut dyn Write,
958) -> Result<(), graphforge_api::GfError> {
959    debug_assert!(args.yes && !args.preview);
960    let result = graph.revert_to_checkpoint(RevertCheckpointRequest {
961        name: args.name,
962        reason: args
963            .reason
964            .expect("clap requires --reason unless --preview is present"),
965        idempotency_key: OperationId(canonical_uuid(
966            args.idempotency_key
967                .as_deref()
968                .expect("clap requires --idempotency-key unless --preview is present"),
969        )?),
970        actor_uuid: actor(args.actor_uuid.as_deref())?,
971    })?;
972    write_execution_result(&result, json, output)
973}
974
975fn revert_args(command: &Command) -> Option<&RevertArgs> {
976    match command {
977        Command::Revert(args)
978        | Command::Checkpoint {
979            command: CheckpointCommand::Revert(args),
980        } => Some(args),
981        _ => None,
982    }
983}
984
985fn handle_revert_before_open(
986    command: &Command,
987    path: &Path,
988    json: bool,
989    output: &mut dyn Write,
990) -> Result<bool, graphforge_api::GfError> {
991    let Some(args) = revert_args(command) else {
992        return Ok(false);
993    };
994    if args.preview {
995        run_revert_preview(path, args.name.clone(), json, output)?;
996        return Ok(true);
997    }
998    if !args.yes {
999        return Err(graphforge_api::GfError::Validation(
1000            "revert requires explicit confirmation with --yes".into(),
1001        ));
1002    }
1003    Ok(false)
1004}
1005
1006fn run_repository(
1007    command: Command,
1008    project_dir: Option<PathBuf>,
1009    json: bool,
1010    skill_bundle: &graphforge_api::SkillBundle<'_>,
1011    output: &mut dyn Write,
1012) -> Result<i32, graphforge_api::GfError> {
1013    let start = project_dir.unwrap_or(
1014        std::env::current_dir()
1015            .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?,
1016    );
1017    let repository = RepositoryContext::discover(start)?;
1018    let always_json = matches!(
1019        &command,
1020        Command::Config {
1021            command: ConfigCommand::Resolve
1022        }
1023    );
1024    let mut exit_code = 0;
1025    let mut plain_output = "ok";
1026    let value = match command {
1027        Command::Init(args) => {
1028            let init = repository.init_without_skills()?;
1029            let skills = if args.no_skills {
1030                None
1031            } else {
1032                Some(repository.skills_install(skill_bundle, false)?)
1033            };
1034            serde_json::to_value(serde_json::json!({
1035                "root": ".",
1036                "created_config": init.created_config,
1037                "ignore_changed": init.ignore_changed,
1038                "state": ".graphforge/state",
1039                "skills": skills
1040            }))
1041        }
1042        Command::Sync(args) => {
1043            let result = repository.sync(RepositorySyncRequest {
1044                check: args.check,
1045                operation_uuid: args
1046                    .idempotency_key
1047                    .as_deref()
1048                    .map(canonical_uuid)
1049                    .transpose()?,
1050                actor_uuid: actor(args.actor_uuid.as_deref())?,
1051            })?;
1052            if args.check && result.status == RepositorySyncStatus::Drift {
1053                exit_code = OUT_OF_SYNC_EXIT_CODE;
1054            }
1055            plain_output = match result.status {
1056                RepositorySyncStatus::InSync => "in_sync",
1057                RepositorySyncStatus::Drift => "drift",
1058                RepositorySyncStatus::Published => "published",
1059            };
1060            serde_json::to_value(result)
1061        }
1062        Command::Remove(args) => {
1063            let receipt = repository.remove(args.yes)?;
1064            serde_json::to_value(serde_json::json!({
1065                "target": ".graphforge/state",
1066                "removed": receipt.removed
1067            }))
1068        }
1069        Command::Config {
1070            command: ConfigCommand::Validate,
1071        } => {
1072            repository.load_config()?;
1073            Ok(serde_json::json!({"valid": true}))
1074        }
1075        Command::Config {
1076            command: ConfigCommand::Resolve,
1077        } => Ok(repository.resolve_config()?),
1078        Command::Infra {
1079            command: InfraCommand::Validate(args),
1080        } => {
1081            plain_output = "valid";
1082            serde_json::to_value(repository.validate_infra_target(&args.target)?)
1083        }
1084        Command::Skills {
1085            command: SkillsCommand::Install(args),
1086        } => serde_json::to_value(repository.skills_install(skill_bundle, args.force)?),
1087        Command::Skills {
1088            command: SkillsCommand::Status,
1089        } => serde_json::to_value(repository.skills_status(skill_bundle)?),
1090        Command::Skills {
1091            command: SkillsCommand::Update(args),
1092        } => serde_json::to_value(repository.skills_update(skill_bundle, args.force)?),
1093        Command::Skills {
1094            command: SkillsCommand::Remove(args),
1095        } => serde_json::to_value(repository.skills_remove(args.force)?),
1096        _ => unreachable!(),
1097    }
1098    .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1099    if json || always_json {
1100        writeln!(
1101            output,
1102            "{}",
1103            serde_json::to_string(&value)
1104                .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
1105        )
1106        .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1107    } else {
1108        writeln!(output, "{plain_output}")
1109            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1110    }
1111    Ok(exit_code)
1112}
1113
1114fn run_repository_with_bundle(
1115    command: Command,
1116    project_dir: Option<PathBuf>,
1117    json: bool,
1118    bundle_dir: Option<&Path>,
1119    output: &mut dyn Write,
1120) -> Result<i32, graphforge_api::GfError> {
1121    let needs_packaged_bundle = matches!(&command, Command::Init(args) if !args.no_skills)
1122        || matches!(
1123            &command,
1124            Command::Skills {
1125                command: SkillsCommand::Install(_)
1126                    | SkillsCommand::Status
1127                    | SkillsCommand::Update(_)
1128            }
1129        );
1130    if !needs_packaged_bundle {
1131        return run_repository(command, project_dir, json, &project_skill_bundle(), output);
1132    }
1133    let owned_bundle = bundle_dir.map(load_skill_bundle).transpose()?;
1134    let owned_files = owned_bundle.as_ref().map(|bundle| {
1135        bundle
1136            .files
1137            .iter()
1138            .map(|file| graphforge_api::SkillBundleFile {
1139                path: &file.path,
1140                bytes: &file.bytes,
1141            })
1142            .collect::<Vec<_>>()
1143    });
1144    let embedded = project_skill_bundle();
1145    let skill_bundle =
1146        owned_bundle
1147            .as_ref()
1148            .zip(owned_files.as_ref())
1149            .map_or(embedded, |(bundle, files)| graphforge_api::SkillBundle {
1150                manifest: &bundle.manifest,
1151                files,
1152            });
1153    run_repository(command, project_dir, json, &skill_bundle, output)
1154}
1155
1156fn resolve_project_path(
1157    project: Option<PathBuf>,
1158    project_dir: Option<PathBuf>,
1159) -> Result<PathBuf, graphforge_api::GfError> {
1160    match project {
1161        Some(path) => Ok(path),
1162        None => Ok(RepositoryContext::discover(
1163            project_dir.unwrap_or(
1164                std::env::current_dir()
1165                    .map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?,
1166            ),
1167        )?
1168        .state_path),
1169    }
1170}
1171
1172fn is_repository_command(command: &Command) -> bool {
1173    matches!(
1174        command,
1175        Command::Init(_)
1176            | Command::Sync(_)
1177            | Command::Remove(_)
1178            | Command::Config { .. }
1179            | Command::Infra { .. }
1180            | Command::Skills { .. }
1181    )
1182}
1183
1184#[allow(clippy::too_many_lines)] // CLI command dispatch table
1185pub(crate) enum CliRuntimeError {
1186    Core(graphforge_api::GfError),
1187    MultiOntology(graphforge_api::MultiOntologyError),
1188}
1189
1190impl From<graphforge_api::GfError> for CliRuntimeError {
1191    fn from(error: graphforge_api::GfError) -> Self {
1192        Self::Core(error)
1193    }
1194}
1195
1196impl From<graphforge_api::MultiOntologyError> for CliRuntimeError {
1197    fn from(error: graphforge_api::MultiOntologyError) -> Self {
1198        Self::MultiOntology(error)
1199    }
1200}
1201
1202#[allow(clippy::too_many_lines)]
1203fn run(cli: Cli, output: &mut dyn Write) -> Result<i32, CliRuntimeError> {
1204    let _capture = storage_attribution_cli::capture(&cli)?;
1205    let allocation = if cli.diagnostics.allocation_diagnostics {
1206        if !cli.json {
1207            return Err(graphforge_api::GfError::Validation(
1208                "allocation diagnostics require --json".into(),
1209            )
1210            .into());
1211        }
1212        Some(graphforge_api::StorageAllocationDiagnostics::read(
1213            std::io::stdin().lock(),
1214        )?)
1215    } else {
1216        None
1217    };
1218    let result = run_with_allocation(cli, output, allocation.as_ref())?;
1219    if let Some(allocation) = allocation {
1220        let (current, peak) = allocation.totals()?;
1221        // The composition sums to `peak_allocated_bytes`, so a consumer can
1222        // attribute the whole high-water mark rather than a fraction of it.
1223        let (composition, residency, transitions, peak_transition) = allocation.peak_detail()?;
1224        serde_json::to_writer(&mut *output, &serde_json::json!({"contract":"graphforge-allocation-operation/1", "current_allocated_bytes":current, "peak_allocated_bytes":peak, "peak_component_allocated_bytes":composition, "component_residency":residency, "owner_transitions":transitions, "peak_transition":peak_transition})).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1225        writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1226    }
1227    Ok(result)
1228}
1229
1230pub(crate) fn open_cli_graph(
1231    path: &Path,
1232    allocation: Option<&graphforge_api::StorageAllocationDiagnostics>,
1233) -> Result<GraphForge, graphforge_api::GfError> {
1234    if let Some(allocation) = allocation {
1235        allocation.open(path)
1236    } else {
1237        let path = path.to_str().ok_or_else(|| {
1238            graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
1239        })?;
1240        GraphForge::new(Some(path))
1241    }
1242}
1243
1244#[allow(clippy::too_many_lines)]
1245fn run_with_allocation(
1246    cli: Cli,
1247    output: &mut dyn Write,
1248    allocation: Option<&graphforge_api::StorageAllocationDiagnostics>,
1249) -> Result<i32, CliRuntimeError> {
1250    if cli.info {
1251        writeln!(output, "graphforge {}", env!("CARGO_PKG_VERSION"))
1252            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1253        return Ok(0);
1254    }
1255    let Some(command) = cli.command else {
1256        writeln!(output, "GraphForge — use --help for options")
1257            .map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
1258        return Ok(0);
1259    };
1260    let command = match command {
1261        Command::Telemetry(args) => return run_telemetry(args, output).map_err(Into::into),
1262        command => command,
1263    };
1264    if is_repository_command(&command) {
1265        return run_repository_with_bundle(
1266            command,
1267            cli.project_dir,
1268            cli.json,
1269            cli.skills_bundle_dir.as_deref(),
1270            output,
1271        )
1272        .map_err(Into::into);
1273    }
1274    if let hub @ (Command::Clone(_) | Command::Publish(_)) = command {
1275        return hub_publish::run_hub_command(hub, cli.project, cli.project_dir, cli.json, output);
1276    }
1277    if let Command::Ontology { command } = &command
1278        && let Some(result) = ontology_cli::run_without_project(command, cli.json, output)
1279    {
1280        return result.map(|()| 0).map_err(Into::into);
1281    }
1282    // Repository-independent portable commands must not call RepositoryContext::discover.
1283    if let Command::Portable { command } = command {
1284        match command {
1285            portable_cli::PortableCommand::Verify(_)
1286            | portable_cli::PortableCommand::PublishOci(_)
1287            | portable_cli::PortableCommand::PullOci(_) => {
1288                return portable_cli::run_portable_without_graph(
1289                    Path::new(""),
1290                    command,
1291                    cli.json,
1292                    output,
1293                    allocation,
1294                )
1295                .map(|()| 0);
1296            }
1297            portable_cli::PortableCommand::Import(_) => {
1298                let path = resolve_project_path(cli.project, cli.project_dir)?;
1299                return portable_cli::run_portable_without_graph(
1300                    &path, command, cli.json, output, allocation,
1301                )
1302                .map(|()| 0);
1303            }
1304            command => {
1305                let path = resolve_project_path(cli.project, cli.project_dir)?;
1306                let path_text = path.to_str().ok_or_else(|| {
1307                    graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
1308                })?;
1309                let mut graph = open_cli_graph(Path::new(path_text), allocation)?;
1310                return portable_cli::run_portable(
1311                    &mut graph, &path, command, cli.json, output, allocation,
1312                )
1313                .map(|()| 0);
1314            }
1315        }
1316    }
1317    let path = resolve_project_path(cli.project, cli.project_dir)?;
1318    let command = match command {
1319        Command::Import(args) => {
1320            return run_import(args, &path, cli.json, output)
1321                .map(|()| 0)
1322                .map_err(Into::into);
1323        }
1324        command => command,
1325    };
1326    if handle_revert_before_open(&command, &path, cli.json, output)? {
1327        return Ok(0);
1328    }
1329    let path_text = path.to_str().ok_or_else(|| {
1330        graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
1331    })?;
1332    let mut graph = open_cli_graph(Path::new(path_text), allocation)?;
1333    let command = match command {
1334        Command::Export(args) => {
1335            return run_export(&graph, args, cli.json, output)
1336                .map(|()| 0)
1337                .map_err(Into::into);
1338        }
1339        Command::SavedQuery { command } => {
1340            return saved_queries_cli::run(&mut graph, command, cli.json, output)
1341                .map(|()| 0)
1342                .map_err(Into::into);
1343        }
1344        Command::Query(args) => {
1345            return portable_cli::run_query(&graph, &args, cli.json, output)
1346                .map(|()| 0)
1347                .map_err(Into::into);
1348        }
1349        Command::ImportSession { command } => {
1350            return portable_cli::run_import_session(&graph, command, cli.json, output)
1351                .map(|()| 0)
1352                .map_err(Into::into);
1353        }
1354        Command::Recovery => {
1355            return maintenance_cli::run_recovery(&graph, cli.json, output)
1356                .map(|()| 0)
1357                .map_err(Into::into);
1358        }
1359        Command::StorageAttribution(args) => {
1360            return storage_attribution_cli::run_storage_attribution(
1361                graph, &path, &args, cli.json, output, allocation,
1362            )
1363            .map(|()| 0)
1364            .map_err(Into::into);
1365        }
1366        Command::Transaction { command } => {
1367            return maintenance_cli::run_transaction(&graph, command, cli.json, output)
1368                .map(|()| 0)
1369                .map_err(Into::into);
1370        }
1371        Command::Maintenance { command } => {
1372            return maintenance_cli::run_maintenance(&mut graph, command, cli.json, output)
1373                .map(|()| 0)
1374                .map_err(Into::into);
1375        }
1376        Command::Ontology { command } => {
1377            return ontology_cli::run_ontology(&mut graph, command, cli.json, output).map(|()| 0);
1378        }
1379        Command::Research { command } => {
1380            return research_cli::run_research(&mut graph, command, cli.json, output).map(|()| 0);
1381        }
1382        Command::SourceArtifact { command } => {
1383            return source_artifact_cli::run_source_artifact(&mut graph, command, cli.json, output)
1384                .map(|()| 0)
1385                .map_err(Into::into);
1386        }
1387        command => command,
1388    };
1389    let command = match command {
1390        Command::Revert(args)
1391        | Command::Checkpoint {
1392            command: CheckpointCommand::Revert(args),
1393        } => {
1394            return run_revert(&mut graph, args, cli.json, output)
1395                .map(|()| 0)
1396                .map_err(Into::into);
1397        }
1398        command => command,
1399    };
1400    let result = match command {
1401        Command::Checkpoint { command } => match command {
1402            CheckpointCommand::Create(args) => graph.checkpoint(CheckpointRequest {
1403                name: args.name,
1404                description: args.description,
1405                idempotency_key: OperationId(canonical_uuid(&args.idempotency_key)?),
1406                actor_uuid: actor(args.actor_uuid.as_deref())?,
1407            })?,
1408            CheckpointCommand::List(args) => {
1409                graph.list_checkpoints(ListCheckpointsRequest { page: page(&args)? })?
1410            }
1411            CheckpointCommand::Show(args) => {
1412                graph.show_checkpoint(ShowCheckpointRequest { name: args.name })?
1413            }
1414            CheckpointCommand::Open(args) => {
1415                let view = graph.open_checkpoint(&args.name)?;
1416                view.execute(&args.query.join(" "))?
1417            }
1418            CheckpointCommand::Delete(args) => {
1419                graph.delete_checkpoint(DeleteCheckpointRequest {
1420                    name: args.name,
1421                    idempotency_key: OperationId(canonical_uuid(&args.idempotency_key)?),
1422                    actor_uuid: actor(args.actor_uuid.as_deref())?,
1423                })?
1424            }
1425            CheckpointCommand::Diff(args) => graph.diff_checkpoints(DiffCheckpointsRequest {
1426                from: selector(args.from, args.from_current, "from")?,
1427                to: selector(args.to, args.to_current, "to")?,
1428                scope: match args.scope {
1429                    ScopeArg::Summary => CheckpointDiffScope::Summary,
1430                    ScopeArg::Graph => CheckpointDiffScope::Graph,
1431                    ScopeArg::Ontology => CheckpointDiffScope::Ontology,
1432                    ScopeArg::Configuration => CheckpointDiffScope::Configuration,
1433                    ScopeArg::Capabilities => CheckpointDiffScope::Capabilities,
1434                    ScopeArg::Provenance => CheckpointDiffScope::Provenance,
1435                    ScopeArg::Knowledge => CheckpointDiffScope::Knowledge,
1436                    ScopeArg::Epistemic => CheckpointDiffScope::Epistemic,
1437                    ScopeArg::All => CheckpointDiffScope::All,
1438                },
1439                detail: match args.detail {
1440                    DetailArg::Summary => CheckpointDiffDetail::Summary,
1441                    DetailArg::Records => CheckpointDiffDetail::Records,
1442                },
1443                page: page(&args.page)?,
1444            })?,
1445            CheckpointCommand::Revert(_) => unreachable!("revert handled before result dispatch"),
1446        },
1447        _ => unreachable!(),
1448    };
1449    write_execution_result(&result, cli.json, output)?;
1450    Ok(0)
1451}
1452
1453/// Captured result of one Rust-owned CLI invocation.
1454#[derive(Clone, Debug, Eq, PartialEq)]
1455pub struct CliExecution {
1456    /// Process-compatible exit status.
1457    pub exit_code: i32,
1458    /// Exact standard-output bytes, including Arrow IPC results.
1459    pub stdout: Vec<u8>,
1460    /// Exact standard-error bytes.
1461    pub stderr: Vec<u8>,
1462}
1463
1464/// Parse and execute the GraphForge CLI without terminating the host process.
1465pub fn execute<I, T>(args: I) -> CliExecution
1466where
1467    I: IntoIterator<Item = T>,
1468    T: Into<std::ffi::OsString> + Clone,
1469{
1470    let mut stdout = Vec::new();
1471    let mut stderr = Vec::new();
1472    let mut args: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
1473    if args.is_empty() {
1474        args.push("graphforge".into());
1475    } else {
1476        args[0] = "graphforge".into();
1477    }
1478    let json = args.iter().any(|arg| arg == "--json");
1479    let cli = match Cli::try_parse_from(&args) {
1480        Ok(cli) => cli,
1481        Err(error) => {
1482            let exit_code = error.exit_code();
1483            if matches!(
1484                error.kind(),
1485                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
1486            ) {
1487                stdout = error.to_string().into_bytes();
1488            } else if json {
1489                write_json_error(
1490                    &mut stderr,
1491                    "GF_VALIDATION",
1492                    &error.to_string(),
1493                    JsonErrorDetails {
1494                        source: "argument_parser",
1495                        kind: clap_error_kind(error.kind()),
1496                        semantic_code: None,
1497                    },
1498                )
1499                .expect("writing to Vec cannot fail");
1500            } else {
1501                stderr = error.to_string().into_bytes();
1502            }
1503            return CliExecution {
1504                exit_code,
1505                stdout,
1506                stderr,
1507            };
1508        }
1509    };
1510    let json = cli.json;
1511    let exit_code = match run(cli, &mut stdout) {
1512        Ok(exit_code) => exit_code,
1513        Err(error) => {
1514            write_runtime_error(&error, json, &mut stderr).expect("writing to Vec cannot fail");
1515            error_exit_code(&error)
1516        }
1517    };
1518    CliExecution {
1519        exit_code,
1520        stdout,
1521        stderr,
1522    }
1523}
1524
1525fn write_error(
1526    error: &graphforge_api::GfError,
1527    json: bool,
1528    output: &mut dyn Write,
1529) -> io::Result<()> {
1530    if json {
1531        write_json_error(
1532            output,
1533            error.code(),
1534            &error.to_string(),
1535            JsonErrorDetails {
1536                source: "runtime",
1537                kind: runtime_error_kind(error),
1538                semantic_code: stable_semantic_code(error),
1539            },
1540        )
1541    } else {
1542        writeln!(output, "{}: {error}", error.code())
1543    }
1544}
1545
1546fn write_runtime_error(
1547    error: &CliRuntimeError,
1548    json: bool,
1549    output: &mut dyn Write,
1550) -> io::Result<()> {
1551    match error {
1552        CliRuntimeError::Core(error) => write_error(error, json, output),
1553        CliRuntimeError::MultiOntology(error) if json => {
1554            #[derive(serde::Serialize)]
1555            struct Envelope<'a> {
1556                error: &'a graphforge_api::MultiOntologyError,
1557            }
1558            serde_json::to_writer(&mut *output, &Envelope { error }).map_err(io::Error::other)?;
1559            writeln!(output)
1560        }
1561        CliRuntimeError::MultiOntology(error) => {
1562            writeln!(output, "{}: {}", error.code, error.message)
1563        }
1564    }
1565}
1566
1567#[derive(Clone, Copy)]
1568struct JsonErrorDetails<'a> {
1569    source: &'static str,
1570    kind: &'static str,
1571    semantic_code: Option<&'a str>,
1572}
1573
1574fn write_json_error(
1575    output: &mut dyn Write,
1576    code: &'static str,
1577    message: &str,
1578    details: JsonErrorDetails<'_>,
1579) -> io::Result<()> {
1580    let code = serde_json::to_string(code).map_err(io::Error::other)?;
1581    let message = serde_json::to_string(message).map_err(io::Error::other)?;
1582    let source = serde_json::to_string(details.source).map_err(io::Error::other)?;
1583    let kind = serde_json::to_string(details.kind).map_err(io::Error::other)?;
1584    if let Some(semantic_code) = details.semantic_code {
1585        let semantic_code = serde_json::to_string(semantic_code).map_err(io::Error::other)?;
1586        writeln!(
1587            output,
1588            "{{\"error\":{{\"code\":{code},\"message\":{message},\"details\":{{\"source\":{source},\"kind\":{kind},\"semantic_code\":{semantic_code}}}}}}}"
1589        )
1590    } else {
1591        writeln!(
1592            output,
1593            "{{\"error\":{{\"code\":{code},\"message\":{message},\"details\":{{\"source\":{source},\"kind\":{kind}}}}}}}"
1594        )
1595    }
1596}
1597
1598fn stable_semantic_code(error: &graphforge_api::GfError) -> Option<&str> {
1599    match error.code() {
1600        "GF_CANCELLED" => return Some("lifecycle.cancelled"),
1601        "GF_RESOURCE_LIMIT" => return Some("resource.bytes"),
1602        "GF_TRANSACTION_CONFLICT" => return Some("inventory.generation_conflict"),
1603        _ => {}
1604    }
1605    let message = match error {
1606        graphforge_api::GfError::Validation(message)
1607        | graphforge_api::GfError::Execution(message)
1608        | graphforge_api::GfError::Storage(message)
1609        | graphforge_api::GfError::Ontology(message) => message.as_str(),
1610        _ => return None,
1611    };
1612    let candidate = message.split_once(':')?.0;
1613    if candidate.contains('.')
1614        && candidate
1615            .bytes()
1616            .all(|byte| byte.is_ascii_lowercase() || byte == b'.' || byte == b'_')
1617    {
1618        return Some(candidate);
1619    }
1620    match candidate {
1621        "Cancelled" => Some("lifecycle.cancelled"),
1622        "LimitExceeded" => Some("resource.bytes"),
1623        "UnsupportedFuture" => Some("interchange.unsupported_future"),
1624        "DigestMismatch" | "DuplicateEntry" | "InvalidStructure" => Some("interchange.integrity"),
1625        "ConcurrentMutation" => Some("inventory.generation_conflict"),
1626        "Incompatible" => Some("interchange.selection"),
1627        "Io" | "InvalidPath" => Some("interchange.io"),
1628        _ if message.contains("dependency-blocked") => Some("dependency.in_use"),
1629        _ if message.contains("selector is ambiguous") => Some("resolution.ambiguous"),
1630        _ if message.contains("not found") || message.contains("is absent") => {
1631            Some("inventory.not_found")
1632        }
1633        _ if message.contains("generation is stale")
1634            || message.contains("fingerprint is stale") =>
1635        {
1636            Some("inventory.generation_conflict")
1637        }
1638        _ => None,
1639    }
1640}
1641
1642const fn clap_error_kind(kind: ErrorKind) -> &'static str {
1643    match kind {
1644        ErrorKind::InvalidValue => "invalid_value",
1645        ErrorKind::UnknownArgument => "unknown_argument",
1646        ErrorKind::InvalidSubcommand => "invalid_subcommand",
1647        ErrorKind::NoEquals => "missing_equals",
1648        ErrorKind::ValueValidation => "value_validation",
1649        ErrorKind::TooManyValues => "too_many_values",
1650        ErrorKind::TooFewValues => "too_few_values",
1651        ErrorKind::WrongNumberOfValues => "wrong_number_of_values",
1652        ErrorKind::ArgumentConflict => "argument_conflict",
1653        ErrorKind::MissingRequiredArgument => "missing_required_argument",
1654        ErrorKind::MissingSubcommand => "missing_subcommand",
1655        ErrorKind::DisplayHelp => "display_help",
1656        ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => "display_help_on_missing_input",
1657        ErrorKind::DisplayVersion => "display_version",
1658        ErrorKind::Io => "io",
1659        ErrorKind::Format => "format",
1660        _ => "other",
1661    }
1662}
1663
1664const fn runtime_error_kind(error: &graphforge_api::GfError) -> &'static str {
1665    match error {
1666        graphforge_api::GfError::NotImplemented(_) => "not_implemented",
1667        graphforge_api::GfError::Parse { .. } => "parse",
1668        graphforge_api::GfError::Bind { .. } => "bind",
1669        graphforge_api::GfError::LoweringExecution(_) | graphforge_api::GfError::Execution(_) => {
1670            "execution"
1671        }
1672        graphforge_api::GfError::Lowering(error) => error.fault_domain(),
1673        graphforge_api::GfError::Algorithm(error) => error.fault_domain(),
1674        graphforge_api::GfError::BindValidation { .. } | graphforge_api::GfError::Validation(_) => {
1675            "validation"
1676        }
1677        graphforge_api::GfError::BindPlan { .. } | graphforge_api::GfError::Plan(_) => "plan",
1678        graphforge_api::GfError::Provider { .. } => "provider",
1679        graphforge_api::GfError::Storage(_) => "storage",
1680        graphforge_api::GfError::Project { .. } => "project",
1681        graphforge_api::GfError::Api { .. } => "api",
1682        graphforge_api::GfError::Lifecycle(_) => "lifecycle",
1683        graphforge_api::GfError::Ontology(_) => "ontology",
1684    }
1685}
1686
1687fn error_exit_code(error: &CliRuntimeError) -> i32 {
1688    match error {
1689        CliRuntimeError::Core(error) if error.code() == "GF_VALIDATION" => 2,
1690        CliRuntimeError::Core(graphforge_api::GfError::Storage(_)) => 3,
1691        CliRuntimeError::MultiOntology(error) if error.code == "GF_VALIDATION" => 2,
1692        _ => 1,
1693    }
1694}
1695
1696/// Stream process output directly and terminate with the native CLI exit status.
1697pub fn run_process() {
1698    let cli = Cli::parse();
1699    let json = cli.json;
1700    let mut output = io::stdout().lock();
1701    hub_publish::allow_device_flow();
1702    hub_clone::attach_to_process();
1703    match run(cli, &mut output) {
1704        Ok(exit_code) if exit_code != 0 => {
1705            let _ = output.flush();
1706            std::process::exit(exit_code);
1707        }
1708        Ok(_) => {}
1709        Err(error) => {
1710            let _ = output.flush();
1711            let stderr = io::stderr();
1712            write_runtime_error(&error, json, &mut stderr.lock()).expect("write CLI stderr");
1713            std::process::exit(error_exit_code(&error));
1714        }
1715    }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use super::*;
1721    use std::fs;
1722    use tempfile::tempdir;
1723
1724    fn write_loader_manifest(root: &Path, paths: &[String]) {
1725        fs::write(
1726            root.join("manifest.json"),
1727            serde_json::to_vec(&serde_json::json!({
1728                "files": paths.iter().map(|path| serde_json::json!({"path": path})).collect::<Vec<_>>()
1729            }))
1730            .unwrap(),
1731        )
1732        .unwrap();
1733    }
1734
1735    #[test]
1736    fn bounded_file_and_skill_entry_validation_fail_before_payload_use() {
1737        let root = tempdir().unwrap();
1738        let file = root.path().join("skill.md");
1739        fs::write(&file, b"abcd").unwrap();
1740        assert_eq!(read_bounded_file(&file, 4, "bounded").unwrap(), b"abcd");
1741        assert!(matches!(
1742            read_bounded_file(&file, 3, "bounded"),
1743            Err(graphforge_api::GfError::Validation(message)) if message == "bounded"
1744        ));
1745        assert!(matches!(
1746            read_bounded_file(root.path(), 100, "regular"),
1747            Err(graphforge_api::GfError::Validation(message)) if message == "regular"
1748        ));
1749        assert!(matches!(
1750            read_bounded_file(&root.path().join("absent"), 100, "missing"),
1751            Err(graphforge_api::GfError::Storage(_))
1752        ));
1753
1754        assert!(matches!(
1755            load_skill_file(root.path(), &serde_json::json!({})),
1756            Err(graphforge_api::GfError::Validation(message))
1757                if message == "project skill file path is required"
1758        ));
1759        for path in ["", "../escape", "/absolute"] {
1760            assert!(matches!(
1761                load_skill_file(root.path(), &serde_json::json!({"path": path})),
1762                Err(graphforge_api::GfError::Validation(_))
1763            ));
1764        }
1765        assert!(matches!(
1766            load_skill_file(
1767                root.path(),
1768                &serde_json::json!({"path": "missing/skill.md"})
1769            ),
1770            Err(graphforge_api::GfError::Storage(_))
1771        ));
1772    }
1773
1774    #[cfg(unix)]
1775    #[test]
1776    fn wave11_skill_bundle_rejects_linked_components_nonfiles_and_entry_overflow() {
1777        use std::os::unix::fs::symlink;
1778
1779        let external = tempdir().unwrap();
1780        fs::write(external.path().join("SKILL.md"), b"external").unwrap();
1781        let root = tempdir().unwrap();
1782        symlink(external.path(), root.path().join("linked")).unwrap();
1783        assert!(matches!(
1784            load_skill_file(
1785                root.path(),
1786                &serde_json::json!({"path": "linked/SKILL.md"})
1787            ),
1788            Err(graphforge_api::GfError::Validation(message))
1789                if message.contains("must not contain symlinks")
1790        ));
1791        assert_eq!(
1792            fs::read(external.path().join("SKILL.md")).unwrap(),
1793            b"external"
1794        );
1795
1796        let root = tempdir().unwrap();
1797        fs::create_dir(root.path().join("directory.md")).unwrap();
1798        assert!(matches!(
1799            load_skill_file(
1800                root.path(),
1801                &serde_json::json!({"path": "directory.md"})
1802            ),
1803            Err(graphforge_api::GfError::Validation(message))
1804                if message.contains("must be a real file")
1805        ));
1806
1807        let root = tempdir().unwrap();
1808        let paths = (0..=MAX_SKILL_BUNDLE_FILES)
1809            .map(|index| format!("skill-{index}.md"))
1810            .collect::<Vec<_>>();
1811        write_loader_manifest(root.path(), &paths);
1812        assert!(matches!(
1813            load_skill_bundle(root.path()),
1814            Err(graphforge_api::GfError::Validation(message))
1815                if message.contains("exceeds file bound")
1816        ));
1817
1818        let root = tempdir().unwrap();
1819        fs::create_dir(root.path().join("manifest.json")).unwrap();
1820        assert!(matches!(
1821            load_skill_bundle(root.path()),
1822            Err(graphforge_api::GfError::Validation(message))
1823                if message.contains("manifest must be a real file")
1824        ));
1825
1826        let root = tempdir().unwrap();
1827        fs::File::create(root.path().join("manifest.json"))
1828            .unwrap()
1829            .set_len(MAX_SKILL_MANIFEST_BYTES + 1)
1830            .unwrap();
1831        assert!(matches!(
1832            load_skill_bundle(root.path()),
1833            Err(graphforge_api::GfError::Validation(message))
1834                if message.contains("manifest exceeds byte bound")
1835        ));
1836    }
1837
1838    #[test]
1839    fn wave11_portable_and_preview_output_contracts_cover_plain_json_and_write_failure() {
1840        struct FailWrite;
1841        impl Write for FailWrite {
1842            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1843                Err(io::Error::other("closed output"))
1844            }
1845            fn flush(&mut self) -> io::Result<()> {
1846                Ok(())
1847            }
1848        }
1849
1850        let source = Uuid::from_u128(1);
1851        let generation = Uuid::from_u128(2);
1852        for replay in [false, true] {
1853            let result = PortableImportResult {
1854                contract: "graphforge-portable-import/1",
1855                source_generation_uuid: source,
1856                generation_uuid: generation,
1857                envelope_sha256: "00".repeat(32),
1858                idempotent_replay: replay,
1859            };
1860            let mut plain = Vec::new();
1861            write_import_result(&result, false, &mut plain).unwrap();
1862            let text = String::from_utf8(plain).unwrap();
1863            assert!(text.starts_with(if replay { "replayed" } else { "imported" }));
1864            let mut json = Vec::new();
1865            write_import_result(&result, true, &mut json).unwrap();
1866            assert_eq!(
1867                serde_json::from_slice::<serde_json::Value>(&json).unwrap()["idempotent_replay"],
1868                replay
1869            );
1870            assert!(matches!(
1871                write_import_result(&result, replay, &mut FailWrite),
1872                Err(graphforge_api::GfError::Execution(_))
1873            ));
1874        }
1875
1876        let preview = RevertCheckpointPreview {
1877            checkpoint_uuid: Uuid::from_u128(3),
1878            source_generation_uuid: source,
1879            source_manifest_sha256: "11".repeat(32),
1880            current_generation_uuid: generation,
1881        };
1882        for json in [false, true] {
1883            let mut output = Vec::new();
1884            write_revert_preview(&preview, json, &mut output).unwrap();
1885            assert!(!output.is_empty());
1886            assert!(matches!(
1887                write_revert_preview(&preview, json, &mut FailWrite),
1888                Err(graphforge_api::GfError::Execution(_))
1889            ));
1890        }
1891    }
1892
1893    #[test]
1894    fn wave11_reusable_execution_separates_plain_parse_and_runtime_failures() {
1895        let parse = execute(["launcher", "unknown-command"]);
1896        assert_eq!(parse.exit_code, 2);
1897        assert!(parse.stdout.is_empty());
1898        assert!(
1899            String::from_utf8(parse.stderr)
1900                .unwrap()
1901                .contains("unrecognized subcommand")
1902        );
1903
1904        let root = tempdir().unwrap();
1905        let runtime = execute([
1906            "launcher",
1907            "--project",
1908            root.path().to_str().unwrap(),
1909            "checkpoint",
1910            "create",
1911            "snapshot",
1912            "--idempotency-key",
1913            "not-a-uuid",
1914        ]);
1915        assert_ne!(runtime.exit_code, 0);
1916        assert!(runtime.stdout.is_empty());
1917        assert!(
1918            String::from_utf8(runtime.stderr)
1919                .unwrap()
1920                .starts_with("GF_")
1921        );
1922    }
1923
1924    #[test]
1925    fn telemetry_command_projects_the_rust_owned_disabled_lifecycle() {
1926        let execution = execute(["gf", "telemetry"]);
1927        assert_eq!(execution.exit_code, 0);
1928        assert!(execution.stderr.is_empty());
1929        let output: serde_json::Value = serde_json::from_slice(&execution.stdout).unwrap();
1930        assert_eq!(output["semantic_contract_version"], 1);
1931        assert_eq!(output["scope"], "io.graphforge.engine");
1932        assert_eq!(output["mode"], "disabled");
1933        assert_eq!(output["flush"], "disabled");
1934        assert_eq!(output["shutdown"], "disabled");
1935    }
1936
1937    #[test]
1938    fn reusable_execution_normalizes_identity_and_captures_structured_errors() {
1939        let version = execute(["arbitrary-launcher", "--version"]);
1940        assert_eq!(version.exit_code, 0);
1941        assert_eq!(
1942            String::from_utf8(version.stdout).expect("UTF-8 version"),
1943            format!("graphforge {}\n", env!("CARGO_PKG_VERSION"))
1944        );
1945        assert!(version.stderr.is_empty());
1946
1947        let invalid = execute(["another-launcher", "--json", "unknown-command"]);
1948        assert_eq!(invalid.exit_code, 2);
1949        assert!(invalid.stdout.is_empty());
1950        let error: serde_json::Value =
1951            serde_json::from_slice(&invalid.stderr).expect("structured CLI error");
1952        assert_eq!(error["error"]["code"], "GF_VALIDATION");
1953        assert_eq!(
1954            error["error"]["details"],
1955            serde_json::json!({
1956                "source": "argument_parser",
1957                "kind": "invalid_subcommand"
1958            })
1959        );
1960        assert!(
1961            String::from_utf8(invalid.stderr.clone())
1962                .unwrap()
1963                .starts_with("{\"error\":{\"code\":\"GF_VALIDATION\",\"message\":")
1964        );
1965        assert!(
1966            error["error"]["message"]
1967                .as_str()
1968                .expect("error message")
1969                .contains("Usage: graphforge")
1970        );
1971    }
1972
1973    #[test]
1974    fn runtime_json_errors_include_stable_safe_details_without_changing_message() {
1975        let cases = [
1976            (
1977                graphforge_api::GfError::Validation("invalid repository input".into()),
1978                "GF_VALIDATION",
1979                "validation",
1980                2,
1981            ),
1982            (
1983                graphforge_api::GfError::Storage("unavailable".into()),
1984                "GF_IO",
1985                "storage",
1986                3,
1987            ),
1988        ];
1989        for (error, code, kind, exit_code) in cases {
1990            let message = error.to_string();
1991            let mut output = Vec::new();
1992            write_error(&error, true, &mut output).unwrap();
1993            let serialized = String::from_utf8(output.clone()).unwrap();
1994            assert!(
1995                serialized.starts_with(&format!("{{\"error\":{{\"code\":\"{code}\",\"message\":"))
1996            );
1997            assert!(
1998                serialized.find("\"message\":").unwrap() < serialized.find("\"details\":").unwrap()
1999            );
2000            let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
2001            assert_eq!(value["error"]["code"], code);
2002            assert_eq!(value["error"]["message"], message);
2003            assert_eq!(
2004                value["error"]["details"],
2005                serde_json::json!({"source": "runtime", "kind": kind})
2006            );
2007            assert_eq!(error_exit_code(&CliRuntimeError::Core(error)), exit_code);
2008        }
2009    }
2010
2011    #[test]
2012    fn runtime_json_preserves_stable_semantic_code() {
2013        let error = graphforge_api::GfError::Validation(
2014            "dependency.in_use: exact module has bounded dependants".into(),
2015        );
2016        let mut output = Vec::new();
2017        write_error(&error, true, &mut output).unwrap();
2018        let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
2019        assert_eq!(
2020            value["error"]["details"]["semantic_code"],
2021            "dependency.in_use"
2022        );
2023        assert_eq!(
2024            value["error"]["message"],
2025            "validation error: dependency.in_use: exact module has bounded dependants"
2026        );
2027    }
2028
2029    #[test]
2030    fn revert_requires_reason_and_idempotency_key() {
2031        let missing_reason = Cli::try_parse_from([
2032            "gf",
2033            "--project",
2034            "/tmp/project",
2035            "checkpoint",
2036            "revert",
2037            "before-change",
2038            "--idempotency-key",
2039            "00000000-0000-0000-0000-000000000001",
2040        ]);
2041        assert!(missing_reason.is_err());
2042
2043        let missing_key = Cli::try_parse_from([
2044            "gf",
2045            "--project",
2046            "/tmp/project",
2047            "checkpoint",
2048            "revert",
2049            "before-change",
2050            "--reason",
2051            "undo invalid import",
2052        ]);
2053        assert!(missing_key.is_err());
2054
2055        let preview = Cli::try_parse_from([
2056            "gf",
2057            "--project",
2058            "/tmp/project",
2059            "revert",
2060            "before-change",
2061            "--preview",
2062        ])
2063        .expect("preview does not require mutation identity");
2064        assert!(matches!(
2065            preview.command,
2066            Some(Command::Revert(RevertArgs { preview: true, .. }))
2067        ));
2068    }
2069
2070    #[test]
2071    fn create_and_delete_require_idempotency_keys() {
2072        for command in ["create", "delete"] {
2073            assert!(
2074                Cli::try_parse_from([
2075                    "gf",
2076                    "--project",
2077                    "/tmp/project",
2078                    "checkpoint",
2079                    command,
2080                    "before-change",
2081                ])
2082                .is_err()
2083            );
2084        }
2085    }
2086
2087    #[test]
2088    fn diff_disambiguates_current_from_a_checkpoint_named_current() {
2089        assert!(matches!(
2090            selector(Some("current".into()), false, "from").unwrap(),
2091            CheckpointSelector::Named(name) if name == "current"
2092        ));
2093        assert!(matches!(
2094            selector(None, true, "to").unwrap(),
2095            CheckpointSelector::Current
2096        ));
2097    }
2098
2099    #[test]
2100    fn canonical_uuid_rejects_noncanonical_text() {
2101        assert!(canonical_uuid("not-an-id").is_err());
2102        assert!(canonical_uuid("00000000-0000-0000-0000-00000000000A").is_err());
2103        assert!(canonical_uuid("00000000-0000-0000-0000-000000000001").is_ok());
2104    }
2105
2106    #[test]
2107    fn open_accepts_only_a_name_and_trailing_read_command() {
2108        let cli = Cli::try_parse_from([
2109            "gf",
2110            "--project",
2111            "/tmp/project",
2112            "checkpoint",
2113            "open",
2114            "before-change",
2115            "--",
2116            "MATCH",
2117            "(n)",
2118            "RETURN",
2119            "n",
2120        ])
2121        .expect("valid checkpoint read command");
2122        let Some(Command::Checkpoint {
2123            command: CheckpointCommand::Open(args),
2124        }) = cli.command
2125        else {
2126            panic!("expected checkpoint open command");
2127        };
2128        assert_eq!(args.name, "before-change");
2129        assert_eq!(args.query.join(" "), "MATCH (n) RETURN n");
2130    }
2131
2132    #[test]
2133    fn show_accepts_exactly_one_checkpoint_name() {
2134        let cli = Cli::try_parse_from([
2135            "gf",
2136            "--project",
2137            "/tmp/project",
2138            "checkpoint",
2139            "show",
2140            "before-change",
2141        ])
2142        .expect("valid checkpoint show command");
2143        assert!(matches!(
2144            cli.command,
2145            Some(Command::Checkpoint {
2146                command: CheckpointCommand::Show(ShowArgs { name })
2147            }) if name == "before-change"
2148        ));
2149    }
2150
2151    #[test]
2152    fn repository_commands_use_project_dir_and_explicit_remove_confirmation() {
2153        assert!(Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "init"]).is_ok());
2154        assert!(
2155            Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "config", "resolve"]).is_ok()
2156        );
2157        assert!(
2158            Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "sync", "--check",]).is_ok()
2159        );
2160        assert!(
2161            Cli::try_parse_from([
2162                "gf",
2163                "--project-dir",
2164                "/tmp/repo",
2165                "sync",
2166                "--check",
2167                "--idempotency-key",
2168                "41414141-4141-4141-4141-414141414141",
2169            ])
2170            .is_err()
2171        );
2172        assert!(
2173            Cli::try_parse_from([
2174                "gf",
2175                "--project-dir",
2176                "/tmp/repo",
2177                "sync",
2178                "--actor-uuid",
2179                "42424242-4242-4242-4242-424242424242",
2180            ])
2181            .is_err()
2182        );
2183        let cli = Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "remove"]).unwrap();
2184        assert!(matches!(
2185            cli.command,
2186            Some(Command::Remove(RemoveArgs { yes: false }))
2187        ));
2188    }
2189
2190    #[test]
2191    fn export_requires_exactly_one_generation_selector() {
2192        let base = ["gf", "--project", "/tmp/project", "export"];
2193        assert!(
2194            Cli::try_parse_from(base.into_iter().chain(["--output", "/tmp/out.gfportable"]))
2195                .is_err()
2196        );
2197        assert!(
2198            Cli::try_parse_from(base.into_iter().chain([
2199                "--current",
2200                "--checkpoint",
2201                "before-change",
2202                "--output",
2203                "/tmp/out.gfportable",
2204            ]))
2205            .is_err()
2206        );
2207        assert!(
2208            Cli::try_parse_from(base.into_iter().chain([
2209                "--current",
2210                "--output",
2211                "/tmp/out.gfportable",
2212            ]))
2213            .is_ok()
2214        );
2215        assert!(
2216            Cli::try_parse_from(base.into_iter().chain([
2217                "--checkpoint",
2218                "before-change",
2219                "--output",
2220                "/tmp/out.gfportable",
2221            ]))
2222            .is_ok()
2223        );
2224    }
2225
2226    #[cfg(unix)]
2227    #[test]
2228    fn packaged_skill_loader_rejects_a_manifest_symlink() {
2229        let root = tempdir().unwrap();
2230        fs::write(root.path().join("real-manifest.json"), br#"{"files":[]}"#).unwrap();
2231        std::os::unix::fs::symlink(
2232            root.path().join("real-manifest.json"),
2233            root.path().join("manifest.json"),
2234        )
2235        .unwrap();
2236        let error = load_skill_bundle(root.path()).err().unwrap();
2237        assert!(
2238            error
2239                .to_string()
2240                .contains("packaged skill manifest must be a real file")
2241        );
2242    }
2243
2244    #[test]
2245    fn packaged_skill_loader_rejects_non_file_and_oversized_manifests() {
2246        let root = tempdir().unwrap();
2247        fs::create_dir(root.path().join("manifest.json")).unwrap();
2248        assert!(
2249            load_skill_bundle(root.path())
2250                .err()
2251                .unwrap()
2252                .to_string()
2253                .contains("packaged skill manifest must be a real file")
2254        );
2255
2256        let root = tempdir().unwrap();
2257        let manifest = fs::File::create(root.path().join("manifest.json")).unwrap();
2258        manifest.set_len(MAX_SKILL_MANIFEST_BYTES + 1).unwrap();
2259        assert!(
2260            load_skill_bundle(root.path())
2261                .err()
2262                .unwrap()
2263                .to_string()
2264                .contains("packaged skill manifest exceeds byte bound")
2265        );
2266    }
2267
2268    #[test]
2269    fn packaged_skill_loader_bounds_paths_files_and_total_payload() {
2270        let root = tempdir().unwrap();
2271        write_loader_manifest(root.path(), &["x".repeat(MAX_SKILL_PATH_BYTES + 1)]);
2272        assert!(
2273            load_skill_bundle(root.path())
2274                .err()
2275                .unwrap()
2276                .to_string()
2277                .contains("project skill file path exceeds byte bound")
2278        );
2279
2280        let root = tempdir().unwrap();
2281        let path = "graphforge-bootstrap/large.md".to_owned();
2282        fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
2283        fs::File::create(root.path().join(&path))
2284            .unwrap()
2285            .set_len(MAX_SKILL_FILE_BYTES + 1)
2286            .unwrap();
2287        write_loader_manifest(root.path(), std::slice::from_ref(&path));
2288        assert!(
2289            load_skill_bundle(root.path())
2290                .err()
2291                .unwrap()
2292                .to_string()
2293                .contains("packaged project skill exceeds per-file byte bound")
2294        );
2295
2296        let root = tempdir().unwrap();
2297        fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
2298        let paths = (0..3)
2299            .map(|index| format!("graphforge-bootstrap/{index}.md"))
2300            .collect::<Vec<_>>();
2301        for path in &paths {
2302            fs::File::create(root.path().join(path))
2303                .unwrap()
2304                .set_len(3 * 1024 * 1024)
2305                .unwrap();
2306        }
2307        write_loader_manifest(root.path(), &paths);
2308        assert!(
2309            load_skill_bundle(root.path())
2310                .err()
2311                .unwrap()
2312                .to_string()
2313                .contains("packaged project skill bundle exceeds total byte bound")
2314        );
2315    }
2316
2317    #[test]
2318    fn packaged_skill_loader_accepts_exact_manifest_and_rejects_malformed_shapes() {
2319        let root = tempdir().unwrap();
2320        fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
2321        fs::write(root.path().join("graphforge-bootstrap/SKILL.md"), b"skill").unwrap();
2322        write_loader_manifest(root.path(), &["graphforge-bootstrap/SKILL.md".into()]);
2323        let loaded = load_skill_bundle(root.path()).unwrap();
2324        assert_eq!(loaded.files.len(), 1);
2325        assert_eq!(loaded.files[0].path, "graphforge-bootstrap/SKILL.md");
2326        assert_eq!(loaded.files[0].bytes, b"skill");
2327
2328        let root = tempdir().unwrap();
2329        fs::write(root.path().join("manifest.json"), b"not json").unwrap();
2330        assert!(matches!(
2331            load_skill_bundle(root.path()),
2332            Err(graphforge_api::GfError::Validation(_))
2333        ));
2334
2335        let root = tempdir().unwrap();
2336        fs::write(root.path().join("manifest.json"), br"{}").unwrap();
2337        assert!(matches!(
2338            load_skill_bundle(root.path()),
2339            Err(graphforge_api::GfError::Validation(_))
2340        ));
2341
2342        let root = tempdir().unwrap();
2343        fs::write(root.path().join("manifest.json"), br#"{"files":[{}]}"#).unwrap();
2344        assert!(matches!(
2345            load_skill_bundle(root.path()),
2346            Err(graphforge_api::GfError::Validation(_))
2347        ));
2348
2349        let root = tempdir().unwrap();
2350        fs::write(
2351            root.path().join("manifest.json"),
2352            br#"{"files":[{"path":"../escape"}]}"#,
2353        )
2354        .unwrap();
2355        assert!(matches!(
2356            load_skill_bundle(root.path()),
2357            Err(graphforge_api::GfError::Validation(_))
2358        ));
2359
2360        let file = tempfile::NamedTempFile::new().unwrap();
2361        assert!(matches!(
2362            load_skill_bundle(file.path()),
2363            Err(graphforge_api::GfError::Validation(_))
2364        ));
2365        assert!(matches!(
2366            read_bounded_file(root.path(), 10, "not a file"),
2367            Err(graphforge_api::GfError::Validation(_))
2368        ));
2369    }
2370
2371    #[test]
2372    fn reusable_execution_covers_default_info_and_runtime_error_vocabulary() {
2373        let default = execute(std::iter::empty::<&str>());
2374        assert_eq!(default.exit_code, 0);
2375        assert_eq!(
2376            default.stdout,
2377            "GraphForge — use --help for options\n".as_bytes()
2378        );
2379
2380        let info = execute(["graphforge", "--info"]);
2381        assert_eq!(info.exit_code, 0);
2382        assert!(
2383            String::from_utf8(info.stdout)
2384                .unwrap()
2385                .starts_with("graphforge ")
2386        );
2387
2388        let cases = [
2389            (
2390                graphforge_api::GfError::NotImplemented("test"),
2391                "not_implemented",
2392            ),
2393            (graphforge_api::GfError::Plan("test".into()), "plan"),
2394            (
2395                graphforge_api::GfError::Execution("test".into()),
2396                "execution",
2397            ),
2398            (
2399                graphforge_api::GfError::Provider {
2400                    class: "transport".into(),
2401                    provider: "provider".into(),
2402                    model: "model".into(),
2403                },
2404                "provider",
2405            ),
2406            (
2407                graphforge_api::GfError::Lifecycle("test".into()),
2408                "lifecycle",
2409            ),
2410            (graphforge_api::GfError::Ontology("test".into()), "ontology"),
2411        ];
2412        for (error, expected) in cases {
2413            assert_eq!(runtime_error_kind(&error), expected);
2414            let mut output = Vec::new();
2415            write_error(&error, false, &mut output).unwrap();
2416            assert!(!output.is_empty());
2417            assert_eq!(error_exit_code(&CliRuntimeError::Core(error)), 1);
2418        }
2419    }
2420
2421    #[test]
2422    fn no_skills_init_does_not_load_the_distribution_bundle() {
2423        let project = tempdir().unwrap();
2424        let missing_bundle = project.path().join("missing-bundle");
2425        let result = execute([
2426            "graphforge".to_owned(),
2427            "--project-dir".to_owned(),
2428            project.path().to_string_lossy().into_owned(),
2429            "--skills-bundle-dir".to_owned(),
2430            missing_bundle.to_string_lossy().into_owned(),
2431            "init".to_owned(),
2432            "--no-skills".to_owned(),
2433        ]);
2434        assert_eq!(
2435            result.exit_code,
2436            0,
2437            "{}",
2438            String::from_utf8_lossy(&result.stderr)
2439        );
2440        assert!(!project.path().join(".agents").exists());
2441        let validate = execute([
2442            "graphforge".to_owned(),
2443            "--project-dir".to_owned(),
2444            project.path().to_string_lossy().into_owned(),
2445            "--skills-bundle-dir".to_owned(),
2446            missing_bundle.to_string_lossy().into_owned(),
2447            "config".to_owned(),
2448            "validate".to_owned(),
2449        ]);
2450        assert_eq!(
2451            validate.exit_code,
2452            0,
2453            "{}",
2454            String::from_utf8_lossy(&validate.stderr)
2455        );
2456
2457        let remove = execute([
2458            "graphforge".to_owned(),
2459            "--project-dir".to_owned(),
2460            project.path().to_string_lossy().into_owned(),
2461            "--skills-bundle-dir".to_owned(),
2462            missing_bundle.to_string_lossy().into_owned(),
2463            "skills".to_owned(),
2464            "remove".to_owned(),
2465        ]);
2466        assert_eq!(
2467            remove.exit_code,
2468            0,
2469            "{}",
2470            String::from_utf8_lossy(&remove.stderr)
2471        );
2472    }
2473
2474    #[test]
2475    fn repository_commands_execute_through_json_and_plain_dispatch() {
2476        let project = tempdir().unwrap();
2477        let root = project.path().to_string_lossy().into_owned();
2478        let init = execute([
2479            "graphforge".to_owned(),
2480            "--json".to_owned(),
2481            "--project-dir".to_owned(),
2482            root.clone(),
2483            "init".to_owned(),
2484            "--no-skills".to_owned(),
2485        ]);
2486        assert_eq!(
2487            init.exit_code,
2488            0,
2489            "{}",
2490            String::from_utf8_lossy(&init.stderr)
2491        );
2492        let init_json: serde_json::Value = serde_json::from_slice(&init.stdout).unwrap();
2493        assert_eq!(init_json["root"], ".");
2494
2495        for tail in [
2496            vec!["config", "resolve"],
2497            vec!["config", "validate"],
2498            vec!["sync", "--check"],
2499        ] {
2500            let mut args = vec![
2501                "graphforge".to_owned(),
2502                "--json".to_owned(),
2503                "--project-dir".to_owned(),
2504                root.clone(),
2505            ];
2506            args.extend(tail.into_iter().map(str::to_owned));
2507            let result = execute(args);
2508            assert!(
2509                result.exit_code == 0 || result.exit_code == OUT_OF_SYNC_EXIT_CODE,
2510                "{}",
2511                String::from_utf8_lossy(&result.stderr)
2512            );
2513            assert!(serde_json::from_slice::<serde_json::Value>(&result.stdout).is_ok());
2514        }
2515
2516        let remove = execute([
2517            "graphforge".to_owned(),
2518            "--project-dir".to_owned(),
2519            root,
2520            "remove".to_owned(),
2521            "--yes".to_owned(),
2522        ]);
2523        assert_eq!(
2524            remove.exit_code,
2525            0,
2526            "{}",
2527            String::from_utf8_lossy(&remove.stderr)
2528        );
2529        assert_eq!(remove.stdout, b"ok\n");
2530    }
2531
2532    #[test]
2533    #[allow(clippy::too_many_lines)]
2534    fn checkpoint_commands_execute_end_to_end_through_the_reusable_cli() {
2535        let project = tempdir().unwrap();
2536        let path = project.path().join("state");
2537        fs::create_dir(&path).unwrap();
2538        let path = path.to_string_lossy().into_owned();
2539        let invoke = |tail: &[&str]| {
2540            let mut args = vec![
2541                "graphforge".to_owned(),
2542                "--json".to_owned(),
2543                "--project".to_owned(),
2544                path.clone(),
2545            ];
2546            args.extend(tail.iter().map(|value| (*value).to_owned()));
2547            execute(args)
2548        };
2549        let create_id = Uuid::now_v7().to_string();
2550        let create = invoke(&[
2551            "checkpoint",
2552            "create",
2553            "baseline",
2554            "--description",
2555            "before change",
2556            "--idempotency-key",
2557            &create_id,
2558        ]);
2559        assert_eq!(
2560            create.exit_code,
2561            0,
2562            "{}",
2563            String::from_utf8_lossy(&create.stderr)
2564        );
2565
2566        for command in [
2567            vec!["checkpoint", "list", "--limit", "10"],
2568            vec!["checkpoint", "show", "baseline"],
2569            vec![
2570                "checkpoint",
2571                "open",
2572                "baseline",
2573                "--",
2574                "RETURN",
2575                "1",
2576                "AS",
2577                "value",
2578            ],
2579            vec![
2580                "checkpoint",
2581                "diff",
2582                "--from",
2583                "baseline",
2584                "--to-current",
2585                "--scope",
2586                "summary",
2587                "--detail",
2588                "summary",
2589            ],
2590            vec!["checkpoint", "revert", "baseline", "--preview"],
2591        ] {
2592            let result = invoke(&command);
2593            assert_eq!(
2594                result.exit_code,
2595                0,
2596                "command={command:?}: {}",
2597                String::from_utf8_lossy(&result.stderr)
2598            );
2599            assert!(!result.stdout.is_empty());
2600        }
2601
2602        for scope in [
2603            "graph",
2604            "ontology",
2605            "configuration",
2606            "capabilities",
2607            "provenance",
2608            "knowledge",
2609            "epistemic",
2610            "all",
2611        ] {
2612            let result = invoke(&[
2613                "checkpoint",
2614                "diff",
2615                "--from",
2616                "baseline",
2617                "--to-current",
2618                "--scope",
2619                scope,
2620                "--detail",
2621                "records",
2622            ]);
2623            assert_eq!(
2624                result.exit_code,
2625                0,
2626                "scope={scope}: {}",
2627                String::from_utf8_lossy(&result.stderr)
2628            );
2629        }
2630
2631        let revert_id = Uuid::now_v7().to_string();
2632        let revert = invoke(&[
2633            "checkpoint",
2634            "revert",
2635            "baseline",
2636            "--reason",
2637            "coverage contract",
2638            "--idempotency-key",
2639            &revert_id,
2640            "--yes",
2641        ]);
2642        assert_eq!(
2643            revert.exit_code,
2644            0,
2645            "{}",
2646            String::from_utf8_lossy(&revert.stderr)
2647        );
2648
2649        let envelope = project.path().join("baseline.gfportable");
2650        let envelope_text = envelope.to_string_lossy().into_owned();
2651        let export = invoke(&[
2652            "export",
2653            "--checkpoint",
2654            "baseline",
2655            "--output",
2656            &envelope_text,
2657        ]);
2658        assert_eq!(
2659            export.exit_code,
2660            0,
2661            "{}",
2662            String::from_utf8_lossy(&export.stderr)
2663        );
2664        assert!(envelope.is_file());
2665
2666        let imported_path = project.path().join("imported");
2667        fs::create_dir(&imported_path).unwrap();
2668        let imported = imported_path.to_string_lossy().into_owned();
2669        let import_id = Uuid::now_v7().to_string();
2670        let import = execute([
2671            "graphforge".to_owned(),
2672            "--json".to_owned(),
2673            "--project".to_owned(),
2674            imported,
2675            "import".to_owned(),
2676            "--input".to_owned(),
2677            envelope_text,
2678            "--idempotency-key".to_owned(),
2679            import_id,
2680        ]);
2681        assert_eq!(
2682            import.exit_code,
2683            0,
2684            "{}",
2685            String::from_utf8_lossy(&import.stderr)
2686        );
2687
2688        let delete_id = Uuid::now_v7().to_string();
2689        let delete = invoke(&[
2690            "checkpoint",
2691            "delete",
2692            "baseline",
2693            "--idempotency-key",
2694            &delete_id,
2695        ]);
2696        assert_eq!(
2697            delete.exit_code,
2698            0,
2699            "{}",
2700            String::from_utf8_lossy(&delete.stderr)
2701        );
2702    }
2703
2704    #[test]
2705    fn storage_attribution_json_is_closed_sanitized_and_reopen_stable() {
2706        let project = tempdir().unwrap();
2707        let path = project.path().join("state");
2708        fs::create_dir(&path).unwrap();
2709        let result = execute([
2710            "graphforge".to_owned(),
2711            "--json".to_owned(),
2712            "--project".to_owned(),
2713            path.to_string_lossy().into_owned(),
2714            "storage-attribution".to_owned(),
2715        ]);
2716        assert_eq!(
2717            result.exit_code,
2718            0,
2719            "{}",
2720            String::from_utf8_lossy(&result.stderr)
2721        );
2722        let json: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
2723        assert_eq!(json["contract"], "graphforge-storage-attribution-command/1");
2724        assert_eq!(
2725            json["storage"]["contract"],
2726            "graphforge-storage-attribution/1"
2727        );
2728        assert_eq!(json["reopen_agrees"], true);
2729        assert_eq!(
2730            json["storage"]["categories"].as_object().unwrap().len(),
2731            graphforge_api::ArtifactCategory::ALL.len()
2732        );
2733        // #1389: the reopen phase now carries its own closed per-phase I/O
2734        // attribution, in the shape construction already emits.
2735        let application_io = json["application_io"].as_object().unwrap();
2736        assert_eq!(application_io.len(), 2);
2737        let phases = application_io["phases"].as_object().unwrap();
2738        assert_eq!(
2739            phases.len(),
2740            graphforge_api::StorageIoPhase::LIFECYCLE.len()
2741        );
2742        assert!(phases.contains_key("read_path_scan"));
2743        assert!(phases.contains_key("hydration_verification"));
2744        for field in [
2745            "read_bytes",
2746            "write_bytes",
2747            "read_calls",
2748            "write_calls",
2749            "object_count",
2750            "block_count",
2751            "fsync_calls",
2752        ] {
2753            let expected: u64 = phases
2754                .values()
2755                .map(|row| row[field].as_u64().unwrap())
2756                .sum();
2757            assert_eq!(
2758                application_io["totals"][field].as_u64().unwrap(),
2759                expected,
2760                "{field} does not reconcile"
2761            );
2762        }
2763        fn assert_sanitized(value: &serde_json::Value) {
2764            match value {
2765                serde_json::Value::Object(object) => {
2766                    for (key, value) in object {
2767                        assert!(
2768                            !matches!(
2769                                key.as_str(),
2770                                "uuid"
2771                                    | "path"
2772                                    | "generation_uuid"
2773                                    | "generation_manifest_sha256"
2774                                    | "physical_identity_allocated_bytes"
2775                                    | "provider_id"
2776                                    | "resource_id"
2777                                    | "credential"
2778                                    | "credentials"
2779                                    | "secret"
2780                            ),
2781                            "sensitive evidence key: {key}"
2782                        );
2783                        assert_sanitized(value);
2784                    }
2785                }
2786                serde_json::Value::Array(values) => {
2787                    values.iter().for_each(assert_sanitized);
2788                }
2789                serde_json::Value::String(value) => {
2790                    assert!(Uuid::parse_str(value).is_err());
2791                    assert!(!Path::new(value).is_absolute());
2792                }
2793                _ => {}
2794            }
2795        }
2796        assert_sanitized(&json);
2797        let encoded = String::from_utf8(result.stdout).unwrap();
2798        assert!(!encoded.contains("generation_uuid"));
2799        assert!(!encoded.contains("sha256"));
2800        assert!(!encoded.contains(path.to_string_lossy().as_ref()));
2801    }
2802
2803    #[test]
2804    fn clap_error_vocabulary_is_total_and_stable() {
2805        let cases = [
2806            (ErrorKind::InvalidValue, "invalid_value"),
2807            (ErrorKind::UnknownArgument, "unknown_argument"),
2808            (ErrorKind::InvalidSubcommand, "invalid_subcommand"),
2809            (ErrorKind::NoEquals, "missing_equals"),
2810            (ErrorKind::ValueValidation, "value_validation"),
2811            (ErrorKind::TooManyValues, "too_many_values"),
2812            (ErrorKind::TooFewValues, "too_few_values"),
2813            (ErrorKind::WrongNumberOfValues, "wrong_number_of_values"),
2814            (ErrorKind::ArgumentConflict, "argument_conflict"),
2815            (
2816                ErrorKind::MissingRequiredArgument,
2817                "missing_required_argument",
2818            ),
2819            (ErrorKind::MissingSubcommand, "missing_subcommand"),
2820            (ErrorKind::DisplayHelp, "display_help"),
2821            (
2822                ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
2823                "display_help_on_missing_input",
2824            ),
2825            (ErrorKind::DisplayVersion, "display_version"),
2826            (ErrorKind::Io, "io"),
2827            (ErrorKind::Format, "format"),
2828        ];
2829        for (kind, token) in cases {
2830            assert_eq!(clap_error_kind(kind), token);
2831        }
2832    }
2833
2834    #[test]
2835    fn selector_requires_exactly_one_source_and_preserves_named_current() {
2836        assert!(selector(None, false, "from").is_err());
2837        assert!(selector(Some("named".into()), true, "from").is_err());
2838        assert!(matches!(
2839            selector(Some("current".into()), false, "from").unwrap(),
2840            CheckpointSelector::Named(value) if value == "current"
2841        ));
2842        assert!(matches!(
2843            selector(None, true, "from").unwrap(),
2844            CheckpointSelector::Current
2845        ));
2846    }
2847}
2848
2849#[cfg(test)]
2850mod command_retirement_tests;
2851
2852#[cfg(test)]
2853mod result_output_tests;