#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use arrow::datatypes::DataType;
use arrow::ipc::writer::StreamWriter;
use arrow::record_batch::RecordBatch;
use clap::{Args, Parser, Subcommand, ValueEnum, error::ErrorKind};
use graphforge_api::{
CheckpointDiffDetail, CheckpointDiffScope, CheckpointRequest, CheckpointSelector,
DeleteCheckpointRequest, DiffCheckpointsRequest, ExecutionResult, GraphForge,
ListCheckpointsRequest, OperationId, PageRequest, PageToken, PortableExportRequest,
PortableExportResult, PortableImportRequest, PortableImportResult, PortableSelection,
PreviewRevertCheckpointRequest, RepositoryContext, RepositorySyncRequest, RepositorySyncStatus,
RevertCheckpointPreview, RevertCheckpointRequest, ShowCheckpointRequest,
};
use uuid::Uuid;
include!(concat!(env!("OUT_DIR"), "/project_skills.rs"));
mod hub_clone;
pub mod hub_fixture_artifacts;
mod hub_http;
mod hub_publish;
mod maintenance_cli;
mod ontology_cli;
mod portable_cli;
mod research_cli;
mod research_versions_cli;
mod saved_queries_cli;
mod source_artifact_cli;
mod storage_attribution_cli;
const MAX_SKILL_MANIFEST_BYTES: u64 = 256 * 1024;
const MAX_SKILL_FILE_BYTES: u64 = 4 * 1024 * 1024;
const MAX_SKILL_BUNDLE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_SKILL_BUNDLE_FILES: usize = 256;
const MAX_SKILL_PATH_BYTES: usize = 1024;
const OUT_OF_SYNC_EXIT_CODE: i32 = 4;
fn project_skill_bundle() -> graphforge_api::SkillBundle<'static> {
graphforge_api::SkillBundle {
manifest: PROJECT_SKILL_MANIFEST,
files: PROJECT_SKILL_FILES,
}
}
struct OwnedSkillFile {
path: String,
bytes: Vec<u8>,
}
struct OwnedSkillBundle {
manifest: Vec<u8>,
files: Vec<OwnedSkillFile>,
}
fn read_bounded_file(
path: &Path,
maximum: u64,
message: &'static str,
) -> Result<Vec<u8>, graphforge_api::GfError> {
let file = std::fs::File::open(path)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
let metadata = file
.metadata()
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if !metadata.is_file() || metadata.len() > maximum {
return Err(graphforge_api::GfError::Validation(message.into()));
}
let capacity = usize::try_from(metadata.len())
.map_err(|_| graphforge_api::GfError::Validation(message.into()))?;
let mut bytes = Vec::with_capacity(capacity);
file.take(maximum + 1)
.read_to_end(&mut bytes)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if bytes.len() as u64 > maximum {
return Err(graphforge_api::GfError::Validation(message.into()));
}
Ok(bytes)
}
fn load_skill_file(
root: &Path,
entry: &serde_json::Value,
) -> Result<(String, PathBuf, u64), graphforge_api::GfError> {
let path = entry
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
graphforge_api::GfError::Validation("project skill file path is required".into())
})?;
if path.is_empty() || path.len() > MAX_SKILL_PATH_BYTES {
return Err(graphforge_api::GfError::Validation(
"project skill file path exceeds byte bound".into(),
));
}
let relative = Path::new(path);
if relative.is_absolute()
|| relative
.components()
.any(|part| !matches!(part, std::path::Component::Normal(_)))
{
return Err(graphforge_api::GfError::Validation(
"project skill file path is not contained".into(),
));
}
let mut candidate = root.to_path_buf();
for component in relative.components() {
candidate.push(component);
let component_metadata = std::fs::symlink_metadata(&candidate)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if component_metadata.file_type().is_symlink() {
return Err(graphforge_api::GfError::Validation(
"packaged project skill paths must not contain symlinks".into(),
));
}
}
let file_metadata = std::fs::symlink_metadata(&candidate)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if file_metadata.file_type().is_symlink() || !file_metadata.is_file() {
return Err(graphforge_api::GfError::Validation(
"packaged project skill must be a real file".into(),
));
}
if file_metadata.len() > MAX_SKILL_FILE_BYTES {
return Err(graphforge_api::GfError::Validation(
"packaged project skill exceeds per-file byte bound".into(),
));
}
let canonical = candidate
.canonicalize()
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if !canonical.starts_with(root) {
return Err(graphforge_api::GfError::Validation(
"project skill file path escaped its bundle".into(),
));
}
Ok((path.to_owned(), canonical, file_metadata.len()))
}
fn load_skill_bundle(root: &Path) -> Result<OwnedSkillBundle, graphforge_api::GfError> {
let metadata = std::fs::symlink_metadata(root)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(graphforge_api::GfError::Validation(
"packaged skill bundle root must be a real directory".into(),
));
}
let root = root
.canonicalize()
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
let manifest_path = root.join("manifest.json");
let manifest_metadata = std::fs::symlink_metadata(&manifest_path)
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?;
if manifest_metadata.file_type().is_symlink() || !manifest_metadata.is_file() {
return Err(graphforge_api::GfError::Validation(
"packaged skill manifest must be a real file".into(),
));
}
if manifest_metadata.len() > MAX_SKILL_MANIFEST_BYTES {
return Err(graphforge_api::GfError::Validation(
"packaged skill manifest exceeds byte bound".into(),
));
}
let manifest = read_bounded_file(
&manifest_path,
MAX_SKILL_MANIFEST_BYTES,
"packaged skill manifest exceeds byte bound",
)?;
let value: serde_json::Value = serde_json::from_slice(&manifest).map_err(|error| {
graphforge_api::GfError::Validation(format!("invalid project skill manifest: {error}"))
})?;
let entries = value
.get("files")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
graphforge_api::GfError::Validation("project skill manifest files are required".into())
})?;
if entries.len() > MAX_SKILL_BUNDLE_FILES {
return Err(graphforge_api::GfError::Validation(
"project skill bundle exceeds file bound".into(),
));
}
let mut prepared = Vec::with_capacity(entries.len());
let mut total_bytes = 0_u64;
for entry in entries {
let file = load_skill_file(&root, entry)?;
total_bytes = total_bytes.checked_add(file.2).ok_or_else(|| {
graphforge_api::GfError::Validation(
"packaged project skill bundle exceeds total byte bound".into(),
)
})?;
if total_bytes > MAX_SKILL_BUNDLE_BYTES {
return Err(graphforge_api::GfError::Validation(
"packaged project skill bundle exceeds total byte bound".into(),
));
}
prepared.push(file);
}
let mut files = Vec::with_capacity(prepared.len());
let mut read_bytes = 0_u64;
for (path, canonical, _) in prepared {
let bytes = read_bounded_file(
&canonical,
MAX_SKILL_FILE_BYTES,
"packaged project skill exceeds per-file byte bound",
)?;
read_bytes = read_bytes
.checked_add(bytes.len() as u64)
.filter(|total| *total <= MAX_SKILL_BUNDLE_BYTES)
.ok_or_else(|| {
graphforge_api::GfError::Validation(
"packaged project skill bundle exceeds total byte bound".into(),
)
})?;
files.push(OwnedSkillFile { path, bytes });
}
Ok(OwnedSkillBundle { manifest, files })
}
#[derive(Parser)]
#[command(name = "graphforge", version, about = "GraphForge CLI")]
struct Cli {
#[arg(long)]
info: bool,
#[arg(long, global = true)]
project: Option<PathBuf>,
#[arg(long, global = true)]
project_dir: Option<PathBuf>,
#[command(flatten)]
diagnostics: storage_attribution_cli::DiagnosticsArgs,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, hide = true)]
skills_bundle_dir: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Telemetry(TelemetryArgs),
Clone(hub_clone::CloneArgs),
Publish(hub_publish::PublishArgs),
Init(InitArgs),
Sync(SyncArgs),
Remove(RemoveArgs),
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Infra {
#[command(subcommand)]
command: InfraCommand,
},
Skills {
#[command(subcommand)]
command: SkillsCommand,
},
Revert(RevertArgs),
Export(ExportArgs),
Import(ImportArgs),
Portable {
#[command(subcommand)]
command: portable_cli::PortableCommand,
},
Ontology {
#[command(subcommand)]
command: ontology_cli::OntologyCommand,
},
Query(portable_cli::QueryArgs),
SavedQuery {
#[command(subcommand)]
command: saved_queries_cli::SavedQueryCommand,
},
ImportSession {
#[command(subcommand)]
command: portable_cli::ImportSessionCommand,
},
Checkpoint {
#[command(subcommand)]
command: CheckpointCommand,
},
Research {
#[command(subcommand)]
command: research_cli::ResearchCommand,
},
SourceArtifact {
#[command(subcommand)]
command: source_artifact_cli::SourceArtifactCommand,
},
Transaction {
#[command(subcommand)]
command: maintenance_cli::TransactionCommand,
},
Maintenance {
#[command(subcommand)]
command: maintenance_cli::MaintenanceCommand,
},
Recovery,
StorageAttribution(storage_attribution_cli::StorageAttributionArgs),
}
#[derive(Args)]
struct TelemetryArgs {
#[arg(long, default_value = "disabled")]
mode: String,
#[arg(long)]
endpoint: Option<String>,
#[arg(long = "header")]
headers: Vec<String>,
#[arg(long, default_value_t = 256)]
queue_capacity: usize,
#[arg(long, default_value_t = 64)]
batch_size: usize,
#[arg(long, default_value_t = 3_000)]
export_timeout_ms: u64,
#[arg(long, default_value_t = 5_000)]
lifecycle_timeout_ms: u64,
#[arg(long, default_value_t = 2)]
max_retries: u8,
}
fn run_telemetry(
args: TelemetryArgs,
output: &mut dyn Write,
) -> Result<i32, graphforge_api::GfError> {
use graphforge_api::telemetry::{OtlpConfig, TelemetryConfig, TelemetryMode, TelemetryRuntime};
let mode = match args.mode.as_str() {
"disabled" => TelemetryMode::Disabled,
"in_memory" => TelemetryMode::InMemory,
"otlp_http_json" => TelemetryMode::OtlpHttpJson,
_ => {
return Err(graphforge_api::GfError::Validation(
"GF_TELEMETRY_INVALID_MODE".into(),
));
}
};
let mut headers = BTreeMap::new();
for header in args.headers {
let Some((name, value)) = header.split_once('=') else {
return Err(graphforge_api::GfError::Validation(
"GF_TELEMETRY_INVALID_HEADER".into(),
));
};
headers.insert(name.to_owned(), value.to_owned());
}
let otlp = args
.endpoint
.map(|endpoint| OtlpConfig { endpoint, headers });
let runtime = TelemetryRuntime::new(TelemetryConfig {
mode,
queue_capacity: args.queue_capacity,
batch_size: args.batch_size,
export_timeout: Duration::from_millis(args.export_timeout_ms),
lifecycle_timeout: Duration::from_millis(args.lifecycle_timeout_ms),
max_retries: args.max_retries,
otlp,
..TelemetryConfig::default()
})
.map_err(|error| graphforge_api::GfError::Validation(error.code.as_str().to_owned()))?;
let flush = runtime.force_flush();
let shutdown = runtime.shutdown();
writeln!(
output,
"{}",
serde_json::json!({
"semantic_contract_version": graphforge_api::telemetry::SEMANTIC_CONTRACT_VERSION,
"scope": graphforge_api::telemetry::INSTRUMENTATION_SCOPE,
"mode": args.mode,
"flush": flush.as_str(),
"shutdown": shutdown.as_str()
})
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
Ok(0)
}
#[derive(Args)]
struct InitArgs {
#[arg(long)]
no_skills: bool,
}
#[derive(Args)]
struct SyncArgs {
#[arg(long, conflicts_with_all = ["idempotency_key", "actor_uuid"])]
check: bool,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long, requires = "idempotency_key")]
actor_uuid: Option<String>,
}
#[derive(Subcommand)]
enum SkillsCommand {
Install(SkillMutationArgs),
Status,
Update(SkillMutationArgs),
Remove(SkillMutationArgs),
}
#[derive(Args)]
struct SkillMutationArgs {
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct RemoveArgs {
#[arg(long)]
yes: bool,
}
#[derive(Subcommand)]
enum ConfigCommand {
Validate,
Resolve,
}
#[derive(Subcommand)]
enum InfraCommand {
Validate(InfraValidateArgs),
}
#[derive(Args)]
struct InfraValidateArgs {
#[arg(long)]
target: String,
}
#[derive(Subcommand)]
enum CheckpointCommand {
Create(CreateArgs),
List(PageArgs),
Show(ShowArgs),
Open(OpenArgs),
Delete(DeleteArgs),
Diff(DiffArgs),
Revert(RevertArgs),
}
#[derive(Args)]
struct CreateArgs {
name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
actor_uuid: Option<String>,
}
#[derive(Args)]
struct PageArgs {
#[arg(long, default_value_t = 100)]
limit: u32,
#[arg(long)]
after: Option<String>,
}
#[derive(Args)]
struct ShowArgs {
name: String,
}
#[derive(Args)]
struct OpenArgs {
name: String,
#[arg(last = true, required = true, num_args = 1..)]
query: Vec<String>,
}
#[derive(Args)]
struct DeleteArgs {
name: String,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
actor_uuid: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
enum ScopeArg {
Summary,
Graph,
Ontology,
Configuration,
Capabilities,
Provenance,
Knowledge,
Epistemic,
All,
}
#[derive(Clone, Copy, ValueEnum)]
enum DetailArg {
Summary,
Records,
}
#[derive(Args)]
struct DiffArgs {
#[arg(long, conflicts_with = "from_current")]
from: Option<String>,
#[arg(long, conflicts_with = "from")]
from_current: bool,
#[arg(long, conflicts_with = "to_current")]
to: Option<String>,
#[arg(long, conflicts_with = "to")]
to_current: bool,
#[arg(long, value_enum)]
scope: ScopeArg,
#[arg(long, value_enum)]
detail: DetailArg,
#[command(flatten)]
page: PageArgs,
}
#[derive(Args)]
struct RevertArgs {
name: String,
#[arg(long, required_unless_present = "preview")]
reason: Option<String>,
#[arg(long, required_unless_present = "preview")]
idempotency_key: Option<String>,
#[arg(long)]
actor_uuid: Option<String>,
#[arg(long, conflicts_with = "preview")]
yes: bool,
#[arg(long)]
preview: bool,
}
#[derive(Args)]
struct ExportArgs {
#[arg(
long,
required_unless_present = "checkpoint",
conflicts_with = "checkpoint"
)]
current: bool,
#[arg(long, required_unless_present = "current", conflicts_with = "current")]
checkpoint: Option<String>,
#[arg(long)]
output: PathBuf,
}
#[derive(Args)]
struct ImportArgs {
#[arg(long)]
input: PathBuf,
#[arg(long)]
idempotency_key: String,
}
pub(crate) fn canonical_uuid(value: &str) -> Result<Uuid, graphforge_api::GfError> {
let parsed = Uuid::parse_str(value)
.map_err(|_| graphforge_api::GfError::Validation("expected canonical UUID".into()))?;
if parsed.hyphenated().to_string() != value {
return Err(graphforge_api::GfError::Validation(
"expected canonical lowercase hyphenated UUID".into(),
));
}
Ok(parsed)
}
fn actor(value: Option<&str>) -> Result<Option<Uuid>, graphforge_api::GfError> {
value.map(canonical_uuid).transpose()
}
fn page(args: &PageArgs) -> Result<PageRequest, graphforge_api::GfError> {
Ok(PageRequest {
limit: args.limit,
after: args.after.as_deref().map(PageToken::parse).transpose()?,
cancellation: None,
})
}
fn selector(
value: Option<String>,
current: bool,
endpoint: &str,
) -> Result<CheckpointSelector, graphforge_api::GfError> {
match (value, current) {
(Some(name), false) => Ok(CheckpointSelector::Named(name)),
(None, true) => Ok(CheckpointSelector::Current),
_ => Err(graphforge_api::GfError::Validation(format!(
"exactly one of --{endpoint} or --{endpoint}-current is required"
))),
}
}
fn write_result(
result: &ExecutionResult,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
let logical = if result.batches.is_empty() {
RecordBatch::new_empty(Arc::clone(&result.schema))
} else {
arrow::compute::concat_batches(&result.schema, &result.batches)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
};
{
let mut writer = StreamWriter::try_new(&mut *output, result.schema.as_ref())
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
writer
.write(&logical)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
writer
.finish()
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
output
.flush()
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))
}
#[derive(serde::Serialize)]
struct JsonResultColumn<'a> {
name: &'a str,
data_type: String,
nullable: bool,
}
#[derive(serde::Serialize)]
struct JsonExecutionResult<'a> {
contract: &'static str,
columns: Vec<JsonResultColumn<'a>>,
metadata: std::collections::BTreeMap<&'a str, &'a str>,
rows: Vec<Vec<serde_json::Value>>,
}
fn canonical_json_value(
value: serde_json::Value,
data_type: &DataType,
) -> Result<serde_json::Value, graphforge_api::GfError> {
match (value, data_type) {
(serde_json::Value::String(hex), DataType::FixedSizeBinary(16)) => {
if hex.len() != 32 {
return Err(graphforge_api::GfError::Execution(
"invalid 16-byte JSON result value".into(),
));
}
let bytes = (0..hex.len())
.step_by(2)
.map(|index| u8::from_str_radix(&hex[index..index + 2], 16))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
Ok(serde_json::Value::String(
Uuid::from_slice(&bytes)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
.hyphenated()
.to_string(),
))
}
(value, _) => Ok(value),
}
}
fn write_json_result(
result: &ExecutionResult,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
let mut encoded = arrow::json::ArrayWriter::new(Vec::new());
let batches = result.batches.iter().collect::<Vec<_>>();
encoded
.write_batches(&batches)
.and_then(|()| encoded.finish())
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
let objects: Vec<serde_json::Map<String, serde_json::Value>> =
serde_json::from_slice(&encoded.into_inner())
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
let fields = result.schema.fields();
let mut rows = Vec::with_capacity(objects.len());
for mut object in objects {
let mut row = Vec::with_capacity(fields.len());
for field in fields {
let value = object
.remove(field.name())
.unwrap_or(serde_json::Value::Null);
row.push(canonical_json_value(value, field.data_type())?);
}
rows.push(row);
}
let value = JsonExecutionResult {
contract: "graphforge-cli-result/1",
columns: fields
.iter()
.map(|field| JsonResultColumn {
name: field.name(),
data_type: field.data_type().to_string(),
nullable: field.is_nullable(),
})
.collect(),
metadata: result
.schema
.metadata()
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect(),
rows,
};
serde_json::to_writer(&mut *output, &value)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))
}
fn write_execution_result(
result: &ExecutionResult,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
if json {
write_json_result(result, output)
} else {
write_result(result, output)
}
}
fn write_export_result(
result: &PortableExportResult,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
if json {
writeln!(
output,
"{}",
serde_json::to_string(&serde_json::json!({
"contract": result.contract,
"source": result.source,
"checkpoint": result.checkpoint,
"generation_uuid": result.generation_uuid,
"envelope_sha256": result.envelope_sha256,
"byte_length": result.byte_length,
"participant_count": result.participant_count,
}))
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
} else {
writeln!(
output,
"exported {} generation {} to {} (sha256 {}, {} bytes, {} participants)",
result.source,
result.generation_uuid,
result.output.display(),
result.envelope_sha256,
result.byte_length,
result.participant_count
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
Ok(())
}
fn write_import_result(
result: &PortableImportResult,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
if json {
writeln!(
output,
"{}",
serde_json::to_string(result)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
} else {
let outcome = if result.idempotent_replay {
"replayed"
} else {
"imported"
};
writeln!(
output,
"{outcome} source generation {} as {} (sha256 {})",
result.source_generation_uuid, result.generation_uuid, result.envelope_sha256
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
Ok(())
}
fn run_import(
args: ImportArgs,
path: &Path,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
let result = GraphForge::import_portable(
path,
&PortableImportRequest {
input: args.input,
operation_id: OperationId(canonical_uuid(&args.idempotency_key)?),
},
)?;
write_import_result(&result, json, output)
}
fn run_export(
graph: &GraphForge,
args: ExportArgs,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
let selection = match args.checkpoint {
Some(name) => PortableSelection::Checkpoint(name),
None if args.current => PortableSelection::Current,
None => unreachable!("clap requires exactly one export selector"),
};
let result = graph.export_portable(PortableExportRequest {
selection,
output: args.output,
})?;
write_export_result(&result, json, output)
}
#[derive(serde::Serialize)]
struct JsonRevertPreview<'a> {
contract: &'static str,
checkpoint_uuid: Uuid,
source_generation_uuid: Uuid,
source_manifest_sha256: &'a str,
current_generation_uuid: Uuid,
}
fn write_revert_preview(
preview: &RevertCheckpointPreview,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
if json {
serde_json::to_writer(
&mut *output,
&JsonRevertPreview {
contract: "graphforge-revert-preview/1",
checkpoint_uuid: preview.checkpoint_uuid,
source_generation_uuid: preview.source_generation_uuid,
source_manifest_sha256: &preview.source_manifest_sha256,
current_generation_uuid: preview.current_generation_uuid,
},
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
} else {
writeln!(
output,
"checkpoint {} pins generation {} (sha256 {}); current generation is {}",
preview.checkpoint_uuid,
preview.source_generation_uuid,
preview.source_manifest_sha256,
preview.current_generation_uuid
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
Ok(())
}
fn run_revert_preview(
path: &Path,
name: String,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
let preview =
GraphForge::preview_revert_to_checkpoint(path, PreviewRevertCheckpointRequest { name })?;
write_revert_preview(&preview, json, output)
}
fn run_revert(
graph: &mut GraphForge,
args: RevertArgs,
json: bool,
output: &mut dyn Write,
) -> Result<(), graphforge_api::GfError> {
debug_assert!(args.yes && !args.preview);
let result = graph.revert_to_checkpoint(RevertCheckpointRequest {
name: args.name,
reason: args
.reason
.expect("clap requires --reason unless --preview is present"),
idempotency_key: OperationId(canonical_uuid(
args.idempotency_key
.as_deref()
.expect("clap requires --idempotency-key unless --preview is present"),
)?),
actor_uuid: actor(args.actor_uuid.as_deref())?,
})?;
write_execution_result(&result, json, output)
}
fn revert_args(command: &Command) -> Option<&RevertArgs> {
match command {
Command::Revert(args)
| Command::Checkpoint {
command: CheckpointCommand::Revert(args),
} => Some(args),
_ => None,
}
}
fn handle_revert_before_open(
command: &Command,
path: &Path,
json: bool,
output: &mut dyn Write,
) -> Result<bool, graphforge_api::GfError> {
let Some(args) = revert_args(command) else {
return Ok(false);
};
if args.preview {
run_revert_preview(path, args.name.clone(), json, output)?;
return Ok(true);
}
if !args.yes {
return Err(graphforge_api::GfError::Validation(
"revert requires explicit confirmation with --yes".into(),
));
}
Ok(false)
}
fn run_repository(
command: Command,
project_dir: Option<PathBuf>,
json: bool,
skill_bundle: &graphforge_api::SkillBundle<'_>,
output: &mut dyn Write,
) -> Result<i32, graphforge_api::GfError> {
let start = project_dir.unwrap_or(
std::env::current_dir()
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?,
);
let repository = RepositoryContext::discover(start)?;
let always_json = matches!(
&command,
Command::Config {
command: ConfigCommand::Resolve
}
);
let mut exit_code = 0;
let mut plain_output = "ok";
let value = match command {
Command::Init(args) => {
let init = repository.init_without_skills()?;
let skills = if args.no_skills {
None
} else {
Some(repository.skills_install(skill_bundle, false)?)
};
serde_json::to_value(serde_json::json!({
"root": ".",
"created_config": init.created_config,
"ignore_changed": init.ignore_changed,
"state": ".graphforge/state",
"skills": skills
}))
}
Command::Sync(args) => {
let result = repository.sync(RepositorySyncRequest {
check: args.check,
operation_uuid: args
.idempotency_key
.as_deref()
.map(canonical_uuid)
.transpose()?,
actor_uuid: actor(args.actor_uuid.as_deref())?,
})?;
if args.check && result.status == RepositorySyncStatus::Drift {
exit_code = OUT_OF_SYNC_EXIT_CODE;
}
plain_output = match result.status {
RepositorySyncStatus::InSync => "in_sync",
RepositorySyncStatus::Drift => "drift",
RepositorySyncStatus::Published => "published",
};
serde_json::to_value(result)
}
Command::Remove(args) => {
let receipt = repository.remove(args.yes)?;
serde_json::to_value(serde_json::json!({
"target": ".graphforge/state",
"removed": receipt.removed
}))
}
Command::Config {
command: ConfigCommand::Validate,
} => {
repository.load_config()?;
Ok(serde_json::json!({"valid": true}))
}
Command::Config {
command: ConfigCommand::Resolve,
} => Ok(repository.resolve_config()?),
Command::Infra {
command: InfraCommand::Validate(args),
} => {
plain_output = "valid";
serde_json::to_value(repository.validate_infra_target(&args.target)?)
}
Command::Skills {
command: SkillsCommand::Install(args),
} => serde_json::to_value(repository.skills_install(skill_bundle, args.force)?),
Command::Skills {
command: SkillsCommand::Status,
} => serde_json::to_value(repository.skills_status(skill_bundle)?),
Command::Skills {
command: SkillsCommand::Update(args),
} => serde_json::to_value(repository.skills_update(skill_bundle, args.force)?),
Command::Skills {
command: SkillsCommand::Remove(args),
} => serde_json::to_value(repository.skills_remove(args.force)?),
_ => unreachable!(),
}
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
if json || always_json {
writeln!(
output,
"{}",
serde_json::to_string(&value)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?
)
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
} else {
writeln!(output, "{plain_output}")
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
Ok(exit_code)
}
fn run_repository_with_bundle(
command: Command,
project_dir: Option<PathBuf>,
json: bool,
bundle_dir: Option<&Path>,
output: &mut dyn Write,
) -> Result<i32, graphforge_api::GfError> {
let needs_packaged_bundle = matches!(&command, Command::Init(args) if !args.no_skills)
|| matches!(
&command,
Command::Skills {
command: SkillsCommand::Install(_)
| SkillsCommand::Status
| SkillsCommand::Update(_)
}
);
if !needs_packaged_bundle {
return run_repository(command, project_dir, json, &project_skill_bundle(), output);
}
let owned_bundle = bundle_dir.map(load_skill_bundle).transpose()?;
let owned_files = owned_bundle.as_ref().map(|bundle| {
bundle
.files
.iter()
.map(|file| graphforge_api::SkillBundleFile {
path: &file.path,
bytes: &file.bytes,
})
.collect::<Vec<_>>()
});
let embedded = project_skill_bundle();
let skill_bundle =
owned_bundle
.as_ref()
.zip(owned_files.as_ref())
.map_or(embedded, |(bundle, files)| graphforge_api::SkillBundle {
manifest: &bundle.manifest,
files,
});
run_repository(command, project_dir, json, &skill_bundle, output)
}
fn resolve_project_path(
project: Option<PathBuf>,
project_dir: Option<PathBuf>,
) -> Result<PathBuf, graphforge_api::GfError> {
match project {
Some(path) => Ok(path),
None => Ok(RepositoryContext::discover(
project_dir.unwrap_or(
std::env::current_dir()
.map_err(|error| graphforge_api::GfError::Storage(error.to_string()))?,
),
)?
.state_path),
}
}
fn is_repository_command(command: &Command) -> bool {
matches!(
command,
Command::Init(_)
| Command::Sync(_)
| Command::Remove(_)
| Command::Config { .. }
| Command::Infra { .. }
| Command::Skills { .. }
)
}
#[allow(clippy::too_many_lines)] pub(crate) enum CliRuntimeError {
Core(graphforge_api::GfError),
MultiOntology(graphforge_api::MultiOntologyError),
}
impl From<graphforge_api::GfError> for CliRuntimeError {
fn from(error: graphforge_api::GfError) -> Self {
Self::Core(error)
}
}
impl From<graphforge_api::MultiOntologyError> for CliRuntimeError {
fn from(error: graphforge_api::MultiOntologyError) -> Self {
Self::MultiOntology(error)
}
}
#[allow(clippy::too_many_lines)]
fn run(cli: Cli, output: &mut dyn Write) -> Result<i32, CliRuntimeError> {
let _capture = storage_attribution_cli::capture(&cli)?;
let allocation = if cli.diagnostics.allocation_diagnostics {
if !cli.json {
return Err(graphforge_api::GfError::Validation(
"allocation diagnostics require --json".into(),
)
.into());
}
Some(graphforge_api::StorageAllocationDiagnostics::read(
std::io::stdin().lock(),
)?)
} else {
None
};
let result = run_with_allocation(cli, output, allocation.as_ref())?;
if let Some(allocation) = allocation {
let (current, peak) = allocation.totals()?;
let (composition, residency, transitions, peak_transition) = allocation.peak_detail()?;
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()))?;
writeln!(output).map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
}
Ok(result)
}
pub(crate) fn open_cli_graph(
path: &Path,
allocation: Option<&graphforge_api::StorageAllocationDiagnostics>,
) -> Result<GraphForge, graphforge_api::GfError> {
if let Some(allocation) = allocation {
allocation.open(path)
} else {
let path = path.to_str().ok_or_else(|| {
graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
})?;
GraphForge::new(Some(path))
}
}
#[allow(clippy::too_many_lines)]
fn run_with_allocation(
cli: Cli,
output: &mut dyn Write,
allocation: Option<&graphforge_api::StorageAllocationDiagnostics>,
) -> Result<i32, CliRuntimeError> {
if cli.info {
writeln!(output, "graphforge {}", env!("CARGO_PKG_VERSION"))
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
return Ok(0);
}
let Some(command) = cli.command else {
writeln!(output, "GraphForge — use --help for options")
.map_err(|error| graphforge_api::GfError::Execution(error.to_string()))?;
return Ok(0);
};
let command = match command {
Command::Telemetry(args) => return run_telemetry(args, output).map_err(Into::into),
command => command,
};
if is_repository_command(&command) {
return run_repository_with_bundle(
command,
cli.project_dir,
cli.json,
cli.skills_bundle_dir.as_deref(),
output,
)
.map_err(Into::into);
}
if let hub @ (Command::Clone(_) | Command::Publish(_)) = command {
return hub_publish::run_hub_command(hub, cli.project, cli.project_dir, cli.json, output);
}
if let Command::Ontology { command } = &command
&& let Some(result) = ontology_cli::run_without_project(command, cli.json, output)
{
return result.map(|()| 0).map_err(Into::into);
}
if let Command::Portable { command } = command {
match command {
portable_cli::PortableCommand::Verify(_)
| portable_cli::PortableCommand::PublishOci(_)
| portable_cli::PortableCommand::PullOci(_) => {
return portable_cli::run_portable_without_graph(
Path::new(""),
command,
cli.json,
output,
allocation,
)
.map(|()| 0);
}
portable_cli::PortableCommand::Import(_) => {
let path = resolve_project_path(cli.project, cli.project_dir)?;
return portable_cli::run_portable_without_graph(
&path, command, cli.json, output, allocation,
)
.map(|()| 0);
}
command => {
let path = resolve_project_path(cli.project, cli.project_dir)?;
let path_text = path.to_str().ok_or_else(|| {
graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
})?;
let mut graph = open_cli_graph(Path::new(path_text), allocation)?;
return portable_cli::run_portable(
&mut graph, &path, command, cli.json, output, allocation,
)
.map(|()| 0);
}
}
}
let path = resolve_project_path(cli.project, cli.project_dir)?;
let command = match command {
Command::Import(args) => {
return run_import(args, &path, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
command => command,
};
if handle_revert_before_open(&command, &path, cli.json, output)? {
return Ok(0);
}
let path_text = path.to_str().ok_or_else(|| {
graphforge_api::GfError::Validation("--project must be valid UTF-8".into())
})?;
let mut graph = open_cli_graph(Path::new(path_text), allocation)?;
let command = match command {
Command::Export(args) => {
return run_export(&graph, args, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::SavedQuery { command } => {
return saved_queries_cli::run(&mut graph, command, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::Query(args) => {
return portable_cli::run_query(&graph, &args, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::ImportSession { command } => {
return portable_cli::run_import_session(&graph, command, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::Recovery => {
return maintenance_cli::run_recovery(&graph, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::StorageAttribution(args) => {
return storage_attribution_cli::run_storage_attribution(
graph, &path, &args, cli.json, output, allocation,
)
.map(|()| 0)
.map_err(Into::into);
}
Command::Transaction { command } => {
return maintenance_cli::run_transaction(&graph, command, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::Maintenance { command } => {
return maintenance_cli::run_maintenance(&mut graph, command, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
Command::Ontology { command } => {
return ontology_cli::run_ontology(&mut graph, command, cli.json, output).map(|()| 0);
}
Command::Research { command } => {
return research_cli::run_research(&mut graph, command, cli.json, output).map(|()| 0);
}
Command::SourceArtifact { command } => {
return source_artifact_cli::run_source_artifact(&mut graph, command, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
command => command,
};
let command = match command {
Command::Revert(args)
| Command::Checkpoint {
command: CheckpointCommand::Revert(args),
} => {
return run_revert(&mut graph, args, cli.json, output)
.map(|()| 0)
.map_err(Into::into);
}
command => command,
};
let result = match command {
Command::Checkpoint { command } => match command {
CheckpointCommand::Create(args) => graph.checkpoint(CheckpointRequest {
name: args.name,
description: args.description,
idempotency_key: OperationId(canonical_uuid(&args.idempotency_key)?),
actor_uuid: actor(args.actor_uuid.as_deref())?,
})?,
CheckpointCommand::List(args) => {
graph.list_checkpoints(ListCheckpointsRequest { page: page(&args)? })?
}
CheckpointCommand::Show(args) => {
graph.show_checkpoint(ShowCheckpointRequest { name: args.name })?
}
CheckpointCommand::Open(args) => {
let view = graph.open_checkpoint(&args.name)?;
view.execute(&args.query.join(" "))?
}
CheckpointCommand::Delete(args) => {
graph.delete_checkpoint(DeleteCheckpointRequest {
name: args.name,
idempotency_key: OperationId(canonical_uuid(&args.idempotency_key)?),
actor_uuid: actor(args.actor_uuid.as_deref())?,
})?
}
CheckpointCommand::Diff(args) => graph.diff_checkpoints(DiffCheckpointsRequest {
from: selector(args.from, args.from_current, "from")?,
to: selector(args.to, args.to_current, "to")?,
scope: match args.scope {
ScopeArg::Summary => CheckpointDiffScope::Summary,
ScopeArg::Graph => CheckpointDiffScope::Graph,
ScopeArg::Ontology => CheckpointDiffScope::Ontology,
ScopeArg::Configuration => CheckpointDiffScope::Configuration,
ScopeArg::Capabilities => CheckpointDiffScope::Capabilities,
ScopeArg::Provenance => CheckpointDiffScope::Provenance,
ScopeArg::Knowledge => CheckpointDiffScope::Knowledge,
ScopeArg::Epistemic => CheckpointDiffScope::Epistemic,
ScopeArg::All => CheckpointDiffScope::All,
},
detail: match args.detail {
DetailArg::Summary => CheckpointDiffDetail::Summary,
DetailArg::Records => CheckpointDiffDetail::Records,
},
page: page(&args.page)?,
})?,
CheckpointCommand::Revert(_) => unreachable!("revert handled before result dispatch"),
},
_ => unreachable!(),
};
write_execution_result(&result, cli.json, output)?;
Ok(0)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CliExecution {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub fn execute<I, T>(args: I) -> CliExecution
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut args: Vec<std::ffi::OsString> = args.into_iter().map(Into::into).collect();
if args.is_empty() {
args.push("graphforge".into());
} else {
args[0] = "graphforge".into();
}
let json = args.iter().any(|arg| arg == "--json");
let cli = match Cli::try_parse_from(&args) {
Ok(cli) => cli,
Err(error) => {
let exit_code = error.exit_code();
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
stdout = error.to_string().into_bytes();
} else if json {
write_json_error(
&mut stderr,
"GF_VALIDATION",
&error.to_string(),
JsonErrorDetails {
source: "argument_parser",
kind: clap_error_kind(error.kind()),
semantic_code: None,
},
)
.expect("writing to Vec cannot fail");
} else {
stderr = error.to_string().into_bytes();
}
return CliExecution {
exit_code,
stdout,
stderr,
};
}
};
let json = cli.json;
let exit_code = match run(cli, &mut stdout) {
Ok(exit_code) => exit_code,
Err(error) => {
write_runtime_error(&error, json, &mut stderr).expect("writing to Vec cannot fail");
error_exit_code(&error)
}
};
CliExecution {
exit_code,
stdout,
stderr,
}
}
fn write_error(
error: &graphforge_api::GfError,
json: bool,
output: &mut dyn Write,
) -> io::Result<()> {
if json {
write_json_error(
output,
error.code(),
&error.to_string(),
JsonErrorDetails {
source: "runtime",
kind: runtime_error_kind(error),
semantic_code: stable_semantic_code(error),
},
)
} else {
writeln!(output, "{}: {error}", error.code())
}
}
fn write_runtime_error(
error: &CliRuntimeError,
json: bool,
output: &mut dyn Write,
) -> io::Result<()> {
match error {
CliRuntimeError::Core(error) => write_error(error, json, output),
CliRuntimeError::MultiOntology(error) if json => {
#[derive(serde::Serialize)]
struct Envelope<'a> {
error: &'a graphforge_api::MultiOntologyError,
}
serde_json::to_writer(&mut *output, &Envelope { error }).map_err(io::Error::other)?;
writeln!(output)
}
CliRuntimeError::MultiOntology(error) => {
writeln!(output, "{}: {}", error.code, error.message)
}
}
}
#[derive(Clone, Copy)]
struct JsonErrorDetails<'a> {
source: &'static str,
kind: &'static str,
semantic_code: Option<&'a str>,
}
fn write_json_error(
output: &mut dyn Write,
code: &'static str,
message: &str,
details: JsonErrorDetails<'_>,
) -> io::Result<()> {
let code = serde_json::to_string(code).map_err(io::Error::other)?;
let message = serde_json::to_string(message).map_err(io::Error::other)?;
let source = serde_json::to_string(details.source).map_err(io::Error::other)?;
let kind = serde_json::to_string(details.kind).map_err(io::Error::other)?;
if let Some(semantic_code) = details.semantic_code {
let semantic_code = serde_json::to_string(semantic_code).map_err(io::Error::other)?;
writeln!(
output,
"{{\"error\":{{\"code\":{code},\"message\":{message},\"details\":{{\"source\":{source},\"kind\":{kind},\"semantic_code\":{semantic_code}}}}}}}"
)
} else {
writeln!(
output,
"{{\"error\":{{\"code\":{code},\"message\":{message},\"details\":{{\"source\":{source},\"kind\":{kind}}}}}}}"
)
}
}
fn stable_semantic_code(error: &graphforge_api::GfError) -> Option<&str> {
match error.code() {
"GF_CANCELLED" => return Some("lifecycle.cancelled"),
"GF_RESOURCE_LIMIT" => return Some("resource.bytes"),
"GF_TRANSACTION_CONFLICT" => return Some("inventory.generation_conflict"),
_ => {}
}
let message = match error {
graphforge_api::GfError::Validation(message)
| graphforge_api::GfError::Execution(message)
| graphforge_api::GfError::Storage(message)
| graphforge_api::GfError::Ontology(message) => message.as_str(),
_ => return None,
};
let candidate = message.split_once(':')?.0;
if candidate.contains('.')
&& candidate
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte == b'.' || byte == b'_')
{
return Some(candidate);
}
match candidate {
"Cancelled" => Some("lifecycle.cancelled"),
"LimitExceeded" => Some("resource.bytes"),
"UnsupportedFuture" => Some("interchange.unsupported_future"),
"DigestMismatch" | "DuplicateEntry" | "InvalidStructure" => Some("interchange.integrity"),
"ConcurrentMutation" => Some("inventory.generation_conflict"),
"Incompatible" => Some("interchange.selection"),
"Io" | "InvalidPath" => Some("interchange.io"),
_ if message.contains("dependency-blocked") => Some("dependency.in_use"),
_ if message.contains("selector is ambiguous") => Some("resolution.ambiguous"),
_ if message.contains("not found") || message.contains("is absent") => {
Some("inventory.not_found")
}
_ if message.contains("generation is stale")
|| message.contains("fingerprint is stale") =>
{
Some("inventory.generation_conflict")
}
_ => None,
}
}
const fn clap_error_kind(kind: ErrorKind) -> &'static str {
match kind {
ErrorKind::InvalidValue => "invalid_value",
ErrorKind::UnknownArgument => "unknown_argument",
ErrorKind::InvalidSubcommand => "invalid_subcommand",
ErrorKind::NoEquals => "missing_equals",
ErrorKind::ValueValidation => "value_validation",
ErrorKind::TooManyValues => "too_many_values",
ErrorKind::TooFewValues => "too_few_values",
ErrorKind::WrongNumberOfValues => "wrong_number_of_values",
ErrorKind::ArgumentConflict => "argument_conflict",
ErrorKind::MissingRequiredArgument => "missing_required_argument",
ErrorKind::MissingSubcommand => "missing_subcommand",
ErrorKind::DisplayHelp => "display_help",
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => "display_help_on_missing_input",
ErrorKind::DisplayVersion => "display_version",
ErrorKind::Io => "io",
ErrorKind::Format => "format",
_ => "other",
}
}
const fn runtime_error_kind(error: &graphforge_api::GfError) -> &'static str {
match error {
graphforge_api::GfError::NotImplemented(_) => "not_implemented",
graphforge_api::GfError::Parse { .. } => "parse",
graphforge_api::GfError::Bind { .. } => "bind",
graphforge_api::GfError::LoweringExecution(_) | graphforge_api::GfError::Execution(_) => {
"execution"
}
graphforge_api::GfError::Lowering(error) => error.fault_domain(),
graphforge_api::GfError::Algorithm(error) => error.fault_domain(),
graphforge_api::GfError::BindValidation { .. } | graphforge_api::GfError::Validation(_) => {
"validation"
}
graphforge_api::GfError::BindPlan { .. } | graphforge_api::GfError::Plan(_) => "plan",
graphforge_api::GfError::Provider { .. } => "provider",
graphforge_api::GfError::Storage(_) => "storage",
graphforge_api::GfError::Project { .. } => "project",
graphforge_api::GfError::Api { .. } => "api",
graphforge_api::GfError::Lifecycle(_) => "lifecycle",
graphforge_api::GfError::Ontology(_) => "ontology",
}
}
fn error_exit_code(error: &CliRuntimeError) -> i32 {
match error {
CliRuntimeError::Core(error) if error.code() == "GF_VALIDATION" => 2,
CliRuntimeError::Core(graphforge_api::GfError::Storage(_)) => 3,
CliRuntimeError::MultiOntology(error) if error.code == "GF_VALIDATION" => 2,
_ => 1,
}
}
pub fn run_process() {
let cli = Cli::parse();
let json = cli.json;
let mut output = io::stdout().lock();
hub_publish::allow_device_flow();
hub_clone::attach_to_process();
match run(cli, &mut output) {
Ok(exit_code) if exit_code != 0 => {
let _ = output.flush();
std::process::exit(exit_code);
}
Ok(_) => {}
Err(error) => {
let _ = output.flush();
let stderr = io::stderr();
write_runtime_error(&error, json, &mut stderr.lock()).expect("write CLI stderr");
std::process::exit(error_exit_code(&error));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write_loader_manifest(root: &Path, paths: &[String]) {
fs::write(
root.join("manifest.json"),
serde_json::to_vec(&serde_json::json!({
"files": paths.iter().map(|path| serde_json::json!({"path": path})).collect::<Vec<_>>()
}))
.unwrap(),
)
.unwrap();
}
#[test]
fn bounded_file_and_skill_entry_validation_fail_before_payload_use() {
let root = tempdir().unwrap();
let file = root.path().join("skill.md");
fs::write(&file, b"abcd").unwrap();
assert_eq!(read_bounded_file(&file, 4, "bounded").unwrap(), b"abcd");
assert!(matches!(
read_bounded_file(&file, 3, "bounded"),
Err(graphforge_api::GfError::Validation(message)) if message == "bounded"
));
assert!(matches!(
read_bounded_file(root.path(), 100, "regular"),
Err(graphforge_api::GfError::Validation(message)) if message == "regular"
));
assert!(matches!(
read_bounded_file(&root.path().join("absent"), 100, "missing"),
Err(graphforge_api::GfError::Storage(_))
));
assert!(matches!(
load_skill_file(root.path(), &serde_json::json!({})),
Err(graphforge_api::GfError::Validation(message))
if message == "project skill file path is required"
));
for path in ["", "../escape", "/absolute"] {
assert!(matches!(
load_skill_file(root.path(), &serde_json::json!({"path": path})),
Err(graphforge_api::GfError::Validation(_))
));
}
assert!(matches!(
load_skill_file(
root.path(),
&serde_json::json!({"path": "missing/skill.md"})
),
Err(graphforge_api::GfError::Storage(_))
));
}
#[cfg(unix)]
#[test]
fn wave11_skill_bundle_rejects_linked_components_nonfiles_and_entry_overflow() {
use std::os::unix::fs::symlink;
let external = tempdir().unwrap();
fs::write(external.path().join("SKILL.md"), b"external").unwrap();
let root = tempdir().unwrap();
symlink(external.path(), root.path().join("linked")).unwrap();
assert!(matches!(
load_skill_file(
root.path(),
&serde_json::json!({"path": "linked/SKILL.md"})
),
Err(graphforge_api::GfError::Validation(message))
if message.contains("must not contain symlinks")
));
assert_eq!(
fs::read(external.path().join("SKILL.md")).unwrap(),
b"external"
);
let root = tempdir().unwrap();
fs::create_dir(root.path().join("directory.md")).unwrap();
assert!(matches!(
load_skill_file(
root.path(),
&serde_json::json!({"path": "directory.md"})
),
Err(graphforge_api::GfError::Validation(message))
if message.contains("must be a real file")
));
let root = tempdir().unwrap();
let paths = (0..=MAX_SKILL_BUNDLE_FILES)
.map(|index| format!("skill-{index}.md"))
.collect::<Vec<_>>();
write_loader_manifest(root.path(), &paths);
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(message))
if message.contains("exceeds file bound")
));
let root = tempdir().unwrap();
fs::create_dir(root.path().join("manifest.json")).unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(message))
if message.contains("manifest must be a real file")
));
let root = tempdir().unwrap();
fs::File::create(root.path().join("manifest.json"))
.unwrap()
.set_len(MAX_SKILL_MANIFEST_BYTES + 1)
.unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(message))
if message.contains("manifest exceeds byte bound")
));
}
#[test]
fn wave11_portable_and_preview_output_contracts_cover_plain_json_and_write_failure() {
struct FailWrite;
impl Write for FailWrite {
fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
Err(io::Error::other("closed output"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let source = Uuid::from_u128(1);
let generation = Uuid::from_u128(2);
for replay in [false, true] {
let result = PortableImportResult {
contract: "graphforge-portable-import/1",
source_generation_uuid: source,
generation_uuid: generation,
envelope_sha256: "00".repeat(32),
idempotent_replay: replay,
};
let mut plain = Vec::new();
write_import_result(&result, false, &mut plain).unwrap();
let text = String::from_utf8(plain).unwrap();
assert!(text.starts_with(if replay { "replayed" } else { "imported" }));
let mut json = Vec::new();
write_import_result(&result, true, &mut json).unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&json).unwrap()["idempotent_replay"],
replay
);
assert!(matches!(
write_import_result(&result, replay, &mut FailWrite),
Err(graphforge_api::GfError::Execution(_))
));
}
let preview = RevertCheckpointPreview {
checkpoint_uuid: Uuid::from_u128(3),
source_generation_uuid: source,
source_manifest_sha256: "11".repeat(32),
current_generation_uuid: generation,
};
for json in [false, true] {
let mut output = Vec::new();
write_revert_preview(&preview, json, &mut output).unwrap();
assert!(!output.is_empty());
assert!(matches!(
write_revert_preview(&preview, json, &mut FailWrite),
Err(graphforge_api::GfError::Execution(_))
));
}
}
#[test]
fn wave11_reusable_execution_separates_plain_parse_and_runtime_failures() {
let parse = execute(["launcher", "unknown-command"]);
assert_eq!(parse.exit_code, 2);
assert!(parse.stdout.is_empty());
assert!(
String::from_utf8(parse.stderr)
.unwrap()
.contains("unrecognized subcommand")
);
let root = tempdir().unwrap();
let runtime = execute([
"launcher",
"--project",
root.path().to_str().unwrap(),
"checkpoint",
"create",
"snapshot",
"--idempotency-key",
"not-a-uuid",
]);
assert_ne!(runtime.exit_code, 0);
assert!(runtime.stdout.is_empty());
assert!(
String::from_utf8(runtime.stderr)
.unwrap()
.starts_with("GF_")
);
}
#[test]
fn telemetry_command_projects_the_rust_owned_disabled_lifecycle() {
let execution = execute(["gf", "telemetry"]);
assert_eq!(execution.exit_code, 0);
assert!(execution.stderr.is_empty());
let output: serde_json::Value = serde_json::from_slice(&execution.stdout).unwrap();
assert_eq!(output["semantic_contract_version"], 1);
assert_eq!(output["scope"], "io.graphforge.engine");
assert_eq!(output["mode"], "disabled");
assert_eq!(output["flush"], "disabled");
assert_eq!(output["shutdown"], "disabled");
}
#[test]
fn reusable_execution_normalizes_identity_and_captures_structured_errors() {
let version = execute(["arbitrary-launcher", "--version"]);
assert_eq!(version.exit_code, 0);
assert_eq!(
String::from_utf8(version.stdout).expect("UTF-8 version"),
format!("graphforge {}\n", env!("CARGO_PKG_VERSION"))
);
assert!(version.stderr.is_empty());
let invalid = execute(["another-launcher", "--json", "unknown-command"]);
assert_eq!(invalid.exit_code, 2);
assert!(invalid.stdout.is_empty());
let error: serde_json::Value =
serde_json::from_slice(&invalid.stderr).expect("structured CLI error");
assert_eq!(error["error"]["code"], "GF_VALIDATION");
assert_eq!(
error["error"]["details"],
serde_json::json!({
"source": "argument_parser",
"kind": "invalid_subcommand"
})
);
assert!(
String::from_utf8(invalid.stderr.clone())
.unwrap()
.starts_with("{\"error\":{\"code\":\"GF_VALIDATION\",\"message\":")
);
assert!(
error["error"]["message"]
.as_str()
.expect("error message")
.contains("Usage: graphforge")
);
}
#[test]
fn runtime_json_errors_include_stable_safe_details_without_changing_message() {
let cases = [
(
graphforge_api::GfError::Validation("invalid repository input".into()),
"GF_VALIDATION",
"validation",
2,
),
(
graphforge_api::GfError::Storage("unavailable".into()),
"GF_IO",
"storage",
3,
),
];
for (error, code, kind, exit_code) in cases {
let message = error.to_string();
let mut output = Vec::new();
write_error(&error, true, &mut output).unwrap();
let serialized = String::from_utf8(output.clone()).unwrap();
assert!(
serialized.starts_with(&format!("{{\"error\":{{\"code\":\"{code}\",\"message\":"))
);
assert!(
serialized.find("\"message\":").unwrap() < serialized.find("\"details\":").unwrap()
);
let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(value["error"]["code"], code);
assert_eq!(value["error"]["message"], message);
assert_eq!(
value["error"]["details"],
serde_json::json!({"source": "runtime", "kind": kind})
);
assert_eq!(error_exit_code(&CliRuntimeError::Core(error)), exit_code);
}
}
#[test]
fn runtime_json_preserves_stable_semantic_code() {
let error = graphforge_api::GfError::Validation(
"dependency.in_use: exact module has bounded dependants".into(),
);
let mut output = Vec::new();
write_error(&error, true, &mut output).unwrap();
let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
assert_eq!(
value["error"]["details"]["semantic_code"],
"dependency.in_use"
);
assert_eq!(
value["error"]["message"],
"validation error: dependency.in_use: exact module has bounded dependants"
);
}
#[test]
fn revert_requires_reason_and_idempotency_key() {
let missing_reason = Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"checkpoint",
"revert",
"before-change",
"--idempotency-key",
"00000000-0000-0000-0000-000000000001",
]);
assert!(missing_reason.is_err());
let missing_key = Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"checkpoint",
"revert",
"before-change",
"--reason",
"undo invalid import",
]);
assert!(missing_key.is_err());
let preview = Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"revert",
"before-change",
"--preview",
])
.expect("preview does not require mutation identity");
assert!(matches!(
preview.command,
Some(Command::Revert(RevertArgs { preview: true, .. }))
));
}
#[test]
fn create_and_delete_require_idempotency_keys() {
for command in ["create", "delete"] {
assert!(
Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"checkpoint",
command,
"before-change",
])
.is_err()
);
}
}
#[test]
fn diff_disambiguates_current_from_a_checkpoint_named_current() {
assert!(matches!(
selector(Some("current".into()), false, "from").unwrap(),
CheckpointSelector::Named(name) if name == "current"
));
assert!(matches!(
selector(None, true, "to").unwrap(),
CheckpointSelector::Current
));
}
#[test]
fn canonical_uuid_rejects_noncanonical_text() {
assert!(canonical_uuid("not-an-id").is_err());
assert!(canonical_uuid("00000000-0000-0000-0000-00000000000A").is_err());
assert!(canonical_uuid("00000000-0000-0000-0000-000000000001").is_ok());
}
#[test]
fn open_accepts_only_a_name_and_trailing_read_command() {
let cli = Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"checkpoint",
"open",
"before-change",
"--",
"MATCH",
"(n)",
"RETURN",
"n",
])
.expect("valid checkpoint read command");
let Some(Command::Checkpoint {
command: CheckpointCommand::Open(args),
}) = cli.command
else {
panic!("expected checkpoint open command");
};
assert_eq!(args.name, "before-change");
assert_eq!(args.query.join(" "), "MATCH (n) RETURN n");
}
#[test]
fn show_accepts_exactly_one_checkpoint_name() {
let cli = Cli::try_parse_from([
"gf",
"--project",
"/tmp/project",
"checkpoint",
"show",
"before-change",
])
.expect("valid checkpoint show command");
assert!(matches!(
cli.command,
Some(Command::Checkpoint {
command: CheckpointCommand::Show(ShowArgs { name })
}) if name == "before-change"
));
}
#[test]
fn repository_commands_use_project_dir_and_explicit_remove_confirmation() {
assert!(Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "init"]).is_ok());
assert!(
Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "config", "resolve"]).is_ok()
);
assert!(
Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "sync", "--check",]).is_ok()
);
assert!(
Cli::try_parse_from([
"gf",
"--project-dir",
"/tmp/repo",
"sync",
"--check",
"--idempotency-key",
"41414141-4141-4141-4141-414141414141",
])
.is_err()
);
assert!(
Cli::try_parse_from([
"gf",
"--project-dir",
"/tmp/repo",
"sync",
"--actor-uuid",
"42424242-4242-4242-4242-424242424242",
])
.is_err()
);
let cli = Cli::try_parse_from(["gf", "--project-dir", "/tmp/repo", "remove"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Remove(RemoveArgs { yes: false }))
));
}
#[test]
fn export_requires_exactly_one_generation_selector() {
let base = ["gf", "--project", "/tmp/project", "export"];
assert!(
Cli::try_parse_from(base.into_iter().chain(["--output", "/tmp/out.gfportable"]))
.is_err()
);
assert!(
Cli::try_parse_from(base.into_iter().chain([
"--current",
"--checkpoint",
"before-change",
"--output",
"/tmp/out.gfportable",
]))
.is_err()
);
assert!(
Cli::try_parse_from(base.into_iter().chain([
"--current",
"--output",
"/tmp/out.gfportable",
]))
.is_ok()
);
assert!(
Cli::try_parse_from(base.into_iter().chain([
"--checkpoint",
"before-change",
"--output",
"/tmp/out.gfportable",
]))
.is_ok()
);
}
#[cfg(unix)]
#[test]
fn packaged_skill_loader_rejects_a_manifest_symlink() {
let root = tempdir().unwrap();
fs::write(root.path().join("real-manifest.json"), br#"{"files":[]}"#).unwrap();
std::os::unix::fs::symlink(
root.path().join("real-manifest.json"),
root.path().join("manifest.json"),
)
.unwrap();
let error = load_skill_bundle(root.path()).err().unwrap();
assert!(
error
.to_string()
.contains("packaged skill manifest must be a real file")
);
}
#[test]
fn packaged_skill_loader_rejects_non_file_and_oversized_manifests() {
let root = tempdir().unwrap();
fs::create_dir(root.path().join("manifest.json")).unwrap();
assert!(
load_skill_bundle(root.path())
.err()
.unwrap()
.to_string()
.contains("packaged skill manifest must be a real file")
);
let root = tempdir().unwrap();
let manifest = fs::File::create(root.path().join("manifest.json")).unwrap();
manifest.set_len(MAX_SKILL_MANIFEST_BYTES + 1).unwrap();
assert!(
load_skill_bundle(root.path())
.err()
.unwrap()
.to_string()
.contains("packaged skill manifest exceeds byte bound")
);
}
#[test]
fn packaged_skill_loader_bounds_paths_files_and_total_payload() {
let root = tempdir().unwrap();
write_loader_manifest(root.path(), &["x".repeat(MAX_SKILL_PATH_BYTES + 1)]);
assert!(
load_skill_bundle(root.path())
.err()
.unwrap()
.to_string()
.contains("project skill file path exceeds byte bound")
);
let root = tempdir().unwrap();
let path = "graphforge-bootstrap/large.md".to_owned();
fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
fs::File::create(root.path().join(&path))
.unwrap()
.set_len(MAX_SKILL_FILE_BYTES + 1)
.unwrap();
write_loader_manifest(root.path(), std::slice::from_ref(&path));
assert!(
load_skill_bundle(root.path())
.err()
.unwrap()
.to_string()
.contains("packaged project skill exceeds per-file byte bound")
);
let root = tempdir().unwrap();
fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
let paths = (0..3)
.map(|index| format!("graphforge-bootstrap/{index}.md"))
.collect::<Vec<_>>();
for path in &paths {
fs::File::create(root.path().join(path))
.unwrap()
.set_len(3 * 1024 * 1024)
.unwrap();
}
write_loader_manifest(root.path(), &paths);
assert!(
load_skill_bundle(root.path())
.err()
.unwrap()
.to_string()
.contains("packaged project skill bundle exceeds total byte bound")
);
}
#[test]
fn packaged_skill_loader_accepts_exact_manifest_and_rejects_malformed_shapes() {
let root = tempdir().unwrap();
fs::create_dir(root.path().join("graphforge-bootstrap")).unwrap();
fs::write(root.path().join("graphforge-bootstrap/SKILL.md"), b"skill").unwrap();
write_loader_manifest(root.path(), &["graphforge-bootstrap/SKILL.md".into()]);
let loaded = load_skill_bundle(root.path()).unwrap();
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files[0].path, "graphforge-bootstrap/SKILL.md");
assert_eq!(loaded.files[0].bytes, b"skill");
let root = tempdir().unwrap();
fs::write(root.path().join("manifest.json"), b"not json").unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(_))
));
let root = tempdir().unwrap();
fs::write(root.path().join("manifest.json"), br"{}").unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(_))
));
let root = tempdir().unwrap();
fs::write(root.path().join("manifest.json"), br#"{"files":[{}]}"#).unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(_))
));
let root = tempdir().unwrap();
fs::write(
root.path().join("manifest.json"),
br#"{"files":[{"path":"../escape"}]}"#,
)
.unwrap();
assert!(matches!(
load_skill_bundle(root.path()),
Err(graphforge_api::GfError::Validation(_))
));
let file = tempfile::NamedTempFile::new().unwrap();
assert!(matches!(
load_skill_bundle(file.path()),
Err(graphforge_api::GfError::Validation(_))
));
assert!(matches!(
read_bounded_file(root.path(), 10, "not a file"),
Err(graphforge_api::GfError::Validation(_))
));
}
#[test]
fn reusable_execution_covers_default_info_and_runtime_error_vocabulary() {
let default = execute(std::iter::empty::<&str>());
assert_eq!(default.exit_code, 0);
assert_eq!(
default.stdout,
"GraphForge — use --help for options\n".as_bytes()
);
let info = execute(["graphforge", "--info"]);
assert_eq!(info.exit_code, 0);
assert!(
String::from_utf8(info.stdout)
.unwrap()
.starts_with("graphforge ")
);
let cases = [
(
graphforge_api::GfError::NotImplemented("test"),
"not_implemented",
),
(graphforge_api::GfError::Plan("test".into()), "plan"),
(
graphforge_api::GfError::Execution("test".into()),
"execution",
),
(
graphforge_api::GfError::Provider {
class: "transport".into(),
provider: "provider".into(),
model: "model".into(),
},
"provider",
),
(
graphforge_api::GfError::Lifecycle("test".into()),
"lifecycle",
),
(graphforge_api::GfError::Ontology("test".into()), "ontology"),
];
for (error, expected) in cases {
assert_eq!(runtime_error_kind(&error), expected);
let mut output = Vec::new();
write_error(&error, false, &mut output).unwrap();
assert!(!output.is_empty());
assert_eq!(error_exit_code(&CliRuntimeError::Core(error)), 1);
}
}
#[test]
fn no_skills_init_does_not_load_the_distribution_bundle() {
let project = tempdir().unwrap();
let missing_bundle = project.path().join("missing-bundle");
let result = execute([
"graphforge".to_owned(),
"--project-dir".to_owned(),
project.path().to_string_lossy().into_owned(),
"--skills-bundle-dir".to_owned(),
missing_bundle.to_string_lossy().into_owned(),
"init".to_owned(),
"--no-skills".to_owned(),
]);
assert_eq!(
result.exit_code,
0,
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert!(!project.path().join(".agents").exists());
let validate = execute([
"graphforge".to_owned(),
"--project-dir".to_owned(),
project.path().to_string_lossy().into_owned(),
"--skills-bundle-dir".to_owned(),
missing_bundle.to_string_lossy().into_owned(),
"config".to_owned(),
"validate".to_owned(),
]);
assert_eq!(
validate.exit_code,
0,
"{}",
String::from_utf8_lossy(&validate.stderr)
);
let remove = execute([
"graphforge".to_owned(),
"--project-dir".to_owned(),
project.path().to_string_lossy().into_owned(),
"--skills-bundle-dir".to_owned(),
missing_bundle.to_string_lossy().into_owned(),
"skills".to_owned(),
"remove".to_owned(),
]);
assert_eq!(
remove.exit_code,
0,
"{}",
String::from_utf8_lossy(&remove.stderr)
);
}
#[test]
fn repository_commands_execute_through_json_and_plain_dispatch() {
let project = tempdir().unwrap();
let root = project.path().to_string_lossy().into_owned();
let init = execute([
"graphforge".to_owned(),
"--json".to_owned(),
"--project-dir".to_owned(),
root.clone(),
"init".to_owned(),
"--no-skills".to_owned(),
]);
assert_eq!(
init.exit_code,
0,
"{}",
String::from_utf8_lossy(&init.stderr)
);
let init_json: serde_json::Value = serde_json::from_slice(&init.stdout).unwrap();
assert_eq!(init_json["root"], ".");
for tail in [
vec!["config", "resolve"],
vec!["config", "validate"],
vec!["sync", "--check"],
] {
let mut args = vec![
"graphforge".to_owned(),
"--json".to_owned(),
"--project-dir".to_owned(),
root.clone(),
];
args.extend(tail.into_iter().map(str::to_owned));
let result = execute(args);
assert!(
result.exit_code == 0 || result.exit_code == OUT_OF_SYNC_EXIT_CODE,
"{}",
String::from_utf8_lossy(&result.stderr)
);
assert!(serde_json::from_slice::<serde_json::Value>(&result.stdout).is_ok());
}
let remove = execute([
"graphforge".to_owned(),
"--project-dir".to_owned(),
root,
"remove".to_owned(),
"--yes".to_owned(),
]);
assert_eq!(
remove.exit_code,
0,
"{}",
String::from_utf8_lossy(&remove.stderr)
);
assert_eq!(remove.stdout, b"ok\n");
}
#[test]
#[allow(clippy::too_many_lines)]
fn checkpoint_commands_execute_end_to_end_through_the_reusable_cli() {
let project = tempdir().unwrap();
let path = project.path().join("state");
fs::create_dir(&path).unwrap();
let path = path.to_string_lossy().into_owned();
let invoke = |tail: &[&str]| {
let mut args = vec![
"graphforge".to_owned(),
"--json".to_owned(),
"--project".to_owned(),
path.clone(),
];
args.extend(tail.iter().map(|value| (*value).to_owned()));
execute(args)
};
let create_id = Uuid::now_v7().to_string();
let create = invoke(&[
"checkpoint",
"create",
"baseline",
"--description",
"before change",
"--idempotency-key",
&create_id,
]);
assert_eq!(
create.exit_code,
0,
"{}",
String::from_utf8_lossy(&create.stderr)
);
for command in [
vec!["checkpoint", "list", "--limit", "10"],
vec!["checkpoint", "show", "baseline"],
vec![
"checkpoint",
"open",
"baseline",
"--",
"RETURN",
"1",
"AS",
"value",
],
vec![
"checkpoint",
"diff",
"--from",
"baseline",
"--to-current",
"--scope",
"summary",
"--detail",
"summary",
],
vec!["checkpoint", "revert", "baseline", "--preview"],
] {
let result = invoke(&command);
assert_eq!(
result.exit_code,
0,
"command={command:?}: {}",
String::from_utf8_lossy(&result.stderr)
);
assert!(!result.stdout.is_empty());
}
for scope in [
"graph",
"ontology",
"configuration",
"capabilities",
"provenance",
"knowledge",
"epistemic",
"all",
] {
let result = invoke(&[
"checkpoint",
"diff",
"--from",
"baseline",
"--to-current",
"--scope",
scope,
"--detail",
"records",
]);
assert_eq!(
result.exit_code,
0,
"scope={scope}: {}",
String::from_utf8_lossy(&result.stderr)
);
}
let revert_id = Uuid::now_v7().to_string();
let revert = invoke(&[
"checkpoint",
"revert",
"baseline",
"--reason",
"coverage contract",
"--idempotency-key",
&revert_id,
"--yes",
]);
assert_eq!(
revert.exit_code,
0,
"{}",
String::from_utf8_lossy(&revert.stderr)
);
let envelope = project.path().join("baseline.gfportable");
let envelope_text = envelope.to_string_lossy().into_owned();
let export = invoke(&[
"export",
"--checkpoint",
"baseline",
"--output",
&envelope_text,
]);
assert_eq!(
export.exit_code,
0,
"{}",
String::from_utf8_lossy(&export.stderr)
);
assert!(envelope.is_file());
let imported_path = project.path().join("imported");
fs::create_dir(&imported_path).unwrap();
let imported = imported_path.to_string_lossy().into_owned();
let import_id = Uuid::now_v7().to_string();
let import = execute([
"graphforge".to_owned(),
"--json".to_owned(),
"--project".to_owned(),
imported,
"import".to_owned(),
"--input".to_owned(),
envelope_text,
"--idempotency-key".to_owned(),
import_id,
]);
assert_eq!(
import.exit_code,
0,
"{}",
String::from_utf8_lossy(&import.stderr)
);
let delete_id = Uuid::now_v7().to_string();
let delete = invoke(&[
"checkpoint",
"delete",
"baseline",
"--idempotency-key",
&delete_id,
]);
assert_eq!(
delete.exit_code,
0,
"{}",
String::from_utf8_lossy(&delete.stderr)
);
}
#[test]
fn storage_attribution_json_is_closed_sanitized_and_reopen_stable() {
let project = tempdir().unwrap();
let path = project.path().join("state");
fs::create_dir(&path).unwrap();
let result = execute([
"graphforge".to_owned(),
"--json".to_owned(),
"--project".to_owned(),
path.to_string_lossy().into_owned(),
"storage-attribution".to_owned(),
]);
assert_eq!(
result.exit_code,
0,
"{}",
String::from_utf8_lossy(&result.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
assert_eq!(json["contract"], "graphforge-storage-attribution-command/1");
assert_eq!(
json["storage"]["contract"],
"graphforge-storage-attribution/1"
);
assert_eq!(json["reopen_agrees"], true);
assert_eq!(
json["storage"]["categories"].as_object().unwrap().len(),
graphforge_api::ArtifactCategory::ALL.len()
);
let application_io = json["application_io"].as_object().unwrap();
assert_eq!(application_io.len(), 2);
let phases = application_io["phases"].as_object().unwrap();
assert_eq!(
phases.len(),
graphforge_api::StorageIoPhase::LIFECYCLE.len()
);
assert!(phases.contains_key("read_path_scan"));
assert!(phases.contains_key("hydration_verification"));
for field in [
"read_bytes",
"write_bytes",
"read_calls",
"write_calls",
"object_count",
"block_count",
"fsync_calls",
] {
let expected: u64 = phases
.values()
.map(|row| row[field].as_u64().unwrap())
.sum();
assert_eq!(
application_io["totals"][field].as_u64().unwrap(),
expected,
"{field} does not reconcile"
);
}
fn assert_sanitized(value: &serde_json::Value) {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
assert!(
!matches!(
key.as_str(),
"uuid"
| "path"
| "generation_uuid"
| "generation_manifest_sha256"
| "physical_identity_allocated_bytes"
| "provider_id"
| "resource_id"
| "credential"
| "credentials"
| "secret"
),
"sensitive evidence key: {key}"
);
assert_sanitized(value);
}
}
serde_json::Value::Array(values) => {
values.iter().for_each(assert_sanitized);
}
serde_json::Value::String(value) => {
assert!(Uuid::parse_str(value).is_err());
assert!(!Path::new(value).is_absolute());
}
_ => {}
}
}
assert_sanitized(&json);
let encoded = String::from_utf8(result.stdout).unwrap();
assert!(!encoded.contains("generation_uuid"));
assert!(!encoded.contains("sha256"));
assert!(!encoded.contains(path.to_string_lossy().as_ref()));
}
#[test]
fn clap_error_vocabulary_is_total_and_stable() {
let cases = [
(ErrorKind::InvalidValue, "invalid_value"),
(ErrorKind::UnknownArgument, "unknown_argument"),
(ErrorKind::InvalidSubcommand, "invalid_subcommand"),
(ErrorKind::NoEquals, "missing_equals"),
(ErrorKind::ValueValidation, "value_validation"),
(ErrorKind::TooManyValues, "too_many_values"),
(ErrorKind::TooFewValues, "too_few_values"),
(ErrorKind::WrongNumberOfValues, "wrong_number_of_values"),
(ErrorKind::ArgumentConflict, "argument_conflict"),
(
ErrorKind::MissingRequiredArgument,
"missing_required_argument",
),
(ErrorKind::MissingSubcommand, "missing_subcommand"),
(ErrorKind::DisplayHelp, "display_help"),
(
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand,
"display_help_on_missing_input",
),
(ErrorKind::DisplayVersion, "display_version"),
(ErrorKind::Io, "io"),
(ErrorKind::Format, "format"),
];
for (kind, token) in cases {
assert_eq!(clap_error_kind(kind), token);
}
}
#[test]
fn selector_requires_exactly_one_source_and_preserves_named_current() {
assert!(selector(None, false, "from").is_err());
assert!(selector(Some("named".into()), true, "from").is_err());
assert!(matches!(
selector(Some("current".into()), false, "from").unwrap(),
CheckpointSelector::Named(value) if value == "current"
));
assert!(matches!(
selector(None, true, "from").unwrap(),
CheckpointSelector::Current
));
}
}
#[cfg(test)]
mod command_retirement_tests;
#[cfg(test)]
mod result_output_tests;