1#![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#[derive(Parser)]
229#[command(name = "graphforge", version, about = "GraphForge CLI")]
230struct Cli {
231 #[arg(long)]
233 info: bool,
234
235 #[arg(long, global = true)]
237 project: Option<PathBuf>,
238
239 #[arg(long, global = true)]
241 project_dir: Option<PathBuf>,
242
243 #[command(flatten)]
244 diagnostics: storage_attribution_cli::DiagnosticsArgs,
245
246 #[arg(long, global = true)]
248 json: bool,
249
250 #[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 Telemetry(TelemetryArgs),
262 Clone(hub_clone::CloneArgs),
263 Publish(hub_publish::PublishArgs),
264 Init(InitArgs),
266 Sync(SyncArgs),
268 Remove(RemoveArgs),
270 Config {
272 #[command(subcommand)]
273 command: ConfigCommand,
274 },
275 Infra {
277 #[command(subcommand)]
278 command: InfraCommand,
279 },
280 Skills {
282 #[command(subcommand)]
283 command: SkillsCommand,
284 },
285 Revert(RevertArgs),
287 Export(ExportArgs),
289 Import(ImportArgs),
291 Portable {
293 #[command(subcommand)]
294 command: portable_cli::PortableCommand,
295 },
296 Ontology {
298 #[command(subcommand)]
299 command: ontology_cli::OntologyCommand,
300 },
301 Query(portable_cli::QueryArgs),
303 SavedQuery {
305 #[command(subcommand)]
306 command: saved_queries_cli::SavedQueryCommand,
307 },
308 ImportSession {
310 #[command(subcommand)]
311 command: portable_cli::ImportSessionCommand,
312 },
313 Checkpoint {
315 #[command(subcommand)]
316 command: CheckpointCommand,
317 },
318 Research {
320 #[command(subcommand)]
321 command: research_cli::ResearchCommand,
322 },
323 SourceArtifact {
325 #[command(subcommand)]
326 command: source_artifact_cli::SourceArtifactCommand,
327 },
328 Transaction {
330 #[command(subcommand)]
331 command: maintenance_cli::TransactionCommand,
332 },
333 Maintenance {
335 #[command(subcommand)]
336 command: maintenance_cli::MaintenanceCommand,
337 },
338 Recovery,
340 StorageAttribution(storage_attribution_cli::StorageAttributionArgs),
342}
343
344#[derive(Args)]
345struct TelemetryArgs {
346 #[arg(long, default_value = "disabled")]
348 mode: String,
349 #[arg(long)]
351 endpoint: Option<String>,
352 #[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 #[arg(long)]
426 no_skills: bool,
427}
428
429#[derive(Args)]
430struct SyncArgs {
431 #[arg(long, conflicts_with_all = ["idempotency_key", "actor_uuid"])]
433 check: bool,
434 #[arg(long)]
436 idempotency_key: Option<String>,
437 #[arg(long, requires = "idempotency_key")]
439 actor_uuid: Option<String>,
440}
441
442#[derive(Subcommand)]
443enum SkillsCommand {
444 Install(SkillMutationArgs),
446 Status,
448 Update(SkillMutationArgs),
450 Remove(SkillMutationArgs),
452}
453
454#[derive(Args)]
455struct SkillMutationArgs {
456 #[arg(long)]
458 force: bool,
459}
460
461#[derive(Args)]
462struct RemoveArgs {
463 #[arg(long)]
465 yes: bool,
466}
467
468#[derive(Subcommand)]
469enum ConfigCommand {
470 Validate,
472 Resolve,
474}
475
476#[derive(Subcommand)]
477enum InfraCommand {
478 Validate(InfraValidateArgs),
480}
481
482#[derive(Args)]
483struct InfraValidateArgs {
484 #[arg(long)]
486 target: String,
487}
488
489#[derive(Subcommand)]
490enum CheckpointCommand {
491 Create(CreateArgs),
493 List(PageArgs),
495 Show(ShowArgs),
497 Open(OpenArgs),
499 Delete(DeleteArgs),
501 Diff(DiffArgs),
503 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 #[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 #[arg(long, conflicts_with = "from_current")]
571 from: Option<String>,
572 #[arg(long, conflicts_with = "from")]
574 from_current: bool,
575 #[arg(long, conflicts_with = "to_current")]
577 to: Option<String>,
578 #[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 #[arg(long, required_unless_present = "preview")]
594 reason: Option<String>,
595 #[arg(long, required_unless_present = "preview")]
597 idempotency_key: Option<String>,
598 #[arg(long)]
599 actor_uuid: Option<String>,
600 #[arg(long, conflicts_with = "preview")]
602 yes: bool,
603 #[arg(long)]
605 preview: bool,
606}
607
608#[derive(Args)]
609struct ExportArgs {
610 #[arg(
612 long,
613 required_unless_present = "checkpoint",
614 conflicts_with = "checkpoint"
615 )]
616 current: bool,
617 #[arg(long, required_unless_present = "current", conflicts_with = "current")]
619 checkpoint: Option<String>,
620 #[arg(long)]
622 output: PathBuf,
623}
624
625#[derive(Args)]
626struct ImportArgs {
627 #[arg(long)]
629 input: PathBuf,
630 #[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 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)] pub(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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
1455pub struct CliExecution {
1456 pub exit_code: i32,
1458 pub stdout: Vec<u8>,
1460 pub stderr: Vec<u8>,
1462}
1463
1464pub 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
1696pub 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 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;