use std::fs; use std::path::Path; use std::path::PathBuf; use clap::{Arg, ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use color_eyre::eyre::{Result, bail}; use omnigraph::db::{Omnigraph, ReadTarget, RunId, SnapshotId}; use omnigraph::loader::LoadMode; use omnigraph_compiler::json_params_to_param_map; use omnigraph_compiler::query::parser::parse_query; use omnigraph_compiler::{JsonParamMode, ParamMap, SchemaMigrationPlan, SchemaMigrationStep}; use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest, RunListOutput, RunOutput, SnapshotOutput, SnapshotTableOutput, commit_output, ingest_output, read_output, run_output, snapshot_payload, }; use omnigraph_server::{ AliasCommand, OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, PolicyTestConfig, ReadOutputFormat, load_config, }; use reqwest::Method; use reqwest::header::AUTHORIZATION; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; mod embed; mod read_format; use embed::{EmbedArgs, EmbedOutput, execute_embed}; use read_format::{ReadRenderOptions, render_read}; const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN"; #[derive(Debug, Parser)] #[command(name = "omnigraph")] #[command(about = "Omnigraph graph database CLI")] #[command(version = env!("CARGO_PKG_VERSION"), disable_version_flag = true)] struct Cli { #[command(subcommand)] command: Command, } #[derive(Debug, Subcommand)] enum Command { /// Print the CLI version Version, /// Generate, clean, or refresh explicit seed embeddings Embed(EmbedArgs), /// Initialize a new repo from a schema Init { #[arg(long)] schema: PathBuf, /// Repo URI (local path or s3://) uri: String, }, /// Load data into a repo Load { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] data: PathBuf, #[arg(long)] branch: Option, #[arg(long, default_value = "overwrite")] mode: CliLoadMode, #[arg(long)] json: bool, }, /// Ingest data into a reviewable named branch Ingest { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] data: PathBuf, #[arg(long)] branch: Option, #[arg(long)] from: Option, #[arg(long, default_value = "merge")] mode: CliLoadMode, #[arg(long)] json: bool, }, /// Branch operations Branch { #[command(subcommand)] command: BranchCommand, }, /// Schema planning operations Schema { #[command(subcommand)] command: SchemaCommand, }, /// Show repo snapshot Snapshot { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] branch: Option, #[arg(long)] json: bool, }, /// Export a full graph snapshot as JSONL Export { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] branch: Option, #[arg(long)] jsonl: bool, #[arg(long = "type")] type_names: Vec, #[arg(long = "table")] table_keys: Vec, }, /// Run operations Run { #[command(subcommand)] command: RunCommand, }, /// Commit history operations Commit { #[command(subcommand)] command: CommitCommand, }, /// Execute a read query against a branch or snapshot Read { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] alias: Option, #[arg(long)] query: Option, #[arg(long)] name: Option, #[command(flatten)] params: ParamsArgs, #[arg(long, conflicts_with = "snapshot")] branch: Option, #[arg(long, conflicts_with = "branch")] snapshot: Option, #[arg(long, conflicts_with = "json")] format: Option, #[arg(long, conflicts_with = "format")] json: bool, #[arg()] alias_args: Vec, }, /// Execute a graph change query against a branch Change { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] alias: Option, #[arg(long)] query: Option, #[arg(long)] name: Option, #[command(flatten)] params: ParamsArgs, #[arg(long)] branch: Option, #[arg(long)] json: bool, #[arg()] alias_args: Vec, }, /// Policy administration and diagnostics Policy { #[command(subcommand)] command: PolicyCommand, }, } #[derive(Debug, Subcommand)] enum BranchCommand { /// Create a new branch Create { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] from: Option, name: String, #[arg(long)] json: bool, }, /// List branches List { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] json: bool, }, /// Delete a branch Delete { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, name: String, #[arg(long)] json: bool, }, /// Merge a source branch into a target branch Merge { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, source: String, #[arg(long)] into: Option, #[arg(long)] json: bool, }, } #[derive(Debug, Subcommand)] enum SchemaCommand { /// Plan a schema migration against the accepted persisted schema Plan { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] schema: PathBuf, #[arg(long)] json: bool, }, } #[derive(Debug, Subcommand)] enum RunCommand { /// List transactional runs List { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] json: bool, }, /// Show a transactional run Show { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, run_id: String, #[arg(long)] json: bool, }, /// Publish a transactional run Publish { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, run_id: String, #[arg(long)] json: bool, }, /// Abort a transactional run Abort { /// Repo URI #[arg(long)] uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, run_id: String, #[arg(long)] json: bool, }, } #[derive(Debug, Subcommand)] enum CommitCommand { /// List graph commits List { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, #[arg(long)] branch: Option, #[arg(long)] json: bool, }, /// Show a graph commit Show { /// Repo URI uri: Option, #[arg(long)] target: Option, #[arg(long)] config: Option, commit_id: String, #[arg(long)] json: bool, }, } #[derive(Debug, Subcommand)] enum PolicyCommand { /// Validate policy YAML and compiled Cedar policy state Validate { #[arg(long)] config: Option, }, /// Run declarative policy tests from policy.tests.yaml Test { #[arg(long)] config: Option, }, /// Explain one policy decision locally Explain { #[arg(long)] config: Option, #[arg(long)] actor: String, #[arg(long)] action: PolicyAction, #[arg(long)] branch: Option, #[arg(long = "target-branch")] target_branch: Option, }, } #[derive(Debug, Args, Clone)] struct ParamsArgs { #[arg(long, conflicts_with = "params_file")] params: Option, #[arg(long, conflicts_with = "params")] params_file: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] #[serde(rename_all = "snake_case")] enum CliLoadMode { Overwrite, Append, Merge, } impl From for LoadMode { fn from(value: CliLoadMode) -> Self { match value { CliLoadMode::Overwrite => LoadMode::Overwrite, CliLoadMode::Append => LoadMode::Append, CliLoadMode::Merge => LoadMode::Merge, } } } impl CliLoadMode { fn as_str(self) -> &'static str { match self { CliLoadMode::Overwrite => "overwrite", CliLoadMode::Append => "append", CliLoadMode::Merge => "merge", } } } #[derive(Debug, Serialize)] struct LoadOutput<'a> { uri: &'a str, branch: &'a str, mode: &'a str, nodes_loaded: usize, edges_loaded: usize, } #[derive(Debug, Serialize)] struct SchemaPlanOutput<'a> { uri: &'a str, supported: bool, step_count: usize, steps: &'a [SchemaMigrationStep], } fn ensure_local_repo_parent(uri: &str) -> Result<()> { if !uri.contains("://") { fs::create_dir_all(uri)?; } Ok(()) } fn print_json(value: &T) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) } fn is_remote_uri(uri: &str) -> bool { uri.starts_with("http://") || uri.starts_with("https://") } fn remote_url(base: &str, path: &str) -> String { format!("{}{}", base.trim_end_matches('/'), path) } fn remote_branch_url(base: &str, branch: &str) -> Result { let mut url = reqwest::Url::parse(&format!("{}/", base.trim_end_matches('/')))?; url.path_segments_mut() .map_err(|_| color_eyre::eyre::eyre!("invalid remote base url"))? .extend(["branches", branch]); Ok(url.to_string()) } fn normalize_bearer_token(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn bearer_token_from_env(var_name: &str) -> Option { normalize_bearer_token(std::env::var(var_name).ok()) } fn parse_env_assignment(line: &str) -> Option<(String, String)> { let line = line.trim(); if line.is_empty() || line.starts_with('#') { return None; } let line = line.strip_prefix("export ").unwrap_or(line).trim(); let (name, value) = line.split_once('=')?; let name = name.trim(); if name.is_empty() { return None; } let value = value.trim(); let value = if value.len() >= 2 && ((value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\''))) { &value[1..value.len() - 1] } else { value }; Some((name.to_string(), value.to_string())) } fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result> { if !path.exists() { return Ok(None); } for line in fs::read_to_string(path)?.lines() { let Some((name, value)) = parse_env_assignment(line) else { continue; }; if name == var_name { return Ok(normalize_bearer_token(Some(value))); } } Ok(None) } fn load_env_file_into_process(path: &Path) -> Result<()> { if !path.exists() { return Ok(()); } for line in fs::read_to_string(path)?.lines() { let Some((name, value)) = parse_env_assignment(line) else { continue; }; if std::env::var_os(&name).is_none() { unsafe { std::env::set_var(name, value); } } } Ok(()) } fn load_cli_config(config_path: Option<&PathBuf>) -> Result { let config = load_config(config_path)?; if let Some(path) = config.resolve_auth_env_file() { load_env_file_into_process(&path)?; } Ok(config) } fn resolve_policy_engine(config: &OmnigraphConfig) -> Result { let policy_file = config .resolve_policy_file() .ok_or_else(|| color_eyre::eyre::eyre!("policy.file must be set in omnigraph.yaml"))?; PolicyEngine::load(&policy_file, &policy_repo_id(config)) } fn resolve_policy_tests_path(config: &OmnigraphConfig) -> Result { config.resolve_policy_tests_file().ok_or_else(|| { color_eyre::eyre::eyre!( "policy.tests.yaml requires policy.file to be set in omnigraph.yaml" ) }) } fn policy_repo_id(config: &OmnigraphConfig) -> String { if let Some(name) = &config.project.name { return name.clone(); } config .resolve_target_uri(None, None, config.server_target_name()) .or_else(|_| config.resolve_target_uri(None, None, config.cli_target_name())) .unwrap_or_else(|_| "default".to_string()) } fn resolve_remote_bearer_token( config: &OmnigraphConfig, explicit_uri: Option<&str>, explicit_target: Option<&str>, ) -> Result> { let scoped_env = config.target_bearer_token_env(explicit_uri, explicit_target, config.cli_target_name()); let mut env_names = Vec::new(); if let Some(name) = scoped_env { env_names.push(name.to_string()); } if env_names .iter() .all(|name| name != DEFAULT_BEARER_TOKEN_ENV) { env_names.push(DEFAULT_BEARER_TOKEN_ENV.to_string()); } let env_file = config.resolve_auth_env_file(); for env_name in env_names { if let Some(token) = bearer_token_from_env(&env_name) { return Ok(Some(token)); } if let Some(path) = env_file.as_ref() { if let Some(token) = bearer_token_from_env_file(path, &env_name)? { return Ok(Some(token)); } } } Ok(None) } fn build_http_client() -> Result { Ok(reqwest::Client::new()) } fn apply_bearer_token( request: reqwest::RequestBuilder, token: Option<&str>, ) -> reqwest::RequestBuilder { if let Some(token) = token { request.header(AUTHORIZATION, format!("Bearer {}", token)) } else { request } } async fn remote_json( client: &reqwest::Client, method: Method, url: String, body: Option, bearer_token: Option<&str>, ) -> Result { let request = apply_bearer_token(client.request(method, url), bearer_token); let request = if let Some(body) = body { request.json(&body) } else { request }; let response = request.send().await?; let status = response.status(); let text = response.text().await?; if !status.is_success() { if let Ok(error) = serde_json::from_str::(&text) { bail!(error.error); } bail!("server returned {}: {}", status, text); } Ok(serde_json::from_str(&text)?) } async fn remote_text( client: &reqwest::Client, method: Method, url: String, body: Option, bearer_token: Option<&str>, ) -> Result { let request = apply_bearer_token(client.request(method, url), bearer_token); let request = if let Some(body) = body { request.json(&body) } else { request }; let response = request.send().await?; let status = response.status(); let text = response.text().await?; if !status.is_success() { if let Ok(error) = serde_json::from_str::(&text) { bail!(error.error); } bail!("server returned {}: {}", status, text); } Ok(text) } fn resolve_uri( config: &OmnigraphConfig, cli_uri: Option, cli_target: Option<&str>, ) -> Result { config.resolve_target_uri(cli_uri, cli_target, config.cli_target_name()) } fn resolve_local_uri( config: &OmnigraphConfig, cli_uri: Option, cli_target: Option<&str>, operation: &str, ) -> Result { let uri = resolve_uri(config, cli_uri, cli_target)?; if is_remote_uri(&uri) { bail!( "{} is only supported against local repo URIs in this milestone", operation ); } Ok(uri) } fn resolve_branch( config: &OmnigraphConfig, cli_branch: Option, alias_branch: Option, default_branch: &str, ) -> String { cli_branch .or(alias_branch) .or_else(|| config.cli.branch.clone()) .unwrap_or_else(|| default_branch.to_string()) } fn resolve_read_target( config: &OmnigraphConfig, cli_branch: Option, cli_snapshot: Option, alias_branch: Option, ) -> Result { if cli_branch.is_some() && cli_snapshot.is_some() { bail!("read target may specify branch or snapshot, not both"); } Ok(read_target_from_cli( cli_branch .or(alias_branch) .or_else(|| config.cli.branch.clone()), cli_snapshot, )) } fn resolve_query_source( config: &OmnigraphConfig, explicit_query: Option<&PathBuf>, alias_query: Option<&str>, ) -> Result { let query_path = explicit_query .map(PathBuf::from) .or_else(|| alias_query.map(PathBuf::from)) .ok_or_else(|| { color_eyre::eyre::eyre!("exactly one of --query or --alias must be provided") })?; Ok(fs::read_to_string(config.resolve_query_path(&query_path)?)?) } fn parse_alias_value(value: &str) -> Value { serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string())) } fn merged_params_json( alias_name: Option<&str>, alias_arg_names: &[String], alias_arg_values: &[String], explicit: Option, ) -> Result> { if alias_arg_values.len() > alias_arg_names.len() { let alias = alias_name.unwrap_or(""); bail!( "alias '{}' expects at most {} args but got {}", alias, alias_arg_names.len(), alias_arg_values.len() ); } let mut merged = serde_json::Map::new(); for (arg_name, arg_value) in alias_arg_names.iter().zip(alias_arg_values.iter()) { merged.insert(arg_name.clone(), parse_alias_value(arg_value)); } match explicit { Some(Value::Object(object)) => { for (key, value) in object { merged.insert(key, value); } } Some(_) => bail!("params JSON must be an object"), None => {} } if merged.is_empty() { Ok(None) } else { Ok(Some(Value::Object(merged))) } } fn print_load_human( uri: &str, branch: &str, mode: CliLoadMode, nodes_loaded: usize, edges_loaded: usize, ) { println!( "loaded {} on branch {} with {}: {} node types, {} edge types", uri, branch, mode.as_str(), nodes_loaded, edges_loaded ); } fn print_ingest_human(output: &IngestOutput) { println!( "ingested {} into branch {} from {} with {} ({})", output.uri, output.branch, output.base_branch, output.mode.as_str(), if output.branch_created { "branch created" } else { "branch exists" } ); for table in &output.tables { println!("{} rows_loaded={}", table.table_key, table.rows_loaded); } if let Some(actor_id) = &output.actor_id { println!("actor_id: {}", actor_id); } } fn print_schema_plan_human(uri: &str, plan: &SchemaMigrationPlan) { println!("schema plan for {}", uri); println!("supported: {}", if plan.supported { "yes" } else { "no" }); if plan.steps.is_empty() { println!("no schema changes"); return; } for step in &plan.steps { println!("- {}", render_schema_plan_step(step)); } } fn render_schema_plan_step(step: &SchemaMigrationStep) -> String { match step { SchemaMigrationStep::AddType { type_kind, name } => { format!("add {} type '{}'", schema_type_kind_label(*type_kind), name) } SchemaMigrationStep::RenameType { type_kind, from, to, } => format!( "rename {} type '{}' -> '{}'", schema_type_kind_label(*type_kind), from, to ), SchemaMigrationStep::AddProperty { type_kind, type_name, property_name, property_type, } => format!( "add property '{}.{}' ({}) on {} '{}'", type_name, property_name, render_prop_type(property_type), schema_type_kind_label(*type_kind), type_name ), SchemaMigrationStep::RenameProperty { type_kind, type_name, from, to, } => format!( "rename property '{}.{}' -> '{}.{}' on {} '{}'", type_name, from, type_name, to, schema_type_kind_label(*type_kind), type_name ), SchemaMigrationStep::AddConstraint { type_kind, type_name, constraint, } => format!( "add constraint {} on {} '{}'", render_constraint(constraint), schema_type_kind_label(*type_kind), type_name ), SchemaMigrationStep::UpdateTypeMetadata { type_kind, name, annotations, } => format!( "update metadata on {} '{}' ({})", schema_type_kind_label(*type_kind), name, render_annotations(annotations) ), SchemaMigrationStep::UpdatePropertyMetadata { type_kind, type_name, property_name, annotations, } => format!( "update metadata on property '{}.{}' of {} '{}' ({})", type_name, property_name, schema_type_kind_label(*type_kind), type_name, render_annotations(annotations) ), SchemaMigrationStep::UnsupportedChange { entity, reason } => { format!("unsupported change on {}: {}", entity, reason) } } } fn schema_type_kind_label(kind: omnigraph_compiler::SchemaTypeKind) -> &'static str { match kind { omnigraph_compiler::SchemaTypeKind::Interface => "interface", omnigraph_compiler::SchemaTypeKind::Node => "node", omnigraph_compiler::SchemaTypeKind::Edge => "edge", } } fn render_prop_type(prop_type: &omnigraph_compiler::PropType) -> String { let base = if let Some(values) = &prop_type.enum_values { format!("Enum({})", values.join("|")) } else { prop_type.scalar.to_string() }; let base = if prop_type.list { format!("[{}]", base) } else { base }; if prop_type.nullable { format!("{}?", base) } else { base } } fn render_constraint(constraint: &omnigraph_compiler::schema::ast::Constraint) -> String { match constraint { omnigraph_compiler::schema::ast::Constraint::Key(columns) => { format!("@key({})", columns.join(", ")) } omnigraph_compiler::schema::ast::Constraint::Unique(columns) => { format!("@unique({})", columns.join(", ")) } omnigraph_compiler::schema::ast::Constraint::Index(columns) => { format!("@index({})", columns.join(", ")) } omnigraph_compiler::schema::ast::Constraint::Range { property, min, max } => { format!("@range({}, {:?}, {:?})", property, min, max) } omnigraph_compiler::schema::ast::Constraint::Check { property, pattern } => { format!("@check({}, {:?})", property, pattern) } } } fn render_annotations(annotations: &[omnigraph_compiler::schema::ast::Annotation]) -> String { annotations .iter() .map(|annotation| match &annotation.value { Some(value) => format!("@{}({})", annotation.name, value), None => format!("@{}", annotation.name), }) .collect::>() .join(", ") } fn print_embed_human(output: &EmbedOutput) { println!( "embedded {} rows (selected {}, cleaned {}) from {} -> {} [{} {}d]", output.embedded_rows, output.selected_rows, output.cleaned_rows, output.input, output.output, output.mode, output.dimension ); } fn print_snapshot_human(branch: &str, manifest_version: u64, entries: &[SnapshotTableOutput]) { println!("branch: {}", branch); println!("manifest_version: {}", manifest_version); for entry in entries { println!( "{} v{} branch={} rows={}", entry.table_key, entry.table_version, entry.table_branch.as_deref().unwrap_or("main"), entry.row_count ); } } fn print_read_output( output: &ReadOutput, format: ReadOutputFormat, config: &OmnigraphConfig, ) -> Result<()> { println!( "{}", render_read( output, format, &ReadRenderOptions { max_column_width: config.table_max_column_width(), cell_layout: config.table_cell_layout(), }, )? ); Ok(()) } fn print_change_human(output: &ChangeOutput) { println!( "changed {} via {}: {} nodes, {} edges", output.branch, output.query_name, output.affected_nodes, output.affected_edges ); if let Some(actor_id) = &output.actor_id { println!("actor_id: {}", actor_id); } } fn print_run_list_human(runs: &[RunOutput]) { for run in runs { println!( "{} {} target={} branch={}{}", run.run_id, run.status, run.target_branch, run.run_branch, run.actor_id .as_deref() .map(|actor| format!(" actor={}", actor)) .unwrap_or_default() ); } } fn print_run_human(run: &RunOutput) { println!("run_id: {}", run.run_id); println!("status: {}", run.status); println!("target_branch: {}", run.target_branch); println!("run_branch: {}", run.run_branch); println!("base_snapshot_id: {}", run.base_snapshot_id); println!("base_manifest_version: {}", run.base_manifest_version); if let Some(actor_id) = &run.actor_id { println!("actor_id: {}", actor_id); } if let Some(operation_hash) = &run.operation_hash { println!("operation_hash: {}", operation_hash); } if let Some(snapshot_id) = &run.published_snapshot_id { println!("published_snapshot_id: {}", snapshot_id); } println!("created_at: {}", run.created_at); println!("updated_at: {}", run.updated_at); } fn print_commit_list_human(commits: &[CommitOutput]) { for commit in commits { let branch = commit.manifest_branch.as_deref().unwrap_or("main"); println!( "{} branch={} version={}{}", commit.graph_commit_id, branch, commit.manifest_version, commit .actor_id .as_deref() .map(|actor| format!(" actor={}", actor)) .unwrap_or_default() ); } } fn print_commit_human(commit: &CommitOutput) { println!("graph_commit_id: {}", commit.graph_commit_id); println!( "manifest_branch: {}", commit.manifest_branch.as_deref().unwrap_or("main") ); println!("manifest_version: {}", commit.manifest_version); if let Some(parent_commit_id) = &commit.parent_commit_id { println!("parent_commit_id: {}", parent_commit_id); } if let Some(merged_parent_commit_id) = &commit.merged_parent_commit_id { println!("merged_parent_commit_id: {}", merged_parent_commit_id); } if let Some(actor_id) = &commit.actor_id { println!("actor_id: {}", actor_id); } println!("created_at: {}", commit.created_at); } fn print_policy_explain(decision: &PolicyDecision, request: &PolicyRequest) { println!( "decision: {}", if decision.allowed { "allow" } else { "deny" } ); println!("actor: {}", request.actor_id); println!("action: {}", request.action); if let Some(branch) = &request.branch { println!("branch: {}", branch); } if let Some(target_branch) = &request.target_branch { println!("target_branch: {}", target_branch); } if let Some(rule_id) = &decision.matched_rule_id { println!("matched_rule: {}", rule_id); } println!("message: {}", decision.message); } fn resolve_read_format( config: &OmnigraphConfig, cli_format: Option, json: bool, alias_format: Option, ) -> ReadOutputFormat { if json { ReadOutputFormat::Json } else { cli_format .or(alias_format) .unwrap_or_else(|| config.cli_output_format()) } } fn resolve_alias<'a>( config: &'a OmnigraphConfig, alias_name: Option<&'a str>, expected: AliasCommand, ) -> Result> { let Some(alias_name) = alias_name else { return Ok(None); }; let alias = config.alias(alias_name)?; if alias.command != expected { bail!( "alias '{}' is a {:?} alias, not a {:?} alias", alias_name, alias.command, expected ); } Ok(Some((alias_name, alias))) } fn normalize_alias_args( uri: Option, target: Option<&str>, default_target_present: bool, alias_name: Option<&str>, mut alias_args: Vec, ) -> (Option, Vec) { let Some(candidate) = uri else { return (None, alias_args); }; if alias_name.is_some() && (target.is_some() || default_target_present) && !is_remote_uri(&candidate) && !candidate.contains(std::path::MAIN_SEPARATOR) && !Path::new(&candidate).exists() { alias_args.insert(0, candidate); return (None, alias_args); } (Some(candidate), alias_args) } fn scaffold_config_if_missing(uri: &str) -> Result<()> { let path = inferred_config_path(uri)?; if path.exists() { return Ok(()); } fs::write( path, format!( "\ project: name: Omnigraph Project targets: local: uri: {} # bearer_token_env: OMNIGRAPH_BEARER_TOKEN server: target: local bind: 127.0.0.1:8080 cli: target: local branch: main output_format: table table_max_column_width: 80 table_cell_layout: truncate query: roots: - queries - . aliases: # owner: # command: read # query: context.gq # name: decision_owner # args: [slug] # target: local # branch: main # format: kv # # attach_trace: # command: change # query: mutations.gq # name: attach_trace # args: [decision_slug, trace_slug] # target: local # branch: main # auth: # env_file: ./.env.omni # # policy: # file: ./policy.yaml ", yaml_string(uri), ), )?; Ok(()) } fn yaml_string(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } fn inferred_config_path(uri: &str) -> Result { if uri.contains("://") { return Ok(omnigraph_server::config::default_config_path()); } let path = Path::new(uri); let base = if path.is_absolute() { path.parent() .map(Path::to_path_buf) .unwrap_or(std::env::current_dir()?) } else { std::env::current_dir()?.join(path.parent().unwrap_or_else(|| Path::new("."))) }; Ok(base.join(omnigraph_server::config::DEFAULT_CONFIG_FILE)) } fn read_target_from_cli(branch: Option, snapshot: Option) -> ReadTarget { if let Some(snapshot) = snapshot { ReadTarget::snapshot(SnapshotId::new(snapshot)) } else { ReadTarget::branch(branch.unwrap_or_else(|| "main".to_string())) } } fn load_params_json(params: &ParamsArgs) -> Result> { match (¶ms.params, ¶ms.params_file) { (Some(inline), None) => Ok(Some(serde_json::from_str(inline)?)), (None, Some(path)) => Ok(Some(serde_json::from_str(&fs::read_to_string(path)?)?)), (None, None) => Ok(None), (Some(_), Some(_)) => bail!("only one of --params or --params-file may be provided"), } } fn select_named_query( query_source: &str, requested_name: Option<&str>, ) -> Result<(String, Vec)> { let parsed = parse_query(query_source)?; let query = if let Some(name) = requested_name { parsed .queries .into_iter() .find(|query| query.name == name) .ok_or_else(|| color_eyre::eyre::eyre!("query '{}' not found", name))? } else if parsed.queries.len() == 1 { parsed.queries.into_iter().next().unwrap() } else { bail!("query file contains multiple queries; pass --name"); }; Ok((query.name, query.params)) } fn query_params_from_json( query_params: &[omnigraph_compiler::query::ast::Param], params_json: Option<&Value>, ) -> Result { json_params_to_param_map(params_json, query_params, JsonParamMode::Standard) .map_err(|err| color_eyre::eyre::eyre!(err.to_string())) } async fn execute_read( uri: &str, query_source: &str, query_name: Option<&str>, target: ReadTarget, params_json: Option<&Value>, ) -> Result { let (selected_name, query_params) = select_named_query(query_source, query_name)?; let params = query_params_from_json(&query_params, params_json)?; let db = Omnigraph::open(uri).await?; let result = db .query(target.clone(), query_source, &selected_name, ¶ms) .await?; Ok(read_output(selected_name, &target, result)) } async fn execute_read_remote( client: &reqwest::Client, uri: &str, query_source: &str, query_name: Option<&str>, target: ReadTarget, params_json: Option<&Value>, bearer_token: Option<&str>, ) -> Result { let (branch, snapshot) = match &target { ReadTarget::Branch(branch) => (Some(branch.clone()), None), ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())), }; remote_json( client, Method::POST, remote_url(uri, "/read"), Some(serde_json::to_value(ReadRequest { query_source: query_source.to_string(), query_name: query_name.map(ToOwned::to_owned), params: params_json.cloned(), branch, snapshot, })?), bearer_token, ) .await } async fn execute_change( uri: &str, query_source: &str, query_name: Option<&str>, branch: &str, params_json: Option<&Value>, ) -> Result { let (selected_name, query_params) = select_named_query(query_source, query_name)?; let params = query_params_from_json(&query_params, params_json)?; let mut db = Omnigraph::open(uri).await?; let result = db .mutate(branch, query_source, &selected_name, ¶ms) .await?; Ok(ChangeOutput { branch: branch.to_string(), query_name: selected_name, affected_nodes: result.affected_nodes, affected_edges: result.affected_edges, actor_id: None, }) } async fn execute_change_remote( client: &reqwest::Client, uri: &str, query_source: &str, query_name: Option<&str>, branch: &str, params_json: Option<&Value>, bearer_token: Option<&str>, ) -> Result { remote_json( client, Method::POST, remote_url(uri, "/change"), Some(serde_json::to_value(ChangeRequest { query_source: query_source.to_string(), query_name: query_name.map(ToOwned::to_owned), params: params_json.cloned(), branch: Some(branch.to_string()), })?), bearer_token, ) .await } async fn execute_export( uri: &str, branch: &str, type_names: &[String], table_keys: &[String], ) -> Result { let db = Omnigraph::open(uri).await?; Ok(db.export_jsonl(branch, type_names, table_keys).await?) } async fn execute_export_remote( client: &reqwest::Client, uri: &str, branch: &str, type_names: &[String], table_keys: &[String], bearer_token: Option<&str>, ) -> Result { remote_text( client, Method::POST, remote_url(uri, "/export"), Some(serde_json::to_value(ExportRequest { branch: Some(branch.to_string()), type_names: type_names.to_vec(), table_keys: table_keys.to_vec(), })?), bearer_token, ) .await } #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; let cli = { let matches = Cli::command() .arg( Arg::new("version") .short('v') .long("version") .action(ArgAction::Version) .help("Print version"), ) .get_matches(); Cli::from_arg_matches(&matches)? }; let http_client = build_http_client()?; match cli.command { Command::Version => { println!("omnigraph {}", env!("CARGO_PKG_VERSION")); } Command::Embed(args) => { let output = execute_embed(&args).await?; if args.json { print_json(&output)?; } else { print_embed_human(&output); } } Command::Init { schema, uri } => { let schema_source = fs::read_to_string(&schema)?; ensure_local_repo_parent(&uri)?; Omnigraph::init(&uri, &schema_source).await?; scaffold_config_if_missing(&uri)?; println!("initialized {}", uri); } Command::Load { uri, target, config, data, branch, mode, json, } => { let config = load_cli_config(config.as_ref())?; let uri = resolve_local_uri(&config, uri, target.as_deref(), "load")?; let branch = resolve_branch(&config, branch, None, "main"); let mut db = Omnigraph::open(&uri).await?; let result = db .load_file(&branch, &data.to_string_lossy(), mode.into()) .await?; let payload = LoadOutput { uri: &uri, branch: &branch, mode: mode.as_str(), nodes_loaded: result.nodes_loaded.len(), edges_loaded: result.edges_loaded.len(), }; if json { print_json(&payload)?; } else { print_load_human( &uri, &branch, mode, payload.nodes_loaded, payload.edges_loaded, ); } } Command::Ingest { uri, target, config, data, branch, from, mode, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let branch = resolve_branch(&config, branch, None, "main"); let from = resolve_branch(&config, from, None, "main"); let payload = if is_remote_uri(&uri) { let data = fs::read_to_string(&data)?; remote_json::( &http_client, Method::POST, remote_url(&uri, "/ingest"), Some(serde_json::to_value(IngestRequest { branch: Some(branch.clone()), from: Some(from.clone()), mode: Some(mode.into()), data, })?), bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; let result = db .ingest_file(&branch, Some(&from), &data.to_string_lossy(), mode.into()) .await?; ingest_output(&uri, &result, None) }; if json { print_json(&payload)?; } else { print_ingest_human(&payload); } } Command::Branch { command } => match command { BranchCommand::Create { uri, target, config, from, name, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let from = resolve_branch(&config, from, None, "main"); let payload = if is_remote_uri(&uri) { remote_json::( &http_client, Method::POST, remote_url(&uri, "/branches"), Some(serde_json::to_value(BranchCreateRequest { from: Some(from.clone()), name: name.clone(), })?), bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; db.branch_create_from(ReadTarget::branch(&from), &name) .await?; BranchCreateOutput { uri: uri.clone(), from: from.clone(), name: name.clone(), actor_id: None, } }; if json { print_json(&payload)?; } else { println!("created branch {} from {}", payload.name, payload.from); } } BranchCommand::List { uri, target, config, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let payload = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, remote_url(&uri, "/branches"), None, bearer_token.as_deref(), ) .await? } else { let db = Omnigraph::open(&uri).await?; let mut branches = db.branch_list().await?; branches.sort(); BranchListOutput { branches } }; if json { print_json(&payload)?; } else { for branch in payload.branches { println!("{}", branch); } } } BranchCommand::Delete { uri, target, config, name, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let payload = if is_remote_uri(&uri) { remote_json::( &http_client, Method::DELETE, remote_branch_url(&uri, &name)?, None, bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; db.branch_delete(&name).await?; BranchDeleteOutput { uri: uri.clone(), name: name.clone(), actor_id: None, } }; if json { print_json(&payload)?; } else { println!("deleted branch {}", payload.name); } } BranchCommand::Merge { uri, target, config, source, into, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let into = resolve_branch(&config, into, None, "main"); let payload = if is_remote_uri(&uri) { remote_json::( &http_client, Method::POST, remote_url(&uri, "/branches/merge"), Some(serde_json::to_value(BranchMergeRequest { source: source.clone(), target: Some(into.clone()), })?), bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; let outcome = db.branch_merge(&source, &into).await?; BranchMergeOutput { source: source.clone(), target: into.clone(), outcome: outcome.into(), actor_id: None, } }; if json { print_json(&payload)?; } else { println!( "merged {} into {}: {}", payload.source, payload.target, payload.outcome.as_str() ); } } }, Command::Commit { command } => match command { CommitCommand::List { uri, target, config, branch, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let commits = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, if let Some(branch) = branch.as_deref() { format!("{}?branch={}", remote_url(&uri, "/commits"), branch) } else { remote_url(&uri, "/commits") }, None, bearer_token.as_deref(), ) .await? .commits } else { let db = Omnigraph::open(&uri).await?; db.list_commits(branch.as_deref()) .await? .iter() .map(commit_output) .collect::>() }; if json { print_json(&CommitListOutput { commits })?; } else { print_commit_list_human(&commits); } } CommitCommand::Show { uri, target, config, commit_id, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let commit = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, remote_url(&uri, &format!("/commits/{}", commit_id)), None, bearer_token.as_deref(), ) .await? } else { let db = Omnigraph::open(&uri).await?; commit_output(&db.get_commit(&commit_id).await?) }; if json { print_json(&commit)?; } else { print_commit_human(&commit); } } }, Command::Schema { command } => match command { SchemaCommand::Plan { uri, target, config, schema, json, } => { let config = load_cli_config(config.as_ref())?; let uri = resolve_local_uri(&config, uri, target.as_deref(), "schema plan")?; let schema_source = fs::read_to_string(&schema)?; let db = Omnigraph::open(&uri).await?; let plan = db.plan_schema(&schema_source).await?; let output = SchemaPlanOutput { uri: &uri, supported: plan.supported, step_count: plan.steps.len(), steps: &plan.steps, }; if json { print_json(&output)?; } else { print_schema_plan_human(&uri, &plan); } } }, Command::Snapshot { uri, target, config, branch, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let branch = resolve_branch(&config, branch, None, "main"); let payload = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, format!("{}?branch={}", remote_url(&uri, "/snapshot"), branch), None, bearer_token.as_deref(), ) .await? } else { let db = Omnigraph::open(&uri).await?; let snapshot = db.snapshot_of(ReadTarget::branch(branch.as_str())).await?; snapshot_payload(&branch, &snapshot) }; if json { print_json(&payload)?; } else { print_snapshot_human(&payload.branch, payload.manifest_version, &payload.tables); } } Command::Export { uri, target, config, branch, jsonl: _, type_names, table_keys, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let branch = resolve_branch(&config, branch, None, "main"); let output = if is_remote_uri(&uri) { execute_export_remote( &http_client, &uri, &branch, &type_names, &table_keys, bearer_token.as_deref(), ) .await? } else { execute_export(&uri, &branch, &type_names, &table_keys).await? }; print!("{output}"); } Command::Run { command } => match command { RunCommand::List { uri, target, config, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let runs = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, remote_url(&uri, "/runs"), None, bearer_token.as_deref(), ) .await? .runs } else { let db = Omnigraph::open(&uri).await?; db.list_runs() .await? .iter() .map(run_output) .collect::>() }; if json { print_json(&RunListOutput { runs })?; } else { print_run_list_human(&runs); } } RunCommand::Show { uri, target, config, run_id, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let run = if is_remote_uri(&uri) { remote_json::( &http_client, Method::GET, remote_url(&uri, &format!("/runs/{}", run_id)), None, bearer_token.as_deref(), ) .await? } else { let db = Omnigraph::open(&uri).await?; run_output(&db.get_run(&RunId::new(run_id)).await?) }; if json { print_json(&run)?; } else { print_run_human(&run); } } RunCommand::Publish { uri, target, config, run_id, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let run = if is_remote_uri(&uri) { remote_json::( &http_client, Method::POST, remote_url(&uri, &format!("/runs/{}/publish", run_id)), Some(serde_json::json!({})), bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; db.publish_run(&RunId::new(run_id.clone())).await?; run_output(&db.get_run(&RunId::new(run_id)).await?) }; if json { print_json(&run)?; } else { print_run_human(&run); } } RunCommand::Abort { uri, target, config, run_id, json, } => { let config = load_cli_config(config.as_ref())?; let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let run = if is_remote_uri(&uri) { remote_json::( &http_client, Method::POST, remote_url(&uri, &format!("/runs/{}/abort", run_id)), Some(serde_json::json!({})), bearer_token.as_deref(), ) .await? } else { let mut db = Omnigraph::open(&uri).await?; run_output(&db.abort_run(&RunId::new(run_id)).await?) }; if json { print_json(&run)?; } else { print_run_human(&run); } } }, Command::Read { uri, target, config, alias, query, name, params, branch, snapshot, format, json, alias_args, } => { if alias.is_some() == query.is_some() { bail!("exactly one of --alias or --query must be provided"); } let config = load_cli_config(config.as_ref())?; let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Read)?; let alias_name = alias.as_ref().map(|(name, _)| *name); let alias_config = alias.as_ref().map(|(_, alias)| *alias); let (uri, alias_args) = normalize_alias_args( uri, target.as_deref(), config.cli_target_name().is_some(), alias_name, alias_args, ); let target_name = target .as_deref() .or_else(|| alias_config.and_then(|alias| alias.target.as_deref())); let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?; let uri = resolve_uri(&config, uri, target_name)?; let query_source = resolve_query_source( &config, query.as_ref(), alias_config.map(|a| a.query.as_str()), )?; let params_json = merged_params_json( alias_name, alias_config .map(|alias| alias.args.as_slice()) .unwrap_or(&[]), &alias_args, load_params_json(¶ms)?, )?; let target = resolve_read_target( &config, branch, snapshot, alias_config.and_then(|alias| alias.branch.clone()), )?; let query_name = name.or_else(|| alias_config.and_then(|alias| alias.name.clone())); let output = if is_remote_uri(&uri) { execute_read_remote( &http_client, &uri, &query_source, query_name.as_deref(), target, params_json.as_ref(), bearer_token.as_deref(), ) .await? } else { execute_read( &uri, &query_source, query_name.as_deref(), target, params_json.as_ref(), ) .await? }; let format = resolve_read_format( &config, format, json, alias_config.and_then(|alias| alias.format), ); print_read_output(&output, format, &config)?; } Command::Change { uri, target, config, alias, query, name, params, branch, json, alias_args, } => { if alias.is_some() == query.is_some() { bail!("exactly one of --alias or --query must be provided"); } let config = load_cli_config(config.as_ref())?; let alias = resolve_alias(&config, alias.as_deref(), AliasCommand::Change)?; let alias_name = alias.as_ref().map(|(name, _)| *name); let alias_config = alias.as_ref().map(|(_, alias)| *alias); let (uri, alias_args) = normalize_alias_args( uri, target.as_deref(), config.cli_target_name().is_some(), alias_name, alias_args, ); let target_name = target .as_deref() .or_else(|| alias_config.and_then(|alias| alias.target.as_deref())); let bearer_token = resolve_remote_bearer_token(&config, uri.as_deref(), target_name)?; let uri = resolve_uri(&config, uri, target_name)?; let query_source = resolve_query_source( &config, query.as_ref(), alias_config.map(|a| a.query.as_str()), )?; let params_json = merged_params_json( alias_name, alias_config .map(|alias| alias.args.as_slice()) .unwrap_or(&[]), &alias_args, load_params_json(¶ms)?, )?; let branch = resolve_branch( &config, branch, alias_config.and_then(|alias| alias.branch.clone()), "main", ); let query_name = name.or_else(|| alias_config.and_then(|alias| alias.name.clone())); let output = if is_remote_uri(&uri) { execute_change_remote( &http_client, &uri, &query_source, query_name.as_deref(), &branch, params_json.as_ref(), bearer_token.as_deref(), ) .await? } else { execute_change( &uri, &query_source, query_name.as_deref(), &branch, params_json.as_ref(), ) .await? }; if json { print_json(&output)?; } else { print_change_human(&output); } } Command::Policy { command } => match command { PolicyCommand::Validate { config } => { let config = load_cli_config(config.as_ref())?; let engine = resolve_policy_engine(&config)?; let policy_file = config .resolve_policy_file() .expect("policy file should exist after resolve_policy_engine"); println!( "policy valid: {} [{} actors]", policy_file.display(), engine.known_actor_count() ); } PolicyCommand::Test { config } => { let config = load_cli_config(config.as_ref())?; let engine = resolve_policy_engine(&config)?; let tests_path = resolve_policy_tests_path(&config)?; let tests = PolicyTestConfig::load(&tests_path)?; engine.run_tests(&tests)?; println!("policy tests passed: {} cases", tests.cases.len()); } PolicyCommand::Explain { config, actor, action, branch, target_branch, } => { let config = load_cli_config(config.as_ref())?; let engine = resolve_policy_engine(&config)?; let request = PolicyRequest { actor_id: actor, action, branch, target_branch, }; let decision = engine.authorize(&request)?; print_policy_explain(&decision, &request); } }, } Ok(()) } #[cfg(test)] mod tests { use std::fs; use super::{ DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, load_cli_config, load_env_file_into_process, normalize_bearer_token, parse_env_assignment, resolve_remote_bearer_token, }; use omnigraph_server::load_config; use reqwest::header::AUTHORIZATION; use tempfile::tempdir; #[test] fn apply_bearer_token_adds_header_when_configured() { let client = reqwest::Client::new(); let request = apply_bearer_token(client.get("http://example.com"), Some("demo-token")) .build() .unwrap(); assert_eq!( request .headers() .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()), Some("Bearer demo-token") ); } #[test] fn apply_bearer_token_leaves_request_unchanged_when_not_configured() { let client = reqwest::Client::new(); let request = apply_bearer_token(client.get("http://example.com"), None) .build() .unwrap(); assert!(request.headers().get(AUTHORIZATION).is_none()); } #[test] fn normalize_bearer_token_trims_and_filters_blank_values() { assert_eq!(normalize_bearer_token(None), None); assert_eq!(normalize_bearer_token(Some(" ".to_string())), None); assert_eq!( normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(), Some("demo-token") ); } #[test] fn parse_env_assignment_supports_plain_and_exported_values() { assert_eq!( parse_env_assignment("DEMO_TOKEN=demo-token"), Some(("DEMO_TOKEN".to_string(), "demo-token".to_string())) ); assert_eq!( parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""), Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string())) ); assert_eq!(parse_env_assignment("# comment"), None); assert_eq!(parse_env_assignment(" "), None); } #[test] fn bearer_token_from_env_file_reads_named_value() { let temp = tempdir().unwrap(); let env_file = temp.path().join(".env.omni"); fs::write( &env_file, "FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n", ) .unwrap(); assert_eq!( bearer_token_from_env_file(&env_file, "DEMO_TOKEN") .unwrap() .as_deref(), Some("demo-token") ); assert_eq!( bearer_token_from_env_file(&env_file, "MISSING").unwrap(), None ); } #[test] fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() { let temp = tempdir().unwrap(); let env_file = temp.path().join(".env.omni"); fs::write( &env_file, "AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n", ) .unwrap(); let missing_key = "AUTOLOAD_ONLY"; let preset_key = "AUTOLOAD_PRESET"; let previous_missing = std::env::var_os(missing_key); let previous_preset = std::env::var_os(preset_key); unsafe { std::env::remove_var(missing_key); std::env::set_var(preset_key, "from-env"); } load_env_file_into_process(&env_file).unwrap(); assert_eq!(std::env::var(missing_key).unwrap(), "from-file"); assert_eq!(std::env::var(preset_key).unwrap(), "from-env"); unsafe { if let Some(value) = previous_missing { std::env::set_var(missing_key, value); } else { std::env::remove_var(missing_key); } if let Some(value) = previous_preset { std::env::set_var(preset_key, value); } else { std::env::remove_var(preset_key); } } } #[test] fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), r#" targets: demo: uri: https://example.com bearer_token_env: DEMO_TOKEN auth: env_file: .env.omni cli: target: demo "#, ) .unwrap(); fs::write( temp.path().join(".env.omni"), "DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n", ) .unwrap(); let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV); unsafe { std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); } let config_path = temp.path().join("omnigraph.yaml"); let config = load_config(Some(&config_path)).unwrap(); assert_eq!( resolve_remote_bearer_token(&config, None, Some("demo")) .unwrap() .as_deref(), Some("scoped-token") ); assert_eq!( resolve_remote_bearer_token(&config, Some("https://override.example.com"), None) .unwrap() .as_deref(), Some("global-token") ); unsafe { if let Some(value) = previous { std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, value); } else { std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV); } } } #[test] fn load_cli_config_autoloads_env_file_into_process() { let temp = tempdir().unwrap(); fs::write( temp.path().join("omnigraph.yaml"), r#" auth: env_file: .env.omni targets: demo: uri: s3://bucket/prefix "#, ) .unwrap(); fs::write( temp.path().join(".env.omni"), "AUTOLOAD_FROM_CONFIG=loaded\n", ) .unwrap(); let key = "AUTOLOAD_FROM_CONFIG"; let previous = std::env::var_os(key); unsafe { std::env::remove_var(key); } let config_path = temp.path().join("omnigraph.yaml"); let config = load_cli_config(Some(&config_path)).unwrap(); assert_eq!( config.resolve_target_uri(None, Some("demo"), None).unwrap(), "s3://bucket/prefix" ); assert_eq!(std::env::var(key).unwrap(), "loaded"); unsafe { if let Some(value) = previous { std::env::set_var(key, value); } else { std::env::remove_var(key); } } } }