From a134ba5dbe9ef8a724b644c9e1d6afbfcac86c17 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 23 Jul 2026 16:16:24 +0930 Subject: [PATCH 01/19] refactor(cli): avoid build-output path ambiguity Rename the command materialization module so repository tooling and architecture scanners do not classify tracked source as generated build output. The module contents remain unchanged; only Rust module references move with it. Constraint: Scryer excludes build-output paths from completeness accounting Rejected: Add synthetic anchors around the filtered path | would hide the source-discovery mismatch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep product source modules out of directories named like generated build outputs Tested: cargo fmt --all --check; cargo test --workspace; cargo clippy --workspace --all-targets --all-features -- -D warnings; Scryer validate_model and get_health Not-tested: None --- src/cli/dispatch.rs | 2 +- src/cli/{build => materialization}/command.rs | 0 src/cli/{build => materialization}/manifest.rs | 0 src/cli/{build => materialization}/mod.rs | 0 src/cli/{build => materialization}/output.rs | 0 src/cli/{build => materialization}/request.rs | 0 src/cli/mcp/refresh.rs | 2 +- src/cli/mod.rs | 2 +- src/cli/setup.rs | 8 ++++---- src/cli/tests/mod.rs | 2 +- src/cli/watch/command.rs | 2 +- src/cli/watch/filter.rs | 2 +- src/cli/watch/native.rs | 2 +- src/cli/watch/options.rs | 2 +- src/cli/watch/poll.rs | 2 +- src/cli/watch/refresh.rs | 2 +- src/cli/watch/snapshot.rs | 2 +- 17 files changed, 15 insertions(+), 15 deletions(-) rename src/cli/{build => materialization}/command.rs (100%) rename src/cli/{build => materialization}/manifest.rs (100%) rename src/cli/{build => materialization}/mod.rs (100%) rename src/cli/{build => materialization}/output.rs (100%) rename src/cli/{build => materialization}/request.rs (100%) diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index fe1c28d..3dcfd0a 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -1,10 +1,10 @@ use super::{ - build::{run_materialize, run_plan}, format::top_level_help, graph::{ run_graph_architecture_queries, run_graph_context, run_graph_health, run_graph_query, run_graph_query_helpers, run_graph_schema, run_graph_search, }, + materialization::{run_materialize, run_plan}, mcp::{run_mcp_command, serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, reinstall::run_reinstall, setup::run_setup, diff --git a/src/cli/build/command.rs b/src/cli/materialization/command.rs similarity index 100% rename from src/cli/build/command.rs rename to src/cli/materialization/command.rs diff --git a/src/cli/build/manifest.rs b/src/cli/materialization/manifest.rs similarity index 100% rename from src/cli/build/manifest.rs rename to src/cli/materialization/manifest.rs diff --git a/src/cli/build/mod.rs b/src/cli/materialization/mod.rs similarity index 100% rename from src/cli/build/mod.rs rename to src/cli/materialization/mod.rs diff --git a/src/cli/build/output.rs b/src/cli/materialization/output.rs similarity index 100% rename from src/cli/build/output.rs rename to src/cli/materialization/output.rs diff --git a/src/cli/build/request.rs b/src/cli/materialization/request.rs similarity index 100% rename from src/cli/build/request.rs rename to src/cli/materialization/request.rs diff --git a/src/cli/mcp/refresh.rs b/src/cli/mcp/refresh.rs index 9df00a5..433e033 100644 --- a/src/cli/mcp/refresh.rs +++ b/src/cli/mcp/refresh.rs @@ -1,7 +1,7 @@ use super::options::McpServeOptions; use crate::cli::{ - build::{materialize_candidate_paths, MaterializeOptions}, graph::resolve_health_runtime, + materialization::{materialize_candidate_paths, MaterializeOptions}, watch::{ collect_poll_batch, collect_watch_batch, probe_native_watcher, start_native_watcher, watch_file_snapshot, WatchEventFilter, WatchLoopConfig, WatchMessage, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 997a764..4d48606 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,9 @@ -mod build; mod constants; mod dispatch; mod format; mod graph; mod install; +mod materialization; mod mcp; mod reinstall; mod setup; diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 95a82bd..330549b 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -1,11 +1,11 @@ use super::{ - build::{ - build_request, dry_run_materialization_payload, materialization_payload, materialize, - MaterializeOptions, - }, constants::server_command, format::setup_help, install::{build_mcp_descriptor, install_mcp_client, McpInstallOptions}, + materialization::{ + build_request, dry_run_materialization_payload, materialization_payload, materialize, + MaterializeOptions, + }, util::{read_json_file, resolve_repo_root, restore_file, snapshot_file}, watch::SetupOptions, }; diff --git a/src/cli/tests/mod.rs b/src/cli/tests/mod.rs index 8f66ada..c23117c 100644 --- a/src/cli/tests/mod.rs +++ b/src/cli/tests/mod.rs @@ -1,5 +1,5 @@ use super::*; -use super::{build::*, install::*, mcp::*, watch::*}; +use super::{install::*, materialization::*, mcp::*, watch::*}; use notify::{Event, EventKind}; use serde_json::json; use std::{ diff --git a/src/cli/watch/command.rs b/src/cli/watch/command.rs index 6ae892c..e558f22 100644 --- a/src/cli/watch/command.rs +++ b/src/cli/watch/command.rs @@ -5,7 +5,7 @@ use super::{ output::{write_watch_event, write_watch_status}, poll::run_poll_watch, }; -use crate::cli::{build::materialize, format::watch_help, util::resolve_source_root}; +use crate::cli::{format::watch_help, materialization::materialize, util::resolve_source_root}; use std::{collections::VecDeque, io::Write}; pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Result<(), String> { diff --git a/src/cli/watch/filter.rs b/src/cli/watch/filter.rs index 2e724c0..ba95c67 100644 --- a/src/cli/watch/filter.rs +++ b/src/cli/watch/filter.rs @@ -1,6 +1,6 @@ use super::helpers::watch_matches_any_pattern; use crate::cli::{ - build::{ + materialization::{ default_excluded_parts, read_codebase_graph_ignore, read_materialization_config_rules, MaterializeOptions, }, diff --git a/src/cli/watch/native.rs b/src/cli/watch/native.rs index b5ef774..3db26a0 100644 --- a/src/cli/watch/native.rs +++ b/src/cli/watch/native.rs @@ -6,7 +6,7 @@ use super::{ types::{WatchMessage, WatchProbeOutcome}, WatchLoopConfig, }; -use crate::cli::build::MaterializeOptions; +use crate::cli::materialization::MaterializeOptions; use notify::{Event, RecursiveMode, Watcher}; use std::{ collections::VecDeque, diff --git a/src/cli/watch/options.rs b/src/cli/watch/options.rs index d1f1fdf..ed285b7 100644 --- a/src/cli/watch/options.rs +++ b/src/cli/watch/options.rs @@ -1,5 +1,5 @@ use crate::cli::{ - build::MaterializeOptions, format::setup_help, install::supported_install_clients, + format::setup_help, install::supported_install_clients, materialization::MaterializeOptions, }; use std::path::PathBuf; diff --git a/src/cli/watch/poll.rs b/src/cli/watch/poll.rs index 37be922..e37b3c9 100644 --- a/src/cli/watch/poll.rs +++ b/src/cli/watch/poll.rs @@ -6,7 +6,7 @@ use super::{ types::WatchFileSnapshot, WatchEventFilter, WatchLoopConfig, }; -use crate::cli::build::MaterializeOptions; +use crate::cli::materialization::MaterializeOptions; use std::{ io::Write, time::{Duration, Instant}, diff --git a/src/cli/watch/refresh.rs b/src/cli/watch/refresh.rs index e4b8aba..22d8d92 100644 --- a/src/cli/watch/refresh.rs +++ b/src/cli/watch/refresh.rs @@ -1,6 +1,6 @@ use super::output::{write_watch_event, write_watch_status}; use crate::{ - cli::build::{materialize_candidate_paths, MaterializeOptions}, + cli::materialization::{materialize_candidate_paths, MaterializeOptions}, db_writer::is_transient_database_error, protocol::NativeSyntaxMaterializationResponse, }; diff --git a/src/cli/watch/snapshot.rs b/src/cli/watch/snapshot.rs index 1780d66..02dfe8c 100644 --- a/src/cli/watch/snapshot.rs +++ b/src/cli/watch/snapshot.rs @@ -2,7 +2,7 @@ use super::{ types::{WatchFileSnapshot, WatchFileState}, WatchEventFilter, }; -use crate::cli::build::default_excluded_parts; +use crate::cli::materialization::default_excluded_parts; use std::{ collections::{BTreeMap, BTreeSet}, fs, From e7681caa3451a313edd8088d0814a55e6f68f794 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 23 Jul 2026 19:58:10 +0930 Subject: [PATCH 02/19] feat(api): ensure all clients share one graph operation path Centralize CLI, MCP, lifecycle, refresh, and embedded execution behind a transport-neutral registry and facade. Linearize materialization from scanned source snapshots through planning, enrichment, and the sole graph writer while preserving existing outputs and behavior. Constraint: Preserve CLI flags, MCP tool schemas, block output, exit codes, and graph semantics Rejected: Retain direct adapter-to-service calls | would leave duplicate execution paths Confidence: high Scope-risk: broad Reversibility: clean Directive: Register future operations in OperationRegistry and route adapters through CodebaseGraphApi Tested: cargo fmt --check; cargo clippy --all-targets --all-features -- -D warnings; cargo test --all-targets --all-features; Scryer validation and drift reconciliation Not-tested: Peak-memory comparison for large repositories --- src/api/boundary_tests.rs | 55 ++ src/api/catalog.rs | 28 + src/api/context.rs | 209 +++++++ src/api/contracts.rs | 401 +++++++++++++ src/api/core.rs | 863 ++++++++++++++++++++++++++++ src/api/facade.rs | 89 +++ src/api/lifecycle.rs | 104 ++++ src/api/materialization.rs | 68 +++ src/api/mod.rs | 26 + src/api/presenter.rs | 77 +++ src/cli/constants.rs | 7 +- src/cli/dispatch.rs | 232 +++++++- src/cli/format/blocks.rs | 22 +- src/cli/format/metadata.rs | 25 +- src/cli/format/mod.rs | 8 +- src/cli/format/schema.rs | 2 +- src/cli/graph/commands.rs | 268 --------- src/cli/graph/health.rs | 16 +- src/cli/graph/mod.rs | 16 +- src/cli/graph/options.rs | 80 +-- src/cli/graph/query.rs | 27 +- src/cli/graph/search.rs | 4 +- src/cli/materialization/command.rs | 216 ++++--- src/cli/materialization/manifest.rs | 8 +- src/cli/materialization/mod.rs | 10 +- src/cli/materialization/output.rs | 6 +- src/cli/materialization/request.rs | 73 ++- src/cli/mcp/protocol.rs | 8 +- src/cli/mcp/refresh.rs | 13 +- src/cli/mcp/tools.rs | 393 ++++++++----- src/cli/mod.rs | 14 +- src/cli/reinstall.rs | 92 ++- src/cli/setup.rs | 109 +++- src/cli/uninstall.rs | 102 +++- src/cli/watch/command.rs | 4 +- src/cli/watch/mod.rs | 1 + src/cli/watch/refresh.rs | 39 +- src/error.rs | 2 + src/execution/mod.rs | 4 +- src/execution/parallel.rs | 78 ++- src/execution/plan.rs | 4 +- src/execution/run.rs | 95 ++- src/lib.rs | 7 +- src/parser/mod.rs | 10 +- src/profiles.rs | 11 + src/protocol.rs | 16 +- src/scan.rs | 21 +- src/semantic_enrichment/mod.rs | 4 +- src/staging_writer/mod.rs | 2 + src/staging_writer/writer.rs | 33 ++ 50 files changed, 3162 insertions(+), 840 deletions(-) create mode 100644 src/api/boundary_tests.rs create mode 100644 src/api/catalog.rs create mode 100644 src/api/context.rs create mode 100644 src/api/contracts.rs create mode 100644 src/api/core.rs create mode 100644 src/api/facade.rs create mode 100644 src/api/lifecycle.rs create mode 100644 src/api/materialization.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/presenter.rs delete mode 100644 src/cli/graph/commands.rs create mode 100644 src/staging_writer/writer.rs diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs new file mode 100644 index 0000000..3bd7024 --- /dev/null +++ b/src/api/boundary_tests.rs @@ -0,0 +1,55 @@ +#[test] +fn transport_adapters_do_not_import_product_execution_services() { + let adapters = [ + ("CLI dispatch", include_str!("../cli/dispatch.rs")), + ( + "CLI materialization", + include_str!("../cli/materialization/command.rs"), + ), + ("MCP tools", include_str!("../cli/mcp/tools.rs")), + ("watch command", include_str!("../cli/watch/command.rs")), + ("watch refresh", include_str!("../cli/watch/refresh.rs")), + ("MCP refresh", include_str!("../cli/mcp/refresh.rs")), + ]; + let forbidden = [ + ["crate", "::db_writer"].concat(), + ["crate", "::scan"].concat(), + ["crate", "::execution"].concat(), + ["crate", "::semantic_enrichment"].concat(), + ["crate", "::staging_writer"].concat(), + ["crate", "::cli::graph"].concat(), + ["crate", "::cli::materialization"].concat(), + ["materialize", "_syntax_batch"].concat(), + ["execute", "_materialization_pipeline"].concat(), + ["execute", "_graph_search"].concat(), + ["execute", "_read_only_query"].concat(), + ["write", "_database("].concat(), + ]; + + for (name, source) in adapters { + for dependency in &forbidden { + assert!( + !source.contains(dependency), + "{name} bypasses the Public API Facade through {dependency}" + ); + } + } +} + +#[test] +fn only_graph_writer_submits_database_updates() { + let graph_writer = include_str!("../staging_writer/writer.rs"); + assert!(graph_writer.contains(&["write", "_database("].concat())); + + let other_materialization_modules = [ + include_str!("../execution/run.rs"), + include_str!("../execution/plan.rs"), + include_str!("../execution/parallel.rs"), + include_str!("../semantic_enrichment/mod.rs"), + include_str!("../cli/materialization/command.rs"), + include_str!("materialization.rs"), + ]; + for source in other_materialization_modules { + assert!(!source.contains(&["write", "_database("].concat())); + } +} diff --git a/src/api/catalog.rs b/src/api/catalog.rs new file mode 100644 index 0000000..19c6805 --- /dev/null +++ b/src/api/catalog.rs @@ -0,0 +1,28 @@ +use crate::cli::constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}; + +pub(crate) use crate::cli::format::schema_statements_from_copy_statements; + +pub fn load_catalog(kind: &str) -> Result { + let source = match kind { + "schema" => GRAPH_SCHEMA_JSON, + "query-helpers" => QUERY_HELPERS_JSON, + "architecture-queries" => ARCHITECTURE_QUERIES_JSON, + _ => return Err(format!("unknown catalog kind: {kind}")), + }; + serde_json::from_str(source) + .map_err(|error| format!("failed to parse embedded catalog {kind}: {error}")) +} + +pub fn filter_catalog( + kind: &str, + payload: &mut serde_json::Value, + group: Option<&str>, +) -> Result<(), String> { + match (kind, group) { + ("architecture-queries", Some(group)) => { + crate::cli::format::filter_architecture_group(payload, group) + } + (_, Some(_)) => Err(format!("catalog {kind} does not support group filtering")), + (_, None) => Ok(()), + } +} diff --git a/src/api/context.rs b/src/api/context.rs new file mode 100644 index 0000000..620bf69 --- /dev/null +++ b/src/api/context.rs @@ -0,0 +1,209 @@ +use crate::api::contracts::RepoSelector; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) struct RepoRuntime { + pub repo_root: PathBuf, + pub db_path: PathBuf, + pub manifest_path: PathBuf, + pub config_path: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct RepoPaths { + pub db_path: PathBuf, + pub manifest_path: PathBuf, +} + +impl RepoPaths { + fn derive(repo_root: &Path) -> Self { + let repo_name = safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ); + let state_dir = repo_root.join(".codebaseGraph"); + Self { + db_path: state_dir.join(format!("{repo_name}_graph.ldb")), + manifest_path: state_dir.join("manifest.json"), + } + } +} + +pub(crate) fn resolve_runtime(selector: &RepoSelector) -> Result { + let repo_root = resolve_repository_root(selector.repo_root.as_deref())?; + let paths = RepoPaths::derive(&repo_root); + let config_path = selector + .config_path + .clone() + .unwrap_or_else(|| repo_root.join(".codebaseGraph").join("config.json")); + let config = if config_path.exists() { + Some(read_json_file(&config_path)?) + } else { + None + }; + Ok(RepoRuntime { + repo_root: repo_root.clone(), + db_path: selector + .db_path + .clone() + .or_else(|| config_path_value(config.as_ref(), "database_path")) + .unwrap_or(paths.db_path), + manifest_path: selector + .manifest_path + .clone() + .or_else(|| config_path_value(config.as_ref(), "manifest_path")) + .unwrap_or(paths.manifest_path), + config_path: Some(config_path), + }) +} + +pub(crate) fn resolve_repository_root(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return path + .canonicalize() + .map_err(|error| format!("failed to resolve repo root: {error}")); + } + let current_dir = std::env::current_dir() + .map_err(|error| format!("failed to read current directory: {error}"))?; + for ancestor in current_dir.ancestors() { + let config_path = ancestor.join(".codebaseGraph").join("config.json"); + if config_path.exists() { + let config = read_json_file(&config_path)?; + if let Some(repo_root) = config.get("repo_root").and_then(serde_json::Value::as_str) { + let repo_root = PathBuf::from(repo_root); + return Ok(repo_root.canonicalize().unwrap_or(repo_root)); + } + return Ok(ancestor.to_path_buf()); + } + } + if let Some(git_root) = current_dir + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + { + return Ok(git_root.to_path_buf()); + } + Ok(current_dir) +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) +} + +fn config_path_value(config: Option<&serde_json::Value>, key: &str) -> Option { + config + .and_then(|value| value.get(key)) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) +} + +fn safe_name(value: &str) -> String { + let normalized: String = value + .chars() + .map(|character| { + if character.is_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect(); + let trimmed = normalized.trim_matches(['.', '_', '-']); + if trimmed.is_empty() { + "repository".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::{resolve_runtime, RepoPaths}; + use crate::api::contracts::RepoSelector; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )) + } + + #[test] + fn graph_paths_are_deterministic() { + let paths = RepoPaths::derive(&std::path::PathBuf::from("/tmp/demo")); + assert!(paths.db_path.to_string_lossy().ends_with("demo_graph.ldb")); + assert_eq!( + paths.manifest_path, + std::path::PathBuf::from("/tmp/demo/.codebaseGraph/manifest.json") + ); + } + + #[test] + fn resolve_runtime_selects_configured_graph_and_manifest_paths() { + let root = unique_temp_dir("codebase-graph-api-runtime"); + let state = root.join(".codebaseGraph"); + fs::create_dir_all(&state).expect("state directory should be created"); + let graph_path = state.join("configured.ldb"); + let manifest_path = state.join("configured-manifest.json"); + fs::write( + state.join("config.json"), + serde_json::to_vec(&serde_json::json!({ + "database_path": graph_path, + "manifest_path": manifest_path, + })) + .expect("config should serialize"), + ) + .expect("config should be written"); + + let runtime = resolve_runtime(&RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: None, + manifest_path: None, + }) + .expect("runtime should resolve"); + + assert_eq!( + runtime.repo_root, + root.canonicalize().expect("root should canonicalize") + ); + assert_eq!(runtime.db_path, graph_path); + assert_eq!(runtime.manifest_path, manifest_path); + assert_eq!( + runtime.config_path, + Some(runtime.repo_root.join(".codebaseGraph").join("config.json")) + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_runtime_prefers_explicit_graph_and_manifest_paths() { + let root = unique_temp_dir("codebase-graph-api-runtime-explicit"); + fs::create_dir_all(&root).expect("repository should be created"); + let graph_path = root.join("explicit.ldb"); + let manifest_path = root.join("explicit-manifest.json"); + + let runtime = resolve_runtime(&RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: Some(graph_path.clone()), + manifest_path: Some(manifest_path.clone()), + }) + .expect("runtime should resolve"); + + assert_eq!(runtime.db_path, graph_path); + assert_eq!(runtime.manifest_path, manifest_path); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/api/contracts.rs b/src/api/contracts.rs new file mode 100644 index 0000000..de2c244 --- /dev/null +++ b/src/api/contracts.rs @@ -0,0 +1,401 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RepoSelector { + pub repo_root: Option, + pub config_path: Option, + pub db_path: Option, + pub manifest_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeRef { + pub id: String, + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "operation", content = "request")] +pub enum OperationRequest { + Health(HealthRequest), + Search(SearchRequest), + Context(ContextRequest), + Query(QueryRequest), + Materialize(MaterializationRequest), + Plan(MaterializationRequest), + Catalog { + kind: String, + group: Option, + output_format: OutputFormat, + }, + Setup(RepositoryLifecycleRequest), + Reinstall(RepositoryLifecycleRequest), + Uninstall(RepositoryLifecycleRequest), + Refresh(RefreshRequest), +} + +impl OperationRequest { + pub fn operation_name(&self) -> &str { + match self { + Self::Health(_) => "health", + Self::Search(_) => "search", + Self::Context(_) => "context", + Self::Query(_) => "query", + Self::Materialize(_) => "materialize", + Self::Plan(_) => "plan", + Self::Catalog { kind, .. } => kind, + Self::Setup(_) => "setup", + Self::Reinstall(_) => "reinstall", + Self::Uninstall(_) => "uninstall", + Self::Refresh(_) => "refresh", + } + } + + pub fn repo_selector(&self) -> Option<&RepoSelector> { + match self { + Self::Health(request) => Some(&request.repo), + Self::Search(request) => Some(&request.repo), + Self::Context(request) => Some(&request.repo), + Self::Query(request) => Some(&request.repo), + Self::Materialize(request) => Some(&request.repo), + Self::Plan(request) => Some(&request.repo), + Self::Catalog { .. } => None, + Self::Setup(request) => Some(&request.repo), + Self::Reinstall(request) => Some(&request.repo), + Self::Uninstall(request) => Some(&request.repo), + Self::Refresh(request) => Some(&request.repo), + } + } + + pub fn output_format(&self) -> OutputFormat { + match self { + Self::Catalog { output_format, .. } + | Self::Health(HealthRequest { output_format, .. }) + | Self::Search(SearchRequest { output_format, .. }) + | Self::Context(ContextRequest { output_format, .. }) + | Self::Query(QueryRequest { output_format, .. }) + | Self::Materialize(MaterializationRequest { output_format, .. }) + | Self::Plan(MaterializationRequest { output_format, .. }) + | Self::Setup(RepositoryLifecycleRequest { output_format, .. }) + | Self::Reinstall(RepositoryLifecycleRequest { output_format, .. }) + | Self::Uninstall(RepositoryLifecycleRequest { output_format, .. }) + | Self::Refresh(RefreshRequest { output_format, .. }) => *output_format, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum OutputFormat { + #[default] + Typed, + Block, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthRequest { + pub repo: RepoSelector, + pub refresh_status: Option, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchRequest { + pub repo: RepoSelector, + pub query: String, + pub profile: String, + pub limit: usize, + pub budget: usize, + pub context_limit: usize, + pub max_depth: Option, + pub detail: String, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextRequest { + pub repo: RepoSelector, + pub query: Option, + pub profile: String, + pub limit: usize, + pub budget: usize, + pub context_limit: usize, + pub max_depth: Option, + pub detail: String, + pub node_id: Option, + pub node_type: Option, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryRequest { + pub repo: RepoSelector, + pub statement: String, + pub parameters: serde_json::Value, + pub limit: usize, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterializationRequest { + pub repo: RepoSelector, + pub native_request_path: Option, + pub source_root: Option, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, + pub use_git: bool, + pub git_diff: bool, + pub git_base: Option, + pub include_patterns: Vec, + pub exclude_patterns: Vec, + pub candidate_paths: Vec, + pub parallel: bool, + pub progress: bool, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryLifecycleRequest { + pub repo: RepoSelector, + pub action: String, + pub output_format: OutputFormat, + pub dry_run: bool, + pub mcp_client: Option, + pub mcp_config_path: Option, + pub instructions_target: Option, + pub skip_mcp_config: bool, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshRequest { + pub repo: RepoSelector, + pub paths: Vec, + pub mode: String, + pub include_fts: bool, + pub semantic_enrichment: bool, + pub semantic_provider_mode: String, + pub parallel: bool, + pub progress: bool, + pub output_format: OutputFormat, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationResponse { + pub operation: String, + pub output_format: OutputFormat, + pub payload: serde_json::Value, + pub diagnostics: Vec, +} + +impl OperationResponse { + pub fn from_payload( + operation: &str, + output_format: OutputFormat, + payload: serde_json::Value, + ) -> Self { + Self { + operation: operation.to_string(), + output_format, + payload, + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiError { + pub code: String, + pub message: String, + pub details: Option, + pub retryable: bool, +} + +impl ApiError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + details: None, + retryable: false, + } + } + + pub fn with_details(mut self, details: serde_json::Value) -> Self { + self.details = Some(details); + self + } + + pub fn retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn repository() -> RepoSelector { + RepoSelector { + repo_root: Some(PathBuf::from("/tmp/repository")), + config_path: Some(PathBuf::from("/tmp/config.json")), + db_path: Some(PathBuf::from("/tmp/graph.ldb")), + manifest_path: Some(PathBuf::from("/tmp/manifest.json")), + } + } + + fn materialization() -> MaterializationRequest { + MaterializationRequest { + repo: repository(), + native_request_path: None, + source_root: Some("/tmp/repository".to_string()), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: true, + git_diff: false, + git_base: None, + include_patterns: vec!["src/**".to_string()], + exclude_patterns: vec!["target/**".to_string()], + candidate_paths: vec!["src/lib.rs".to_string()], + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + } + } + + fn lifecycle(action: &str) -> RepositoryLifecycleRequest { + RepositoryLifecycleRequest { + repo: repository(), + action: action.to_string(), + output_format: OutputFormat::Typed, + dry_run: false, + mcp_client: Some("none".to_string()), + mcp_config_path: Some(PathBuf::from("/tmp/mcp.json")), + instructions_target: None, + skip_mcp_config: true, + mode: "full".to_string(), + include_fts: true, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + } + } + + #[test] + fn public_operation_contracts_round_trip_without_transport_types() { + let requests = vec![ + OperationRequest::Health(HealthRequest { + repo: repository(), + refresh_status: Some(json!({"running": true})), + output_format: OutputFormat::Block, + }), + OperationRequest::Search(SearchRequest { + repo: repository(), + query: "execute operation".to_string(), + profile: "brief".to_string(), + limit: 3, + budget: 600, + context_limit: 2, + max_depth: Some(2), + detail: "slim".to_string(), + output_format: OutputFormat::Typed, + }), + OperationRequest::Context(ContextRequest { + repo: repository(), + query: None, + profile: "dependencies".to_string(), + limit: 3, + budget: 600, + context_limit: 2, + max_depth: None, + detail: "standard".to_string(), + node_id: Some("node-1".to_string()), + node_type: Some("Function".to_string()), + output_format: OutputFormat::Block, + }), + OperationRequest::Query(QueryRequest { + repo: repository(), + statement: "MATCH (n) RETURN n LIMIT 1".to_string(), + parameters: json!({}), + limit: 1, + output_format: OutputFormat::Typed, + }), + OperationRequest::Materialize(materialization()), + OperationRequest::Plan(materialization()), + OperationRequest::Catalog { + kind: "architecture-queries".to_string(), + group: Some("dependencies".to_string()), + output_format: OutputFormat::Block, + }, + OperationRequest::Setup(lifecycle("setup")), + OperationRequest::Reinstall(lifecycle("reinstall")), + OperationRequest::Uninstall(lifecycle("uninstall")), + OperationRequest::Refresh(RefreshRequest { + repo: repository(), + paths: vec!["src/lib.rs".to_string()], + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + }), + ]; + + for request in requests { + let encoded = serde_json::to_value(&request).expect("request should serialize"); + let decoded: OperationRequest = + serde_json::from_value(encoded.clone()).expect("request should deserialize"); + assert_eq!( + serde_json::to_value(decoded).expect("decoded request should serialize"), + encoded + ); + } + + let node = NodeRef { + id: "node-1".to_string(), + kind: "Function".to_string(), + }; + let encoded_node = serde_json::to_value(&node).unwrap(); + assert_eq!( + serde_json::to_value(serde_json::from_value::(encoded_node.clone()).unwrap()) + .unwrap(), + encoded_node + ); + + let response = OperationResponse::from_payload("search", OutputFormat::Typed, json!({})); + let encoded_response = serde_json::to_value(&response).unwrap(); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(encoded_response.clone()).unwrap() + ) + .unwrap(), + encoded_response + ); + + let error = ApiError::new("temporary_failure", "try again") + .with_details(json!({"attempt": 1})) + .retryable(true); + let encoded_error = serde_json::to_value(&error).unwrap(); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(encoded_error.clone()).unwrap() + ) + .unwrap(), + encoded_error + ); + + let source = include_str!("contracts.rs"); + assert!(!source.contains(&["crate", "::cli"].concat())); + assert!(!source.contains(&["mcp", "::"].concat())); + } +} diff --git a/src/api/core.rs b/src/api/core.rs new file mode 100644 index 0000000..b438129 --- /dev/null +++ b/src/api/core.rs @@ -0,0 +1,863 @@ +use crate::api::catalog::{filter_catalog, load_catalog}; +use crate::api::context::{resolve_runtime, RepoRuntime}; +use crate::api::contracts::{ + ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationRequest, + OperationResponse, OutputFormat, QueryRequest, SearchRequest, +}; +use crate::api::lifecycle::{ + refresh_repository, reinstall_repository, setup_repository, uninstall_repository, +}; +use crate::api::materialization::execute_materialization_request; +use crate::api::presenter::present_operation_response; +use crate::cli::{ + graph::{ + count_graph_nodes, execute_graph_context, execute_graph_search, execute_read_only_query, + validate_read_only_statement, + }, + materialization::{ + build_request, materialization_payload as materialize_output_payload, read_request, + MaterializeOptions, + }, +}; +use crate::error::NativeError; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use serde_json::json; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy)] +pub struct OperationDescriptor { + pub id: &'static str, + pub summary: &'static str, + pub request_schema: fn() -> serde_json::Value, + pub surfaces: &'static [&'static str], + pub supported_outputs: &'static [OutputFormat], + pub mcp_tool_name: Option<&'static str>, +} + +#[derive(Debug, Clone)] +pub(crate) struct OperationRegistry { + pub(crate) operations: BTreeMap<&'static str, RegisteredOperation>, +} + +type OperationHandler = + fn(&OperationRequest, Option<&RepoRuntime>) -> Result; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RegisteredOperation { + descriptor: OperationDescriptor, + handler: OperationHandler, +} + +#[derive(Debug, Clone, Default)] +pub struct ApiCore; + +impl ApiCore { + pub fn new() -> Self { + Self + } + + pub(crate) fn register_operations(&self) -> OperationRegistry { + let descriptors = [ + OperationDescriptor { + id: "health", + summary: "Check codebase graph and manifest availability", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_health"), + }, + OperationDescriptor { + id: "search", + summary: "Search for code graph entities", + request_schema: search_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_search"), + }, + OperationDescriptor { + id: "context", + summary: "Return graph context for matches or explicit nodes", + request_schema: context_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_context"), + }, + OperationDescriptor { + id: "query", + summary: "Execute read-only graph query", + request_schema: query_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_query"), + }, + OperationDescriptor { + id: "materialize", + summary: "Scan and build full graph materialization", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "plan", + summary: "Plan materialization without database write", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "schema", + summary: "Return graph schema catalog", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_schema"), + }, + OperationDescriptor { + id: "query-helpers", + summary: "Return query helper catalog", + request_schema: empty_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_query_helpers"), + }, + OperationDescriptor { + id: "architecture-queries", + summary: "Return architecture query catalog", + request_schema: architecture_queries_request_schema, + surfaces: &["api", "cli", "mcp"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: Some("graph_architecture_queries"), + }, + OperationDescriptor { + id: "setup", + summary: "Install graph state and optional MCP configuration", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "reinstall", + summary: "Rebuild graph state after backup/restore choreography", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "uninstall", + summary: "Remove graph state and related MCP configuration", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + OperationDescriptor { + id: "refresh", + summary: "Refresh changed paths via incremental materialization", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, + ]; + + build_registry(descriptors) + } + + pub(crate) fn resolve_operation(&self, id: &str) -> Option { + self.register_operations().operations.get(id).copied() + } + + pub fn execute(&self, request: &OperationRequest) -> Result { + let operation = self + .resolve_operation(request.operation_name()) + .ok_or_else(|| { + ApiError::new( + "unsupported_operation", + format!("unsupported operation: {}", request.operation_name()), + ) + })?; + let runtime = request + .repo_selector() + .map(resolve_runtime) + .transpose() + .map_err(|error| ApiError::new("runtime_resolution_failed", error))?; + let response = dispatch_operation(request, &operation, runtime.as_ref())?; + Ok(present_operation_response( + response, + request.output_format(), + )) + } + + pub(crate) fn operations(&self) -> Vec { + let mut items: Vec<_> = self + .register_operations() + .operations + .values() + .map(|operation| operation.descriptor) + .collect(); + items.sort_by_key(|descriptor| descriptor.id); + items + } +} + +fn build_registry(descriptors: impl IntoIterator) -> OperationRegistry { + let mut by_id = BTreeMap::new(); + for descriptor in descriptors { + let handler: OperationHandler = match descriptor.id { + "health" => handle_health, + "search" => handle_search, + "context" => handle_context, + "query" => handle_query, + "materialize" => handle_materialize, + "plan" => handle_plan, + "schema" | "query-helpers" | "architecture-queries" => handle_catalog, + "setup" => handle_setup, + "reinstall" => handle_reinstall, + "uninstall" => handle_uninstall, + "refresh" => handle_refresh, + id => panic!("operation {id} does not have a handler registration"), + }; + assert!( + !descriptor.supported_outputs.is_empty(), + "operation {} must expose at least one output format", + descriptor.id + ); + assert_eq!( + descriptor.surfaces.contains(&"mcp"), + descriptor.mcp_tool_name.is_some(), + "operation {} has inconsistent MCP exposure", + descriptor.id + ); + if by_id + .insert( + descriptor.id, + RegisteredOperation { + descriptor, + handler, + }, + ) + .is_some() + { + panic!("duplicate operation registration: {}", descriptor.id); + } + } + OperationRegistry { operations: by_id } +} + +fn empty_request_schema() -> serde_json::Value { + json!({}) +} + +fn search_request_schema() -> serde_json::Value { + json!({ + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1}, + "profile": {"type": "string"}, + "budget": {"type": "integer", "minimum": 0}, + "context_limit": {"type": "integer", "minimum": 0}, + "max_depth": {"type": "integer", "minimum": 0}, + "detail": {"type": "string", "enum": ["standard", "slim"]}, + }) +} + +fn context_request_schema() -> serde_json::Value { + json!({ + "query": {"type": "string"}, + "node_id": {"type": "string"}, + "node_type": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1}, + "profile": {"type": "string"}, + "budget": {"type": "integer", "minimum": 0}, + "context_limit": {"type": "integer", "minimum": 0}, + "max_depth": {"type": "integer", "minimum": 0}, + "detail": {"type": "string", "enum": ["standard", "slim"]}, + }) +} + +fn query_request_schema() -> serde_json::Value { + json!({ + "statement": {"type": "string"}, + "parameters": {"type": "object"}, + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + }) +} + +fn architecture_queries_request_schema() -> serde_json::Value { + json!({ + "group": {"type": "string"}, + }) +} + +fn dispatch_operation( + request: &OperationRequest, + operation: &RegisteredOperation, + runtime: Option<&RepoRuntime>, +) -> Result { + (operation.handler)(request, runtime) +} + +fn required_runtime<'a>( + operation: &str, + runtime: Option<&'a RepoRuntime>, +) -> Result<&'a RepoRuntime, ApiError> { + runtime.ok_or_else(|| { + ApiError::new( + "runtime_resolution_failed", + format!("{operation} operation requires a repository selector"), + ) + }) +} + +fn invalid_request(expected: &str, request: &OperationRequest) -> ApiError { + ApiError::new( + "invalid_operation_request", + format!( + "operation handler expected {expected}, received {}", + request.operation_name() + ), + ) +} + +fn handle_health( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Health(request) = request else { + return Err(invalid_request("health", request)); + }; + execute_health(request, required_runtime("health", runtime)?) +} + +fn handle_search( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Search(request) = request else { + return Err(invalid_request("search", request)); + }; + execute_search(request, required_runtime("search", runtime)?) +} + +fn handle_context( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Context(request) = request else { + return Err(invalid_request("context", request)); + }; + execute_context(request, required_runtime("context", runtime)?) +} + +fn handle_query( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Query(request) = request else { + return Err(invalid_request("query", request)); + }; + execute_query(request, required_runtime("query", runtime)?) +} + +fn handle_materialize( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Materialize(request) = request else { + return Err(invalid_request("materialize", request)); + }; + execute_materialization(request, required_runtime("materialize", runtime)?, false) +} + +fn handle_plan( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Plan(request) = request else { + return Err(invalid_request("plan", request)); + }; + execute_materialization(request, required_runtime("plan", runtime)?, true) +} + +fn handle_catalog( + request: &OperationRequest, + _runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Catalog { kind, group, .. } = request else { + return Err(invalid_request("catalog", request)); + }; + let mut payload = + load_catalog(kind).map_err(|error| ApiError::new("invalid_catalog_kind", error))?; + filter_catalog(kind, &mut payload, group.as_deref()) + .map_err(|error| ApiError::new("invalid_catalog_group", error))?; + Ok(OperationResponse::from_payload( + kind, + OutputFormat::Typed, + payload, + )) +} + +fn handle_setup( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Setup(request) = request else { + return Err(invalid_request("setup", request)); + }; + Ok(OperationResponse::from_payload( + "setup", + OutputFormat::Typed, + setup_repository(request, required_runtime("setup", runtime)?)?, + )) +} + +fn handle_reinstall( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Reinstall(request) = request else { + return Err(invalid_request("reinstall", request)); + }; + Ok(OperationResponse::from_payload( + "reinstall", + OutputFormat::Typed, + reinstall_repository(request, required_runtime("reinstall", runtime)?)?, + )) +} + +fn handle_uninstall( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Uninstall(request) = request else { + return Err(invalid_request("uninstall", request)); + }; + Ok(OperationResponse::from_payload( + "uninstall", + OutputFormat::Typed, + uninstall_repository(request, required_runtime("uninstall", runtime)?)?, + )) +} + +fn handle_refresh( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::Refresh(request) = request else { + return Err(invalid_request("refresh", request)); + }; + Ok(OperationResponse::from_payload( + "refresh", + OutputFormat::Typed, + refresh_repository(request, required_runtime("refresh", runtime)?)?, + )) +} + +fn execute_health( + request: &HealthRequest, + runtime: &RepoRuntime, +) -> Result { + let output_format = request.output_format; + + let mut graph_readable = false; + let mut total_nodes = 0_u64; + let mut error_message = None; + let database_exists = runtime.db_path.exists(); + let manifest_exists = runtime.manifest_path.exists(); + + if database_exists { + match count_graph_nodes(&runtime.db_path) { + Ok(count) => { + graph_readable = true; + total_nodes = count; + } + Err(error) => { + error_message = Some(error); + } + } + } else { + error_message = Some(format!( + "database file does not exist: {}", + runtime.db_path.display() + )); + } + + let payload = json!({ + "ok": database_exists && graph_readable, + "repo_root": runtime.repo_root, + "database_path": runtime.db_path, + "manifest_path": runtime.manifest_path, + "database_exists": database_exists, + "manifest_exists": manifest_exists, + "graph_readable": graph_readable, + "total_nodes": total_nodes, + "error": error_message, + "refresh": request.refresh_status, + }); + Ok(OperationResponse::from_payload( + "health", + output_format, + payload, + )) +} + +fn execute_search( + request: &SearchRequest, + runtime: &RepoRuntime, +) -> Result { + validate_search_request( + &request.query, + &request.detail, + request.limit, + request.context_limit, + )?; + let output_format = request.output_format; + let options = crate::cli::graph::GraphSearchOptions { + query: request.query.clone(), + limit: request.limit, + profile: request.profile.clone(), + budget: request.budget, + context_limit: request.context_limit, + max_depth: request.max_depth, + detail: request.detail.clone(), + repo_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), + output: crate::cli::graph::MetadataOutputOptions { + format: if matches!(output_format, OutputFormat::Typed) { + "json".to_string() + } else { + "block".to_string() + }, + pretty: false, + help: false, + }, + }; + let results = execute_graph_search(&runtime.db_path, &options) + .map_err(|error| ApiError::new("search_execution_failed", error.to_string()))?; + let payload = serde_json::json!({ + "query": request.query, + "profile": request.profile, + "limit": request.limit, + "budget": request.budget, + "results": results, + }); + Ok(OperationResponse::from_payload( + "search", + output_format, + payload, + )) +} + +fn execute_context( + request: &ContextRequest, + runtime: &RepoRuntime, +) -> Result { + validate_search_request( + request.query.as_deref().unwrap_or_default(), + &request.detail, + request.limit, + request.context_limit, + ) + .or_else(|error| { + if request.node_id.is_some() && request.node_type.is_some() && error.code == "missing_query" + { + Ok(()) + } else { + Err(error) + } + })?; + if request.node_id.is_some() != request.node_type.is_some() { + return Err(ApiError::new( + "invalid_node_reference", + "context operation requires both node_id and node_type", + )); + } + let output_format = request.output_format; + let search = crate::cli::graph::GraphSearchOptions { + query: request.query.clone().unwrap_or_default(), + profile: request.profile.clone(), + limit: request.limit, + budget: request.budget, + context_limit: request.context_limit, + max_depth: request.max_depth, + detail: request.detail.clone(), + repo_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), + output: crate::cli::graph::MetadataOutputOptions { + format: if matches!(output_format, OutputFormat::Typed) { + "json".to_string() + } else { + "block".to_string() + }, + pretty: false, + help: false, + }, + }; + let payload = if let (Some(node_id), Some(node_type)) = + (request.node_id.as_ref(), request.node_type.as_ref()) + { + let context = execute_graph_context(&runtime.db_path, node_id, node_type, &search) + .map_err(|error| ApiError::new("context_execution_failed", error.to_string()))?; + json!({ + "node_id": node_id, + "node_type": node_type, + "profile": request.profile, + "context": context, + }) + } else { + if search.query.is_empty() { + return Err(ApiError::new( + "context_validation_failed", + "query is required", + )); + } + let results = execute_graph_search(&runtime.db_path, &search) + .map_err(|error| ApiError::new("context_execution_failed", error.to_string()))?; + json!({ + "query": request.query, + "profile": request.profile, + "limit": request.limit, + "budget": request.budget, + "results": results, + }) + }; + Ok(OperationResponse::from_payload( + "context", + output_format, + payload, + )) +} + +fn execute_query( + request: &QueryRequest, + runtime: &RepoRuntime, +) -> Result { + if request.limit == 0 || request.limit > 1000 { + return Err(ApiError::new( + "invalid_limit", + "graph_query limit must be between 1 and 1000", + )); + } + validate_read_only_statement(&request.statement) + .map_err(|error| ApiError::new("query_validation_failed", error.to_string()))?; + let output_format = request.output_format; + let parameters = request.parameters.as_object().cloned().ok_or_else(|| { + ApiError::new( + "query_invalid_parameters", + "query parameters must be an object", + ) + })?; + let (rows, truncated) = execute_read_only_query( + &runtime.db_path, + &request.statement, + ¶meters, + request.limit, + ) + .map_err(|error| ApiError::new("query_execution_failed", error.to_string()))?; + let payload = serde_json::json!({ + "statement": request.statement, + "row_count": rows.len(), + "rows": rows, + "truncated": truncated, + }); + Ok(OperationResponse::from_payload( + "query", + output_format, + payload, + )) +} + +fn validate_search_request( + query: &str, + detail: &str, + limit: usize, + _context_limit: usize, +) -> Result<(), ApiError> { + if query.trim().is_empty() { + return Err(ApiError::new( + "missing_query", + "Search query must not be empty", + )); + } + if detail != "standard" && detail != "slim" { + return Err(ApiError::new( + "invalid_detail", + "detail must be standard or slim", + )); + } + if limit == 0 { + return Err(ApiError::new( + "invalid_limit", + "search limit must be greater than zero", + )); + } + Ok(()) +} + +fn execute_materialization( + request: &MaterializationRequest, + runtime: &RepoRuntime, + dry_plan: bool, +) -> Result { + let output_format = request.output_format; + let mut materialize_options = MaterializeOptions { + source_root: request + .source_root + .as_ref() + .map(std::path::PathBuf::from) + .or_else(|| Some(runtime.repo_root.clone())), + db: Some( + request + .repo + .db_path + .clone() + .unwrap_or_else(|| runtime.db_path.clone()), + ), + manifest: Some( + request + .repo + .manifest_path + .clone() + .unwrap_or_else(|| runtime.manifest_path.clone()), + ), + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + use_git: request.use_git, + git_diff: request.git_diff, + git_base: request.git_base.clone(), + include_patterns: request.include_patterns.clone(), + exclude_patterns: request.exclude_patterns.clone(), + candidate_paths: request.candidate_paths.clone(), + parallel: request.parallel, + progress: request.progress, + plan_only: dry_plan, + ..MaterializeOptions::default() + }; + materialize_options.parallel = request.parallel; + + let mut native_request = if let Some(request_path) = request.native_request_path.as_ref() { + read_request(request_path) + .map_err(|error| ApiError::new("materialization_request_failed", error))? + } else { + build_request(&materialize_options) + .map_err(|error| ApiError::new("materialization_build_failed", error))? + }; + + let response = if dry_plan { + native_request.atomic_rebuild = false; + execute_plan_native(&native_request) + .map_err(|error| ApiError::new("materialization_plan_failed", error.to_string()))? + } else { + execute_materialization_request(&materialize_options, native_request) + .map(|(_, response)| response) + .map_err(|error| ApiError::new("materialization_failed", error))? + }; + + let payload = materialization_payload(request, &response, &runtime.repo_root, dry_plan); + let mut operation_response = OperationResponse::from_payload( + if dry_plan { "plan" } else { "materialize" }, + output_format, + payload, + ); + operation_response.diagnostics = response.diagnostics.clone(); + Ok(operation_response) +} + +fn execute_plan_native( + request: &NativeSyntaxMaterializationRequest, +) -> Result { + crate::plan_materialization(request) +} + +fn materialization_payload( + request: &MaterializationRequest, + response: &NativeSyntaxMaterializationResponse, + repo_root: &std::path::Path, + dry_plan: bool, +) -> serde_json::Value { + if dry_plan { + let runtime_paths = crate::cli::setup::GraphStatePaths::derive(repo_root); + materialize_output_payload(response, &request.mode, &runtime_paths) + } else { + serde_json::to_value(response).unwrap_or_else(|_| json!({})) + } +} + +#[cfg(test)] +mod tests { + use crate::api::contracts::OperationRequest; + use crate::api::core::ApiCore; + + #[test] + fn operation_registry_is_unique_and_sorted() { + let core = ApiCore::new(); + let operations = core.operations(); + let ids: Vec<_> = operations.iter().map(|entry| entry.id).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + assert_eq!(ids, sorted); + assert!(ids.iter().all(|id| core.resolve_operation(id).is_some())); + } + + #[test] + fn resolve_unknown_operation_is_none() { + let core = ApiCore::new(); + assert!(core.resolve_operation("_missing_").is_none()); + } + + #[test] + fn duplicate_operations_are_rejected_in_registration() { + let core = ApiCore::new(); + let descriptor = core + .operations() + .into_iter() + .find(|descriptor| descriptor.id == "health") + .expect("health descriptor should exist"); + let result = std::panic::catch_unwind(|| super::build_registry([descriptor, descriptor])); + assert!(result.is_err()); + } + + #[test] + fn unknown_operation_request_is_rejected() { + let core = ApiCore::new(); + let unsupported = OperationRequest::Catalog { + kind: "_missing_".to_string(), + group: None, + output_format: crate::api::contracts::OutputFormat::Typed, + }; + let error = core + .execute(&unsupported) + .expect_err("unknown operation should be rejected"); + assert_eq!(error.code, "unsupported_operation"); + assert!(!error.retryable); + } + + #[test] + fn resolve_operation_returns_registered_descriptor_and_handler() { + let core = ApiCore::new(); + let operation = core + .resolve_operation("schema") + .expect("schema should be registered"); + assert_eq!(operation.descriptor.id, "schema"); + let response = (operation.handler)( + &OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: crate::api::contracts::OutputFormat::Typed, + }, + None, + ) + .expect("registered schema handler should execute"); + assert_eq!(response.operation, "schema"); + } +} diff --git a/src/api/facade.rs b/src/api/facade.rs new file mode 100644 index 0000000..c834470 --- /dev/null +++ b/src/api/facade.rs @@ -0,0 +1,89 @@ +use crate::api::{ + contracts::{ApiError, OperationRequest, OperationResponse}, + core::{ApiCore, OperationDescriptor}, +}; + +pub trait OperationExecutor { + fn execute(&self, request: &OperationRequest) -> Result; +} + +impl OperationExecutor for ApiCore { + fn execute(&self, request: &OperationRequest) -> Result { + ApiCore::execute(self, request) + } +} + +#[derive(Debug, Clone)] +pub struct CodebaseGraphApi { + core: C, +} + +impl CodebaseGraphApi { + pub fn new() -> Self { + Self { + core: ApiCore::new(), + } + } + + pub fn operation_descriptors(&self) -> Vec { + self.core.operations() + } +} + +impl CodebaseGraphApi { + pub fn execute_operation( + &self, + request: &OperationRequest, + ) -> Result { + self.core.execute(request) + } +} + +impl Default for CodebaseGraphApi { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::contracts::OutputFormat; + use std::cell::Cell; + + struct SpyCore { + calls: Cell, + } + + impl OperationExecutor for SpyCore { + fn execute(&self, request: &OperationRequest) -> Result { + self.calls.set(self.calls.get() + 1); + Ok(OperationResponse::from_payload( + request.operation_name(), + request.output_format(), + serde_json::json!({"ok": true}), + )) + } + } + + #[test] + fn execute_operation_delegates_exactly_once() { + let api = CodebaseGraphApi { + core: SpyCore { + calls: Cell::new(0), + }, + }; + let request = OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: OutputFormat::Typed, + }; + + let response = api + .execute_operation(&request) + .expect("spy operation should succeed"); + + assert_eq!(api.core.calls.get(), 1); + assert_eq!(response.operation, "schema"); + } +} diff --git a/src/api/lifecycle.rs b/src/api/lifecycle.rs new file mode 100644 index 0000000..f0111e0 --- /dev/null +++ b/src/api/lifecycle.rs @@ -0,0 +1,104 @@ +use crate::api::context::RepoRuntime; +use crate::api::contracts::{ApiError, RefreshRequest, RepositoryLifecycleRequest}; +use crate::api::materialization::execute_candidate_materialization; +use crate::cli::materialization::MaterializeOptions; +use crate::cli::{reinstall, setup, uninstall}; + +pub(crate) fn is_retryable_refresh_failure(error: &str) -> bool { + crate::db_writer::is_transient_database_error(error) +} + +pub(crate) fn setup_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "setup")?; + setup::execute_setup_operation(request, &runtime.repo_root) + .map_err(|error| ApiError::new("setup_failed", error)) +} + +pub(crate) fn reinstall_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "reinstall")?; + reinstall::execute_reinstall_operation(request, &runtime.repo_root) + .map_err(|error| ApiError::new("reinstall_failed", error)) +} + +pub(crate) fn uninstall_repository( + request: &RepositoryLifecycleRequest, + runtime: &RepoRuntime, +) -> Result { + validate_lifecycle_action(request, "uninstall")?; + uninstall::execute_uninstall_operation(request, &runtime.repo_root) + .map_err(|error| ApiError::new("uninstall_failed", error)) +} + +fn validate_lifecycle_action( + request: &RepositoryLifecycleRequest, + expected: &str, +) -> Result<(), ApiError> { + if request.action == expected { + Ok(()) + } else { + Err(ApiError::new( + "invalid_lifecycle_action", + format!( + "lifecycle request action {} does not match {expected}", + request.action + ), + )) + } +} + +pub(crate) fn refresh_repository( + request: &RefreshRequest, + runtime: &RepoRuntime, +) -> Result { + let repo_root = request + .repo + .repo_root + .clone() + .unwrap_or_else(|| runtime.repo_root.clone()); + let options = MaterializeOptions { + source_root: Some(repo_root), + db: Some( + request + .repo + .db_path + .clone() + .unwrap_or_else(|| runtime.db_path.clone()), + ), + manifest: Some( + request + .repo + .manifest_path + .clone() + .unwrap_or_else(|| runtime.manifest_path.clone()), + ), + use_git: false, + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: request.paths.clone(), + parallel: request.parallel, + progress: request.progress, + plan_only: false, + ..MaterializeOptions::default() + }; + + let (_request, response) = + execute_candidate_materialization(&options, options.candidate_paths.clone()).map_err( + |error| { + let retryable = is_retryable_refresh_failure(&error); + ApiError::new("refresh_failed", error).retryable(retryable) + }, + )?; + Ok(serde_json::to_value(response).unwrap_or_else(|_| serde_json::json!({}))) +} diff --git a/src/api/materialization.rs b/src/api/materialization.rs new file mode 100644 index 0000000..4361832 --- /dev/null +++ b/src/api/materialization.rs @@ -0,0 +1,68 @@ +use crate::cli::materialization::{ + build_request, read_request, request_manifest_path, write_manifest, MaterializeOptions, +}; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use std::time::Instant; + +pub(crate) fn execute_materialization( + options: &MaterializeOptions, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let request = match options.native_request.as_ref() { + Some(request_path) => read_request(request_path)?, + None => build_request(options)?, + }; + execute_materialization_request(options, request) +} + +pub(crate) fn execute_candidate_materialization( + options: &MaterializeOptions, + candidate_paths: Vec, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let mut request = build_request(options)?; + request.candidate_paths = candidate_paths; + request.atomic_rebuild = false; + execute_materialization_request(options, request) +} + +pub(crate) fn execute_materialization_request( + options: &MaterializeOptions, + request: NativeSyntaxMaterializationRequest, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, +> { + let started = Instant::now(); + let final_request = request; + let mut response = crate::execute_materialization_pipeline(&final_request) + .map_err(|error| error.to_string())?; + response.phase_timings.insert( + "native_cli_seconds".to_string(), + started.elapsed().as_secs_f64(), + ); + + if let Some(manifest_path) = request_manifest_path(options).as_ref() { + write_manifest( + manifest_path, + &final_request, + &response.rebuilt_entries, + &response.diff, + )?; + } + + Ok((final_request, response)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..2f4dbc1 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,26 @@ +//! Stable, transport-neutral entry point for codebase graph operations. +//! +//! Embedded clients construct an [`OperationRequest`] and submit it through +//! [`CodebaseGraphApi::execute_operation`]. The same facade is used by the CLI +//! and MCP adapters, so validation, runtime selection, errors, and presentation +//! remain consistent across surfaces. + +pub mod catalog; +pub mod context; +pub mod contracts; +pub mod core; +pub mod facade; +pub mod lifecycle; +pub(crate) mod materialization; +pub mod presenter; + +pub use contracts::{ + ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationRequest, + OperationResponse, OutputFormat, QueryRequest, RefreshRequest, RepoSelector, + RepositoryLifecycleRequest, SearchRequest, +}; +pub use core::OperationDescriptor; +pub use facade::{CodebaseGraphApi, OperationExecutor}; + +#[cfg(test)] +mod boundary_tests; diff --git a/src/api/presenter.rs b/src/api/presenter.rs new file mode 100644 index 0000000..0419e92 --- /dev/null +++ b/src/api/presenter.rs @@ -0,0 +1,77 @@ +use crate::api::contracts::{OperationResponse, OutputFormat}; +use crate::cli::format::{ + serialize_architecture_queries_block, serialize_context_block, serialize_health_block, + serialize_query_block, serialize_query_helpers_block, serialize_schema_block, + serialize_search_block, +}; +use crate::cli::materialization::serialize_plan_block; +use crate::cli::uninstall::serialize_uninstall_block; + +fn present_block(operation: &str, payload: &serde_json::Value) -> String { + match operation { + "health" => serialize_health_block(payload), + "search" => serialize_search_block(payload), + "context" => { + if payload.get("context").is_some() { + serialize_context_block(payload) + } else { + serialize_search_block(payload) + } + } + "query" => serialize_query_block(payload), + "schema" => serialize_schema_block(payload), + "query-helpers" => serialize_query_helpers_block(payload), + "architecture-queries" => serialize_architecture_queries_block(payload), + "plan" => serialize_plan_block(payload), + "uninstall" => serialize_uninstall_block(payload), + _ => serde_json::to_string_pretty(payload).unwrap_or_else(|_| String::new()), + } +} + +pub fn present_operation_response( + mut response: OperationResponse, + output_format: OutputFormat, +) -> OperationResponse { + response.output_format = output_format; + if output_format == OutputFormat::Block { + let text = present_block(&response.operation, &response.payload); + response.payload = serde_json::json!({ + "text": text, + "structured": response.payload, + }); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn selected_output_format_presents_typed_or_block_response() { + let payload = json!({ + "statement": "MATCH (n) RETURN n", + "row_count": 1, + "rows": [{"n": "node-1"}], + "truncated": false, + }); + let typed = present_operation_response( + OperationResponse::from_payload("query", OutputFormat::Typed, payload.clone()), + OutputFormat::Typed, + ); + assert_eq!(typed.payload, payload); + assert_eq!(typed.output_format, OutputFormat::Typed); + + let block = present_operation_response( + OperationResponse::from_payload("query", OutputFormat::Typed, payload.clone()), + OutputFormat::Block, + ); + assert_eq!(block.output_format, OutputFormat::Block); + assert_eq!(block.payload["structured"], payload); + assert!(block.payload["text"] + .as_str() + .expect("block text should be present") + .starts_with("query rows=1")); + } +} diff --git a/src/cli/constants.rs b/src/cli/constants.rs index 8b512e4..bd9482f 100644 --- a/src/cli/constants.rs +++ b/src/cli/constants.rs @@ -1,10 +1,9 @@ use std::env; -pub(super) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); -pub(super) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); -pub(super) const ARCHITECTURE_QUERIES_JSON: &str = +pub(crate) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); +pub(crate) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); +pub(crate) const ARCHITECTURE_QUERIES_JSON: &str = include_str!("../../assets/architecture_queries.json"); -pub(super) const MCP_TOOL_SPECS_JSON: &str = include_str!("../../assets/mcp_tool_specs.json"); pub(super) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; pub(super) const MAX_HTTP_BODY_BYTES: usize = 1_000_000; diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 3dcfd0a..2e59947 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -1,8 +1,11 @@ use super::{ - format::top_level_help, + format::{ + graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, top_level_help, + }, graph::{ - run_graph_architecture_queries, run_graph_context, run_graph_health, run_graph_query, - run_graph_query_helpers, run_graph_schema, run_graph_search, + ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, + HealthOptions, MetadataOutputOptions, }, materialization::{run_materialize, run_plan}, mcp::{run_mcp_command, serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, @@ -11,6 +14,13 @@ use super::{ uninstall::run_uninstall, watch::run_watch, }; +use crate::api::{ + contracts::{ + ContextRequest, HealthRequest, OperationRequest, OutputFormat, QueryRequest, RepoSelector, + SearchRequest, + }, + CodebaseGraphApi, +}; use std::{ env, io::{self, Write}, @@ -78,6 +88,222 @@ where } } +fn run_graph_health(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = HealthOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", graph_health_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Health(HealthRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + refresh_status: None, + output_format: output_format(options.json), + }))?; + write_api_response(stdout, &response.payload, &options.json, false) +} + +fn run_graph_schema(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = MetadataOutputOptions::parse(args, "schema")?; + if options.help { + writeln!(stdout, "{}", graph_schema_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: output_format(options.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.format.eq("json"), + options.pretty, + ) +} + +fn run_graph_query_helpers(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = MetadataOutputOptions::parse(args, "query-helpers")?; + if options.help { + writeln!(stdout, "{}", graph_query_helpers_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "query-helpers".to_string(), + group: None, + output_format: output_format(options.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.format.eq("json"), + options.pretty, + ) +} + +fn run_graph_architecture_queries(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = ArchitectureQueryOptions::parse(args)?; + if options.output.help { + writeln!(stdout, "{}", graph_architecture_queries_help()) + .map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Catalog { + kind: "architecture-queries".to_string(), + group: options.group, + output_format: output_format(options.output.format == "json"), + })?; + write_api_response( + stdout, + &response.payload, + &options.output.format.eq("json"), + options.output.pretty, + ) +} + +fn run_graph_search(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphSearchOptions::parse(args)?; + if options.output.help { + writeln!(stdout, "{}", graph_search_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Search(SearchRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + query: options.query, + profile: options.profile, + limit: options.limit, + budget: options.budget, + context_limit: options.context_limit, + max_depth: options.max_depth, + detail: options.detail, + output_format: output_format(options.output.format == "json"), + }))?; + write_api_response( + stdout, + &response.payload, + &options.output.format.eq("json"), + options.output.pretty, + ) +} + +fn run_graph_context(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphContextOptions::parse(args)?; + if options.search.output.help { + writeln!(stdout, "{}", graph_context_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Context(ContextRequest { + repo: repo_selector_from_options( + options.search.repo_root, + options.search.config, + options.search.db, + options.search.manifest, + ), + query: if options.search.query.trim().is_empty() { + None + } else { + Some(options.search.query) + }, + profile: options.search.profile, + limit: options.search.limit, + budget: options.search.budget, + context_limit: options.search.context_limit, + max_depth: options.search.max_depth, + detail: options.search.detail, + node_id: options.node_id, + node_type: options.node_type, + output_format: output_format(options.search.output.format == "json"), + }))?; + write_api_response( + stdout, + &response.payload, + &options.search.output.format.eq("json"), + options.search.output.pretty, + ) +} + +fn run_graph_query(args: &[String], stdout: &mut W) -> Result<(), String> { + let options = GraphQueryOptions::parse(args)?; + if options.help { + writeln!(stdout, "{}", graph_query_help()).map_err(|error| error.to_string())?; + return Ok(()); + } + let response = execute_api_request(OperationRequest::Query(QueryRequest { + repo: repo_selector_from_options( + options.repo_root, + options.config, + options.db, + options.manifest, + ), + statement: options.statement, + parameters: serde_json::Value::Object(options.parameters), + limit: options.limit, + output_format: output_format(options.json), + }))?; + write_api_response(stdout, &response.payload, &options.json, false) +} + +fn execute_api_request( + request: OperationRequest, +) -> Result { + CodebaseGraphApi::new() + .execute_operation(&request) + .map_err(|error| error.message) +} + +fn output_format(is_json: bool) -> OutputFormat { + if is_json { + OutputFormat::Typed + } else { + OutputFormat::Block + } +} + +fn write_api_response( + stdout: &mut W, + payload: &serde_json::Value, + is_json: &bool, + pretty: bool, +) -> Result<(), String> { + if *is_json { + let text = if pretty { + serde_json::to_string_pretty(payload).map_err(|error| error.to_string())? + } else { + serde_json::to_string(payload).map_err(|error| error.to_string())? + }; + writeln!(stdout, "{text}").map_err(|error| error.to_string()) + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string()) + } +} + +fn repo_selector_from_options( + repo_root: Option, + config_path: Option, + db_path: Option, + manifest_path: Option, +) -> RepoSelector { + RepoSelector { + repo_root, + config_path, + db_path, + manifest_path, + } +} + pub fn error_exit_code(error: &str) -> i32 { if error.starts_with("graph_query is read-only; blocked keyword:") || error.starts_with("graph_query accepts one read-only statement at a time") diff --git a/src/cli/format/blocks.rs b/src/cli/format/blocks.rs index 43100e7..ce811d3 100644 --- a/src/cli/format/blocks.rs +++ b/src/cli/format/blocks.rs @@ -1,4 +1,4 @@ -pub(in crate::cli) fn serialize_schema_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_schema_block(payload: &serde_json::Value) -> String { let node_types = value_array(payload, "node_types"); let relation_types = value_array(payload, "relation_types"); let parser_mappings = value_array(payload, "parser_node_mappings"); @@ -53,7 +53,7 @@ pub(in crate::cli) fn serialize_schema_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { let helpers = value_array(payload, "query_helpers"); let mut lines = vec![format!("query_helpers count={}", helpers.len())]; for helper in helpers { @@ -62,7 +62,7 @@ pub(in crate::cli) fn serialize_query_helpers_block(payload: &serde_json::Value) format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { let groups = value_array(payload, "groups"); let mut lines = vec![format!( "architecture_queries workflow={} execution_tool={} groups={}", @@ -87,7 +87,7 @@ pub(in crate::cli) fn serialize_architecture_queries_block(payload: &serde_json: format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_health_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_health_block(payload: &serde_json::Value) -> String { let mut lines = vec![format!( "health ok={} database_exists={} manifest_exists={} graph_readable={} total_nodes={}", value_bool(payload, "ok"), @@ -108,7 +108,7 @@ pub(in crate::cli) fn serialize_health_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_search_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_search_block(payload: &serde_json::Value) -> String { let results = value_array(payload, "results"); let mut lines = vec![format!("q {}", block_value(value_str(payload, "query")))]; let mut current_path: Option = None; @@ -141,7 +141,7 @@ pub(in crate::cli) fn serialize_search_block(payload: &serde_json::Value) -> Str format!("{}\n", lines.join("\n")) } -pub(in crate::cli) fn serialize_query_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_query_block(payload: &serde_json::Value) -> String { let rows = value_array(payload, "rows"); let columns = query_columns(value_str(payload, "statement")); let mut lines = vec![format!( @@ -273,7 +273,7 @@ pub(in crate::cli) fn block_json_value(value: &serde_json::Value) -> String { } } -pub(in crate::cli) fn serialize_error_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_error_block(payload: &serde_json::Value) -> String { let error = payload.get("error").unwrap_or(payload); format!( "error tool={} type={} message={}\n", @@ -283,7 +283,7 @@ pub(in crate::cli) fn serialize_error_block(payload: &serde_json::Value) -> Stri ) } -pub(in crate::cli) fn serialize_context_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_context_block(payload: &serde_json::Value) -> String { let mut lines = vec![format!( "context {} id={} profile={}", value_str(payload, "node_type"), @@ -362,7 +362,7 @@ pub(in crate::cli) fn append_query_spec_lines( } } -pub(in crate::cli) fn value_array<'a>( +pub(crate) fn value_array<'a>( payload: &'a serde_json::Value, key: &str, ) -> &'a [serde_json::Value] { @@ -373,7 +373,7 @@ pub(in crate::cli) fn value_array<'a>( .unwrap_or(&[]) } -pub(in crate::cli) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { +pub(crate) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { payload .get(key) .and_then(serde_json::Value::as_str) @@ -410,7 +410,7 @@ pub(in crate::cli) fn csv_values(value: Option<&serde_json::Value>) -> String { .unwrap_or_default() } -pub(in crate::cli) fn block_value(value: &str) -> String { +pub(crate) fn block_value(value: &str) -> String { if value.is_empty() { "\"\"".to_string() } else if value.chars().all(|character| { diff --git a/src/cli/format/metadata.rs b/src/cli/format/metadata.rs index 14f105f..c140cd6 100644 --- a/src/cli/format/metadata.rs +++ b/src/cli/format/metadata.rs @@ -1,30 +1,9 @@ -use crate::cli::graph::MetadataOutputOptions; -use std::io::Write; - -pub(in crate::cli) fn metadata_payload(source: &str) -> Result { +pub(crate) fn metadata_payload(source: &str) -> Result { serde_json::from_str(source) .map_err(|error| format!("failed to parse embedded metadata: {error}")) } -pub(in crate::cli) fn write_metadata_output( - stdout: &mut W, - payload: &serde_json::Value, - options: &MetadataOutputOptions, - block_serializer: fn(&serde_json::Value) -> String, -) -> Result<(), String> { - let text = if options.format == "json" { - if options.pretty { - serde_json::to_string_pretty(payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(payload).map_err(|error| error.to_string())? - } - } else { - block_serializer(payload) - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) -} - -pub(in crate::cli) fn filter_architecture_group( +pub(crate) fn filter_architecture_group( payload: &mut serde_json::Value, group: &str, ) -> Result<(), String> { diff --git a/src/cli/format/mod.rs b/src/cli/format/mod.rs index 1e6b146..f208aeb 100644 --- a/src/cli/format/mod.rs +++ b/src/cli/format/mod.rs @@ -3,7 +3,7 @@ mod help; mod metadata; mod schema; -pub(in crate::cli) use blocks::{ +pub(crate) use blocks::{ block_value, serialize_architecture_queries_block, serialize_context_block, serialize_error_block, serialize_health_block, serialize_query_block, serialize_query_helpers_block, serialize_schema_block, serialize_search_block, value_array, @@ -15,7 +15,5 @@ pub(in crate::cli) use help::{ mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, uninstall_help, watch_help, }; -pub(in crate::cli) use metadata::{ - filter_architecture_group, metadata_payload, write_metadata_output, -}; -pub(in crate::cli) use schema::schema_statements_from_copy_statements; +pub(crate) use metadata::{filter_architecture_group, metadata_payload}; +pub(crate) use schema::schema_statements_from_copy_statements; diff --git a/src/cli/format/schema.rs b/src/cli/format/schema.rs index 7230b3b..9af12d7 100644 --- a/src/cli/format/schema.rs +++ b/src/cli/format/schema.rs @@ -2,7 +2,7 @@ use super::{metadata_payload, value_array, value_str}; use crate::cli::{constants::GRAPH_SCHEMA_JSON, graph::cypher_single_quoted}; use std::collections::BTreeSet; -pub(in crate::cli) fn schema_statements_from_copy_statements( +pub(crate) fn schema_statements_from_copy_statements( include_fts: bool, copy_statements: &[String], ) -> Vec { diff --git a/src/cli/graph/commands.rs b/src/cli/graph/commands.rs deleted file mode 100644 index 3d3217b..0000000 --- a/src/cli/graph/commands.rs +++ /dev/null @@ -1,268 +0,0 @@ -use super::{ - health::{count_graph_nodes, resolve_health_runtime}, - options::HealthOptions, - options::{ - ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, - MetadataOutputOptions, - }, - query::{execute_read_only_query, validate_read_only_statement}, - search::{execute_graph_context, execute_graph_search}, -}; -use crate::cli::{ - constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}, - format::{ - filter_architecture_group, graph_architecture_queries_help, graph_context_help, - graph_health_help, graph_query_help, graph_query_helpers_help, graph_schema_help, - graph_search_help, metadata_payload, serialize_architecture_queries_block, - serialize_context_block, serialize_health_block, serialize_query_block, - serialize_query_helpers_block, serialize_schema_block, serialize_search_block, - write_metadata_output, - }, -}; -use serde_json::json; -use std::io::Write; - -pub(in crate::cli) fn run_graph_health( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = HealthOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", graph_health_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&options)?; - let mut graph_readable = false; - let mut total_nodes = 0_u64; - let mut error_message = None; - let database_exists = runtime.db_path.exists(); - let manifest_exists = runtime.manifest_path.exists(); - - if database_exists { - match count_graph_nodes(&runtime.db_path) { - Ok(count) => { - graph_readable = true; - total_nodes = count; - } - Err(error) => { - error_message = Some(error); - } - } - } else { - error_message = Some(format!( - "database file does not exist: {}", - runtime.db_path.display() - )); - } - - let output = json!({ - "ok": database_exists && graph_readable, - "repo_root": runtime.repo_root, - "database_path": runtime.db_path, - "manifest_path": runtime.manifest_path, - "database_exists": database_exists, - "manifest_exists": manifest_exists, - "graph_readable": graph_readable, - "total_nodes": total_nodes, - "error": error_message, - }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_health_block(&output)).map_err(|error| error.to_string())?; - } - Ok(()) -} - -pub(in crate::cli) fn run_graph_schema( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = MetadataOutputOptions::parse(args, "schema")?; - if options.help { - writeln!(stdout, "{}", graph_schema_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let payload = metadata_payload(GRAPH_SCHEMA_JSON)?; - write_metadata_output(stdout, &payload, &options, serialize_schema_block) -} - -pub(in crate::cli) fn run_graph_query_helpers( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = MetadataOutputOptions::parse(args, "query-helpers")?; - if options.help { - writeln!(stdout, "{}", graph_query_helpers_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let payload = metadata_payload(QUERY_HELPERS_JSON)?; - write_metadata_output(stdout, &payload, &options, serialize_query_helpers_block) -} - -pub(in crate::cli) fn run_graph_architecture_queries( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = ArchitectureQueryOptions::parse(args)?; - if options.output.help { - writeln!(stdout, "{}", graph_architecture_queries_help()) - .map_err(|error| error.to_string())?; - return Ok(()); - } - let mut payload = metadata_payload(ARCHITECTURE_QUERIES_JSON)?; - if let Some(group) = options.group { - filter_architecture_group(&mut payload, &group)?; - } - write_metadata_output( - stdout, - &payload, - &options.output, - serialize_architecture_queries_block, - ) -} - -pub(in crate::cli) fn run_graph_search( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphSearchOptions::parse(args)?; - if options.output.help { - writeln!(stdout, "{}", graph_search_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.repo_root.clone(), - config: options.config.clone(), - db: options.db.clone(), - manifest: options.manifest.clone(), - help: false, - json: false, - })?; - let results = execute_graph_search(&runtime.db_path, &options)?; - let payload = json!({ - "query": options.query, - "profile": options.profile, - "limit": options.limit, - "budget": options.budget, - "results": results, - }); - if options.output.format == "json" { - let text = if options.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_search_block(&payload)).map_err(|error| error.to_string()) - } -} - -pub(in crate::cli) fn run_graph_context( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphContextOptions::parse(args)?; - if options.search.output.help { - writeln!(stdout, "{}", graph_context_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.search.repo_root.clone(), - config: options.search.config.clone(), - db: options.search.db.clone(), - manifest: options.search.manifest.clone(), - help: false, - json: false, - })?; - if let (Some(node_id), Some(node_type)) = (options.node_id.as_ref(), options.node_type.as_ref()) - { - let context = execute_graph_context(&runtime.db_path, node_id, node_type, &options.search)?; - let payload = json!({ - "node_id": node_id, - "node_type": node_type, - "profile": options.search.profile, - "context": context, - }); - if options.search.output.format == "json" { - let text = if options.search.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_context_block(&payload)) - .map_err(|error| error.to_string()) - } - } else { - let results = execute_graph_search(&runtime.db_path, &options.search)?; - let payload = json!({ - "query": options.search.query, - "profile": options.search.profile, - "limit": options.search.limit, - "budget": options.search.budget, - "results": results, - }); - if options.search.output.format == "json" { - let text = if options.search.output.pretty { - serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? - } else { - serde_json::to_string(&payload).map_err(|error| error.to_string())? - }; - writeln!(stdout, "{text}").map_err(|error| error.to_string()) - } else { - writeln!(stdout, "{}", serialize_search_block(&payload)) - .map_err(|error| error.to_string()) - } - } -} - -pub(in crate::cli) fn run_graph_query( - args: &[String], - stdout: &mut W, -) -> Result<(), String> { - let options = GraphQueryOptions::parse(args)?; - if options.help { - writeln!(stdout, "{}", graph_query_help()).map_err(|error| error.to_string())?; - return Ok(()); - } - validate_read_only_statement(&options.statement)?; - let runtime = resolve_health_runtime(&HealthOptions { - repo_root: options.repo_root, - config: options.config, - db: options.db, - manifest: options.manifest, - help: false, - json: false, - })?; - let (rows, truncated) = execute_read_only_query( - &runtime.db_path, - &options.statement, - &options.parameters, - options.limit, - )?; - let output = json!({ - "statement": options.statement, - "row_count": rows.len(), - "rows": rows, - "truncated": truncated, - }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_query_block(&output)).map_err(|error| error.to_string())?; - } - Ok(()) -} diff --git a/src/cli/graph/health.rs b/src/cli/graph/health.rs index 52e0c0a..11f19da 100644 --- a/src/cli/graph/health.rs +++ b/src/cli/graph/health.rs @@ -10,15 +10,13 @@ use lbug::Value; use std::path::{Path, PathBuf}; #[derive(Debug)] -pub(in crate::cli) struct HealthRuntime { - pub(in crate::cli) repo_root: PathBuf, - pub(in crate::cli) db_path: PathBuf, - pub(in crate::cli) manifest_path: PathBuf, +pub(crate) struct HealthRuntime { + pub(crate) repo_root: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, } -pub(in crate::cli) fn resolve_health_runtime( - options: &HealthOptions, -) -> Result { +pub(crate) fn resolve_health_runtime(options: &HealthOptions) -> Result { let repo_root = resolve_repo_root(options.repo_root.as_deref())?; let default_paths = GraphStatePaths::derive(&repo_root); let config_path = options @@ -58,7 +56,7 @@ pub(in crate::cli) fn resolve_health_runtime( manifest_path, }) } -pub(in crate::cli) fn count_graph_nodes(db_path: &Path) -> Result { +pub(crate) fn count_graph_nodes(db_path: &Path) -> Result { let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) .map_err(|error| error.to_string())?; let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; @@ -73,7 +71,7 @@ pub(in crate::cli) fn count_graph_nodes(db_path: &Path) -> Result { .ok_or_else(|| "graph health query returned a non-numeric node count".to_string()) } -pub(in crate::cli) fn value_to_u64(value: &Value) -> Option { +pub(crate) fn value_to_u64(value: &Value) -> Option { match value { Value::Int64(value) if *value >= 0 => Some(*value as u64), Value::Int32(value) if *value >= 0 => Some(*value as u64), diff --git a/src/cli/graph/mod.rs b/src/cli/graph/mod.rs index 370b1a5..318bdd7 100644 --- a/src/cli/graph/mod.rs +++ b/src/cli/graph/mod.rs @@ -1,17 +1,15 @@ -mod commands; mod health; mod options; mod query; mod search; -pub(in crate::cli) use commands::{ - run_graph_architecture_queries, run_graph_context, run_graph_health, run_graph_query, - run_graph_query_helpers, run_graph_schema, run_graph_search, +pub(crate) use health::{count_graph_nodes, resolve_health_runtime}; +pub(crate) use options::HealthOptions; +pub(crate) use options::{ + ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, + MetadataOutputOptions, }; -pub(in crate::cli) use health::{count_graph_nodes, resolve_health_runtime}; -pub(in crate::cli) use options::HealthOptions; -pub(in crate::cli) use options::{GraphContextOptions, GraphSearchOptions, MetadataOutputOptions}; -pub(in crate::cli) use query::{ +pub(crate) use query::{ cypher_single_quoted, execute_read_only_query, validate_read_only_statement, }; -pub(in crate::cli) use search::{execute_graph_context, execute_graph_search}; +pub(crate) use search::{execute_graph_context, execute_graph_search}; diff --git a/src/cli/graph/options.rs b/src/cli/graph/options.rs index 2a91b77..7948f3e 100644 --- a/src/cli/graph/options.rs +++ b/src/cli/graph/options.rs @@ -5,17 +5,17 @@ use crate::cli::{ use std::path::PathBuf; #[derive(Debug)] -pub(in crate::cli) struct HealthOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) help: bool, - pub(in crate::cli) json: bool, +pub(crate) struct HealthOptions { + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) help: bool, + pub(crate) json: bool, } impl HealthOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut options = Self { repo_root: None, config: None, @@ -76,20 +76,20 @@ impl HealthOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphQueryOptions { - pub(in crate::cli) statement: String, - pub(in crate::cli) parameters: serde_json::Map, - pub(in crate::cli) limit: usize, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) help: bool, - pub(in crate::cli) json: bool, +pub(crate) struct GraphQueryOptions { + pub(crate) statement: String, + pub(crate) parameters: serde_json::Map, + pub(crate) limit: usize, + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) help: bool, + pub(crate) json: bool, } impl GraphQueryOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut statement = None; let mut parameters = serde_json::Map::new(); let mut limit = 100_usize; @@ -233,14 +233,14 @@ impl GraphQueryOptions { } #[derive(Debug)] -pub(in crate::cli) struct MetadataOutputOptions { - pub(in crate::cli) format: String, - pub(in crate::cli) pretty: bool, - pub(in crate::cli) help: bool, +pub(crate) struct MetadataOutputOptions { + pub(crate) format: String, + pub(crate) pretty: bool, + pub(crate) help: bool, } impl MetadataOutputOptions { - pub(in crate::cli) fn parse(args: &[String], command_name: &str) -> Result { + pub(crate) fn parse(args: &[String], command_name: &str) -> Result { let mut options = Self { format: "block".to_string(), pretty: false, @@ -284,7 +284,7 @@ impl MetadataOutputOptions { } #[derive(Debug)] -pub(in crate::cli) struct ArchitectureQueryOptions { +pub(crate) struct ArchitectureQueryOptions { pub(in crate::cli) output: MetadataOutputOptions, pub(in crate::cli) group: Option, } @@ -317,23 +317,23 @@ impl ArchitectureQueryOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphSearchOptions { - pub(in crate::cli) query: String, - pub(in crate::cli) limit: usize, - pub(in crate::cli) profile: String, - pub(in crate::cli) budget: usize, - pub(in crate::cli) context_limit: usize, - pub(in crate::cli) max_depth: Option, - pub(in crate::cli) detail: String, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) output: MetadataOutputOptions, +pub(crate) struct GraphSearchOptions { + pub(crate) query: String, + pub(crate) limit: usize, + pub(crate) profile: String, + pub(crate) budget: usize, + pub(crate) context_limit: usize, + pub(crate) max_depth: Option, + pub(crate) detail: String, + pub(crate) repo_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) output: MetadataOutputOptions, } impl GraphSearchOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { let mut query = None; let mut limit = 3_usize; let mut profile = "brief".to_string(); @@ -484,7 +484,7 @@ impl GraphSearchOptions { } #[derive(Debug)] -pub(in crate::cli) struct GraphContextOptions { +pub(crate) struct GraphContextOptions { pub(in crate::cli) search: GraphSearchOptions, pub(in crate::cli) node_id: Option, pub(in crate::cli) node_type: Option, diff --git a/src/cli/graph/query.rs b/src/cli/graph/query.rs index dea3fdd..0f3b59f 100644 --- a/src/cli/graph/query.rs +++ b/src/cli/graph/query.rs @@ -5,10 +5,7 @@ use lbug::Value; use serde_json::json; use std::path::Path; -pub(in crate::cli) fn span_json( - line_start: Option, - line_end: Option, -) -> serde_json::Value { +pub(crate) fn span_json(line_start: Option, line_end: Option) -> serde_json::Value { let mut span = serde_json::Map::new(); if let Some(line_start) = line_start { span.insert("line_start".to_string(), json!(line_start)); @@ -19,15 +16,15 @@ pub(in crate::cli) fn span_json( serde_json::Value::Object(span) } -pub(in crate::cli) fn cypher_single_quoted(value: &str) -> String { +pub(crate) fn cypher_single_quoted(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } -pub(in crate::cli) fn cypher_identifier(value: &str) -> String { +pub(crate) fn cypher_identifier(value: &str) -> String { value.replace('`', "``") } -pub(in crate::cli) fn value_to_string(value: Option<&Value>) -> String { +pub(crate) fn value_to_string(value: Option<&Value>) -> String { match value { Some(Value::String(value)) => value.clone(), Some(Value::Int64(value)) => value.to_string(), @@ -39,7 +36,7 @@ pub(in crate::cli) fn value_to_string(value: Option<&Value>) -> String { } } -pub(in crate::cli) fn value_to_i64(value: Option<&Value>) -> Option { +pub(crate) fn value_to_i64(value: Option<&Value>) -> Option { match value { Some(Value::Int64(value)) => Some(*value), Some(Value::Int32(value)) => Some(i64::from(*value)), @@ -53,7 +50,7 @@ pub(in crate::cli) fn value_to_i64(value: Option<&Value>) -> Option { } } -pub(in crate::cli) fn value_to_f64(value: Option<&Value>) -> f64 { +pub(crate) fn value_to_f64(value: Option<&Value>) -> f64 { match value { Some(Value::Double(value)) => *value, Some(Value::Float(value)) => f64::from(*value), @@ -65,7 +62,7 @@ pub(in crate::cli) fn value_to_f64(value: Option<&Value>) -> f64 { } } -pub(in crate::cli) fn validate_read_only_statement(statement: &str) -> Result<(), String> { +pub(crate) fn validate_read_only_statement(statement: &str) -> Result<(), String> { let stripped = statement.trim().trim_end_matches(';'); if stripped.contains(';') { return Err("graph_query accepts one read-only statement at a time".to_string()); @@ -111,7 +108,7 @@ pub(in crate::cli) fn is_keyword_char(character: char) -> bool { character.is_ascii_alphanumeric() || character == '_' } -pub(in crate::cli) fn execute_read_only_query( +pub(crate) fn execute_read_only_query( db_path: &Path, statement: &str, parameters: &serde_json::Map, @@ -150,7 +147,7 @@ pub(in crate::cli) fn execute_read_only_query( Ok((rows, truncated)) } -pub(in crate::cli) fn lbug_query_parameters( +pub(crate) fn lbug_query_parameters( parameters: &serde_json::Map, ) -> Result, String> { let mut converted = Vec::with_capacity(parameters.len()); @@ -163,9 +160,7 @@ pub(in crate::cli) fn lbug_query_parameters( Ok(converted) } -pub(in crate::cli) fn json_parameter_to_lbug_value( - value: &serde_json::Value, -) -> Result { +pub(crate) fn json_parameter_to_lbug_value(value: &serde_json::Value) -> Result { match value { serde_json::Value::Bool(value) => Ok(Value::Bool(*value)), serde_json::Value::Number(value) => { @@ -186,7 +181,7 @@ pub(in crate::cli) fn json_parameter_to_lbug_value( } } -pub(in crate::cli) fn json_safe_value(value: Value) -> serde_json::Value { +pub(crate) fn json_safe_value(value: Value) -> serde_json::Value { match value { Value::Null(_) => serde_json::Value::Null, Value::Bool(value) => json!(value), diff --git a/src/cli/graph/search.rs b/src/cli/graph/search.rs index a52a122..89d2efc 100644 --- a/src/cli/graph/search.rs +++ b/src/cli/graph/search.rs @@ -13,7 +13,7 @@ use lbug::{Connection, Database, SystemConfig}; use serde_json::json; use std::{collections::BTreeSet, path::Path}; -pub(in crate::cli) fn execute_graph_search( +pub(crate) fn execute_graph_search( db_path: &Path, options: &GraphSearchOptions, ) -> Result, String> { @@ -85,7 +85,7 @@ pub(in crate::cli) fn execute_graph_search( Ok(payloads) } -pub(in crate::cli) fn execute_graph_context( +pub(crate) fn execute_graph_context( db_path: &Path, node_id: &str, node_type: &str, diff --git a/src/cli/materialization/command.rs b/src/cli/materialization/command.rs index 394dc45..13d70ed 100644 --- a/src/cli/materialization/command.rs +++ b/src/cli/materialization/command.rs @@ -1,13 +1,12 @@ -use super::manifest::{read_request, request_manifest_path, write_manifest}; -use super::output::{materialization_payload, serialize_plan_block}; +use super::manifest::read_request; use super::request::{build_request, MaterializeOptions}; -use crate::cli::format::{materialize_help, plan_help, schema_statements_from_copy_statements}; -use crate::cli::setup::GraphStatePaths; -use crate::db_writer::{write_database, LadybugWriteRequest}; -use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use crate::api::{ + contracts::{MaterializationRequest, OperationRequest, OutputFormat, RepoSelector}, + CodebaseGraphApi, +}; +use crate::cli::format::{materialize_help, plan_help}; use std::io::Write; use std::path::Path; -use std::time::Instant; pub(in crate::cli) fn run_materialize( args: &[String], @@ -18,128 +17,36 @@ pub(in crate::cli) fn run_materialize( writeln!(stdout, "{}", materialize_help()).map_err(|error| error.to_string())?; return Ok(()); } - let (_, response) = materialize(&options)?; - let output = serde_json::to_string_pretty(&response).map_err(|error| error.to_string())?; + let api = CodebaseGraphApi::new(); + let request = materialize_request(&options, OutputFormat::Typed)?; + let response = api + .execute_operation(&OperationRequest::Materialize(request)) + .map_err(|error| error.message)?; + let output = + serde_json::to_string_pretty(&response.payload).map_err(|error| error.to_string())?; writeln!(stdout, "{output}").map_err(|error| error.to_string())?; Ok(()) } -pub(in crate::cli) fn materialize( - options: &MaterializeOptions, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let request = match options.native_request.as_ref() { - Some(request_path) => read_request(request_path)?, - None => build_request(options)?, - }; - materialize_request(options, request) -} - -pub(in crate::cli) fn materialize_candidate_paths( - options: &MaterializeOptions, - candidate_paths: Vec, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let mut request = build_request(options)?; - request.candidate_paths = candidate_paths; - request.atomic_rebuild = false; - materialize_request(options, request) -} - -pub(in crate::cli) fn materialize_request( - options: &MaterializeOptions, - request: NativeSyntaxMaterializationRequest, -) -> Result< - ( - NativeSyntaxMaterializationRequest, - NativeSyntaxMaterializationResponse, - ), - String, -> { - let started = Instant::now(); - let final_request = request; - let mut response = - crate::materialize_syntax_batch(&final_request).map_err(|error| error.to_string())?; - if !response.skipped { - let database_started = Instant::now(); - write_materialized_database(&final_request, &response)?; - response.phase_timings.insert( - "database_write_seconds".to_string(), - database_started.elapsed().as_secs_f64(), - ); - response.database_written = true; - } - response.phase_timings.insert( - "native_cli_seconds".to_string(), - started.elapsed().as_secs_f64(), - ); - - if let Some(manifest_path) = request_manifest_path(options).as_ref() { - write_manifest( - manifest_path, - &final_request, - &response.rebuilt_entries, - &response.diff, - )?; - } - - Ok((final_request, response)) -} - -fn write_materialized_database( - request: &NativeSyntaxMaterializationRequest, - response: &NativeSyntaxMaterializationResponse, -) -> Result<(), String> { - let schema_statements = if request.schema_statements.is_empty() { - schema_statements_from_copy_statements(request.include_fts, &response.copy_statements) - } else { - request.schema_statements.clone() - }; - let mut delete_statements = crate::db_writer::partition_delete_statements( - request.previous_manifest.as_ref(), - &response.diff, - ); - delete_statements.extend(crate::db_writer::incoming_row_delete_statements( - request.previous_manifest.as_ref(), - &response.diff, - &response.rebuilt_entries, - )); - write_database(LadybugWriteRequest { - db_path: request.db_path.clone(), - include_fts: request.include_fts, - schema_statements, - replace_database: response.diff.force_rebuild, - delete_statements, - copy_statements: response.copy_statements.clone(), - }) - .map_err(|error| error.to_string()) -} - pub(in crate::cli) fn run_plan(args: &[String], stdout: &mut W) -> Result<(), String> { let options = MaterializeOptions::parse_with_command(args, "plan")?; if options.help { writeln!(stdout, "{}", plan_help()).map_err(|error| error.to_string())?; return Ok(()); } - let mut request = match options.native_request.as_ref() { - Some(request_path) => read_request(request_path)?, - None => build_request(&options)?, - }; - request.atomic_rebuild = false; - let response = - crate::plan_syntax_materialization(&request).map_err(|error| error.to_string())?; - let paths = GraphStatePaths::derive(Path::new(&request.source_root)); - let payload = materialization_payload(&response, &request.mode, &paths); + let api = CodebaseGraphApi::new(); + let request = materialize_request( + &options, + if options.json_output { + OutputFormat::Typed + } else { + OutputFormat::Block + }, + )?; + let response = api + .execute_operation(&OperationRequest::Plan(request)) + .map_err(|error| error.message)?; + let payload = response.payload; if options.json_output { writeln!( stdout, @@ -148,6 +55,75 @@ pub(in crate::cli) fn run_plan(args: &[String], stdout: &mut W) -> Res ) .map_err(|error| error.to_string()) } else { - write!(stdout, "{}", serialize_plan_block(&payload)).map_err(|error| error.to_string()) + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string()) } } + +fn materialize_request( + options: &MaterializeOptions, + output_format: OutputFormat, +) -> Result { + let request = match options.native_request.as_ref() { + Some(request_path) => { + let native = read_request(request_path)?; + MaterializationRequest { + repo: RepoSelector { + repo_root: Some(Path::new(&native.source_root).to_path_buf()), + config_path: None, + db_path: Some(Path::new(&native.db_path).to_path_buf()), + manifest_path: options.manifest.clone(), + }, + native_request_path: Some(request_path.clone()), + source_root: Some(native.source_root), + mode: native.mode, + include_fts: native.include_fts, + semantic_enrichment: native.semantic_enrichment, + semantic_provider_mode: native.semantic_provider_mode, + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: native.include_patterns, + exclude_patterns: native.exclude_patterns, + candidate_paths: native.candidate_paths, + parallel: native.parallel, + progress: native.progress, + output_format, + } + } + None => { + let materialization_request = build_request(options) + .map_err(|error| format!("failed to build materialization request: {error}"))?; + MaterializationRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: None, + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + native_request_path: None, + source_root: options + .source_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + mode: materialization_request.mode, + include_fts: materialization_request.include_fts, + semantic_enrichment: materialization_request.semantic_enrichment, + semantic_provider_mode: materialization_request.semantic_provider_mode, + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: materialization_request.include_patterns, + exclude_patterns: materialization_request.exclude_patterns, + candidate_paths: materialization_request.candidate_paths, + parallel: materialization_request.parallel, + progress: materialization_request.progress, + output_format, + } + } + }; + Ok(request) +} diff --git a/src/cli/materialization/manifest.rs b/src/cli/materialization/manifest.rs index cdffd5b..70d0d3b 100644 --- a/src/cli/materialization/manifest.rs +++ b/src/cli/materialization/manifest.rs @@ -12,7 +12,7 @@ pub(in crate::cli) fn read_manifest(path: &Path) -> Result Option { +pub(crate) fn request_manifest_path(options: &MaterializeOptions) -> Option { if options.native_request.is_some() { return options.manifest.clone(); } @@ -26,16 +26,14 @@ pub(in crate::cli) fn request_manifest_path(options: &MaterializeOptions) -> Opt ) } -pub(in crate::cli) fn read_request( - path: &Path, -) -> Result { +pub(crate) fn read_request(path: &Path) -> Result { let text = fs::read_to_string(path) .map_err(|error| format!("failed to read native request {}: {error}", path.display()))?; serde_json::from_str(&text) .map_err(|error| format!("failed to parse native request {}: {error}", path.display())) } -pub(in crate::cli) fn write_manifest( +pub(crate) fn write_manifest( path: &Path, request: &NativeSyntaxMaterializationRequest, rebuilt_entries: &BTreeMap, diff --git a/src/cli/materialization/mod.rs b/src/cli/materialization/mod.rs index 7561b3d..9c48741 100644 --- a/src/cli/materialization/mod.rs +++ b/src/cli/materialization/mod.rs @@ -3,11 +3,11 @@ mod manifest; mod output; mod request; -pub(in crate::cli) use command::{ - materialize, materialize_candidate_paths, run_materialize, run_plan, -}; -pub(in crate::cli) use output::{dry_run_materialization_payload, materialization_payload}; -pub(in crate::cli) use request::{ +pub(in crate::cli) use command::{run_materialize, run_plan}; +pub(crate) use manifest::{read_request, request_manifest_path, write_manifest}; +pub(crate) use output::serialize_plan_block; +pub(crate) use output::{dry_run_materialization_payload, materialization_payload}; +pub(crate) use request::{ build_request, default_excluded_parts, read_codebase_graph_ignore, read_materialization_config_rules, MaterializeOptions, }; diff --git a/src/cli/materialization/output.rs b/src/cli/materialization/output.rs index ace7712..f809e50 100644 --- a/src/cli/materialization/output.rs +++ b/src/cli/materialization/output.rs @@ -5,7 +5,7 @@ use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializ use serde_json::json; use std::path::Path; -pub(in crate::cli) fn materialization_payload( +pub(crate) fn materialization_payload( response: &NativeSyntaxMaterializationResponse, mode: &str, paths: &GraphStatePaths, @@ -54,7 +54,7 @@ pub(in crate::cli) fn materialization_payload( }) } -pub(in crate::cli) fn dry_run_materialization_payload( +pub(crate) fn dry_run_materialization_payload( request: &NativeSyntaxMaterializationRequest, paths: &GraphStatePaths, ) -> serde_json::Value { @@ -79,7 +79,7 @@ pub(in crate::cli) fn dry_run_materialization_payload( }) } -pub(in crate::cli) fn serialize_plan_block(payload: &serde_json::Value) -> String { +pub(crate) fn serialize_plan_block(payload: &serde_json::Value) -> String { let mut lines = vec![format!( "plan mode={} scanned={} rebuild={} delete={} skip={} ignored={}", block_value(value_str(payload, "mode")), diff --git a/src/cli/materialization/request.rs b/src/cli/materialization/request.rs index ef0ae7b..ad6f5f0 100644 --- a/src/cli/materialization/request.rs +++ b/src/cli/materialization/request.rs @@ -9,7 +9,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -pub(in crate::cli) fn build_request( +pub(crate) fn build_request( options: &MaterializeOptions, ) -> Result { let source_root = resolve_source_root(options.source_root.as_deref())?; @@ -65,14 +65,12 @@ pub(in crate::cli) fn build_request( } #[derive(Default)] -pub(in crate::cli) struct ConfigScanRules { - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, +pub(crate) struct ConfigScanRules { + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, } -pub(in crate::cli) fn read_materialization_config_rules( - path: &Path, -) -> Result { +pub(crate) fn read_materialization_config_rules(path: &Path) -> Result { if !path.exists() { return Ok(ConfigScanRules::default()); } @@ -95,7 +93,7 @@ pub(in crate::cli) fn read_materialization_config_rules( }) } -pub(in crate::cli) fn json_string_array(value: &serde_json::Value) -> Vec { +pub(crate) fn json_string_array(value: &serde_json::Value) -> Vec { value .as_array() .map(|items| { @@ -108,9 +106,7 @@ pub(in crate::cli) fn json_string_array(value: &serde_json::Value) -> Vec Result, String> { +pub(crate) fn read_codebase_graph_ignore(source_root: &Path) -> Result, String> { let path = source_root.join(".codebaseGraphignore"); if !path.exists() { return Ok(Vec::new()); @@ -125,7 +121,7 @@ pub(in crate::cli) fn read_codebase_graph_ignore( .collect()) } -pub(in crate::cli) fn git_candidate_paths( +pub(crate) fn git_candidate_paths( source_root: &Path, options: &MaterializeOptions, ) -> Result, String> { @@ -158,7 +154,7 @@ pub(in crate::cli) fn git_candidate_paths( Ok(paths) } -pub(in crate::cli) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { +pub(crate) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { let output = Command::new("git") .args(args) .current_dir(source_root) @@ -175,7 +171,7 @@ pub(in crate::cli) fn git_paths(source_root: &Path, args: &[&str]) -> Result Vec { +pub(crate) fn normalized_candidate_paths(paths: &[String]) -> Vec { let mut paths = paths .iter() .map(|path| path.trim().trim_start_matches("./").replace('\\', "/")) @@ -186,7 +182,7 @@ pub(in crate::cli) fn normalized_candidate_paths(paths: &[String]) -> Vec Vec { +pub(crate) fn default_excluded_parts() -> Vec { [ ".bzr", ".cache", @@ -211,26 +207,26 @@ pub(in crate::cli) fn default_excluded_parts() -> Vec { } #[derive(Clone, Debug)] -pub(in crate::cli) struct MaterializeOptions { - pub(in crate::cli) native_request: Option, - pub(in crate::cli) source_root: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) mode: String, - pub(in crate::cli) include_fts: bool, - pub(in crate::cli) semantic_enrichment: bool, - pub(in crate::cli) semantic_provider_mode: String, - pub(in crate::cli) use_git: bool, - pub(in crate::cli) git_diff: bool, - pub(in crate::cli) git_base: Option, - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, - pub(in crate::cli) candidate_paths: Vec, - pub(in crate::cli) parallel: bool, - pub(in crate::cli) progress: bool, - pub(in crate::cli) plan_only: bool, - pub(in crate::cli) help: bool, - pub(in crate::cli) json_output: bool, +pub(crate) struct MaterializeOptions { + pub(crate) native_request: Option, + pub(crate) source_root: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) mode: String, + pub(crate) include_fts: bool, + pub(crate) semantic_enrichment: bool, + pub(crate) semantic_provider_mode: String, + pub(crate) use_git: bool, + pub(crate) git_diff: bool, + pub(crate) git_base: Option, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) candidate_paths: Vec, + pub(crate) parallel: bool, + pub(crate) progress: bool, + pub(crate) plan_only: bool, + pub(crate) help: bool, + pub(crate) json_output: bool, } impl Default for MaterializeOptions { @@ -260,14 +256,11 @@ impl Default for MaterializeOptions { } impl MaterializeOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(crate) fn parse(args: &[String]) -> Result { Self::parse_with_command(args, "build") } - pub(in crate::cli) fn parse_with_command( - args: &[String], - command_name: &str, - ) -> Result { + pub(crate) fn parse_with_command(args: &[String], command_name: &str) -> Result { let mut options = Self { mode: "changed".to_string(), include_fts: true, diff --git a/src/cli/mcp/protocol.rs b/src/cli/mcp/protocol.rs index 7c796b8..af2eeef 100644 --- a/src/cli/mcp/protocol.rs +++ b/src/cli/mcp/protocol.rs @@ -1,8 +1,6 @@ use super::{options::McpServeOptions, tools::mcp_call_tool_result}; -use crate::cli::{ - constants::{LATEST_PROTOCOL_VERSION, MCP_TOOL_SPECS_JSON}, - format::metadata_payload, -}; +use crate::cli::constants::LATEST_PROTOCOL_VERSION; +use crate::cli::mcp::tools::generate_mcp_specs; use serde_json::json; pub(in crate::cli) fn handle_mcp_message( @@ -48,7 +46,7 @@ pub(in crate::cli) fn handle_mcp_message( })) } "ping" => Ok(json!({})), - "tools/list" => metadata_payload(MCP_TOOL_SPECS_JSON), + "tools/list" => generate_mcp_specs(), "tools/call" => { let params = message.get("params").cloned().unwrap_or_else(|| json!({})); let tool_name = params diff --git a/src/cli/mcp/refresh.rs b/src/cli/mcp/refresh.rs index 433e033..66063d9 100644 --- a/src/cli/mcp/refresh.rs +++ b/src/cli/mcp/refresh.rs @@ -1,13 +1,13 @@ use super::options::McpServeOptions; +use crate::api::lifecycle::is_retryable_refresh_failure; use crate::cli::{ graph::resolve_health_runtime, - materialization::{materialize_candidate_paths, MaterializeOptions}, + materialization::MaterializeOptions, watch::{ - collect_poll_batch, collect_watch_batch, probe_native_watcher, start_native_watcher, - watch_file_snapshot, WatchEventFilter, WatchLoopConfig, WatchMessage, + collect_poll_batch, collect_watch_batch, execute_refresh_operation, probe_native_watcher, + start_native_watcher, watch_file_snapshot, WatchEventFilter, WatchLoopConfig, WatchMessage, }, }; -use crate::db_writer::is_transient_database_error; use crate::protocol::NativeSyntaxMaterializationResponse; use serde_json::json; use std::{ @@ -359,8 +359,7 @@ fn refresh_batch( return Ok(()); } refresh_batch_with(state, backend, event_count, paths, |candidate_paths| { - materialize_candidate_paths(materialize_options, candidate_paths) - .map(|(_, response)| response) + execute_refresh_operation(materialize_options, candidate_paths) }) } @@ -397,7 +396,7 @@ fn refresh_batch_with( return Ok(()); } Err(error) => { - let retrying = is_transient_database_error(&error); + let retrying = is_retryable_refresh_failure(&error); state.mark_refresh_error(backend, event_count, changed_paths, error, retrying); if !retrying { return Ok(()); diff --git a/src/cli/mcp/tools.rs b/src/cli/mcp/tools.rs index b35eac7..eaf1ae0 100644 --- a/src/cli/mcp/tools.rs +++ b/src/cli/mcp/tools.rs @@ -1,63 +1,119 @@ use super::options::McpServeOptions; -use crate::cli::{ - constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}, - format::{ - filter_architecture_group, metadata_payload, serialize_architecture_queries_block, - serialize_context_block, serialize_error_block, serialize_health_block, - serialize_query_block, serialize_query_helpers_block, serialize_schema_block, - serialize_search_block, - }, - graph::{ - count_graph_nodes, execute_graph_context, execute_graph_search, execute_read_only_query, - resolve_health_runtime, validate_read_only_statement, GraphContextOptions, - GraphSearchOptions, MetadataOutputOptions, +use crate::api::{ + contracts::{ + ApiError, ContextRequest, HealthRequest, OperationRequest, OperationResponse, OutputFormat, + QueryRequest, RepoSelector, SearchRequest, }, + CodebaseGraphApi, OperationDescriptor, }; +use crate::cli::format::serialize_error_block; use serde_json::json; +use serde_json::Map; + +pub(in crate::cli) fn generate_mcp_specs() -> Result { + let tools = CodebaseGraphApi::new() + .operation_descriptors() + .into_iter() + .filter(|operation| operation.surfaces.contains(&"mcp")) + .filter_map(operation_spec_from_descriptor) + .collect::>(); + Ok(json!({"tools": tools})) +} + +fn operation_spec_from_descriptor(descriptor: OperationDescriptor) -> Option { + let name = descriptor.mcp_tool_name?; + let properties = (descriptor.request_schema)(); + let mut property_map: Map = + properties.as_object().cloned().unwrap_or_default(); + property_map.insert( + "output_format".to_string(), + json!({"type":"string","enum":["json","block"],"default":"block"}), + ); + property_map.insert( + "include_structured_content".to_string(), + json!({"type":"boolean","default":false,"description":"Include the MCP structuredContent payload alongside the text result."}), + ); + + Some(json!({ + "name": name, + "description": descriptor.summary, + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": property_map, + }, + })) +} + +fn map_error_to_transport(tool_name: &str, error: &ApiError) -> serde_json::Value { + json!({ + "error": { + "tool": tool_name, + "type": "ValueError", + "code": error.code, + "message": error.message, + "details": error.details, + "retryable": error.retryable, + } + }) +} pub(in crate::cli) fn mcp_call_tool_result( tool_name: &str, arguments: &serde_json::Value, options: &McpServeOptions, ) -> Result { - let payload = mcp_tool_payload(tool_name, arguments, options); let output_format = arguments .get("output_format") .and_then(serde_json::Value::as_str) .unwrap_or("block"); + let output_format = match output_format { + "json" => "json", + "block" => "block", + _ => return Err("graph tool output_format must be \"json\" or \"block\"".to_string()), + }; + let response = mcp_tool_payload(tool_name, arguments, options); let include_structured = arguments .get("include_structured_content") .and_then(serde_json::Value::as_bool) .unwrap_or(false); - match payload { - Ok(payload) => { - let text = if output_format == "json" { - serde_json::to_string(&payload).map_err(|error| error.to_string())? + match response { + Ok(response) => { + let payload = response.payload; + let (text, structured) = if output_format == "json" { + ( + serde_json::to_string(&payload).map_err(|error| error.to_string())?, + payload.clone(), + ) } else { - mcp_block_text(tool_name, &payload) + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())? + .to_string(); + let structured = payload + .get("structured") + .cloned() + .unwrap_or(serde_json::Value::Null); + (text, structured) }; let mut result = json!({ "content": [{"type": "text", "text": text}], "isError": false, }); if include_structured { - result["structuredContent"] = payload; + result["structuredContent"] = structured; } Ok(result) } Err(error) - if tool_name.is_empty() || error.starts_with("Unknown codebaseGraph MCP tool") => + if tool_name.is_empty() + || error.message.starts_with("Unknown codebaseGraph MCP tool") => { - Err(error) + Err(error.message) } Err(error) => { - let payload = json!({ - "error": { - "tool": tool_name, - "type": "ValueError", - "message": error, - } - }); + let payload = map_error_to_transport(tool_name, &error); let text = if output_format == "json" { serde_json::to_string(&payload).map_err(|error| error.to_string())? } else { @@ -79,7 +135,7 @@ pub(in crate::cli) fn mcp_tool_payload( tool_name: &str, arguments: &serde_json::Value, options: &McpServeOptions, -) -> Result { +) -> Result { let _refresh_read_guard = if matches!( tool_name, "graph_health" | "graph_search" | "graph_context" | "graph_query" @@ -88,134 +144,128 @@ pub(in crate::cli) fn mcp_tool_payload( .refresh .as_ref() .map(|refresh| refresh.read_guard()) - .transpose()? + .transpose() + .map_err(|error| ApiError::new("refresh_lock_failed", error))? } else { None }; - match tool_name { - "graph_health" => graph_health_payload(options), - "graph_schema" => metadata_payload(GRAPH_SCHEMA_JSON), - "graph_query_helpers" => metadata_payload(QUERY_HELPERS_JSON), - "graph_architecture_queries" => { - let mut payload = metadata_payload(ARCHITECTURE_QUERIES_JSON)?; - if let Some(group) = arguments.get("group").and_then(serde_json::Value::as_str) { - filter_architecture_group(&mut payload, group)?; - } - Ok(payload) + let output_format = match arguments + .get("output_format") + .and_then(serde_json::Value::as_str) + .unwrap_or("block") + { + "json" => OutputFormat::Typed, + "block" => OutputFormat::Block, + _ => { + return Err(ApiError::new( + "invalid_output_format", + "graph tool output_format must be \"json\" or \"block\"", + )) } + }; + match tool_name { + "graph_health" => execute_api_request(OperationRequest::Health(HealthRequest { + repo: repo_selector_from_mcp_options(options), + refresh_status: options + .refresh + .as_ref() + .map(|refresh| serde_json::to_value(refresh.as_json()).unwrap_or_default()), + output_format, + })), + "graph_schema" => execute_api_request(OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format, + }), + "graph_query_helpers" => execute_api_request(OperationRequest::Catalog { + kind: "query-helpers".to_string(), + group: None, + output_format, + }), + "graph_architecture_queries" => execute_api_request(OperationRequest::Catalog { + kind: "architecture-queries".to_string(), + group: arguments + .get("group") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + output_format, + }), "graph_search" => { - let search = graph_search_options_from_mcp(arguments, options, true)?; - let runtime = resolve_health_runtime(&options.health_options())?; - let results = execute_graph_search(&runtime.db_path, &search)?; - Ok(json!({ - "query": search.query, - "profile": search.profile, - "limit": search.limit, - "budget": search.budget, - "results": results, - })) + let search = search_request_from_mcp(arguments, options, output_format, true) + .map_err(|error| ApiError::new("invalid_query", error))?; + execute_api_request(OperationRequest::Search(search)) } "graph_context" => { - let context = graph_context_options_from_mcp(arguments, options)?; - let runtime = resolve_health_runtime(&options.health_options())?; - if let (Some(node_id), Some(node_type)) = - (context.node_id.as_ref(), context.node_type.as_ref()) - { - let rows = - execute_graph_context(&runtime.db_path, node_id, node_type, &context.search)?; - Ok(json!({ - "node_id": node_id, - "node_type": node_type, - "profile": context.search.profile, - "context": rows, - })) - } else { - let results = execute_graph_search(&runtime.db_path, &context.search)?; - Ok(json!({ - "query": context.search.query, - "profile": context.search.profile, - "limit": context.search.limit, - "budget": context.search.budget, - "results": results, - })) - } + let context = context_request_from_mcp(arguments, options, output_format) + .map_err(|error| ApiError::new("invalid_context", error))?; + execute_api_request(OperationRequest::Context(context)) } "graph_query" => { let statement = arguments .get("statement") - .or_else(|| arguments.get("query")) .and_then(serde_json::Value::as_str) .unwrap_or("") .trim(); if statement.is_empty() { - return Err("graph_query requires a non-empty statement".to_string()); + return Err(ApiError::new( + "missing_query", + "graph_query requires a non-empty statement", + )); } - validate_read_only_statement(statement)?; let parameters = arguments .get("parameters") .cloned() .unwrap_or_else(|| json!({})); - let parameters = parameters - .as_object() - .ok_or_else(|| "graph_query parameters must be a JSON object".to_string())?; + let parameters = parameters.as_object().ok_or_else(|| { + ApiError::new( + "invalid_parameters", + "graph_query parameters must be a JSON object", + ) + })?; let limit = arguments .get("limit") .and_then(serde_json::Value::as_u64) .unwrap_or(100) as usize; if limit == 0 || limit > 1000 { - return Err("graph_query limit must be between 1 and 1000".to_string()); + return Err(ApiError::new( + "invalid_limit", + "graph_query limit must be between 1 and 1000", + )); } - let runtime = resolve_health_runtime(&options.health_options())?; - let (rows, truncated) = - execute_read_only_query(&runtime.db_path, statement, parameters, limit)?; - Ok(json!({ - "statement": statement, - "row_count": rows.len(), - "rows": rows, - "truncated": truncated, + execute_api_request(OperationRequest::Query(QueryRequest { + repo: repo_selector_from_mcp_options(options), + statement: statement.to_string(), + parameters: serde_json::Value::Object(parameters.clone()), + limit, + output_format, })) } - _ => Err(format!("Unknown codebaseGraph MCP tool: {tool_name}")), + _ => Err(ApiError::new( + "unknown_tool", + format!("Unknown codebaseGraph MCP tool: {tool_name}"), + )), } } -pub(in crate::cli) fn graph_health_payload( - options: &McpServeOptions, -) -> Result { - let runtime = resolve_health_runtime(&options.health_options())?; - let database_exists = runtime.db_path.exists(); - let manifest_exists = runtime.manifest_path.exists(); - let mut graph_readable = false; - let mut total_nodes = 0_u64; - let mut error_message = None; - if database_exists { - match count_graph_nodes(&runtime.db_path) { - Ok(count) => { - graph_readable = true; - total_nodes = count; - } - Err(error) => error_message = Some(error), - } +fn execute_api_request(request: OperationRequest) -> Result { + CodebaseGraphApi::new().execute_operation(&request) +} + +fn repo_selector_from_mcp_options(options: &McpServeOptions) -> RepoSelector { + RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), } - Ok(json!({ - "ok": database_exists && graph_readable, - "repo_root": runtime.repo_root, - "database_path": runtime.db_path, - "manifest_path": runtime.manifest_path, - "database_exists": database_exists, - "manifest_exists": manifest_exists, - "graph_readable": graph_readable, - "total_nodes": total_nodes, - "refresh": options.refresh.as_ref().map(|refresh| refresh.as_json()), - "error": error_message, - })) } -pub(in crate::cli) fn graph_search_options_from_mcp( +fn search_request_from_mcp( arguments: &serde_json::Value, options: &McpServeOptions, + output_format: OutputFormat, require_query: bool, -) -> Result { +) -> Result { let query = arguments .get("query") .and_then(serde_json::Value::as_str) @@ -231,7 +281,8 @@ pub(in crate::cli) fn graph_search_options_from_mcp( if detail != "standard" && detail != "slim" { return Err("--detail must be standard or slim".to_string()); } - Ok(GraphSearchOptions { + Ok(SearchRequest { + repo: repo_selector_from_mcp_options(options), query, limit: json_usize(arguments, "limit", 3), profile: arguments @@ -246,26 +297,15 @@ pub(in crate::cli) fn graph_search_options_from_mcp( .and_then(serde_json::Value::as_u64) .map(|value| value as usize), detail: detail.to_string(), - repo_root: options.repo_root.clone(), - config: options.config.clone(), - db: options.db.clone(), - manifest: options.manifest.clone(), - output: MetadataOutputOptions { - format: arguments - .get("output_format") - .and_then(serde_json::Value::as_str) - .unwrap_or("block") - .to_string(), - pretty: false, - help: false, - }, + output_format, }) } -pub(in crate::cli) fn graph_context_options_from_mcp( +fn context_request_from_mcp( arguments: &serde_json::Value, options: &McpServeOptions, -) -> Result { + output_format: OutputFormat, +) -> Result { let node_id = arguments .get("node_id") .and_then(serde_json::Value::as_str) @@ -281,11 +321,23 @@ pub(in crate::cli) fn graph_context_options_from_mcp( "codebase-context explicit lookup requires both --node-id and --node-type".to_string(), ); } - let search = graph_search_options_from_mcp(arguments, options, node_id.is_none())?; - Ok(GraphContextOptions { - search, + let search = search_request_from_mcp(arguments, options, output_format, node_id.is_none())?; + Ok(ContextRequest { + repo: search.repo, + query: if search.query.is_empty() { + None + } else { + Some(search.query) + }, + profile: search.profile, + limit: search.limit, + budget: search.budget, + context_limit: search.context_limit, + max_depth: search.max_depth, + detail: search.detail, node_id, node_type, + output_format, }) } @@ -301,21 +353,52 @@ pub(in crate::cli) fn json_usize( .unwrap_or(default) } -pub(in crate::cli) fn mcp_block_text(tool_name: &str, payload: &serde_json::Value) -> String { - match tool_name { - "graph_health" => serialize_health_block(payload), - "graph_schema" => serialize_schema_block(payload), - "graph_query_helpers" => serialize_query_helpers_block(payload), - "graph_architecture_queries" => serialize_architecture_queries_block(payload), - "graph_search" => serialize_search_block(payload), - "graph_context" => { - if payload.get("context").is_some() { - serialize_context_block(payload) - } else { - serialize_search_block(payload) - } - } - "graph_query" => serialize_query_block(payload), - _ => serde_json::to_string(payload).unwrap_or_default(), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_errors_map_to_mcp_failures_without_changing_codes() { + let error = ApiError::new("graph_busy", "graph is busy") + .with_details(json!({"operation": "query"})) + .retryable(true); + + let payload = map_error_to_transport("graph_query", &error); + + assert_eq!(payload["error"]["tool"], "graph_query"); + assert_eq!(payload["error"]["code"], "graph_busy"); + assert_eq!(payload["error"]["message"], "graph is busy"); + assert_eq!(payload["error"]["details"]["operation"], "query"); + assert_eq!(payload["error"]["retryable"], true); + } + + #[test] + fn mcp_specs_are_generated_from_registered_operation_metadata() { + let specs = generate_mcp_specs().expect("MCP specs should generate"); + let tools = specs["tools"].as_array().expect("tools should be an array"); + let names = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "graph_architecture_queries", + "graph_context", + "graph_health", + "graph_query", + "graph_query_helpers", + "graph_schema", + "graph_search", + ] + ); + let search = tools + .iter() + .find(|tool| tool["name"] == "graph_search") + .expect("search tool should be advertised"); + assert_eq!( + search["inputSchema"]["properties"]["detail"]["enum"], + json!(["standard", "slim"]) + ); } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4d48606..bf36879 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,13 +1,13 @@ -mod constants; +pub(crate) mod constants; mod dispatch; -mod format; -mod graph; +pub(crate) mod format; +pub(crate) mod graph; mod install; -mod materialization; +pub(crate) mod materialization; mod mcp; -mod reinstall; -mod setup; -mod uninstall; +pub(crate) mod reinstall; +pub(crate) mod setup; +pub(crate) mod uninstall; mod util; mod watch; diff --git a/src/cli/reinstall.rs b/src/cli/reinstall.rs index 3c7b9cd..2e65679 100644 --- a/src/cli/reinstall.rs +++ b/src/cli/reinstall.rs @@ -1,9 +1,13 @@ use super::{ format::reinstall_help, - setup::{setup_payload, GraphStatePaths}, - util::resolve_repo_root, + setup::{setup_payload_for_root, GraphStatePaths}, watch::SetupOptions, }; +use crate::api::contracts::RepositoryLifecycleRequest; +use crate::api::{ + contracts::{OperationRequest, OutputFormat, RepoSelector}, + CodebaseGraphApi, +}; use serde_json::json; use std::{ fs, @@ -20,8 +24,78 @@ pub(in crate::cli) fn run_reinstall( writeln!(stdout, "{}", reinstall_help()).map_err(|error| error.to_string())?; return Ok(()); } + let request = RepositoryLifecycleRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: None, + db_path: None, + manifest_path: None, + }, + action: "reinstall".to_string(), + output_format: OutputFormat::Typed, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.mcp_config_path.clone(), + instructions_target: Some(options.instructions_target.clone()), + skip_mcp_config: options.skip_mcp_config, + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + }; + let payload = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Reinstall(request)) + .map_err(|error| error.message)? + .payload; + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +pub(crate) fn execute_reinstall_operation( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + reinstall_payload_for_request(request, repo_root) +} - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; +pub(crate) fn reinstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + execute_reinstall_operation_from_options( + &SetupOptions { + repo_root: request.repo.repo_root.clone(), + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + mcp_client: request + .mcp_client + .clone() + .unwrap_or_else(|| "codex".to_string()), + mcp_config_path: request.mcp_config_path.clone(), + skip_mcp_config: request.skip_mcp_config, + dry_run: request.dry_run, + instructions_target: request + .instructions_target + .clone() + .unwrap_or_else(|| "auto".to_string()), + help: false, + }, + repo_root, + ) +} + +fn execute_reinstall_operation_from_options( + options: &SetupOptions, + repo_root: &Path, +) -> Result { + let repo_root = repo_root.to_path_buf(); if repo_root .components() .any(|component| component.as_os_str() == ".codebaseGraph") @@ -35,9 +109,9 @@ pub(in crate::cli) fn run_reinstall( let paths = GraphStatePaths::derive(&repo_root); let state = reinstall_state(&paths, options.dry_run)?; let install = if options.dry_run { - setup_payload(&options)? + setup_payload_for_root(options, &repo_root)? } else { - match setup_payload(&options) { + match setup_payload_for_root(options, &repo_root) { Ok(payload) => { remove_backup(state.backup_path.as_deref())?; payload @@ -56,13 +130,7 @@ pub(in crate::cli) fn run_reinstall( "state": state.payload, "install": install, }); - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - Ok(()) + Ok(output) } struct ReinstallState { diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 330549b..cfc842e 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -3,12 +3,15 @@ use super::{ format::setup_help, install::{build_mcp_descriptor, install_mcp_client, McpInstallOptions}, materialization::{ - build_request, dry_run_materialization_payload, materialization_payload, materialize, - MaterializeOptions, + build_request, dry_run_materialization_payload, materialization_payload, MaterializeOptions, }, - util::{read_json_file, resolve_repo_root, restore_file, snapshot_file}, + util::{read_json_file, restore_file, snapshot_file}, watch::SetupOptions, }; +use crate::api::contracts::RepositoryLifecycleRequest; +use crate::api::contracts::{OperationRequest, RepoSelector}; +use crate::api::materialization::execute_materialization; +use crate::api::CodebaseGraphApi; use serde_json::json; use std::{ fs, @@ -22,7 +25,29 @@ pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), writeln!(stdout, "{}", setup_help()).map_err(|error| error.to_string())?; return Ok(()); } - let output = setup_payload(&options)?; + let request = RepositoryLifecycleRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: None, + db_path: None, + manifest_path: None, + }, + action: "setup".to_string(), + output_format: crate::api::contracts::OutputFormat::Typed, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.mcp_config_path.clone(), + instructions_target: Some(options.instructions_target.clone()), + skip_mcp_config: options.skip_mcp_config, + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + }; + let output = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Setup(request)) + .map_err(|error| error.message)? + .payload; writeln!( stdout, "{}", @@ -32,8 +57,44 @@ pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), Ok(()) } -pub(in crate::cli) fn setup_payload(options: &SetupOptions) -> Result { - let source_root = resolve_repo_root(options.repo_root.as_deref())?; +pub(crate) fn setup_payload_for_request( + request: &RepositoryLifecycleRequest, + source_root: &Path, +) -> Result { + let options = SetupOptions { + repo_root: request.repo.repo_root.clone(), + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + mcp_client: request + .mcp_client + .clone() + .unwrap_or_else(|| "codex".to_string()), + mcp_config_path: request.mcp_config_path.clone(), + skip_mcp_config: request.skip_mcp_config, + dry_run: request.dry_run, + instructions_target: request + .instructions_target + .clone() + .unwrap_or_else(|| "auto".to_string()), + help: false, + }; + setup_payload_for_root(&options, source_root) +} + +pub(crate) fn execute_setup_operation( + request: &RepositoryLifecycleRequest, + source_root: &Path, +) -> Result { + setup_payload_for_request(request, source_root) +} + +pub(in crate::cli) fn setup_payload_for_root( + options: &SetupOptions, + source_root: &Path, +) -> Result { + let source_root = source_root.to_path_buf(); let paths = GraphStatePaths::derive(&source_root); if source_root .components() @@ -101,7 +162,7 @@ pub(in crate::cli) fn setup_payload(options: &SetupOptions) -> Result serde_json::Value { +pub(crate) fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> serde_json::Value { json!({ "schema_version": 1, "repo_root": repo_root, @@ -254,7 +315,7 @@ pub(super) fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> }) } -pub(super) fn write_setup_config( +pub(crate) fn write_setup_config( paths: &GraphStatePaths, repo_root: &Path, ) -> Result<&'static str, String> { @@ -285,7 +346,7 @@ pub(super) fn write_setup_config( Ok(action) } -pub(super) fn json_file_would_change( +pub(crate) fn json_file_would_change( path: &Path, payload: &serde_json::Value, ) -> Result { @@ -295,7 +356,7 @@ pub(super) fn json_file_would_change( Ok(read_json_file(path)? != *payload) } -pub(super) fn instruction_target_path( +pub(crate) fn instruction_target_path( repo_root: &Path, target: &str, ) -> Result, String> { @@ -318,7 +379,7 @@ pub(super) fn instruction_target_path( } } -pub(super) fn upsert_instruction_block( +pub(crate) fn upsert_instruction_block( repo_root: &Path, target: &str, config_path: &Path, @@ -346,7 +407,7 @@ pub(super) fn upsert_instruction_block( Ok(json!({"action": action, "path": path.to_string_lossy()})) } -pub(super) fn instruction_block(config_path: &Path) -> String { +pub(crate) fn instruction_block(config_path: &Path) -> String { format!( "\n\ ## codebaseGraph workflow\n\ @@ -365,7 +426,7 @@ pub(super) fn instruction_block(config_path: &Path) -> String { ) } -pub(super) fn upsert_instruction_text( +pub(crate) fn upsert_instruction_text( existing: &str, block: &str, created: bool, @@ -408,7 +469,7 @@ pub(super) fn upsert_instruction_text( (text, "updated") } -pub(super) fn remove_instruction_text(existing: &str) -> (String, bool) { +pub(crate) fn remove_instruction_text(existing: &str) -> (String, bool) { const START: &str = ""; const END: &str = ""; let Some(start) = existing.find(START) else { @@ -429,16 +490,16 @@ pub(super) fn remove_instruction_text(existing: &str) -> (String, bool) { (text, true) } #[derive(Debug)] -pub(super) struct GraphStatePaths { - pub(super) repo_name: String, - pub(super) state_dir: PathBuf, - pub(super) db_path: PathBuf, - pub(super) manifest_path: PathBuf, - pub(super) config_path: PathBuf, +pub(crate) struct GraphStatePaths { + pub(crate) repo_name: String, + pub(crate) state_dir: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) config_path: PathBuf, } impl GraphStatePaths { - pub(super) fn derive(repo_root: &Path) -> Self { + pub(crate) fn derive(repo_root: &Path) -> Self { let repo_name = safe_name( repo_root .file_name() @@ -456,7 +517,7 @@ impl GraphStatePaths { } } -pub(super) fn safe_name(value: &str) -> String { +pub(crate) fn safe_name(value: &str) -> String { let normalized: String = value .chars() .map(|character| { diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index e1957f7..34e8653 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -6,7 +6,11 @@ use super::{ McpInstallOptions, }, setup::{remove_instruction_text, GraphStatePaths}, - util::{read_json_file, required_arg, resolve_repo_root}, + util::{read_json_file, required_arg}, +}; +use crate::api::{ + contracts::{OperationRequest, OutputFormat, RepositoryLifecycleRequest}, + CodebaseGraphApi, }; use serde_json::json; use std::{ @@ -104,38 +108,96 @@ pub(in crate::cli) fn run_uninstall( writeln!(stdout, "{}", uninstall_help()).map_err(|error| error.to_string())?; return Ok(()); } - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; + let request = RepositoryLifecycleRequest { + repo: crate::api::contracts::RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config.clone(), + db_path: None, + manifest_path: None, + }, + action: "uninstall".to_string(), + output_format: if options.json { + OutputFormat::Typed + } else { + OutputFormat::Block + }, + dry_run: options.dry_run, + mcp_client: Some(options.mcp_client.clone()), + mcp_config_path: options.client_config_path.clone(), + instructions_target: None, + skip_mcp_config: false, + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + }; + let payload = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Uninstall(request)) + .map_err(|error| error.message)? + .payload; + if options.json { + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + ) + .map_err(|error| error.to_string())?; + } else { + let text = payload + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "block response did not contain text".to_string())?; + write!(stdout, "{text}").map_err(|error| error.to_string())?; + } + Ok(()) +} + +pub(crate) fn execute_uninstall_operation( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + uninstall_payload_for_request(request, repo_root) +} + +pub(crate) fn uninstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + let repo_root = repo_root.to_path_buf(); let paths = GraphStatePaths::derive(&repo_root); - let config_path = options - .config + let config_path = request + .repo + .config_path .clone() .unwrap_or_else(|| paths.config_path.clone()); + let mcp_client = request + .mcp_client + .clone() + .unwrap_or_else(|| "all".to_string()); let server_name = uninstall_server_name(&repo_root, &config_path)?; - let state = uninstall_state_dir(&paths.state_dir, options.dry_run)?; - let instructions = uninstall_instruction_blocks(&repo_root, options.dry_run)?; + let state = uninstall_state_dir(&paths.state_dir, request.dry_run)?; + let instructions = uninstall_instruction_blocks(&repo_root, request.dry_run)?; + let options = UninstallOptions { + repo_root: request.repo.repo_root.clone(), + config: request.repo.config_path.clone(), + mcp_client, + client_config_path: request.mcp_config_path.clone(), + dry_run: request.dry_run, + json: request.output_format == crate::api::contracts::OutputFormat::Typed, + help: false, + }; let mcp_clients = uninstall_mcp_clients(&options, &repo_root, &config_path, &server_name)?; let output = json!({ "ok": true, "repo_root": repo_root, "config_path": config_path, "server_name": server_name, - "dry_run": options.dry_run, + "dry_run": request.dry_run, "state": state, "instructions": instructions, "mcp_clients": mcp_clients, }); - if options.json { - writeln!( - stdout, - "{}", - serde_json::to_string_pretty(&output).map_err(|error| error.to_string())? - ) - .map_err(|error| error.to_string())?; - } else { - write!(stdout, "{}", serialize_uninstall_block(&output)) - .map_err(|error| error.to_string())?; - } - Ok(()) + Ok(output) } fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result { @@ -293,7 +355,7 @@ fn uninstall_mcp_client( })) } -fn serialize_uninstall_block(output: &serde_json::Value) -> String { +pub(crate) fn serialize_uninstall_block(output: &serde_json::Value) -> String { let mut lines = vec![format!( "uninstall ok={} server_name={}", output["ok"].as_bool().unwrap_or(false), diff --git a/src/cli/watch/command.rs b/src/cli/watch/command.rs index e558f22..f0d97b0 100644 --- a/src/cli/watch/command.rs +++ b/src/cli/watch/command.rs @@ -5,7 +5,7 @@ use super::{ output::{write_watch_event, write_watch_status}, poll::run_poll_watch, }; -use crate::cli::{format::watch_help, materialization::materialize, util::resolve_source_root}; +use crate::cli::{format::watch_help, util::resolve_source_root, watch::execute_refresh_operation}; use std::{collections::VecDeque, io::Write}; pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Result<(), String> { @@ -26,7 +26,7 @@ pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Re materialize_options.source_root = Some(source_root.clone()); let filter = WatchEventFilter::from_options(&source_root, &materialize_options)?; if once { - let (_, response) = materialize(&materialize_options)?; + let response = execute_refresh_operation(&materialize_options, Vec::new())?; write_watch_event(stdout, "refreshed", None, 0, 0, &response)?; return Ok(()); } diff --git a/src/cli/watch/mod.rs b/src/cli/watch/mod.rs index beefc41..91cb3f1 100644 --- a/src/cli/watch/mod.rs +++ b/src/cli/watch/mod.rs @@ -16,6 +16,7 @@ pub(in crate::cli) use filter::WatchEventFilter; pub(in crate::cli) use native::{probe_native_watcher, start_native_watcher}; pub(in crate::cli) use options::{SetupOptions, WatchLoopConfig}; pub(in crate::cli) use poll::collect_poll_batch; +pub(in crate::cli) use refresh::execute_refresh_operation; pub(in crate::cli) use snapshot::{scan_source_snapshots, watch_file_snapshot}; pub(in crate::cli) use types::WatchMessage; diff --git a/src/cli/watch/refresh.rs b/src/cli/watch/refresh.rs index 22d8d92..42e1fe7 100644 --- a/src/cli/watch/refresh.rs +++ b/src/cli/watch/refresh.rs @@ -1,7 +1,11 @@ use super::output::{write_watch_event, write_watch_status}; use crate::{ - cli::materialization::{materialize_candidate_paths, MaterializeOptions}, - db_writer::is_transient_database_error, + api::{ + contracts::{OperationRequest, OutputFormat, RefreshRequest, RepoSelector}, + lifecycle::is_retryable_refresh_failure, + CodebaseGraphApi, + }, + cli::materialization::MaterializeOptions, protocol::NativeSyntaxMaterializationResponse, }; use std::{collections::BTreeSet, io::Write, thread, time::Duration}; @@ -17,11 +21,36 @@ pub(in crate::cli) fn refresh_watch_batch( paths: &BTreeSet, ) -> Result { refresh_watch_batch_with(stdout, backend, event_count, paths, |candidate_paths| { - materialize_candidate_paths(materialize_options, candidate_paths) - .map(|(_, response)| response) + execute_refresh_operation(materialize_options, candidate_paths) }) } +pub(in crate::cli) fn execute_refresh_operation( + options: &MaterializeOptions, + paths: Vec, +) -> Result { + let response = CodebaseGraphApi::new() + .execute_operation(&OperationRequest::Refresh(RefreshRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: None, + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + paths, + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + parallel: options.parallel, + progress: options.progress, + output_format: OutputFormat::Typed, + })) + .map_err(|error| error.message)?; + serde_json::from_value(response.payload) + .map_err(|error| format!("invalid refresh response: {error}")) +} + fn refresh_watch_batch_with( stdout: &mut W, backend: &str, @@ -44,7 +73,7 @@ fn refresh_watch_batch_with( return Ok(true); } Err(error) => { - let transient = is_transient_database_error(&error); + let transient = is_retryable_refresh_failure(&error); write_watch_status( stdout, if transient { "retrying" } else { "error" }, diff --git a/src/error.rs b/src/error.rs index 2e473b1..cc32696 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,8 @@ pub enum NativeError { Unsupported(String), } +pub type MaterializationError = NativeError; + impl fmt::Display for NativeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/src/execution/mod.rs b/src/execution/mod.rs index 4d4c3b6..fc7c3d0 100644 --- a/src/execution/mod.rs +++ b/src/execution/mod.rs @@ -3,5 +3,5 @@ mod plan; mod run; mod timing; -pub use plan::plan_syntax_materialization; -pub use run::{materialize_syntax_batch, materialize_syntax_batch_json}; +pub use plan::plan_materialization; +pub use run::execute_materialization_pipeline; diff --git a/src/execution/parallel.rs b/src/execution/parallel.rs index 3f3bfe1..f5a28c6 100644 --- a/src/execution/parallel.rs +++ b/src/execution/parallel.rs @@ -2,7 +2,6 @@ use super::timing::elapsed_seconds; use crate::error::NativeError; use crate::parser; use crate::partition_builder; -use crate::profiles; use crate::protocol::{LanguageProfile, NativeSyntaxMaterializationRequest, SourceSnapshot}; use crate::scan; use std::thread; @@ -15,12 +14,11 @@ pub(super) struct PartitionBuildResult { pub(super) graph_build_seconds: f64, } -pub(super) fn build_partitions( - request: &NativeSyntaxMaterializationRequest, +pub(super) fn build_execution_plan( scan: &scan::SourceScan, rebuild_paths: &[String], ) -> Result, NativeError> { - let profile_set = profiles::ProfileSet::new(&request.profiles); + let request = &scan.input; if request.parallel && rebuild_paths.len() > 1 { return thread::scope(|scope| { let mut handles = Vec::new(); @@ -31,7 +29,11 @@ pub(super) fn build_partitions( let Some(language) = snapshot.language.as_deref() else { continue; }; - let Some(profile) = profile_set.profile_for_language(language) else { + let Some(profile) = scan + .profiles + .iter() + .find(|profile| profile.language == language) + else { continue; }; handles.push( @@ -57,7 +59,11 @@ pub(super) fn build_partitions( let Some(language) = snapshot.language.as_deref() else { continue; }; - let Some(profile) = profile_set.profile_for_language(language) else { + let Some(profile) = scan + .profiles + .iter() + .find(|profile| profile.language == language) + else { continue; }; results.push(build_partition_for_snapshot(request, snapshot, profile)?); @@ -84,3 +90,63 @@ fn build_partition_for_snapshot( graph_build_seconds, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{NativeSyntaxMaterializationRequest, OntologySchema}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn execution_plan_uses_scanned_source_after_repository_is_removed() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let source_root = + std::env::temp_dir().join(format!("codebase-graph-scanned-source-{nonce}")); + fs::create_dir_all(source_root.join("src")).expect("source directory should be created"); + fs::write( + source_root.join("src/lib.rs"), + "pub fn scanned_source() -> bool { true }\n", + ) + .expect("source file should be written"); + + let request = NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().into_owned(), + repository_label: "scanned-source".to_string(), + mode: "full".to_string(), + parser_version: "test".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: OntologySchema::default(), + previous_manifest: None, + profiles: Vec::new(), + excluded_parts: Vec::new(), + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + ignore_patterns: Vec::new(), + candidate_paths: Vec::new(), + db_path: source_root.join("graph").to_string_lossy().into_owned(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + schema_statements: Vec::new(), + staging_dir: source_root.join("staging").to_string_lossy().into_owned(), + atomic_rebuild: false, + strict: true, + parallel: false, + progress: false, + }; + let scan = crate::scan::scan_sources(&request).expect("scan should succeed"); + let rebuild_paths = scan.diff.rebuild_paths(); + + fs::remove_dir_all(&source_root).expect("source repository should be removed"); + + let plan = build_execution_plan(&scan, &rebuild_paths) + .expect("planning should use the scanned source payload"); + assert_eq!(plan.len(), 1); + assert_eq!(plan[0].partition.entry.path, "src/lib.rs"); + } +} diff --git a/src/execution/plan.rs b/src/execution/plan.rs index f27bb70..0352958 100644 --- a/src/execution/plan.rs +++ b/src/execution/plan.rs @@ -5,12 +5,12 @@ use crate::scan; use std::collections::BTreeMap; use std::time::Instant; -pub fn plan_syntax_materialization( +pub fn plan_materialization( request: &NativeSyntaxMaterializationRequest, ) -> Result { let mut phase_timings = BTreeMap::new(); let scan_started = Instant::now(); - let scan = scan::scan_source_state(request)?; + let scan = scan::scan_sources(request)?; phase_timings.insert("scan_seconds".to_string(), elapsed_seconds(scan_started)); Ok(NativeSyntaxMaterializationResponse::skipped( scan.snapshots, diff --git a/src/execution/run.rs b/src/execution/run.rs index 5d57814..0555248 100644 --- a/src/execution/run.rs +++ b/src/execution/run.rs @@ -1,4 +1,4 @@ -use super::parallel::build_partitions; +use super::parallel::build_execution_plan; use super::timing::elapsed_seconds; use crate::error::NativeError; use crate::protocol::{ @@ -9,13 +9,21 @@ use crate::{scan, semantic_enrichment, staging_writer}; use std::collections::{BTreeMap, BTreeSet}; use std::time::Instant; -pub fn materialize_syntax_batch( +pub fn execute_materialization_pipeline( request: &NativeSyntaxMaterializationRequest, ) -> Result { let mut phase_timings = BTreeMap::new(); let scan_started = Instant::now(); - let scan = scan::scan_source_state(request)?; + let scan = scan::scan_sources(request)?; phase_timings.insert("scan_seconds".to_string(), elapsed_seconds(scan_started)); + execute_scanned_materialization(scan, phase_timings) +} + +fn execute_scanned_materialization( + scan: scan::SourceScan, + mut phase_timings: BTreeMap, +) -> Result { + let request = &scan.input; let diff = scan.diff.clone(); if diff.rebuild_paths().is_empty() && diff.deleted.is_empty() { return Ok(NativeSyntaxMaterializationResponse::skipped( @@ -41,7 +49,7 @@ pub fn materialize_syntax_batch( let (retained_nodes, retained_edges) = retained_manifest_ids(request, &diff, &rebuild_paths); let mut partitions = Vec::new(); - for (index, result) in build_partitions(request, &scan, &rebuild_paths)? + for (index, result) in build_execution_plan(&scan, &rebuild_paths)? .into_iter() .enumerate() { @@ -62,7 +70,7 @@ pub fn materialize_syntax_batch( phase_timings.insert("parse_seconds".to_string(), parse_seconds); phase_timings.insert("graph_build_seconds".to_string(), graph_build_seconds); - let semantic_stats = semantic_enrichment::enrich_partitions(&mut partitions, request)?; + let semantic_stats = semantic_enrichment::enrich_semantics(&mut partitions, request)?; for (phase, seconds) in semantic_stats.phase_timings { phase_timings.insert(phase, seconds); } @@ -107,6 +115,13 @@ pub fn materialize_syntax_batch( phase_timings, ); response.progress_events = progress_events; + let graph_write_started = Instant::now(); + staging_writer::write_graph_rows(request, &response).map_err(NativeError::InvalidInput)?; + response.phase_timings.insert( + "database_write_seconds".to_string(), + elapsed_seconds(graph_write_started), + ); + response.database_written = true; Ok(response) } @@ -139,11 +154,67 @@ fn retained_manifest_ids( (retained_nodes, retained_edges) } -pub fn materialize_syntax_batch_json(payload: &str) -> Result { - let decode_started = Instant::now(); - let request: NativeSyntaxMaterializationRequest = serde_json::from_str(payload)?; - let json_decode_seconds = elapsed_seconds(decode_started); - let mut response = materialize_syntax_batch(&request)?; - response.add_phase_timing("rust_json_decode_seconds", json_decode_seconds); - Ok(serde_json::to_string(&response)?) +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{NativeSyntaxMaterializationRequest, OntologySchema}; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn scanned_payload_completes_enrichment_and_graph_write_after_source_removal() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!("codebase-graph-scanned-pipeline-{nonce}")); + let source_root = root.join("repository"); + let state_root = root.join("state"); + fs::create_dir_all(source_root.join("src")).expect("source directory should be created"); + fs::create_dir_all(&state_root).expect("state directory should be created"); + fs::write( + source_root.join("src/lib.rs"), + "pub fn scanned_pipeline() -> bool { true }\n", + ) + .expect("source file should be written"); + let db_path = state_root.join("graph.ldb"); + let request = NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().into_owned(), + repository_label: "scanned-pipeline".to_string(), + mode: "full".to_string(), + parser_version: "test".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: OntologySchema::default(), + previous_manifest: None, + profiles: Vec::new(), + excluded_parts: Vec::new(), + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + ignore_patterns: Vec::new(), + candidate_paths: Vec::new(), + db_path: db_path.to_string_lossy().into_owned(), + include_fts: false, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + schema_statements: Vec::new(), + staging_dir: state_root.join("staging").to_string_lossy().into_owned(), + atomic_rebuild: false, + strict: true, + parallel: false, + progress: false, + }; + let mut timings = BTreeMap::new(); + let scan = crate::scan::scan_sources(&request).expect("scan should succeed"); + timings.insert("scan_seconds".to_string(), 0.0); + + fs::remove_dir_all(&source_root).expect("source repository should be removed"); + + let response = execute_scanned_materialization(scan, timings) + .expect("scanned payload should complete the pipeline"); + assert!(response.database_written); + assert_eq!(response.rebuilt_entries.len(), 1); + assert!(db_path.exists()); + let _ = fs::remove_dir_all(root); + } } diff --git a/src/lib.rs b/src/lib.rs index 138149b..7af0f54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod cli; pub mod db_writer; pub mod error; @@ -14,6 +15,6 @@ mod semantic_enrichment; mod staging_writer; mod syntax_materializer; -pub use execution::{ - materialize_syntax_batch, materialize_syntax_batch_json, plan_syntax_materialization, -}; +pub use error::MaterializationError; +pub use execution::{execute_materialization_pipeline, plan_materialization}; +pub use protocol::{MaterializationInput, MaterializationResult}; diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 97fa3fe..f9fd419 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -6,7 +6,6 @@ mod tree_sitter; use crate::error::NativeError; use crate::normalize::SyntaxNode; use crate::protocol::{LanguageProfile, SourceSnapshot}; -use std::fs; #[derive(Debug, Clone)] pub(crate) struct ParseOutput { @@ -18,8 +17,13 @@ pub(crate) fn parse_file( snapshot: &SourceSnapshot, profile: &LanguageProfile, ) -> Result { - let source = fs::read_to_string(&snapshot.absolute_path)?; - parse_source(&source, profile) + let source = snapshot.source.as_deref().ok_or_else(|| { + NativeError::InvalidInput(format!( + "missing source content for scanned file: {}", + snapshot.path + )) + })?; + parse_source(source, profile) } fn parse_source(source: &str, profile: &LanguageProfile) -> Result { diff --git a/src/profiles.rs b/src/profiles.rs index 232dbd2..d902c52 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -32,9 +32,20 @@ impl ProfileSet { .cloned() } + #[cfg(test)] pub(crate) fn profile_for_language(&self, language: &str) -> Option<&LanguageProfile> { self.by_language.get(language) } + + pub(crate) fn selected_profiles( + &self, + languages: &std::collections::BTreeSet, + ) -> Vec { + languages + .iter() + .filter_map(|language| self.by_language.get(language).cloned()) + .collect() + } } fn base_profiles() -> Vec { diff --git a/src/protocol.rs b/src/protocol.rs index 71f615c..71cf7b0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -43,6 +43,8 @@ pub struct NativeSyntaxMaterializationRequest { pub progress: bool, } +pub type MaterializationInput = NativeSyntaxMaterializationRequest; + fn default_semantic_provider_mode() -> String { "local_only".to_string() } @@ -127,6 +129,8 @@ pub struct SourceSnapshot { pub absolute_path: String, pub content_hash: String, pub language: Option, + #[serde(skip)] + pub source: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -147,7 +151,7 @@ impl ManifestDiff { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct NativeSyntaxMaterializationResponse { pub snapshots: BTreeMap, pub diff: ManifestDiff, @@ -165,13 +169,15 @@ pub struct NativeSyntaxMaterializationResponse { pub database_written: bool, } -#[derive(Debug, Clone, Default, Serialize)] +pub type MaterializationResult = NativeSyntaxMaterializationResponse; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct GraphSummary { pub node_count: usize, pub edge_count: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ProgressEvent { pub phase: String, pub current: usize, @@ -231,10 +237,6 @@ impl NativeSyntaxMaterializationResponse { database_written: false, } } - - pub(crate) fn add_phase_timing(&mut self, phase: &str, seconds: f64) { - self.phase_timings.insert(phase.to_string(), seconds); - } } fn manifest_files_from_any<'de, D>( diff --git a/src/scan.rs b/src/scan.rs index ee9b44f..d3a7172 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -1,19 +1,24 @@ use crate::error::NativeError; use crate::hash; use crate::profiles::ProfileSet; -use crate::protocol::{ManifestDiff, NativeSyntaxMaterializationRequest, SourceSnapshot}; +use crate::protocol::{ + LanguageProfile, ManifestDiff, NativeSyntaxMaterializationRequest, SourceSnapshot, +}; use std::collections::{BTreeMap, BTreeSet}; +use std::fs; use std::path::{Path, PathBuf}; use walkdir::WalkDir; pub(crate) struct SourceScan { + pub(crate) input: NativeSyntaxMaterializationRequest, + pub(crate) profiles: Vec, pub(crate) snapshots: BTreeMap, pub(crate) supported: BTreeMap, pub(crate) diagnostics: Vec, pub(crate) diff: ManifestDiff, } -pub(crate) fn scan_source_state( +pub(crate) fn scan_sources( request: &NativeSyntaxMaterializationRequest, ) -> Result { let source_root = PathBuf::from(&request.source_root); @@ -60,11 +65,17 @@ pub(crate) fn scan_source_state( if language.is_none() { diagnostics.push(format!("Skipped unsupported file: {relative_path}")); } + let source = if language.is_some() { + Some(fs::read_to_string(path)?) + } else { + None + }; let snapshot = SourceSnapshot { path: relative_path.clone(), absolute_path: path.to_string_lossy().to_string(), content_hash, language, + source, }; if snapshot.language.is_some() { supported.insert(relative_path.clone(), snapshot.clone()); @@ -73,7 +84,13 @@ pub(crate) fn scan_source_state( } let diff = compute_diff(request, &supported, &candidate_paths); + let selected_languages = supported + .values() + .filter_map(|snapshot| snapshot.language.clone()) + .collect::>(); Ok(SourceScan { + input: request.clone(), + profiles: profiles.selected_profiles(&selected_languages), snapshots, supported, diagnostics, diff --git a/src/semantic_enrichment/mod.rs b/src/semantic_enrichment/mod.rs index 2822d43..4d89a99 100644 --- a/src/semantic_enrichment/mod.rs +++ b/src/semantic_enrichment/mod.rs @@ -34,7 +34,7 @@ struct SemanticOutput { fallbacks: Vec, } -pub(crate) fn enrich_partitions( +pub(crate) fn enrich_semantics( partitions: &mut [GraphPartition], request: &NativeSyntaxMaterializationRequest, ) -> Result { @@ -296,7 +296,7 @@ mod tests { progress: false, }; - let stats = enrich_partitions(&mut partitions, &request).unwrap(); + let stats = enrich_semantics(&mut partitions, &request).unwrap(); assert!(stats .phase_timings diff --git a/src/staging_writer/mod.rs b/src/staging_writer/mod.rs index 0b590d6..983f83b 100644 --- a/src/staging_writer/mod.rs +++ b/src/staging_writer/mod.rs @@ -5,9 +5,11 @@ mod merge; mod ordering; mod result; mod rows; +mod writer; pub(crate) use accumulator::StagingAccumulator; pub(crate) use result::StagingResult; +pub(crate) use writer::write_graph_rows; #[cfg(test)] mod tests; diff --git a/src/staging_writer/writer.rs b/src/staging_writer/writer.rs new file mode 100644 index 0000000..16e3826 --- /dev/null +++ b/src/staging_writer/writer.rs @@ -0,0 +1,33 @@ +use crate::api::catalog::schema_statements_from_copy_statements; +use crate::db_writer::{ + incoming_row_delete_statements, partition_delete_statements, write_database, + LadybugWriteRequest, +}; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; + +pub(crate) fn write_graph_rows( + request: &NativeSyntaxMaterializationRequest, + response: &NativeSyntaxMaterializationResponse, +) -> Result<(), String> { + let schema_statements = if request.schema_statements.is_empty() { + schema_statements_from_copy_statements(request.include_fts, &response.copy_statements) + } else { + request.schema_statements.clone() + }; + let mut delete_statements = + partition_delete_statements(request.previous_manifest.as_ref(), &response.diff); + delete_statements.extend(incoming_row_delete_statements( + request.previous_manifest.as_ref(), + &response.diff, + &response.rebuilt_entries, + )); + write_database(LadybugWriteRequest { + db_path: request.db_path.clone(), + include_fts: request.include_fts, + schema_statements, + replace_database: response.diff.force_rebuild, + delete_statements, + copy_statements: response.copy_statements.clone(), + }) + .map_err(|error| error.to_string()) +} From b4ec6e2386e4c499ecd65b6d59a53b5066c501ba Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 23 Jul 2026 23:22:40 +0930 Subject: [PATCH 03/19] refactor(api): centralize transport-neutral execution policy Move request normalization, repository runtime resolution, materialization preparation, refresh coordination, graph reads, lifecycle work, and shared presentation behind the API boundary. Reduce CLI and MCP modules to parsing, public invocation, and response framing. Constraint: Preserve existing CLI flags, MCP tool contracts, result shapes, and storage formats Rejected: Retain CLI compatibility wrappers | would preserve reverse dependencies and duplicated policy Confidence: high Scope-risk: broad Reversibility: clean Directive: Keep src/api free of crate::cli imports and route new transport behavior through public contracts Tested: cargo fmt --check; cargo clippy --all-targets --all-features -- -D warnings; cargo test --all-targets --all-features Not-tested: none --- .../api-adapter-boundary-centralization.md | 217 ++ src/api/boundary_tests.rs | 35 +- src/api/catalog.rs | 15 +- .../schema.rs => api/catalog_support.rs} | 85 +- src/api/context.rs | 23 +- src/api/core.rs | 154 +- .../graph/search.rs => api/graph_read.rs} | 313 ++- src/api/lifecycle.rs | 1989 ++++++++++++++++- src/api/materialization.rs | 596 ++++- src/api/mod.rs | 3 + src/api/normalization.rs | 346 +++ src/api/presentation_support.rs | 483 ++++ src/api/presenter.rs | 10 +- src/api/refresh.rs | 1363 +++++++++++ src/cli/constants.rs | 4 - src/cli/format/blocks.rs | 399 +--- src/cli/format/metadata.rs | 34 - src/cli/format/mod.rs | 11 +- src/cli/graph/health.rs | 86 - src/cli/graph/mod.rs | 8 - src/cli/graph/options.rs | 33 +- src/cli/graph/query.rs | 216 -- src/cli/install/mod.rs | 4 +- src/cli/install/render.rs | 188 -- src/cli/materialization/command.rs | 68 +- src/cli/materialization/manifest.rs | 81 - src/cli/materialization/mod.rs | 10 +- src/cli/materialization/output.rs | 124 - src/cli/materialization/request.rs | 275 +-- src/cli/mcp/options.rs | 13 +- src/cli/mcp/refresh.rs | 493 +--- src/cli/mcp/tools.rs | 59 +- src/cli/reinstall.rs | 209 +- src/cli/setup.rs | 505 +---- src/cli/tests/dispatch_materialize.rs | 24 +- src/cli/uninstall.rs | 260 +-- src/cli/util.rs | 34 +- src/cli/watch/batch.rs | 76 - src/cli/watch/command.rs | 131 +- src/cli/watch/filter.rs | 135 -- src/cli/watch/helpers.rs | 64 - src/cli/watch/mod.rs | 26 +- src/cli/watch/native.rs | 176 -- src/cli/watch/options.rs | 25 +- src/cli/watch/poll.rs | 96 - src/cli/watch/refresh.rs | 183 -- src/cli/watch/snapshot.rs | 129 -- src/cli/watch/types.rs | 29 - 48 files changed, 5561 insertions(+), 4279 deletions(-) create mode 100644 .omx/plans/api-adapter-boundary-centralization.md rename src/{cli/format/schema.rs => api/catalog_support.rs} (75%) rename src/{cli/graph/search.rs => api/graph_read.rs} (58%) create mode 100644 src/api/normalization.rs create mode 100644 src/api/presentation_support.rs create mode 100644 src/api/refresh.rs delete mode 100644 src/cli/format/metadata.rs delete mode 100644 src/cli/graph/health.rs delete mode 100644 src/cli/graph/query.rs delete mode 100644 src/cli/materialization/manifest.rs delete mode 100644 src/cli/materialization/output.rs delete mode 100644 src/cli/watch/batch.rs delete mode 100644 src/cli/watch/filter.rs delete mode 100644 src/cli/watch/helpers.rs delete mode 100644 src/cli/watch/native.rs delete mode 100644 src/cli/watch/poll.rs delete mode 100644 src/cli/watch/refresh.rs delete mode 100644 src/cli/watch/snapshot.rs delete mode 100644 src/cli/watch/types.rs diff --git a/.omx/plans/api-adapter-boundary-centralization.md b/.omx/plans/api-adapter-boundary-centralization.md new file mode 100644 index 0000000..a77b39a --- /dev/null +++ b/.omx/plans/api-adapter-boundary-centralization.md @@ -0,0 +1,217 @@ +# API and Adapter Boundary Centralization + +## Scryer change + +- Change: `chg-1` +- Intent: centralize request normalization, repository runtime resolution, + materialization preparation, and refresh policy under the transport-neutral + API; reduce CLI and MCP adapters to transport parsing and response framing. +- Model status: structurally valid; implementation remains pending. + +## Scope + +### In scope + +- Add canonical request defaulting and semantic validation under `src/api/**`. +- Resolve one repository runtime context per public operation under + `src/api/**`. +- Prepare materialization inputs, configuration, and manifest persistence under + `src/api/**`. +- Share refresh batching, filtering, retry/backoff, watcher selection, and + refresh state between CLI and MCP under `src/api/**`. +- Remove reverse dependencies from `src/api/**` into `src/cli/**`. +- Keep CLI and MCP modules responsible for transport syntax, invocation, and + output/protocol framing. +- Preserve existing CLI flags, MCP tool names and schemas, result shapes, error + codes, and observable refresh behavior. + +### Out of scope + +- Changing graph query or materialization engine semantics. +- Renaming CLI commands, flags, or MCP tools. +- Changing storage schemas or graph database formats. +- Introducing a new dependency. +- Redesigning user-facing output. + +## Target ownership + +| Concern | API owner | Adapter responsibility | +| --- | --- | --- | +| Request defaults and semantic validation | `Request Normalizer` in `src/api/normalization.rs` | Parse CLI arguments or MCP JSON into public request contracts | +| Repository paths and execution context | `Repository Runtime Resolver` in `src/api/context.rs` | Pass repository selection from the transport | +| Materialization input preparation and manifest persistence | `Materialization API` in `src/api/materialization.rs` | Parse build/plan inputs and frame results | +| Refresh filtering, batching, retries, watcher choice, and state | `Repository Refresh Service` in `src/api/refresh.rs` | Parse watch/refresh inputs and frame status/events | +| Public result formatting primitives | Existing API presenter/catalog components | Render terminal or MCP protocol envelopes only | + +## Requirements + +1. Every public operation enters the facade as a public request contract, then + receives canonical defaults and semantic validation exactly once. +2. Runtime resolution produces one canonical source root, graph path, + configuration, and manifest context for the selected repository. +3. Materialization preparation reads and converts request/configuration inputs + once, and both build and dry-run plan execution consume that prepared input. +4. CLI watch and MCP auto-refresh use the same event filtering, batch bounds, + retry classification, backoff, watcher selection, and refresh state model. +5. `src/api/**` has no dependency on `src/cli/**`; adapter modules do not call + graph, materialization, or refresh implementation internals directly. +6. Transport adapters retain only syntax/protocol parsing, invocation of the + public API, and response framing. + +## Acceptance criteria + +- `rg -n "crate::cli" src/api` returns no matches. +- Boundary tests fail if API modules import CLI modules or if CLI/MCP transport + modules bypass the facade to invoke graph, materialization, or refresh + internals. +- Equivalent CLI and MCP requests normalize to the same public request and + produce the same `ApiError` code for invalid semantic input. +- MCP generated schemas and runtime validation agree on required fields and + accepted defaults. +- Repository selection is resolved once per operation; graph reads, + materialization, health, and refresh use the same resolved graph and manifest + paths. +- Materialization preparation does not duplicate configuration reads, source + scans, include/exclude resolution, graph builds, or manifest writes. +- CLI watch and MCP refresh pass the same tests for event filtering, + coalescing, transient retry/backoff, permanent failures, and status + transitions. +- Existing CLI output snapshots and MCP protocol/result tests remain unchanged + unless an existing test encodes the architectural defect itself. +- `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, + and `cargo test --all-targets --all-features` pass. +- Scryer `chg-1` is folded only with source anchors and genuine tests attached + to each conditional symbol claim. + +## Implementation sequence + +### 1. Lock the transport-neutral behavior + +Add or strengthen regression coverage before moving ownership: + +- Extend `src/api/boundary_tests.rs` with both dependency directions: + API must not import CLI, and CLI/MCP must not bypass the public facade. +- Extend `src/cli/tests/graph.rs` and `src/cli/tests/mcp.rs` with equivalent + request/error cases. +- Extend `src/cli/tests/dispatch_materialize.rs` with build-versus-plan + preparation parity and single-preparation assertions. +- Extend `src/cli/tests/watch.rs` with shared policy cases for filtering, + batching, retry, and state transitions. +- Preserve repository auto-detection coverage in `tests/auto_repo_root.rs`. + +These tests establish the compatibility baseline for the ownership move. + +### 2. Centralize request normalization + +- Create `src/api/normalization.rs` implementing the planned + `normalize_request` and `validate_request` symbols over public contracts from + `src/api/contracts.rs`. +- Route all facade/core dispatch through normalization in `src/api/core.rs` and + `src/api/facade.rs`. +- Keep only syntactic extraction in `src/cli/graph/options.rs`, + `src/cli/watch/options.rs`, and `src/cli/mcp/tools.rs`. +- Generate or validate MCP schemas from the same public contract rules so + required fields cannot diverge from runtime behavior. +- Remove adapter-local semantic defaults and validation after parity tests pass. + +### 3. Make runtime resolution canonical + +- Expand `src/api/context.rs` into the planned Repository Runtime Resolver, + owning `RepoRuntime` and `resolve_runtime`. +- Move repository source, graph, configuration, and manifest path selection out + of CLI helpers. +- Delete the separate + `src/cli/graph/health.rs::resolve_health_runtime` path and route health through + the same resolver. +- Update materialization and refresh entry points to accept the resolved API + context rather than recomputing paths. +- Verify explicit repository selection and auto-detected repository selection + resolve identically across CLI and MCP. + +### 4. Move materialization preparation into the API + +- Consolidate request conversion, configuration preparation, source metadata, + and manifest persistence in `src/api/materialization.rs`. +- Move the responsibilities currently implemented by + `src/cli/materialization/request.rs` and + `src/cli/materialization/manifest.rs` behind public API inputs and outputs. +- Keep `src/cli/materialization/command.rs` limited to parsing build/plan + transport input, invoking the facade, and selecting an output frame. +- Keep `src/cli/materialization/output.rs` limited to terminal presentation. +- Ensure build and dry-run plan share one preparation path and neither performs + a hidden second graph build or configuration read. + +### 5. Introduce the shared refresh service + +- Create `src/api/refresh.rs` for the planned Repository Refresh Service. +- Move event filtering, batch collection, polling/native watcher coordination, + retry classification/backoff, and refresh state from `src/cli/watch/**` and + `src/cli/mcp/refresh.rs`. +- Expose transport-neutral one-shot and continuous refresh operations through + public contracts and the facade. +- Reduce `src/cli/watch/command.rs` to watch-command parsing, API invocation, and + event/status framing. +- Reduce `src/cli/mcp/refresh.rs` to MCP lifecycle wiring and protocol framing; + it must not own a separate refresh algorithm or policy. +- Preserve cancellation, shutdown, and read/write coordination semantics with + focused concurrency tests. + +### 6. Remove the remaining reverse dependencies + +- Move any reusable catalog or presentation implementation still imported from + `src/cli/**` behind `src/api/catalog.rs` and `src/api/presenter.rs`. +- Re-export the new API services through `src/api/mod.rs`. +- Delete obsolete adapter wrappers and duplicate helpers only after callers and + regression tests have migrated. +- Run the boundary checks before each deletion to avoid replacing direct + duplication with a dependency cycle. + +### 7. Verify and close the Scryer change + +Run, in order: + +```text +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +rg -n "crate::cli" src/api +``` + +Then: + +- Re-run Scryer structural validation. +- Inspect drift for every moved symbol. +- Fold `chg-1` responsibility-by-responsibility with exact source anchors. +- Attach tests for request rejection, runtime resolution, refresh batch + coalescing, and transient refresh retry to their conditional claims. +- Confirm the pending queue for `chg-1` is empty and the committed model is + healthy. + +## Risks and mitigations + +- **Transport behavior drift:** lock CLI snapshots and MCP result/error tests + before moving code. +- **Over-generalized normalization:** normalize public contracts by operation; + do not build a stringly typed transport abstraction. +- **Runtime path divergence:** make resolved runtime context immutable for the + duration of an operation and pass it downward. +- **Refresh lifecycle regressions:** preserve cancellation and guard ownership + with deterministic state-transition and concurrency tests. +- **Materialization double work:** instrument preparation in tests and assert a + single preparation/build path. +- **Dependency-cycle substitution:** enforce both API-to-adapter and + adapter-to-internal boundary tests continuously. + +## Scryer model delta + +The plan adds three API components: + +- `Request Normalizer` (`node-142`) +- `Repository Runtime Resolver` (`node-143`) +- `Repository Refresh Service` (`node-144`) + +It moves runtime, materialization-preparation, and refresh symbols to those API +owners, rewords CLI/MCP adapter responsibilities around parsing and framing, +deletes the duplicate health runtime resolver, and links both adapters to the +shared refresh service. The pending implementation queue is intentionally kept +under `chg-1`. diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs index 3bd7024..e1154c9 100644 --- a/src/api/boundary_tests.rs +++ b/src/api/boundary_tests.rs @@ -1,3 +1,33 @@ +#[test] +fn api_does_not_import_transport_adapters() { + let api_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/api"); + let mut pending = vec![api_root]; + let forbidden = ["crate", "::cli"].concat(); + + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(&path).expect("API directory should be readable") { + let path = entry + .expect("API directory entry should be readable") + .path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") + || path.file_name().and_then(std::ffi::OsStr::to_str) == Some("boundary_tests.rs") + { + continue; + } + let source = std::fs::read_to_string(&path).expect("API source should be readable"); + assert!( + !source.contains(&forbidden), + "{} imports a CLI transport module", + path.display() + ); + } + } +} + #[test] fn transport_adapters_do_not_import_product_execution_services() { let adapters = [ @@ -8,7 +38,6 @@ fn transport_adapters_do_not_import_product_execution_services() { ), ("MCP tools", include_str!("../cli/mcp/tools.rs")), ("watch command", include_str!("../cli/watch/command.rs")), - ("watch refresh", include_str!("../cli/watch/refresh.rs")), ("MCP refresh", include_str!("../cli/mcp/refresh.rs")), ]; let forbidden = [ @@ -23,6 +52,10 @@ fn transport_adapters_do_not_import_product_execution_services() { ["execute", "_materialization_pipeline"].concat(), ["execute", "_graph_search"].concat(), ["execute", "_read_only_query"].concat(), + ["execute", "_refresh_operation"].concat(), + ["start", "_native_watcher"].concat(), + ["run", "_poll_watch"].concat(), + ["resolve", "_source_root"].concat(), ["write", "_database("].concat(), ]; diff --git a/src/api/catalog.rs b/src/api/catalog.rs index 19c6805..7131151 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -1,6 +1,11 @@ -use crate::cli::constants::{ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, QUERY_HELPERS_JSON}; +#[path = "catalog_support.rs"] +pub(crate) mod support; -pub(crate) use crate::cli::format::schema_statements_from_copy_statements; +pub(crate) use self::support::schema_statements_from_copy_statements; +use self::support::{ + filter_architecture_group, metadata_payload, ARCHITECTURE_QUERIES_JSON, GRAPH_SCHEMA_JSON, + QUERY_HELPERS_JSON, +}; pub fn load_catalog(kind: &str) -> Result { let source = match kind { @@ -9,7 +14,7 @@ pub fn load_catalog(kind: &str) -> Result { "architecture-queries" => ARCHITECTURE_QUERIES_JSON, _ => return Err(format!("unknown catalog kind: {kind}")), }; - serde_json::from_str(source) + metadata_payload(source) .map_err(|error| format!("failed to parse embedded catalog {kind}: {error}")) } @@ -19,9 +24,7 @@ pub fn filter_catalog( group: Option<&str>, ) -> Result<(), String> { match (kind, group) { - ("architecture-queries", Some(group)) => { - crate::cli::format::filter_architecture_group(payload, group) - } + ("architecture-queries", Some(group)) => filter_architecture_group(payload, group), (_, Some(_)) => Err(format!("catalog {kind} does not support group filtering")), (_, None) => Ok(()), } diff --git a/src/cli/format/schema.rs b/src/api/catalog_support.rs similarity index 75% rename from src/cli/format/schema.rs rename to src/api/catalog_support.rs index 9af12d7..d76887f 100644 --- a/src/cli/format/schema.rs +++ b/src/api/catalog_support.rs @@ -1,7 +1,45 @@ -use super::{metadata_payload, value_array, value_str}; -use crate::cli::{constants::GRAPH_SCHEMA_JSON, graph::cypher_single_quoted}; use std::collections::BTreeSet; +pub(crate) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); +pub(crate) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); +pub(crate) const ARCHITECTURE_QUERIES_JSON: &str = + include_str!("../../assets/architecture_queries.json"); + +pub(crate) fn metadata_payload(source: &str) -> Result { + serde_json::from_str(source) + .map_err(|error| format!("failed to parse embedded metadata: {error}")) +} + +pub(crate) fn filter_architecture_group( + payload: &mut serde_json::Value, + group: &str, +) -> Result<(), String> { + let groups = payload + .get("groups") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let selected: Vec = groups + .iter() + .filter(|value| value.get("name").and_then(serde_json::Value::as_str) == Some(group)) + .cloned() + .collect(); + if selected.is_empty() { + let valid = groups + .iter() + .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) + .collect::>() + .join(", "); + return Err(format!( + "Unknown architecture query group: {group}. Valid groups: {valid}" + )); + } + if let Some(object) = payload.as_object_mut() { + object.insert("groups".to_string(), serde_json::Value::Array(selected)); + } + Ok(()) +} + pub(crate) fn schema_statements_from_copy_statements( include_fts: bool, copy_statements: &[String], @@ -121,7 +159,7 @@ fn string_array(value: &serde_json::Value, key: &str) -> Vec { .collect() } -pub(in crate::cli) fn fts_index_statements(node_tables: &[String]) -> Vec { +fn fts_index_statements(node_tables: &[String]) -> Vec { let Ok(schema) = metadata_payload(GRAPH_SCHEMA_JSON) else { return Vec::new(); }; @@ -158,7 +196,7 @@ pub(in crate::cli) fn fts_index_statements(node_tables: &[String]) -> Vec BTreeSet { +fn copy_tables(copy_statements: &[String]) -> BTreeSet { copy_statements .iter() .filter_map(|statement| { @@ -170,7 +208,7 @@ pub(in crate::cli) fn copy_tables(copy_statements: &[String]) -> BTreeSet) -> BTreeSet { +fn relation_names(tables: &BTreeSet) -> BTreeSet { let mut relations = BTreeSet::new(); for table in tables { if let Some(name) = table.strip_prefix("FROM_") { @@ -183,10 +221,7 @@ pub(in crate::cli) fn relation_names(tables: &BTreeSet) -> BTreeSet, -) -> String { +fn node_table_sql(table: &str, fields: Vec<(&'static str, &'static str)>) -> String { let columns: Vec = fields .into_iter() .map(|(name, value_type)| { @@ -200,7 +235,7 @@ pub(in crate::cli) fn node_table_sql( ) } -pub(in crate::cli) fn relation_table_sql( +fn relation_table_sql( table: &str, from_tables: &[String], to_tables: &[String], @@ -222,7 +257,7 @@ pub(in crate::cli) fn relation_table_sql( ) } -pub(in crate::cli) fn node_fields(table: &str) -> Vec<(&'static str, &'static str)> { +fn node_fields(table: &str) -> Vec<(&'static str, &'static str)> { let mut fields = common_node_fields(); if table == "File" { fields.push(("content_hash", "STRING")); @@ -230,7 +265,7 @@ pub(in crate::cli) fn node_fields(table: &str) -> Vec<(&'static str, &'static st fields } -pub(in crate::cli) fn common_node_fields() -> Vec<(&'static str, &'static str)> { +fn common_node_fields() -> Vec<(&'static str, &'static str)> { vec![ ("id", "STRING"), ("label", "STRING"), @@ -250,7 +285,7 @@ pub(in crate::cli) fn common_node_fields() -> Vec<(&'static str, &'static str)> ] } -pub(in crate::cli) fn edge_fields() -> Vec<(&'static str, &'static str)> { +fn edge_fields() -> Vec<(&'static str, &'static str)> { vec![ ("id", "STRING"), ("kind", "STRING"), @@ -259,8 +294,28 @@ pub(in crate::cli) fn edge_fields() -> Vec<(&'static str, &'static str)> { ("confidence", "DOUBLE"), ("line_start", "INT64"), ("line_end", "INT64"), - ("byte_start", "INT64"), - ("byte_end", "INT64"), ("metadata", "JSON"), ] } + +pub(crate) fn value_array<'a>( + payload: &'a serde_json::Value, + key: &str, +) -> &'a [serde_json::Value] { + payload + .get(key) + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +pub(crate) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { + payload + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") +} + +pub(crate) fn cypher_single_quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} diff --git a/src/api/context.rs b/src/api/context.rs index 620bf69..2529ae1 100644 --- a/src/api/context.rs +++ b/src/api/context.rs @@ -12,12 +12,15 @@ pub(crate) struct RepoRuntime { #[derive(Debug, Clone)] pub(crate) struct RepoPaths { - pub db_path: PathBuf, - pub manifest_path: PathBuf, + pub(crate) repo_name: String, + pub(crate) state_dir: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) config_path: PathBuf, } impl RepoPaths { - fn derive(repo_root: &Path) -> Self { + pub(crate) fn derive(repo_root: &Path) -> Self { let repo_name = safe_name( repo_root .file_name() @@ -26,8 +29,11 @@ impl RepoPaths { ); let state_dir = repo_root.join(".codebaseGraph"); Self { + repo_name: repo_name.clone(), + state_dir: state_dir.clone(), db_path: state_dir.join(format!("{repo_name}_graph.ldb")), manifest_path: state_dir.join("manifest.json"), + config_path: state_dir.join("config.json"), } } } @@ -38,7 +44,7 @@ pub(crate) fn resolve_runtime(selector: &RepoSelector) -> Result Result { + let request = normalize_request(request); + validate_request(&request)?; let operation = self .resolve_operation(request.operation_name()) .ok_or_else(|| { @@ -185,7 +185,7 @@ impl ApiCore { .map(resolve_runtime) .transpose() .map_err(|error| ApiError::new("runtime_resolution_failed", error))?; - let response = dispatch_operation(request, &operation, runtime.as_ref())?; + let response = dispatch_operation(&request, &operation, runtime.as_ref())?; Ok(present_operation_response( response, request.output_format(), @@ -509,14 +509,8 @@ fn execute_search( request: &SearchRequest, runtime: &RepoRuntime, ) -> Result { - validate_search_request( - &request.query, - &request.detail, - request.limit, - request.context_limit, - )?; let output_format = request.output_format; - let options = crate::cli::graph::GraphSearchOptions { + let options = GraphSearchRequest { query: request.query.clone(), limit: request.limit, profile: request.profile.clone(), @@ -524,19 +518,6 @@ fn execute_search( context_limit: request.context_limit, max_depth: request.max_depth, detail: request.detail.clone(), - repo_root: Some(runtime.repo_root.clone()), - config: runtime.config_path.clone(), - db: Some(runtime.db_path.clone()), - manifest: Some(runtime.manifest_path.clone()), - output: crate::cli::graph::MetadataOutputOptions { - format: if matches!(output_format, OutputFormat::Typed) { - "json".to_string() - } else { - "block".to_string() - }, - pretty: false, - help: false, - }, }; let results = execute_graph_search(&runtime.db_path, &options) .map_err(|error| ApiError::new("search_execution_failed", error.to_string()))?; @@ -558,28 +539,8 @@ fn execute_context( request: &ContextRequest, runtime: &RepoRuntime, ) -> Result { - validate_search_request( - request.query.as_deref().unwrap_or_default(), - &request.detail, - request.limit, - request.context_limit, - ) - .or_else(|error| { - if request.node_id.is_some() && request.node_type.is_some() && error.code == "missing_query" - { - Ok(()) - } else { - Err(error) - } - })?; - if request.node_id.is_some() != request.node_type.is_some() { - return Err(ApiError::new( - "invalid_node_reference", - "context operation requires both node_id and node_type", - )); - } let output_format = request.output_format; - let search = crate::cli::graph::GraphSearchOptions { + let search = GraphSearchRequest { query: request.query.clone().unwrap_or_default(), profile: request.profile.clone(), limit: request.limit, @@ -587,19 +548,6 @@ fn execute_context( context_limit: request.context_limit, max_depth: request.max_depth, detail: request.detail.clone(), - repo_root: Some(runtime.repo_root.clone()), - config: runtime.config_path.clone(), - db: Some(runtime.db_path.clone()), - manifest: Some(runtime.manifest_path.clone()), - output: crate::cli::graph::MetadataOutputOptions { - format: if matches!(output_format, OutputFormat::Typed) { - "json".to_string() - } else { - "block".to_string() - }, - pretty: false, - help: false, - }, }; let payload = if let (Some(node_id), Some(node_type)) = (request.node_id.as_ref(), request.node_type.as_ref()) @@ -640,21 +588,14 @@ fn execute_query( request: &QueryRequest, runtime: &RepoRuntime, ) -> Result { - if request.limit == 0 || request.limit > 1000 { - return Err(ApiError::new( - "invalid_limit", - "graph_query limit must be between 1 and 1000", - )); - } validate_read_only_statement(&request.statement) .map_err(|error| ApiError::new("query_validation_failed", error.to_string()))?; let output_format = request.output_format; - let parameters = request.parameters.as_object().cloned().ok_or_else(|| { - ApiError::new( - "query_invalid_parameters", - "query parameters must be an object", - ) - })?; + let parameters = request + .parameters + .as_object() + .expect("normalized query parameters must be an object") + .clone(); let (rows, truncated) = execute_read_only_query( &runtime.db_path, &request.statement, @@ -675,33 +616,6 @@ fn execute_query( )) } -fn validate_search_request( - query: &str, - detail: &str, - limit: usize, - _context_limit: usize, -) -> Result<(), ApiError> { - if query.trim().is_empty() { - return Err(ApiError::new( - "missing_query", - "Search query must not be empty", - )); - } - if detail != "standard" && detail != "slim" { - return Err(ApiError::new( - "invalid_detail", - "detail must be standard or slim", - )); - } - if limit == 0 { - return Err(ApiError::new( - "invalid_limit", - "search limit must be greater than zero", - )); - } - Ok(()) -} - fn execute_materialization( request: &MaterializationRequest, runtime: &RepoRuntime, @@ -709,25 +623,10 @@ fn execute_materialization( ) -> Result { let output_format = request.output_format; let mut materialize_options = MaterializeOptions { - source_root: request - .source_root - .as_ref() - .map(std::path::PathBuf::from) - .or_else(|| Some(runtime.repo_root.clone())), - db: Some( - request - .repo - .db_path - .clone() - .unwrap_or_else(|| runtime.db_path.clone()), - ), - manifest: Some( - request - .repo - .manifest_path - .clone() - .unwrap_or_else(|| runtime.manifest_path.clone()), - ), + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), mode: request.mode.clone(), include_fts: request.include_fts, semantic_enrichment: request.semantic_enrichment, @@ -763,7 +662,7 @@ fn execute_materialization( .map_err(|error| ApiError::new("materialization_failed", error))? }; - let payload = materialization_payload(request, &response, &runtime.repo_root, dry_plan); + let payload = materialization_payload(request, &response, &runtime.manifest_path, dry_plan); let mut operation_response = OperationResponse::from_payload( if dry_plan { "plan" } else { "materialize" }, output_format, @@ -782,12 +681,11 @@ fn execute_plan_native( fn materialization_payload( request: &MaterializationRequest, response: &NativeSyntaxMaterializationResponse, - repo_root: &std::path::Path, + manifest_path: &std::path::Path, dry_plan: bool, ) -> serde_json::Value { if dry_plan { - let runtime_paths = crate::cli::setup::GraphStatePaths::derive(repo_root); - materialize_output_payload(response, &request.mode, &runtime_paths) + plan_materialization_payload(response, &request.mode, manifest_path) } else { serde_json::to_value(response).unwrap_or_else(|_| json!({})) } diff --git a/src/cli/graph/search.rs b/src/api/graph_read.rs similarity index 58% rename from src/cli/graph/search.rs rename to src/api/graph_read.rs index 89d2efc..6d959a3 100644 --- a/src/cli/graph/search.rs +++ b/src/api/graph_read.rs @@ -1,21 +1,40 @@ -use super::{ - options::GraphSearchOptions, - query::{ - cypher_identifier, cypher_single_quoted, span_json, value_to_f64, value_to_i64, - value_to_string, - }, +use crate::api::catalog::support::{metadata_payload, value_array, value_str, GRAPH_SCHEMA_JSON}; +use crate::db_writer::{ + connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, }; -use crate::cli::{ - constants::GRAPH_SCHEMA_JSON, - format::{metadata_payload, value_array, value_str}, -}; -use lbug::{Connection, Database, SystemConfig}; +use lbug::{Connection, Database, SystemConfig, Value}; use serde_json::json; use std::{collections::BTreeSet, path::Path}; +#[derive(Debug, Clone)] +pub(crate) struct GraphSearchRequest { + pub(crate) query: String, + pub(crate) limit: usize, + pub(crate) profile: String, + pub(crate) budget: usize, + pub(crate) context_limit: usize, + pub(crate) max_depth: Option, + pub(crate) detail: String, +} + +pub(crate) fn count_graph_nodes(db_path: &Path) -> Result { + let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) + .map_err(|error| error.to_string())?; + let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; + let mut result = conn + .query("MATCH (n) RETURN count(n) AS total_nodes LIMIT 1") + .map_err(|error| format!("failed to query graph health: {error}"))?; + let row = result + .next() + .ok_or_else(|| "graph health query returned no rows".to_string())?; + row.first() + .and_then(value_to_u64) + .ok_or_else(|| "graph health query returned a non-numeric node count".to_string()) +} + pub(crate) fn execute_graph_search( db_path: &Path, - options: &GraphSearchOptions, + options: &GraphSearchRequest, ) -> Result, String> { let db = Database::new(db_path, SystemConfig::default().read_only(true)).map_err(|error| { format!( @@ -89,7 +108,7 @@ pub(crate) fn execute_graph_context( db_path: &Path, node_id: &str, node_type: &str, - options: &GraphSearchOptions, + options: &GraphSearchRequest, ) -> Result, String> { if options.context_limit == 0 || options.budget == 0 { return Ok(Vec::new()); @@ -168,7 +187,231 @@ pub(crate) fn execute_graph_context( Ok(context) } -pub(in crate::cli) fn query_relation_neighbors( +pub(crate) fn validate_read_only_statement(statement: &str) -> Result<(), String> { + let stripped = statement.trim().trim_end_matches(';'); + if stripped.contains(';') { + return Err("graph_query accepts one read-only statement at a time".to_string()); + } + for keyword in [ + "ALTER", "ATTACH", "CALL", "COPY", "CREATE", "DELETE", "DETACH", "DROP", "EXPORT", + "IMPORT", "INSERT", "INSTALL", "LOAD", "MERGE", "REMOVE", "RENAME", "SET", "TRUNCATE", + "UPDATE", "USE", + ] { + if contains_keyword(stripped, keyword) { + return Err(format!( + "graph_query is read-only; blocked keyword: {keyword}" + )); + } + } + Ok(()) +} + +pub(crate) fn execute_read_only_query( + db_path: &Path, + statement: &str, + parameters: &serde_json::Map, + limit: usize, +) -> Result<(Vec>, bool), String> { + let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) + .map_err(|error| error.to_string())?; + let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; + let mut result = if parameters.is_empty() { + conn.query(statement) + .map_err(|error| format!("failed to execute graph query: {error}"))? + } else { + let named_parameters = lbug_query_parameters(parameters)?; + let mut prepared = conn + .prepare(statement) + .map_err(|error| format!("failed to prepare graph query: {error}"))?; + if !prepared.is_read_only() { + return Err("graph-query prepared statement is not read-only".to_string()); + } + let execute_parameters = named_parameters + .iter() + .map(|(name, value)| (name.as_str(), value.clone())) + .collect(); + conn.execute(&mut prepared, execute_parameters) + .map_err(|error| format!("failed to execute graph query: {error}"))? + }; + let mut rows = Vec::new(); + let mut truncated = false; + for row in result.by_ref().take(limit + 1) { + if rows.len() == limit { + truncated = true; + break; + } + rows.push(row.into_iter().map(json_safe_value).collect()); + } + Ok((rows, truncated)) +} + +pub(crate) fn cypher_single_quoted(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} + +fn cypher_identifier(value: &str) -> String { + value.replace('`', "``") +} + +fn span_json(line_start: Option, line_end: Option) -> serde_json::Value { + let mut span = serde_json::Map::new(); + if let Some(line_start) = line_start { + span.insert("line_start".to_string(), json!(line_start)); + } + if let Some(line_end) = line_end { + span.insert("line_end".to_string(), json!(line_end)); + } + serde_json::Value::Object(span) +} + +fn value_to_string(value: Option<&Value>) -> String { + match value { + Some(Value::String(value)) => value.clone(), + Some(Value::Int64(value)) => value.to_string(), + Some(Value::UInt64(value)) => value.to_string(), + Some(Value::Int32(value)) => value.to_string(), + Some(Value::UInt32(value)) => value.to_string(), + Some(Value::Null(_)) | None => String::new(), + Some(value) => value.to_string(), + } +} + +fn value_to_i64(value: Option<&Value>) -> Option { + match value { + Some(Value::Int64(value)) => Some(*value), + Some(Value::Int32(value)) => Some(i64::from(*value)), + Some(Value::Int16(value)) => Some(i64::from(*value)), + Some(Value::Int8(value)) => Some(i64::from(*value)), + Some(Value::UInt64(value)) => i64::try_from(*value).ok(), + Some(Value::UInt32(value)) => Some(i64::from(*value)), + Some(Value::UInt16(value)) => Some(i64::from(*value)), + Some(Value::UInt8(value)) => Some(i64::from(*value)), + _ => None, + } +} + +fn value_to_u64(value: &Value) -> Option { + match value { + Value::Int64(value) if *value >= 0 => Some(*value as u64), + Value::Int32(value) if *value >= 0 => Some(*value as u64), + Value::Int16(value) if *value >= 0 => Some(*value as u64), + Value::Int8(value) if *value >= 0 => Some(*value as u64), + Value::UInt64(value) => Some(*value), + Value::UInt32(value) => Some(u64::from(*value)), + Value::UInt16(value) => Some(u64::from(*value)), + Value::UInt8(value) => Some(u64::from(*value)), + _ => None, + } +} + +fn value_to_f64(value: Option<&Value>) -> f64 { + match value { + Some(Value::Double(value)) => *value, + Some(Value::Float(value)) => f64::from(*value), + Some(Value::Int64(value)) => *value as f64, + Some(Value::UInt64(value)) => *value as f64, + Some(Value::Int32(value)) => f64::from(*value), + Some(Value::UInt32(value)) => f64::from(*value), + _ => 0.0, + } +} + +fn contains_keyword(statement: &str, keyword: &str) -> bool { + let uppercase = statement.to_ascii_uppercase(); + let mut search_start = 0; + while let Some(index) = uppercase[search_start..].find(keyword) { + let absolute_index = search_start + index; + let before = uppercase[..absolute_index] + .chars() + .next_back() + .map(is_keyword_char) + .unwrap_or(false); + let after = uppercase[absolute_index + keyword.len()..] + .chars() + .next() + .map(is_keyword_char) + .unwrap_or(false); + if !before && !after { + return true; + } + search_start = absolute_index + keyword.len(); + } + false +} + +fn is_keyword_char(character: char) -> bool { + character.is_ascii_alphanumeric() || character == '_' +} + +fn lbug_query_parameters( + parameters: &serde_json::Map, +) -> Result, String> { + let mut converted = Vec::with_capacity(parameters.len()); + for (name, value) in parameters { + if name.trim().is_empty() { + return Err("graph_query parameter names must not be blank".to_string()); + } + converted.push((name.clone(), json_parameter_to_lbug_value(value)?)); + } + Ok(converted) +} + +fn json_parameter_to_lbug_value(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::Bool(value) => Ok(Value::Bool(*value)), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(Value::Int64(value)) + } else if let Some(value) = value.as_u64() { + Ok(Value::UInt64(value)) + } else if let Some(value) = value.as_f64() { + Ok(Value::Double(value)) + } else { + Err("graph_query numeric parameter is not representable".to_string()) + } + } + serde_json::Value::String(value) => Ok(Value::String(value.clone())), + serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + Ok(Value::Json(value.clone())) + } + } +} + +fn json_safe_value(value: Value) -> serde_json::Value { + match value { + Value::Null(_) => serde_json::Value::Null, + Value::Bool(value) => json!(value), + Value::Int64(value) => json!(value), + Value::Int32(value) => json!(value), + Value::Int16(value) => json!(value), + Value::Int8(value) => json!(value), + Value::UInt64(value) => json!(value), + Value::UInt32(value) => json!(value), + Value::UInt16(value) => json!(value), + Value::UInt8(value) => json!(value), + Value::Int128(value) => json!(value.to_string()), + Value::Double(value) => serde_json::Number::from_f64(value) + .map(serde_json::Value::Number) + .unwrap_or_else(|| json!(value.to_string())), + Value::Float(value) => serde_json::Number::from_f64(f64::from(value)) + .map(serde_json::Value::Number) + .unwrap_or_else(|| json!(value.to_string())), + Value::String(value) => json!(value), + Value::Json(value) => value, + Value::List(_, values) | Value::Array(_, values) => { + serde_json::Value::Array(values.into_iter().map(json_safe_value).collect()) + } + Value::Struct(values) => serde_json::Value::Object( + values + .into_iter() + .map(|(key, value)| (key, json_safe_value(value))) + .collect(), + ), + other => json!(other.to_string()), + } +} + +fn query_relation_neighbors( conn: &Connection, schema: &serde_json::Value, node_id: &str, @@ -263,7 +506,7 @@ pub(in crate::cli) fn query_relation_neighbors( Ok(neighbors) } -pub(in crate::cli) fn relation_type<'a>( +fn relation_type<'a>( schema: &'a serde_json::Value, relation: &str, ) -> Option<&'a serde_json::Value> { @@ -272,7 +515,7 @@ pub(in crate::cli) fn relation_type<'a>( .find(|value| value_str(value, "name") == relation) } -pub(in crate::cli) fn neighbor_statement( +fn neighbor_statement( node_type: &str, neighbor_type: &str, relation: &str, @@ -305,7 +548,7 @@ pub(in crate::cli) fn neighbor_statement( } } -pub(in crate::cli) fn search_fts_index( +fn search_fts_index( conn: &Connection, node_type: &str, index_name: &str, @@ -348,29 +591,29 @@ pub(in crate::cli) fn search_fts_index( Ok(rows) } -pub(in crate::cli) fn is_missing_search_target_error(error: &str) -> bool { +fn is_missing_search_target_error(error: &str) -> bool { error.contains("does not exist") || error.contains("doesn't have an index") || error.contains("Index not found") } #[derive(Debug, Clone)] -pub(in crate::cli) struct SearchHitRow { - pub(in crate::cli) id: String, - pub(in crate::cli) node_type: String, - pub(in crate::cli) label: String, - pub(in crate::cli) qualified_name: String, - pub(in crate::cli) path: String, - pub(in crate::cli) line_start: Option, - pub(in crate::cli) line_end: Option, - pub(in crate::cli) summary: String, - pub(in crate::cli) score: f64, - pub(in crate::cli) rank_score: f64, - pub(in crate::cli) index_order: usize, +struct SearchHitRow { + id: String, + node_type: String, + label: String, + qualified_name: String, + path: String, + line_start: Option, + line_end: Option, + summary: String, + score: f64, + rank_score: f64, + index_order: usize, } impl SearchHitRow { - pub(in crate::cli) fn into_json(self, options: &GraphSearchOptions) -> serde_json::Value { + fn into_json(self, options: &GraphSearchRequest) -> serde_json::Value { let span = span_json(self.line_start, self.line_end); if options.detail == "slim" { let mut payload = json!({ @@ -410,7 +653,7 @@ impl SearchHitRow { } } -pub(in crate::cli) fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { +fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { let max_score = hits.iter().map(|hit| hit.score).fold(0.0, f64::max); for hit in hits { let fts_score = if max_score > 0.0 { @@ -425,7 +668,7 @@ pub(in crate::cli) fn rank_search_hits(hits: &mut [SearchHitRow], query: &str) { } } -pub(in crate::cli) fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { +fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { let normalized_query = query.to_ascii_lowercase(); if normalized_query.is_empty() { return 0.0; @@ -444,7 +687,7 @@ pub(in crate::cli) fn lexical_score(query: &str, hit: &SearchHitRow) -> f64 { } } -pub(in crate::cli) fn entity_priority_score(node_type: &str) -> f64 { +fn entity_priority_score(node_type: &str) -> f64 { match node_type { "Class" | "Function" | "Method" | "Module" | "Variable" | "Parameter" | "Field" | "Enum" | "Interface" | "Trait" | "Struct" => 1.0, @@ -457,6 +700,6 @@ pub(in crate::cli) fn entity_priority_score(node_type: &str) -> f64 { } } -pub(in crate::cli) fn round6(value: f64) -> f64 { +fn round6(value: f64) -> f64 { (value * 1_000_000.0).round() / 1_000_000.0 } diff --git a/src/api/lifecycle.rs b/src/api/lifecycle.rs index f0111e0..3210c97 100644 --- a/src/api/lifecycle.rs +++ b/src/api/lifecycle.rs @@ -1,8 +1,15 @@ use crate::api::context::RepoRuntime; use crate::api::contracts::{ApiError, RefreshRequest, RepositoryLifecycleRequest}; -use crate::api::materialization::execute_candidate_materialization; -use crate::cli::materialization::MaterializeOptions; -use crate::cli::{reinstall, setup, uninstall}; +use crate::api::materialization::{ + build_request, default_excluded_parts, execute_candidate_materialization, + execute_materialization, MaterializeOptions, +}; +use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use serde_json::json; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; pub(crate) fn is_retryable_refresh_failure(error: &str) -> bool { crate::db_writer::is_transient_database_error(error) @@ -13,7 +20,7 @@ pub(crate) fn setup_repository( runtime: &RepoRuntime, ) -> Result { validate_lifecycle_action(request, "setup")?; - setup::execute_setup_operation(request, &runtime.repo_root) + setup_payload_for_request(request, &runtime.repo_root) .map_err(|error| ApiError::new("setup_failed", error)) } @@ -22,7 +29,7 @@ pub(crate) fn reinstall_repository( runtime: &RepoRuntime, ) -> Result { validate_lifecycle_action(request, "reinstall")?; - reinstall::execute_reinstall_operation(request, &runtime.repo_root) + reinstall_payload_for_request(request, &runtime.repo_root) .map_err(|error| ApiError::new("reinstall_failed", error)) } @@ -31,7 +38,7 @@ pub(crate) fn uninstall_repository( runtime: &RepoRuntime, ) -> Result { validate_lifecycle_action(request, "uninstall")?; - uninstall::execute_uninstall_operation(request, &runtime.repo_root) + uninstall_payload_for_request(request, &runtime.repo_root) .map_err(|error| ApiError::new("uninstall_failed", error)) } @@ -56,27 +63,11 @@ pub(crate) fn refresh_repository( request: &RefreshRequest, runtime: &RepoRuntime, ) -> Result { - let repo_root = request - .repo - .repo_root - .clone() - .unwrap_or_else(|| runtime.repo_root.clone()); let options = MaterializeOptions { - source_root: Some(repo_root), - db: Some( - request - .repo - .db_path - .clone() - .unwrap_or_else(|| runtime.db_path.clone()), - ), - manifest: Some( - request - .repo - .manifest_path - .clone() - .unwrap_or_else(|| runtime.manifest_path.clone()), - ), + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), use_git: false, mode: request.mode.clone(), include_fts: request.include_fts, @@ -102,3 +93,1949 @@ pub(crate) fn refresh_repository( )?; Ok(serde_json::to_value(response).unwrap_or_else(|_| serde_json::json!({}))) } + +#[derive(Debug, Clone)] +struct LifecycleOptions { + mode: String, + include_fts: bool, + semantic_enrichment: bool, + semantic_provider_mode: String, + mcp_client: String, + mcp_config_path: Option, + skip_mcp_config: bool, + dry_run: bool, + instructions_target: String, +} + +impl LifecycleOptions { + fn from_request(request: &RepositoryLifecycleRequest) -> Self { + Self { + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + mcp_client: request + .mcp_client + .clone() + .unwrap_or_else(|| "codex".to_string()), + mcp_config_path: request.mcp_config_path.clone(), + skip_mcp_config: request.skip_mcp_config, + dry_run: request.dry_run, + instructions_target: request + .instructions_target + .clone() + .unwrap_or_else(|| "auto".to_string()), + } + } +} + +fn setup_payload_for_request( + request: &RepositoryLifecycleRequest, + source_root: &Path, +) -> Result { + let options = LifecycleOptions::from_request(request); + setup_payload_for_root(&options, source_root) +} + +fn setup_payload_for_root( + options: &LifecycleOptions, + source_root: &Path, +) -> Result { + let source_root = source_root.to_path_buf(); + let paths = GraphStatePaths::derive(&source_root); + reject_state_dir_root(&source_root)?; + + let materialize_options = MaterializeOptions { + source_root: Some(source_root.clone()), + db: Some(paths.db_path.clone()), + manifest: Some(paths.manifest_path.clone()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: true, + ..MaterializeOptions::default() + }; + let config_payload = setup_config_payload(&paths, &source_root); + let instructions_path = instruction_target_path(&source_root, &options.instructions_target)?; + let state_dir_existed = paths.state_dir.exists(); + let graph_state_existed = + paths.config_path.exists() && paths.db_path.exists() && paths.manifest_path.exists(); + let previous_config = snapshot_file(&paths.config_path)?; + let previous_instructions = match instructions_path.as_ref() { + Some(path) => Some((path.clone(), snapshot_file(path)?)), + None => None, + }; + + let (config_action, instructions, mcp_config, materialization) = if options.dry_run { + let request = materialization_request(&materialize_options)?; + let materialization = dry_run_materialization_payload(&request, &paths); + let config_action = if json_file_would_change(&paths.config_path, &config_payload)? { + "dry_run" + } else { + "unchanged" + }; + let instructions = json!({ + "action": if instructions_path.is_some() { "dry_run" } else { "skipped" }, + "path": instructions_path.as_ref().map(|path| path.to_string_lossy().to_string()), + }); + let mcp_config = setup_mcp_config(options, &paths, true)?; + ( + config_action.to_string(), + instructions, + mcp_config, + materialization, + ) + } else { + fs::create_dir_all(&paths.state_dir).map_err(|error| { + format!( + "failed to create state directory {}: {error}", + paths.state_dir.display() + ) + })?; + let result = (|| { + let config_action = write_setup_config(&paths, &source_root)?; + let instructions = upsert_instruction_block( + &source_root, + &options.instructions_target, + &paths.config_path, + )?; + let materialization = if graph_state_existed { + existing_graph_materialization_payload(&materialize_options.mode, &paths) + } else { + let (_, response) = execute_materialization(&materialize_options)?; + materialization_payload(&response, &materialize_options.mode, &paths) + }; + let mcp_config = setup_mcp_config(options, &paths, false)?; + Ok::<_, String>(( + config_action.to_string(), + instructions, + mcp_config, + materialization, + )) + })(); + match result { + Ok(result) => result, + Err(error) => { + restore_file(&paths.config_path, previous_config.as_deref())?; + if let Some((path, previous)) = previous_instructions.as_ref() { + restore_file(path, previous.as_deref())?; + } + if !state_dir_existed { + let _ = fs::remove_dir_all(&paths.state_dir); + } + return Err(error); + } + } + }; + + Ok(json!({ + "ok": true, + "repo_root": source_root, + "repo_name": paths.repo_name, + "state_dir": paths.state_dir, + "db_path": paths.db_path, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "config_path": paths.config_path, + "config_action": config_action, + "mcp_config": mcp_config, + "instructions": instructions, + "materialization": materialization, + "database_written": materialization.get("database_written").cloned().unwrap_or(json!(false)), + "skipped": materialization.get("skipped").cloned().unwrap_or(json!(0)), + "node_rows": materialization.get("node_rows").cloned().unwrap_or(json!(0)), + "edge_rows": materialization.get("edge_rows").cloned().unwrap_or(json!(0)), + "connector_rows": materialization.get("connector_rows").cloned().unwrap_or(json!(0)), + "diagnostics": materialization.get("diagnostics").cloned().unwrap_or(json!([])), + })) +} + +fn reinstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + let options = LifecycleOptions::from_request(request); + let repo_root = repo_root.to_path_buf(); + reject_state_dir_root(&repo_root)?; + + let paths = GraphStatePaths::derive(&repo_root); + let state = reinstall_state(&paths, options.dry_run)?; + let install = if options.dry_run { + setup_payload_for_root(&options, &repo_root)? + } else { + match setup_payload_for_root(&options, &repo_root) { + Ok(payload) => { + remove_backup(state.backup_path.as_deref())?; + payload + } + Err(error) => { + restore_backup(&paths.state_dir, state.backup_path.as_deref())?; + return Err(error); + } + } + }; + + Ok(json!({ + "ok": true, + "repo_root": repo_root, + "dry_run": options.dry_run, + "state": state.payload, + "install": install, + })) +} + +fn uninstall_payload_for_request( + request: &RepositoryLifecycleRequest, + repo_root: &Path, +) -> Result { + let repo_root = repo_root.to_path_buf(); + let paths = GraphStatePaths::derive(&repo_root); + let config_path = request + .repo + .config_path + .clone() + .unwrap_or_else(|| paths.config_path.clone()); + let mcp_client = request + .mcp_client + .clone() + .unwrap_or_else(|| "all".to_string()); + let server_name = uninstall_server_name(&repo_root, &config_path)?; + let state = uninstall_state_dir(&paths.state_dir, request.dry_run)?; + let instructions = uninstall_instruction_blocks(&repo_root, request.dry_run)?; + let mcp_clients = uninstall_mcp_clients( + &mcp_client, + request.mcp_config_path.as_deref(), + &repo_root, + &config_path, + &server_name, + request.dry_run, + )?; + + Ok(json!({ + "ok": true, + "repo_root": repo_root, + "config_path": config_path, + "server_name": server_name, + "dry_run": request.dry_run, + "state": state, + "instructions": instructions, + "mcp_clients": mcp_clients, + })) +} + +fn reject_state_dir_root(repo_root: &Path) -> Result<(), String> { + if repo_root + .components() + .any(|component| component.as_os_str() == ".codebaseGraph") + { + Err(format!( + "Repository root may not be inside a .codebaseGraph state directory: {}", + repo_root.display() + )) + } else { + Ok(()) + } +} + +fn existing_graph_materialization_payload( + mode: &str, + paths: &GraphStatePaths, +) -> serde_json::Value { + json!({ + "mode": mode, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "database_written": false, + "skipped": true, + "skip_reason": "existing_graph_state", + "rebuilt": 0, + "deleted": 0, + "node_rows": 0, + "edge_rows": 0, + "connector_rows": 0, + "diagnostics": [], + "phase_timings": {}, + }) +} + +fn materialization_request( + options: &MaterializeOptions, +) -> Result { + build_request(options) +} + +fn materialization_payload( + response: &NativeSyntaxMaterializationResponse, + mode: &str, + paths: &GraphStatePaths, +) -> serde_json::Value { + let rebuilt_paths = response.diff.rebuild_paths(); + let skipped_paths = response + .snapshots + .iter() + .filter_map(|(path, snapshot)| { + if snapshot.language.is_none() { + Some(path.clone()) + } else { + None + } + }) + .collect::>(); + let ignored_paths = response + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) + .map(str::to_string) + .collect::>(); + json!({ + "mode": mode, + "scanned": response.snapshots.len(), + "rebuilt": rebuilt_paths.len(), + "skipped": skipped_paths.len(), + "ignored": ignored_paths.len(), + "deleted": response.diff.deleted.len(), + "diagnostics": response.diagnostics, + "manifest_path": paths.manifest_path, + "rebuilt_paths": rebuilt_paths, + "skipped_paths": skipped_paths.clone(), + "ignored_paths": ignored_paths, + "deleted_paths": response.diff.deleted.clone(), + "would_rebuild": response.diff.rebuild_paths(), + "would_delete": response.diff.deleted, + "would_skip": skipped_paths, + "graph_summary": response.graph_summary, + "node_rows": response.node_rows, + "edge_rows": response.edge_rows, + "connector_rows": response.connector_rows, + "database_written": response.database_written, + "progress_events": response.progress_events, + "phase_timings": response.phase_timings, + }) +} + +fn dry_run_materialization_payload( + request: &NativeSyntaxMaterializationRequest, + paths: &GraphStatePaths, +) -> serde_json::Value { + let snapshots = scan_source_snapshots(Path::new(&request.source_root)); + let scanned = snapshots.len(); + let skipped_paths = snapshots + .into_iter() + .filter_map(|(path, language)| if language.is_none() { Some(path) } else { None }) + .collect::>(); + json!({ + "mode": "dry_run", + "scanned": scanned, + "rebuilt": 0, + "skipped": skipped_paths.len(), + "deleted": 0, + "diagnostics": [], + "manifest_path": paths.manifest_path, + "rebuilt_paths": [], + "skipped_paths": skipped_paths, + "deleted_paths": [], + "graph_summary": {}, + }) +} + +fn scan_source_snapshots(root: &Path) -> Vec<(String, Option<&'static str>)> { + let mut snapshots = Vec::new(); + scan_source_snapshots_inner(root, root, &mut snapshots); + snapshots.sort_by(|left, right| left.0.cmp(&right.0)); + snapshots +} + +fn scan_source_snapshots_inner( + root: &Path, + directory: &Path, + snapshots: &mut Vec<(String, Option<&'static str>)>, +) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if default_excluded_parts().iter().any(|part| part == name) { + continue; + } + if path.is_dir() { + scan_source_snapshots_inner(root, &path, snapshots); + } else if path.is_file() { + let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy(); + snapshots.push((relative.to_string(), language_for_path(&path))); + } + } +} + +fn language_for_path(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|value| value.to_str()) { + Some("py") => Some("python"), + Some("rs") => Some("rust"), + Some("go") => Some("go"), + Some("c") | Some("h") => Some("c"), + Some("cc") | Some("cpp") | Some("cxx") | Some("hpp") | Some("hh") => Some("cpp"), + Some("f") | Some("f90") | Some("f95") | Some("for") => Some("fortran"), + _ => None, + } +} + +fn setup_mcp_config( + options: &LifecycleOptions, + paths: &GraphStatePaths, + dry_run: bool, +) -> Result { + let descriptor = build_mcp_descriptor( + "generic", + "local", + Some("codebase_graph".to_string()), + Some(paths.config_path.clone()), + None, + Some( + paths + .state_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")), + ), + )?; + if options.skip_mcp_config || options.mcp_client == "none" { + return Ok(json!({ + "action": "skipped", + "client": options.mcp_client, + "scope": "local", + "server_name": descriptor.name, + "method": serde_json::Value::Null, + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, true), + })); + } + + install_mcp_client( + &options.mcp_client, + if options.mcp_client == "claude-project" { + "project" + } else { + "local" + }, + Some("codebase_graph".to_string()), + Some(paths.config_path.clone()), + options.mcp_config_path.clone(), + Some( + paths + .state_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")), + ), + dry_run, + ) +} + +fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result { + if config_path.exists() { + let config = read_json_file(config_path)?; + if let Some(name) = config + .pointer("/mcp/server_name") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + return Ok(name.to_string()); + } + } + Ok(build_mcp_descriptor( + "generic", + "local", + None, + Some(config_path.to_path_buf()), + None, + Some(repo_root.to_path_buf()), + )? + .name) +} + +fn uninstall_state_dir(path: &Path, dry_run: bool) -> Result { + if !path.exists() { + return Ok(json!({"action": "unchanged", "path": path})); + } + if !dry_run { + fs::remove_dir_all(path).map_err(|error| { + format!( + "failed to remove state directory {}: {error}", + path.display() + ) + })?; + } + Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) +} + +fn uninstall_instruction_blocks( + repo_root: &Path, + dry_run: bool, +) -> Result, String> { + ["AGENTS.md", "CLAUDE.md"] + .into_iter() + .map(|file_name| uninstall_instruction_file(&repo_root.join(file_name), dry_run)) + .collect() +} + +fn uninstall_instruction_file(path: &Path, dry_run: bool) -> Result { + let Ok(existing) = fs::read_to_string(path) else { + return Ok(json!({"action": "unchanged", "path": path})); + }; + let (next, removed) = remove_instruction_text(&existing); + if !removed { + return Ok(json!({"action": "unchanged", "path": path})); + } + if !dry_run { + fs::write(path, next).map_err(|error| { + format!("failed to update instructions {}: {error}", path.display()) + })?; + } + Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) +} + +fn uninstall_mcp_clients( + mcp_client: &str, + client_config_path: Option<&Path>, + repo_root: &Path, + config_path: &Path, + server_name: &str, + dry_run: bool, +) -> Result, String> { + let clients = if mcp_client == "all" { + supported_install_clients() + .into_iter() + .map(str::to_string) + .collect::>() + } else { + vec![mcp_client.to_string()] + }; + + Ok(clients + .into_iter() + .map(|client| { + uninstall_mcp_client( + &client, + client_config_path, + repo_root, + config_path, + server_name, + dry_run, + ) + .unwrap_or_else(|error| { + json!({ + "action": "failed", + "client": client, + "server_name": server_name, + "error": error, + }) + }) + }) + .collect()) +} + +fn uninstall_mcp_client( + client: &str, + client_config_path: Option<&Path>, + repo_root: &Path, + config_path: &Path, + server_name: &str, + dry_run: bool, +) -> Result { + if matches!(client, "copilot-studio" | "microsoft-copilot") { + return Ok(json!({ + "action": "skipped", + "reason": "manual_metadata", + "client": client, + "server_name": server_name, + })); + } + + let scope = if client == "claude-project" { + "project" + } else { + "local" + }; + let descriptor = build_mcp_descriptor( + client, + scope, + Some(server_name.to_string()), + Some(config_path.to_path_buf()), + client_config_path.map(Path::to_path_buf), + Some(repo_root.to_path_buf()), + )?; + let normalized_scope = install_scope(client, scope); + let path = client_config_path + .map(Path::to_path_buf) + .unwrap_or_else(|| default_client_config_path(client, &normalized_scope, &descriptor)); + let existing = fs::read_to_string(&path).ok(); + let removed = + remove_client_config(client, &normalized_scope, existing.as_deref(), server_name)?; + if removed.action == "removed" && !dry_run { + write_text_atomic(&path, &removed.text)?; + } + let action = if removed.action == "removed" && dry_run { + "dry_run".to_string() + } else { + removed.action + }; + Ok(json!({ + "action": action, + "client": client, + "scope": normalized_scope, + "server_name": server_name, + "path": path, + "previous": removed.previous, + "payload": removed.payload, + })) +} + +pub(crate) fn remove_instruction_text(existing: &str) -> (String, bool) { + const START: &str = ""; + const END: &str = ""; + let Some(start) = existing.find(START) else { + return (existing.to_string(), false); + }; + let Some(end) = existing[start..].find(END).map(|index| start + index) else { + return (existing.to_string(), false); + }; + let after_end = end + END.len(); + let before = existing[..start].trim_end(); + let after = existing[after_end..].trim_start(); + let text = match (before.is_empty(), after.is_empty()) { + (true, true) => String::new(), + (true, false) => format!("{after}\n"), + (false, true) => format!("{before}\n"), + (false, false) => format!("{before}\n\n{after}"), + }; + (text, true) +} + +pub(crate) struct GraphStatePaths { + pub(crate) repo_name: String, + pub(crate) state_dir: PathBuf, + pub(crate) db_path: PathBuf, + pub(crate) manifest_path: PathBuf, + pub(crate) config_path: PathBuf, +} + +impl GraphStatePaths { + pub(crate) fn derive(repo_root: &Path) -> Self { + let repo_name = safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ); + let state_dir = repo_root.join(".codebaseGraph"); + Self { + db_path: state_dir.join(format!("{repo_name}_graph.ldb")), + manifest_path: state_dir.join("manifest.json"), + config_path: state_dir.join("config.json"), + state_dir, + repo_name, + } + } +} + +pub(crate) fn safe_name(value: &str) -> String { + let normalized: String = value + .chars() + .map(|character| { + if character.is_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect(); + let trimmed = normalized.trim_matches(['.', '_', '-']); + if trimmed.is_empty() { + "repository".to_string() + } else { + trimmed.to_string() + } +} + +fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> serde_json::Value { + json!({ + "schema_version": 1, + "repo_root": repo_root, + "repo_name": paths.repo_name, + "state_dir": paths.state_dir, + "database_path": paths.db_path, + "manifest_path": paths.manifest_path, + "ontology_version": "code_ontology_v1", + "package_version": env!("CARGO_PKG_VERSION"), + "materialization": { + "include": [], + "exclude": [] + }, + "mcp": { + "server_name": "codebase_graph", + "command": [ + server_command(), + "mcp", + "start", + "--config", + paths.config_path.to_string_lossy() + ] + } + }) +} + +fn write_setup_config(paths: &GraphStatePaths, repo_root: &Path) -> Result<&'static str, String> { + let payload = setup_config_payload(paths, repo_root); + let mut action = "created"; + if paths.config_path.exists() { + let previous = read_json_file(&paths.config_path)?; + if previous == payload { + return Ok("unchanged"); + } + action = "updated"; + } + if let Some(parent) = paths.config_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create config directory {}: {error}", + parent.display() + ) + })?; + } + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())?; + fs::write(&paths.config_path, format!("{text}\n")).map_err(|error| { + format!( + "failed to write install config {}: {error}", + paths.config_path.display() + ) + })?; + Ok(action) +} + +fn json_file_would_change(path: &Path, payload: &serde_json::Value) -> Result { + if !path.exists() { + return Ok(true); + } + Ok(read_json_file(path)? != *payload) +} + +fn instruction_target_path(repo_root: &Path, target: &str) -> Result, String> { + match target { + "skip" => Ok(None), + "agents" => Ok(Some(repo_root.join("AGENTS.md"))), + "claude" => Ok(Some(repo_root.join("CLAUDE.md"))), + "auto" => { + let agents = repo_root.join("AGENTS.md"); + if agents.exists() { + return Ok(Some(agents)); + } + let claude = repo_root.join("CLAUDE.md"); + if claude.exists() { + return Ok(Some(claude)); + } + Ok(Some(agents)) + } + other => Err(format!("Unsupported instruction target: {other}")), + } +} + +fn upsert_instruction_block( + repo_root: &Path, + target: &str, + config_path: &Path, +) -> Result { + let Some(path) = instruction_target_path(repo_root, target)? else { + return Ok(json!({"action": "skipped", "path": serde_json::Value::Null})); + }; + let existing = fs::read_to_string(&path).unwrap_or_default(); + let created = !path.exists(); + let block = instruction_block(config_path); + let (next_text, action) = upsert_instruction_text(&existing, &block, created); + if next_text == existing { + return Ok(json!({"action": "unchanged", "path": path.to_string_lossy()})); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create instruction directory {}: {error}", + parent.display() + ) + })?; + } + fs::write(&path, next_text) + .map_err(|error| format!("failed to write instructions {}: {error}", path.display()))?; + Ok(json!({"action": action, "path": path.to_string_lossy()})) +} + +fn instruction_block(config_path: &Path) -> String { + format!( + "\n\ +## codebaseGraph workflow\n\ +- Treat the repo-local `.codebaseGraph` graph as the project operating source of truth. It is prohibited to read the code source before you find the target files using the graph.\n\ +- Prefer the `codebase_graph` MCP server tools over shell commands whenever they are exposed in the current agent session.\n\ +- AI agents receive block output by default for graph CLI and MCP tools; request `output_format: \"json\"` or `include_structured_content: true` only for tests, APIs, or explicit structured-payload debugging.\n\ +- Use MCP `graph_search` with `detail: \"slim\"` and `context_limit: 1` before answering repo-structure questions or performing coding tasks.\n\ +- Use MCP `graph_context` with `profile: \"\"`, `detail: \"slim\"`, and `context_limit: 2` when relationships or nearby evidence matter; useful profiles include `definitions`, `dependencies`, `callgraph`, `docs`, `runtime`, and `change_impact`.\n\ +- For architecture orientation, use MCP `graph_architecture_queries`, then execute selected read-only statements with MCP `graph_query`.\n\ +- Use MCP `graph_schema` or `graph_query_helpers` before writing raw graph queries, and keep `graph_query` read-only.\n\ +- If MCP tools are unavailable, fall back to CLI: `{command} codebase-search --no-refresh --detail slim --context-limit 1`, `{command} codebase-context --profile --no-refresh --detail slim --context-limit 2`, `{command} codebase-architecture-queries`, `{command} graph-query \"\"`, `{command} schema`, and `{command} query-helpers`.\n\ +- Do not rerun install to refresh the graph. The MCP server started from this setup config watches the repo and refreshes automatically; use `{command} build --mode full` only for explicit manual rebuilds. Setup config: `{config_path}`.\n\ +\n", + command = server_command(), + config_path = config_path.to_string_lossy(), + ) +} + +fn upsert_instruction_text(existing: &str, block: &str, created: bool) -> (String, &'static str) { + const START: &str = ""; + const END: &str = ""; + if existing.trim().is_empty() { + return (block.to_string(), "created"); + } + let Some(start) = existing.find(START) else { + let separator = if existing.ends_with('\n') { "" } else { "\n" }; + let action = if created { "created" } else { "updated" }; + return ( + format!("{}{separator}\n{}", existing.trim_end(), block), + action, + ); + }; + let Some(end) = existing.find(END) else { + return ( + format!("{}\n\n{}", existing.trim_end(), block), + if created { "created" } else { "updated" }, + ); + }; + if end < start { + return ( + format!("{}\n\n{}", existing.trim_end(), block), + if created { "created" } else { "updated" }, + ); + } + let after_end = end + END.len(); + let text = format!( + "{}\n\n{}\n\n{}", + existing[..start].trim_end(), + block.trim_end(), + existing[after_end..].trim_start() + ) + .trim() + .to_string() + + "\n"; + (text, "updated") +} + +fn snapshot_file(path: &Path) -> Result>, String> { + if !path.exists() { + return Ok(None); + } + fs::read(path) + .map(Some) + .map_err(|error| format!("failed to snapshot {}: {error}", path.display())) +} + +fn restore_file(path: &Path, previous: Option<&[u8]>) -> Result<(), String> { + match previous { + Some(previous) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create restore directory {}: {error}", + parent.display() + ) + })?; + } + fs::write(path, previous) + .map_err(|error| format!("failed to restore {}: {error}", path.display())) + } + None => { + if path.exists() { + fs::remove_file(path) + .map_err(|error| format!("failed to remove {}: {error}", path.display()))?; + } + Ok(()) + } + } +} + +struct ReinstallState { + payload: serde_json::Value, + backup_path: Option, +} + +fn reinstall_state(paths: &GraphStatePaths, dry_run: bool) -> Result { + if !paths.state_dir.exists() { + return Ok(ReinstallState { + payload: json!({ + "action": "unchanged", + "path": paths.state_dir, + "backup_path": serde_json::Value::Null, + }), + backup_path: None, + }); + } + let backup_path = next_backup_path(&paths.state_dir)?; + if dry_run { + return Ok(ReinstallState { + payload: json!({ + "action": "dry_run", + "path": paths.state_dir, + "backup_path": backup_path, + }), + backup_path: None, + }); + } + fs::rename(&paths.state_dir, &backup_path).map_err(|error| { + format!( + "failed to move existing graph state {} to {}: {error}", + paths.state_dir.display(), + backup_path.display() + ) + })?; + Ok(ReinstallState { + payload: json!({ + "action": "backed_up", + "path": paths.state_dir, + "backup_path": backup_path, + }), + backup_path: Some(backup_path), + }) +} + +fn next_backup_path(state_dir: &Path) -> Result { + let parent = state_dir.parent().unwrap_or_else(|| Path::new(".")); + let file_name = state_dir + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(".codebaseGraph"); + for index in 0..1000 { + let suffix = if index == 0 { + "reinstall-backup".to_string() + } else { + format!("reinstall-backup-{index}") + }; + let candidate = parent.join(format!("{file_name}.{suffix}")); + if !candidate.exists() { + return Ok(candidate); + } + } + Err(format!( + "failed to choose backup path for {}", + state_dir.display() + )) +} + +fn remove_backup(path: Option<&Path>) -> Result<(), String> { + let Some(path) = path else { + return Ok(()); + }; + remove_path(path).map_err(|error| { + format!( + "failed to remove reinstall backup {} after successful setup: {error}", + path.display() + ) + }) +} + +fn restore_backup(state_dir: &Path, backup_path: Option<&Path>) -> Result<(), String> { + let Some(backup_path) = backup_path else { + if state_dir.exists() { + remove_path(state_dir).map_err(|error| { + format!( + "failed to remove partial graph state {} after setup failure: {error}", + state_dir.display() + ) + })?; + } + return Ok(()); + }; + if state_dir.exists() { + remove_path(state_dir).map_err(|error| { + format!( + "failed to remove partial graph state {} before restore: {error}", + state_dir.display() + ) + })?; + } + fs::rename(backup_path, state_dir).map_err(|error| { + format!( + "failed to restore graph state backup {} to {}: {error}", + backup_path.display(), + state_dir.display() + ) + }) +} + +fn remove_path(path: &Path) -> std::io::Result<()> { + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) +} + +fn server_command() -> String { + env::var("CODEBASE_GRAPH_SERVER_COMMAND").unwrap_or_else(|_| "codebase-graph".to_string()) +} + +#[derive(Debug, Clone)] +struct NativeMcpDescriptor { + name: String, + command: String, + args: Vec, + setup_config_path: String, + repo_root: String, + timeout: u64, +} + +impl NativeMcpDescriptor { + fn as_json(&self) -> serde_json::Value { + json!({ + "name": self.name, + "transport": "stdio", + "command": self.command, + "args": self.args, + "env": {}, + "cwd": serde_json::Value::Null, + "setup_config_path": self.setup_config_path, + "repo_root": self.repo_root, + "timeout": self.timeout, + "tool_policy": "graph_query_read_only", + }) + } + + fn stdio_entry(&self, include_type: bool, include_timeout: bool) -> serde_json::Value { + let mut entry = serde_json::Map::new(); + entry.insert("command".to_string(), json!(self.command)); + entry.insert("args".to_string(), json!(self.args)); + if include_type { + entry.insert("type".to_string(), json!("stdio")); + } + if include_timeout { + entry.insert("startup_timeout_sec".to_string(), json!(self.timeout)); + } + serde_json::Value::Object(entry) + } +} + +fn build_mcp_descriptor( + client: &str, + scope: &str, + name: Option, + config_path: Option, + client_config_path: Option, + repo_root: Option, +) -> Result { + let resolved_repo_root = repo_root.unwrap_or_else(|| PathBuf::from(".")); + let config_path = config_path + .clone() + .unwrap_or_else(|| GraphStatePaths::derive(&resolved_repo_root).config_path); + let setup_config = if config_path.exists() { + Some(read_json_file(&config_path)?) + } else { + None + }; + let repo_root = setup_config + .as_ref() + .and_then(|payload| payload.get("repo_root")) + .and_then(serde_json::Value::as_str) + .map(expand_path) + .unwrap_or_else(|| { + config_path + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or(resolved_repo_root.clone()) + }); + let repo_name = setup_config + .as_ref() + .and_then(|payload| payload.get("repo_name")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + safe_name( + repo_root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repository"), + ) + }); + let name = name.unwrap_or_else(|| format!("codebase_graph_{}", install_safe_name(&repo_name))); + let command_from_config = setup_config + .as_ref() + .and_then(|payload| payload.pointer("/mcp/command")) + .and_then(serde_json::Value::as_array) + .and_then(|values| { + let command: Option> = values + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect(); + command.filter(|parts| parts.len() >= 5) + }); + let (command, args) = if let Some(mut parts) = command_from_config { + let command = parts.remove(0); + (command, parts) + } else { + ( + server_command(), + vec![ + "mcp".to_string(), + "start".to_string(), + "--config".to_string(), + config_path.to_string_lossy().to_string(), + ], + ) + }; + let _ = client; + let _ = scope; + let _ = client_config_path; + Ok(NativeMcpDescriptor { + name, + command, + args, + setup_config_path: config_path.to_string_lossy().to_string(), + repo_root: repo_root.to_string_lossy().to_string(), + timeout: 60, + }) +} + +fn install_mcp_client( + client: &str, + scope: &str, + name: Option, + config_path: Option, + client_config_path: Option, + repo_root: Option, + dry_run: bool, +) -> Result { + let descriptor = build_mcp_descriptor( + client, + scope, + name, + config_path, + client_config_path.clone(), + repo_root, + )?; + if client == "copilot-studio" || client == "microsoft-copilot" { + let metadata = copilot_studio_metadata(&descriptor); + return Ok(json!({ + "action": if dry_run { "dry_run" } else { "reported" }, + "client": client, + "scope": scope, + "server_name": descriptor.name, + "method": "manual_metadata", + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": metadata["stdio"].clone(), + "payload": metadata, + })); + } + + let native_command = native_client_command(client, &descriptor, scope); + let native_available = native_command + .as_ref() + .and_then(|command| command.first()) + .is_some_and(|executable| executable_in_path(executable)); + let normalized_scope = install_scope(client, scope); + + if dry_run && client_config_path.is_none() && native_available { + return Ok(json!({ + "action": "dry_run", + "client": client, + "scope": normalized_scope, + "server_name": descriptor.name, + "method": "native_cli", + "path": serde_json::Value::Null, + "command": native_command, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, false), + })); + } + if !dry_run && client_config_path.is_none() && native_available { + let Some(command) = native_command.clone() else { + return file_adapter_result( + client, + &normalized_scope, + &descriptor, + None, + None, + dry_run, + client_config_path, + ); + }; + let completed = Command::new(&command[0]) + .args(&command[1..]) + .output() + .map_err(|error| format!("failed to run native client installer: {error}"))?; + if completed.status.success() { + return Ok(json!({ + "action": "updated", + "client": client, + "scope": normalized_scope, + "server_name": descriptor.name, + "method": "native_cli", + "path": serde_json::Value::Null, + "command": command, + "descriptor": descriptor.as_json(), + "entry": descriptor.stdio_entry(false, false), + })); + } + let error = subprocess_error(&completed); + return file_adapter_result( + client, + &normalized_scope, + &descriptor, + Some(command), + Some(error), + dry_run, + client_config_path, + ); + } + + let native_error = native_command.as_ref().and_then(|command| { + command.first().and_then(|executable| { + if executable_in_path(executable) { + None + } else { + Some(format!("{executable} executable not found")) + } + }) + }); + file_adapter_result( + client, + &normalized_scope, + &descriptor, + native_command, + native_error, + dry_run, + client_config_path, + ) +} + +fn file_adapter_result( + client: &str, + scope: &str, + descriptor: &NativeMcpDescriptor, + native_command: Option>, + native_error: Option, + dry_run: bool, + client_config_path: Option, +) -> Result { + let path = + client_config_path.unwrap_or_else(|| default_client_config_path(client, scope, descriptor)); + let existing = fs::read_to_string(&path).ok(); + let rendered = render_client_config(client, scope, existing.as_deref(), descriptor)?; + let action = if dry_run { + "dry_run".to_string() + } else { + rendered.action.clone() + }; + if !dry_run { + write_text_atomic(&path, &rendered.text)?; + } + let mut payload = json!({ + "action": action, + "client": client, + "scope": scope, + "server_name": descriptor.name, + "method": "file_adapter", + "path": path.to_string_lossy(), + "command": serde_json::Value::Null, + "descriptor": descriptor.as_json(), + "entry": rendered.entry, + "patch": rendered.patch, + "payload": rendered.payload, + }); + if let Some(command) = native_command { + payload["native_command"] = json!(command); + } + if let Some(error) = native_error { + payload["native_error"] = json!(error); + } + Ok(payload) +} + +fn default_client_config_path( + client: &str, + scope: &str, + descriptor: &NativeMcpDescriptor, +) -> PathBuf { + let home = home_dir(); + match adapter_id(client, scope) { + "codex" => env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")) + .join("config.toml"), + "claude" => { + let mac_path = + home.join("Library/Application Support/Claude/claude_desktop_config.json"); + if mac_path.parent().is_some_and(Path::exists) { + mac_path + } else { + home.join(".config/claude/claude_desktop_config.json") + } + } + "claude-project" => PathBuf::from(&descriptor.repo_root).join(".mcp.json"), + "lmstudio" => home.join(".lmstudio/mcp.json"), + "github-copilot" => PathBuf::from(&descriptor.repo_root).join(".vscode/mcp.json"), + "hermes" => home.join(".hermes/config.yaml"), + "openclaw" => env::var_os("OPENCLAW_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".openclaw")) + .join("mcp.json5"), + _ => home.join(".config/mcp/mcp.json"), + } +} + +fn supported_install_clients() -> Vec<&'static str> { + vec![ + "claude", + "claude-project", + "codex", + "copilot-studio", + "generic", + "github-copilot", + "hermes", + "lmstudio", + "microsoft-copilot", + "openclaw", + ] +} + +fn install_scope(client: &str, scope: &str) -> String { + if client == "claude-project" { + "project".to_string() + } else { + scope.to_string() + } +} + +fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { + if client == "claude" && scope == "project" { + "claude-project" + } else { + client + } +} + +fn native_client_command( + client: &str, + descriptor: &NativeMcpDescriptor, + scope: &str, +) -> Option> { + match client { + "codex" => Some(vec![ + "codex".to_string(), + "mcp".to_string(), + "add".to_string(), + descriptor.name.clone(), + "--".to_string(), + descriptor.command.clone(), + descriptor.args[0].clone(), + descriptor.args[1].clone(), + descriptor.args[2].clone(), + descriptor.args[3].clone(), + ]), + "claude" | "claude-project" => Some(vec![ + "claude".to_string(), + "mcp".to_string(), + "add".to_string(), + "--transport".to_string(), + "stdio".to_string(), + "--scope".to_string(), + install_scope(client, scope), + descriptor.name.clone(), + "--".to_string(), + descriptor.command.clone(), + descriptor.args[0].clone(), + descriptor.args[1].clone(), + descriptor.args[2].clone(), + descriptor.args[3].clone(), + ]), + "openclaw" => Some(vec![ + "openclaw".to_string(), + "mcp".to_string(), + "set".to_string(), + descriptor.name.clone(), + serde_json::to_string(&descriptor.stdio_entry(true, false)).ok()?, + ]), + _ => None, + } +} + +struct RenderedNativeConfig { + text: String, + action: String, + entry: serde_json::Value, + patch: serde_json::Value, + payload: serde_json::Value, +} + +struct RemovedNativeConfig { + text: String, + action: String, + previous: serde_json::Value, + payload: serde_json::Value, +} + +fn render_client_config( + client: &str, + scope: &str, + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + match adapter_id(client, scope) { + "codex" => render_codex_config(existing, descriptor), + "hermes" => render_hermes_config(existing, descriptor), + "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { + render_json_config(adapter_id(client, scope), existing, descriptor) + } + other => Err(format!("Unsupported MCP client adapter: {other}")), + } +} + +fn remove_client_config( + client: &str, + scope: &str, + existing: Option<&str>, + server_name: &str, +) -> Result { + match adapter_id(client, scope) { + "codex" => remove_codex_config(existing, server_name), + "hermes" => remove_hermes_config(existing, server_name), + "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { + remove_json_config(adapter_id(client, scope), existing, server_name) + } + other => Err(format!("Unsupported MCP client adapter: {other}")), + } +} + +fn render_json_config( + adapter: &str, + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let mut payload = existing + .filter(|text| !text.trim().is_empty()) + .map(serde_json::from_str::) + .transpose() + .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? + .unwrap_or_else(|| json!({})); + if !payload.is_object() { + return Err("MCP config must contain a JSON object".to_string()); + } + let root_path = match adapter { + "github-copilot" => vec!["servers"], + "openclaw" => vec!["mcp", "servers"], + _ => vec!["mcpServers"], + }; + let include_type = !matches!(adapter, "claude" | "generic"); + let entry = descriptor.stdio_entry(include_type, false); + let previous = json_container_mut(&mut payload, &root_path)? + .insert(descriptor.name.clone(), entry.clone()); + let action = action_for_json(previous.as_ref(), &entry, existing.is_some()); + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else { + action + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: payload.clone(), + payload, + }) +} + +fn remove_json_config( + adapter: &str, + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!({}), + }); + }; + let mut payload = if existing.trim().is_empty() { + json!({}) + } else { + serde_json::from_str::(existing) + .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? + }; + if !payload.is_object() { + return Err("MCP config must contain a JSON object".to_string()); + } + let root_path = match adapter { + "github-copilot" => vec!["servers"], + "openclaw" => vec!["mcp", "servers"], + _ => vec!["mcpServers"], + }; + let previous = json_container_mut(&mut payload, &root_path)?.remove(server_name); + let action = if previous.is_some() { + "removed" + } else { + "unchanged" + } + .to_string(); + let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; + Ok(RemovedNativeConfig { + text, + action, + previous: previous.unwrap_or(serde_json::Value::Null), + payload, + }) +} + +fn render_codex_config( + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let entry = descriptor.stdio_entry(false, true); + let patch = codex_toml_block(descriptor); + let (text, previous) = + upsert_toml_block(existing.unwrap_or_default(), &descriptor.name, &patch); + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else if previous.is_none() { + "created".to_string() + } else if previous.as_deref() == Some(patch.trim_end()) { + "unchanged".to_string() + } else { + "updated".to_string() + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: json!(patch), + payload: json!(patch), + }) +} + +fn remove_codex_config( + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!(""), + }); + }; + let (text, previous) = remove_toml_block(existing, server_name); + Ok(RemovedNativeConfig { + text, + action: if previous.is_some() { + "removed".to_string() + } else { + "unchanged".to_string() + }, + previous: previous + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + payload: json!(existing), + }) +} + +fn render_hermes_config( + existing: Option<&str>, + descriptor: &NativeMcpDescriptor, +) -> Result { + let entry = descriptor.stdio_entry(true, false); + let patch = hermes_yaml_block(descriptor); + let (text, previous) = upsert_marked_block(existing.unwrap_or_default(), &patch); + let action = if existing == Some(text.as_str()) { + "unchanged".to_string() + } else if previous.is_none() { + "created".to_string() + } else if previous.as_deref() == Some(patch.trim_end()) { + "unchanged".to_string() + } else { + "updated".to_string() + }; + Ok(RenderedNativeConfig { + text, + action, + entry, + patch: json!(patch), + payload: json!(patch), + }) +} + +fn remove_hermes_config( + existing: Option<&str>, + server_name: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(RemovedNativeConfig { + text: String::new(), + action: "unchanged".to_string(), + previous: serde_json::Value::Null, + payload: json!(""), + }); + }; + let (text, previous) = remove_marked_block(existing); + let previous = previous.filter(|block| block.contains(&format!(" {server_name}:"))); + let action = if previous.is_some() { + "removed".to_string() + } else { + "unchanged".to_string() + }; + Ok(RemovedNativeConfig { + text: if previous.is_some() { + text + } else { + existing.to_string() + }, + action, + previous: previous + .map(serde_json::Value::String) + .unwrap_or(serde_json::Value::Null), + payload: json!(existing), + }) +} + +fn json_container_mut<'a>( + payload: &'a mut serde_json::Value, + path: &[&str], +) -> Result<&'a mut serde_json::Map, String> { + let mut cursor = payload + .as_object_mut() + .ok_or_else(|| "MCP config must contain a JSON object".to_string())?; + for key in path { + let next = cursor + .entry((*key).to_string()) + .or_insert_with(|| json!({})); + cursor = next + .as_object_mut() + .ok_or_else(|| format!("MCP config key must contain an object: {}", path.join(".")))?; + } + Ok(cursor) +} + +fn action_for_json( + previous: Option<&serde_json::Value>, + next_value: &serde_json::Value, + file_exists: bool, +) -> String { + if !file_exists { + "created".to_string() + } else if previous == Some(next_value) { + "unchanged".to_string() + } else { + "updated".to_string() + } +} + +fn copilot_studio_metadata(descriptor: &NativeMcpDescriptor) -> serde_json::Value { + json!({ + "kind": "copilot_studio_manual_metadata", + "stdio": descriptor.stdio_entry(true, false), + "http": { + "url": "http://127.0.0.1:8765/mcp", + "start_command": [ + descriptor.command, + "mcp", + "http", + "--config", + descriptor.setup_config_path, + "--host", + "127.0.0.1", + "--port", + "8765", + "--path", + "/mcp" + ], + "host": "127.0.0.1", + "port": 8765, + "path": "/mcp", + }, + "notes": [ + "No local client configuration file is written for Copilot Studio.", + "Remote Copilot Studio use requires user-managed endpoint exposure, bearer-token configuration, and TLS.", + ], + }) +} + +fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { + format!( + "[mcp_servers.{}]\ncommand = {}\nargs = {}\nstartup_timeout_sec = {}\n", + descriptor.name, + toml_string(&descriptor.command), + toml_array(&descriptor.args), + descriptor.timeout + ) +} + +fn toml_array(values: &[String]) -> String { + format!( + "[{}]", + values + .iter() + .map(|value| toml_string(value)) + .collect::>() + .join(", ") + ) +} + +fn toml_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn upsert_toml_block(existing: &str, server_name: &str, block: &str) -> (String, Option) { + let lines = existing.lines().collect::>(); + let header = format!("[mcp_servers.{server_name}]"); + let env_header = format!("[mcp_servers.{server_name}.env]"); + let start = lines + .iter() + .position(|line| line.trim() == header || line.trim() == env_header); + let Some(start) = start else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + let end = lines[start + 1..] + .iter() + .position(|line| { + let trimmed = line.trim(); + trimmed.starts_with('[') + && trimmed.ends_with(']') + && trimmed != header + && trimmed != env_header + }) + .map(|index| start + 1 + index) + .unwrap_or(lines.len()); + let previous = lines[start..end].join("\n").trim_end().to_string(); + let mut next_lines = Vec::new(); + next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); + next_lines.extend(block.trim_end().lines().map(str::to_string)); + next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); + ( + next_lines.join("\n").trim_end().to_string() + "\n", + Some(previous), + ) +} + +fn remove_toml_block(existing: &str, server_name: &str) -> (String, Option) { + let lines = existing.lines().collect::>(); + let header = format!("[mcp_servers.{server_name}]"); + let env_header = format!("[mcp_servers.{server_name}.env]"); + let start = lines + .iter() + .position(|line| line.trim() == header || line.trim() == env_header); + let Some(start) = start else { + return (existing.to_string(), None); + }; + let end = lines[start + 1..] + .iter() + .position(|line| { + let trimmed = line.trim(); + trimmed.starts_with('[') + && trimmed.ends_with(']') + && trimmed != header + && trimmed != env_header + }) + .map(|index| start + 1 + index) + .unwrap_or(lines.len()); + let previous = lines[start..end].join("\n").trim_end().to_string(); + let mut next_lines = Vec::new(); + next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); + next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); + let text = next_lines.join("\n").trim().to_string(); + let text = if text.is_empty() { + String::new() + } else { + text + "\n" + }; + (text, Some(previous)) +} + +fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { + let mut lines = vec![ + "# codebaseGraph MCP server start".to_string(), + "mcp_servers:".to_string(), + format!(" {}:", descriptor.name), + " type: stdio".to_string(), + format!(" command: {}", yaml_scalar(&descriptor.command)), + " args:".to_string(), + ]; + for arg in &descriptor.args { + lines.push(format!(" - {}", yaml_scalar(arg))); + } + lines.push("# codebaseGraph MCP server end".to_string()); + lines.join("\n") + "\n" +} + +fn yaml_scalar(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn upsert_marked_block(existing: &str, block: &str) -> (String, Option) { + const START: &str = "# codebaseGraph MCP server start"; + const END: &str = "# codebaseGraph MCP server end"; + let Some(start) = existing.find(START) else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + let Some(end) = existing.find(END) else { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + }; + if end < start { + let prefix = existing.trim_end(); + let separator = if prefix.is_empty() { "" } else { "\n\n" }; + return (format!("{prefix}{separator}{block}"), None); + } + let after_end = end + END.len(); + let previous = existing[start..after_end].trim_end().to_string(); + let text = format!( + "{}\n\n{}\n\n{}", + existing[..start].trim_end(), + block.trim_end(), + existing[after_end..].trim_start() + ) + .trim() + .to_string() + + "\n"; + (text, Some(previous)) +} + +fn remove_marked_block(existing: &str) -> (String, Option) { + const START: &str = "# codebaseGraph MCP server start"; + const END: &str = "# codebaseGraph MCP server end"; + let Some(start) = existing.find(START) else { + return (existing.to_string(), None); + }; + let Some(end) = existing.find(END) else { + return (existing.to_string(), None); + }; + if end < start { + return (existing.to_string(), None); + } + let after_end = end + END.len(); + let previous = existing[start..after_end].trim_end().to_string(); + let before = existing[..start].trim_end(); + let after = existing[after_end..].trim_start(); + let text = match (before.is_empty(), after.is_empty()) { + (true, true) => String::new(), + (true, false) => format!("{after}\n"), + (false, true) => format!("{before}\n"), + (false, false) => format!("{before}\n\n{after}"), + }; + (text, Some(previous)) +} + +fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create config directory {}: {error}", + parent.display() + ) + })?; + } + let tmp_path = path.with_extension(format!( + "{}.tmp", + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + )); + fs::write(&tmp_path, text).map_err(|error| { + format!( + "failed to write temporary config {}: {error}", + tmp_path.display() + ) + })?; + fs::rename(&tmp_path, path) + .map_err(|error| format!("failed to replace config {}: {error}", path.display())) +} + +fn expand_path(value: &str) -> PathBuf { + if let Some(rest) = value.strip_prefix("~/") { + return home_dir().join(rest); + } + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +fn home_dir() -> PathBuf { + env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn executable_in_path(executable: &str) -> bool { + let path = Path::new(executable); + if path.components().count() > 1 { + return path.is_file(); + } + env::var_os("PATH") + .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) + .unwrap_or(false) +} + +fn subprocess_error(completed: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&completed.stdout) + .trim() + .to_string(); + let stderr = String::from_utf8_lossy(&completed.stderr) + .trim() + .to_string(); + let output = [stdout, stderr] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let code = completed.status.code().unwrap_or(1); + if output.is_empty() { + format!("exit {code}") + } else { + format!("exit {code}: {output}") + } +} + +fn install_safe_name(value: &str) -> String { + let normalized: String = value + .trim() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' || character == '_' { + character.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + normalized.trim_matches(['.', '_', '-']).to_string() +} diff --git a/src/api/materialization.rs b/src/api/materialization.rs index 4361832..572ce72 100644 --- a/src/api/materialization.rs +++ b/src/api/materialization.rs @@ -1,9 +1,106 @@ -use crate::cli::materialization::{ - build_request, read_request, request_manifest_path, write_manifest, MaterializeOptions, +use crate::api::{ + context::{resolve_repository_root, RepoPaths}, + contracts::{MaterializationRequest, OutputFormat, RepoSelector}, }; -use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; +use crate::protocol::{ + ManifestDiff, ManifestEntry, NativeManifest, NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Instant; +#[derive(Clone, Debug)] +pub(crate) struct MaterializeOptions { + pub(crate) native_request: Option, + pub(crate) source_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) mode: String, + pub(crate) include_fts: bool, + pub(crate) semantic_enrichment: bool, + pub(crate) semantic_provider_mode: String, + pub(crate) use_git: bool, + pub(crate) git_diff: bool, + pub(crate) git_base: Option, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) candidate_paths: Vec, + pub(crate) parallel: bool, + pub(crate) progress: bool, + pub(crate) plan_only: bool, + pub(crate) help: bool, + pub(crate) json_output: bool, +} + +impl Default for MaterializeOptions { + fn default() -> Self { + Self { + native_request: None, + source_root: None, + config: None, + db: None, + manifest: None, + mode: String::new(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + plan_only: false, + help: false, + json_output: false, + } + } +} + +#[derive(Default)] +pub(crate) struct ConfigScanRules { + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, +} + +pub(crate) fn materialization_request( + options: &MaterializeOptions, + output_format: OutputFormat, +) -> Result { + Ok(MaterializationRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + native_request_path: options.native_request.clone(), + source_root: options + .source_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: options.include_patterns.clone(), + exclude_patterns: options.exclude_patterns.clone(), + candidate_paths: options.candidate_paths.clone(), + parallel: options.parallel, + progress: options.progress, + output_format, + }) +} + pub(crate) fn execute_materialization( options: &MaterializeOptions, ) -> Result< @@ -17,7 +114,8 @@ pub(crate) fn execute_materialization( Some(request_path) => read_request(request_path)?, None => build_request(options)?, }; - execute_materialization_request(options, request) + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) } pub(crate) fn execute_candidate_materialization( @@ -33,7 +131,8 @@ pub(crate) fn execute_candidate_materialization( let mut request = build_request(options)?; request.candidate_paths = candidate_paths; request.atomic_rebuild = false; - execute_materialization_request(options, request) + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) } pub(crate) fn execute_materialization_request( @@ -45,6 +144,20 @@ pub(crate) fn execute_materialization_request( NativeSyntaxMaterializationResponse, ), String, +> { + let manifest_path = request_manifest_path(options); + execute_materialization_request_with_manifest(manifest_path.as_deref(), request) +} + +pub(crate) fn execute_materialization_request_with_manifest( + manifest_path: Option<&Path>, + request: NativeSyntaxMaterializationRequest, +) -> Result< + ( + NativeSyntaxMaterializationRequest, + NativeSyntaxMaterializationResponse, + ), + String, > { let started = Instant::now(); let final_request = request; @@ -55,9 +168,9 @@ pub(crate) fn execute_materialization_request( started.elapsed().as_secs_f64(), ); - if let Some(manifest_path) = request_manifest_path(options).as_ref() { + if let Some(path) = manifest_path { write_manifest( - manifest_path, + path, &final_request, &response.rebuilt_entries, &response.diff, @@ -66,3 +179,472 @@ pub(crate) fn execute_materialization_request( Ok((final_request, response)) } + +pub(crate) fn plan_materialization_payload( + response: &NativeSyntaxMaterializationResponse, + mode: &str, + manifest_path: &Path, +) -> serde_json::Value { + let rebuilt_paths = response.diff.rebuild_paths(); + let skipped_paths = response + .snapshots + .iter() + .filter(|(_, snapshot)| snapshot.language.is_none()) + .map(|(path, _)| path.clone()) + .collect::>(); + let ignored_paths = response + .diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) + .map(str::to_string) + .collect::>(); + serde_json::json!({ + "mode": mode, + "scanned": response.snapshots.len(), + "rebuilt": rebuilt_paths.len(), + "skipped": skipped_paths.len(), + "ignored": ignored_paths.len(), + "deleted": response.diff.deleted.len(), + "diagnostics": response.diagnostics, + "manifest_path": manifest_path, + "rebuilt_paths": rebuilt_paths, + "skipped_paths": skipped_paths.clone(), + "ignored_paths": ignored_paths, + "deleted_paths": response.diff.deleted.clone(), + "would_rebuild": response.diff.rebuild_paths(), + "would_delete": response.diff.deleted, + "would_skip": skipped_paths, + "graph_summary": response.graph_summary, + "node_rows": response.node_rows, + "edge_rows": response.edge_rows, + "connector_rows": response.connector_rows, + "database_written": response.database_written, + "progress_events": response.progress_events, + "phase_timings": response.phase_timings, + }) +} + +pub(crate) fn build_request( + options: &MaterializeOptions, +) -> Result { + let source_root = resolved_source_root(options)?; + let paths = RepoPaths::derive(&source_root); + let db_path = options.db.clone().unwrap_or_else(|| paths.db_path.clone()); + let manifest_path = options + .manifest + .clone() + .unwrap_or_else(|| paths.manifest_path.clone()); + let previous_manifest = if manifest_path.exists() { + Some(read_manifest(&manifest_path)?) + } else { + None + }; + let config_path = options + .config + .clone() + .unwrap_or_else(|| paths.config_path.clone()); + let config_rules = read_materialization_config_rules(&config_path)?; + let mut include_patterns = config_rules.include_patterns; + include_patterns.extend(options.include_patterns.clone()); + let mut exclude_patterns = config_rules.exclude_patterns; + exclude_patterns.extend(options.exclude_patterns.clone()); + let ignore_patterns = read_codebase_graph_ignore(&source_root)?; + let candidate_paths = if options.candidate_paths.is_empty() { + git_candidate_paths(&source_root, options)? + } else { + normalized_candidate_paths(&options.candidate_paths) + }; + let staging_dir = paths.state_dir.join("native-staging"); + + Ok(NativeSyntaxMaterializationRequest { + source_root: source_root.to_string_lossy().to_string(), + repository_label: paths.repo_name, + mode: options.mode.clone(), + parser_version: "native-rust-cli-v1".to_string(), + manifest_schema_version: 1, + ontology: "code_ontology_v1".to_string(), + ontology_schema: Default::default(), + previous_manifest, + profiles: Vec::new(), + excluded_parts: default_excluded_parts(), + include_patterns, + exclude_patterns, + ignore_patterns, + candidate_paths, + db_path: db_path.to_string_lossy().to_string(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + schema_statements: Vec::new(), + staging_dir: staging_dir.to_string_lossy().to_string(), + atomic_rebuild: true, + strict: true, + parallel: options.parallel, + progress: options.progress, + }) +} + +pub(crate) fn read_materialization_config_rules(path: &Path) -> Result { + if !path.exists() { + return Ok(ConfigScanRules::default()); + } + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read config {}: {error}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| format!("failed to parse config {}: {error}", path.display()))?; + let materialization = value + .get("materialization") + .and_then(serde_json::Value::as_object); + Ok(ConfigScanRules { + include_patterns: materialization + .and_then(|payload| payload.get("include")) + .map(json_string_array) + .unwrap_or_default(), + exclude_patterns: materialization + .and_then(|payload| payload.get("exclude")) + .map(json_string_array) + .unwrap_or_default(), + }) +} + +pub(crate) fn json_string_array(value: &serde_json::Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn read_codebase_graph_ignore(source_root: &Path) -> Result, String> { + let path = source_root.join(".codebaseGraphignore"); + if !path.exists() { + return Ok(Vec::new()); + } + let text = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + Ok(text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_string) + .collect()) +} + +pub(crate) fn git_candidate_paths( + source_root: &Path, + options: &MaterializeOptions, +) -> Result, String> { + if !options.use_git { + return Ok(Vec::new()); + } + let mut paths = if options.git_diff && options.plan_only { + let base = options.git_base.as_deref().unwrap_or("HEAD"); + git_paths( + source_root, + &["diff", "--name-only", "--diff-filter=ACMRTD", base, "--"], + ) + .unwrap_or_default() + } else { + git_paths( + source_root, + &["ls-files", "--cached", "--others", "--exclude-standard"], + ) + .unwrap_or_default() + }; + if options.git_diff && options.plan_only { + if let Ok(untracked) = + git_paths(source_root, &["ls-files", "--others", "--exclude-standard"]) + { + paths.extend(untracked); + } + } + paths.sort(); + paths.dedup(); + Ok(paths) +} + +pub(crate) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { + let output = Command::new("git") + .args(args) + .current_dir(source_root) + .output() + .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| line.replace('\\', "/")) + .collect()) +} + +pub(crate) fn normalized_candidate_paths(paths: &[String]) -> Vec { + let mut paths = paths + .iter() + .map(|path| path.trim().trim_start_matches("./").replace('\\', "/")) + .filter(|path| !path.is_empty()) + .collect::>(); + paths.sort(); + paths.dedup(); + paths +} + +pub(crate) fn default_excluded_parts() -> Vec { + [ + ".bzr", + ".cache", + ".codebaseGraph", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".svn", + ".tox", + ".venv", + "dist", + "node_modules", + "target", + "venv", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn request_manifest_path(options: &MaterializeOptions) -> Option { + if options.native_request.is_some() { + return options.manifest.clone(); + } + let source_root = resolved_source_root(options).unwrap_or_else(|_| PathBuf::from(".")); + Some( + options + .manifest + .clone() + .unwrap_or_else(|| RepoPaths::derive(&source_root).manifest_path), + ) +} + +pub(crate) fn read_manifest(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read manifest {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse manifest {}: {error}", path.display())) +} + +pub(crate) fn read_request(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read native request {}: {error}", path.display()))?; + serde_json::from_str(&text) + .map_err(|error| format!("failed to parse native request {}: {error}", path.display())) +} + +pub(crate) fn write_manifest( + path: &Path, + request: &NativeSyntaxMaterializationRequest, + rebuilt_entries: &BTreeMap, + diff: &ManifestDiff, +) -> Result<(), String> { + let mut files = if diff.force_rebuild { + BTreeMap::new() + } else { + request + .previous_manifest + .as_ref() + .map(|manifest| manifest.files.clone()) + .unwrap_or_default() + }; + let removed: BTreeSet = diff + .deleted + .iter() + .chain(diff.rebuild_paths().iter()) + .cloned() + .collect(); + files.retain(|path, _| !removed.contains(path)); + files.extend( + rebuilt_entries + .iter() + .map(|(path, entry)| (path.clone(), entry.clone())), + ); + + let manifest = NativeManifest { + schema_version: request.manifest_schema_version, + ontology: request.ontology.clone(), + parser_version: request.parser_version.clone(), + files, + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "failed to create manifest directory {}: {error}", + parent.display() + ) + })?; + } + let text = serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + fs::write(path, format!("{text}\n")) + .map_err(|error| format!("failed to write manifest {}: {error}", path.display())) +} + +fn resolved_source_root(options: &MaterializeOptions) -> Result { + resolve_repository_root(options.source_root.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos() + )) + } + + #[test] + fn public_materialization_request_preserves_transport_fields_without_preparation() { + let root = unique_temp_dir("codebase-graph-api-materialization-request"); + fs::create_dir_all(&root).expect("temp root should exist"); + let request_path = root.join("request.json"); + + let request = materialization_request( + &MaterializeOptions { + native_request: Some(request_path.clone()), + source_root: Some(root.clone()), + db: Some(root.join("graph.ldb")), + manifest: Some(root.join("manifest.json")), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: false, + semantic_provider_mode: "local_only".to_string(), + use_git: true, + git_diff: true, + git_base: Some("origin/main".to_string()), + include_patterns: vec!["src/**/*.rs".to_string()], + exclude_patterns: vec!["target/**".to_string()], + candidate_paths: vec!["src/lib.rs".to_string()], + parallel: false, + progress: true, + ..MaterializeOptions::default() + }, + OutputFormat::Typed, + ) + .expect("request should be built"); + + assert_eq!(request.native_request_path, Some(request_path)); + assert_eq!(request.repo.repo_root, Some(root.clone())); + assert_eq!(request.repo.db_path, Some(root.join("graph.ldb"))); + assert_eq!(request.include_patterns, vec!["src/**/*.rs"]); + assert_eq!(request.exclude_patterns, vec!["target/**"]); + assert_eq!(request.candidate_paths, vec!["src/lib.rs"]); + assert_eq!(request.git_base.as_deref(), Some("origin/main")); + assert_eq!(request.output_format, OutputFormat::Typed); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn build_request_reads_manifest_and_merges_config_rules() { + let root = unique_temp_dir("codebase-graph-api-build-request"); + let state_dir = root.join(".codebaseGraph"); + fs::create_dir_all(&state_dir).expect("state directory should exist"); + fs::write( + state_dir.join("config.json"), + serde_json::to_vec(&json!({ + "materialization": { + "include": ["src/**/*.rs"], + "exclude": ["target/**"] + } + })) + .expect("config should serialize"), + ) + .expect("config should be written"); + fs::write( + state_dir.join("manifest.json"), + serde_json::to_vec(&json!({ + "schema_version": 1, + "ontology": "code_ontology_v1", + "parser_version": "native-test", + "files": {} + })) + .expect("manifest should serialize"), + ) + .expect("manifest should be written"); + fs::write(root.join(".codebaseGraphignore"), "build/\n# note\n").expect("ignore file"); + + let request = build_request(&MaterializeOptions { + source_root: Some(root.clone()), + mode: "full".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: false, + include_patterns: vec!["tests/**/*.rs".to_string()], + exclude_patterns: vec!["dist/**".to_string()], + parallel: true, + ..MaterializeOptions::default() + }) + .expect("request should build"); + + assert_eq!( + request.repository_label, + root.file_name() + .and_then(|value| value.to_str()) + .expect("temp root should have a file name") + ); + assert!(request.previous_manifest.is_some()); + assert_eq!( + request.include_patterns, + vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()] + ); + assert_eq!( + request.exclude_patterns, + vec!["target/**".to_string(), "dist/**".to_string()] + ); + assert_eq!(request.ignore_patterns, vec!["build/".to_string()]); + assert!(request.candidate_paths.is_empty()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn request_manifest_path_uses_override_for_native_requests_only_when_present() { + let root = unique_temp_dir("codebase-graph-api-manifest-path"); + fs::create_dir_all(&root).expect("temp root should exist"); + let override_path = root.join("override-manifest.json"); + + let native = request_manifest_path(&MaterializeOptions { + native_request: Some(root.join("request.json")), + manifest: Some(override_path.clone()), + ..MaterializeOptions::default() + }); + assert_eq!(native, Some(override_path.clone())); + + let direct = request_manifest_path(&MaterializeOptions { + source_root: Some(root.clone()), + ..MaterializeOptions::default() + }); + assert_eq!( + direct, + Some( + root.canonicalize() + .expect("temp root should canonicalize") + .join(".codebaseGraph") + .join("manifest.json") + ) + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 2f4dbc1..32631b1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,9 +10,12 @@ pub mod context; pub mod contracts; pub mod core; pub mod facade; +pub(crate) mod graph_read; pub mod lifecycle; pub(crate) mod materialization; +pub(crate) mod normalization; pub mod presenter; +pub(crate) mod refresh; pub use contracts::{ ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationRequest, diff --git a/src/api/normalization.rs b/src/api/normalization.rs new file mode 100644 index 0000000..8347b2a --- /dev/null +++ b/src/api/normalization.rs @@ -0,0 +1,346 @@ +use crate::api::contracts::{ + ApiError, MaterializationRequest, OperationRequest, RefreshRequest, RepositoryLifecycleRequest, +}; +use crate::api::materialization::MaterializeOptions; + +const DEFAULT_PROFILE: &str = "brief"; +const DEFAULT_DETAIL: &str = "standard"; +const DEFAULT_MATERIALIZATION_MODE: &str = "changed"; +const DEFAULT_SEMANTIC_PROVIDER_MODE: &str = "local_only"; + +pub(crate) fn normalize_request(request: &OperationRequest) -> OperationRequest { + let mut normalized = request.clone(); + match &mut normalized { + OperationRequest::Search(request) => { + request.query = request.query.trim().to_string(); + default_string(&mut request.profile, DEFAULT_PROFILE); + default_string(&mut request.detail, DEFAULT_DETAIL); + } + OperationRequest::Context(request) => { + default_string(&mut request.profile, DEFAULT_PROFILE); + default_string(&mut request.detail, DEFAULT_DETAIL); + request.query = request + .query + .take() + .map(|query| query.trim().to_string()) + .filter(|query| !query.is_empty()); + request.node_id = normalized_optional_string(request.node_id.take()); + request.node_type = normalized_optional_string(request.node_type.take()); + } + OperationRequest::Query(request) => { + request.statement = request.statement.trim().to_string(); + } + OperationRequest::Materialize(request) | OperationRequest::Plan(request) => { + normalize_materialization(request); + } + OperationRequest::Setup(request) + | OperationRequest::Reinstall(request) + | OperationRequest::Uninstall(request) => { + normalize_lifecycle(request); + } + OperationRequest::Refresh(request) => normalize_refresh(request), + OperationRequest::Catalog { kind, group, .. } => { + *kind = kind.trim().to_string(); + *group = normalized_optional_string(group.take()); + } + OperationRequest::Health(_) => {} + } + normalized +} + +pub(crate) fn normalize_materialize_options(options: &mut MaterializeOptions) { + default_string(&mut options.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut options.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + normalize_paths(&mut options.candidate_paths); +} + +pub(crate) fn validate_request(request: &OperationRequest) -> Result<(), ApiError> { + match request { + OperationRequest::Search(request) => { + validate_search_fields(&request.query, &request.detail, request.limit) + } + OperationRequest::Context(request) => { + if request.node_id.is_some() != request.node_type.is_some() { + return Err(ApiError::new( + "invalid_node_reference", + "context operation requires both node_id and node_type", + )); + } + if request.query.is_none() && request.node_id.is_none() { + return Err(ApiError::new( + "missing_query", + "context operation requires a query or explicit node reference", + )); + } + validate_detail_and_limit(&request.detail, request.limit) + } + OperationRequest::Query(request) => { + if request.statement.is_empty() { + return Err(ApiError::new( + "missing_query", + "graph_query requires a non-empty statement", + )); + } + if request.limit == 0 || request.limit > 1000 { + return Err(ApiError::new( + "invalid_limit", + "graph_query limit must be between 1 and 1000", + )); + } + if !request.parameters.is_object() { + return Err(ApiError::new( + "query_invalid_parameters", + "query parameters must be an object", + )); + } + Ok(()) + } + OperationRequest::Materialize(request) | OperationRequest::Plan(request) => { + validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + "materialization", + ) + } + OperationRequest::Refresh(request) => validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + "refresh", + ), + OperationRequest::Setup(request) => validate_lifecycle(request, "setup"), + OperationRequest::Reinstall(request) => validate_lifecycle(request, "reinstall"), + OperationRequest::Uninstall(request) => validate_lifecycle(request, "uninstall"), + OperationRequest::Catalog { kind, .. } => { + if kind.is_empty() { + Err(ApiError::new( + "invalid_catalog_kind", + "catalog kind must not be empty", + )) + } else { + Ok(()) + } + } + OperationRequest::Health(_) => Ok(()), + } +} + +pub(crate) fn required_fields(operation_id: &str) -> &'static [&'static str] { + match operation_id { + "search" => &["query"], + "query" => &["statement"], + _ => &[], + } +} + +fn normalize_materialization(request: &mut MaterializationRequest) { + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + request.source_root = normalized_optional_string(request.source_root.take()); + if request.repo.repo_root.is_none() { + request.repo.repo_root = request.source_root.as_deref().map(std::path::PathBuf::from); + } + request.git_base = normalized_optional_string(request.git_base.take()); + normalize_paths(&mut request.candidate_paths); +} + +fn normalize_refresh(request: &mut RefreshRequest) { + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + normalize_paths(&mut request.paths); +} + +fn normalize_lifecycle(request: &mut RepositoryLifecycleRequest) { + request.action = request.action.trim().to_string(); + default_string(&mut request.mode, DEFAULT_MATERIALIZATION_MODE); + default_string( + &mut request.semantic_provider_mode, + DEFAULT_SEMANTIC_PROVIDER_MODE, + ); + request.mcp_client = normalized_optional_string(request.mcp_client.take()); + request.instructions_target = normalized_optional_string(request.instructions_target.take()); +} + +fn validate_search_fields(query: &str, detail: &str, limit: usize) -> Result<(), ApiError> { + if query.trim().is_empty() { + return Err(ApiError::new( + "missing_query", + "Search query must not be empty", + )); + } + validate_detail_and_limit(detail, limit) +} + +fn validate_detail_and_limit(detail: &str, limit: usize) -> Result<(), ApiError> { + if detail != "standard" && detail != "slim" { + return Err(ApiError::new( + "invalid_detail", + "detail must be standard or slim", + )); + } + if limit == 0 { + return Err(ApiError::new( + "invalid_limit", + "search limit must be greater than zero", + )); + } + Ok(()) +} + +fn validate_materialization_fields( + mode: &str, + semantic_provider_mode: &str, + operation: &str, +) -> Result<(), ApiError> { + if mode != "full" && mode != "changed" { + return Err(ApiError::new( + "invalid_materialization_mode", + format!("{operation} mode must be full or changed"), + )); + } + if semantic_provider_mode != "local_only" { + return Err(ApiError::new( + "invalid_semantic_provider_mode", + format!("{operation} semantic provider mode must be local_only"), + )); + } + Ok(()) +} + +fn validate_lifecycle( + request: &RepositoryLifecycleRequest, + expected_action: &str, +) -> Result<(), ApiError> { + if request.action != expected_action { + return Err(ApiError::new( + "invalid_lifecycle_action", + format!( + "lifecycle request action {} does not match {expected_action}", + request.action + ), + )); + } + validate_materialization_fields( + &request.mode, + &request.semantic_provider_mode, + expected_action, + ) +} + +fn default_string(value: &mut String, default: &str) { + let normalized = value.trim(); + *value = if normalized.is_empty() { + default.to_string() + } else { + normalized.to_string() + }; +} + +fn normalized_optional_string(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_paths(paths: &mut Vec) { + for path in paths.iter_mut() { + *path = path.trim().trim_start_matches("./").replace('\\', "/"); + } + paths.retain(|path| !path.is_empty()); + paths.sort(); + paths.dedup(); +} + +#[cfg(test)] +mod tests { + use super::{normalize_request, required_fields, validate_request}; + use crate::api::contracts::{ + ContextRequest, MaterializationRequest, OperationRequest, OutputFormat, RepoSelector, + SearchRequest, + }; + + #[test] + fn normalize_request_applies_canonical_defaults() { + let normalized = normalize_request(&OperationRequest::Search(SearchRequest { + repo: RepoSelector::default(), + query: "needle".to_string(), + profile: " ".to_string(), + limit: 3, + budget: 600, + context_limit: 3, + max_depth: None, + detail: String::new(), + output_format: OutputFormat::Typed, + })); + + let OperationRequest::Search(search) = normalized else { + panic!("search request should remain a search request"); + }; + assert_eq!(search.profile, "brief"); + assert_eq!(search.detail, "standard"); + + let normalized = + normalize_request(&OperationRequest::Materialize(MaterializationRequest { + repo: RepoSelector::default(), + native_request_path: None, + source_root: Some(" ./repository ".to_string()), + mode: String::new(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + output_format: OutputFormat::Typed, + })); + let OperationRequest::Materialize(materialization) = normalized else { + panic!("materialization request should remain a materialization request"); + }; + assert_eq!(materialization.mode, "changed"); + assert_eq!(materialization.semantic_provider_mode, "local_only"); + assert_eq!( + materialization.repo.repo_root, + Some(std::path::PathBuf::from("./repository")) + ); + } + + #[test] + fn validate_request_rejects_invalid_operation_rules_before_execution() { + let error = validate_request(&OperationRequest::Context(ContextRequest { + repo: RepoSelector::default(), + query: None, + profile: "brief".to_string(), + limit: 3, + budget: 600, + context_limit: 3, + max_depth: None, + detail: "standard".to_string(), + node_id: Some("Function:1".to_string()), + node_type: None, + output_format: OutputFormat::Typed, + })) + .expect_err("partial node reference should be rejected"); + + assert_eq!(error.code, "invalid_node_reference"); + } + + #[test] + fn operation_schema_requirements_share_normalization_policy() { + assert_eq!(required_fields("search"), ["query"]); + assert_eq!(required_fields("query"), ["statement"]); + assert!(required_fields("context").is_empty()); + } +} diff --git a/src/api/presentation_support.rs b/src/api/presentation_support.rs new file mode 100644 index 0000000..e7e3d8f --- /dev/null +++ b/src/api/presentation_support.rs @@ -0,0 +1,483 @@ +pub(crate) fn serialize_schema_block(payload: &serde_json::Value) -> String { + let node_types = value_array(payload, "node_types"); + let relation_types = value_array(payload, "relation_types"); + let parser_mappings = value_array(payload, "parser_node_mappings"); + let search_indexes = value_array(payload, "search_indexes"); + let profiles = payload + .get("context_profiles") + .and_then(serde_json::Value::as_object) + .map(serde_json::Map::len) + .unwrap_or_default(); + let query_helpers = value_array(payload, "query_helpers"); + let mut lines = vec![format!( + "schema {} version={} nodes={} relations={} parser_mappings={} indexes={} profiles={} helpers={}", + block_value(value_str(payload, "ontology")), + block_value(value_str(payload, "version")), + node_types.len(), + relation_types.len(), + parser_mappings.len(), + search_indexes.len(), + profiles, + query_helpers.len() + )]; + if !node_types.is_empty() { + lines.push(format!("node_types {}", csv_names(node_types))); + } + if !relation_types.is_empty() { + lines.push(format!("relation_types {}", csv_names(relation_types))); + } + for index in search_indexes { + lines.push(format!( + "index {} node_types={} fields={}", + block_value(value_str(index, "name")), + csv_values(index.get("node_types")), + csv_values(index.get("fields")) + )); + } + if let Some(context_profiles) = payload + .get("context_profiles") + .and_then(serde_json::Value::as_object) + { + for (name, profile) in context_profiles { + let relations = csv_values(profile.get("relations")); + if relations.is_empty() { + lines.push(format!("profile {}", block_value(name))); + } else { + lines.push(format!( + "profile {} relations={relations}", + block_value(name) + )); + } + } + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { + let helpers = value_array(payload, "query_helpers"); + let mut lines = vec![format!("query_helpers count={}", helpers.len())]; + for helper in helpers { + append_query_spec_lines(&mut lines, helper, ""); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { + let groups = value_array(payload, "groups"); + let mut lines = vec![format!( + "architecture_queries workflow={} execution_tool={} groups={}", + block_value(value_str(payload, "workflow")), + block_value(value_str(payload, "execution_tool")), + groups.len() + )]; + let recommended_order = csv_values(payload.get("recommended_order")); + if !recommended_order.is_empty() { + lines.push(format!("recommended_order {recommended_order}")); + } + for group in groups { + lines.push(format!( + "group {} goal={}", + block_value(value_str(group, "name")), + block_value(value_str(group, "goal")) + )); + for query in value_array(group, "queries") { + append_query_spec_lines(&mut lines, query, " "); + } + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_health_block(payload: &serde_json::Value) -> String { + let mut lines = vec![format!( + "health ok={} database_exists={} manifest_exists={} graph_readable={} total_nodes={}", + value_bool(payload, "ok"), + value_bool(payload, "database_exists"), + value_bool(payload, "manifest_exists"), + value_bool(payload, "graph_readable"), + payload + .get("total_nodes") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() + )]; + for key in ["repo_root", "database_path", "manifest_path"] { + lines.push(format!("{key} {}", block_value(value_str(payload, key)))); + } + if let Some(error) = payload.get("error").and_then(serde_json::Value::as_str) { + lines.push(format!("error {}", block_value(error))); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_search_block(payload: &serde_json::Value) -> String { + let results = value_array(payload, "results"); + let mut lines = vec![format!("q {}", block_value(value_str(payload, "query")))]; + let mut current_path: Option = None; + for result in results { + let result_path = value_str(result, "path").to_string(); + if current_path.as_deref() != Some(result_path.as_str()) { + if lines.len() > 1 { + lines.push(String::new()); + } + lines.push(format!("file path {}", block_value(&result_path))); + current_path = Some(result_path); + } + let mut result_parts = vec![ + format!("- {}", value_str(result, "type")), + block_value(value_str(result, "label")), + block_span(result.get("span")), + ]; + if let Some(rank_score) = result.get("rank_score").and_then(serde_json::Value::as_f64) { + result_parts.push(format!("rank_score={rank_score:.2}")); + } + if let Some(id) = result.get("id").and_then(serde_json::Value::as_str) { + result_parts.push(format!("id={}", block_value(id))); + } + let summary = value_str(result, "summary"); + if !summary.is_empty() && summary != value_str(result, "label") { + result_parts.push(format!("summary={}", block_value(summary))); + } + lines.push(result_parts.join(" ")); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_query_block(payload: &serde_json::Value) -> String { + let rows = value_array(payload, "rows"); + let columns = query_columns(value_str(payload, "statement")); + let mut lines = vec![format!( + "query rows={} truncated={}", + payload + .get("row_count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(rows.len() as u64), + value_bool(payload, "truncated") + )]; + lines.push(format!( + "statement {}", + block_value(value_str(payload, "statement")) + )); + if !columns.is_empty() { + lines.push(format!("columns {}", columns.join(","))); + } + for (index, row) in rows.iter().enumerate() { + let values = row + .as_array() + .cloned() + .unwrap_or_else(|| vec![(*row).clone()]); + let row_text = if !columns.is_empty() && columns.len() == values.len() { + columns + .iter() + .zip(values.iter()) + .map(|(column, value)| format!("{column}={}", block_json_value(value))) + .collect::>() + .join(" ") + } else { + values + .iter() + .map(block_json_value) + .collect::>() + .join(" ") + }; + lines.push( + format!("row {} {}", index + 1, row_text) + .trim_end() + .to_string(), + ); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_context_block(payload: &serde_json::Value) -> String { + let mut lines = vec![format!( + "context {} id={} profile={}", + value_str(payload, "node_type"), + block_value(value_str(payload, "node_id")), + block_value(value_str(payload, "profile")) + )]; + let mut current_path: Option = None; + for context in value_array(payload, "context") { + let context_path = value_str(context, "path").to_string(); + if current_path.as_deref() != Some(context_path.as_str()) { + if lines.len() > 1 { + lines.push(String::new()); + } + lines.push(format!("file path {}", block_value(&context_path))); + current_path = Some(context_path); + } + let mut parts = vec![ + value_str(context, "direction").to_string(), + value_str(context, "relation").to_string(), + value_str(context, "type").to_string(), + block_value(value_str(context, "label")), + block_span(context.get("span")), + ]; + let summary = value_str(context, "summary"); + if !summary.is_empty() && summary != value_str(context, "label") { + parts.push(format!("summary={}", block_value(summary))); + } + lines.push(parts.join(" ").trim_end().to_string()); + } + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_plan_block(payload: &serde_json::Value) -> String { + let mut lines = vec![format!( + "plan mode={} scanned={} rebuild={} delete={} skip={} ignored={}", + block_value(value_str(payload, "mode")), + payload + .get("scanned") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("rebuilt") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("deleted") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("skipped") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + payload + .get("ignored") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + )]; + append_plan_path_lines(&mut lines, "rebuild", value_array(payload, "would_rebuild")); + append_plan_path_lines(&mut lines, "delete", value_array(payload, "would_delete")); + append_plan_path_lines(&mut lines, "skip", value_array(payload, "would_skip")); + append_plan_path_lines(&mut lines, "ignore", value_array(payload, "ignored_paths")); + format!("{}\n", lines.join("\n")) +} + +pub(crate) fn serialize_uninstall_block(output: &serde_json::Value) -> String { + let mut lines = vec![format!( + "uninstall ok={} server_name={}", + output["ok"].as_bool().unwrap_or(false), + output["server_name"].as_str().unwrap_or_default() + )]; + if let Some(state) = output["state"].as_object() { + lines.push(format!( + "state action={} path={}", + state + .get("action") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + state + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + )); + } + for item in output["instructions"].as_array().into_iter().flatten() { + lines.push(format!( + "instructions action={} path={}", + item["action"].as_str().unwrap_or("unknown"), + item["path"].as_str().unwrap_or_default() + )); + } + for item in output["mcp_clients"].as_array().into_iter().flatten() { + lines.push(format!( + "mcp client={} action={} path={}", + item["client"].as_str().unwrap_or("unknown"), + item["action"].as_str().unwrap_or("unknown"), + item["path"].as_str().unwrap_or_default() + )); + } + lines.join("\n") + "\n" +} + +fn block_span(value: Option<&serde_json::Value>) -> String { + let Some(span) = value.and_then(serde_json::Value::as_object) else { + return String::new(); + }; + let start = span + .get("line_start") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let end = span + .get("line_end") + .and_then(serde_json::Value::as_i64) + .unwrap_or(start); + if start <= 0 && end <= 0 { + String::new() + } else { + format!("L{start}-L{end}") + } +} + +fn append_query_spec_lines(lines: &mut Vec, query: &serde_json::Value, indent: &str) { + lines.push(format!( + "{indent}query {} description={}", + block_value(value_str(query, "name")), + block_value(value_str(query, "description")) + )); + let parameters = csv_values(query.get("parameters")); + if !parameters.is_empty() { + lines.push(format!("{indent}parameters {parameters}")); + } + let returns = csv_values(query.get("returns")); + if !returns.is_empty() { + lines.push(format!("{indent}returns {returns}")); + } + if let Some(statement) = query + .get("statement") + .or_else(|| query.get("query")) + .and_then(serde_json::Value::as_str) + { + lines.push(format!("{indent}statement {}", block_value(statement))); + } +} + +fn query_columns(statement: &str) -> Vec { + let upper = statement.to_ascii_uppercase(); + let Some(return_index) = upper.find("RETURN") else { + return Vec::new(); + }; + let after_return = return_index + "RETURN".len(); + let end = ["ORDER BY", "LIMIT"] + .iter() + .filter_map(|marker| { + upper[after_return..] + .find(marker) + .map(|index| after_return + index) + }) + .min() + .unwrap_or(statement.len()); + split_return_expressions(&statement[after_return..end]) + .into_iter() + .filter_map(|expression| query_column_label(&expression)) + .collect() +} + +fn split_return_expressions(text: &str) -> Vec { + let mut expressions = Vec::new(); + let mut current = String::new(); + let mut depth = 0_i32; + for character in text.chars() { + match character { + '(' => { + depth += 1; + current.push(character); + } + ')' => { + depth = (depth - 1).max(0); + current.push(character); + } + ',' if depth == 0 => { + let trimmed = current.trim(); + if !trimmed.is_empty() { + expressions.push(trimmed.to_string()); + } + current.clear(); + } + _ => current.push(character), + } + } + let trimmed = current.trim(); + if !trimmed.is_empty() { + expressions.push(trimmed.to_string()); + } + expressions +} + +fn query_column_label(expression: &str) -> Option { + let parts = expression.split_whitespace().collect::>(); + for index in 0..parts.len().saturating_sub(1) { + if parts[index].eq_ignore_ascii_case("AS") && is_identifier(parts[index + 1]) { + return Some(parts[index + 1].to_string()); + } + } + let label = expression.rsplit('.').next()?.trim(); + if is_identifier(label) { + Some(label.to_string()) + } else { + None + } +} + +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} + +fn block_json_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::String(value) => block_value(value), + other => serde_json::to_string(other).unwrap_or_else(|_| "{}".to_string()), + } +} + +fn value_array<'a>(payload: &'a serde_json::Value, key: &str) -> &'a [serde_json::Value] { + payload + .get(key) + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { + payload + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") +} + +fn value_bool(payload: &serde_json::Value, key: &str) -> bool { + payload + .get(key) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +fn csv_names(values: &[serde_json::Value]) -> String { + values + .iter() + .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) + .map(block_value) + .collect::>() + .join(",") +} + +fn csv_values(value: Option<&serde_json::Value>) -> String { + value + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(block_value) + .collect::>() + .join(",") + }) + .unwrap_or_default() +} + +fn append_plan_path_lines(lines: &mut Vec, label: &str, paths: &[serde_json::Value]) { + for path in paths { + if let Some(path) = path.as_str() { + lines.push(format!("{label} {}", block_value(path))); + } + } +} + +fn block_value(value: &str) -> String { + if value.is_empty() { + "\"\"".to_string() + } else if value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | '/' | ':') + }) { + value.to_string() + } else { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) + } +} diff --git a/src/api/presenter.rs b/src/api/presenter.rs index 0419e92..57ce78e 100644 --- a/src/api/presenter.rs +++ b/src/api/presenter.rs @@ -1,11 +1,11 @@ use crate::api::contracts::{OperationResponse, OutputFormat}; -use crate::cli::format::{ +#[path = "presentation_support.rs"] +mod support; +use self::support::{ serialize_architecture_queries_block, serialize_context_block, serialize_health_block, - serialize_query_block, serialize_query_helpers_block, serialize_schema_block, - serialize_search_block, + serialize_plan_block, serialize_query_block, serialize_query_helpers_block, + serialize_schema_block, serialize_search_block, serialize_uninstall_block, }; -use crate::cli::materialization::serialize_plan_block; -use crate::cli::uninstall::serialize_uninstall_block; fn present_block(operation: &str, payload: &serde_json::Value) -> String { match operation { diff --git a/src/api/refresh.rs b/src/api/refresh.rs new file mode 100644 index 0000000..d33912b --- /dev/null +++ b/src/api/refresh.rs @@ -0,0 +1,1363 @@ +use crate::{ + api::{ + context::{resolve_runtime, RepoPaths}, + contracts::RepoSelector, + lifecycle::is_retryable_refresh_failure, + materialization::{ + default_excluded_parts, execute_candidate_materialization, read_codebase_graph_ignore, + read_materialization_config_rules, MaterializeOptions, + }, + normalization::normalize_materialize_options, + }, + protocol::NativeSyntaxMaterializationResponse, +}; +use notify::{ + event::{AccessKind, AccessMode}, + Event, EventKind, RecursiveMode, Watcher, +}; +use serde_json::json; +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + env, fs, + path::{Path, PathBuf}, + sync::{ + mpsc::{self, Receiver}, + Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug)] +pub(crate) struct WatchEventFilter { + pub(crate) source_root: PathBuf, + pub(crate) current_dir: PathBuf, + pub(crate) excluded_parts: BTreeSet, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) ignore_patterns: Vec, +} + +impl WatchEventFilter { + pub(crate) fn from_options( + source_root: &Path, + options: &MaterializeOptions, + ) -> Result { + let config_path = options + .config + .clone() + .unwrap_or_else(|| config_path_for(source_root)); + let config_rules = read_materialization_config_rules(&config_path)?; + let mut include_patterns = config_rules.include_patterns; + include_patterns.extend(options.include_patterns.clone()); + let mut exclude_patterns = config_rules.exclude_patterns; + exclude_patterns.extend(options.exclude_patterns.clone()); + Ok(Self { + source_root: source_root.to_path_buf(), + current_dir: env::current_dir().unwrap_or_else(|_| source_root.to_path_buf()), + excluded_parts: default_excluded_parts().into_iter().collect(), + include_patterns, + exclude_patterns, + ignore_patterns: read_codebase_graph_ignore(source_root)?, + }) + } + + pub(crate) fn relevant_paths(&self, event: &Event) -> BTreeSet { + if !watch_event_refreshes(event) { + return BTreeSet::new(); + } + event + .paths + .iter() + .filter_map(|path| self.relevant_path(path)) + .collect() + } + + pub(crate) fn relevant_path(&self, path: &Path) -> Option { + let relative = self.relative_event_path(path)?; + if relative.as_os_str().is_empty() { + return None; + } + if relative.components().any(|component| { + self.excluded_parts + .contains(component.as_os_str().to_string_lossy().as_ref()) + }) { + return None; + } + let relative = relative.to_string_lossy().replace('\\', "/"); + if self.ignored_by_patterns(&relative) { + None + } else { + Some(relative) + } + } + + pub(crate) fn relative_event_path(&self, path: &Path) -> Option { + if let Ok(relative) = path.strip_prefix(&self.source_root) { + return Some(relative.to_path_buf()); + } + if path.is_relative() { + let absolute = self.current_dir.join(path); + if let Ok(relative) = absolute.strip_prefix(&self.source_root) { + return Some(relative.to_path_buf()); + } + #[cfg(windows)] + { + let absolute = normalize_windows_verbatim_path(&absolute); + let source_root = normalize_windows_verbatim_path(&self.source_root); + if let Ok(relative) = absolute.strip_prefix(source_root) { + return Some(relative.to_path_buf()); + } + } + return Some(path.to_path_buf()); + } + None + } + + pub(crate) fn ignored_by_patterns(&self, relative_path: &str) -> bool { + if !self.include_patterns.is_empty() + && !watch_matches_any_pattern(relative_path, &self.include_patterns) + { + return true; + } + watch_matches_any_pattern(relative_path, &self.ignore_patterns) + || watch_matches_any_pattern(relative_path, &self.exclude_patterns) + } +} + +#[cfg(windows)] +fn normalize_windows_verbatim_path(path: &Path) -> PathBuf { + let normalized = path.to_string_lossy().replace('\\', "/"); + if let Some(stripped) = normalized.strip_prefix("//?/UNC/") { + PathBuf::from(format!("//{stripped}")) + } else if let Some(stripped) = normalized.strip_prefix("//?/") { + PathBuf::from(stripped) + } else { + PathBuf::from(normalized) + } +} + +fn config_path_for(source_root: &Path) -> PathBuf { + RepoPaths::derive(source_root).config_path +} + +pub(crate) fn watch_event_refreshes(event: &Event) -> bool { + matches!( + event.kind, + EventKind::Any + | EventKind::Create(_) + | EventKind::Modify(_) + | EventKind::Remove(_) + | EventKind::Other + | EventKind::Access(AccessKind::Close(AccessMode::Write)) + ) +} + +#[derive(Debug)] +pub(crate) enum WatchMessage { + Event(Event), + Error(String), +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct WatchChangeBatch { + pub(crate) paths: BTreeSet, + pub(crate) event_count: usize, +} + +#[derive(Debug, Default)] +pub(crate) struct WatchProbeOutcome { + pub(crate) delivered: bool, + pub(crate) queued: VecDeque, + pub(crate) reason: Option, +} + +pub(crate) fn start_native_watcher( + source_root: &Path, +) -> Result<(notify::RecommendedWatcher, Receiver), String> { + let (tx, rx) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |result: notify::Result| { + let message = match result { + Ok(event) => WatchMessage::Event(event), + Err(error) => WatchMessage::Error(error.to_string()), + }; + let _ = tx.send(message); + }) + .map_err(|error| format!("failed to start filesystem watcher: {error}"))?; + watcher + .watch(source_root, RecursiveMode::Recursive) + .map_err(|error| format!("failed to watch {}: {error}", source_root.display()))?; + Ok((watcher, rx)) +} + +pub(crate) fn probe_native_watcher( + source_root: &Path, + filter: &WatchEventFilter, + rx: &Receiver, +) -> Result { + let timeout = watch_probe_timeout(); + let probe_dir = source_root.join(".codebaseGraph").join("watch-probe"); + let probe_path = probe_dir.join(format!( + "probe-{}-{}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + )); + if !watch_probe_skip_write() { + fs::create_dir_all(&probe_dir) + .map_err(|error| format!("failed to create watch probe directory: {error}"))?; + fs::write(&probe_path, b"probe") + .map_err(|error| format!("failed to write watch probe: {error}"))?; + } + + let started = Instant::now(); + let mut outcome = WatchProbeOutcome::default(); + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + match rx.recv_timeout(remaining) { + Ok(WatchMessage::Event(event)) => { + outcome.delivered = true; + if !watch_event_is_under_dir(&event, &probe_dir, source_root, &filter.current_dir) { + outcome.queued.push_back(WatchMessage::Event(event)); + } + } + Ok(WatchMessage::Error(error)) => { + outcome.reason = Some("watcher_error".to_string()); + outcome.queued.push_back(WatchMessage::Error(error)); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => break, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("filesystem watcher stopped during health probe".to_string()) + } + } + } + let _ = fs::remove_file(&probe_path); + if !outcome.delivered && outcome.reason.is_none() { + outcome.reason = Some("probe_timeout".to_string()); + } + Ok(outcome) +} + +fn watch_probe_timeout() -> Duration { + env::var("CODEBASE_GRAPH_WATCH_PROBE_TIMEOUT_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or_else(|| Duration::from_millis(750)) +} + +fn watch_probe_skip_write() -> bool { + env::var("CODEBASE_GRAPH_WATCH_PROBE_SKIP_WRITE").is_ok_and(|value| value == "1") +} + +fn watch_event_is_under_dir( + event: &Event, + directory: &Path, + source_root: &Path, + current_dir: &Path, +) -> bool { + !event.paths.is_empty() + && event + .paths + .iter() + .all(|path| watch_path_is_under_dir(path, directory, source_root, current_dir)) +} + +fn watch_path_is_under_dir( + path: &Path, + directory: &Path, + source_root: &Path, + current_dir: &Path, +) -> bool { + if path.starts_with(directory) { + return true; + } + if path.is_relative() { + return current_dir.join(path).starts_with(directory) + || source_root.join(path).starts_with(directory); + } + false +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct WatchFileState { + pub(crate) modified_nanos: u128, + pub(crate) len: u64, +} + +pub(crate) type WatchFileSnapshot = BTreeMap; + +pub(crate) fn apply_watch_message( + message: WatchMessage, + filter: &WatchEventFilter, + batch: &mut WatchChangeBatch, +) -> Result<(), String> { + match message { + WatchMessage::Event(event) => { + let paths = filter.relevant_paths(&event); + if !paths.is_empty() { + batch.event_count += 1; + batch.paths.extend(paths); + } + Ok(()) + } + WatchMessage::Error(error) => Err(format!("filesystem watcher error: {error}")), + } +} + +pub(crate) fn collect_watch_batch( + first: WatchMessage, + rx: &Receiver, + queued: &mut VecDeque, + filter: &WatchEventFilter, + debounce: Duration, + max_wait: Duration, +) -> Result, String> { + let mut batch = WatchChangeBatch::default(); + apply_watch_message(first, filter, &mut batch)?; + if batch.paths.is_empty() { + return Ok(None); + } + + let started = Instant::now(); + let mut last_relevant = started; + loop { + let elapsed = started.elapsed(); + if elapsed >= max_wait { + return Ok(Some(batch)); + } + let quiet_elapsed = last_relevant.elapsed(); + if quiet_elapsed >= debounce { + return Ok(Some(batch)); + } + let timeout = debounce + .saturating_sub(quiet_elapsed) + .min(max_wait.saturating_sub(elapsed)); + let message = match queued.pop_front() { + Some(message) => Ok(message), + None => rx.recv_timeout(timeout), + }; + match message { + Ok(message) => { + let before = batch.paths.len(); + let before_events = batch.event_count; + apply_watch_message(message, filter, &mut batch)?; + if batch.paths.len() != before || batch.event_count != before_events { + last_relevant = Instant::now(); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => return Ok(Some(batch)), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("filesystem watcher stopped".to_string()) + } + } + } +} + +pub(crate) fn watch_file_snapshot(filter: &WatchEventFilter) -> Result { + let mut snapshot = BTreeMap::new(); + watch_file_snapshot_inner(filter, &filter.source_root, &mut snapshot)?; + Ok(snapshot) +} + +fn watch_file_snapshot_inner( + filter: &WatchEventFilter, + directory: &Path, + snapshot: &mut WatchFileSnapshot, +) -> Result<(), String> { + let entries = fs::read_dir(directory) + .map_err(|error| format!("failed to read directory {}: {error}", directory.display()))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filter.excluded_parts.contains(name) { + continue; + } + watch_file_snapshot_inner(filter, &path, snapshot)?; + } else if path.is_file() { + let Some(relative_path) = filter.relevant_path(&path) else { + continue; + }; + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let modified_nanos = metadata + .modified() + .ok() + .and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_nanos()) + }) + .unwrap_or(0); + snapshot.insert( + relative_path, + WatchFileState { + modified_nanos, + len: metadata.len(), + }, + ); + } + } + Ok(()) +} + +pub(crate) fn watch_snapshot_diff( + previous: &WatchFileSnapshot, + current: &WatchFileSnapshot, +) -> BTreeSet { + let mut changed_paths = BTreeSet::new(); + for (path, state) in current { + if previous.get(path) != Some(state) { + changed_paths.insert(path.clone()); + } + } + for path in previous.keys() { + if !current.contains_key(path) { + changed_paths.insert(path.clone()); + } + } + changed_paths +} + +pub(crate) fn collect_poll_batch( + filter: &WatchEventFilter, + previous_snapshot: &mut WatchFileSnapshot, + poll_interval: Duration, + debounce: Duration, + max_wait: Duration, +) -> Result { + loop { + thread::sleep(poll_interval); + let current_snapshot = watch_file_snapshot(filter)?; + let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); + *previous_snapshot = current_snapshot; + if changed_paths.is_empty() { + continue; + } + + let started = Instant::now(); + let mut last_relevant = started; + let mut batch = WatchChangeBatch { + paths: changed_paths, + event_count: 1, + }; + loop { + let elapsed = started.elapsed(); + if elapsed >= max_wait { + return Ok(batch); + } + let quiet_elapsed = last_relevant.elapsed(); + if quiet_elapsed >= debounce { + return Ok(batch); + } + let timeout = poll_interval + .min(debounce.saturating_sub(quiet_elapsed)) + .min(max_wait.saturating_sub(elapsed)); + thread::sleep(timeout); + let current_snapshot = watch_file_snapshot(filter)?; + let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); + *previous_snapshot = current_snapshot; + if !changed_paths.is_empty() { + batch.paths.extend(changed_paths); + batch.event_count += 1; + last_relevant = Instant::now(); + } + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RefreshLoopConfig { + pub(crate) poll_interval: Duration, + pub(crate) debounce: Duration, + pub(crate) max_wait: Duration, + pub(crate) max_iterations: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RefreshBackend { + Auto, + Native, + Poll, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RefreshWatchConfig { + pub(crate) backend: RefreshBackend, + pub(crate) loop_config: RefreshLoopConfig, + pub(crate) once: bool, +} + +pub(crate) trait RefreshWatchObserver { + fn on_success( + &mut self, + backend: Option<&str>, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_error( + &mut self, + backend: &str, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String>; +} + +pub(crate) fn run_refresh_watch( + options: &MaterializeOptions, + config: RefreshWatchConfig, + observer: &mut impl RefreshWatchObserver, +) -> Result<(), String> { + let runtime = resolve_runtime(&RepoSelector { + repo_root: options.source_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + })?; + let mut materialize_options = options.clone(); + normalize_materialize_options(&mut materialize_options); + materialize_options.source_root = Some(runtime.repo_root.clone()); + materialize_options.config = runtime.config_path; + materialize_options.db = Some(runtime.db_path); + materialize_options.manifest = Some(runtime.manifest_path); + + if config.once { + let response = execute_refresh_operation(&materialize_options, Vec::new())?; + return observer.on_success(None, &response, 0, 0); + } + + let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; + match config.backend { + RefreshBackend::Poll => run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }), + RefreshBackend::Native => { + let (watcher, rx) = start_native_watcher(&runtime.repo_root)?; + run_native_watch( + config.loop_config, + &filter, + watcher, + rx, + VecDeque::new(), + |batch| refresh_watch_batch(observer, "native", &materialize_options, batch), + ) + } + RefreshBackend::Auto => match start_native_watcher(&runtime.repo_root) { + Ok((watcher, rx)) => { + let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; + if probe.delivered { + run_native_watch( + config.loop_config, + &filter, + watcher, + rx, + probe.queued, + |batch| { + refresh_watch_batch(observer, "native", &materialize_options, batch) + }, + ) + } else { + drop(watcher); + observer + .on_fallback("poll", probe.reason.as_deref().unwrap_or("probe_failed"))?; + run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }) + } + } + Err(_) => { + observer.on_fallback("poll", "watcher_start_failed")?; + run_poll_watch(config.loop_config, &filter, |batch| { + refresh_watch_batch(observer, "poll", &materialize_options, batch) + }) + } + }, + } +} + +fn refresh_watch_batch( + observer: &mut impl RefreshWatchObserver, + backend: &str, + materialize_options: &MaterializeOptions, + batch: &WatchChangeBatch, +) -> Result { + let mut bound_observer = BoundRefreshWatchObserver { observer, backend }; + execute_refresh_with_policy( + &mut bound_observer, + batch.event_count, + &batch.paths, + RefreshRetryPolicy::default(), + |candidate_paths| execute_refresh_operation(materialize_options, candidate_paths), + ) +} + +struct BoundRefreshWatchObserver<'a, O> { + observer: &'a mut O, + backend: &'a str, +} + +impl RefreshObserver for BoundRefreshWatchObserver<'_, O> { + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.observer + .on_success(Some(self.backend), response, event_count, changed_paths) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.observer + .on_error(self.backend, error, retrying, event_count, changed_paths) + } +} + +pub(crate) fn run_poll_watch( + config: RefreshLoopConfig, + filter: &WatchEventFilter, + mut refresh: impl FnMut(&WatchChangeBatch) -> Result, +) -> Result<(), String> { + let mut previous_snapshot = watch_file_snapshot(filter)?; + let mut refreshes = 0_usize; + loop { + let batch = collect_poll_batch( + filter, + &mut previous_snapshot, + config.poll_interval, + config.debounce, + config.max_wait, + )?; + if !refresh(&batch)? { + continue; + } + refreshes += 1; + if config.max_iterations.is_some_and(|max| refreshes >= max) { + return Ok(()); + } + } +} + +pub(crate) fn run_native_watch( + config: RefreshLoopConfig, + filter: &WatchEventFilter, + _watcher: notify::RecommendedWatcher, + rx: Receiver, + mut queued: VecDeque, + mut refresh: impl FnMut(&WatchChangeBatch) -> Result, +) -> Result<(), String> { + let mut refreshes = 0_usize; + loop { + let first = match queued.pop_front() { + Some(message) => message, + None => rx + .recv() + .map_err(|error| format!("filesystem watcher stopped: {error}"))?, + }; + let Some(batch) = collect_watch_batch( + first, + &rx, + &mut queued, + filter, + config.debounce, + config.max_wait, + )? + else { + continue; + }; + if !refresh(&batch)? { + continue; + } + refreshes += 1; + if config.max_iterations.is_some_and(|max| refreshes >= max) { + return Ok(()); + } + } +} + +pub(crate) fn execute_refresh_operation( + options: &MaterializeOptions, + paths: Vec, +) -> Result { + let (_request, response) = execute_candidate_materialization(options, paths)?; + Ok(response) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RefreshRetryPolicy { + pub(crate) initial_delay: Duration, + pub(crate) max_delay: Duration, +} + +impl Default for RefreshRetryPolicy { + fn default() -> Self { + Self { + initial_delay: Duration::from_millis(100), + max_delay: Duration::from_millis(1_000), + } + } +} + +pub(crate) trait RefreshObserver { + fn before_attempt(&mut self, _event_count: usize, _changed_paths: usize) -> Result<(), String> { + Ok(()) + } + + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; +} + +pub(crate) fn execute_refresh_with_policy( + observer: &mut impl RefreshObserver, + event_count: usize, + paths: &BTreeSet, + policy: RefreshRetryPolicy, + mut refresh: impl FnMut(Vec) -> Result, +) -> Result { + let changed_paths = paths.len(); + if changed_paths == 0 { + return Ok(true); + } + + let candidate_paths = paths.iter().cloned().collect::>(); + let mut delay = policy.initial_delay; + loop { + observer.before_attempt(event_count, changed_paths)?; + match refresh(candidate_paths.clone()) { + Ok(response) => { + observer.on_success(&response, event_count, changed_paths)?; + return Ok(true); + } + Err(error) => { + let retrying = is_retryable_refresh_failure(&error); + observer.on_error(&error, retrying, event_count, changed_paths)?; + if !retrying { + return Ok(false); + } + thread::sleep(delay); + delay = delay.saturating_mul(2).min(policy.max_delay); + } + } + } +} + +pub(crate) fn watch_error_reason(error: &str) -> String { + let reason = error.lines().next().unwrap_or("refresh_failed").trim(); + if reason.is_empty() { + "refresh_failed".to_string() + } else { + reason + .split_whitespace() + .collect::>() + .join("_") + .chars() + .take(160) + .collect() + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RefreshStatus { + pub(crate) enabled: bool, + pub(crate) backend: String, + pub(crate) refreshing: bool, + pub(crate) pending: bool, + pub(crate) last_refresh_unix_ms: Option, + pub(crate) last_error: Option, + pub(crate) last_error_count: usize, + pub(crate) last_retry_unix_ms: Option, + pub(crate) last_event_count: usize, + pub(crate) last_changed_paths: usize, + pub(crate) last_rebuilt: usize, + pub(crate) last_deleted: usize, + pub(crate) last_database_written: bool, +} + +impl Default for RefreshStatus { + fn default() -> Self { + Self { + enabled: true, + backend: "starting".to_string(), + refreshing: false, + pending: false, + last_refresh_unix_ms: None, + last_error: None, + last_error_count: 0, + last_retry_unix_ms: None, + last_event_count: 0, + last_changed_paths: 0, + last_rebuilt: 0, + last_deleted: 0, + last_database_written: false, + } + } +} + +#[derive(Debug)] +pub(crate) struct RefreshState { + status: Mutex, + graph_lock: RwLock<()>, +} + +impl RefreshState { + pub(crate) fn new() -> Self { + Self { + status: Mutex::new(RefreshStatus::default()), + graph_lock: RwLock::new(()), + } + } + + pub(crate) fn snapshot(&self) -> RefreshStatus { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or_else(|_| RefreshStatus { + enabled: false, + backend: "failed".to_string(), + refreshing: false, + pending: false, + last_refresh_unix_ms: None, + last_error: Some("refresh status lock poisoned".to_string()), + last_error_count: 1, + last_retry_unix_ms: None, + last_event_count: 0, + last_changed_paths: 0, + last_rebuilt: 0, + last_deleted: 0, + last_database_written: false, + }) + } + + pub(crate) fn as_json(&self) -> serde_json::Value { + let status = self.snapshot(); + json!({ + "enabled": status.enabled, + "backend": status.backend, + "refreshing": status.refreshing, + "pending": status.pending, + "last_refresh_unix_ms": status.last_refresh_unix_ms, + "last_error": status.last_error, + "last_error_count": status.last_error_count, + "last_retry_unix_ms": status.last_retry_unix_ms, + "last_event_count": status.last_event_count, + "last_changed_paths": status.last_changed_paths, + "last_rebuilt": status.last_rebuilt, + "last_deleted": status.last_deleted, + "last_database_written": status.last_database_written, + }) + } + + pub(crate) fn read_guard(&self) -> Result, String> { + self.graph_lock + .read() + .map_err(|_| "refresh graph read lock poisoned".to_string()) + } + + pub(crate) fn write_guard(&self) -> Result, String> { + self.graph_lock + .write() + .map_err(|_| "refresh graph write lock poisoned".to_string()) + } + + pub(crate) fn set_backend(&self, backend: &str) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.enabled = true; + status.last_error = None; + } + } + + pub(crate) fn set_error(&self, backend: &str, error: String) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.enabled = true; + status.refreshing = false; + status.pending = false; + status.last_error = Some(error); + status.last_error_count = status.last_error_count.saturating_add(1); + } + } + + pub(crate) fn mark_pending(&self) { + if let Ok(mut status) = self.status.lock() { + status.pending = true; + } + } + + pub(crate) fn mark_refreshing(&self, backend: &str) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = true; + status.pending = false; + status.last_error = None; + } + } + + pub(crate) fn mark_refresh_error( + &self, + backend: &str, + event_count: usize, + changed_paths: usize, + error: String, + retrying: bool, + ) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = false; + status.pending = retrying; + status.last_error = Some(error); + status.last_error_count = status.last_error_count.saturating_add(1); + status.last_retry_unix_ms = retrying.then_some(unix_ms()); + status.last_event_count = event_count; + status.last_changed_paths = changed_paths; + } + } + + pub(crate) fn mark_refreshed( + &self, + backend: &str, + event_count: usize, + changed_paths: usize, + rebuilt: usize, + deleted: usize, + database_written: bool, + ) { + if let Ok(mut status) = self.status.lock() { + status.backend = backend.to_string(); + status.refreshing = false; + status.pending = false; + status.last_refresh_unix_ms = Some(unix_ms()); + status.last_error = None; + status.last_error_count = 0; + status.last_retry_unix_ms = None; + status.last_event_count = event_count; + status.last_changed_paths = changed_paths; + status.last_rebuilt = rebuilt; + status.last_deleted = deleted; + status.last_database_written = database_written; + } + } +} + +pub(crate) fn start_refresh_service(selector: RepoSelector) -> Arc { + let state = Arc::new(RefreshState::new()); + let thread_state = Arc::clone(&state); + thread::spawn(move || { + if let Err(error) = run_refresh_service(selector, &thread_state) { + thread_state.set_error("failed", error.clone()); + eprintln!( + "{}", + json!({"event": "repository.refresh_error", "message": error}) + ); + } + }); + state +} + +fn run_refresh_service(selector: RepoSelector, state: &Arc) -> Result<(), String> { + let runtime = resolve_runtime(&selector)?; + let materialize_options = MaterializeOptions { + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path, + db: Some(runtime.db_path), + manifest: Some(runtime.manifest_path), + mode: "changed".to_string(), + include_fts: true, + semantic_enrichment: true, + semantic_provider_mode: "local_only".to_string(), + use_git: false, + ..MaterializeOptions::default() + }; + let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; + let loop_config = RefreshLoopConfig { + poll_interval: Duration::from_millis(500), + debounce: Duration::from_millis(250), + max_wait: Duration::from_millis(1_000), + max_iterations: None, + }; + + match start_native_watcher(&runtime.repo_root) { + Ok((watcher, rx)) => { + let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; + if probe.delivered { + state.set_backend("native"); + match run_service_native_loop( + state, + loop_config, + &materialize_options, + &filter, + watcher, + rx, + probe.queued, + ) { + Ok(()) => Ok(()), + Err(error) => { + state.set_error("poll", error); + let filter = WatchEventFilter::from_options( + &runtime.repo_root, + &materialize_options, + )?; + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } + } else { + drop(watcher); + state.set_error( + "poll", + probe + .reason + .unwrap_or_else(|| "native probe failed".to_string()), + ); + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } + Err(error) => { + state.set_error("poll", error); + run_service_poll_loop(state, loop_config, &materialize_options, &filter) + } + } +} + +fn run_service_native_loop( + state: &Arc, + config: RefreshLoopConfig, + materialize_options: &MaterializeOptions, + filter: &WatchEventFilter, + watcher: notify::RecommendedWatcher, + rx: Receiver, + queued: VecDeque, +) -> Result<(), String> { + run_native_watch(config, filter, watcher, rx, queued, |batch| { + refresh_batch_with_state( + state, + "native", + materialize_options, + batch.event_count, + &batch.paths, + ) + }) +} + +fn run_service_poll_loop( + state: &Arc, + config: RefreshLoopConfig, + materialize_options: &MaterializeOptions, + filter: &WatchEventFilter, +) -> Result<(), String> { + state.set_backend("poll"); + run_poll_watch(config, filter, |batch| { + refresh_batch_with_state( + state, + "poll", + materialize_options, + batch.event_count, + &batch.paths, + ) + }) +} + +fn refresh_batch_with_state( + state: &Arc, + backend: &str, + materialize_options: &MaterializeOptions, + event_count: usize, + paths: &BTreeSet, +) -> Result { + let mut observer = StateRefreshObserver::new(state, backend); + execute_refresh_with_policy( + &mut observer, + event_count, + paths, + RefreshRetryPolicy::default(), + |candidate_paths| execute_refresh_operation(materialize_options, candidate_paths), + ) +} + +struct StateRefreshObserver<'a> { + state: &'a Arc, + backend: &'a str, + guard: Option>, +} + +impl<'a> StateRefreshObserver<'a> { + fn new(state: &'a Arc, backend: &'a str) -> Self { + Self { + state, + backend, + guard: None, + } + } +} + +impl RefreshObserver for StateRefreshObserver<'_> { + fn before_attempt(&mut self, _event_count: usize, _changed_paths: usize) -> Result<(), String> { + self.state.mark_pending(); + self.guard = Some(self.state.write_guard()?); + self.state.mark_refreshing(self.backend); + Ok(()) + } + + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.guard.take(); + self.state.mark_refreshed( + self.backend, + event_count, + changed_paths, + response.diff.rebuild_paths().len(), + response.diff.deleted.len(), + response.database_written, + ); + Ok(()) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.guard.take(); + self.state.mark_refresh_error( + self.backend, + event_count, + changed_paths, + error.to_string(), + retrying, + ); + Ok(()) + } +} + +fn unix_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn watch_matches_any_pattern(path: &str, patterns: &[String]) -> bool { + patterns + .iter() + .map(|pattern| pattern.trim()) + .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('#')) + .any(|pattern| watch_glob_matches(path, pattern)) +} + +fn watch_glob_matches(path: &str, pattern: &str) -> bool { + let pattern = watch_normalize_pattern(pattern); + if pattern.ends_with('/') { + return path.starts_with(pattern.trim_end_matches('/')); + } + if !pattern.contains('/') + && watch_wildcard_match(path.rsplit('/').next().unwrap_or(path), &pattern) + { + return true; + } + watch_wildcard_match(path, &pattern) +} + +fn watch_normalize_pattern(pattern: &str) -> String { + pattern + .trim() + .trim_start_matches("./") + .replace('\\', "/") + .to_string() +} + +fn watch_wildcard_match(text: &str, pattern: &str) -> bool { + let (mut text_index, mut pattern_index) = (0_usize, 0_usize); + let mut star_index = None; + let mut match_index = 0_usize; + let text = text.as_bytes(); + let pattern = pattern.as_bytes(); + while text_index < text.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == text[text_index]) + { + text_index += 1; + pattern_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + match_index = text_index; + pattern_index += 1; + } else if let Some(star) = star_index { + pattern_index = star + 1; + match_index += 1; + text_index = match_index; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::ManifestDiff; + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + + fn skipped_response() -> NativeSyntaxMaterializationResponse { + NativeSyntaxMaterializationResponse::skipped( + BTreeMap::new(), + ManifestDiff { + added: Vec::new(), + modified: Vec::new(), + unchanged: Vec::new(), + deleted: Vec::new(), + force_rebuild: false, + }, + Vec::new(), + Vec::new(), + BTreeMap::new(), + ) + } + + struct RecordingObserver { + retries: Vec<(bool, String, usize, usize)>, + successes: Vec<(usize, usize, usize)>, + } + + impl RecordingObserver { + fn new() -> Self { + Self { + retries: Vec::new(), + successes: Vec::new(), + } + } + } + + impl RefreshObserver for RecordingObserver { + fn on_success( + &mut self, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.successes.push(( + event_count, + changed_paths, + response.diff.rebuild_paths().len(), + )); + Ok(()) + } + + fn on_error( + &mut self, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + self.retries + .push((retrying, error.to_string(), event_count, changed_paths)); + Ok(()) + } + } + + #[test] + fn refresh_retry_policy_retries_transient_errors_before_success() { + let attempts = AtomicUsize::new(0); + let mut observer = RecordingObserver::new(); + let refreshed = execute_refresh_with_policy( + &mut observer, + 2, + &BTreeSet::from(["src/lib.rs".to_string()]), + RefreshRetryPolicy { + initial_delay: Duration::from_millis(0), + max_delay: Duration::from_millis(0), + }, + |_| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + Err("IO exception: Could not set lock on file".to_string()) + } else { + Ok(skipped_response()) + } + }, + ) + .unwrap(); + + assert!(refreshed); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert_eq!(observer.retries.len(), 1); + assert!(observer.retries[0].0); + assert_eq!(observer.successes, vec![(2, 1, 0)]); + } + + #[test] + fn refresh_retry_policy_stops_on_non_transient_errors() { + let mut observer = RecordingObserver::new(); + let refreshed = execute_refresh_with_policy( + &mut observer, + 1, + &BTreeSet::from(["src/lib.rs".to_string()]), + RefreshRetryPolicy { + initial_delay: Duration::from_millis(0), + max_delay: Duration::from_millis(0), + }, + |_| Err("parser exploded".to_string()), + ) + .unwrap(); + + assert!(!refreshed); + assert_eq!( + observer.retries, + vec![(false, "parser exploded".to_string(), 1, 1)] + ); + assert!(observer.successes.is_empty()); + } + + #[test] + fn watch_error_reason_compacts_multiline_errors() { + assert_eq!( + watch_error_reason("IO exception: Could not set lock\nSee docs"), + "IO_exception:_Could_not_set_lock" + ); + } +} diff --git a/src/cli/constants.rs b/src/cli/constants.rs index bd9482f..0723a2e 100644 --- a/src/cli/constants.rs +++ b/src/cli/constants.rs @@ -1,9 +1,5 @@ use std::env; -pub(crate) const GRAPH_SCHEMA_JSON: &str = include_str!("../../assets/graph_schema.json"); -pub(crate) const QUERY_HELPERS_JSON: &str = include_str!("../../assets/query_helpers.json"); -pub(crate) const ARCHITECTURE_QUERIES_JSON: &str = - include_str!("../../assets/architecture_queries.json"); pub(super) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; pub(super) const MAX_HTTP_BODY_BYTES: usize = 1_000_000; diff --git a/src/cli/format/blocks.rs b/src/cli/format/blocks.rs index ce811d3..03c7f8a 100644 --- a/src/cli/format/blocks.rs +++ b/src/cli/format/blocks.rs @@ -1,278 +1,3 @@ -pub(crate) fn serialize_schema_block(payload: &serde_json::Value) -> String { - let node_types = value_array(payload, "node_types"); - let relation_types = value_array(payload, "relation_types"); - let parser_mappings = value_array(payload, "parser_node_mappings"); - let search_indexes = value_array(payload, "search_indexes"); - let profiles = payload - .get("context_profiles") - .and_then(serde_json::Value::as_object) - .map(serde_json::Map::len) - .unwrap_or_default(); - let query_helpers = value_array(payload, "query_helpers"); - let mut lines = vec![format!( - "schema {} version={} nodes={} relations={} parser_mappings={} indexes={} profiles={} helpers={}", - block_value(value_str(payload, "ontology")), - block_value(value_str(payload, "version")), - node_types.len(), - relation_types.len(), - parser_mappings.len(), - search_indexes.len(), - profiles, - query_helpers.len() - )]; - if !node_types.is_empty() { - lines.push(format!("node_types {}", csv_names(node_types))); - } - if !relation_types.is_empty() { - lines.push(format!("relation_types {}", csv_names(relation_types))); - } - for index in search_indexes { - lines.push(format!( - "index {} node_types={} fields={}", - block_value(value_str(index, "name")), - csv_values(index.get("node_types")), - csv_values(index.get("fields")) - )); - } - if let Some(context_profiles) = payload - .get("context_profiles") - .and_then(serde_json::Value::as_object) - { - for (name, profile) in context_profiles { - let relations = csv_values(profile.get("relations")); - if relations.is_empty() { - lines.push(format!("profile {}", block_value(name))); - } else { - lines.push(format!( - "profile {} relations={relations}", - block_value(name) - )); - } - } - } - format!("{}\n", lines.join("\n")) -} - -pub(crate) fn serialize_query_helpers_block(payload: &serde_json::Value) -> String { - let helpers = value_array(payload, "query_helpers"); - let mut lines = vec![format!("query_helpers count={}", helpers.len())]; - for helper in helpers { - append_query_spec_lines(&mut lines, helper, ""); - } - format!("{}\n", lines.join("\n")) -} - -pub(crate) fn serialize_architecture_queries_block(payload: &serde_json::Value) -> String { - let groups = value_array(payload, "groups"); - let mut lines = vec![format!( - "architecture_queries workflow={} execution_tool={} groups={}", - block_value(value_str(payload, "workflow")), - block_value(value_str(payload, "execution_tool")), - groups.len() - )]; - let recommended_order = csv_values(payload.get("recommended_order")); - if !recommended_order.is_empty() { - lines.push(format!("recommended_order {recommended_order}")); - } - for group in groups { - lines.push(format!( - "group {} goal={}", - block_value(value_str(group, "name")), - block_value(value_str(group, "goal")) - )); - for query in value_array(group, "queries") { - append_query_spec_lines(&mut lines, query, " "); - } - } - format!("{}\n", lines.join("\n")) -} - -pub(crate) fn serialize_health_block(payload: &serde_json::Value) -> String { - let mut lines = vec![format!( - "health ok={} database_exists={} manifest_exists={} graph_readable={} total_nodes={}", - value_bool(payload, "ok"), - value_bool(payload, "database_exists"), - value_bool(payload, "manifest_exists"), - value_bool(payload, "graph_readable"), - payload - .get("total_nodes") - .and_then(serde_json::Value::as_u64) - .unwrap_or_default() - )]; - for key in ["repo_root", "database_path", "manifest_path"] { - lines.push(format!("{key} {}", block_value(value_str(payload, key)))); - } - if let Some(error) = payload.get("error").and_then(serde_json::Value::as_str) { - lines.push(format!("error {}", block_value(error))); - } - format!("{}\n", lines.join("\n")) -} - -pub(crate) fn serialize_search_block(payload: &serde_json::Value) -> String { - let results = value_array(payload, "results"); - let mut lines = vec![format!("q {}", block_value(value_str(payload, "query")))]; - let mut current_path: Option = None; - for result in results { - let result_path = value_str(result, "path").to_string(); - if current_path.as_deref() != Some(result_path.as_str()) { - if lines.len() > 1 { - lines.push(String::new()); - } - lines.push(format!("file path {}", block_value(&result_path))); - current_path = Some(result_path); - } - let mut result_parts = vec![ - format!("- {}", value_str(result, "type")), - block_value(value_str(result, "label")), - block_span(result.get("span")), - ]; - if let Some(rank_score) = result.get("rank_score").and_then(serde_json::Value::as_f64) { - result_parts.push(format!("rank_score={rank_score:.2}")); - } - if let Some(id) = result.get("id").and_then(serde_json::Value::as_str) { - result_parts.push(format!("id={}", block_value(id))); - } - let summary = value_str(result, "summary"); - if !summary.is_empty() && summary != value_str(result, "label") { - result_parts.push(format!("summary={}", block_value(summary))); - } - lines.push(result_parts.join(" ")); - } - format!("{}\n", lines.join("\n")) -} - -pub(crate) fn serialize_query_block(payload: &serde_json::Value) -> String { - let rows = value_array(payload, "rows"); - let columns = query_columns(value_str(payload, "statement")); - let mut lines = vec![format!( - "query rows={} truncated={}", - payload - .get("row_count") - .and_then(serde_json::Value::as_u64) - .unwrap_or(rows.len() as u64), - value_bool(payload, "truncated") - )]; - lines.push(format!( - "statement {}", - block_value(value_str(payload, "statement")) - )); - if !columns.is_empty() { - lines.push(format!("columns {}", columns.join(","))); - } - for (index, row) in rows.iter().enumerate() { - let values = row - .as_array() - .cloned() - .unwrap_or_else(|| vec![(*row).clone()]); - let row_text = if !columns.is_empty() && columns.len() == values.len() { - columns - .iter() - .zip(values.iter()) - .map(|(column, value)| format!("{column}={}", block_json_value(value))) - .collect::>() - .join(" ") - } else { - values - .iter() - .map(block_json_value) - .collect::>() - .join(" ") - }; - lines.push( - format!("row {} {}", index + 1, row_text) - .trim_end() - .to_string(), - ); - } - format!("{}\n", lines.join("\n")) -} - -pub(in crate::cli) fn query_columns(statement: &str) -> Vec { - let upper = statement.to_ascii_uppercase(); - let Some(return_index) = upper.find("RETURN") else { - return Vec::new(); - }; - let after_return = return_index + "RETURN".len(); - let end = ["ORDER BY", "LIMIT"] - .iter() - .filter_map(|marker| { - upper[after_return..] - .find(marker) - .map(|index| after_return + index) - }) - .min() - .unwrap_or(statement.len()); - split_return_expressions(&statement[after_return..end]) - .into_iter() - .filter_map(|expression| query_column_label(&expression)) - .collect() -} - -pub(in crate::cli) fn split_return_expressions(text: &str) -> Vec { - let mut expressions = Vec::new(); - let mut current = String::new(); - let mut depth = 0_i32; - for character in text.chars() { - match character { - '(' => { - depth += 1; - current.push(character); - } - ')' => { - depth = (depth - 1).max(0); - current.push(character); - } - ',' if depth == 0 => { - let trimmed = current.trim(); - if !trimmed.is_empty() { - expressions.push(trimmed.to_string()); - } - current.clear(); - } - _ => current.push(character), - } - } - let trimmed = current.trim(); - if !trimmed.is_empty() { - expressions.push(trimmed.to_string()); - } - expressions -} - -pub(in crate::cli) fn query_column_label(expression: &str) -> Option { - let parts = expression.split_whitespace().collect::>(); - for index in 0..parts.len().saturating_sub(1) { - if parts[index].eq_ignore_ascii_case("AS") && is_identifier(parts[index + 1]) { - return Some(parts[index + 1].to_string()); - } - } - let label = expression.rsplit('.').next()?.trim(); - if is_identifier(label) { - Some(label.to_string()) - } else { - None - } -} - -pub(in crate::cli) fn is_identifier(value: &str) -> bool { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - (first == '_' || first.is_ascii_alphabetic()) - && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) -} - -pub(in crate::cli) fn block_json_value(value: &serde_json::Value) -> String { - match value { - serde_json::Value::Null => "null".to_string(), - serde_json::Value::Bool(value) => value.to_string(), - serde_json::Value::Number(value) => value.to_string(), - serde_json::Value::String(value) => block_value(value), - other => serde_json::to_string(other).unwrap_or_else(|_| "{}".to_string()), - } -} - pub(crate) fn serialize_error_block(payload: &serde_json::Value) -> String { let error = payload.get("error").unwrap_or(payload); format!( @@ -283,134 +8,14 @@ pub(crate) fn serialize_error_block(payload: &serde_json::Value) -> String { ) } -pub(crate) fn serialize_context_block(payload: &serde_json::Value) -> String { - let mut lines = vec![format!( - "context {} id={} profile={}", - value_str(payload, "node_type"), - block_value(value_str(payload, "node_id")), - block_value(value_str(payload, "profile")) - )]; - let mut current_path: Option = None; - for context in value_array(payload, "context") { - let context_path = value_str(context, "path").to_string(); - if current_path.as_deref() != Some(context_path.as_str()) { - if lines.len() > 1 { - lines.push(String::new()); - } - lines.push(format!("file path {}", block_value(&context_path))); - current_path = Some(context_path); - } - let mut parts = vec![ - value_str(context, "direction").to_string(), - value_str(context, "relation").to_string(), - value_str(context, "type").to_string(), - block_value(value_str(context, "label")), - block_span(context.get("span")), - ]; - let summary = value_str(context, "summary"); - if !summary.is_empty() && summary != value_str(context, "label") { - parts.push(format!("summary={}", block_value(summary))); - } - lines.push(parts.join(" ").trim_end().to_string()); - } - format!("{}\n", lines.join("\n")) -} - -pub(in crate::cli) fn block_span(value: Option<&serde_json::Value>) -> String { - let Some(span) = value.and_then(serde_json::Value::as_object) else { - return String::new(); - }; - let start = span - .get("line_start") - .and_then(serde_json::Value::as_i64) - .unwrap_or_default(); - let end = span - .get("line_end") - .and_then(serde_json::Value::as_i64) - .unwrap_or(start); - if start <= 0 && end <= 0 { - String::new() - } else { - format!("L{start}-L{end}") - } -} - -pub(in crate::cli) fn append_query_spec_lines( - lines: &mut Vec, - query: &serde_json::Value, - indent: &str, -) { - lines.push(format!( - "{indent}query {} description={}", - block_value(value_str(query, "name")), - block_value(value_str(query, "description")) - )); - let parameters = csv_values(query.get("parameters")); - if !parameters.is_empty() { - lines.push(format!("{indent}parameters {parameters}")); - } - let returns = csv_values(query.get("returns")); - if !returns.is_empty() { - lines.push(format!("{indent}returns {returns}")); - } - if let Some(statement) = query - .get("statement") - .or_else(|| query.get("query")) - .and_then(serde_json::Value::as_str) - { - lines.push(format!("{indent}statement {}", block_value(statement))); - } -} - -pub(crate) fn value_array<'a>( - payload: &'a serde_json::Value, - key: &str, -) -> &'a [serde_json::Value] { - payload - .get(key) - .and_then(serde_json::Value::as_array) - .map(Vec::as_slice) - .unwrap_or(&[]) -} - -pub(crate) fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { +fn value_str<'a>(payload: &'a serde_json::Value, key: &str) -> &'a str { payload .get(key) .and_then(serde_json::Value::as_str) .unwrap_or("") } -pub(in crate::cli) fn value_bool(payload: &serde_json::Value, key: &str) -> bool { - payload - .get(key) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} - -pub(in crate::cli) fn csv_names(values: &[serde_json::Value]) -> String { - values - .iter() - .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) - .map(block_value) - .collect::>() - .join(",") -} - -pub(in crate::cli) fn csv_values(value: Option<&serde_json::Value>) -> String { - value - .and_then(serde_json::Value::as_array) - .map(|values| { - values - .iter() - .filter_map(serde_json::Value::as_str) - .map(block_value) - .collect::>() - .join(",") - }) - .unwrap_or_default() -} - -pub(crate) fn block_value(value: &str) -> String { +fn block_value(value: &str) -> String { if value.is_empty() { "\"\"".to_string() } else if value.chars().all(|character| { diff --git a/src/cli/format/metadata.rs b/src/cli/format/metadata.rs deleted file mode 100644 index c140cd6..0000000 --- a/src/cli/format/metadata.rs +++ /dev/null @@ -1,34 +0,0 @@ -pub(crate) fn metadata_payload(source: &str) -> Result { - serde_json::from_str(source) - .map_err(|error| format!("failed to parse embedded metadata: {error}")) -} - -pub(crate) fn filter_architecture_group( - payload: &mut serde_json::Value, - group: &str, -) -> Result<(), String> { - let groups = payload - .get("groups") - .and_then(serde_json::Value::as_array) - .cloned() - .unwrap_or_default(); - let selected: Vec = groups - .iter() - .filter(|value| value.get("name").and_then(serde_json::Value::as_str) == Some(group)) - .cloned() - .collect(); - if selected.is_empty() { - let valid = groups - .iter() - .filter_map(|value| value.get("name").and_then(serde_json::Value::as_str)) - .collect::>() - .join(", "); - return Err(format!( - "Unknown architecture query group: {group}. Valid groups: {valid}" - )); - } - if let Some(object) = payload.as_object_mut() { - object.insert("groups".to_string(), serde_json::Value::Array(selected)); - } - Ok(()) -} diff --git a/src/cli/format/mod.rs b/src/cli/format/mod.rs index f208aeb..9b543dc 100644 --- a/src/cli/format/mod.rs +++ b/src/cli/format/mod.rs @@ -1,19 +1,10 @@ mod blocks; mod help; -mod metadata; -mod schema; -pub(crate) use blocks::{ - block_value, serialize_architecture_queries_block, serialize_context_block, - serialize_error_block, serialize_health_block, serialize_query_block, - serialize_query_helpers_block, serialize_schema_block, serialize_search_block, value_array, - value_str, -}; +pub(crate) use blocks::serialize_error_block; pub(in crate::cli) use help::{ graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, mcp_help, mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, uninstall_help, watch_help, }; -pub(crate) use metadata::{filter_architecture_group, metadata_payload}; -pub(crate) use schema::schema_statements_from_copy_statements; diff --git a/src/cli/graph/health.rs b/src/cli/graph/health.rs deleted file mode 100644 index 11f19da..0000000 --- a/src/cli/graph/health.rs +++ /dev/null @@ -1,86 +0,0 @@ -use super::options::HealthOptions; -use crate::cli::{ - setup::GraphStatePaths, - util::{read_json_file, resolve_repo_root}, -}; -use crate::db_writer::{ - connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, -}; -use lbug::Value; -use std::path::{Path, PathBuf}; - -#[derive(Debug)] -pub(crate) struct HealthRuntime { - pub(crate) repo_root: PathBuf, - pub(crate) db_path: PathBuf, - pub(crate) manifest_path: PathBuf, -} - -pub(crate) fn resolve_health_runtime(options: &HealthOptions) -> Result { - let repo_root = resolve_repo_root(options.repo_root.as_deref())?; - let default_paths = GraphStatePaths::derive(&repo_root); - let config_path = options - .config - .clone() - .unwrap_or_else(|| default_paths.config_path.clone()); - let config = if config_path.exists() { - Some(read_json_file(&config_path)?) - } else { - None - }; - let db_path = options - .db - .clone() - .or_else(|| { - config - .as_ref() - .and_then(|value| value.get("database_path")) - .and_then(serde_json::Value::as_str) - .map(PathBuf::from) - }) - .unwrap_or(default_paths.db_path); - let manifest_path = options - .manifest - .clone() - .or_else(|| { - config - .as_ref() - .and_then(|value| value.get("manifest_path")) - .and_then(serde_json::Value::as_str) - .map(PathBuf::from) - }) - .unwrap_or(default_paths.manifest_path); - Ok(HealthRuntime { - repo_root, - db_path, - manifest_path, - }) -} -pub(crate) fn count_graph_nodes(db_path: &Path) -> Result { - let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) - .map_err(|error| error.to_string())?; - let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; - let mut result = conn - .query("MATCH (n) RETURN count(n) AS total_nodes LIMIT 1") - .map_err(|error| format!("failed to query graph health: {error}"))?; - let row = result - .next() - .ok_or_else(|| "graph health query returned no rows".to_string())?; - row.first() - .and_then(value_to_u64) - .ok_or_else(|| "graph health query returned a non-numeric node count".to_string()) -} - -pub(crate) fn value_to_u64(value: &Value) -> Option { - match value { - Value::Int64(value) if *value >= 0 => Some(*value as u64), - Value::Int32(value) if *value >= 0 => Some(*value as u64), - Value::Int16(value) if *value >= 0 => Some(*value as u64), - Value::Int8(value) if *value >= 0 => Some(*value as u64), - Value::UInt64(value) => Some(*value), - Value::UInt32(value) => Some(u64::from(*value)), - Value::UInt16(value) => Some(u64::from(*value)), - Value::UInt8(value) => Some(u64::from(*value)), - _ => None, - } -} diff --git a/src/cli/graph/mod.rs b/src/cli/graph/mod.rs index 318bdd7..4162259 100644 --- a/src/cli/graph/mod.rs +++ b/src/cli/graph/mod.rs @@ -1,15 +1,7 @@ -mod health; mod options; -mod query; -mod search; -pub(crate) use health::{count_graph_nodes, resolve_health_runtime}; pub(crate) use options::HealthOptions; pub(crate) use options::{ ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, MetadataOutputOptions, }; -pub(crate) use query::{ - cypher_single_quoted, execute_read_only_query, validate_read_only_statement, -}; -pub(crate) use search::{execute_graph_context, execute_graph_search}; diff --git a/src/cli/graph/options.rs b/src/cli/graph/options.rs index 7948f3e..542fa50 100644 --- a/src/cli/graph/options.rs +++ b/src/cli/graph/options.rs @@ -126,12 +126,6 @@ impl GraphQueryOptions { limit = value .parse::() .map_err(|error| format!("--limit must be an integer: {error}"))?; - if limit == 0 { - return Err("graph-query limit must be greater than zero".to_string()); - } - if limit > 1000 { - return Err("graph-query limit must be 1000 or less".to_string()); - } index += 2; } "--repo-root" | "--source-root" => { @@ -214,10 +208,7 @@ impl GraphQueryOptions { json, }); } - let statement = statement - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "graph-query requires a non-empty statement".to_string())?; + let statement = statement.unwrap_or_default(); Ok(Self { statement, parameters, @@ -362,9 +353,6 @@ impl GraphSearchOptions { } "--limit" => { limit = parse_usize_arg(args, index, "--limit")?; - if limit == 0 { - return Err("Search limit must be greater than zero".to_string()); - } index += 2; } "--profile" => { @@ -401,9 +389,6 @@ impl GraphSearchOptions { } "--detail" => { let value = required_arg(args, index, "--detail")?; - if value != "standard" && value != "slim" { - return Err("--detail must be standard or slim".to_string()); - } detail = value.to_string(); index += 2; } @@ -462,10 +447,7 @@ impl GraphSearchOptions { output, }); } - let query = query - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "Search query must not be empty".to_string())?; + let query = query.unwrap_or_default(); Ok(Self { query, limit, @@ -512,20 +494,9 @@ impl GraphContextOptions { } } } - if node_id.is_some() - && node_type.is_some() - && !search_args.iter().any(|arg| arg == "-h" || arg == "--help") - { - search_args.push("__explicit_context__".to_string()); - } let mut search = GraphSearchOptions::parse(&search_args)?; if node_id.is_some() && node_type.is_some() { search.query.clear(); - } else if node_id.is_some() || node_type.is_some() { - return Err( - "codebase-context explicit lookup requires both --node-id and --node-type" - .to_string(), - ); } Ok(Self { search, diff --git a/src/cli/graph/query.rs b/src/cli/graph/query.rs deleted file mode 100644 index 0f3b59f..0000000 --- a/src/cli/graph/query.rs +++ /dev/null @@ -1,216 +0,0 @@ -use crate::db_writer::{ - connect_ladybug_database, open_ladybug_database, retry_transient_database, READ_RETRY_POLICY, -}; -use lbug::Value; -use serde_json::json; -use std::path::Path; - -pub(crate) fn span_json(line_start: Option, line_end: Option) -> serde_json::Value { - let mut span = serde_json::Map::new(); - if let Some(line_start) = line_start { - span.insert("line_start".to_string(), json!(line_start)); - } - if let Some(line_end) = line_end { - span.insert("line_end".to_string(), json!(line_end)); - } - serde_json::Value::Object(span) -} - -pub(crate) fn cypher_single_quoted(value: &str) -> String { - value.replace('\\', "\\\\").replace('\'', "\\'") -} - -pub(crate) fn cypher_identifier(value: &str) -> String { - value.replace('`', "``") -} - -pub(crate) fn value_to_string(value: Option<&Value>) -> String { - match value { - Some(Value::String(value)) => value.clone(), - Some(Value::Int64(value)) => value.to_string(), - Some(Value::UInt64(value)) => value.to_string(), - Some(Value::Int32(value)) => value.to_string(), - Some(Value::UInt32(value)) => value.to_string(), - Some(Value::Null(_)) | None => String::new(), - Some(value) => value.to_string(), - } -} - -pub(crate) fn value_to_i64(value: Option<&Value>) -> Option { - match value { - Some(Value::Int64(value)) => Some(*value), - Some(Value::Int32(value)) => Some(i64::from(*value)), - Some(Value::Int16(value)) => Some(i64::from(*value)), - Some(Value::Int8(value)) => Some(i64::from(*value)), - Some(Value::UInt64(value)) => i64::try_from(*value).ok(), - Some(Value::UInt32(value)) => Some(i64::from(*value)), - Some(Value::UInt16(value)) => Some(i64::from(*value)), - Some(Value::UInt8(value)) => Some(i64::from(*value)), - _ => None, - } -} - -pub(crate) fn value_to_f64(value: Option<&Value>) -> f64 { - match value { - Some(Value::Double(value)) => *value, - Some(Value::Float(value)) => f64::from(*value), - Some(Value::Int64(value)) => *value as f64, - Some(Value::UInt64(value)) => *value as f64, - Some(Value::Int32(value)) => f64::from(*value), - Some(Value::UInt32(value)) => f64::from(*value), - _ => 0.0, - } -} - -pub(crate) fn validate_read_only_statement(statement: &str) -> Result<(), String> { - let stripped = statement.trim().trim_end_matches(';'); - if stripped.contains(';') { - return Err("graph_query accepts one read-only statement at a time".to_string()); - } - for keyword in [ - "ALTER", "ATTACH", "CALL", "COPY", "CREATE", "DELETE", "DETACH", "DROP", "EXPORT", - "IMPORT", "INSERT", "INSTALL", "LOAD", "MERGE", "REMOVE", "RENAME", "SET", "TRUNCATE", - "UPDATE", "USE", - ] { - if contains_keyword(stripped, keyword) { - return Err(format!( - "graph_query is read-only; blocked keyword: {keyword}" - )); - } - } - Ok(()) -} - -pub(in crate::cli) fn contains_keyword(statement: &str, keyword: &str) -> bool { - let uppercase = statement.to_ascii_uppercase(); - let mut search_start = 0; - while let Some(index) = uppercase[search_start..].find(keyword) { - let absolute_index = search_start + index; - let before = uppercase[..absolute_index] - .chars() - .next_back() - .map(is_keyword_char) - .unwrap_or(false); - let after = uppercase[absolute_index + keyword.len()..] - .chars() - .next() - .map(is_keyword_char) - .unwrap_or(false); - if !before && !after { - return true; - } - search_start = absolute_index + keyword.len(); - } - false -} - -pub(in crate::cli) fn is_keyword_char(character: char) -> bool { - character.is_ascii_alphanumeric() || character == '_' -} - -pub(crate) fn execute_read_only_query( - db_path: &Path, - statement: &str, - parameters: &serde_json::Map, - limit: usize, -) -> Result<(Vec>, bool), String> { - let db = retry_transient_database(READ_RETRY_POLICY, || open_ladybug_database(db_path, true)) - .map_err(|error| error.to_string())?; - let conn = connect_ladybug_database(&db).map_err(|error| error.to_string())?; - let mut result = if parameters.is_empty() { - conn.query(statement) - .map_err(|error| format!("failed to execute graph query: {error}"))? - } else { - let named_parameters = lbug_query_parameters(parameters)?; - let mut prepared = conn - .prepare(statement) - .map_err(|error| format!("failed to prepare graph query: {error}"))?; - if !prepared.is_read_only() { - return Err("graph-query prepared statement is not read-only".to_string()); - } - let execute_parameters = named_parameters - .iter() - .map(|(name, value)| (name.as_str(), value.clone())) - .collect(); - conn.execute(&mut prepared, execute_parameters) - .map_err(|error| format!("failed to execute graph query: {error}"))? - }; - let mut rows = Vec::new(); - let mut truncated = false; - for row in result.by_ref().take(limit + 1) { - if rows.len() == limit { - truncated = true; - break; - } - rows.push(row.into_iter().map(json_safe_value).collect()); - } - Ok((rows, truncated)) -} - -pub(crate) fn lbug_query_parameters( - parameters: &serde_json::Map, -) -> Result, String> { - let mut converted = Vec::with_capacity(parameters.len()); - for (name, value) in parameters { - if name.trim().is_empty() { - return Err("graph_query parameter names must not be blank".to_string()); - } - converted.push((name.clone(), json_parameter_to_lbug_value(value)?)); - } - Ok(converted) -} - -pub(crate) fn json_parameter_to_lbug_value(value: &serde_json::Value) -> Result { - match value { - serde_json::Value::Bool(value) => Ok(Value::Bool(*value)), - serde_json::Value::Number(value) => { - if let Some(value) = value.as_i64() { - Ok(Value::Int64(value)) - } else if let Some(value) = value.as_u64() { - Ok(Value::UInt64(value)) - } else if let Some(value) = value.as_f64() { - Ok(Value::Double(value)) - } else { - Err("graph_query numeric parameter is not representable".to_string()) - } - } - serde_json::Value::String(value) => Ok(Value::String(value.clone())), - serde_json::Value::Null | serde_json::Value::Array(_) | serde_json::Value::Object(_) => { - Ok(Value::Json(value.clone())) - } - } -} - -pub(crate) fn json_safe_value(value: Value) -> serde_json::Value { - match value { - Value::Null(_) => serde_json::Value::Null, - Value::Bool(value) => json!(value), - Value::Int64(value) => json!(value), - Value::Int32(value) => json!(value), - Value::Int16(value) => json!(value), - Value::Int8(value) => json!(value), - Value::UInt64(value) => json!(value), - Value::UInt32(value) => json!(value), - Value::UInt16(value) => json!(value), - Value::UInt8(value) => json!(value), - Value::Int128(value) => json!(value.to_string()), - Value::Double(value) => serde_json::Number::from_f64(value) - .map(serde_json::Value::Number) - .unwrap_or_else(|| json!(value.to_string())), - Value::Float(value) => serde_json::Number::from_f64(f64::from(value)) - .map(serde_json::Value::Number) - .unwrap_or_else(|| json!(value.to_string())), - Value::String(value) => json!(value), - Value::Json(value) => value, - Value::List(_, values) | Value::Array(_, values) => { - serde_json::Value::Array(values.into_iter().map(json_safe_value).collect()) - } - Value::Struct(values) => serde_json::Value::Object( - values - .into_iter() - .map(|(key, value)| (key, json_safe_value(value))) - .collect(), - ), - other => json!(other.to_string()), - } -} diff --git a/src/cli/install/mod.rs b/src/cli/install/mod.rs index e76bf8b..ee42e8d 100644 --- a/src/cli/install/mod.rs +++ b/src/cli/install/mod.rs @@ -19,8 +19,6 @@ pub(in crate::cli) use metadata::{ supported_install_clients, supported_install_clients_with_all, }; pub(in crate::cli) use options::McpInstallOptions; -pub(in crate::cli) use render::{ - copilot_studio_metadata, remove_client_config, render_client_config, -}; +pub(in crate::cli) use render::{copilot_studio_metadata, render_client_config}; pub(in crate::cli) use state::McpHttpState; pub(in crate::cli) use verify::attach_install_verification; diff --git a/src/cli/install/render.rs b/src/cli/install/render.rs index 2b0ab32..048f55d 100644 --- a/src/cli/install/render.rs +++ b/src/cli/install/render.rs @@ -9,13 +9,6 @@ pub(in crate::cli) struct RenderedNativeConfig { pub(in crate::cli) payload: serde_json::Value, } -pub(in crate::cli) struct RemovedNativeConfig { - pub(in crate::cli) text: String, - pub(in crate::cli) action: String, - pub(in crate::cli) previous: serde_json::Value, - pub(in crate::cli) payload: serde_json::Value, -} - pub(in crate::cli) fn render_client_config( client: &str, scope: &str, @@ -32,22 +25,6 @@ pub(in crate::cli) fn render_client_config( } } -pub(in crate::cli) fn remove_client_config( - client: &str, - scope: &str, - existing: Option<&str>, - server_name: &str, -) -> Result { - match adapter_id(client, scope) { - "codex" => remove_codex_config(existing, server_name), - "hermes" => remove_hermes_config(existing, server_name), - "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { - remove_json_config(adapter_id(client, scope), existing, server_name) - } - other => Err(format!("Unsupported MCP client adapter: {other}")), - } -} - pub(in crate::cli) fn render_json_config( adapter: &str, existing: Option<&str>, @@ -87,49 +64,6 @@ pub(in crate::cli) fn render_json_config( }) } -pub(in crate::cli) fn remove_json_config( - adapter: &str, - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!({}), - }); - }; - let mut payload = if existing.trim().is_empty() { - json!({}) - } else { - serde_json::from_str::(existing) - .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? - }; - if !payload.is_object() { - return Err("MCP config must contain a JSON object".to_string()); - } - let root_path = match adapter { - "github-copilot" => vec!["servers"], - "openclaw" => vec!["mcp", "servers"], - _ => vec!["mcpServers"], - }; - let previous = json_container_mut(&mut payload, &root_path)?.remove(server_name); - let action = if previous.is_some() { - "removed" - } else { - "unchanged" - } - .to_string(); - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; - Ok(RemovedNativeConfig { - text, - action, - previous: previous.unwrap_or(serde_json::Value::Null), - payload, - }) -} - pub(in crate::cli) fn render_codex_config( existing: Option<&str>, descriptor: &NativeMcpDescriptor, @@ -156,33 +90,6 @@ pub(in crate::cli) fn render_codex_config( }) } -pub(in crate::cli) fn remove_codex_config( - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!(""), - }); - }; - let (text, previous) = remove_toml_block(existing, server_name); - Ok(RemovedNativeConfig { - text, - action: if previous.is_some() { - "removed".to_string() - } else { - "unchanged".to_string() - }, - previous: previous - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - payload: json!(existing), - }) -} - pub(in crate::cli) fn render_hermes_config( existing: Option<&str>, descriptor: &NativeMcpDescriptor, @@ -208,39 +115,6 @@ pub(in crate::cli) fn render_hermes_config( }) } -pub(in crate::cli) fn remove_hermes_config( - existing: Option<&str>, - server_name: &str, -) -> Result { - let Some(existing) = existing else { - return Ok(RemovedNativeConfig { - text: String::new(), - action: "unchanged".to_string(), - previous: serde_json::Value::Null, - payload: json!(""), - }); - }; - let (text, previous) = remove_marked_block(existing); - let previous = previous.filter(|block| block.contains(&format!(" {server_name}:"))); - let action = if previous.is_some() { - "removed".to_string() - } else { - "unchanged".to_string() - }; - Ok(RemovedNativeConfig { - text: if previous.is_some() { - text - } else { - existing.to_string() - }, - action, - previous: previous - .map(serde_json::Value::String) - .unwrap_or(serde_json::Value::Null), - payload: json!(existing), - }) -} - pub(in crate::cli) fn json_container_mut<'a>( payload: &'a mut serde_json::Value, path: &[&str], @@ -368,43 +242,6 @@ pub(in crate::cli) fn upsert_toml_block( ) } -pub(in crate::cli) fn remove_toml_block( - existing: &str, - server_name: &str, -) -> (String, Option) { - let lines = existing.lines().collect::>(); - let header = format!("[mcp_servers.{server_name}]"); - let env_header = format!("[mcp_servers.{server_name}.env]"); - let start = lines - .iter() - .position(|line| line.trim() == header || line.trim() == env_header); - let Some(start) = start else { - return (existing.to_string(), None); - }; - let end = lines[start + 1..] - .iter() - .position(|line| { - let trimmed = line.trim(); - trimmed.starts_with('[') - && trimmed.ends_with(']') - && trimmed != header - && trimmed != env_header - }) - .map(|index| start + 1 + index) - .unwrap_or(lines.len()); - let previous = lines[start..end].join("\n").trim_end().to_string(); - let mut next_lines = Vec::new(); - next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); - next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); - let text = next_lines.join("\n").trim().to_string(); - let text = if text.is_empty() { - String::new() - } else { - text + "\n" - }; - (text, Some(previous)) -} - pub(in crate::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { let mut lines = vec![ "# codebaseGraph MCP server start".to_string(), @@ -456,28 +293,3 @@ pub(in crate::cli) fn upsert_marked_block(existing: &str, block: &str) -> (Strin + "\n"; (text, Some(previous)) } - -pub(in crate::cli) fn remove_marked_block(existing: &str) -> (String, Option) { - const START: &str = "# codebaseGraph MCP server start"; - const END: &str = "# codebaseGraph MCP server end"; - let Some(start) = existing.find(START) else { - return (existing.to_string(), None); - }; - let Some(end) = existing.find(END) else { - return (existing.to_string(), None); - }; - if end < start { - return (existing.to_string(), None); - } - let after_end = end + END.len(); - let previous = existing[start..after_end].trim_end().to_string(); - let before = existing[..start].trim_end(); - let after = existing[after_end..].trim_start(); - let text = match (before.is_empty(), after.is_empty()) { - (true, true) => String::new(), - (true, false) => format!("{after}\n"), - (false, true) => format!("{before}\n"), - (false, false) => format!("{before}\n\n{after}"), - }; - (text, Some(previous)) -} diff --git a/src/cli/materialization/command.rs b/src/cli/materialization/command.rs index 13d70ed..c7a5ae9 100644 --- a/src/cli/materialization/command.rs +++ b/src/cli/materialization/command.rs @@ -1,12 +1,12 @@ -use super::manifest::read_request; -use super::request::{build_request, MaterializeOptions}; use crate::api::{ - contracts::{MaterializationRequest, OperationRequest, OutputFormat, RepoSelector}, + contracts::{MaterializationRequest, OperationRequest, OutputFormat}, + materialization::{ + materialization_request as build_public_materialization_request, MaterializeOptions, + }, CodebaseGraphApi, }; use crate::cli::format::{materialize_help, plan_help}; use std::io::Write; -use std::path::Path; pub(in crate::cli) fn run_materialize( args: &[String], @@ -67,63 +67,5 @@ fn materialize_request( options: &MaterializeOptions, output_format: OutputFormat, ) -> Result { - let request = match options.native_request.as_ref() { - Some(request_path) => { - let native = read_request(request_path)?; - MaterializationRequest { - repo: RepoSelector { - repo_root: Some(Path::new(&native.source_root).to_path_buf()), - config_path: None, - db_path: Some(Path::new(&native.db_path).to_path_buf()), - manifest_path: options.manifest.clone(), - }, - native_request_path: Some(request_path.clone()), - source_root: Some(native.source_root), - mode: native.mode, - include_fts: native.include_fts, - semantic_enrichment: native.semantic_enrichment, - semantic_provider_mode: native.semantic_provider_mode, - use_git: options.use_git, - git_diff: options.git_diff, - git_base: options.git_base.clone(), - include_patterns: native.include_patterns, - exclude_patterns: native.exclude_patterns, - candidate_paths: native.candidate_paths, - parallel: native.parallel, - progress: native.progress, - output_format, - } - } - None => { - let materialization_request = build_request(options) - .map_err(|error| format!("failed to build materialization request: {error}"))?; - MaterializationRequest { - repo: RepoSelector { - repo_root: options.source_root.clone(), - config_path: None, - db_path: options.db.clone(), - manifest_path: options.manifest.clone(), - }, - native_request_path: None, - source_root: options - .source_root - .as_ref() - .map(|path| path.to_string_lossy().to_string()), - mode: materialization_request.mode, - include_fts: materialization_request.include_fts, - semantic_enrichment: materialization_request.semantic_enrichment, - semantic_provider_mode: materialization_request.semantic_provider_mode, - use_git: options.use_git, - git_diff: options.git_diff, - git_base: options.git_base.clone(), - include_patterns: materialization_request.include_patterns, - exclude_patterns: materialization_request.exclude_patterns, - candidate_paths: materialization_request.candidate_paths, - parallel: materialization_request.parallel, - progress: materialization_request.progress, - output_format, - } - } - }; - Ok(request) + build_public_materialization_request(options, output_format) } diff --git a/src/cli/materialization/manifest.rs b/src/cli/materialization/manifest.rs deleted file mode 100644 index 70d0d3b..0000000 --- a/src/cli/materialization/manifest.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::request::MaterializeOptions; -use crate::cli::{setup::GraphStatePaths, util::resolve_source_root}; -use crate::protocol::{NativeManifest, NativeSyntaxMaterializationRequest}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Path, PathBuf}; - -pub(in crate::cli) fn read_manifest(path: &Path) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read manifest {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse manifest {}: {error}", path.display())) -} - -pub(crate) fn request_manifest_path(options: &MaterializeOptions) -> Option { - if options.native_request.is_some() { - return options.manifest.clone(); - } - let source_root = - resolve_source_root(options.source_root.as_deref()).unwrap_or_else(|_| PathBuf::from(".")); - Some( - options - .manifest - .clone() - .unwrap_or_else(|| GraphStatePaths::derive(&source_root).manifest_path), - ) -} - -pub(crate) fn read_request(path: &Path) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read native request {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse native request {}: {error}", path.display())) -} - -pub(crate) fn write_manifest( - path: &Path, - request: &NativeSyntaxMaterializationRequest, - rebuilt_entries: &BTreeMap, - diff: &crate::protocol::ManifestDiff, -) -> Result<(), String> { - let mut files = if diff.force_rebuild { - BTreeMap::new() - } else { - request - .previous_manifest - .as_ref() - .map(|manifest| manifest.files.clone()) - .unwrap_or_default() - }; - let removed: BTreeSet = diff - .deleted - .iter() - .chain(diff.rebuild_paths().iter()) - .cloned() - .collect(); - files.retain(|path, _| !removed.contains(path)); - files.extend( - rebuilt_entries - .iter() - .map(|(path, entry)| (path.clone(), entry.clone())), - ); - - let manifest = NativeManifest { - schema_version: request.manifest_schema_version, - ontology: request.ontology.clone(), - parser_version: request.parser_version.clone(), - files, - }; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create manifest directory {}: {error}", - parent.display() - ) - })?; - } - let text = serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; - fs::write(path, format!("{text}\n")) - .map_err(|error| format!("failed to write manifest {}: {error}", path.display())) -} diff --git a/src/cli/materialization/mod.rs b/src/cli/materialization/mod.rs index 9c48741..243a738 100644 --- a/src/cli/materialization/mod.rs +++ b/src/cli/materialization/mod.rs @@ -1,13 +1,5 @@ mod command; -mod manifest; -mod output; mod request; +pub(crate) use crate::api::materialization::MaterializeOptions; pub(in crate::cli) use command::{run_materialize, run_plan}; -pub(crate) use manifest::{read_request, request_manifest_path, write_manifest}; -pub(crate) use output::serialize_plan_block; -pub(crate) use output::{dry_run_materialization_payload, materialization_payload}; -pub(crate) use request::{ - build_request, default_excluded_parts, read_codebase_graph_ignore, - read_materialization_config_rules, MaterializeOptions, -}; diff --git a/src/cli/materialization/output.rs b/src/cli/materialization/output.rs deleted file mode 100644 index f809e50..0000000 --- a/src/cli/materialization/output.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::cli::format::{block_value, value_array, value_str}; -use crate::cli::setup::GraphStatePaths; -use crate::cli::watch::scan_source_snapshots; -use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; -use serde_json::json; -use std::path::Path; - -pub(crate) fn materialization_payload( - response: &NativeSyntaxMaterializationResponse, - mode: &str, - paths: &GraphStatePaths, -) -> serde_json::Value { - let rebuilt_paths = response.diff.rebuild_paths(); - let skipped_paths = response - .snapshots - .iter() - .filter_map(|(path, snapshot)| { - if snapshot.language.is_none() { - Some(path.clone()) - } else { - None - } - }) - .collect::>(); - let ignored_paths = response - .diagnostics - .iter() - .filter_map(|diagnostic| diagnostic.strip_prefix("Ignored file: ")) - .map(str::to_string) - .collect::>(); - json!({ - "mode": mode, - "scanned": response.snapshots.len(), - "rebuilt": rebuilt_paths.len(), - "skipped": skipped_paths.len(), - "ignored": ignored_paths.len(), - "deleted": response.diff.deleted.len(), - "diagnostics": response.diagnostics, - "manifest_path": paths.manifest_path, - "rebuilt_paths": rebuilt_paths, - "skipped_paths": skipped_paths.clone(), - "ignored_paths": ignored_paths, - "deleted_paths": response.diff.deleted.clone(), - "would_rebuild": response.diff.rebuild_paths(), - "would_delete": response.diff.deleted, - "would_skip": skipped_paths, - "graph_summary": response.graph_summary, - "node_rows": response.node_rows, - "edge_rows": response.edge_rows, - "connector_rows": response.connector_rows, - "database_written": response.database_written, - "progress_events": response.progress_events, - "phase_timings": response.phase_timings, - }) -} - -pub(crate) fn dry_run_materialization_payload( - request: &NativeSyntaxMaterializationRequest, - paths: &GraphStatePaths, -) -> serde_json::Value { - let snapshots = scan_source_snapshots(Path::new(&request.source_root)); - let scanned = snapshots.len(); - let skipped_paths = snapshots - .into_iter() - .filter_map(|(path, language)| if language.is_none() { Some(path) } else { None }) - .collect::>(); - json!({ - "mode": "dry_run", - "scanned": scanned, - "rebuilt": 0, - "skipped": skipped_paths.len(), - "deleted": 0, - "diagnostics": [], - "manifest_path": paths.manifest_path, - "rebuilt_paths": [], - "skipped_paths": skipped_paths, - "deleted_paths": [], - "graph_summary": {}, - }) -} - -pub(crate) fn serialize_plan_block(payload: &serde_json::Value) -> String { - let mut lines = vec![format!( - "plan mode={} scanned={} rebuild={} delete={} skip={} ignored={}", - block_value(value_str(payload, "mode")), - payload - .get("scanned") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("rebuilt") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("deleted") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("skipped") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - payload - .get("ignored") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0), - )]; - append_plan_path_lines(&mut lines, "rebuild", value_array(payload, "would_rebuild")); - append_plan_path_lines(&mut lines, "delete", value_array(payload, "would_delete")); - append_plan_path_lines(&mut lines, "skip", value_array(payload, "would_skip")); - append_plan_path_lines(&mut lines, "ignore", value_array(payload, "ignored_paths")); - format!("{}\n", lines.join("\n")) -} - -pub(in crate::cli) fn append_plan_path_lines( - lines: &mut Vec, - label: &str, - paths: &[serde_json::Value], -) { - for path in paths { - if let Some(path) = path.as_str() { - lines.push(format!("{label} {}", block_value(path))); - } - } -} diff --git a/src/cli/materialization/request.rs b/src/cli/materialization/request.rs index ad6f5f0..0c530ff 100644 --- a/src/cli/materialization/request.rs +++ b/src/cli/materialization/request.rs @@ -1,259 +1,6 @@ -use super::manifest::read_manifest; -use crate::cli::setup::GraphStatePaths; -use crate::cli::{ - format::{materialize_help, plan_help, watch_help}, - util::resolve_source_root, -}; -use crate::protocol::NativeSyntaxMaterializationRequest; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -pub(crate) fn build_request( - options: &MaterializeOptions, -) -> Result { - let source_root = resolve_source_root(options.source_root.as_deref())?; - let paths = GraphStatePaths::derive(&source_root); - let db_path = options.db.clone().unwrap_or_else(|| paths.db_path.clone()); - let manifest_path = options - .manifest - .clone() - .unwrap_or_else(|| paths.manifest_path.clone()); - let previous_manifest = if manifest_path.exists() { - Some(read_manifest(&manifest_path)?) - } else { - None - }; - let config_rules = read_materialization_config_rules(&paths.config_path)?; - let mut include_patterns = config_rules.include_patterns; - include_patterns.extend(options.include_patterns.clone()); - let mut exclude_patterns = config_rules.exclude_patterns; - exclude_patterns.extend(options.exclude_patterns.clone()); - let ignore_patterns = read_codebase_graph_ignore(&source_root)?; - let candidate_paths = if options.candidate_paths.is_empty() { - git_candidate_paths(&source_root, options)? - } else { - normalized_candidate_paths(&options.candidate_paths) - }; - let staging_dir = paths.state_dir.join("native-staging"); - Ok(NativeSyntaxMaterializationRequest { - source_root: source_root.to_string_lossy().to_string(), - repository_label: paths.repo_name, - mode: options.mode.clone(), - parser_version: "native-rust-cli-v1".to_string(), - manifest_schema_version: 1, - ontology: "code_ontology_v1".to_string(), - ontology_schema: Default::default(), - previous_manifest, - profiles: Vec::new(), - excluded_parts: default_excluded_parts(), - include_patterns, - exclude_patterns, - ignore_patterns, - candidate_paths, - db_path: db_path.to_string_lossy().to_string(), - include_fts: options.include_fts, - semantic_enrichment: options.semantic_enrichment, - semantic_provider_mode: options.semantic_provider_mode.clone(), - schema_statements: Vec::new(), - staging_dir: staging_dir.to_string_lossy().to_string(), - atomic_rebuild: true, - strict: true, - parallel: options.parallel, - progress: options.progress, - }) -} - -#[derive(Default)] -pub(crate) struct ConfigScanRules { - pub(crate) include_patterns: Vec, - pub(crate) exclude_patterns: Vec, -} - -pub(crate) fn read_materialization_config_rules(path: &Path) -> Result { - if !path.exists() { - return Ok(ConfigScanRules::default()); - } - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read config {}: {error}", path.display()))?; - let value: serde_json::Value = serde_json::from_str(&text) - .map_err(|error| format!("failed to parse config {}: {error}", path.display()))?; - let materialization = value - .get("materialization") - .and_then(serde_json::Value::as_object); - Ok(ConfigScanRules { - include_patterns: materialization - .and_then(|payload| payload.get("include")) - .map(json_string_array) - .unwrap_or_default(), - exclude_patterns: materialization - .and_then(|payload| payload.get("exclude")) - .map(json_string_array) - .unwrap_or_default(), - }) -} - -pub(crate) fn json_string_array(value: &serde_json::Value) -> Vec { - value - .as_array() - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() -} - -pub(crate) fn read_codebase_graph_ignore(source_root: &Path) -> Result, String> { - let path = source_root.join(".codebaseGraphignore"); - if !path.exists() { - return Ok(Vec::new()); - } - let text = fs::read_to_string(&path) - .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - Ok(text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty() && !line.starts_with('#')) - .map(str::to_string) - .collect()) -} - -pub(crate) fn git_candidate_paths( - source_root: &Path, - options: &MaterializeOptions, -) -> Result, String> { - if !options.use_git { - return Ok(Vec::new()); - } - let mut paths = if options.git_diff && options.plan_only { - let base = options.git_base.as_deref().unwrap_or("HEAD"); - git_paths( - source_root, - &["diff", "--name-only", "--diff-filter=ACMRTD", base, "--"], - ) - .unwrap_or_default() - } else { - git_paths( - source_root, - &["ls-files", "--cached", "--others", "--exclude-standard"], - ) - .unwrap_or_default() - }; - if options.git_diff && options.plan_only { - if let Ok(untracked) = - git_paths(source_root, &["ls-files", "--others", "--exclude-standard"]) - { - paths.extend(untracked); - } - } - paths.sort(); - paths.dedup(); - Ok(paths) -} - -pub(crate) fn git_paths(source_root: &Path, args: &[&str]) -> Result, String> { - let output = Command::new("git") - .args(args) - .current_dir(source_root) - .output() - .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| line.replace('\\', "/")) - .collect()) -} - -pub(crate) fn normalized_candidate_paths(paths: &[String]) -> Vec { - let mut paths = paths - .iter() - .map(|path| path.trim().trim_start_matches("./").replace('\\', "/")) - .filter(|path| !path.is_empty()) - .collect::>(); - paths.sort(); - paths.dedup(); - paths -} - -pub(crate) fn default_excluded_parts() -> Vec { - [ - ".bzr", - ".cache", - ".codebaseGraph", - ".direnv", - ".eggs", - ".git", - ".hg", - ".mypy_cache", - ".nox", - ".svn", - ".tox", - ".venv", - "dist", - "node_modules", - "target", - "venv", - ] - .into_iter() - .map(str::to_string) - .collect() -} - -#[derive(Clone, Debug)] -pub(crate) struct MaterializeOptions { - pub(crate) native_request: Option, - pub(crate) source_root: Option, - pub(crate) db: Option, - pub(crate) manifest: Option, - pub(crate) mode: String, - pub(crate) include_fts: bool, - pub(crate) semantic_enrichment: bool, - pub(crate) semantic_provider_mode: String, - pub(crate) use_git: bool, - pub(crate) git_diff: bool, - pub(crate) git_base: Option, - pub(crate) include_patterns: Vec, - pub(crate) exclude_patterns: Vec, - pub(crate) candidate_paths: Vec, - pub(crate) parallel: bool, - pub(crate) progress: bool, - pub(crate) plan_only: bool, - pub(crate) help: bool, - pub(crate) json_output: bool, -} - -impl Default for MaterializeOptions { - fn default() -> Self { - Self { - native_request: None, - source_root: None, - db: None, - manifest: None, - mode: String::new(), - include_fts: false, - semantic_enrichment: false, - semantic_provider_mode: String::new(), - use_git: false, - git_diff: false, - git_base: None, - include_patterns: Vec::new(), - exclude_patterns: Vec::new(), - candidate_paths: Vec::new(), - parallel: true, - progress: false, - plan_only: false, - help: false, - json_output: false, - } - } -} +use crate::api::materialization::MaterializeOptions; +use crate::cli::format::{materialize_help, plan_help, watch_help}; +use std::path::PathBuf; impl MaterializeOptions { pub(crate) fn parse(args: &[String]) -> Result { @@ -262,10 +9,8 @@ impl MaterializeOptions { pub(crate) fn parse_with_command(args: &[String], command_name: &str) -> Result { let mut options = Self { - mode: "changed".to_string(), include_fts: true, semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), use_git: true, plan_only: command_name == "plan", ..Self::default() @@ -308,10 +53,7 @@ impl MaterializeOptions { "--mode" => { let value = args .get(index + 1) - .ok_or_else(|| "--mode requires full or changed".to_string())?; - if value != "full" && value != "changed" { - return Err("--mode must be full or changed".to_string()); - } + .ok_or_else(|| "--mode requires a value".to_string())?; options.mode = value.clone(); index += 2; } @@ -324,12 +66,9 @@ impl MaterializeOptions { index += 1; } "--semantic-provider-mode" => { - let value = args.get(index + 1).ok_or_else(|| { - "--semantic-provider-mode requires local_only".to_string() - })?; - if value != "local_only" { - return Err("--semantic-provider-mode must be local_only".to_string()); - } + let value = args + .get(index + 1) + .ok_or_else(|| "--semantic-provider-mode requires a value".to_string())?; options.semantic_provider_mode = value.clone(); index += 2; } diff --git a/src/cli/mcp/options.rs b/src/cli/mcp/options.rs index cfcde9a..ac1bd97 100644 --- a/src/cli/mcp/options.rs +++ b/src/cli/mcp/options.rs @@ -1,6 +1,6 @@ use super::http::is_local_host; use super::refresh::McpRefreshState; -use crate::cli::{format::mcp_help, graph::HealthOptions, util::required_arg}; +use crate::cli::{format::mcp_help, util::required_arg}; use std::{env, net::TcpListener, path::PathBuf, sync::Arc}; #[derive(Clone, Debug)] @@ -52,17 +52,6 @@ impl McpServeOptions { } Ok(options) } - - pub(in crate::cli) fn health_options(&self) -> HealthOptions { - HealthOptions { - repo_root: self.repo_root.clone(), - config: self.config.clone(), - db: self.db.clone(), - manifest: self.manifest.clone(), - help: false, - json: false, - } - } } #[derive(Clone, Debug)] diff --git a/src/cli/mcp/refresh.rs b/src/cli/mcp/refresh.rs index 66063d9..fea3b52 100644 --- a/src/cli/mcp/refresh.rs +++ b/src/cli/mcp/refresh.rs @@ -1,490 +1,17 @@ use super::options::McpServeOptions; -use crate::api::lifecycle::is_retryable_refresh_failure; -use crate::cli::{ - graph::resolve_health_runtime, - materialization::MaterializeOptions, - watch::{ - collect_poll_batch, collect_watch_batch, execute_refresh_operation, probe_native_watcher, - start_native_watcher, watch_file_snapshot, WatchEventFilter, WatchLoopConfig, WatchMessage, - }, +use crate::api::{ + contracts::RepoSelector, + refresh::{start_refresh_service, RefreshState}, }; -use crate::protocol::NativeSyntaxMaterializationResponse; -use serde_json::json; -use std::{ - collections::{BTreeSet, VecDeque}, - sync::{mpsc::Receiver, Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}, - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -const DEFAULT_POLL_MS: u64 = 500; -const DEFAULT_DEBOUNCE_MS: u64 = 250; -const REFRESH_INITIAL_RETRY_MS: u64 = 100; -const REFRESH_MAX_RETRY_MS: u64 = 1_000; - -#[derive(Debug)] -pub(in crate::cli) struct McpRefreshState { - status: Mutex, - graph_lock: RwLock<()>, -} - -#[derive(Clone, Debug)] -pub(in crate::cli) struct McpRefreshStatus { - pub(in crate::cli) enabled: bool, - pub(in crate::cli) backend: String, - pub(in crate::cli) refreshing: bool, - pub(in crate::cli) pending: bool, - pub(in crate::cli) last_refresh_unix_ms: Option, - pub(in crate::cli) last_error: Option, - pub(in crate::cli) last_error_count: usize, - pub(in crate::cli) last_retry_unix_ms: Option, - pub(in crate::cli) last_event_count: usize, - pub(in crate::cli) last_changed_paths: usize, - pub(in crate::cli) last_rebuilt: usize, - pub(in crate::cli) last_deleted: usize, - pub(in crate::cli) last_database_written: bool, -} - -impl Default for McpRefreshStatus { - fn default() -> Self { - Self { - enabled: true, - backend: "starting".to_string(), - refreshing: false, - pending: false, - last_refresh_unix_ms: None, - last_error: None, - last_error_count: 0, - last_retry_unix_ms: None, - last_event_count: 0, - last_changed_paths: 0, - last_rebuilt: 0, - last_deleted: 0, - last_database_written: false, - } - } -} - -impl McpRefreshState { - pub(in crate::cli) fn new() -> Self { - Self { - status: Mutex::new(McpRefreshStatus::default()), - graph_lock: RwLock::new(()), - } - } - - pub(in crate::cli) fn snapshot(&self) -> McpRefreshStatus { - self.status - .lock() - .map(|status| status.clone()) - .unwrap_or_else(|_| McpRefreshStatus { - enabled: false, - backend: "failed".to_string(), - refreshing: false, - pending: false, - last_refresh_unix_ms: None, - last_error: Some("refresh status lock poisoned".to_string()), - last_error_count: 1, - last_retry_unix_ms: None, - last_event_count: 0, - last_changed_paths: 0, - last_rebuilt: 0, - last_deleted: 0, - last_database_written: false, - }) - } +use std::sync::Arc; - pub(in crate::cli) fn as_json(&self) -> serde_json::Value { - let status = self.snapshot(); - json!({ - "enabled": status.enabled, - "backend": status.backend, - "refreshing": status.refreshing, - "pending": status.pending, - "last_refresh_unix_ms": status.last_refresh_unix_ms, - "last_error": status.last_error, - "last_error_count": status.last_error_count, - "last_retry_unix_ms": status.last_retry_unix_ms, - "last_event_count": status.last_event_count, - "last_changed_paths": status.last_changed_paths, - "last_rebuilt": status.last_rebuilt, - "last_deleted": status.last_deleted, - "last_database_written": status.last_database_written, - }) - } - - pub(in crate::cli) fn read_guard(&self) -> Result, String> { - self.graph_lock - .read() - .map_err(|_| "refresh graph read lock poisoned".to_string()) - } - - fn write_guard(&self) -> Result, String> { - self.graph_lock - .write() - .map_err(|_| "refresh graph write lock poisoned".to_string()) - } - - fn set_backend(&self, backend: &str) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.enabled = true; - status.last_error = None; - } - } - - fn set_error(&self, backend: &str, error: String) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.enabled = true; - status.refreshing = false; - status.pending = false; - status.last_error = Some(error); - status.last_error_count = status.last_error_count.saturating_add(1); - } - } - - fn mark_pending(&self) { - if let Ok(mut status) = self.status.lock() { - status.pending = true; - } - } - - fn mark_refreshing(&self, backend: &str) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = true; - status.pending = false; - status.last_error = None; - } - } - - fn mark_refresh_error( - &self, - backend: &str, - event_count: usize, - changed_paths: usize, - error: String, - retrying: bool, - ) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = false; - status.pending = retrying; - status.last_error = Some(error); - status.last_error_count = status.last_error_count.saturating_add(1); - status.last_retry_unix_ms = retrying.then_some(unix_ms()); - status.last_event_count = event_count; - status.last_changed_paths = changed_paths; - } - } - - fn mark_refreshed( - &self, - backend: &str, - event_count: usize, - changed_paths: usize, - rebuilt: usize, - deleted: usize, - database_written: bool, - ) { - if let Ok(mut status) = self.status.lock() { - status.backend = backend.to_string(); - status.refreshing = false; - status.pending = false; - status.last_refresh_unix_ms = Some(unix_ms()); - status.last_error = None; - status.last_error_count = 0; - status.last_retry_unix_ms = None; - status.last_event_count = event_count; - status.last_changed_paths = changed_paths; - status.last_rebuilt = rebuilt; - status.last_deleted = deleted; - status.last_database_written = database_written; - } - } -} +pub(in crate::cli) type McpRefreshState = RefreshState; pub(in crate::cli) fn start_auto_refresh(options: &McpServeOptions) -> Arc { - let state = Arc::new(McpRefreshState::new()); - let mut refresh_options = options.clone(); - refresh_options.refresh = None; - let thread_state = Arc::clone(&state); - thread::spawn(move || { - if let Err(error) = run_auto_refresh(refresh_options, &thread_state) { - thread_state.set_error("failed", error.clone()); - eprintln!( - "{}", - json!({"event": "mcp.auto_refresh_error", "message": error}) - ); - } - }); - state -} - -fn run_auto_refresh(options: McpServeOptions, state: &Arc) -> Result<(), String> { - let runtime = resolve_health_runtime(&options.health_options())?; - let materialize_options = MaterializeOptions { - source_root: Some(runtime.repo_root.clone()), - db: Some(runtime.db_path.clone()), - manifest: Some(runtime.manifest_path.clone()), - mode: "changed".to_string(), - include_fts: true, - semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), - use_git: false, - ..MaterializeOptions::default() - }; - let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; - let loop_config = WatchLoopConfig { - poll_ms: DEFAULT_POLL_MS, - debounce_ms: DEFAULT_DEBOUNCE_MS, - max_iterations: None, - }; - - match start_native_watcher(&runtime.repo_root) { - Ok((watcher, rx)) => { - let probe = probe_native_watcher(&runtime.repo_root, &filter, &rx)?; - if probe.delivered { - state.set_backend("native"); - match run_native_refresh_loop( - state, - loop_config, - materialize_options.clone(), - filter, - watcher, - rx, - probe.queued, - ) { - Ok(()) => Ok(()), - Err(error) => { - state.set_error("poll", error); - let filter = WatchEventFilter::from_options( - &runtime.repo_root, - &materialize_options, - )?; - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } - } else { - drop(watcher); - state.set_error( - "poll", - probe - .reason - .unwrap_or_else(|| "native probe failed".to_string()), - ); - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } - Err(error) => { - state.set_error("poll", error); - run_poll_refresh_loop(state, loop_config, materialize_options, filter) - } - } -} - -fn run_native_refresh_loop( - state: &Arc, - loop_config: WatchLoopConfig, - materialize_options: MaterializeOptions, - filter: WatchEventFilter, - _watcher: notify::RecommendedWatcher, - rx: Receiver, - mut queued: VecDeque, -) -> Result<(), String> { - loop { - let first = match queued.pop_front() { - Some(message) => message, - None => rx - .recv() - .map_err(|error| format!("filesystem watcher stopped: {error}"))?, - }; - let Some(batch) = collect_watch_batch( - first, - &rx, - &mut queued, - &filter, - Duration::from_millis(loop_config.debounce_ms), - Duration::from_millis(loop_config.debounce_ms.saturating_mul(4).max(1)), - )? - else { - continue; - }; - refresh_batch( - state, - "native", - &materialize_options, - batch.event_count, - batch.paths, - )?; - } -} - -fn run_poll_refresh_loop( - state: &Arc, - loop_config: WatchLoopConfig, - materialize_options: MaterializeOptions, - filter: WatchEventFilter, -) -> Result<(), String> { - state.set_backend("poll"); - let mut previous_snapshot = watch_file_snapshot(&filter)?; - loop { - let batch = collect_poll_batch( - &filter, - &mut previous_snapshot, - Duration::from_millis(loop_config.poll_ms), - Duration::from_millis(loop_config.debounce_ms), - Duration::from_millis(loop_config.debounce_ms.saturating_mul(4).max(1)), - )?; - refresh_batch( - state, - "poll", - &materialize_options, - batch.event_count, - batch.paths, - )?; - } -} - -fn refresh_batch( - state: &Arc, - backend: &str, - materialize_options: &MaterializeOptions, - event_count: usize, - paths: BTreeSet, -) -> Result<(), String> { - let changed_paths = paths.len(); - if changed_paths == 0 { - return Ok(()); - } - refresh_batch_with(state, backend, event_count, paths, |candidate_paths| { - execute_refresh_operation(materialize_options, candidate_paths) + start_refresh_service(RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), }) } - -fn refresh_batch_with( - state: &Arc, - backend: &str, - event_count: usize, - paths: BTreeSet, - mut refresh: impl FnMut(Vec) -> Result, -) -> Result<(), String> { - let changed_paths = paths.len(); - if changed_paths == 0 { - return Ok(()); - } - let candidate_paths = paths.into_iter().collect::>(); - let mut retry_delay = Duration::from_millis(REFRESH_INITIAL_RETRY_MS); - loop { - state.mark_pending(); - let result = { - let _guard = state.write_guard()?; - state.mark_refreshing(backend); - refresh(candidate_paths.clone()) - }; - match result { - Ok(response) => { - state.mark_refreshed( - backend, - event_count, - changed_paths, - response.diff.rebuild_paths().len(), - response.diff.deleted.len(), - response.database_written, - ); - return Ok(()); - } - Err(error) => { - let retrying = is_retryable_refresh_failure(&error); - state.mark_refresh_error(backend, event_count, changed_paths, error, retrying); - if !retrying { - return Ok(()); - } - thread::sleep(retry_delay); - retry_delay = retry_delay - .saturating_mul(2) - .min(Duration::from_millis(REFRESH_MAX_RETRY_MS)); - } - } - } -} - -fn unix_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::ManifestDiff; - use std::{ - collections::{BTreeMap, BTreeSet}, - sync::atomic::{AtomicUsize, Ordering}, - }; - - #[test] - fn refresh_batch_retries_transient_errors_without_failing_state() { - let state = Arc::new(McpRefreshState::new()); - let attempts = AtomicUsize::new(0); - refresh_batch_with( - &state, - "poll", - 1, - BTreeSet::from(["src/lib.rs".to_string()]), - |_| { - if attempts.fetch_add(1, Ordering::SeqCst) == 0 { - Err("IO exception: Could not set lock on file".to_string()) - } else { - Ok(NativeSyntaxMaterializationResponse::skipped( - BTreeMap::new(), - ManifestDiff { - added: Vec::new(), - modified: Vec::new(), - unchanged: Vec::new(), - deleted: Vec::new(), - force_rebuild: false, - }, - Vec::new(), - Vec::new(), - BTreeMap::new(), - )) - } - }, - ) - .unwrap(); - - let status = state.snapshot(); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert_eq!(status.backend, "poll"); - assert!(!status.refreshing); - assert!(!status.pending); - assert!(status.last_error.is_none()); - assert_eq!(status.last_error_count, 0); - assert!(status.last_refresh_unix_ms.is_some()); - } - - #[test] - fn refresh_batch_records_non_transient_errors_and_keeps_loop_alive() { - let state = Arc::new(McpRefreshState::new()); - refresh_batch_with( - &state, - "native", - 1, - BTreeSet::from(["src/lib.rs".to_string()]), - |_| Err("parser exploded".to_string()), - ) - .unwrap(); - - let status = state.snapshot(); - assert_eq!(status.backend, "native"); - assert!(!status.refreshing); - assert!(!status.pending); - assert_eq!(status.last_error.as_deref(), Some("parser exploded")); - assert_eq!(status.last_error_count, 1); - assert!(status.last_refresh_unix_ms.is_none()); - } -} diff --git a/src/cli/mcp/tools.rs b/src/cli/mcp/tools.rs index eaf1ae0..4c1c308 100644 --- a/src/cli/mcp/tools.rs +++ b/src/cli/mcp/tools.rs @@ -1,4 +1,5 @@ use super::options::McpServeOptions; +use crate::api::normalization::required_fields; use crate::api::{ contracts::{ ApiError, ContextRequest, HealthRequest, OperationRequest, OperationResponse, OutputFormat, @@ -41,6 +42,7 @@ fn operation_spec_from_descriptor(descriptor: OperationDescriptor) -> Option { - let search = search_request_from_mcp(arguments, options, output_format, true) - .map_err(|error| ApiError::new("invalid_query", error))?; + let search = search_request_from_mcp(arguments, options, output_format); execute_api_request(OperationRequest::Search(search)) } "graph_context" => { - let context = context_request_from_mcp(arguments, options, output_format) - .map_err(|error| ApiError::new("invalid_context", error))?; + let context = context_request_from_mcp(arguments, options, output_format); execute_api_request(OperationRequest::Context(context)) } "graph_query" => { @@ -205,37 +205,19 @@ pub(in crate::cli) fn mcp_tool_payload( .get("statement") .and_then(serde_json::Value::as_str) .unwrap_or("") - .trim(); - if statement.is_empty() { - return Err(ApiError::new( - "missing_query", - "graph_query requires a non-empty statement", - )); - } + .to_string(); let parameters = arguments .get("parameters") .cloned() .unwrap_or_else(|| json!({})); - let parameters = parameters.as_object().ok_or_else(|| { - ApiError::new( - "invalid_parameters", - "graph_query parameters must be a JSON object", - ) - })?; let limit = arguments .get("limit") .and_then(serde_json::Value::as_u64) .unwrap_or(100) as usize; - if limit == 0 || limit > 1000 { - return Err(ApiError::new( - "invalid_limit", - "graph_query limit must be between 1 and 1000", - )); - } execute_api_request(OperationRequest::Query(QueryRequest { repo: repo_selector_from_mcp_options(options), - statement: statement.to_string(), - parameters: serde_json::Value::Object(parameters.clone()), + statement, + parameters, limit, output_format, })) @@ -264,24 +246,17 @@ fn search_request_from_mcp( arguments: &serde_json::Value, options: &McpServeOptions, output_format: OutputFormat, - require_query: bool, -) -> Result { +) -> SearchRequest { let query = arguments .get("query") .and_then(serde_json::Value::as_str) .unwrap_or("") .to_string(); - if require_query && query.trim().is_empty() { - return Err("Search query must not be empty".to_string()); - } let detail = arguments .get("detail") .and_then(serde_json::Value::as_str) .unwrap_or("standard"); - if detail != "standard" && detail != "slim" { - return Err("--detail must be standard or slim".to_string()); - } - Ok(SearchRequest { + SearchRequest { repo: repo_selector_from_mcp_options(options), query, limit: json_usize(arguments, "limit", 3), @@ -298,14 +273,14 @@ fn search_request_from_mcp( .map(|value| value as usize), detail: detail.to_string(), output_format, - }) + } } fn context_request_from_mcp( arguments: &serde_json::Value, options: &McpServeOptions, output_format: OutputFormat, -) -> Result { +) -> ContextRequest { let node_id = arguments .get("node_id") .and_then(serde_json::Value::as_str) @@ -316,13 +291,8 @@ fn context_request_from_mcp( .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string); - if node_id.is_some() != node_type.is_some() { - return Err( - "codebase-context explicit lookup requires both --node-id and --node-type".to_string(), - ); - } - let search = search_request_from_mcp(arguments, options, output_format, node_id.is_none())?; - Ok(ContextRequest { + let search = search_request_from_mcp(arguments, options, output_format); + ContextRequest { repo: search.repo, query: if search.query.is_empty() { None @@ -338,7 +308,7 @@ fn context_request_from_mcp( node_id, node_type, output_format, - }) + } } pub(in crate::cli) fn json_usize( @@ -400,5 +370,6 @@ mod tests { search["inputSchema"]["properties"]["detail"]["enum"], json!(["standard", "slim"]) ); + assert_eq!(search["inputSchema"]["required"], json!(["query"])); } } diff --git a/src/cli/reinstall.rs b/src/cli/reinstall.rs index 2e65679..08f37ba 100644 --- a/src/cli/reinstall.rs +++ b/src/cli/reinstall.rs @@ -1,19 +1,9 @@ -use super::{ - format::reinstall_help, - setup::{setup_payload_for_root, GraphStatePaths}, - watch::SetupOptions, -}; -use crate::api::contracts::RepositoryLifecycleRequest; +use super::{format::reinstall_help, watch::SetupOptions}; use crate::api::{ - contracts::{OperationRequest, OutputFormat, RepoSelector}, + contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, CodebaseGraphApi, }; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::io::Write; pub(in crate::cli) fn run_reinstall( args: &[String], @@ -55,196 +45,3 @@ pub(in crate::cli) fn run_reinstall( .map_err(|error| error.to_string())?; Ok(()) } - -pub(crate) fn execute_reinstall_operation( - request: &RepositoryLifecycleRequest, - repo_root: &Path, -) -> Result { - reinstall_payload_for_request(request, repo_root) -} - -pub(crate) fn reinstall_payload_for_request( - request: &RepositoryLifecycleRequest, - repo_root: &Path, -) -> Result { - execute_reinstall_operation_from_options( - &SetupOptions { - repo_root: request.repo.repo_root.clone(), - mode: request.mode.clone(), - include_fts: request.include_fts, - semantic_enrichment: request.semantic_enrichment, - semantic_provider_mode: request.semantic_provider_mode.clone(), - mcp_client: request - .mcp_client - .clone() - .unwrap_or_else(|| "codex".to_string()), - mcp_config_path: request.mcp_config_path.clone(), - skip_mcp_config: request.skip_mcp_config, - dry_run: request.dry_run, - instructions_target: request - .instructions_target - .clone() - .unwrap_or_else(|| "auto".to_string()), - help: false, - }, - repo_root, - ) -} - -fn execute_reinstall_operation_from_options( - options: &SetupOptions, - repo_root: &Path, -) -> Result { - let repo_root = repo_root.to_path_buf(); - if repo_root - .components() - .any(|component| component.as_os_str() == ".codebaseGraph") - { - return Err(format!( - "Repository root may not be inside a .codebaseGraph state directory: {}", - repo_root.display() - )); - } - - let paths = GraphStatePaths::derive(&repo_root); - let state = reinstall_state(&paths, options.dry_run)?; - let install = if options.dry_run { - setup_payload_for_root(options, &repo_root)? - } else { - match setup_payload_for_root(options, &repo_root) { - Ok(payload) => { - remove_backup(state.backup_path.as_deref())?; - payload - } - Err(error) => { - restore_backup(&paths.state_dir, state.backup_path.as_deref())?; - return Err(error); - } - } - }; - - let output = json!({ - "ok": true, - "repo_root": repo_root, - "dry_run": options.dry_run, - "state": state.payload, - "install": install, - }); - Ok(output) -} - -struct ReinstallState { - payload: serde_json::Value, - backup_path: Option, -} - -fn reinstall_state(paths: &GraphStatePaths, dry_run: bool) -> Result { - if !paths.state_dir.exists() { - return Ok(ReinstallState { - payload: json!({ - "action": "unchanged", - "path": paths.state_dir, - "backup_path": serde_json::Value::Null, - }), - backup_path: None, - }); - } - let backup_path = next_backup_path(&paths.state_dir)?; - if dry_run { - return Ok(ReinstallState { - payload: json!({ - "action": "dry_run", - "path": paths.state_dir, - "backup_path": backup_path, - }), - backup_path: None, - }); - } - fs::rename(&paths.state_dir, &backup_path).map_err(|error| { - format!( - "failed to move existing graph state {} to {}: {error}", - paths.state_dir.display(), - backup_path.display() - ) - })?; - Ok(ReinstallState { - payload: json!({ - "action": "backed_up", - "path": paths.state_dir, - "backup_path": backup_path, - }), - backup_path: Some(backup_path), - }) -} - -fn next_backup_path(state_dir: &Path) -> Result { - let parent = state_dir.parent().unwrap_or_else(|| Path::new(".")); - let file_name = state_dir - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(".codebaseGraph"); - for index in 0..1000 { - let suffix = if index == 0 { - "reinstall-backup".to_string() - } else { - format!("reinstall-backup-{index}") - }; - let candidate = parent.join(format!("{file_name}.{suffix}")); - if !candidate.exists() { - return Ok(candidate); - } - } - Err(format!( - "failed to choose backup path for {}", - state_dir.display() - )) -} - -fn remove_backup(path: Option<&Path>) -> Result<(), String> { - let Some(path) = path else { - return Ok(()); - }; - remove_path(path).map_err(|error| { - format!( - "failed to remove reinstall backup {} after successful setup: {error}", - path.display() - ) - }) -} - -fn restore_backup(state_dir: &Path, backup_path: Option<&Path>) -> Result<(), String> { - let Some(backup_path) = backup_path else { - if state_dir.exists() { - remove_path(state_dir).map_err(|error| { - format!( - "failed to remove partial graph state {} after setup failure: {error}", - state_dir.display() - ) - })?; - } - return Ok(()); - }; - if state_dir.exists() { - remove_path(state_dir).map_err(|error| { - format!( - "failed to remove partial graph state {} before restore: {error}", - state_dir.display() - ) - })?; - } - fs::rename(backup_path, state_dir).map_err(|error| { - format!( - "failed to restore graph state backup {} to {}: {error}", - backup_path.display(), - state_dir.display() - ) - }) -} - -fn remove_path(path: &Path) -> std::io::Result<()> { - if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } -} diff --git a/src/cli/setup.rs b/src/cli/setup.rs index cfc842e..001bb90 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -1,23 +1,11 @@ -use super::{ - constants::server_command, - format::setup_help, - install::{build_mcp_descriptor, install_mcp_client, McpInstallOptions}, - materialization::{ - build_request, dry_run_materialization_payload, materialization_payload, MaterializeOptions, - }, - util::{read_json_file, restore_file, snapshot_file}, - watch::SetupOptions, +use super::{format::setup_help, watch::SetupOptions}; +use crate::api::contracts::{ + OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, }; -use crate::api::contracts::RepositoryLifecycleRequest; -use crate::api::contracts::{OperationRequest, RepoSelector}; -use crate::api::materialization::execute_materialization; use crate::api::CodebaseGraphApi; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::io::Write; + +pub(crate) use crate::api::lifecycle::{safe_name, GraphStatePaths}; pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), String> { let options = SetupOptions::parse(args)?; @@ -33,7 +21,7 @@ pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), manifest_path: None, }, action: "setup".to_string(), - output_format: crate::api::contracts::OutputFormat::Typed, + output_format: OutputFormat::Typed, dry_run: options.dry_run, mcp_client: Some(options.mcp_client.clone()), mcp_config_path: options.mcp_config_path.clone(), @@ -56,482 +44,3 @@ pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), .map_err(|error| error.to_string())?; Ok(()) } - -pub(crate) fn setup_payload_for_request( - request: &RepositoryLifecycleRequest, - source_root: &Path, -) -> Result { - let options = SetupOptions { - repo_root: request.repo.repo_root.clone(), - mode: request.mode.clone(), - include_fts: request.include_fts, - semantic_enrichment: request.semantic_enrichment, - semantic_provider_mode: request.semantic_provider_mode.clone(), - mcp_client: request - .mcp_client - .clone() - .unwrap_or_else(|| "codex".to_string()), - mcp_config_path: request.mcp_config_path.clone(), - skip_mcp_config: request.skip_mcp_config, - dry_run: request.dry_run, - instructions_target: request - .instructions_target - .clone() - .unwrap_or_else(|| "auto".to_string()), - help: false, - }; - setup_payload_for_root(&options, source_root) -} - -pub(crate) fn execute_setup_operation( - request: &RepositoryLifecycleRequest, - source_root: &Path, -) -> Result { - setup_payload_for_request(request, source_root) -} - -pub(in crate::cli) fn setup_payload_for_root( - options: &SetupOptions, - source_root: &Path, -) -> Result { - let source_root = source_root.to_path_buf(); - let paths = GraphStatePaths::derive(&source_root); - if source_root - .components() - .any(|component| component.as_os_str() == ".codebaseGraph") - { - return Err(format!( - "Repository root may not be inside a .codebaseGraph state directory: {}", - source_root.display() - )); - } - let materialize_options = MaterializeOptions { - source_root: Some(source_root.clone()), - db: Some(paths.db_path.clone()), - manifest: Some(paths.manifest_path.clone()), - mode: options.mode.clone(), - include_fts: options.include_fts, - semantic_enrichment: options.semantic_enrichment, - semantic_provider_mode: options.semantic_provider_mode.clone(), - use_git: true, - ..MaterializeOptions::default() - }; - let config_payload = setup_config_payload(&paths, &source_root); - let instructions_path = instruction_target_path(&source_root, &options.instructions_target)?; - let state_dir_existed = paths.state_dir.exists(); - let graph_state_existed = - paths.config_path.exists() && paths.db_path.exists() && paths.manifest_path.exists(); - let previous_config = snapshot_file(&paths.config_path)?; - let previous_instructions = match instructions_path.as_ref() { - Some(path) => Some((path.clone(), snapshot_file(path)?)), - None => None, - }; - let (config_action, instructions, mcp_config, materialization) = if options.dry_run { - let request = build_request(&materialize_options)?; - let materialization = dry_run_materialization_payload(&request, &paths); - let config_action = if json_file_would_change(&paths.config_path, &config_payload)? { - "dry_run" - } else { - "unchanged" - }; - let instructions = json!({ - "action": if instructions_path.is_some() { "dry_run" } else { "skipped" }, - "path": instructions_path.as_ref().map(|path| path.to_string_lossy().to_string()), - }); - let mcp_config = setup_mcp_config(options, &paths, true)?; - ( - config_action.to_string(), - instructions, - mcp_config, - materialization, - ) - } else { - fs::create_dir_all(&paths.state_dir).map_err(|error| { - format!( - "failed to create state directory {}: {error}", - paths.state_dir.display() - ) - })?; - let result = (|| { - let config_action = write_setup_config(&paths, &source_root)?; - let instructions = upsert_instruction_block( - &source_root, - &options.instructions_target, - &paths.config_path, - )?; - let materialization = if graph_state_existed { - existing_graph_materialization_payload(&materialize_options.mode, &paths) - } else { - let (_, response) = execute_materialization(&materialize_options)?; - materialization_payload(&response, &materialize_options.mode, &paths) - }; - let mcp_config = setup_mcp_config(options, &paths, false)?; - Ok::<_, String>(( - config_action.to_string(), - instructions, - mcp_config, - materialization, - )) - })(); - match result { - Ok(result) => result, - Err(error) => { - restore_file(&paths.config_path, previous_config.as_deref())?; - if let Some((path, previous)) = previous_instructions.as_ref() { - restore_file(path, previous.as_deref())?; - } - if !state_dir_existed { - let _ = fs::remove_dir_all(&paths.state_dir); - } - return Err(error); - } - } - }; - Ok(json!({ - "ok": true, - "repo_root": source_root, - "repo_name": paths.repo_name, - "state_dir": paths.state_dir, - "db_path": paths.db_path, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "config_path": paths.config_path, - "config_action": config_action, - "mcp_config": mcp_config, - "instructions": instructions, - "materialization": materialization, - "database_written": materialization.get("database_written").cloned().unwrap_or(json!(false)), - "skipped": materialization.get("skipped").cloned().unwrap_or(json!(0)), - "node_rows": materialization.get("node_rows").cloned().unwrap_or(json!(0)), - "edge_rows": materialization.get("edge_rows").cloned().unwrap_or(json!(0)), - "connector_rows": materialization.get("connector_rows").cloned().unwrap_or(json!(0)), - "diagnostics": materialization.get("diagnostics").cloned().unwrap_or(json!([])), - })) -} - -fn existing_graph_materialization_payload( - mode: &str, - paths: &GraphStatePaths, -) -> serde_json::Value { - json!({ - "mode": mode, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "database_written": false, - "skipped": true, - "skip_reason": "existing_graph_state", - "rebuilt": 0, - "deleted": 0, - "node_rows": 0, - "edge_rows": 0, - "connector_rows": 0, - "diagnostics": [], - "phase_timings": {}, - }) -} -pub(in crate::cli) fn setup_mcp_config( - options: &SetupOptions, - paths: &GraphStatePaths, - dry_run: bool, -) -> Result { - let descriptor = build_mcp_descriptor(&McpInstallOptions { - client: "generic".to_string(), - scope: "local".to_string(), - name: Some("codebase_graph".to_string()), - config_path: Some(paths.config_path.clone()), - client_config_path: None, - repo_root: Some( - paths - .state_dir - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")), - ), - dry_run: true, - verify: false, - json: true, - help: false, - })?; - if options.skip_mcp_config || options.mcp_client == "none" { - return Ok(json!({ - "action": "skipped", - "client": options.mcp_client, - "scope": "local", - "server_name": descriptor.name, - "method": serde_json::Value::Null, - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, true), - })); - } - install_mcp_client(&McpInstallOptions { - client: options.mcp_client.clone(), - scope: if options.mcp_client == "claude-project" { - "project".to_string() - } else { - "local".to_string() - }, - name: Some("codebase_graph".to_string()), - config_path: Some(paths.config_path.clone()), - client_config_path: options.mcp_config_path.clone(), - repo_root: Some( - paths - .state_dir - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")), - ), - dry_run, - verify: false, - json: true, - help: false, - }) -} -pub(crate) fn setup_config_payload(paths: &GraphStatePaths, repo_root: &Path) -> serde_json::Value { - json!({ - "schema_version": 1, - "repo_root": repo_root, - "repo_name": paths.repo_name, - "state_dir": paths.state_dir, - "database_path": paths.db_path, - "manifest_path": paths.manifest_path, - "ontology_version": "code_ontology_v1", - "package_version": env!("CARGO_PKG_VERSION"), - "materialization": { - "include": [], - "exclude": [] - }, - "mcp": { - "server_name": "codebase_graph", - "command": [ - server_command(), - "mcp", - "start", - "--config", - paths.config_path.to_string_lossy() - ] - } - }) -} - -pub(crate) fn write_setup_config( - paths: &GraphStatePaths, - repo_root: &Path, -) -> Result<&'static str, String> { - let payload = setup_config_payload(paths, repo_root); - let mut action = "created"; - if paths.config_path.exists() { - let previous = read_json_file(&paths.config_path)?; - if previous == payload { - return Ok("unchanged"); - } - action = "updated"; - } - if let Some(parent) = paths.config_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create config directory {}: {error}", - parent.display() - ) - })?; - } - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())?; - fs::write(&paths.config_path, format!("{text}\n")).map_err(|error| { - format!( - "failed to write install config {}: {error}", - paths.config_path.display() - ) - })?; - Ok(action) -} - -pub(crate) fn json_file_would_change( - path: &Path, - payload: &serde_json::Value, -) -> Result { - if !path.exists() { - return Ok(true); - } - Ok(read_json_file(path)? != *payload) -} - -pub(crate) fn instruction_target_path( - repo_root: &Path, - target: &str, -) -> Result, String> { - match target { - "skip" => Ok(None), - "agents" => Ok(Some(repo_root.join("AGENTS.md"))), - "claude" => Ok(Some(repo_root.join("CLAUDE.md"))), - "auto" => { - let agents = repo_root.join("AGENTS.md"); - if agents.exists() { - return Ok(Some(agents)); - } - let claude = repo_root.join("CLAUDE.md"); - if claude.exists() { - return Ok(Some(claude)); - } - Ok(Some(agents)) - } - other => Err(format!("Unsupported instruction target: {other}")), - } -} - -pub(crate) fn upsert_instruction_block( - repo_root: &Path, - target: &str, - config_path: &Path, -) -> Result { - let Some(path) = instruction_target_path(repo_root, target)? else { - return Ok(json!({"action": "skipped", "path": serde_json::Value::Null})); - }; - let existing = fs::read_to_string(&path).unwrap_or_default(); - let created = !path.exists(); - let block = instruction_block(config_path); - let (next_text, action) = upsert_instruction_text(&existing, &block, created); - if next_text == existing { - return Ok(json!({"action": "unchanged", "path": path.to_string_lossy()})); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create instruction directory {}: {error}", - parent.display() - ) - })?; - } - fs::write(&path, next_text) - .map_err(|error| format!("failed to write instructions {}: {error}", path.display()))?; - Ok(json!({"action": action, "path": path.to_string_lossy()})) -} - -pub(crate) fn instruction_block(config_path: &Path) -> String { - format!( - "\n\ -## codebaseGraph workflow\n\ -- Treat the repo-local `.codebaseGraph` graph as the project operating source of truth. It is prohibited to read the code source before you find the target files using the graph.\n\ -- Prefer the `codebase_graph` MCP server tools over shell commands whenever they are exposed in the current agent session.\n\ -- AI agents receive block output by default for graph CLI and MCP tools; request `output_format: \"json\"` or `include_structured_content: true` only for tests, APIs, or explicit structured-payload debugging.\n\ -- Use MCP `graph_search` with `detail: \"slim\"` and `context_limit: 1` before answering repo-structure questions or performing coding tasks.\n\ -- Use MCP `graph_context` with `profile: \"\"`, `detail: \"slim\"`, and `context_limit: 2` when relationships or nearby evidence matter; useful profiles include `definitions`, `dependencies`, `callgraph`, `docs`, `runtime`, and `change_impact`.\n\ -- For architecture orientation, use MCP `graph_architecture_queries`, then execute selected read-only statements with MCP `graph_query`.\n\ -- Use MCP `graph_schema` or `graph_query_helpers` before writing raw graph queries, and keep `graph_query` read-only.\n\ -- If MCP tools are unavailable, fall back to CLI: `{command} codebase-search --no-refresh --detail slim --context-limit 1`, `{command} codebase-context --profile --no-refresh --detail slim --context-limit 2`, `{command} codebase-architecture-queries`, `{command} graph-query \"\"`, `{command} schema`, and `{command} query-helpers`.\n\ -- Do not rerun install to refresh the graph. The MCP server started from this setup config watches the repo and refreshes automatically; use `{command} build --mode full` only for explicit manual rebuilds. Setup config: `{config_path}`.\n\ -\n", - command = server_command(), - config_path = config_path.to_string_lossy(), - ) -} - -pub(crate) fn upsert_instruction_text( - existing: &str, - block: &str, - created: bool, -) -> (String, &'static str) { - const START: &str = ""; - const END: &str = ""; - if existing.trim().is_empty() { - return (block.to_string(), "created"); - } - let Some(start) = existing.find(START) else { - let separator = if existing.ends_with('\n') { "" } else { "\n" }; - let action = if created { "created" } else { "updated" }; - return ( - format!("{}{separator}\n{}", existing.trim_end(), block), - action, - ); - }; - let Some(end) = existing.find(END) else { - return ( - format!("{}\n\n{}", existing.trim_end(), block), - if created { "created" } else { "updated" }, - ); - }; - if end < start { - return ( - format!("{}\n\n{}", existing.trim_end(), block), - if created { "created" } else { "updated" }, - ); - } - let after_end = end + END.len(); - let text = format!( - "{}\n\n{}\n\n{}", - existing[..start].trim_end(), - block.trim_end(), - existing[after_end..].trim_start() - ) - .trim() - .to_string() - + "\n"; - (text, "updated") -} - -pub(crate) fn remove_instruction_text(existing: &str) -> (String, bool) { - const START: &str = ""; - const END: &str = ""; - let Some(start) = existing.find(START) else { - return (existing.to_string(), false); - }; - let Some(end) = existing[start..].find(END).map(|index| start + index) else { - return (existing.to_string(), false); - }; - let after_end = end + END.len(); - let before = existing[..start].trim_end(); - let after = existing[after_end..].trim_start(); - let text = match (before.is_empty(), after.is_empty()) { - (true, true) => String::new(), - (true, false) => format!("{after}\n"), - (false, true) => format!("{before}\n"), - (false, false) => format!("{before}\n\n{after}"), - }; - (text, true) -} -#[derive(Debug)] -pub(crate) struct GraphStatePaths { - pub(crate) repo_name: String, - pub(crate) state_dir: PathBuf, - pub(crate) db_path: PathBuf, - pub(crate) manifest_path: PathBuf, - pub(crate) config_path: PathBuf, -} - -impl GraphStatePaths { - pub(crate) fn derive(repo_root: &Path) -> Self { - let repo_name = safe_name( - repo_root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repository"), - ); - let state_dir = repo_root.join(".codebaseGraph"); - Self { - db_path: state_dir.join(format!("{repo_name}_graph.ldb")), - manifest_path: state_dir.join("manifest.json"), - config_path: state_dir.join("config.json"), - state_dir, - repo_name, - } - } -} - -pub(crate) fn safe_name(value: &str) -> String { - let normalized: String = value - .chars() - .map(|character| { - if character.is_alphanumeric() || character == '-' || character == '_' { - character - } else { - '_' - } - }) - .collect(); - let trimmed = normalized.trim_matches(['.', '_', '-']); - if trimmed.is_empty() { - "repository".to_string() - } else { - trimmed.to_string() - } -} diff --git a/src/cli/tests/dispatch_materialize.rs b/src/cli/tests/dispatch_materialize.rs index dc7ba64..4cb98b8 100644 --- a/src/cli/tests/dispatch_materialize.rs +++ b/src/cli/tests/dispatch_materialize.rs @@ -105,24 +105,24 @@ fn reinstall_help_is_product_command_help() { #[test] fn materialize_rejects_provider_backed_semantic_modes() { - let args = vec![ - "--semantic-provider-mode".to_string(), - "provider_first".to_string(), - ]; - let error = MaterializeOptions::parse(&args).unwrap_err(); + let error = run( + ["build", "--semantic-provider-mode", "provider_first"], + &mut Vec::new(), + ) + .unwrap_err(); - assert!(error.contains("--semantic-provider-mode must be local_only")); + assert!(error.contains("materialization semantic provider mode must be local_only")); } #[test] fn setup_rejects_provider_backed_semantic_modes() { - let args = vec![ - "--semantic-provider-mode".to_string(), - "opportunistic".to_string(), - ]; - let error = SetupOptions::parse(&args).unwrap_err(); + let error = run( + ["install", "--semantic-provider-mode", "opportunistic"], + &mut Vec::new(), + ) + .unwrap_err(); - assert!(error.contains("--semantic-provider-mode must be local_only")); + assert!(error.contains("setup semantic provider mode must be local_only")); } #[test] diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index 34e8653..6bbe9be 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -1,23 +1,13 @@ use super::{ format::uninstall_help, - install::{ - build_mcp_descriptor, default_client_config_path, install_scope, remove_client_config, - supported_install_clients, supported_install_clients_with_all, write_text_atomic, - McpInstallOptions, - }, - setup::{remove_instruction_text, GraphStatePaths}, - util::{read_json_file, required_arg}, + install::{supported_install_clients, supported_install_clients_with_all}, + util::required_arg, }; use crate::api::{ - contracts::{OperationRequest, OutputFormat, RepositoryLifecycleRequest}, + contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, CodebaseGraphApi, }; -use serde_json::json; -use std::{ - fs, - io::Write, - path::{Path, PathBuf}, -}; +use std::{io::Write, path::PathBuf}; #[derive(Debug)] pub(in crate::cli) struct UninstallOptions { @@ -109,7 +99,7 @@ pub(in crate::cli) fn run_uninstall( return Ok(()); } let request = RepositoryLifecycleRequest { - repo: crate::api::contracts::RepoSelector { + repo: RepoSelector { repo_root: options.repo_root.clone(), config_path: options.config.clone(), db_path: None, @@ -151,243 +141,3 @@ pub(in crate::cli) fn run_uninstall( } Ok(()) } - -pub(crate) fn execute_uninstall_operation( - request: &RepositoryLifecycleRequest, - repo_root: &Path, -) -> Result { - uninstall_payload_for_request(request, repo_root) -} - -pub(crate) fn uninstall_payload_for_request( - request: &RepositoryLifecycleRequest, - repo_root: &Path, -) -> Result { - let repo_root = repo_root.to_path_buf(); - let paths = GraphStatePaths::derive(&repo_root); - let config_path = request - .repo - .config_path - .clone() - .unwrap_or_else(|| paths.config_path.clone()); - let mcp_client = request - .mcp_client - .clone() - .unwrap_or_else(|| "all".to_string()); - let server_name = uninstall_server_name(&repo_root, &config_path)?; - let state = uninstall_state_dir(&paths.state_dir, request.dry_run)?; - let instructions = uninstall_instruction_blocks(&repo_root, request.dry_run)?; - let options = UninstallOptions { - repo_root: request.repo.repo_root.clone(), - config: request.repo.config_path.clone(), - mcp_client, - client_config_path: request.mcp_config_path.clone(), - dry_run: request.dry_run, - json: request.output_format == crate::api::contracts::OutputFormat::Typed, - help: false, - }; - let mcp_clients = uninstall_mcp_clients(&options, &repo_root, &config_path, &server_name)?; - let output = json!({ - "ok": true, - "repo_root": repo_root, - "config_path": config_path, - "server_name": server_name, - "dry_run": request.dry_run, - "state": state, - "instructions": instructions, - "mcp_clients": mcp_clients, - }); - Ok(output) -} - -fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result { - if config_path.exists() { - let config = read_json_file(config_path)?; - if let Some(name) = config - .pointer("/mcp/server_name") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - { - return Ok(name.to_string()); - } - } - Ok(build_mcp_descriptor(&McpInstallOptions { - client: "generic".to_string(), - scope: "local".to_string(), - name: None, - config_path: Some(config_path.to_path_buf()), - client_config_path: None, - repo_root: Some(repo_root.to_path_buf()), - dry_run: true, - verify: false, - json: true, - help: false, - })? - .name) -} - -fn uninstall_state_dir(path: &Path, dry_run: bool) -> Result { - if !path.exists() { - return Ok(json!({"action": "unchanged", "path": path})); - } - if !dry_run { - fs::remove_dir_all(path).map_err(|error| { - format!( - "failed to remove state directory {}: {error}", - path.display() - ) - })?; - } - Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) -} - -fn uninstall_instruction_blocks( - repo_root: &Path, - dry_run: bool, -) -> Result, String> { - ["AGENTS.md", "CLAUDE.md"] - .into_iter() - .map(|file_name| uninstall_instruction_file(&repo_root.join(file_name), dry_run)) - .collect() -} - -fn uninstall_instruction_file(path: &Path, dry_run: bool) -> Result { - let Ok(existing) = fs::read_to_string(path) else { - return Ok(json!({"action": "unchanged", "path": path})); - }; - let (next, removed) = remove_instruction_text(&existing); - if !removed { - return Ok(json!({"action": "unchanged", "path": path})); - } - if !dry_run { - fs::write(path, next).map_err(|error| { - format!("failed to update instructions {}: {error}", path.display()) - })?; - } - Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path})) -} - -fn uninstall_mcp_clients( - options: &UninstallOptions, - repo_root: &Path, - config_path: &Path, - server_name: &str, -) -> Result, String> { - let clients = if options.mcp_client == "all" { - supported_install_clients() - .into_iter() - .map(str::to_string) - .collect::>() - } else { - vec![options.mcp_client.clone()] - }; - Ok(clients - .into_iter() - .map(|client| { - uninstall_mcp_client(&client, options, repo_root, config_path, server_name) - .unwrap_or_else(|error| { - json!({ - "action": "failed", - "client": client, - "server_name": server_name, - "error": error, - }) - }) - }) - .collect()) -} - -fn uninstall_mcp_client( - client: &str, - options: &UninstallOptions, - repo_root: &Path, - config_path: &Path, - server_name: &str, -) -> Result { - if matches!(client, "copilot-studio" | "microsoft-copilot") { - return Ok(json!({ - "action": "skipped", - "reason": "manual_metadata", - "client": client, - "server_name": server_name, - })); - } - let scope = if client == "claude-project" { - "project" - } else { - "local" - }; - let descriptor = build_mcp_descriptor(&McpInstallOptions { - client: client.to_string(), - scope: scope.to_string(), - name: Some(server_name.to_string()), - config_path: Some(config_path.to_path_buf()), - client_config_path: options.client_config_path.clone(), - repo_root: Some(repo_root.to_path_buf()), - dry_run: true, - verify: false, - json: true, - help: false, - })?; - let scope = install_scope(client, scope); - let path = options - .client_config_path - .clone() - .unwrap_or_else(|| default_client_config_path(client, &scope, &descriptor)); - let existing = fs::read_to_string(&path).ok(); - let removed = remove_client_config(client, &scope, existing.as_deref(), server_name)?; - if removed.action == "removed" && !options.dry_run { - write_text_atomic(&path, &removed.text)?; - } - let action = if removed.action == "removed" && options.dry_run { - "dry_run".to_string() - } else { - removed.action - }; - Ok(json!({ - "action": action, - "client": client, - "scope": scope, - "server_name": server_name, - "path": path, - "previous": removed.previous, - "payload": removed.payload, - })) -} - -pub(crate) fn serialize_uninstall_block(output: &serde_json::Value) -> String { - let mut lines = vec![format!( - "uninstall ok={} server_name={}", - output["ok"].as_bool().unwrap_or(false), - output["server_name"].as_str().unwrap_or_default() - )]; - if let Some(state) = output["state"].as_object() { - lines.push(format!( - "state action={} path={}", - state - .get("action") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - state - .get("path") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - )); - } - for item in output["instructions"].as_array().into_iter().flatten() { - lines.push(format!( - "instructions action={} path={}", - item["action"].as_str().unwrap_or("unknown"), - item["path"].as_str().unwrap_or_default() - )); - } - for item in output["mcp_clients"].as_array().into_iter().flatten() { - lines.push(format!( - "mcp client={} action={} path={}", - item["client"].as_str().unwrap_or("unknown"), - item["action"].as_str().unwrap_or("unknown"), - item["path"].as_str().unwrap_or_default() - )); - } - lines.join("\n") + "\n" -} diff --git a/src/cli/util.rs b/src/cli/util.rs index 519d443..0b9500b 100644 --- a/src/cli/util.rs +++ b/src/cli/util.rs @@ -1,36 +1,8 @@ use std::{ - env, fs, io, + env, fs, path::{Path, PathBuf}, }; -pub(super) fn snapshot_file(path: &Path) -> Result, String> { - if !path.exists() { - return Ok(None); - } - fs::read_to_string(path) - .map(Some) - .map_err(|error| format!("failed to snapshot {}: {error}", path.display())) -} - -pub(super) fn restore_file(path: &Path, previous: Option<&str>) -> Result<(), String> { - match previous { - Some(text) => { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("failed to restore directory {}: {error}", parent.display()) - })?; - } - fs::write(path, text) - .map_err(|error| format!("failed to restore {}: {error}", path.display())) - } - None => match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(format!("failed to remove {}: {error}", path.display())), - }, - } -} - pub(super) fn read_json_file(path: &Path) -> Result { let text = fs::read_to_string(path) .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; @@ -42,10 +14,6 @@ pub(super) fn resolve_repo_root(explicit: Option<&Path>) -> Result) -> Result { - resolve_root(explicit, "source root") -} - fn resolve_root(explicit: Option<&Path>, label: &str) -> Result { if let Some(path) = explicit { return path diff --git a/src/cli/watch/batch.rs b/src/cli/watch/batch.rs deleted file mode 100644 index b8fb2ca..0000000 --- a/src/cli/watch/batch.rs +++ /dev/null @@ -1,76 +0,0 @@ -use super::{ - types::{WatchChangeBatch, WatchMessage}, - WatchEventFilter, -}; -use std::{ - collections::VecDeque, - sync::mpsc::{self, Receiver}, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn collect_watch_batch( - first: WatchMessage, - rx: &Receiver, - queued: &mut VecDeque, - filter: &WatchEventFilter, - debounce: Duration, - max_wait: Duration, -) -> Result, String> { - let mut batch = WatchChangeBatch::default(); - apply_watch_message(first, filter, &mut batch)?; - if batch.paths.is_empty() { - return Ok(None); - } - - let started = Instant::now(); - let mut last_relevant = started; - loop { - let elapsed = started.elapsed(); - if elapsed >= max_wait { - return Ok(Some(batch)); - } - let quiet_elapsed = last_relevant.elapsed(); - if quiet_elapsed >= debounce { - return Ok(Some(batch)); - } - let timeout = debounce - .saturating_sub(quiet_elapsed) - .min(max_wait.saturating_sub(elapsed)); - let message = match queued.pop_front() { - Some(message) => Ok(message), - None => rx.recv_timeout(timeout), - }; - match message { - Ok(message) => { - let before = batch.paths.len(); - let before_events = batch.event_count; - apply_watch_message(message, filter, &mut batch)?; - if batch.paths.len() != before || batch.event_count != before_events { - last_relevant = Instant::now(); - } - } - Err(mpsc::RecvTimeoutError::Timeout) => return Ok(Some(batch)), - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err("filesystem watcher stopped".to_string()) - } - } - } -} - -pub(in crate::cli) fn apply_watch_message( - message: WatchMessage, - filter: &WatchEventFilter, - batch: &mut WatchChangeBatch, -) -> Result<(), String> { - match message { - WatchMessage::Event(event) => { - let paths = filter.relevant_paths(&event); - if !paths.is_empty() { - batch.event_count += 1; - batch.paths.extend(paths); - } - Ok(()) - } - WatchMessage::Error(error) => Err(format!("filesystem watcher error: {error}")), - } -} diff --git a/src/cli/watch/command.rs b/src/cli/watch/command.rs index f0d97b0..431fa39 100644 --- a/src/cli/watch/command.rs +++ b/src/cli/watch/command.rs @@ -1,12 +1,16 @@ use super::{ - filter::WatchEventFilter, - native::{probe_native_watcher, run_native_watch, start_native_watcher}, - options::{WatchBackend, WatchLoopConfig, WatchOptions}, + options::{WatchBackend, WatchOptions}, output::{write_watch_event, write_watch_status}, - poll::run_poll_watch, }; -use crate::cli::{format::watch_help, util::resolve_source_root, watch::execute_refresh_operation}; -use std::{collections::VecDeque, io::Write}; +use crate::{ + api::refresh::{ + run_refresh_watch, watch_error_reason, RefreshBackend, RefreshLoopConfig, + RefreshWatchConfig, RefreshWatchObserver, + }, + cli::format::watch_help, + protocol::NativeSyntaxMaterializationResponse, +}; +use std::{io::Write, time::Duration}; pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Result<(), String> { let options = WatchOptions::parse(args)?; @@ -14,60 +18,69 @@ pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Re writeln!(stdout, "{}", watch_help()).map_err(|error| error.to_string())?; return Ok(()); } - let backend = options.backend; - let loop_config = WatchLoopConfig { - poll_ms: options.poll_ms, - debounce_ms: options.debounce_ms, - max_iterations: options.max_iterations, + let backend = match options.backend { + WatchBackend::Auto => RefreshBackend::Auto, + WatchBackend::Native => RefreshBackend::Native, + WatchBackend::Poll => RefreshBackend::Poll, }; - let once = options.once; - let mut materialize_options = options.materialize; - let source_root = resolve_source_root(materialize_options.source_root.as_deref())?; - materialize_options.source_root = Some(source_root.clone()); - let filter = WatchEventFilter::from_options(&source_root, &materialize_options)?; - if once { - let response = execute_refresh_operation(&materialize_options, Vec::new())?; - write_watch_event(stdout, "refreshed", None, 0, 0, &response)?; - return Ok(()); - } - match backend { - WatchBackend::Poll => run_poll_watch(stdout, loop_config, &materialize_options, &filter), - WatchBackend::Native => { - let (watcher, rx) = start_native_watcher(&source_root)?; - run_native_watch( - stdout, - loop_config, - &materialize_options, - &filter, - watcher, - rx, - VecDeque::new(), - ) - } - WatchBackend::Auto => match start_native_watcher(&source_root) { - Ok((watcher, rx)) => { - let probe = probe_native_watcher(&source_root, &filter, &rx)?; - if probe.delivered { - run_native_watch( - stdout, - loop_config, - &materialize_options, - &filter, - watcher, - rx, - probe.queued, - ) - } else { - drop(watcher); - write_watch_status(stdout, "fallback", "poll", probe.reason.as_deref())?; - run_poll_watch(stdout, loop_config, &materialize_options, &filter) - } - } - Err(error) => { - write_watch_status(stdout, "fallback", "poll", Some("watcher_start_failed"))?; - let _ = error; - run_poll_watch(stdout, loop_config, &materialize_options, &filter) - } + let config = RefreshWatchConfig { + backend, + loop_config: RefreshLoopConfig { + poll_interval: Duration::from_millis(options.poll_ms), + debounce: Duration::from_millis(options.debounce_ms), + max_wait: Duration::from_secs(5).max(Duration::from_millis( + options.debounce_ms.saturating_mul(10), + )), + max_iterations: options.max_iterations, }, + once: options.once, + }; + run_refresh_watch( + &options.materialize, + config, + &mut CliRefreshObserver { stdout }, + ) +} + +struct CliRefreshObserver<'a, W> { + stdout: &'a mut W, +} + +impl RefreshWatchObserver for CliRefreshObserver<'_, W> { + fn on_success( + &mut self, + backend: Option<&str>, + response: &NativeSyntaxMaterializationResponse, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String> { + write_watch_event( + self.stdout, + "refreshed", + backend, + event_count, + changed_paths, + response, + ) + } + + fn on_error( + &mut self, + backend: &str, + error: &str, + retrying: bool, + _event_count: usize, + _changed_paths: usize, + ) -> Result<(), String> { + write_watch_status( + self.stdout, + if retrying { "retrying" } else { "error" }, + backend, + Some(&watch_error_reason(error)), + ) + } + + fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String> { + write_watch_status(self.stdout, "fallback", backend, Some(reason)) } } diff --git a/src/cli/watch/filter.rs b/src/cli/watch/filter.rs deleted file mode 100644 index ba95c67..0000000 --- a/src/cli/watch/filter.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::helpers::watch_matches_any_pattern; -use crate::cli::{ - materialization::{ - default_excluded_parts, read_codebase_graph_ignore, read_materialization_config_rules, - MaterializeOptions, - }, - setup::GraphStatePaths, -}; -use notify::{ - event::{AccessKind, AccessMode}, - Event, EventKind, -}; -use std::{ - collections::BTreeSet, - env, - path::{Path, PathBuf}, -}; - -#[derive(Debug)] -pub(in crate::cli) struct WatchEventFilter { - pub(in crate::cli) source_root: PathBuf, - pub(in crate::cli) current_dir: PathBuf, - pub(in crate::cli) excluded_parts: BTreeSet, - pub(in crate::cli) include_patterns: Vec, - pub(in crate::cli) exclude_patterns: Vec, - pub(in crate::cli) ignore_patterns: Vec, -} - -impl WatchEventFilter { - pub(in crate::cli) fn from_options( - source_root: &Path, - options: &MaterializeOptions, - ) -> Result { - let paths = GraphStatePaths::derive(source_root); - let config_rules = read_materialization_config_rules(&paths.config_path)?; - let mut include_patterns = config_rules.include_patterns; - include_patterns.extend(options.include_patterns.clone()); - let mut exclude_patterns = config_rules.exclude_patterns; - exclude_patterns.extend(options.exclude_patterns.clone()); - Ok(Self { - source_root: source_root.to_path_buf(), - current_dir: env::current_dir().unwrap_or_else(|_| source_root.to_path_buf()), - excluded_parts: default_excluded_parts().into_iter().collect(), - include_patterns, - exclude_patterns, - ignore_patterns: read_codebase_graph_ignore(source_root)?, - }) - } - - pub(in crate::cli) fn relevant_paths(&self, event: &Event) -> BTreeSet { - if !watch_event_refreshes(event) { - return BTreeSet::new(); - } - event - .paths - .iter() - .filter_map(|path| self.relevant_path(path)) - .collect() - } - - pub(in crate::cli) fn relevant_path(&self, path: &Path) -> Option { - let relative = self.relative_event_path(path)?; - if relative.as_os_str().is_empty() { - return None; - } - if relative.components().any(|component| { - self.excluded_parts - .contains(component.as_os_str().to_string_lossy().as_ref()) - }) { - return None; - } - let relative = relative.to_string_lossy().replace('\\', "/"); - if self.ignored_by_patterns(&relative) { - None - } else { - Some(relative) - } - } - - pub(in crate::cli) fn relative_event_path(&self, path: &Path) -> Option { - if let Ok(relative) = path.strip_prefix(&self.source_root) { - return Some(relative.to_path_buf()); - } - if path.is_relative() { - let absolute = self.current_dir.join(path); - if let Ok(relative) = absolute.strip_prefix(&self.source_root) { - return Some(relative.to_path_buf()); - } - #[cfg(windows)] - { - let absolute = normalize_windows_verbatim_path(&absolute); - let source_root = normalize_windows_verbatim_path(&self.source_root); - if let Ok(relative) = absolute.strip_prefix(source_root) { - return Some(relative.to_path_buf()); - } - } - return Some(path.to_path_buf()); - } - None - } - - pub(in crate::cli) fn ignored_by_patterns(&self, relative_path: &str) -> bool { - if !self.include_patterns.is_empty() - && !watch_matches_any_pattern(relative_path, &self.include_patterns) - { - return true; - } - watch_matches_any_pattern(relative_path, &self.ignore_patterns) - || watch_matches_any_pattern(relative_path, &self.exclude_patterns) - } -} - -#[cfg(windows)] -fn normalize_windows_verbatim_path(path: &Path) -> PathBuf { - let normalized = path.to_string_lossy().replace('\\', "/"); - if let Some(stripped) = normalized.strip_prefix("//?/UNC/") { - PathBuf::from(format!("//{stripped}")) - } else if let Some(stripped) = normalized.strip_prefix("//?/") { - PathBuf::from(stripped) - } else { - PathBuf::from(normalized) - } -} - -pub(in crate::cli) fn watch_event_refreshes(event: &Event) -> bool { - matches!( - event.kind, - EventKind::Any - | EventKind::Create(_) - | EventKind::Modify(_) - | EventKind::Remove(_) - | EventKind::Other - | EventKind::Access(AccessKind::Close(AccessMode::Write)) - ) -} diff --git a/src/cli/watch/helpers.rs b/src/cli/watch/helpers.rs deleted file mode 100644 index 67ef316..0000000 --- a/src/cli/watch/helpers.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::time::Duration; - -pub(in crate::cli) fn watch_max_wait(debounce_ms: u64) -> Duration { - Duration::from_secs(5).max(Duration::from_millis(debounce_ms.saturating_mul(10))) -} - -pub(in crate::cli) fn watch_matches_any_pattern(path: &str, patterns: &[String]) -> bool { - patterns - .iter() - .map(|pattern| pattern.trim()) - .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('#')) - .any(|pattern| watch_glob_matches(path, pattern)) -} - -pub(in crate::cli) fn watch_glob_matches(path: &str, pattern: &str) -> bool { - let pattern = watch_normalize_pattern(pattern); - if pattern.ends_with('/') { - return path.starts_with(pattern.trim_end_matches('/')); - } - if !pattern.contains('/') - && watch_wildcard_match(path.rsplit('/').next().unwrap_or(path), &pattern) - { - return true; - } - watch_wildcard_match(path, &pattern) -} - -pub(in crate::cli) fn watch_normalize_pattern(pattern: &str) -> String { - pattern - .trim() - .trim_start_matches("./") - .replace('\\', "/") - .to_string() -} - -pub(in crate::cli) fn watch_wildcard_match(text: &str, pattern: &str) -> bool { - let (mut text_index, mut pattern_index) = (0_usize, 0_usize); - let mut star_index = None; - let mut match_index = 0_usize; - let text = text.as_bytes(); - let pattern = pattern.as_bytes(); - while text_index < text.len() { - if pattern_index < pattern.len() - && (pattern[pattern_index] == b'?' || pattern[pattern_index] == text[text_index]) - { - text_index += 1; - pattern_index += 1; - } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - star_index = Some(pattern_index); - match_index = text_index; - pattern_index += 1; - } else if let Some(star) = star_index { - pattern_index = star + 1; - match_index += 1; - text_index = match_index; - } else { - return false; - } - } - while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { - pattern_index += 1; - } - pattern_index == pattern.len() -} diff --git a/src/cli/watch/mod.rs b/src/cli/watch/mod.rs index 91cb3f1..c594532 100644 --- a/src/cli/watch/mod.rs +++ b/src/cli/watch/mod.rs @@ -1,30 +1,14 @@ -mod batch; mod command; -mod filter; -mod helpers; -mod native; mod options; mod output; -mod poll; -mod refresh; -mod snapshot; -mod types; -pub(in crate::cli) use batch::collect_watch_batch; pub(in crate::cli) use command::run_watch; -pub(in crate::cli) use filter::WatchEventFilter; -pub(in crate::cli) use native::{probe_native_watcher, start_native_watcher}; -pub(in crate::cli) use options::{SetupOptions, WatchLoopConfig}; -pub(in crate::cli) use poll::collect_poll_batch; -pub(in crate::cli) use refresh::execute_refresh_operation; -pub(in crate::cli) use snapshot::{scan_source_snapshots, watch_file_snapshot}; -pub(in crate::cli) use types::WatchMessage; +pub(in crate::cli) use options::SetupOptions; #[cfg(test)] -pub(super) use batch::apply_watch_message; +pub(super) use crate::api::refresh::{ + apply_watch_message, collect_poll_batch, collect_watch_batch, probe_native_watcher, + watch_file_snapshot, watch_snapshot_diff, WatchChangeBatch, WatchEventFilter, WatchMessage, +}; #[cfg(test)] pub(super) use options::{WatchBackend, WatchOptions}; -#[cfg(test)] -pub(super) use snapshot::watch_snapshot_diff; -#[cfg(test)] -pub(super) use types::WatchChangeBatch; diff --git a/src/cli/watch/native.rs b/src/cli/watch/native.rs deleted file mode 100644 index 3db26a0..0000000 --- a/src/cli/watch/native.rs +++ /dev/null @@ -1,176 +0,0 @@ -use super::{ - batch::collect_watch_batch, - filter::WatchEventFilter, - helpers::watch_max_wait, - refresh::refresh_watch_batch, - types::{WatchMessage, WatchProbeOutcome}, - WatchLoopConfig, -}; -use crate::cli::materialization::MaterializeOptions; -use notify::{Event, RecursiveMode, Watcher}; -use std::{ - collections::VecDeque, - env, fs, - io::Write, - path::Path, - sync::mpsc::{self, Receiver}, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn start_native_watcher( - source_root: &Path, -) -> Result<(notify::RecommendedWatcher, Receiver), String> { - let (tx, rx) = mpsc::channel(); - let mut watcher = notify::recommended_watcher(move |result: notify::Result| { - let message = match result { - Ok(event) => WatchMessage::Event(event), - Err(error) => WatchMessage::Error(error.to_string()), - }; - let _ = tx.send(message); - }) - .map_err(|error| format!("failed to start filesystem watcher: {error}"))?; - watcher - .watch(source_root, RecursiveMode::Recursive) - .map_err(|error| format!("failed to watch {}: {error}", source_root.display()))?; - Ok((watcher, rx)) -} - -pub(in crate::cli) fn run_native_watch( - stdout: &mut W, - loop_config: WatchLoopConfig, - materialize_options: &MaterializeOptions, - filter: &WatchEventFilter, - _watcher: notify::RecommendedWatcher, - rx: Receiver, - mut queued: VecDeque, -) -> Result<(), String> { - let mut refreshes = 0_usize; - loop { - let first = match queued.pop_front() { - Some(message) => message, - None => rx - .recv() - .map_err(|error| format!("filesystem watcher stopped: {error}"))?, - }; - let batch = match collect_watch_batch( - first, - &rx, - &mut queued, - filter, - Duration::from_millis(loop_config.debounce_ms), - watch_max_wait(loop_config.debounce_ms), - )? { - Some(batch) => batch, - None => continue, - }; - let refreshed = refresh_watch_batch( - stdout, - "native", - materialize_options, - batch.event_count, - &batch.paths, - )?; - if !refreshed { - continue; - } - refreshes += 1; - if loop_config - .max_iterations - .is_some_and(|max| refreshes >= max) - { - return Ok(()); - } - } -} - -pub(in crate::cli) fn probe_native_watcher( - source_root: &Path, - filter: &WatchEventFilter, - rx: &Receiver, -) -> Result { - let timeout = watch_probe_timeout(); - let probe_dir = source_root.join(".codebaseGraph").join("watch-probe"); - let probe_path = probe_dir.join(format!( - "probe-{}-{}.tmp", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) - )); - if !watch_probe_skip_write() { - fs::create_dir_all(&probe_dir) - .map_err(|error| format!("failed to create watch probe directory: {error}"))?; - fs::write(&probe_path, b"probe") - .map_err(|error| format!("failed to write watch probe: {error}"))?; - } - - let started = Instant::now(); - let mut outcome = WatchProbeOutcome::default(); - while started.elapsed() < timeout { - let remaining = timeout.saturating_sub(started.elapsed()); - match rx.recv_timeout(remaining) { - Ok(WatchMessage::Event(event)) => { - outcome.delivered = true; - if !watch_event_is_under_dir(&event, &probe_dir, source_root, &filter.current_dir) { - outcome.queued.push_back(WatchMessage::Event(event)); - } - } - Ok(WatchMessage::Error(error)) => { - outcome.reason = Some("watcher_error".to_string()); - outcome.queued.push_back(WatchMessage::Error(error)); - break; - } - Err(mpsc::RecvTimeoutError::Timeout) => break, - Err(mpsc::RecvTimeoutError::Disconnected) => { - return Err("filesystem watcher stopped during health probe".to_string()) - } - } - } - let _ = fs::remove_file(&probe_path); - if !outcome.delivered && outcome.reason.is_none() { - outcome.reason = Some("probe_timeout".to_string()); - } - Ok(outcome) -} - -pub(in crate::cli) fn watch_probe_timeout() -> Duration { - env::var("CODEBASE_GRAPH_WATCH_PROBE_TIMEOUT_MS") - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or_else(|| Duration::from_millis(750)) -} - -pub(in crate::cli) fn watch_probe_skip_write() -> bool { - env::var("CODEBASE_GRAPH_WATCH_PROBE_SKIP_WRITE").is_ok_and(|value| value == "1") -} - -pub(in crate::cli) fn watch_event_is_under_dir( - event: &Event, - directory: &Path, - source_root: &Path, - current_dir: &Path, -) -> bool { - !event.paths.is_empty() - && event - .paths - .iter() - .all(|path| watch_path_is_under_dir(path, directory, source_root, current_dir)) -} - -pub(in crate::cli) fn watch_path_is_under_dir( - path: &Path, - directory: &Path, - source_root: &Path, - current_dir: &Path, -) -> bool { - if path.starts_with(directory) { - return true; - } - if path.is_relative() { - return current_dir.join(path).starts_with(directory) - || source_root.join(path).starts_with(directory); - } - false -} diff --git a/src/cli/watch/options.rs b/src/cli/watch/options.rs index ed285b7..4b1a602 100644 --- a/src/cli/watch/options.rs +++ b/src/cli/watch/options.rs @@ -21,13 +21,6 @@ pub(in crate::cli) enum WatchBackend { Poll, } -#[derive(Clone, Copy, Debug)] -pub(in crate::cli) struct WatchLoopConfig { - pub(in crate::cli) poll_ms: u64, - pub(in crate::cli) debounce_ms: u64, - pub(in crate::cli) max_iterations: Option, -} - impl WatchBackend { pub(in crate::cli) fn parse(value: &str) -> Result { match value { @@ -138,10 +131,10 @@ impl SetupOptions { ) -> Result { let mut options = Self { repo_root: None, - mode: "changed".to_string(), + mode: String::new(), include_fts: true, semantic_enrichment: true, - semantic_provider_mode: "local_only".to_string(), + semantic_provider_mode: String::new(), mcp_client: "codex".to_string(), mcp_config_path: None, skip_mcp_config: false, @@ -166,10 +159,7 @@ impl SetupOptions { "--mode" => { let value = args .get(index + 1) - .ok_or_else(|| "--mode requires full or changed".to_string())?; - if value != "full" && value != "changed" { - return Err("--mode must be full or changed".to_string()); - } + .ok_or_else(|| "--mode requires a value".to_string())?; options.mode = value.clone(); index += 2; } @@ -223,12 +213,9 @@ impl SetupOptions { index += 1; } "--semantic-provider-mode" => { - let value = args.get(index + 1).ok_or_else(|| { - "--semantic-provider-mode requires local_only".to_string() - })?; - if value != "local_only" { - return Err("--semantic-provider-mode must be local_only".to_string()); - } + let value = args + .get(index + 1) + .ok_or_else(|| "--semantic-provider-mode requires a value".to_string())?; options.semantic_provider_mode = value.clone(); index += 2; } diff --git a/src/cli/watch/poll.rs b/src/cli/watch/poll.rs deleted file mode 100644 index e37b3c9..0000000 --- a/src/cli/watch/poll.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::{ - helpers::watch_max_wait, - refresh::refresh_watch_batch, - snapshot::{watch_file_snapshot, watch_snapshot_diff}, - types::WatchChangeBatch, - types::WatchFileSnapshot, - WatchEventFilter, WatchLoopConfig, -}; -use crate::cli::materialization::MaterializeOptions; -use std::{ - io::Write, - time::{Duration, Instant}, -}; - -pub(in crate::cli) fn run_poll_watch( - stdout: &mut W, - loop_config: WatchLoopConfig, - materialize_options: &MaterializeOptions, - filter: &WatchEventFilter, -) -> Result<(), String> { - let mut previous_snapshot = watch_file_snapshot(filter)?; - let mut refreshes = 0_usize; - loop { - let batch = collect_poll_batch( - filter, - &mut previous_snapshot, - Duration::from_millis(loop_config.poll_ms), - Duration::from_millis(loop_config.debounce_ms), - watch_max_wait(loop_config.debounce_ms), - )?; - let refreshed = refresh_watch_batch( - stdout, - "poll", - materialize_options, - batch.event_count, - &batch.paths, - )?; - if !refreshed { - continue; - } - refreshes += 1; - if loop_config - .max_iterations - .is_some_and(|max| refreshes >= max) - { - return Ok(()); - } - } -} - -pub(in crate::cli) fn collect_poll_batch( - filter: &WatchEventFilter, - previous_snapshot: &mut WatchFileSnapshot, - poll_interval: Duration, - debounce: Duration, - max_wait: Duration, -) -> Result { - loop { - std::thread::sleep(poll_interval); - let current_snapshot = watch_file_snapshot(filter)?; - let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); - *previous_snapshot = current_snapshot; - if changed_paths.is_empty() { - continue; - } - - let started = Instant::now(); - let mut last_relevant = started; - let mut batch = WatchChangeBatch { - paths: changed_paths, - event_count: 1, - }; - loop { - let elapsed = started.elapsed(); - if elapsed >= max_wait { - return Ok(batch); - } - let quiet_elapsed = last_relevant.elapsed(); - if quiet_elapsed >= debounce { - return Ok(batch); - } - let timeout = poll_interval - .min(debounce.saturating_sub(quiet_elapsed)) - .min(max_wait.saturating_sub(elapsed)); - std::thread::sleep(timeout); - let current_snapshot = watch_file_snapshot(filter)?; - let changed_paths = watch_snapshot_diff(previous_snapshot, ¤t_snapshot); - *previous_snapshot = current_snapshot; - if !changed_paths.is_empty() { - batch.paths.extend(changed_paths); - batch.event_count += 1; - last_relevant = Instant::now(); - } - } - } -} diff --git a/src/cli/watch/refresh.rs b/src/cli/watch/refresh.rs deleted file mode 100644 index 42e1fe7..0000000 --- a/src/cli/watch/refresh.rs +++ /dev/null @@ -1,183 +0,0 @@ -use super::output::{write_watch_event, write_watch_status}; -use crate::{ - api::{ - contracts::{OperationRequest, OutputFormat, RefreshRequest, RepoSelector}, - lifecycle::is_retryable_refresh_failure, - CodebaseGraphApi, - }, - cli::materialization::MaterializeOptions, - protocol::NativeSyntaxMaterializationResponse, -}; -use std::{collections::BTreeSet, io::Write, thread, time::Duration}; - -const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(100); -const MAX_RETRY_DELAY: Duration = Duration::from_millis(1_000); - -pub(in crate::cli) fn refresh_watch_batch( - stdout: &mut W, - backend: &str, - materialize_options: &MaterializeOptions, - event_count: usize, - paths: &BTreeSet, -) -> Result { - refresh_watch_batch_with(stdout, backend, event_count, paths, |candidate_paths| { - execute_refresh_operation(materialize_options, candidate_paths) - }) -} - -pub(in crate::cli) fn execute_refresh_operation( - options: &MaterializeOptions, - paths: Vec, -) -> Result { - let response = CodebaseGraphApi::new() - .execute_operation(&OperationRequest::Refresh(RefreshRequest { - repo: RepoSelector { - repo_root: options.source_root.clone(), - config_path: None, - db_path: options.db.clone(), - manifest_path: options.manifest.clone(), - }, - paths, - mode: options.mode.clone(), - include_fts: options.include_fts, - semantic_enrichment: options.semantic_enrichment, - semantic_provider_mode: options.semantic_provider_mode.clone(), - parallel: options.parallel, - progress: options.progress, - output_format: OutputFormat::Typed, - })) - .map_err(|error| error.message)?; - serde_json::from_value(response.payload) - .map_err(|error| format!("invalid refresh response: {error}")) -} - -fn refresh_watch_batch_with( - stdout: &mut W, - backend: &str, - event_count: usize, - paths: &BTreeSet, - mut refresh: impl FnMut(Vec) -> Result, -) -> Result { - let mut delay = INITIAL_RETRY_DELAY; - loop { - match refresh(paths.iter().cloned().collect()) { - Ok(response) => { - write_watch_event( - stdout, - "refreshed", - Some(backend), - event_count, - paths.len(), - &response, - )?; - return Ok(true); - } - Err(error) => { - let transient = is_retryable_refresh_failure(&error); - write_watch_status( - stdout, - if transient { "retrying" } else { "error" }, - backend, - Some(&watch_error_reason(&error)), - )?; - if !transient { - return Ok(false); - } - thread::sleep(delay); - delay = delay.saturating_mul(2).min(MAX_RETRY_DELAY); - } - } - } -} - -fn watch_error_reason(error: &str) -> String { - let reason = error.lines().next().unwrap_or("refresh_failed").trim(); - if reason.is_empty() { - "refresh_failed".to_string() - } else { - reason - .split_whitespace() - .collect::>() - .join("_") - .chars() - .take(160) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::protocol::ManifestDiff; - use std::{ - collections::BTreeMap, - sync::atomic::{AtomicUsize, Ordering}, - }; - - fn skipped_response() -> NativeSyntaxMaterializationResponse { - NativeSyntaxMaterializationResponse::skipped( - BTreeMap::new(), - ManifestDiff { - added: Vec::new(), - modified: Vec::new(), - unchanged: Vec::new(), - deleted: Vec::new(), - force_rebuild: false, - }, - Vec::new(), - Vec::new(), - BTreeMap::new(), - ) - } - - #[test] - fn watch_error_reason_compacts_multiline_errors() { - assert_eq!( - watch_error_reason("IO exception: Could not set lock\nSee docs"), - "IO_exception:_Could_not_set_lock" - ); - } - - #[test] - fn watch_refresh_retries_transient_errors_before_success() { - let attempts = AtomicUsize::new(0); - let mut output = Vec::new(); - let refreshed = refresh_watch_batch_with( - &mut output, - "poll", - 2, - &BTreeSet::from(["src/lib.rs".to_string()]), - |_| { - if attempts.fetch_add(1, Ordering::SeqCst) == 0 { - Err("IO exception: Could not set lock on file".to_string()) - } else { - Ok(skipped_response()) - } - }, - ) - .unwrap(); - let text = String::from_utf8(output).unwrap(); - - assert!(refreshed); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - assert!(text.contains("watch event=retrying backend=poll")); - assert!(text.contains("watch event=refreshed backend=poll")); - } - - #[test] - fn watch_refresh_reports_non_transient_errors_without_success() { - let mut output = Vec::new(); - let refreshed = refresh_watch_batch_with( - &mut output, - "native", - 1, - &BTreeSet::from(["src/lib.rs".to_string()]), - |_| Err("parser exploded".to_string()), - ) - .unwrap(); - let text = String::from_utf8(output).unwrap(); - - assert!(!refreshed); - assert!(text.contains("watch event=error backend=native reason=parser_exploded")); - } -} diff --git a/src/cli/watch/snapshot.rs b/src/cli/watch/snapshot.rs deleted file mode 100644 index 02dfe8c..0000000 --- a/src/cli/watch/snapshot.rs +++ /dev/null @@ -1,129 +0,0 @@ -use super::{ - types::{WatchFileSnapshot, WatchFileState}, - WatchEventFilter, -}; -use crate::cli::materialization::default_excluded_parts; -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, - path::Path, -}; - -pub(in crate::cli) fn watch_file_snapshot( - filter: &WatchEventFilter, -) -> Result { - let mut snapshot = BTreeMap::new(); - watch_file_snapshot_inner(filter, &filter.source_root, &mut snapshot)?; - Ok(snapshot) -} - -pub(in crate::cli) fn watch_file_snapshot_inner( - filter: &WatchEventFilter, - directory: &Path, - snapshot: &mut WatchFileSnapshot, -) -> Result<(), String> { - let entries = fs::read_dir(directory) - .map_err(|error| format!("failed to read directory {}: {error}", directory.display()))?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(""); - if filter.excluded_parts.contains(name) { - continue; - } - watch_file_snapshot_inner(filter, &path, snapshot)?; - } else if path.is_file() { - let Some(relative_path) = filter.relevant_path(&path) else { - continue; - }; - let metadata = match fs::metadata(&path) { - Ok(metadata) => metadata, - Err(_) => continue, - }; - let modified_nanos = metadata - .modified() - .ok() - .and_then(|modified| { - modified - .duration_since(std::time::UNIX_EPOCH) - .ok() - .map(|duration| duration.as_nanos()) - }) - .unwrap_or(0); - snapshot.insert( - relative_path, - WatchFileState { - modified_nanos, - len: metadata.len(), - }, - ); - } - } - Ok(()) -} - -pub(in crate::cli) fn watch_snapshot_diff( - previous: &WatchFileSnapshot, - current: &WatchFileSnapshot, -) -> BTreeSet { - let mut changed_paths = BTreeSet::new(); - for (path, state) in current { - if previous.get(path) != Some(state) { - changed_paths.insert(path.clone()); - } - } - for path in previous.keys() { - if !current.contains_key(path) { - changed_paths.insert(path.clone()); - } - } - changed_paths -} - -pub(in crate::cli) fn scan_source_snapshots(root: &Path) -> Vec<(String, Option<&'static str>)> { - let mut snapshots = Vec::new(); - scan_source_snapshots_inner(root, root, &mut snapshots); - snapshots.sort_by(|left, right| left.0.cmp(&right.0)); - snapshots -} - -pub(in crate::cli) fn scan_source_snapshots_inner( - root: &Path, - directory: &Path, - snapshots: &mut Vec<(String, Option<&'static str>)>, -) { - let Ok(entries) = fs::read_dir(directory) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(""); - if default_excluded_parts().iter().any(|part| part == name) { - continue; - } - if path.is_dir() { - scan_source_snapshots_inner(root, &path, snapshots); - } else if path.is_file() { - let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy(); - snapshots.push((relative.to_string(), language_for_path(&path))); - } - } -} - -pub(in crate::cli) fn language_for_path(path: &Path) -> Option<&'static str> { - match path.extension().and_then(|value| value.to_str()) { - Some("py") => Some("python"), - Some("rs") => Some("rust"), - Some("go") => Some("go"), - Some("c") | Some("h") => Some("c"), - Some("cc") | Some("cpp") | Some("cxx") | Some("hpp") | Some("hh") => Some("cpp"), - Some("f") | Some("f90") | Some("f95") | Some("for") => Some("fortran"), - _ => None, - } -} diff --git a/src/cli/watch/types.rs b/src/cli/watch/types.rs deleted file mode 100644 index e880d72..0000000 --- a/src/cli/watch/types.rs +++ /dev/null @@ -1,29 +0,0 @@ -use notify::Event; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; - -#[derive(Debug)] -pub(in crate::cli) enum WatchMessage { - Event(Event), - Error(String), -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub(in crate::cli) struct WatchChangeBatch { - pub(in crate::cli) paths: BTreeSet, - pub(in crate::cli) event_count: usize, -} - -#[derive(Debug, Default)] -pub(in crate::cli) struct WatchProbeOutcome { - pub(in crate::cli) delivered: bool, - pub(in crate::cli) queued: VecDeque, - pub(in crate::cli) reason: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::cli) struct WatchFileState { - pub(in crate::cli) modified_nanos: u128, - pub(in crate::cli) len: u64, -} - -pub(in crate::cli) type WatchFileSnapshot = BTreeMap; From aa696e9af295149d2c5f87a30eb446f454888f15 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 11:34:44 +0930 Subject: [PATCH 04/19] docs(okf): define the path to a production wiki Record the verified gap analysis between OKF v0.1, the current Markdown materializer, and the transport-neutral public API. Recommend an adjacent OKF projection so wiki semantics can evolve without destabilizing code graph contracts. Constraint: Preserve the existing CodebaseGraphApi and graph schema contracts Rejected: Reuse DocumentationSource and DocumentationChunk as the canonical OKF model | those nodes omit frontmatter, reserved files, links, and concept identity Confidence: high Scope-risk: narrow Directive: Keep OKF page semantics separate from graph-search response contracts until the normalized model stabilizes Tested: Source-reference review and git diff --cached --check Not-tested: No production code changed; runtime tests not applicable --- .omx/plans/okf-wiki-implementation-study.md | 594 ++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 .omx/plans/okf-wiki-implementation-study.md diff --git a/.omx/plans/okf-wiki-implementation-study.md b/.omx/plans/okf-wiki-implementation-study.md new file mode 100644 index 0000000..537d6f8 --- /dev/null +++ b/.omx/plans/okf-wiki-implementation-study.md @@ -0,0 +1,594 @@ +# OKF Wiki Implementation Study + +## Summary + +This repository already exposes a stable, transport-neutral public API for graph +health, search, context, read-only query, materialization, and lifecycle +operations through `CodebaseGraphApi::execute_operation` +(`src/api/mod.rs:1-26`, `src/api/facade.rs:16-39`, `src/api/core.rs:57-205`). +That API is a strong substrate for repository and code-graph retrieval, but it +is not yet an OKF consumer: current Markdown ingestion only produces +`DocumentationSource` and `DocumentationChunk` nodes from whole documents and +heading sections, with no YAML frontmatter model, no OKF reserved-file +semantics, and no markdown-link or citation extraction +(`src/profiles.rs:69-82`, `src/parser/markdown.rs:8-173`, +`src/syntax_materializer/relations.rs:103-115`). + +The recommended direction is to build an adjacent OKF-specific compiler and wiki +service that uses the existing `codebaseGraph` public API for repository/code +graph features, while keeping OKF parsing, conformance, bundle navigation, and +wiki page projection in a separate bounded module or package. Pushing OKF +semantics directly into the existing documentation-node model would create +avoidable coupling with a code-search-oriented graph schema and search surface. + +## Confirmed Current State + +### 1. Public API boundary and exposure + +Confirmed facts: + +- The repository defines a stable API entry point intended for embedded + consumers, and the same facade is used by CLI and MCP adapters + (`src/api/mod.rs:1-26`). +- The public facade is `CodebaseGraphApi`, which executes an + `OperationRequest` via `execute_operation` + (`src/api/facade.rs:16-39`). +- Public request contracts currently cover `Health`, `Search`, `Context`, + `Query`, `Materialize`, `Plan`, `Catalog`, `Setup`, `Reinstall`, + `Uninstall`, and `Refresh`; there is no OKF- or wiki-specific request variant + today (`src/api/contracts.rs:18-157`, `src/api/contracts.rs:159-180`). +- The authoritative operation registry exposes only twelve operations, and only + `health`, `search`, `context`, `query`, `schema`, `query-helpers`, and + `architecture-queries` are MCP-exposed (`src/api/core.rs:57-163`). +- Request normalization, validation, runtime resolution, dispatch, and response + presentation are centralized in `ApiCore::execute` + (`src/api/core.rs:172-205`). +- Boundary tests enforce that `src/api/**` does not import transport adapters + and that CLI/MCP modules do not bypass the public facade + (`src/api/boundary_tests.rs:1-88`). + +Implication: + +- There is already a viable public API boundary to reuse, but any OKF wiki + capability would need either new public contracts or a separate service that + composes this API rather than trying to tunnel through existing graph-search + requests. + +### 2. Current Markdown ingestion model + +Confirmed facts: + +- Markdown and MDX are recognized as one language profile with only two capture + mappings: `doc.source -> DocumentationSource` and + `doc.chunk -> DocumentationChunk` (`src/profiles.rs:69-82`). +- The markdown parser emits one `DocumentationSource` node for the whole file + and additional `DocumentationChunk` nodes for heading-delimited sections + (`src/parser/markdown.rs:8-59`). +- The parser discovers sections only by markdown headings. It does not parse + YAML frontmatter, markdown links, citations, reserved filenames, or any OKF + structure (`src/parser/markdown.rs:119-173`). +- Node labels default to named fields when present, otherwise to the raw text + content of the node (`src/syntax_materializer/capture.rs:16-49`). +- For documentation nodes, the materializer stores the full captured text as the + `summary` field (`src/syntax_materializer/builder/nodes.rs:43-99`). +- Documentation nodes emit only `Documents` and `EvidencedBy` relations to + owners and parser evidence (`src/syntax_materializer/builder/semantic.rs:499-515`). +- The default relation allowlist permits `Documents` from documentation nodes to + `Repository`, `File`, `Module`, or declaration nodes, not to OKF concept/link + targets (`src/syntax_materializer/relations.rs:55-117`). +- The graph schema defines `DocumentationSource` and `DocumentationChunk` with + generic graph fields such as `label`, `path`, `summary`, and `metadata`; it + does not define OKF-specific fields such as `type`, `resource`, `tags`, + `timestamp`, `bundle_id`, or `concept_id` + (`assets/graph_schema.json:3580-3698`). +- The docs search index is `idx_docs` over `label`, `path`, and `summary` for + those two node types (`assets/graph_schema.json:6551-6620`). + +Implication: + +- The current graph can retrieve documentation text, but only as generic + document and section nodes. It does not preserve the semantic units that OKF + requires. + +### 3. Search, context, and query behavior + +Confirmed facts: + +- `execute_graph_search` runs across all configured search indexes and returns + ranked node payloads with optional context (`src/api/graph_read.rs:35-105`). +- Context traversal is relation-profile-driven and returns neighboring graph + nodes, not a document-oriented render model (`src/api/graph_read.rs:107-180`). +- Search results in `slim` mode contain `id`, `type`, `label`, optional `path`, + optional `span`, and optional `summary`; standard mode adds + `qualified_name`, score details, and `context` + (`src/api/graph_read.rs:600-653`). +- Search ranking is optimized for generic code/entity retrieval, with + `DocumentationSource` and `DocumentationChunk` treated as lower-priority than + code declarations (`src/api/graph_read.rs:656-700`). +- `graph_query` is explicitly read-only; both keyword blocking and prepared + statement read-only checks are enforced (`src/api/graph_read.rs:220-245`, + `src/api/core.rs:587-616`, `SECURITY.md:21-31`). + +Implication: + +- The existing public API is good for retrieval and introspection, but it is + not a page-delivery API for an OKF wiki. It has no concept-specific route or + bundle projection surface. + +### 4. Storage, refresh, and state + +Confirmed facts: + +- Repository runtime resolution derives `.codebaseGraph/config.json`, + `.codebaseGraph/manifest.json`, and `.codebaseGraph/_graph.ldb` under + the repository root (`src/api/context.rs:13-67`). +- Materialization persists manifests and graph state through a dedicated + pipeline and manifest diff model (`src/api/materialization.rs:104-240`, + `src/protocol.rs:72-170`). +- The source snapshot model tracks content hashes and changed paths, which is + useful if an OKF compiler wants similar incremental behavior + (`src/protocol.rs:126-170`). +- The README documents automatic freshness through `mcp start` or `watch`, and + treats `build` as an explicit manual rebuild (`README.md:32-39`, + `README.md:82-105`). + +Implication: + +- `codebaseGraph` already solves incremental graph state management for source + files, but its persisted state is specific to syntax-materialized graph rows, + not OKF bundle/page projections. + +### 5. Transport and security posture + +Confirmed facts: + +- MCP specs are generated from the public API operation descriptors + (`src/cli/mcp/tools.rs:14-48`). +- MCP tool handling maps only the graph operations listed in the registry and + supports either block text or structured JSON payloads + (`src/cli/mcp/tools.rs:63-220`). +- The README documents only graph-oriented MCP tools today + (`README.md:54-80`). +- The documented security boundary is local-first; remote HTTP remains weakly + scoped and `graph_query` must remain read-only (`README.md:62-70`, + `SECURITY.md:21-31`). + +Implication: + +- Any wiki service exposed beyond local use needs its own security model; it + should not inherit assumptions from the current local-first graph transport. + +## OKF v0.1 Requirement Mapping + +The table below compares the attached OKF v0.1 draft to the confirmed current +state of this repository. + +| OKF draft requirement | Current `codebaseGraph` support | Status | Evidence | +| --- | --- | --- | --- | +| Bundle is a directory tree of markdown files | Markdown files are scanned and materialized | Partial | `README.md:3-5`, `src/profiles.rs:69-82` | +| Every concept has YAML frontmatter with required `type` | No frontmatter parser or typed frontmatter fields exist | Missing | `src/parser/markdown.rs:8-173`, `assets/graph_schema.json:3580-3698` | +| Optional `title`, `description`, `resource`, `tags`, `timestamp`, and unknown extensions must be preserved | No OKF metadata model exists in the graph schema | Missing | `assets/graph_schema.json:3580-3698` | +| Concept ID is bundle-relative path without `.md` | No concept identity rule exists; current IDs are graph IDs derived from capture/materialization keys | Missing | `src/syntax_materializer/builder/nodes.rs:52-97` | +| `index.md` and `log.md` are reserved and not concepts | Current markdown ingestion treats all `.md` files generically | Missing | `src/profiles.rs:69-82`, `src/parser/markdown.rs:8-173` | +| Exception: root `index.md` may contain `okf_version` frontmatter | No special root-index handling exists | Missing | `src/parser/markdown.rs:8-173` | +| Standard markdown body should remain readable and structured | Whole-file and heading-level markdown text is captured | Partial | `src/parser/markdown.rs:20-59` | +| Internal markdown links represent relationships and may be broken | Links are not extracted from markdown bodies today | Missing | `src/parser/markdown.rs:8-173`, `src/syntax_materializer/relations.rs:103-115` | +| Citations should be representable under `# Citations` | Headings are captured, but citations are not parsed as first-class data | Partial | `src/parser/markdown.rs:35-59`, `src/parser/markdown.rs:119-173` | +| Consumers must tolerate unknown types and broken links | No OKF consumer exists yet; current docs model avoids this by not parsing those semantics | Missing | `src/api/contracts.rs:18-157`, `src/parser/markdown.rs:8-173` | +| Index browsing and progressive disclosure | Graph search/context can retrieve docs, but no directory/index projection exists | Partial | `src/api/core.rs:57-163`, `src/api/graph_read.rs:35-180` | +| Optional logs for scoped history | No log-file parsing exists | Missing | `src/parser/markdown.rs:8-173` | +| Version declaration via root `index.md` frontmatter | No version parsing exists | Missing | `src/parser/markdown.rs:8-173` | +| Best-effort consumption rather than hard rejection | The graph API is permissive at graph-read time, but there is no OKF conformance layer | Partial | `src/api/core.rs:172-205`, `src/api/graph_read.rs:35-105` | + +## Public API Capability Matrix + +This matrix evaluates the already-written public API specifically for an OKF +wiki service. + +| Public surface | What it does now | Useful for OKF wiki | Gap for OKF wiki | +| --- | --- | --- | --- | +| `CodebaseGraphApi::execute_operation` | Stable library entry point over public requests | Yes | Needs composition by a wiki service or extension with OKF-specific operations | +| `Health` | Confirms graph DB/manifest readability | Yes | Does not validate OKF bundle state | +| `Search` | FTS search over graph node types | Yes, for code/document retrieval sidebars | No concept-aware OKF search ranking or filtering | +| `Context` | Relation-based graph neighborhoods | Yes, for code-neighborhood panels | No concept page model or backlinks from markdown links | +| `Query` | Read-only graph queries | Yes, for admin/debugging | Unsafe to treat as end-user wiki contract; not concept-oriented | +| `Materialize` / `Plan` | Incremental graph build/plan for source files | Indirectly yes | Does not parse OKF semantics or produce wiki artifacts | +| `Catalog` (`schema`, `query-helpers`, `architecture-queries`) | Exposes graph metadata and canned queries | Indirectly yes | No OKF schema/catalog or bundle contracts | +| `Refresh` / `watch` | Keeps graph state fresh | Yes | No OKF projection refresh pipeline | +| MCP generated from descriptors | Makes graph tools consumable by agents | Yes, for codebaseGraph operations | No OKF-specific read/write tools | + +## Prioritized Gap Analysis + +### Priority 1: No OKF semantic model + +Root problem: + +- The current Markdown pipeline captures documents and heading chunks, but it + never parses the semantic payload that OKF standardizes: frontmatter, + concept-type metadata, reserved files, root-index exception, citations, or + markdown cross-links (`src/parser/markdown.rs:8-173`, + `assets/graph_schema.json:3580-3698`). + +Why it matters: + +- Without an OKF semantic model, a wiki cannot reliably answer basic questions + such as “what concepts exist?”, “what type is this concept?”, “what tags does + it have?”, “what links point here?”, or “is this `index.md` a directory page + or a concept?” + +### Priority 2: Public API is graph-oriented, not page-oriented + +Root problem: + +- The public API returns graph-search hits, graph neighborhoods, or raw query + rows. It has no operation that returns a fully normalized concept page, + directory listing, diagnostics set, or bundle manifest + (`src/api/contracts.rs:18-157`, `src/api/core.rs:57-163`, + `src/api/graph_read.rs:600-653`). + +Why it matters: + +- An OKF wiki needs stable concept/directory contracts, not just search hits. + +### Priority 3: Search and ranking are wrong for OKF navigation + +Root problem: + +- The docs search index is generic (`label`, `path`, `summary`) and search + ranking favors code entities over documentation nodes + (`assets/graph_schema.json:6609-6620`, `src/api/graph_read.rs:656-700`). + +Why it matters: + +- OKF search should boost concept titles, concept IDs, tags, types, and + descriptions. None of those are available in the current graph model. + +### Priority 4: No reserved-file or history semantics + +Root problem: + +- `index.md` and `log.md` are not treated specially in current ingestion + (`src/parser/markdown.rs:8-173`). + +Why it matters: + +- OKF requires both files to carry semantic meaning, and `index.md` at the + bundle root has a special versioning exception that current parsing would miss. + +### Priority 5: Security and transport assumptions differ + +Root problem: + +- `codebaseGraph` is explicitly local-first, and its HTTP mode is not positioned + as a multi-user or production-grade remote API (`README.md:62-70`, + `SECURITY.md:21-31`). + +Why it matters: + +- A wiki service may need browser access, sanitization, authentication, and a + stronger remote boundary than the current graph transport. + +## Recommended Target Architecture + +### Recommendation + +Build the OKF wiki as an adjacent subsystem that composes `codebaseGraph` +rather than modifying the current documentation graph model in place. + +### Proposed boundaries + +1. `codebaseGraph` remains the repository/code graph engine. + - Ownership: syntax graph, incremental source scanning, read-only graph + retrieval, architecture queries. + - Interface used by the wiki: `CodebaseGraphApi` and existing MCP/CLI + surfaces (`src/api/mod.rs:1-26`, `src/api/facade.rs:16-39`). + +2. New `okf_wiki` library/service owns OKF semantics. + - OKF bundle discovery. + - Frontmatter parsing and lossless extension preservation. + - Concept ID derivation. + - Reserved-file handling for `index.md` and `log.md`. + - Internal-link, backlink, and citation extraction. + - Conformance diagnostics against the OKF draft. + - Directory and concept page projection. + +3. Wiki backend/API composes both. + - Primary page data comes from `okf_wiki`. + - Optional “code context” or “related implementation” panels use + `CodebaseGraphApi::Search`, `Context`, and `Query`. + +4. Wiki UI stays thin. + - Render pre-normalized concept/directory payloads. + - Do not make the browser responsible for interpreting raw OKF rules. + +### Why this split is preferred + +Pros: + +- Preserves the existing graph API and schema contract. +- Avoids forcing OKF concepts into a code-search-first node taxonomy. +- Lets the OKF implementation evolve independently from syntax graph internals. +- Keeps repository/code retrieval reusable for non-OKF consumers. + +Cons: + +- Introduces another projection layer and state artifact. +- Requires coordination between two refresh paths if both code graph and OKF + projection are kept hot. + +### Rejected alternative + +Do not treat `DocumentationSource`/`DocumentationChunk` as the canonical OKF +model. + +Reason: + +- Those nodes are too generic and too lossy. Retrofitting them would require + stretching existing fields and search indexes far beyond their current + meaning, with higher compatibility risk than a bounded adjacent module. + +## Data Model, Storage, and Indexing Implications + +### Recommended OKF data model + +Add a normalized model owned by the wiki subsystem: + +- `Bundle { id, root_path, okf_version, diagnostics[] }` +- `Directory { path, title, description, authored_index, children[], concepts[], logs[] }` +- `Concept { id, source_path, type, title, description, resource, tags[], timestamp, extensions, body_markdown, headings[], citations[], outbound_links[], backlinks[] }` +- `Link { raw_href, target_id?, fragment?, status }` +- `LogEntry { scope_path, date, category?, text, links[] }` + +### Recommended storage + +Recommended initial choice: + +- Keep codebaseGraph state in `.codebaseGraph/` unchanged. +- Write OKF projection artifacts to a separate state root, for example + `.okfWiki/`, to avoid accidental coupling to graph manifests or DB schema. + +Rationale: + +- `RepoPaths::derive` and runtime resolution are explicitly tied to + `.codebaseGraph` and graph-specific files (`src/api/context.rs:13-67`). +- Materialization manifests are keyed to source snapshots and graph rows, not to + OKF concepts (`src/protocol.rs:72-170`). + +### Recommended indexing + +- Build an OKF search index over `concept_id`, `title`, `type`, `tags`, + `description`, headings, and body text. +- Keep backlinks and link diagnostics as first-class indexed data. +- Optionally mirror selected OKF concept summaries into `codebaseGraph` later, + but do not make that a day-one dependency. + +### Migration implications + +- No migration to existing `codebaseGraph` graph schema is required for an + initial release. +- If later integration into LadyBugDB is desired, add a versioned import path + rather than mutating `DocumentationSource` semantics in place. + +## APIs and Contracts + +### Reuse existing public API as-is for + +- Repository health checks. +- Code or document discovery by path/content terms. +- Graph neighborhood views for implementation cross-links. +- Read-only ad hoc inspection by maintainers. + +### New OKF-facing contracts needed + +Recommended library/service contracts: + +- `LoadBundleRequest { root_path }` +- `ValidateBundleRequest { root_path, profile }` +- `BuildWikiProjectionRequest { root_path, incremental }` +- `GetConceptRequest { bundle_id, concept_id }` +- `GetDirectoryRequest { bundle_id, directory_path }` +- `SearchConceptsRequest { bundle_id, query, filters }` +- `GetDiagnosticsRequest { bundle_id, severity? }` +- `GetRecentChangesRequest { bundle_id, scope? }` + +### Contract boundary recommendation + +- Do not overload `OperationRequest::Search` or `OperationRequest::Context` + with OKF semantics. +- Either: + 1. create a parallel `OkfWikiApi`, or + 2. add a separate top-level product surface after the OKF model stabilizes. + +Option 1 is lower risk for the first implementation. + +## Security, Concurrency, and Operational Considerations + +### Security + +Confirmed constraints: + +- Current remote HTTP graph transport is intentionally minimal and not a + production multi-user security model (`README.md:62-70`, + `SECURITY.md:21-31`). + +Recommendations for the wiki: + +- Parse YAML with safe loaders only. +- Sanitize rendered markdown HTML. +- Treat all links and `resource` URIs as untrusted. +- If browser-served, add explicit authn/authz and do not rely on current MCP + HTTP assumptions. + +### Concurrency and refresh + +Confirmed constraints: + +- The graph system already has refresh/build state and locking concerns + (`README.md:93-105`, `src/cli/mcp/tools.rs:141-153`). + +Recommendations: + +- Make OKF projection refresh independent from graph DB locking. +- If both systems watch the same repository, use one debounced source event + stream feeding two consumers instead of two unrelated recursive watchers. +- Fail independently: a graph refresh failure should not make the OKF wiki + unreadable, and an OKF parse failure should not stop code graph reads. + +## Phased Implementation Plan + +### Phase 0: Specification alignment + +- Freeze the OKF v0.1 requirements set used for implementation. +- Encode the special root `index.md` frontmatter exception for `okf_version`. +- Write fixtures that cover concept docs, `index.md`, `log.md`, broken links, + citations, and unknown extension keys. + +Exit criteria: + +- Requirement mapping is complete and test fixtures reflect every normative OKF + rule used by the implementation. + +### Phase 1: OKF core parser and model + +- Implement bundle scanning. +- Parse YAML frontmatter and markdown body separately. +- Derive concept IDs from bundle-relative paths. +- Preserve unknown frontmatter keys. +- Distinguish concept docs from `index.md` and `log.md`. + +Exit criteria: + +- Bundle loading produces normalized concepts/directories/logs with diagnostics. + +### Phase 2: Link, citation, and diagnostics layer + +- Extract internal markdown links and fragments. +- Resolve absolute and relative links. +- Record broken links without rejecting the bundle. +- Extract citation sections and scoped log entries. + +Exit criteria: + +- The model supports backlinks, unresolved-link diagnostics, and changes views. + +### Phase 3: Wiki API and projection + +- Build concept/directory/search endpoints or library methods. +- Add concept-aware search ranking. +- Provide machine-readable diagnostics and recent-changes outputs. + +Exit criteria: + +- A consumer can request a concept page or directory listing without directly + reading files. + +### Phase 4: Adjacent composition with `codebaseGraph` + +- Add optional “related code”, “implementation neighborhood”, or “architecture + query” panels powered by `CodebaseGraphApi`. +- Keep this integration optional and additive. + +Exit criteria: + +- The wiki can show implementation context without changing OKF page semantics. + +### Phase 5: Delivery and hardening + +- Add browser UI or static rendering. +- Add authn/authz if remote-serving is required. +- Add watch/refresh integration and deployment packaging. + +Exit criteria: + +- The service is operationally independent, testable, and deployable. + +## Testing and Verification Plan + +### Unit tests + +- Frontmatter parsing and extension preservation. +- Concept ID derivation. +- Reserved-file classification. +- Absolute and relative link resolution. +- Root `index.md` version exception handling. +- Citation and `log.md` parsing. + +### Integration tests + +- End-to-end bundle load and page projection. +- Broken-link diagnostics without hard failure. +- Search ranking over titles, concept IDs, and tags. +- Backlink recomputation on file changes. + +### Composition tests + +- `okf_wiki` uses `CodebaseGraphApi` only for optional implementation context. +- A failing graph query does not prevent concept rendering. +- A malformed OKF bundle does not corrupt `.codebaseGraph` state. + +### Non-functional verification + +- Incremental rebuild correctness. +- HTML sanitization and link-scheme safety. +- Watch/refresh coalescing behavior. +- Concurrency tests if remote service mode is introduced. + +## Acceptance Criteria + +1. Every non-reserved markdown file becomes exactly one OKF concept with the + correct path-derived concept ID. +2. `index.md` and `log.md` are never treated as normal concepts. +3. Bundle-root `index.md` may declare `okf_version` without breaking index + semantics. +4. Unknown frontmatter keys survive parse and serialization. +5. Missing optional OKF fields do not block best-effort consumption. +6. Internal links produce backlinks and diagnostics for unresolved targets. +7. Search supports concept-aware ranking and filters. +8. `CodebaseGraphApi` integration remains optional and additive. +9. Existing `codebaseGraph` public API behavior remains backward compatible. +10. No change to `.codebaseGraph` schema or current MCP tool names is required + for the first OKF wiki release. + +## Rollout and Compatibility Risks + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Overloading current documentation nodes with OKF semantics | Breaks existing graph consumers and search expectations | Keep OKF model adjacent and separate | +| Reusing current search ranking for wiki navigation | Poor concept discovery quality | Build dedicated OKF search index | +| Sharing transport/security assumptions with current MCP HTTP mode | Weak remote security posture | Give the wiki its own service boundary and auth model | +| Coupling OKF state to `.codebaseGraph` manifest/DB | Hard-to-reason upgrades and migration risk | Use separate projection state root | +| Dual watchers over the same repo | Duplicate work and race conditions | Centralize source change detection or coordinate refresh | + +## Assumptions and Open Questions + +Assumptions: + +- The attached OKF v0.1 draft is the governing format for the first wiki + implementation. +- The wiki service is intended to exist alongside `codebaseGraph`, not replace + it. +- Backward compatibility of the current `codebaseGraph` public API matters more + than collapsing everything into one surface immediately. + +Open questions: + +1. Should the first release be a library-only projection, a local HTTP service, + or static artifact generation? +2. Does the wiki need authoring/editing in scope, or is read-only sufficient for + the first release? +3. Should OKF projection state be persisted as JSON artifacts, SQLite, or a new + LadyBugDB graph import? +4. Does search need to span only OKF concepts, or unified OKF plus code graph + results? +5. Are OKF bundles expected to live inside the same repository as code, or in + separate repositories that the wiki aggregates? + +## Bottom Line + +Confirmed current state: this repository already has a strong public API and a +working incremental graph engine, but its Markdown support is intentionally too +generic to serve as an OKF consumer out of the box. + +Recommendation: implement OKF semantics in a dedicated adjacent subsystem, +compose `CodebaseGraphApi` for code-graph retrieval, and keep the initial OKF +wiki release independent from existing graph schema changes. From 648c4ba71906e7ef42e4fc2fd53adc5303a00833 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 14:23:17 +0930 Subject: [PATCH 05/19] refactor(adapters): separate CLI and MCP transports Place command and MCP transports under peer src/adapters modules while preserving the existing CLI compatibility export. Move MCP-owned protocol constants, HTTP session state, refresh wiring, and response framing out of the CLI adapter. Constraint: Preserve CLI commands, MCP stdio and HTTP behavior, public result shapes, and the codebase_graph::cli compatibility path Rejected: Keep MCP nested under CLI | contradicts the peer transport boundary and permits reverse adapter dependencies Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep MCP independent of adapters::cli; CLI may launch MCP only through adapters::mcp Tested: cargo fmt --all -- --check; cargo clippy --all-targets --all-features -- -D warnings; cargo test --all-targets --all-features --quiet; cargo test -p xtask --quiet; cargo test mcp -- --nocapture; cargo test api::boundary_tests -- --nocapture Not-tested: none --- crates/xtask/src/main.rs | 2 +- src/{ => adapters}/cli/constants.rs | 3 - src/{ => adapters}/cli/dispatch.rs | 9 ++- src/{ => adapters}/cli/format/help.rs | 34 ++++----- src/{ => adapters}/cli/format/mod.rs | 4 +- src/{ => adapters}/cli/graph/mod.rs | 0 src/{ => adapters}/cli/graph/options.rs | 23 +++--- src/{ => adapters}/cli/install/client.rs | 4 +- src/{ => adapters}/cli/install/command.rs | 4 +- src/{ => adapters}/cli/install/descriptor.rs | 24 +++--- src/{ => adapters}/cli/install/fs_util.rs | 10 +-- src/{ => adapters}/cli/install/metadata.rs | 12 +-- src/adapters/cli/install/mod.rs | 22 ++++++ src/{ => adapters}/cli/install/options.rs | 26 +++---- src/{ => adapters}/cli/install/render.rs | 43 ++++++----- src/{ => adapters}/cli/install/verify.rs | 26 ++++--- .../cli/materialization/command.rs | 9 ++- src/{ => adapters}/cli/materialization/mod.rs | 2 +- .../cli/materialization/request.rs | 4 +- .../cli/mcp_command.rs} | 4 +- src/{ => adapters}/cli/mod.rs | 2 +- src/{ => adapters}/cli/reinstall.rs | 2 +- src/{ => adapters}/cli/setup.rs | 0 .../cli/tests/dispatch_materialize.rs | 0 src/{ => adapters}/cli/tests/fixtures.rs | 0 src/{ => adapters}/cli/tests/graph.rs | 0 src/{ => adapters}/cli/tests/install.rs | 0 src/{ => adapters}/cli/tests/mcp.rs | 20 +++-- src/{ => adapters}/cli/tests/mod.rs | 3 +- src/{ => adapters}/cli/tests/watch.rs | 0 src/{ => adapters}/cli/uninstall.rs | 6 +- src/{ => adapters}/cli/util.rs | 12 +-- src/{ => adapters}/cli/watch/command.rs | 5 +- src/{ => adapters}/cli/watch/mod.rs | 4 +- src/{ => adapters}/cli/watch/options.rs | 52 ++++++------- src/{ => adapters}/cli/watch/output.rs | 4 +- .../blocks.rs => adapters/mcp/block.rs} | 2 +- src/{cli => adapters}/mcp/http.rs | 52 +++++++------ src/adapters/mcp/mod.rs | 20 +++++ src/{cli => adapters}/mcp/options.rs | 46 +++++------- src/{cli => adapters}/mcp/protocol.rs | 21 +++--- src/{cli => adapters}/mcp/refresh.rs | 4 +- src/adapters/mcp/state.rs | 15 ++++ src/{cli => adapters}/mcp/stdio.rs | 8 +- src/{cli => adapters}/mcp/tools.rs | 11 ++- src/adapters/mod.rs | 12 +++ src/api/boundary_tests.rs | 73 +++++++++++++++---- src/bin/codebase-graph.rs | 4 +- src/cli/install/mod.rs | 24 ------ src/cli/install/state.rs | 15 ---- src/cli/mcp/mod.rs | 18 ----- src/lib.rs | 4 +- 52 files changed, 386 insertions(+), 318 deletions(-) rename src/{ => adapters}/cli/constants.rs (56%) rename src/{ => adapters}/cli/dispatch.rs (97%) rename src/{ => adapters}/cli/format/help.rs (92%) rename src/{ => adapters}/cli/format/mod.rs (78%) rename src/{ => adapters}/cli/graph/mod.rs (100%) rename src/{ => adapters}/cli/graph/options.rs (96%) rename src/{ => adapters}/cli/install/client.rs (98%) rename src/{ => adapters}/cli/install/command.rs (93%) rename src/{ => adapters}/cli/install/descriptor.rs (86%) rename src/{ => adapters}/cli/install/fs_util.rs (83%) rename src/{ => adapters}/cli/install/metadata.rs (87%) create mode 100644 src/adapters/cli/install/mod.rs rename src/{ => adapters}/cli/install/options.rs (83%) rename src/{ => adapters}/cli/install/render.rs (87%) rename src/{ => adapters}/cli/install/verify.rs (89%) rename src/{ => adapters}/cli/materialization/command.rs (90%) rename src/{ => adapters}/cli/materialization/mod.rs (56%) rename src/{ => adapters}/cli/materialization/request.rs (96%) rename src/{cli/mcp/commands.rs => adapters/cli/mcp_command.rs} (86%) rename src/{ => adapters}/cli/mod.rs (95%) rename src/{ => adapters}/cli/reinstall.rs (96%) rename src/{ => adapters}/cli/setup.rs (100%) rename src/{ => adapters}/cli/tests/dispatch_materialize.rs (100%) rename src/{ => adapters}/cli/tests/fixtures.rs (100%) rename src/{ => adapters}/cli/tests/graph.rs (100%) rename src/{ => adapters}/cli/tests/install.rs (100%) rename src/{ => adapters}/cli/tests/mcp.rs (96%) rename src/{ => adapters}/cli/tests/mod.rs (82%) rename src/{ => adapters}/cli/tests/watch.rs (100%) rename src/{ => adapters}/cli/uninstall.rs (97%) rename src/{ => adapters}/cli/util.rs (95%) rename src/{ => adapters}/cli/watch/command.rs (95%) rename src/{ => adapters}/cli/watch/mod.rs (75%) rename src/{ => adapters}/cli/watch/options.rs (83%) rename src/{ => adapters}/cli/watch/output.rs (90%) rename src/{cli/format/blocks.rs => adapters/mcp/block.rs} (92%) rename src/{cli => adapters}/mcp/http.rs (87%) create mode 100644 src/adapters/mcp/mod.rs rename src/{cli => adapters}/mcp/options.rs (80%) rename src/{cli => adapters}/mcp/protocol.rs (84%) rename src/{cli => adapters}/mcp/refresh.rs (70%) create mode 100644 src/adapters/mcp/state.rs rename src/{cli => adapters}/mcp/stdio.rs (92%) rename src/{cli => adapters}/mcp/tools.rs (97%) create mode 100644 src/adapters/mod.rs delete mode 100644 src/cli/install/mod.rs delete mode 100644 src/cli/install/state.rs delete mode 100644 src/cli/mcp/mod.rs diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 039f939..c5a263e 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -229,7 +229,7 @@ fn check_no_legacy_surfaces(issues: &mut Vec) { for path in [ "Cargo.toml", "src/lib.rs", - "src/cli/mod.rs", + "src/adapters/cli/mod.rs", "src/ladybug_writer.rs", ] { let text = fs::read_to_string(path).unwrap_or_default(); diff --git a/src/cli/constants.rs b/src/adapters/cli/constants.rs similarity index 56% rename from src/cli/constants.rs rename to src/adapters/cli/constants.rs index 0723a2e..d87f961 100644 --- a/src/cli/constants.rs +++ b/src/adapters/cli/constants.rs @@ -1,8 +1,5 @@ use std::env; -pub(super) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; -pub(super) const MAX_HTTP_BODY_BYTES: usize = 1_000_000; - pub(super) fn server_command() -> String { env::var("CODEBASE_GRAPH_SERVER_COMMAND").unwrap_or_else(|_| "codebase-graph".to_string()) } diff --git a/src/cli/dispatch.rs b/src/adapters/cli/dispatch.rs similarity index 97% rename from src/cli/dispatch.rs rename to src/adapters/cli/dispatch.rs index 2e59947..3be9271 100644 --- a/src/cli/dispatch.rs +++ b/src/adapters/cli/dispatch.rs @@ -1,19 +1,20 @@ use super::{ format::{ graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, - graph_query_helpers_help, graph_schema_help, graph_search_help, top_level_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, mcp_help, top_level_help, }, graph::{ ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, HealthOptions, MetadataOutputOptions, }, materialization::{run_materialize, run_plan}, - mcp::{run_mcp_command, serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, + mcp_command::run_mcp_command, reinstall::run_reinstall, setup::run_setup, uninstall::run_uninstall, watch::run_watch, }; +use crate::adapters::mcp::{serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}; use crate::api::{ contracts::{ ContextRequest, HealthRequest, OperationRequest, OutputFormat, QueryRequest, RepoSelector, @@ -38,11 +39,11 @@ pub fn run_process_args(args: Vec) -> Result<(), String> { if args.first().map(String::as_str) == Some("mcp") { match args.get(1).map(String::as_str) { Some("start") => { - let options = McpServeOptions::parse(&args[2..])?; + let options = McpServeOptions::parse(&args[2..], mcp_help())?; return serve_mcp_stdio(&options, io::stdin().lock(), &mut io::stdout()); } Some("http") => { - let options = McpHttpOptions::parse(&args[2..])?; + let options = McpHttpOptions::parse(&args[2..], mcp_help())?; return serve_mcp_http(&options); } _ => {} diff --git a/src/cli/format/help.rs b/src/adapters/cli/format/help.rs similarity index 92% rename from src/cli/format/help.rs rename to src/adapters/cli/format/help.rs index 0ec8f5c..090ee9c 100644 --- a/src/cli/format/help.rs +++ b/src/adapters/cli/format/help.rs @@ -1,64 +1,64 @@ -pub(in crate::cli) fn top_level_help() -> &'static str { +pub(in crate::adapters::cli) fn top_level_help() -> &'static str { "codebase-graph native CLI\n\nUSAGE:\n codebase-graph [options]\n\nCOMMANDS:\n install Perform first-time graph setup and write .codebaseGraph/config.json\n reinstall Remove existing graph state and run install again\n uninstall Remove repo graph state, instructions, and MCP registrations\n build Materialize a graph through the Rust native engine\n plan Preview files that would rebuild, delete, skip, or ignore\n watch Watch for file changes and refresh after a debounce window\n check-health Check whether the native graph database is readable\n schema Return ontology schema, indexes, profiles, and helpers\n query-helpers Return named read-only graph query helpers\n codebase-architecture-queries Return the architecture-discovery query catalog\n codebase-search Search the code graph with compact context\n codebase-context Return compact graph context\n graph-query Execute a restricted read-only graph query\n mcp Serve codebaseGraph MCP over stdio or HTTP\n\nRun `codebase-graph --help` for command options." } -pub(in crate::cli) fn mcp_help() -> &'static str { +pub(in crate::adapters::cli) fn mcp_help() -> &'static str { "codebase-graph mcp\n\nUSAGE:\n codebase-graph mcp install [--client ] [--scope ] [--config-path ] [--client-config-path ] [--dry-run] [--json]\n codebase-graph mcp start [--repo-root ] [--config ] [--db ] [--manifest ]\n codebase-graph mcp http [--repo-root ] [--config ] [--db ] [--manifest ] [--host ] [--port ] [--path ] [--allow-remote] [--auth-token |--auth-token-env ]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --host HTTP bind host; defaults to 127.0.0.1\n --port HTTP bind port; defaults to 8765\n --path HTTP endpoint path; defaults to /mcp\n --allow-remote Permit non-local HTTP bind when an auth token is supplied\n --auth-token Bearer token required for HTTP requests\n --auth-token-env Environment variable containing the bearer token" } -pub(in crate::cli) fn mcp_install_help() -> &'static str { +pub(in crate::adapters::cli) fn mcp_install_help() -> &'static str { "codebase-graph mcp install\n\nUSAGE:\n codebase-graph mcp install [--client ] [--scope local|user|project] [--name ] [--config-path ] [--client-config-path ] [--repo-root ] [--dry-run] [--verify] [--json]\n\nOPTIONS:\n --client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or all\n --scope local, user, or project; defaults to local\n --name MCP server name; defaults to codebase_graph_\n --config-path Path to .codebaseGraph/config.json\n --client-config-path Override the target MCP client config path\n --repo-root Repository root override; auto-detected when omitted\n --dry-run Show install action without writing files or invoking CLIs\n --verify Accepted for compatibility\n --json Emit JSON output" } -pub(in crate::cli) fn materialize_help() -> &'static str { +pub(in crate::adapters::cli) fn materialize_help() -> &'static str { "codebase-graph build\n\nUSAGE:\n codebase-graph build [--source-root |--repo-root ] [--db ] [--manifest ] [--mode full|changed] [--json]\n codebase-graph build --native-request [--manifest ] [--json]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --db Ladybug database path; defaults under .codebaseGraph\n --manifest Manifest path; defaults under .codebaseGraph\n --mode full or changed; defaults to changed\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Materialize files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --parallel Parse independent files concurrently; default behavior\n --single-thread Disable concurrent parsing and build partitions serially\n --progress Include progress events in JSON output\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --native-request JSON NativeSyntaxMaterializationRequest payload\n --json Emit JSON output" } -pub(in crate::cli) fn plan_help() -> &'static str { +pub(in crate::adapters::cli) fn plan_help() -> &'static str { "codebase-graph plan\n\nUSAGE:\n codebase-graph plan [--source-root |--repo-root ] [--manifest ] [--mode full|changed] [--json]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --manifest Manifest path; defaults under .codebaseGraph\n --mode full or changed; defaults to changed\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Plan files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --native-request JSON NativeSyntaxMaterializationRequest payload\n --json Emit JSON output" } -pub(in crate::cli) fn watch_help() -> &'static str { +pub(in crate::adapters::cli) fn watch_help() -> &'static str { "codebase-graph watch\n\nUSAGE:\n codebase-graph watch [--source-root |--repo-root ] [--mode full|changed] [--watch-backend auto|native|poll] [--poll-ms ] [--debounce-ms ]\n\nOPTIONS:\n --source-root Repository or source root override; auto-detected when omitted\n --repo-root Alias for --source-root\n --mode full or changed; defaults to changed\n --watch-backend auto, native, or poll; defaults to auto\n --poll-ms Poll interval for poll backend or auto fallback; defaults to 500\n --debounce-ms Quiet-window debounce interval in milliseconds; defaults to 250\n --max-iterations Stop after n refreshes, useful for tests\n --once Run one refresh immediately and exit\n --no-git Disable Git file discovery and scan the filesystem\n --git-diff Refresh files from git diff plus untracked files\n --git-base Revision used by --git-diff; defaults to HEAD\n --include Include only paths matching the glob; repeatable\n --exclude Exclude paths matching the glob; repeatable\n --parallel Parse independent files concurrently; default behavior\n --single-thread Disable concurrent parsing and build partitions serially\n --progress Include progress events in JSON output" } -pub(in crate::cli) fn setup_help() -> &'static str { +pub(in crate::adapters::cli) fn setup_help() -> &'static str { "codebase-graph install\n\nUSAGE:\n codebase-graph install [--mode full|changed] [--mcp-client ] [--mcp-config-path ] [--skip-mcp-config] [--dry-run] [--instructions-target auto|agents|claude|skip] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --mode full or changed; defaults to changed\n --mcp-client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or none\n --mcp-config-path Override MCP client config path\n --skip-mcp-config Do not write MCP client config\n --dry-run Report install changes without writing repo or client state\n --instructions-target auto, agents, claude, or skip\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --json Emit JSON output" } -pub(in crate::cli) fn reinstall_help() -> &'static str { +pub(in crate::adapters::cli) fn reinstall_help() -> &'static str { "codebase-graph reinstall\n\nRemove existing graph state and run install again.\n\nUSAGE:\n codebase-graph reinstall [--mode full|changed] [--mcp-client ] [--mcp-config-path ] [--skip-mcp-config] [--dry-run] [--instructions-target auto|agents|claude|skip] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --mode full or changed; defaults to changed\n --mcp-client codex, claude, claude-project, lmstudio, github-copilot, hermes, openclaw, generic, copilot-studio, microsoft-copilot, or none\n --mcp-config-path Override MCP client config path\n --skip-mcp-config Do not write MCP client config\n --dry-run Report reinstall changes without writing repo or client state\n --instructions-target auto, agents, claude, or skip\n --no-fts Skip FTS extension loading and index creation\n --no-semantic-enrichment Skip semantic enrichment\n --semantic-provider-mode local_only only; provider-backed modes are not supported by Rust-only production\n --json Emit JSON output" } -pub(in crate::cli) fn uninstall_help() -> &'static str { +pub(in crate::adapters::cli) fn uninstall_help() -> &'static str { "codebase-graph uninstall\n\nUSAGE:\n codebase-graph uninstall [--repo-root ] [--config ] [--mcp-client |all] [--client-config-path ] [--dry-run] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --mcp-client Client registration to remove; defaults to all supported clients\n --client-config-path Override a single target MCP client config path\n --dry-run Report removals without deleting files or updating configs\n --json Emit JSON output" } -pub(in crate::cli) fn graph_health_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_health_help() -> &'static str { "codebase-graph check-health\n\nUSAGE:\n codebase-graph check-health [--repo-root ] [--config ] [--db ] [--manifest ] [--json]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --json Emit JSON output" } -pub(in crate::cli) fn graph_schema_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_schema_help() -> &'static str { "codebase-graph schema\n\nUSAGE:\n codebase-graph schema [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_query_helpers_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_query_helpers_help() -> &'static str { "codebase-graph query-helpers\n\nUSAGE:\n codebase-graph query-helpers [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_architecture_queries_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_architecture_queries_help() -> &'static str { "codebase-graph codebase-architecture-queries\n\nUSAGE:\n codebase-graph codebase-architecture-queries [--group ] [--format json|block] [--json] [--pretty]\n\nOPTIONS:\n --group Optional architecture query group to return\n --format block or json; defaults to block\n --json Emit compact JSON output\n --pretty Pretty-print JSON output" } -pub(in crate::cli) fn graph_search_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_search_help() -> &'static str { "codebase-graph codebase-search\n\nUSAGE:\n codebase-graph codebase-search [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--profile ] [--detail standard|slim] [--format json|block] [--json]\n\nOPTIONS:\n Search query\n --limit Maximum search hits; defaults to 3\n --profile Context profile label; defaults to brief\n --budget Context budget retained in output payload; defaults to 600\n --context-limit Context item limit retained for compatibility\n --detail standard or slim; defaults to standard\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --format block or json; defaults to block\n --json Emit compact JSON output" } -pub(in crate::cli) fn graph_context_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_context_help() -> &'static str { "codebase-graph codebase-context\n\nUSAGE:\n codebase-graph codebase-context [query] [--node-id --node-type ] [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--context-limit ] [--profile ] [--detail standard|slim] [--format json|block] [--json]\n\nOPTIONS:\n [query] Search query used when explicit node lookup is not supplied\n --node-id Explicit graph node id\n --node-type Explicit graph node type\n --limit Maximum search hits in query mode; defaults to 3\n --context-limit Maximum explicit context rows; defaults to 3\n --profile Context profile label; defaults to brief\n --budget Context budget retained in output payload; defaults to 600\n --detail standard or slim; defaults to standard\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --format block or json; defaults to block\n --json Emit compact JSON output" } -pub(in crate::cli) fn metadata_help(command_name: &str) -> &'static str { +pub(in crate::adapters::cli) fn metadata_help(command_name: &str) -> &'static str { match command_name { "schema" => graph_schema_help(), "query-helpers" => graph_query_helpers_help(), @@ -69,6 +69,6 @@ pub(in crate::cli) fn metadata_help(command_name: &str) -> &'static str { } } -pub(in crate::cli) fn graph_query_help() -> &'static str { +pub(in crate::adapters::cli) fn graph_query_help() -> &'static str { "codebase-graph graph-query\n\nUSAGE:\n codebase-graph graph-query [--repo-root ] [--config ] [--db ] [--manifest ] [--limit ] [--parameters ] [--json]\n\nOPTIONS:\n Restricted read-only Cypher statement\n --parameters JSON object with named query parameters\n --limit Maximum rows to return; defaults to 100 and caps at 1000\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --json Emit JSON output" } diff --git a/src/cli/format/mod.rs b/src/adapters/cli/format/mod.rs similarity index 78% rename from src/cli/format/mod.rs rename to src/adapters/cli/format/mod.rs index 9b543dc..ff1f18d 100644 --- a/src/cli/format/mod.rs +++ b/src/adapters/cli/format/mod.rs @@ -1,8 +1,6 @@ -mod blocks; mod help; -pub(crate) use blocks::serialize_error_block; -pub(in crate::cli) use help::{ +pub(in crate::adapters::cli) use help::{ graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, mcp_help, mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, diff --git a/src/cli/graph/mod.rs b/src/adapters/cli/graph/mod.rs similarity index 100% rename from src/cli/graph/mod.rs rename to src/adapters/cli/graph/mod.rs diff --git a/src/cli/graph/options.rs b/src/adapters/cli/graph/options.rs similarity index 96% rename from src/cli/graph/options.rs rename to src/adapters/cli/graph/options.rs index 542fa50..65c36cb 100644 --- a/src/cli/graph/options.rs +++ b/src/adapters/cli/graph/options.rs @@ -1,6 +1,9 @@ -use crate::cli::{ - format::{graph_health_help, graph_query_help, graph_search_help, metadata_help}, - util::{parse_usize_arg, required_arg}, +use crate::adapters::{ + cli::{ + format::{graph_health_help, graph_query_help, graph_search_help, metadata_help}, + util::parse_usize_arg, + }, + required_arg, }; use std::path::PathBuf; @@ -276,12 +279,12 @@ impl MetadataOutputOptions { #[derive(Debug)] pub(crate) struct ArchitectureQueryOptions { - pub(in crate::cli) output: MetadataOutputOptions, - pub(in crate::cli) group: Option, + pub(in crate::adapters::cli) output: MetadataOutputOptions, + pub(in crate::adapters::cli) group: Option, } impl ArchitectureQueryOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut metadata_args = Vec::new(); let mut group = None; let mut index = 0; @@ -467,13 +470,13 @@ impl GraphSearchOptions { #[derive(Debug)] pub(crate) struct GraphContextOptions { - pub(in crate::cli) search: GraphSearchOptions, - pub(in crate::cli) node_id: Option, - pub(in crate::cli) node_type: Option, + pub(in crate::adapters::cli) search: GraphSearchOptions, + pub(in crate::adapters::cli) node_id: Option, + pub(in crate::adapters::cli) node_type: Option, } impl GraphContextOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut search_args = Vec::new(); let mut node_id = None; let mut node_type = None; diff --git a/src/cli/install/client.rs b/src/adapters/cli/install/client.rs similarity index 98% rename from src/cli/install/client.rs rename to src/adapters/cli/install/client.rs index 5ba750b..1cf7973 100644 --- a/src/cli/install/client.rs +++ b/src/adapters/cli/install/client.rs @@ -7,7 +7,7 @@ use super::{ use serde_json::json; use std::{fs, process::Command}; -pub(in crate::cli) fn install_mcp_client( +pub(in crate::adapters::cli) fn install_mcp_client( options: &McpInstallOptions, ) -> Result { let descriptor = build_mcp_descriptor(options)?; @@ -89,7 +89,7 @@ pub(in crate::cli) fn install_mcp_client( file_adapter_result(options, &descriptor, native_command, native_error) } -pub(in crate::cli) fn file_adapter_result( +pub(in crate::adapters::cli) fn file_adapter_result( options: &McpInstallOptions, descriptor: &NativeMcpDescriptor, native_command: Option>, diff --git a/src/cli/install/command.rs b/src/adapters/cli/install/command.rs similarity index 93% rename from src/cli/install/command.rs rename to src/adapters/cli/install/command.rs index 31def0b..7ea06a6 100644 --- a/src/cli/install/command.rs +++ b/src/adapters/cli/install/command.rs @@ -1,11 +1,11 @@ use super::{ install_mcp_client, install_scope, options::McpInstallOptions, supported_install_clients, }; -use crate::cli::format::mcp_install_help; +use crate::adapters::cli::format::mcp_install_help; use serde_json::json; use std::io::Write; -pub(in crate::cli) fn run_mcp_install( +pub(in crate::adapters::cli) fn run_mcp_install( args: &[String], stdout: &mut W, ) -> Result<(), String> { diff --git a/src/cli/install/descriptor.rs b/src/adapters/cli/install/descriptor.rs similarity index 86% rename from src/cli/install/descriptor.rs rename to src/adapters/cli/install/descriptor.rs index bbf059a..99dad45 100644 --- a/src/cli/install/descriptor.rs +++ b/src/adapters/cli/install/descriptor.rs @@ -1,5 +1,5 @@ use super::{expand_path, McpInstallOptions}; -use crate::cli::{ +use crate::adapters::cli::{ constants::server_command, setup::{safe_name, GraphStatePaths}, util::{read_json_file, resolve_repo_root}, @@ -8,17 +8,17 @@ use serde_json::json; use std::path::Path; #[derive(Debug, Clone)] -pub(in crate::cli) struct NativeMcpDescriptor { - pub(in crate::cli) name: String, - pub(in crate::cli) command: String, - pub(in crate::cli) args: Vec, - pub(in crate::cli) setup_config_path: String, - pub(in crate::cli) repo_root: String, - pub(in crate::cli) timeout: u64, +pub(in crate::adapters::cli) struct NativeMcpDescriptor { + pub(in crate::adapters::cli) name: String, + pub(in crate::adapters::cli) command: String, + pub(in crate::adapters::cli) args: Vec, + pub(in crate::adapters::cli) setup_config_path: String, + pub(in crate::adapters::cli) repo_root: String, + pub(in crate::adapters::cli) timeout: u64, } impl NativeMcpDescriptor { - pub(in crate::cli) fn as_json(&self) -> serde_json::Value { + pub(in crate::adapters::cli) fn as_json(&self) -> serde_json::Value { json!({ "name": self.name, "transport": "stdio", @@ -33,7 +33,7 @@ impl NativeMcpDescriptor { }) } - pub(in crate::cli) fn stdio_entry( + pub(in crate::adapters::cli) fn stdio_entry( &self, include_type: bool, include_timeout: bool, @@ -51,7 +51,7 @@ impl NativeMcpDescriptor { } } -pub(in crate::cli) fn build_mcp_descriptor( +pub(in crate::adapters::cli) fn build_mcp_descriptor( options: &McpInstallOptions, ) -> Result { let resolved_repo_root = resolve_repo_root(options.repo_root.as_deref())?; @@ -128,7 +128,7 @@ pub(in crate::cli) fn build_mcp_descriptor( }) } -pub(in crate::cli) fn install_safe_name(value: &str) -> String { +pub(in crate::adapters::cli) fn install_safe_name(value: &str) -> String { let normalized: String = value .trim() .chars() diff --git a/src/cli/install/fs_util.rs b/src/adapters/cli/install/fs_util.rs similarity index 83% rename from src/cli/install/fs_util.rs rename to src/adapters/cli/install/fs_util.rs index 89130bb..80715f7 100644 --- a/src/cli/install/fs_util.rs +++ b/src/adapters/cli/install/fs_util.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, }; -pub(in crate::cli) fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { +pub(in crate::adapters::cli) fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { format!( @@ -28,7 +28,7 @@ pub(in crate::cli) fn write_text_atomic(path: &Path, text: &str) -> Result<(), S .map_err(|error| format!("failed to replace config {}: {error}", path.display())) } -pub(in crate::cli) fn expand_path(value: &str) -> PathBuf { +pub(in crate::adapters::cli) fn expand_path(value: &str) -> PathBuf { if let Some(rest) = value.strip_prefix("~/") { return home_dir().join(rest); } @@ -42,13 +42,13 @@ pub(in crate::cli) fn expand_path(value: &str) -> PathBuf { } } -pub(in crate::cli) fn home_dir() -> PathBuf { +pub(in crate::adapters::cli) fn home_dir() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } -pub(in crate::cli) fn executable_in_path(executable: &str) -> bool { +pub(in crate::adapters::cli) fn executable_in_path(executable: &str) -> bool { let path = Path::new(executable); if path.components().count() > 1 { return path.is_file(); @@ -58,7 +58,7 @@ pub(in crate::cli) fn executable_in_path(executable: &str) -> bool { .unwrap_or(false) } -pub(in crate::cli) fn subprocess_error(completed: &std::process::Output) -> String { +pub(in crate::adapters::cli) fn subprocess_error(completed: &std::process::Output) -> String { let stdout = String::from_utf8_lossy(&completed.stdout) .trim() .to_string(); diff --git a/src/cli/install/metadata.rs b/src/adapters/cli/install/metadata.rs similarity index 87% rename from src/cli/install/metadata.rs rename to src/adapters/cli/install/metadata.rs index ec1c684..77a8879 100644 --- a/src/cli/install/metadata.rs +++ b/src/adapters/cli/install/metadata.rs @@ -4,7 +4,7 @@ use std::{ path::{Path, PathBuf}, }; -pub(in crate::cli) fn default_client_config_path( +pub(in crate::adapters::cli) fn default_client_config_path( client: &str, scope: &str, descriptor: &NativeMcpDescriptor, @@ -36,7 +36,7 @@ pub(in crate::cli) fn default_client_config_path( } } -pub(in crate::cli) fn supported_install_clients() -> Vec<&'static str> { +pub(in crate::adapters::cli) fn supported_install_clients() -> Vec<&'static str> { vec![ "claude", "claude-project", @@ -51,13 +51,13 @@ pub(in crate::cli) fn supported_install_clients() -> Vec<&'static str> { ] } -pub(in crate::cli) fn supported_install_clients_with_all() -> Vec<&'static str> { +pub(in crate::adapters::cli) fn supported_install_clients_with_all() -> Vec<&'static str> { let mut clients = supported_install_clients(); clients.push("all"); clients } -pub(in crate::cli) fn install_scope(client: &str, scope: &str) -> String { +pub(in crate::adapters::cli) fn install_scope(client: &str, scope: &str) -> String { if client == "claude-project" { "project".to_string() } else { @@ -65,7 +65,7 @@ pub(in crate::cli) fn install_scope(client: &str, scope: &str) -> String { } } -pub(in crate::cli) fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { +pub(in crate::adapters::cli) fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { if client == "claude" && scope == "project" { "claude-project" } else { @@ -73,7 +73,7 @@ pub(in crate::cli) fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { } } -pub(in crate::cli) fn native_client_command( +pub(in crate::adapters::cli) fn native_client_command( client: &str, descriptor: &NativeMcpDescriptor, scope: &str, diff --git a/src/adapters/cli/install/mod.rs b/src/adapters/cli/install/mod.rs new file mode 100644 index 0000000..d6291cc --- /dev/null +++ b/src/adapters/cli/install/mod.rs @@ -0,0 +1,22 @@ +mod client; +mod command; +mod descriptor; +mod fs_util; +mod metadata; +mod options; +mod render; +mod verify; + +pub(in crate::adapters::cli) use client::install_mcp_client; +pub(in crate::adapters::cli) use command::run_mcp_install; +pub(in crate::adapters::cli) use descriptor::{build_mcp_descriptor, NativeMcpDescriptor}; +pub(in crate::adapters::cli) use fs_util::{ + executable_in_path, expand_path, home_dir, subprocess_error, write_text_atomic, +}; +pub(in crate::adapters::cli) use metadata::{ + adapter_id, default_client_config_path, install_scope, native_client_command, + supported_install_clients, supported_install_clients_with_all, +}; +pub(in crate::adapters::cli) use options::McpInstallOptions; +pub(in crate::adapters::cli) use render::{copilot_studio_metadata, render_client_config}; +pub(in crate::adapters::cli) use verify::attach_install_verification; diff --git a/src/cli/install/options.rs b/src/adapters/cli/install/options.rs similarity index 83% rename from src/cli/install/options.rs rename to src/adapters/cli/install/options.rs index e982ba9..ee15e3a 100644 --- a/src/cli/install/options.rs +++ b/src/adapters/cli/install/options.rs @@ -1,23 +1,23 @@ use super::{expand_path, supported_install_clients, supported_install_clients_with_all}; -use crate::cli::{format::mcp_install_help, util::required_arg}; +use crate::adapters::{cli::format::mcp_install_help, required_arg}; use std::path::PathBuf; #[derive(Debug, Clone)] -pub(in crate::cli) struct McpInstallOptions { - pub(in crate::cli) client: String, - pub(in crate::cli) scope: String, - pub(in crate::cli) name: Option, - pub(in crate::cli) config_path: Option, - pub(in crate::cli) client_config_path: Option, - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) dry_run: bool, - pub(in crate::cli) verify: bool, - pub(in crate::cli) json: bool, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct McpInstallOptions { + pub(in crate::adapters::cli) client: String, + pub(in crate::adapters::cli) scope: String, + pub(in crate::adapters::cli) name: Option, + pub(in crate::adapters::cli) config_path: Option, + pub(in crate::adapters::cli) client_config_path: Option, + pub(in crate::adapters::cli) repo_root: Option, + pub(in crate::adapters::cli) dry_run: bool, + pub(in crate::adapters::cli) verify: bool, + pub(in crate::adapters::cli) json: bool, + pub(in crate::adapters::cli) help: bool, } impl McpInstallOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut options = Self { client: "codex".to_string(), scope: "local".to_string(), diff --git a/src/cli/install/render.rs b/src/adapters/cli/install/render.rs similarity index 87% rename from src/cli/install/render.rs rename to src/adapters/cli/install/render.rs index 048f55d..084f96b 100644 --- a/src/cli/install/render.rs +++ b/src/adapters/cli/install/render.rs @@ -1,15 +1,15 @@ use super::{adapter_id, NativeMcpDescriptor}; use serde_json::json; -pub(in crate::cli) struct RenderedNativeConfig { - pub(in crate::cli) text: String, - pub(in crate::cli) action: String, - pub(in crate::cli) entry: serde_json::Value, - pub(in crate::cli) patch: serde_json::Value, - pub(in crate::cli) payload: serde_json::Value, +pub(in crate::adapters::cli) struct RenderedNativeConfig { + pub(in crate::adapters::cli) text: String, + pub(in crate::adapters::cli) action: String, + pub(in crate::adapters::cli) entry: serde_json::Value, + pub(in crate::adapters::cli) patch: serde_json::Value, + pub(in crate::adapters::cli) payload: serde_json::Value, } -pub(in crate::cli) fn render_client_config( +pub(in crate::adapters::cli) fn render_client_config( client: &str, scope: &str, existing: Option<&str>, @@ -25,7 +25,7 @@ pub(in crate::cli) fn render_client_config( } } -pub(in crate::cli) fn render_json_config( +pub(in crate::adapters::cli) fn render_json_config( adapter: &str, existing: Option<&str>, descriptor: &NativeMcpDescriptor, @@ -64,7 +64,7 @@ pub(in crate::cli) fn render_json_config( }) } -pub(in crate::cli) fn render_codex_config( +pub(in crate::adapters::cli) fn render_codex_config( existing: Option<&str>, descriptor: &NativeMcpDescriptor, ) -> Result { @@ -90,7 +90,7 @@ pub(in crate::cli) fn render_codex_config( }) } -pub(in crate::cli) fn render_hermes_config( +pub(in crate::adapters::cli) fn render_hermes_config( existing: Option<&str>, descriptor: &NativeMcpDescriptor, ) -> Result { @@ -115,7 +115,7 @@ pub(in crate::cli) fn render_hermes_config( }) } -pub(in crate::cli) fn json_container_mut<'a>( +pub(in crate::adapters::cli) fn json_container_mut<'a>( payload: &'a mut serde_json::Value, path: &[&str], ) -> Result<&'a mut serde_json::Map, String> { @@ -133,7 +133,7 @@ pub(in crate::cli) fn json_container_mut<'a>( Ok(cursor) } -pub(in crate::cli) fn action_for_json( +pub(in crate::adapters::cli) fn action_for_json( previous: Option<&serde_json::Value>, next_value: &serde_json::Value, file_exists: bool, @@ -147,7 +147,7 @@ pub(in crate::cli) fn action_for_json( } } -pub(in crate::cli) fn copilot_studio_metadata( +pub(in crate::adapters::cli) fn copilot_studio_metadata( descriptor: &NativeMcpDescriptor, ) -> serde_json::Value { json!({ @@ -179,7 +179,7 @@ pub(in crate::cli) fn copilot_studio_metadata( }) } -pub(in crate::cli) fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { +pub(in crate::adapters::cli) fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { format!( "[mcp_servers.{}]\ncommand = {}\nargs = {}\nstartup_timeout_sec = {}\n", descriptor.name, @@ -189,7 +189,7 @@ pub(in crate::cli) fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> Stri ) } -pub(in crate::cli) fn toml_array(values: &[String]) -> String { +pub(in crate::adapters::cli) fn toml_array(values: &[String]) -> String { format!( "[{}]", values @@ -200,11 +200,11 @@ pub(in crate::cli) fn toml_array(values: &[String]) -> String { ) } -pub(in crate::cli) fn toml_string(value: &str) -> String { +pub(in crate::adapters::cli) fn toml_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } -pub(in crate::cli) fn upsert_toml_block( +pub(in crate::adapters::cli) fn upsert_toml_block( existing: &str, server_name: &str, block: &str, @@ -242,7 +242,7 @@ pub(in crate::cli) fn upsert_toml_block( ) } -pub(in crate::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { +pub(in crate::adapters::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { let mut lines = vec![ "# codebaseGraph MCP server start".to_string(), "mcp_servers:".to_string(), @@ -258,11 +258,14 @@ pub(in crate::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> Str lines.join("\n") + "\n" } -pub(in crate::cli) fn yaml_scalar(value: &str) -> String { +pub(in crate::adapters::cli) fn yaml_scalar(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } -pub(in crate::cli) fn upsert_marked_block(existing: &str, block: &str) -> (String, Option) { +pub(in crate::adapters::cli) fn upsert_marked_block( + existing: &str, + block: &str, +) -> (String, Option) { const START: &str = "# codebaseGraph MCP server start"; const END: &str = "# codebaseGraph MCP server end"; let Some(start) = existing.find(START) else { diff --git a/src/cli/install/verify.rs b/src/adapters/cli/install/verify.rs similarity index 89% rename from src/cli/install/verify.rs rename to src/adapters/cli/install/verify.rs index 6923e8e..962e6a2 100644 --- a/src/cli/install/verify.rs +++ b/src/adapters/cli/install/verify.rs @@ -1,5 +1,5 @@ use super::{executable_in_path, McpInstallOptions, NativeMcpDescriptor}; -use crate::cli::constants::LATEST_PROTOCOL_VERSION; +use crate::adapters::mcp::LATEST_PROTOCOL_VERSION; use serde_json::json; use std::{ collections::{BTreeMap, BTreeSet}, @@ -7,7 +7,7 @@ use std::{ process::Command, }; -pub(in crate::cli) fn attach_install_verification( +pub(in crate::adapters::cli) fn attach_install_verification( mut payload: serde_json::Value, descriptor: &NativeMcpDescriptor, options: &McpInstallOptions, @@ -18,7 +18,7 @@ pub(in crate::cli) fn attach_install_verification( Ok(payload) } -pub(in crate::cli) fn verify_mcp_install( +pub(in crate::adapters::cli) fn verify_mcp_install( descriptor: &NativeMcpDescriptor, client: &str, ) -> serde_json::Value { @@ -32,7 +32,9 @@ pub(in crate::cli) fn verify_mcp_install( }) } -pub(in crate::cli) fn verify_stdio(descriptor: &NativeMcpDescriptor) -> serde_json::Value { +pub(in crate::adapters::cli) fn verify_stdio( + descriptor: &NativeMcpDescriptor, +) -> serde_json::Value { let command = descriptor_command(descriptor); let payload = [ stdio_json_rpc_message( @@ -97,7 +99,7 @@ pub(in crate::cli) fn verify_stdio(descriptor: &NativeMcpDescriptor) -> serde_js }) } -pub(in crate::cli) fn verify_client_visibility( +pub(in crate::adapters::cli) fn verify_client_visibility( client: &str, server_name: &str, ) -> serde_json::Value { @@ -125,13 +127,15 @@ pub(in crate::cli) fn verify_client_visibility( } } -pub(in crate::cli) fn descriptor_command(descriptor: &NativeMcpDescriptor) -> Vec { +pub(in crate::adapters::cli) fn descriptor_command( + descriptor: &NativeMcpDescriptor, +) -> Vec { let mut command = vec![descriptor.command.clone()]; command.extend(descriptor.args.clone()); command } -pub(in crate::cli) fn stdio_json_rpc_message( +pub(in crate::adapters::cli) fn stdio_json_rpc_message( method: &str, params: serde_json::Value, id: u64, @@ -146,14 +150,16 @@ pub(in crate::cli) fn stdio_json_rpc_message( + "\n" } -pub(in crate::cli) fn parse_stdio_json_lines(data: &[u8]) -> Vec { +pub(in crate::adapters::cli) fn parse_stdio_json_lines(data: &[u8]) -> Vec { String::from_utf8_lossy(data) .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .collect() } -pub(in crate::cli) fn stdio_checks(responses: &[serde_json::Value]) -> BTreeMap { +pub(in crate::adapters::cli) fn stdio_checks( + responses: &[serde_json::Value], +) -> BTreeMap { let mut by_id = BTreeMap::new(); for response in responses { if let Some(id) = response.get("id").and_then(serde_json::Value::as_u64) { @@ -208,7 +214,7 @@ pub(in crate::cli) fn stdio_checks(responses: &[serde_json::Value]) -> BTreeMap< checks } -pub(in crate::cli) fn visibility_command(client: &str) -> Option> { +pub(in crate::adapters::cli) fn visibility_command(client: &str) -> Option> { match client { "codex" => Some(vec![ "codex".to_string(), diff --git a/src/cli/materialization/command.rs b/src/adapters/cli/materialization/command.rs similarity index 90% rename from src/cli/materialization/command.rs rename to src/adapters/cli/materialization/command.rs index c7a5ae9..0087133 100644 --- a/src/cli/materialization/command.rs +++ b/src/adapters/cli/materialization/command.rs @@ -1,3 +1,4 @@ +use crate::adapters::cli::format::{materialize_help, plan_help}; use crate::api::{ contracts::{MaterializationRequest, OperationRequest, OutputFormat}, materialization::{ @@ -5,10 +6,9 @@ use crate::api::{ }, CodebaseGraphApi, }; -use crate::cli::format::{materialize_help, plan_help}; use std::io::Write; -pub(in crate::cli) fn run_materialize( +pub(in crate::adapters::cli) fn run_materialize( args: &[String], stdout: &mut W, ) -> Result<(), String> { @@ -28,7 +28,10 @@ pub(in crate::cli) fn run_materialize( Ok(()) } -pub(in crate::cli) fn run_plan(args: &[String], stdout: &mut W) -> Result<(), String> { +pub(in crate::adapters::cli) fn run_plan( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { let options = MaterializeOptions::parse_with_command(args, "plan")?; if options.help { writeln!(stdout, "{}", plan_help()).map_err(|error| error.to_string())?; diff --git a/src/cli/materialization/mod.rs b/src/adapters/cli/materialization/mod.rs similarity index 56% rename from src/cli/materialization/mod.rs rename to src/adapters/cli/materialization/mod.rs index 243a738..4536178 100644 --- a/src/cli/materialization/mod.rs +++ b/src/adapters/cli/materialization/mod.rs @@ -2,4 +2,4 @@ mod command; mod request; pub(crate) use crate::api::materialization::MaterializeOptions; -pub(in crate::cli) use command::{run_materialize, run_plan}; +pub(in crate::adapters::cli) use command::{run_materialize, run_plan}; diff --git a/src/cli/materialization/request.rs b/src/adapters/cli/materialization/request.rs similarity index 96% rename from src/cli/materialization/request.rs rename to src/adapters/cli/materialization/request.rs index 0c530ff..3aa487f 100644 --- a/src/cli/materialization/request.rs +++ b/src/adapters/cli/materialization/request.rs @@ -1,5 +1,5 @@ +use crate::adapters::cli::format::{materialize_help, plan_help, watch_help}; use crate::api::materialization::MaterializeOptions; -use crate::cli::format::{materialize_help, plan_help, watch_help}; use std::path::PathBuf; impl MaterializeOptions { @@ -130,7 +130,7 @@ impl MaterializeOptions { } } -pub(in crate::cli) fn materialize_like_help(command_name: &str) -> &'static str { +pub(in crate::adapters::cli) fn materialize_like_help(command_name: &str) -> &'static str { match command_name { "plan" => plan_help(), "watch" => watch_help(), diff --git a/src/cli/mcp/commands.rs b/src/adapters/cli/mcp_command.rs similarity index 86% rename from src/cli/mcp/commands.rs rename to src/adapters/cli/mcp_command.rs index 00a66af..0fda4fe 100644 --- a/src/cli/mcp/commands.rs +++ b/src/adapters/cli/mcp_command.rs @@ -1,7 +1,7 @@ -use crate::cli::{format::mcp_help, install::run_mcp_install}; +use super::{format::mcp_help, install::run_mcp_install}; use std::io::Write; -pub(in crate::cli) fn run_mcp_command( +pub(in crate::adapters::cli) fn run_mcp_command( args: &[String], stdout: &mut W, ) -> Result<(), String> { diff --git a/src/cli/mod.rs b/src/adapters/cli/mod.rs similarity index 95% rename from src/cli/mod.rs rename to src/adapters/cli/mod.rs index bf36879..7d83467 100644 --- a/src/cli/mod.rs +++ b/src/adapters/cli/mod.rs @@ -4,7 +4,7 @@ pub(crate) mod format; pub(crate) mod graph; mod install; pub(crate) mod materialization; -mod mcp; +mod mcp_command; pub(crate) mod reinstall; pub(crate) mod setup; pub(crate) mod uninstall; diff --git a/src/cli/reinstall.rs b/src/adapters/cli/reinstall.rs similarity index 96% rename from src/cli/reinstall.rs rename to src/adapters/cli/reinstall.rs index 08f37ba..8824e41 100644 --- a/src/cli/reinstall.rs +++ b/src/adapters/cli/reinstall.rs @@ -5,7 +5,7 @@ use crate::api::{ }; use std::io::Write; -pub(in crate::cli) fn run_reinstall( +pub(in crate::adapters::cli) fn run_reinstall( args: &[String], stdout: &mut W, ) -> Result<(), String> { diff --git a/src/cli/setup.rs b/src/adapters/cli/setup.rs similarity index 100% rename from src/cli/setup.rs rename to src/adapters/cli/setup.rs diff --git a/src/cli/tests/dispatch_materialize.rs b/src/adapters/cli/tests/dispatch_materialize.rs similarity index 100% rename from src/cli/tests/dispatch_materialize.rs rename to src/adapters/cli/tests/dispatch_materialize.rs diff --git a/src/cli/tests/fixtures.rs b/src/adapters/cli/tests/fixtures.rs similarity index 100% rename from src/cli/tests/fixtures.rs rename to src/adapters/cli/tests/fixtures.rs diff --git a/src/cli/tests/graph.rs b/src/adapters/cli/tests/graph.rs similarity index 100% rename from src/cli/tests/graph.rs rename to src/adapters/cli/tests/graph.rs diff --git a/src/cli/tests/install.rs b/src/adapters/cli/tests/install.rs similarity index 100% rename from src/cli/tests/install.rs rename to src/adapters/cli/tests/install.rs diff --git a/src/cli/tests/mcp.rs b/src/adapters/cli/tests/mcp.rs similarity index 96% rename from src/cli/tests/mcp.rs rename to src/adapters/cli/tests/mcp.rs index c8e9df8..7c0b0ab 100644 --- a/src/cli/tests/mcp.rs +++ b/src/adapters/cli/tests/mcp.rs @@ -166,16 +166,22 @@ fn mcp_stdio_serves_tools_and_tool_errors() { #[test] fn mcp_http_rejects_remote_bind_without_auth_token() { - let error = McpHttpOptions::parse(&[ - "--host".to_string(), - "0.0.0.0".to_string(), - "--allow-remote".to_string(), - ]) + let error = McpHttpOptions::parse( + &[ + "--host".to_string(), + "0.0.0.0".to_string(), + "--allow-remote".to_string(), + ], + format::mcp_help(), + ) .unwrap_err(); assert!(error.contains("auth token")); - let local_error = - McpHttpOptions::parse(&["--host".to_string(), "0.0.0.0".to_string()]).unwrap_err(); + let local_error = McpHttpOptions::parse( + &["--host".to_string(), "0.0.0.0".to_string()], + format::mcp_help(), + ) + .unwrap_err(); assert!(local_error.contains("localhost")); } diff --git a/src/cli/tests/mod.rs b/src/adapters/cli/tests/mod.rs similarity index 82% rename from src/cli/tests/mod.rs rename to src/adapters/cli/tests/mod.rs index c23117c..7a628cc 100644 --- a/src/cli/tests/mod.rs +++ b/src/adapters/cli/tests/mod.rs @@ -1,5 +1,6 @@ use super::*; -use super::{install::*, materialization::*, mcp::*, watch::*}; +use super::{materialization::*, watch::*}; +use crate::adapters::mcp::*; use notify::{Event, EventKind}; use serde_json::json; use std::{ diff --git a/src/cli/tests/watch.rs b/src/adapters/cli/tests/watch.rs similarity index 100% rename from src/cli/tests/watch.rs rename to src/adapters/cli/tests/watch.rs diff --git a/src/cli/uninstall.rs b/src/adapters/cli/uninstall.rs similarity index 97% rename from src/cli/uninstall.rs rename to src/adapters/cli/uninstall.rs index 6bbe9be..75628c4 100644 --- a/src/cli/uninstall.rs +++ b/src/adapters/cli/uninstall.rs @@ -1,8 +1,8 @@ use super::{ format::uninstall_help, install::{supported_install_clients, supported_install_clients_with_all}, - util::required_arg, }; +use crate::adapters::required_arg; use crate::api::{ contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, CodebaseGraphApi, @@ -10,7 +10,7 @@ use crate::api::{ use std::{io::Write, path::PathBuf}; #[derive(Debug)] -pub(in crate::cli) struct UninstallOptions { +pub(in crate::adapters::cli) struct UninstallOptions { repo_root: Option, config: Option, mcp_client: String, @@ -89,7 +89,7 @@ impl UninstallOptions { } } -pub(in crate::cli) fn run_uninstall( +pub(in crate::adapters::cli) fn run_uninstall( args: &[String], stdout: &mut W, ) -> Result<(), String> { diff --git a/src/cli/util.rs b/src/adapters/cli/util.rs similarity index 95% rename from src/cli/util.rs rename to src/adapters/cli/util.rs index 0b9500b..be9adc8 100644 --- a/src/cli/util.rs +++ b/src/adapters/cli/util.rs @@ -55,18 +55,8 @@ fn discover_git_root(start: &Path) -> Option { .map(Path::to_path_buf) } -pub(super) fn required_arg<'a>( - args: &'a [String], - index: usize, - name: &str, -) -> Result<&'a str, String> { - args.get(index + 1) - .map(String::as_str) - .ok_or_else(|| format!("{name} requires a value")) -} - pub(super) fn parse_usize_arg(args: &[String], index: usize, name: &str) -> Result { - required_arg(args, index, name)? + crate::adapters::required_arg(args, index, name)? .parse::() .map_err(|error| format!("{name} must be an integer: {error}")) } diff --git a/src/cli/watch/command.rs b/src/adapters/cli/watch/command.rs similarity index 95% rename from src/cli/watch/command.rs rename to src/adapters/cli/watch/command.rs index 431fa39..4cfb03a 100644 --- a/src/cli/watch/command.rs +++ b/src/adapters/cli/watch/command.rs @@ -12,7 +12,10 @@ use crate::{ }; use std::{io::Write, time::Duration}; -pub(in crate::cli) fn run_watch(args: &[String], stdout: &mut W) -> Result<(), String> { +pub(in crate::adapters::cli) fn run_watch( + args: &[String], + stdout: &mut W, +) -> Result<(), String> { let options = WatchOptions::parse(args)?; if options.help { writeln!(stdout, "{}", watch_help()).map_err(|error| error.to_string())?; diff --git a/src/cli/watch/mod.rs b/src/adapters/cli/watch/mod.rs similarity index 75% rename from src/cli/watch/mod.rs rename to src/adapters/cli/watch/mod.rs index c594532..d04eb85 100644 --- a/src/cli/watch/mod.rs +++ b/src/adapters/cli/watch/mod.rs @@ -2,8 +2,8 @@ mod command; mod options; mod output; -pub(in crate::cli) use command::run_watch; -pub(in crate::cli) use options::SetupOptions; +pub(in crate::adapters::cli) use command::run_watch; +pub(in crate::adapters::cli) use options::SetupOptions; #[cfg(test)] pub(super) use crate::api::refresh::{ diff --git a/src/cli/watch/options.rs b/src/adapters/cli/watch/options.rs similarity index 83% rename from src/cli/watch/options.rs rename to src/adapters/cli/watch/options.rs index 4b1a602..34f9c7c 100644 --- a/src/cli/watch/options.rs +++ b/src/adapters/cli/watch/options.rs @@ -1,28 +1,28 @@ -use crate::cli::{ +use crate::adapters::cli::{ format::setup_help, install::supported_install_clients, materialization::MaterializeOptions, }; use std::path::PathBuf; #[derive(Debug)] -pub(in crate::cli) struct WatchOptions { - pub(in crate::cli) materialize: MaterializeOptions, - pub(in crate::cli) backend: WatchBackend, - pub(in crate::cli) poll_ms: u64, - pub(in crate::cli) debounce_ms: u64, - pub(in crate::cli) max_iterations: Option, - pub(in crate::cli) once: bool, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct WatchOptions { + pub(in crate::adapters::cli) materialize: MaterializeOptions, + pub(in crate::adapters::cli) backend: WatchBackend, + pub(in crate::adapters::cli) poll_ms: u64, + pub(in crate::adapters::cli) debounce_ms: u64, + pub(in crate::adapters::cli) max_iterations: Option, + pub(in crate::adapters::cli) once: bool, + pub(in crate::adapters::cli) help: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::cli) enum WatchBackend { +pub(in crate::adapters::cli) enum WatchBackend { Auto, Native, Poll, } impl WatchBackend { - pub(in crate::cli) fn parse(value: &str) -> Result { + pub(in crate::adapters::cli) fn parse(value: &str) -> Result { match value { "auto" => Ok(Self::Auto), "native" => Ok(Self::Native), @@ -33,7 +33,7 @@ impl WatchBackend { } impl WatchOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { let mut materialize_args = Vec::new(); let mut backend = WatchBackend::Auto; let mut poll_ms = 500_u64; @@ -105,26 +105,26 @@ impl WatchOptions { } #[derive(Debug)] -pub(in crate::cli) struct SetupOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) mode: String, - pub(in crate::cli) include_fts: bool, - pub(in crate::cli) semantic_enrichment: bool, - pub(in crate::cli) semantic_provider_mode: String, - pub(in crate::cli) mcp_client: String, - pub(in crate::cli) mcp_config_path: Option, - pub(in crate::cli) skip_mcp_config: bool, - pub(in crate::cli) dry_run: bool, - pub(in crate::cli) instructions_target: String, - pub(in crate::cli) help: bool, +pub(in crate::adapters::cli) struct SetupOptions { + pub(in crate::adapters::cli) repo_root: Option, + pub(in crate::adapters::cli) mode: String, + pub(in crate::adapters::cli) include_fts: bool, + pub(in crate::adapters::cli) semantic_enrichment: bool, + pub(in crate::adapters::cli) semantic_provider_mode: String, + pub(in crate::adapters::cli) mcp_client: String, + pub(in crate::adapters::cli) mcp_config_path: Option, + pub(in crate::adapters::cli) skip_mcp_config: bool, + pub(in crate::adapters::cli) dry_run: bool, + pub(in crate::adapters::cli) instructions_target: String, + pub(in crate::adapters::cli) help: bool, } impl SetupOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters::cli) fn parse(args: &[String]) -> Result { Self::parse_with_help(args, "install", setup_help()) } - pub(in crate::cli) fn parse_with_help( + pub(in crate::adapters::cli) fn parse_with_help( args: &[String], command_name: &str, help: &str, diff --git a/src/cli/watch/output.rs b/src/adapters/cli/watch/output.rs similarity index 90% rename from src/cli/watch/output.rs rename to src/adapters/cli/watch/output.rs index 27470c8..872480a 100644 --- a/src/cli/watch/output.rs +++ b/src/adapters/cli/watch/output.rs @@ -1,7 +1,7 @@ use crate::protocol::NativeSyntaxMaterializationResponse; use std::io::Write; -pub(in crate::cli) fn write_watch_event( +pub(in crate::adapters::cli) fn write_watch_event( stdout: &mut W, event: &str, backend: Option<&str>, @@ -27,7 +27,7 @@ pub(in crate::cli) fn write_watch_event( .map_err(|error| error.to_string()) } -pub(in crate::cli) fn write_watch_status( +pub(in crate::adapters::cli) fn write_watch_status( stdout: &mut W, event: &str, backend: &str, diff --git a/src/cli/format/blocks.rs b/src/adapters/mcp/block.rs similarity index 92% rename from src/cli/format/blocks.rs rename to src/adapters/mcp/block.rs index 03c7f8a..cf6b63c 100644 --- a/src/cli/format/blocks.rs +++ b/src/adapters/mcp/block.rs @@ -1,4 +1,4 @@ -pub(crate) fn serialize_error_block(payload: &serde_json::Value) -> String { +pub(super) fn serialize_error_block(payload: &serde_json::Value) -> String { let error = payload.get("error").unwrap_or(payload); format!( "error tool={} type={} message={}\n", diff --git a/src/cli/mcp/http.rs b/src/adapters/mcp/http.rs similarity index 87% rename from src/cli/mcp/http.rs rename to src/adapters/mcp/http.rs index f167dde..38381be 100644 --- a/src/cli/mcp/http.rs +++ b/src/adapters/mcp/http.rs @@ -2,8 +2,8 @@ use super::{ options::McpHttpOptions, protocol::{handle_mcp_message, is_supported_protocol_version, parse_mcp_payload, rpc_error}, refresh::start_auto_refresh, + state::McpHttpState, }; -use crate::cli::{constants::MAX_HTTP_BODY_BYTES, install::McpHttpState}; use serde_json::json; use std::{ collections::BTreeMap, @@ -11,14 +11,16 @@ use std::{ net::{TcpListener, TcpStream}, }; -pub(in crate::cli) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { +const MAX_HTTP_BODY_BYTES: usize = 1_000_000; + +pub(in crate::adapters) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { let listener = options.bind_listener()?; let mut options = options.clone(); options.serve.refresh = Some(start_auto_refresh(&options.serve)); serve_mcp_http_listener(&options, listener, None) } -pub(in crate::cli) fn serve_mcp_http_listener( +pub(in crate::adapters) fn serve_mcp_http_listener( options: &McpHttpOptions, listener: TcpListener, max_requests: Option, @@ -45,7 +47,7 @@ pub(in crate::cli) fn serve_mcp_http_listener( Ok(()) } -pub(in crate::cli) fn handle_mcp_http_stream( +pub(in crate::adapters) fn handle_mcp_http_stream( options: &McpHttpOptions, state: &mut McpHttpState, stream: &mut TcpStream, @@ -60,7 +62,7 @@ pub(in crate::cli) fn handle_mcp_http_stream( ) } -pub(in crate::cli) fn handle_mcp_http_request( +pub(in crate::adapters) fn handle_mcp_http_request( options: &McpHttpOptions, state: &mut McpHttpState, request: HttpRequest, @@ -194,14 +196,14 @@ pub(in crate::cli) fn handle_mcp_http_request( } } -pub(in crate::cli) fn valid_http_origin(origin: Option<&str>) -> bool { +pub(in crate::adapters) fn valid_http_origin(origin: Option<&str>) -> bool { match origin.and_then(http_origin_host) { None => true, Some(host) => matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"), } } -pub(in crate::cli) fn http_origin_host(origin: &str) -> Option { +pub(in crate::adapters) fn http_origin_host(origin: &str) -> Option { let after_scheme = origin .split_once("://") .map(|(_, rest)| rest) @@ -220,7 +222,9 @@ pub(in crate::cli) fn http_origin_host(origin: &str) -> Option { } } -pub(in crate::cli) fn read_http_request(stream: &mut TcpStream) -> Result { +pub(in crate::adapters) fn read_http_request( + stream: &mut TcpStream, +) -> Result { let mut buffer = Vec::new(); let mut chunk = [0_u8; 1024]; let header_end = loop { @@ -292,11 +296,11 @@ pub(in crate::cli) fn read_http_request(stream: &mut TcpStream) -> Result Option { +pub(in crate::adapters) fn find_header_end(buffer: &[u8]) -> Option { buffer.windows(4).position(|window| window == b"\r\n\r\n") } -pub(in crate::cli) fn write_http_json( +pub(in crate::adapters) fn write_http_json( stream: &mut TcpStream, status: u16, payload: &serde_json::Value, @@ -322,7 +326,7 @@ pub(in crate::cli) fn write_http_json( stream.flush().map_err(|error| error.to_string()) } -pub(in crate::cli) fn http_reason(status: u16) -> &'static str { +pub(in crate::adapters) fn http_reason(status: u16) -> &'static str { match status { 200 => "OK", 202 => "Accepted", @@ -337,16 +341,16 @@ pub(in crate::cli) fn http_reason(status: u16) -> &'static str { } #[derive(Debug)] -pub(in crate::cli) struct HttpRequest { - pub(in crate::cli) method: String, - pub(in crate::cli) path: String, - pub(in crate::cli) headers: BTreeMap, - pub(in crate::cli) body: Vec, - pub(in crate::cli) body_too_large: bool, +pub(in crate::adapters) struct HttpRequest { + pub(in crate::adapters) method: String, + pub(in crate::adapters) path: String, + pub(in crate::adapters) headers: BTreeMap, + pub(in crate::adapters) body: Vec, + pub(in crate::adapters) body_too_large: bool, } impl HttpRequest { - pub(in crate::cli) fn header(&self, name: &str) -> Option<&str> { + pub(in crate::adapters) fn header(&self, name: &str) -> Option<&str> { self.headers .get(&name.to_ascii_lowercase()) .map(String::as_str) @@ -354,14 +358,14 @@ impl HttpRequest { } #[derive(Debug)] -pub(in crate::cli) struct HttpResponse { - pub(in crate::cli) status: u16, - pub(in crate::cli) payload: serde_json::Value, - pub(in crate::cli) headers: Vec<(String, String)>, +pub(in crate::adapters) struct HttpResponse { + pub(in crate::adapters) status: u16, + pub(in crate::adapters) payload: serde_json::Value, + pub(in crate::adapters) headers: Vec<(String, String)>, } impl HttpResponse { - pub(in crate::cli) fn json(status: u16, payload: serde_json::Value) -> Self { + pub(in crate::adapters) fn json(status: u16, payload: serde_json::Value) -> Self { Self { status, payload, @@ -370,6 +374,6 @@ impl HttpResponse { } } -pub(in crate::cli) fn is_local_host(host: &str) -> bool { +pub(in crate::adapters) fn is_local_host(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "::1") } diff --git a/src/adapters/mcp/mod.rs b/src/adapters/mcp/mod.rs new file mode 100644 index 0000000..fcd1e1b --- /dev/null +++ b/src/adapters/mcp/mod.rs @@ -0,0 +1,20 @@ +mod block; +mod http; +mod options; +mod protocol; +mod refresh; +mod state; +mod stdio; +mod tools; + +pub(in crate::adapters) use http::serve_mcp_http; +pub(in crate::adapters) use options::{McpHttpOptions, McpServeOptions}; +pub(in crate::adapters) use protocol::{McpSession, LATEST_PROTOCOL_VERSION}; +pub(in crate::adapters) use stdio::serve_mcp_stdio; + +#[cfg(test)] +pub(super) use http::{handle_mcp_http_request, HttpRequest}; +#[cfg(test)] +pub(super) use state::McpHttpState; +#[cfg(test)] +pub(super) use tools::mcp_call_tool_result; diff --git a/src/cli/mcp/options.rs b/src/adapters/mcp/options.rs similarity index 80% rename from src/cli/mcp/options.rs rename to src/adapters/mcp/options.rs index ac1bd97..20b6737 100644 --- a/src/cli/mcp/options.rs +++ b/src/adapters/mcp/options.rs @@ -1,19 +1,19 @@ use super::http::is_local_host; use super::refresh::McpRefreshState; -use crate::cli::{format::mcp_help, util::required_arg}; +use crate::adapters::required_arg; use std::{env, net::TcpListener, path::PathBuf, sync::Arc}; #[derive(Clone, Debug)] -pub(in crate::cli) struct McpServeOptions { - pub(in crate::cli) repo_root: Option, - pub(in crate::cli) config: Option, - pub(in crate::cli) db: Option, - pub(in crate::cli) manifest: Option, - pub(in crate::cli) refresh: Option>, +pub(in crate::adapters) struct McpServeOptions { + pub(in crate::adapters) repo_root: Option, + pub(in crate::adapters) config: Option, + pub(in crate::adapters) db: Option, + pub(in crate::adapters) manifest: Option, + pub(in crate::adapters) refresh: Option>, } impl McpServeOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { repo_root: None, config: None, @@ -43,10 +43,7 @@ impl McpServeOptions { index += 2; } other => { - return Err(format!( - "unknown mcp start option: {other}\n\n{}", - mcp_help() - )); + return Err(format!("unknown mcp start option: {other}\n\n{help}")); } } } @@ -55,17 +52,17 @@ impl McpServeOptions { } #[derive(Clone, Debug)] -pub(in crate::cli) struct McpHttpOptions { - pub(in crate::cli) serve: McpServeOptions, - pub(in crate::cli) host: String, - pub(in crate::cli) port: u16, - pub(in crate::cli) endpoint_path: String, - pub(in crate::cli) allow_remote: bool, - pub(in crate::cli) auth_token: Option, +pub(in crate::adapters) struct McpHttpOptions { + pub(in crate::adapters) serve: McpServeOptions, + pub(in crate::adapters) host: String, + pub(in crate::adapters) port: u16, + pub(in crate::adapters) endpoint_path: String, + pub(in crate::adapters) allow_remote: bool, + pub(in crate::adapters) auth_token: Option, } impl McpHttpOptions { - pub(in crate::cli) fn parse(args: &[String]) -> Result { + pub(in crate::adapters) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { serve: McpServeOptions { repo_root: None, @@ -137,10 +134,7 @@ impl McpHttpOptions { index += 2; } other => { - return Err(format!( - "unknown mcp http option: {other}\n\n{}", - mcp_help() - )); + return Err(format!("unknown mcp http option: {other}\n\n{help}")); } } } @@ -148,7 +142,7 @@ impl McpHttpOptions { Ok(options) } - pub(in crate::cli) fn validate(&self) -> Result<(), String> { + pub(in crate::adapters) fn validate(&self) -> Result<(), String> { if self .auth_token .as_deref() @@ -168,7 +162,7 @@ impl McpHttpOptions { Ok(()) } - pub(in crate::cli) fn bind_listener(&self) -> Result { + pub(in crate::adapters) fn bind_listener(&self) -> Result { self.validate()?; TcpListener::bind((self.host.as_str(), self.port)) .map_err(|error| format!("failed to bind MCP HTTP server: {error}")) diff --git a/src/cli/mcp/protocol.rs b/src/adapters/mcp/protocol.rs similarity index 84% rename from src/cli/mcp/protocol.rs rename to src/adapters/mcp/protocol.rs index af2eeef..51b48a8 100644 --- a/src/cli/mcp/protocol.rs +++ b/src/adapters/mcp/protocol.rs @@ -1,9 +1,10 @@ +use super::tools::generate_mcp_specs; use super::{options::McpServeOptions, tools::mcp_call_tool_result}; -use crate::cli::constants::LATEST_PROTOCOL_VERSION; -use crate::cli::mcp::tools::generate_mcp_specs; use serde_json::json; -pub(in crate::cli) fn handle_mcp_message( +pub(in crate::adapters) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; + +pub(in crate::adapters) fn handle_mcp_message( message: serde_json::Value, session: &mut McpSession, options: &McpServeOptions, @@ -73,7 +74,7 @@ pub(in crate::cli) fn handle_mcp_message( } } -pub(in crate::cli) fn parse_mcp_payload(data: &[u8]) -> Result { +pub(in crate::adapters) fn parse_mcp_payload(data: &[u8]) -> Result { let payload: serde_json::Value = serde_json::from_slice(data).map_err(|error| error.to_string())?; if !payload.is_object() { @@ -82,14 +83,14 @@ pub(in crate::cli) fn parse_mcp_payload(data: &[u8]) -> Result String { +pub(in crate::adapters) fn negotiate_protocol_version(requested: &str) -> String { match requested { "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05" => requested.to_string(), _ => LATEST_PROTOCOL_VERSION.to_string(), } } -pub(in crate::cli) fn rpc_error( +pub(in crate::adapters) fn rpc_error( request_id: serde_json::Value, code: i64, message: &str, @@ -104,7 +105,7 @@ pub(in crate::cli) fn rpc_error( }) } -pub(in crate::cli) fn is_supported_protocol_version(version: &str) -> bool { +pub(in crate::adapters) fn is_supported_protocol_version(version: &str) -> bool { matches!( version, "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05" @@ -112,7 +113,7 @@ pub(in crate::cli) fn is_supported_protocol_version(version: &str) -> bool { } #[derive(Debug, Default)] -pub(in crate::cli) struct McpSession { - pub(in crate::cli) protocol_version: Option, - pub(in crate::cli) initialized: bool, +pub(in crate::adapters) struct McpSession { + pub(in crate::adapters) protocol_version: Option, + pub(in crate::adapters) initialized: bool, } diff --git a/src/cli/mcp/refresh.rs b/src/adapters/mcp/refresh.rs similarity index 70% rename from src/cli/mcp/refresh.rs rename to src/adapters/mcp/refresh.rs index fea3b52..31932aa 100644 --- a/src/cli/mcp/refresh.rs +++ b/src/adapters/mcp/refresh.rs @@ -5,9 +5,9 @@ use crate::api::{ }; use std::sync::Arc; -pub(in crate::cli) type McpRefreshState = RefreshState; +pub(in crate::adapters) type McpRefreshState = RefreshState; -pub(in crate::cli) fn start_auto_refresh(options: &McpServeOptions) -> Arc { +pub(in crate::adapters) fn start_auto_refresh(options: &McpServeOptions) -> Arc { start_refresh_service(RepoSelector { repo_root: options.repo_root.clone(), config_path: options.config.clone(), diff --git a/src/adapters/mcp/state.rs b/src/adapters/mcp/state.rs new file mode 100644 index 0000000..86301bf --- /dev/null +++ b/src/adapters/mcp/state.rs @@ -0,0 +1,15 @@ +use super::McpSession; +use std::collections::BTreeMap; + +#[derive(Debug, Default)] +pub(in crate::adapters) struct McpHttpState { + pub(in crate::adapters) sessions: BTreeMap, + pub(in crate::adapters) next_session: u64, +} + +impl McpHttpState { + pub(in crate::adapters) fn next_session_id(&mut self) -> String { + self.next_session += 1; + format!("native-http-session-{}", self.next_session) + } +} diff --git a/src/cli/mcp/stdio.rs b/src/adapters/mcp/stdio.rs similarity index 92% rename from src/cli/mcp/stdio.rs rename to src/adapters/mcp/stdio.rs index 550a893..9d28580 100644 --- a/src/cli/mcp/stdio.rs +++ b/src/adapters/mcp/stdio.rs @@ -6,7 +6,7 @@ use super::{ use serde_json::json; use std::io::{BufRead, Write}; -pub(in crate::cli) fn serve_mcp_stdio( +pub(in crate::adapters) fn serve_mcp_stdio( options: &McpServeOptions, mut input: R, output: &mut W, @@ -22,7 +22,7 @@ pub(in crate::cli) fn serve_mcp_stdio( Ok(()) } -pub(in crate::cli) fn read_mcp_message( +pub(in crate::adapters) fn read_mcp_message( input: &mut R, output: &mut W, ) -> Result, String> { @@ -93,7 +93,7 @@ pub(in crate::cli) fn read_mcp_message( }) } -pub(in crate::cli) fn log_mcp_stdio_parse_error(error: &str) { +pub(in crate::adapters) fn log_mcp_stdio_parse_error(error: &str) { eprintln!( "{}", json!({ @@ -103,7 +103,7 @@ pub(in crate::cli) fn log_mcp_stdio_parse_error(error: &str) { ); } -pub(in crate::cli) fn write_mcp_message( +pub(in crate::adapters) fn write_mcp_message( output: &mut W, message: &serde_json::Value, ) -> Result<(), String> { diff --git a/src/cli/mcp/tools.rs b/src/adapters/mcp/tools.rs similarity index 97% rename from src/cli/mcp/tools.rs rename to src/adapters/mcp/tools.rs index 4c1c308..72a771f 100644 --- a/src/cli/mcp/tools.rs +++ b/src/adapters/mcp/tools.rs @@ -1,4 +1,4 @@ -use super::options::McpServeOptions; +use super::{block::serialize_error_block, options::McpServeOptions}; use crate::api::normalization::required_fields; use crate::api::{ contracts::{ @@ -7,11 +7,10 @@ use crate::api::{ }, CodebaseGraphApi, OperationDescriptor, }; -use crate::cli::format::serialize_error_block; use serde_json::json; use serde_json::Map; -pub(in crate::cli) fn generate_mcp_specs() -> Result { +pub(in crate::adapters) fn generate_mcp_specs() -> Result { let tools = CodebaseGraphApi::new() .operation_descriptors() .into_iter() @@ -60,7 +59,7 @@ fn map_error_to_transport(tool_name: &str, error: &ApiError) -> serde_json::Valu }) } -pub(in crate::cli) fn mcp_call_tool_result( +pub(in crate::adapters) fn mcp_call_tool_result( tool_name: &str, arguments: &serde_json::Value, options: &McpServeOptions, @@ -133,7 +132,7 @@ pub(in crate::cli) fn mcp_call_tool_result( } } -pub(in crate::cli) fn mcp_tool_payload( +pub(in crate::adapters) fn mcp_tool_payload( tool_name: &str, arguments: &serde_json::Value, options: &McpServeOptions, @@ -311,7 +310,7 @@ fn context_request_from_mcp( } } -pub(in crate::cli) fn json_usize( +pub(in crate::adapters) fn json_usize( arguments: &serde_json::Value, key: &str, default: usize, diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..14979e0 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,12 @@ +pub mod cli; +pub(crate) mod mcp; + +pub(in crate::adapters) fn required_arg<'a>( + args: &'a [String], + index: usize, + name: &str, +) -> Result<&'a str, String> { + args.get(index + 1) + .map(String::as_str) + .ok_or_else(|| format!("{name} requires a value")) +} diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs index e1154c9..ed41e59 100644 --- a/src/api/boundary_tests.rs +++ b/src/api/boundary_tests.rs @@ -2,7 +2,10 @@ fn api_does_not_import_transport_adapters() { let api_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/api"); let mut pending = vec![api_root]; - let forbidden = ["crate", "::cli"].concat(); + let forbidden = [ + ["crate", "::adapters::cli"].concat(), + ["crate", "::adapters::mcp"].concat(), + ]; while let Some(path) = pending.pop() { for entry in std::fs::read_dir(&path).expect("API directory should be readable") { @@ -19,11 +22,50 @@ fn api_does_not_import_transport_adapters() { continue; } let source = std::fs::read_to_string(&path).expect("API source should be readable"); - assert!( - !source.contains(&forbidden), - "{} imports a CLI transport module", - path.display() - ); + for adapter in &forbidden { + assert!( + !source.contains(adapter), + "{} imports a transport adapter", + path.display() + ); + } + } + } +} + +#[test] +fn command_and_mcp_transports_are_peer_adapters() { + let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + assert!(source_root.join("adapters/cli").is_dir()); + assert!(source_root.join("adapters/mcp").is_dir()); + assert!(!source_root.join("cli").exists()); + + let mut pending = vec![source_root.join("adapters/mcp")]; + let forbidden = [ + ["crate", "::cli"].concat(), + ["crate", "::adapters::cli"].concat(), + ]; + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(&path).expect("MCP adapter directory should be readable") { + let path = entry + .expect("MCP adapter directory entry should be readable") + .path(); + if path.is_dir() { + pending.push(path); + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { + continue; + } + let source = + std::fs::read_to_string(&path).expect("MCP adapter source should be readable"); + for adapter in &forbidden { + assert!( + !source.contains(adapter), + "{} imports the CLI adapter", + path.display() + ); + } } } } @@ -31,14 +73,17 @@ fn api_does_not_import_transport_adapters() { #[test] fn transport_adapters_do_not_import_product_execution_services() { let adapters = [ - ("CLI dispatch", include_str!("../cli/dispatch.rs")), + ("CLI dispatch", include_str!("../adapters/cli/dispatch.rs")), ( "CLI materialization", - include_str!("../cli/materialization/command.rs"), + include_str!("../adapters/cli/materialization/command.rs"), + ), + ("MCP tools", include_str!("../adapters/mcp/tools.rs")), + ( + "watch command", + include_str!("../adapters/cli/watch/command.rs"), ), - ("MCP tools", include_str!("../cli/mcp/tools.rs")), - ("watch command", include_str!("../cli/watch/command.rs")), - ("MCP refresh", include_str!("../cli/mcp/refresh.rs")), + ("MCP refresh", include_str!("../adapters/mcp/refresh.rs")), ]; let forbidden = [ ["crate", "::db_writer"].concat(), @@ -46,8 +91,8 @@ fn transport_adapters_do_not_import_product_execution_services() { ["crate", "::execution"].concat(), ["crate", "::semantic_enrichment"].concat(), ["crate", "::staging_writer"].concat(), - ["crate", "::cli::graph"].concat(), - ["crate", "::cli::materialization"].concat(), + ["crate", "::adapters::cli::graph"].concat(), + ["crate", "::adapters::cli::materialization"].concat(), ["materialize", "_syntax_batch"].concat(), ["execute", "_materialization_pipeline"].concat(), ["execute", "_graph_search"].concat(), @@ -79,7 +124,7 @@ fn only_graph_writer_submits_database_updates() { include_str!("../execution/plan.rs"), include_str!("../execution/parallel.rs"), include_str!("../semantic_enrichment/mod.rs"), - include_str!("../cli/materialization/command.rs"), + include_str!("../adapters/cli/materialization/command.rs"), include_str!("materialization.rs"), ]; for source in other_materialization_modules { diff --git a/src/bin/codebase-graph.rs b/src/bin/codebase-graph.rs index 23a9fcd..335e6c5 100644 --- a/src/bin/codebase-graph.rs +++ b/src/bin/codebase-graph.rs @@ -1,6 +1,6 @@ fn main() { - if let Err(error) = codebase_graph::cli::run_from_env() { + if let Err(error) = codebase_graph::adapters::cli::run_from_env() { eprintln!("{error}"); - std::process::exit(codebase_graph::cli::error_exit_code(&error)); + std::process::exit(codebase_graph::adapters::cli::error_exit_code(&error)); } } diff --git a/src/cli/install/mod.rs b/src/cli/install/mod.rs deleted file mode 100644 index ee42e8d..0000000 --- a/src/cli/install/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod client; -mod command; -mod descriptor; -mod fs_util; -mod metadata; -mod options; -mod render; -mod state; -mod verify; - -pub(in crate::cli) use client::install_mcp_client; -pub(in crate::cli) use command::run_mcp_install; -pub(in crate::cli) use descriptor::{build_mcp_descriptor, NativeMcpDescriptor}; -pub(in crate::cli) use fs_util::{ - executable_in_path, expand_path, home_dir, subprocess_error, write_text_atomic, -}; -pub(in crate::cli) use metadata::{ - adapter_id, default_client_config_path, install_scope, native_client_command, - supported_install_clients, supported_install_clients_with_all, -}; -pub(in crate::cli) use options::McpInstallOptions; -pub(in crate::cli) use render::{copilot_studio_metadata, render_client_config}; -pub(in crate::cli) use state::McpHttpState; -pub(in crate::cli) use verify::attach_install_verification; diff --git a/src/cli/install/state.rs b/src/cli/install/state.rs deleted file mode 100644 index 5013daa..0000000 --- a/src/cli/install/state.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::cli::mcp::McpSession; -use std::collections::BTreeMap; - -#[derive(Debug, Default)] -pub(in crate::cli) struct McpHttpState { - pub(in crate::cli) sessions: BTreeMap, - pub(in crate::cli) next_session: u64, -} - -impl McpHttpState { - pub(in crate::cli) fn next_session_id(&mut self) -> String { - self.next_session += 1; - format!("native-http-session-{}", self.next_session) - } -} diff --git a/src/cli/mcp/mod.rs b/src/cli/mcp/mod.rs deleted file mode 100644 index 1dec691..0000000 --- a/src/cli/mcp/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod commands; -mod http; -mod options; -mod protocol; -mod refresh; -mod stdio; -mod tools; - -pub(in crate::cli) use commands::run_mcp_command; -pub(in crate::cli) use http::serve_mcp_http; -pub(in crate::cli) use options::{McpHttpOptions, McpServeOptions}; -pub(in crate::cli) use protocol::McpSession; -pub(in crate::cli) use stdio::serve_mcp_stdio; - -#[cfg(test)] -pub(super) use http::{handle_mcp_http_request, HttpRequest}; -#[cfg(test)] -pub(super) use tools::mcp_call_tool_result; diff --git a/src/lib.rs b/src/lib.rs index 7af0f54..818bd56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ +pub mod adapters; pub mod api; -pub mod cli; pub mod db_writer; pub mod error; mod execution; @@ -18,3 +18,5 @@ mod syntax_materializer; pub use error::MaterializationError; pub use execution::{execute_materialization_pipeline, plan_materialization}; pub use protocol::{MaterializationInput, MaterializationResult}; + +pub use adapters::cli; From f7169aa309590ac6c9efa473f2415bad71c117bf Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 16:55:06 +0930 Subject: [PATCH 06/19] refactor(api): centralize MCP client installation Route standalone MCP registration through the public API so transport adapters only parse requests, frame verification, and print responses. Remove the duplicate CLI descriptor, rendering, client metadata, filesystem, and repository-resolution implementations. Constraint: Preserve existing CLI install payloads and optional post-install verification. Rejected: Share private CLI helpers with the API | keeps product policy owned by a transport adapter. Confidence: high Scope-risk: moderate Directive: Keep supported-client policy and installation mutation under src/api. Tested: cargo test --all-targets; cargo clippy --all-targets -- -D warnings; cargo fmt -- --check Not-tested: Native Codex, Claude, and OpenClaw installer executables were not invoked. --- src/adapters/cli/constants.rs | 5 - src/adapters/cli/dispatch.rs | 2 + src/adapters/cli/install/client.rs | 140 ------------ src/adapters/cli/install/command.rs | 53 ++--- src/adapters/cli/install/descriptor.rs | 144 ------------ src/adapters/cli/install/fs_util.rs | 79 ------- src/adapters/cli/install/metadata.rs | 119 ---------- src/adapters/cli/install/mod.rs | 15 -- src/adapters/cli/install/options.rs | 22 +- src/adapters/cli/install/render.rs | 298 ------------------------- src/adapters/cli/install/verify.rs | 84 +++++-- src/adapters/cli/mod.rs | 1 - src/adapters/cli/setup.rs | 2 - src/adapters/cli/uninstall.rs | 11 +- src/adapters/cli/util.rs | 160 ------------- src/adapters/cli/watch/options.rs | 10 +- src/api/contracts.rs | 24 ++ src/api/core.rs | 86 ++++++- src/api/lifecycle.rs | 62 ++++- src/api/mod.rs | 5 +- src/api/normalization.rs | 90 +++++++- 21 files changed, 347 insertions(+), 1065 deletions(-) delete mode 100644 src/adapters/cli/constants.rs delete mode 100644 src/adapters/cli/install/client.rs delete mode 100644 src/adapters/cli/install/descriptor.rs delete mode 100644 src/adapters/cli/install/fs_util.rs delete mode 100644 src/adapters/cli/install/metadata.rs delete mode 100644 src/adapters/cli/install/render.rs diff --git a/src/adapters/cli/constants.rs b/src/adapters/cli/constants.rs deleted file mode 100644 index d87f961..0000000 --- a/src/adapters/cli/constants.rs +++ /dev/null @@ -1,5 +0,0 @@ -use std::env; - -pub(super) fn server_command() -> String { - env::var("CODEBASE_GRAPH_SERVER_COMMAND").unwrap_or_else(|_| "codebase-graph".to_string()) -} diff --git a/src/adapters/cli/dispatch.rs b/src/adapters/cli/dispatch.rs index 3be9271..8267675 100644 --- a/src/adapters/cli/dispatch.rs +++ b/src/adapters/cli/dispatch.rs @@ -317,6 +317,8 @@ pub fn error_exit_code(error: &str) -> i32 { || error.starts_with("unknown reinstall option:") || error.starts_with("--mcp-client must be") || error.starts_with("--mcp-client requires") + || error.starts_with("unsupported MCP client:") + || error.starts_with("MCP install scope must be") || error.starts_with("--instructions-target must be") || error.starts_with("--instructions-target requires") { diff --git a/src/adapters/cli/install/client.rs b/src/adapters/cli/install/client.rs deleted file mode 100644 index 1cf7973..0000000 --- a/src/adapters/cli/install/client.rs +++ /dev/null @@ -1,140 +0,0 @@ -use super::{ - attach_install_verification, build_mcp_descriptor, copilot_studio_metadata, - default_client_config_path, executable_in_path, install_scope, native_client_command, - render_client_config, subprocess_error, write_text_atomic, McpInstallOptions, - NativeMcpDescriptor, -}; -use serde_json::json; -use std::{fs, process::Command}; - -pub(in crate::adapters::cli) fn install_mcp_client( - options: &McpInstallOptions, -) -> Result { - let descriptor = build_mcp_descriptor(options)?; - if options.client == "copilot-studio" || options.client == "microsoft-copilot" { - let metadata = copilot_studio_metadata(&descriptor); - let payload = json!({ - "action": if options.dry_run { "dry_run" } else { "reported" }, - "client": options.client, - "scope": options.scope, - "server_name": descriptor.name, - "method": "manual_metadata", - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": metadata["stdio"].clone(), - "payload": metadata, - }); - return attach_install_verification(payload, &descriptor, options); - } - let native_command = native_client_command(&options.client, &descriptor, &options.scope); - let native_available = native_command - .as_ref() - .and_then(|command| command.first()) - .is_some_and(|executable| executable_in_path(executable)); - if options.dry_run && options.client_config_path.is_none() && native_available { - return attach_install_verification( - json!({ - "action": "dry_run", - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "native_cli", - "path": serde_json::Value::Null, - "command": native_command, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, false), - }), - &descriptor, - options, - ); - } - if !options.dry_run && options.client_config_path.is_none() && native_available { - let Some(command) = native_command.clone() else { - return file_adapter_result(options, &descriptor, native_command, None); - }; - let completed = Command::new(&command[0]) - .args(&command[1..]) - .output() - .map_err(|error| format!("failed to run native client installer: {error}"))?; - if completed.status.success() { - return attach_install_verification( - json!({ - "action": "updated", - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "native_cli", - "path": serde_json::Value::Null, - "command": command, - "descriptor": descriptor.as_json(), - "entry": descriptor.stdio_entry(false, false), - }), - &descriptor, - options, - ); - } - let error = subprocess_error(&completed); - return file_adapter_result(options, &descriptor, Some(command), Some(error)); - } - let native_error = native_command.as_ref().and_then(|command| { - command.first().and_then(|executable| { - if executable_in_path(executable) { - None - } else { - Some(format!("{executable} executable not found")) - } - }) - }); - file_adapter_result(options, &descriptor, native_command, native_error) -} - -pub(in crate::adapters::cli) fn file_adapter_result( - options: &McpInstallOptions, - descriptor: &NativeMcpDescriptor, - native_command: Option>, - native_error: Option, -) -> Result { - let path = options.client_config_path.clone().unwrap_or_else(|| { - default_client_config_path( - &options.client, - &install_scope(&options.client, &options.scope), - descriptor, - ) - }); - let existing = fs::read_to_string(&path).ok(); - let rendered = render_client_config( - &options.client, - &install_scope(&options.client, &options.scope), - existing.as_deref(), - descriptor, - )?; - let action = if options.dry_run { - "dry_run".to_string() - } else { - rendered.action.clone() - }; - if !options.dry_run { - write_text_atomic(&path, &rendered.text)?; - } - let mut payload = json!({ - "action": action, - "client": options.client, - "scope": install_scope(&options.client, &options.scope), - "server_name": descriptor.name, - "method": "file_adapter", - "path": path.to_string_lossy(), - "command": serde_json::Value::Null, - "descriptor": descriptor.as_json(), - "entry": rendered.entry, - "patch": rendered.patch, - "payload": rendered.payload, - }); - if let Some(command) = native_command { - payload["native_command"] = json!(command); - } - if let Some(error) = native_error { - payload["native_error"] = json!(error); - } - attach_install_verification(payload, descriptor, options) -} diff --git a/src/adapters/cli/install/command.rs b/src/adapters/cli/install/command.rs index 7ea06a6..257f98e 100644 --- a/src/adapters/cli/install/command.rs +++ b/src/adapters/cli/install/command.rs @@ -1,8 +1,9 @@ -use super::{ - install_mcp_client, install_scope, options::McpInstallOptions, supported_install_clients, -}; +use super::{attach_install_verification, options::McpInstallOptions}; use crate::adapters::cli::format::mcp_install_help; -use serde_json::json; +use crate::api::{ + contracts::{McpInstallRequest, OperationRequest, OutputFormat, RepoSelector}, + CodebaseGraphApi, +}; use std::io::Write; pub(in crate::adapters::cli) fn run_mcp_install( @@ -14,32 +15,24 @@ pub(in crate::adapters::cli) fn run_mcp_install( writeln!(stdout, "{}", mcp_install_help()).map_err(|error| error.to_string())?; return Ok(()); } - let payload = if options.client == "all" { - let results = supported_install_clients() - .into_iter() - .map(|client| { - let mut client_options = options.clone(); - client_options.client = client.to_string(); - install_mcp_client(&client_options).unwrap_or_else(|error| { - json!({ - "action": "failed", - "client": client, - "scope": install_scope(client, &client_options.scope), - "server_name": client_options.name.clone().unwrap_or_else(|| "codebase_graph".to_string()), - "method": serde_json::Value::Null, - "path": serde_json::Value::Null, - "command": serde_json::Value::Null, - "descriptor": {}, - "entry": {}, - "error": error, - }) - }) - }) - .collect::>(); - json!({ "results": results }) - } else { - install_mcp_client(&options)? - }; + let request = OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector { + repo_root: options.repo_root.clone(), + config_path: options.config_path.clone(), + db_path: None, + manifest_path: None, + }, + client: options.client.clone(), + scope: options.scope.clone(), + name: options.name.clone(), + client_config_path: options.client_config_path.clone(), + dry_run: options.dry_run, + output_format: OutputFormat::Typed, + }); + let response = CodebaseGraphApi::new() + .execute_operation(&request) + .map_err(|error| error.message)?; + let payload = attach_install_verification(response.payload, &options); writeln!( stdout, "{}", diff --git a/src/adapters/cli/install/descriptor.rs b/src/adapters/cli/install/descriptor.rs deleted file mode 100644 index 99dad45..0000000 --- a/src/adapters/cli/install/descriptor.rs +++ /dev/null @@ -1,144 +0,0 @@ -use super::{expand_path, McpInstallOptions}; -use crate::adapters::cli::{ - constants::server_command, - setup::{safe_name, GraphStatePaths}, - util::{read_json_file, resolve_repo_root}, -}; -use serde_json::json; -use std::path::Path; - -#[derive(Debug, Clone)] -pub(in crate::adapters::cli) struct NativeMcpDescriptor { - pub(in crate::adapters::cli) name: String, - pub(in crate::adapters::cli) command: String, - pub(in crate::adapters::cli) args: Vec, - pub(in crate::adapters::cli) setup_config_path: String, - pub(in crate::adapters::cli) repo_root: String, - pub(in crate::adapters::cli) timeout: u64, -} - -impl NativeMcpDescriptor { - pub(in crate::adapters::cli) fn as_json(&self) -> serde_json::Value { - json!({ - "name": self.name, - "transport": "stdio", - "command": self.command, - "args": self.args, - "env": {}, - "cwd": serde_json::Value::Null, - "setup_config_path": self.setup_config_path, - "repo_root": self.repo_root, - "timeout": self.timeout, - "tool_policy": "graph_query_read_only", - }) - } - - pub(in crate::adapters::cli) fn stdio_entry( - &self, - include_type: bool, - include_timeout: bool, - ) -> serde_json::Value { - let mut entry = serde_json::Map::new(); - entry.insert("command".to_string(), json!(self.command)); - entry.insert("args".to_string(), json!(self.args)); - if include_type { - entry.insert("type".to_string(), json!("stdio")); - } - if include_timeout { - entry.insert("startup_timeout_sec".to_string(), json!(self.timeout)); - } - serde_json::Value::Object(entry) - } -} - -pub(in crate::adapters::cli) fn build_mcp_descriptor( - options: &McpInstallOptions, -) -> Result { - let resolved_repo_root = resolve_repo_root(options.repo_root.as_deref())?; - let config_path = options - .config_path - .clone() - .unwrap_or_else(|| GraphStatePaths::derive(&resolved_repo_root).config_path); - let setup_config = if config_path.exists() { - Some(read_json_file(&config_path)?) - } else { - None - }; - let repo_root = setup_config - .as_ref() - .and_then(|payload| payload.get("repo_root")) - .and_then(serde_json::Value::as_str) - .map(expand_path) - .unwrap_or_else(|| { - config_path - .parent() - .and_then(Path::parent) - .map(Path::to_path_buf) - .unwrap_or_else(|| resolved_repo_root.clone()) - }); - let repo_name = setup_config - .as_ref() - .and_then(|payload| payload.get("repo_name")) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| { - safe_name( - repo_root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repository"), - ) - }); - let name = options - .name - .clone() - .unwrap_or_else(|| format!("codebase_graph_{}", install_safe_name(&repo_name))); - let command_from_config = setup_config - .as_ref() - .and_then(|payload| payload.pointer("/mcp/command")) - .and_then(serde_json::Value::as_array) - .and_then(|values| { - let command: Option> = values - .iter() - .map(|value| value.as_str().map(str::to_string)) - .collect(); - command.filter(|parts| parts.len() >= 5) - }); - let (command, args) = if let Some(mut parts) = command_from_config { - let command = parts.remove(0); - (command, parts) - } else { - ( - server_command(), - vec![ - "mcp".to_string(), - "start".to_string(), - "--config".to_string(), - config_path.to_string_lossy().to_string(), - ], - ) - }; - Ok(NativeMcpDescriptor { - name, - command, - args, - setup_config_path: config_path.to_string_lossy().to_string(), - repo_root: repo_root.to_string_lossy().to_string(), - timeout: 60, - }) -} - -pub(in crate::adapters::cli) fn install_safe_name(value: &str) -> String { - let normalized: String = value - .trim() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' || character == '_' { - character.to_ascii_lowercase() - } else { - '_' - } - }) - .collect(); - normalized.trim_matches(['.', '_', '-']).to_string() -} diff --git a/src/adapters/cli/install/fs_util.rs b/src/adapters/cli/install/fs_util.rs deleted file mode 100644 index 80715f7..0000000 --- a/src/adapters/cli/install/fs_util.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -pub(in crate::adapters::cli) fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "failed to create config directory {}: {error}", - parent.display() - ) - })?; - } - let tmp_path = path.with_extension(format!( - "{}.tmp", - path.extension() - .and_then(|value| value.to_str()) - .unwrap_or_default() - )); - fs::write(&tmp_path, text).map_err(|error| { - format!( - "failed to write temporary config {}: {error}", - tmp_path.display() - ) - })?; - fs::rename(&tmp_path, path) - .map_err(|error| format!("failed to replace config {}: {error}", path.display())) -} - -pub(in crate::adapters::cli) fn expand_path(value: &str) -> PathBuf { - if let Some(rest) = value.strip_prefix("~/") { - return home_dir().join(rest); - } - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else { - env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(path) - } -} - -pub(in crate::adapters::cli) fn home_dir() -> PathBuf { - env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) -} - -pub(in crate::adapters::cli) fn executable_in_path(executable: &str) -> bool { - let path = Path::new(executable); - if path.components().count() > 1 { - return path.is_file(); - } - env::var_os("PATH") - .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) - .unwrap_or(false) -} - -pub(in crate::adapters::cli) fn subprocess_error(completed: &std::process::Output) -> String { - let stdout = String::from_utf8_lossy(&completed.stdout) - .trim() - .to_string(); - let stderr = String::from_utf8_lossy(&completed.stderr) - .trim() - .to_string(); - let output = [stdout, stderr] - .into_iter() - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n"); - let code = completed.status.code().unwrap_or(1); - if output.is_empty() { - format!("exit {code}") - } else { - format!("exit {code}: {output}") - } -} diff --git a/src/adapters/cli/install/metadata.rs b/src/adapters/cli/install/metadata.rs deleted file mode 100644 index 77a8879..0000000 --- a/src/adapters/cli/install/metadata.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::{home_dir, NativeMcpDescriptor}; -use std::{ - env, - path::{Path, PathBuf}, -}; - -pub(in crate::adapters::cli) fn default_client_config_path( - client: &str, - scope: &str, - descriptor: &NativeMcpDescriptor, -) -> PathBuf { - let home = home_dir(); - match adapter_id(client, scope) { - "codex" => env::var_os("CODEX_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".codex")) - .join("config.toml"), - "claude" => { - let mac_path = - home.join("Library/Application Support/Claude/claude_desktop_config.json"); - if mac_path.parent().is_some_and(Path::exists) { - mac_path - } else { - home.join(".config/claude/claude_desktop_config.json") - } - } - "claude-project" => PathBuf::from(&descriptor.repo_root).join(".mcp.json"), - "lmstudio" => home.join(".lmstudio/mcp.json"), - "github-copilot" => PathBuf::from(&descriptor.repo_root).join(".vscode/mcp.json"), - "hermes" => home.join(".hermes/config.yaml"), - "openclaw" => env::var_os("OPENCLAW_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".openclaw")) - .join("mcp.json5"), - _ => home.join(".config/mcp/mcp.json"), - } -} - -pub(in crate::adapters::cli) fn supported_install_clients() -> Vec<&'static str> { - vec![ - "claude", - "claude-project", - "codex", - "copilot-studio", - "generic", - "github-copilot", - "hermes", - "lmstudio", - "microsoft-copilot", - "openclaw", - ] -} - -pub(in crate::adapters::cli) fn supported_install_clients_with_all() -> Vec<&'static str> { - let mut clients = supported_install_clients(); - clients.push("all"); - clients -} - -pub(in crate::adapters::cli) fn install_scope(client: &str, scope: &str) -> String { - if client == "claude-project" { - "project".to_string() - } else { - scope.to_string() - } -} - -pub(in crate::adapters::cli) fn adapter_id<'a>(client: &'a str, scope: &str) -> &'a str { - if client == "claude" && scope == "project" { - "claude-project" - } else { - client - } -} - -pub(in crate::adapters::cli) fn native_client_command( - client: &str, - descriptor: &NativeMcpDescriptor, - scope: &str, -) -> Option> { - match client { - "codex" => Some(vec![ - "codex".to_string(), - "mcp".to_string(), - "add".to_string(), - descriptor.name.clone(), - "--".to_string(), - descriptor.command.clone(), - descriptor.args[0].clone(), - descriptor.args[1].clone(), - descriptor.args[2].clone(), - descriptor.args[3].clone(), - ]), - "claude" | "claude-project" => Some(vec![ - "claude".to_string(), - "mcp".to_string(), - "add".to_string(), - "--transport".to_string(), - "stdio".to_string(), - "--scope".to_string(), - install_scope(client, scope), - descriptor.name.clone(), - "--".to_string(), - descriptor.command.clone(), - descriptor.args[0].clone(), - descriptor.args[1].clone(), - descriptor.args[2].clone(), - descriptor.args[3].clone(), - ]), - "openclaw" => Some(vec![ - "openclaw".to_string(), - "mcp".to_string(), - "set".to_string(), - descriptor.name.clone(), - serde_json::to_string(&descriptor.stdio_entry(true, false)).ok()?, - ]), - _ => None, - } -} diff --git a/src/adapters/cli/install/mod.rs b/src/adapters/cli/install/mod.rs index d6291cc..cd170d1 100644 --- a/src/adapters/cli/install/mod.rs +++ b/src/adapters/cli/install/mod.rs @@ -1,22 +1,7 @@ -mod client; mod command; -mod descriptor; -mod fs_util; -mod metadata; mod options; -mod render; mod verify; -pub(in crate::adapters::cli) use client::install_mcp_client; pub(in crate::adapters::cli) use command::run_mcp_install; -pub(in crate::adapters::cli) use descriptor::{build_mcp_descriptor, NativeMcpDescriptor}; -pub(in crate::adapters::cli) use fs_util::{ - executable_in_path, expand_path, home_dir, subprocess_error, write_text_atomic, -}; -pub(in crate::adapters::cli) use metadata::{ - adapter_id, default_client_config_path, install_scope, native_client_command, - supported_install_clients, supported_install_clients_with_all, -}; pub(in crate::adapters::cli) use options::McpInstallOptions; -pub(in crate::adapters::cli) use render::{copilot_studio_metadata, render_client_config}; pub(in crate::adapters::cli) use verify::attach_install_verification; diff --git a/src/adapters/cli/install/options.rs b/src/adapters/cli/install/options.rs index ee15e3a..95a5eaa 100644 --- a/src/adapters/cli/install/options.rs +++ b/src/adapters/cli/install/options.rs @@ -1,4 +1,3 @@ -use super::{expand_path, supported_install_clients, supported_install_clients_with_all}; use crate::adapters::{cli::format::mcp_install_help, required_arg}; use std::path::PathBuf; @@ -39,25 +38,10 @@ impl McpInstallOptions { } "--client" => { options.client = required_arg(args, index, "--client")?.to_string(); - if options.client != "all" - && !supported_install_clients().contains(&options.client.as_str()) - { - return Err(format!( - "Unsupported MCP client: {}. Supported clients: {}", - options.client, - supported_install_clients_with_all().join(", ") - )); - } index += 2; } "--scope" => { options.scope = required_arg(args, index, "--scope")?.to_string(); - if !matches!(options.scope.as_str(), "local" | "user" | "project") { - return Err( - "Unsupported MCP install scope: expected local, user, or project" - .to_string(), - ); - } index += 2; } "--name" => { @@ -66,11 +50,11 @@ impl McpInstallOptions { } "--config-path" => { options.config_path = - Some(expand_path(required_arg(args, index, "--config-path")?)); + Some(PathBuf::from(required_arg(args, index, "--config-path")?)); index += 2; } "--client-config-path" => { - options.client_config_path = Some(expand_path(required_arg( + options.client_config_path = Some(PathBuf::from(required_arg( args, index, "--client-config-path", @@ -79,7 +63,7 @@ impl McpInstallOptions { } "--repo-root" => { options.repo_root = - Some(expand_path(required_arg(args, index, "--repo-root")?)); + Some(PathBuf::from(required_arg(args, index, "--repo-root")?)); index += 2; } "--dry-run" => { diff --git a/src/adapters/cli/install/render.rs b/src/adapters/cli/install/render.rs deleted file mode 100644 index 084f96b..0000000 --- a/src/adapters/cli/install/render.rs +++ /dev/null @@ -1,298 +0,0 @@ -use super::{adapter_id, NativeMcpDescriptor}; -use serde_json::json; - -pub(in crate::adapters::cli) struct RenderedNativeConfig { - pub(in crate::adapters::cli) text: String, - pub(in crate::adapters::cli) action: String, - pub(in crate::adapters::cli) entry: serde_json::Value, - pub(in crate::adapters::cli) patch: serde_json::Value, - pub(in crate::adapters::cli) payload: serde_json::Value, -} - -pub(in crate::adapters::cli) fn render_client_config( - client: &str, - scope: &str, - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - match adapter_id(client, scope) { - "codex" => render_codex_config(existing, descriptor), - "hermes" => render_hermes_config(existing, descriptor), - "claude" | "claude-project" | "lmstudio" | "github-copilot" | "openclaw" | "generic" => { - render_json_config(adapter_id(client, scope), existing, descriptor) - } - other => Err(format!("Unsupported MCP client adapter: {other}")), - } -} - -pub(in crate::adapters::cli) fn render_json_config( - adapter: &str, - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let mut payload = existing - .filter(|text| !text.trim().is_empty()) - .map(serde_json::from_str::) - .transpose() - .map_err(|error| format!("MCP config must contain a JSON object: {error}"))? - .unwrap_or_else(|| json!({})); - if !payload.is_object() { - return Err("MCP config must contain a JSON object".to_string()); - } - let root_path = match adapter { - "github-copilot" => vec!["servers"], - "openclaw" => vec!["mcp", "servers"], - _ => vec!["mcpServers"], - }; - let include_type = !matches!(adapter, "claude" | "generic"); - let entry = descriptor.stdio_entry(include_type, false); - let previous = json_container_mut(&mut payload, &root_path)? - .insert(descriptor.name.clone(), entry.clone()); - let action = action_for_json(previous.as_ref(), &entry, existing.is_some()); - let text = serde_json::to_string_pretty(&payload).map_err(|error| error.to_string())? + "\n"; - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else { - action - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: payload.clone(), - payload, - }) -} - -pub(in crate::adapters::cli) fn render_codex_config( - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let entry = descriptor.stdio_entry(false, true); - let patch = codex_toml_block(descriptor); - let (text, previous) = - upsert_toml_block(existing.unwrap_or_default(), &descriptor.name, &patch); - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else if previous.is_none() { - "created".to_string() - } else if previous.as_deref() == Some(patch.trim_end()) { - "unchanged".to_string() - } else { - "updated".to_string() - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: json!(patch), - payload: json!(patch), - }) -} - -pub(in crate::adapters::cli) fn render_hermes_config( - existing: Option<&str>, - descriptor: &NativeMcpDescriptor, -) -> Result { - let entry = descriptor.stdio_entry(true, false); - let patch = hermes_yaml_block(descriptor); - let (text, previous) = upsert_marked_block(existing.unwrap_or_default(), &patch); - let action = if existing == Some(text.as_str()) { - "unchanged".to_string() - } else if previous.is_none() { - "created".to_string() - } else if previous.as_deref() == Some(patch.trim_end()) { - "unchanged".to_string() - } else { - "updated".to_string() - }; - Ok(RenderedNativeConfig { - text, - action, - entry, - patch: json!(patch), - payload: json!(patch), - }) -} - -pub(in crate::adapters::cli) fn json_container_mut<'a>( - payload: &'a mut serde_json::Value, - path: &[&str], -) -> Result<&'a mut serde_json::Map, String> { - let mut cursor = payload - .as_object_mut() - .ok_or_else(|| "MCP config must contain a JSON object".to_string())?; - for key in path { - let next = cursor - .entry((*key).to_string()) - .or_insert_with(|| json!({})); - cursor = next - .as_object_mut() - .ok_or_else(|| format!("MCP config key must contain an object: {}", path.join(".")))?; - } - Ok(cursor) -} - -pub(in crate::adapters::cli) fn action_for_json( - previous: Option<&serde_json::Value>, - next_value: &serde_json::Value, - file_exists: bool, -) -> String { - if !file_exists { - "created".to_string() - } else if previous == Some(next_value) { - "unchanged".to_string() - } else { - "updated".to_string() - } -} - -pub(in crate::adapters::cli) fn copilot_studio_metadata( - descriptor: &NativeMcpDescriptor, -) -> serde_json::Value { - json!({ - "kind": "copilot_studio_manual_metadata", - "stdio": descriptor.stdio_entry(true, false), - "http": { - "url": "http://127.0.0.1:8765/mcp", - "start_command": [ - descriptor.command, - "mcp", - "http", - "--config", - descriptor.setup_config_path, - "--host", - "127.0.0.1", - "--port", - "8765", - "--path", - "/mcp" - ], - "host": "127.0.0.1", - "port": 8765, - "path": "/mcp", - }, - "notes": [ - "No local client configuration file is written for Copilot Studio.", - "Remote Copilot Studio use requires user-managed endpoint exposure, bearer-token configuration, and TLS.", - ], - }) -} - -pub(in crate::adapters::cli) fn codex_toml_block(descriptor: &NativeMcpDescriptor) -> String { - format!( - "[mcp_servers.{}]\ncommand = {}\nargs = {}\nstartup_timeout_sec = {}\n", - descriptor.name, - toml_string(&descriptor.command), - toml_array(&descriptor.args), - descriptor.timeout - ) -} - -pub(in crate::adapters::cli) fn toml_array(values: &[String]) -> String { - format!( - "[{}]", - values - .iter() - .map(|value| toml_string(value)) - .collect::>() - .join(", ") - ) -} - -pub(in crate::adapters::cli) fn toml_string(value: &str) -> String { - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) -} - -pub(in crate::adapters::cli) fn upsert_toml_block( - existing: &str, - server_name: &str, - block: &str, -) -> (String, Option) { - let lines = existing.lines().collect::>(); - let header = format!("[mcp_servers.{server_name}]"); - let env_header = format!("[mcp_servers.{server_name}.env]"); - let start = lines - .iter() - .position(|line| line.trim() == header || line.trim() == env_header); - let Some(start) = start else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - let end = lines[start + 1..] - .iter() - .position(|line| { - let trimmed = line.trim(); - trimmed.starts_with('[') - && trimmed.ends_with(']') - && trimmed != header - && trimmed != env_header - }) - .map(|index| start + 1 + index) - .unwrap_or(lines.len()); - let previous = lines[start..end].join("\n").trim_end().to_string(); - let mut next_lines = Vec::new(); - next_lines.extend(lines[..start].iter().map(|value| (*value).to_string())); - next_lines.extend(block.trim_end().lines().map(str::to_string)); - next_lines.extend(lines[end..].iter().map(|value| (*value).to_string())); - ( - next_lines.join("\n").trim_end().to_string() + "\n", - Some(previous), - ) -} - -pub(in crate::adapters::cli) fn hermes_yaml_block(descriptor: &NativeMcpDescriptor) -> String { - let mut lines = vec![ - "# codebaseGraph MCP server start".to_string(), - "mcp_servers:".to_string(), - format!(" {}:", descriptor.name), - " type: stdio".to_string(), - format!(" command: {}", yaml_scalar(&descriptor.command)), - " args:".to_string(), - ]; - for arg in &descriptor.args { - lines.push(format!(" - {}", yaml_scalar(arg))); - } - lines.push("# codebaseGraph MCP server end".to_string()); - lines.join("\n") + "\n" -} - -pub(in crate::adapters::cli) fn yaml_scalar(value: &str) -> String { - format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) -} - -pub(in crate::adapters::cli) fn upsert_marked_block( - existing: &str, - block: &str, -) -> (String, Option) { - const START: &str = "# codebaseGraph MCP server start"; - const END: &str = "# codebaseGraph MCP server end"; - let Some(start) = existing.find(START) else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - let Some(end) = existing.find(END) else { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - }; - if end < start { - let prefix = existing.trim_end(); - let separator = if prefix.is_empty() { "" } else { "\n\n" }; - return (format!("{prefix}{separator}{block}"), None); - } - let after_end = end + END.len(); - let previous = existing[start..after_end].trim_end().to_string(); - let text = format!( - "{}\n\n{}\n\n{}", - existing[..start].trim_end(), - block.trim_end(), - existing[after_end..].trim_start() - ) - .trim() - .to_string() - + "\n"; - (text, Some(previous)) -} diff --git a/src/adapters/cli/install/verify.rs b/src/adapters/cli/install/verify.rs index 962e6a2..a981365 100644 --- a/src/adapters/cli/install/verify.rs +++ b/src/adapters/cli/install/verify.rs @@ -1,29 +1,56 @@ -use super::{executable_in_path, McpInstallOptions, NativeMcpDescriptor}; +use super::McpInstallOptions; use crate::adapters::mcp::LATEST_PROTOCOL_VERSION; use serde_json::json; use std::{ collections::{BTreeMap, BTreeSet}, + env, io::Write, + path::Path, process::Command, }; pub(in crate::adapters::cli) fn attach_install_verification( mut payload: serde_json::Value, - descriptor: &NativeMcpDescriptor, options: &McpInstallOptions, -) -> Result { - if options.verify && !options.dry_run { - payload["verification"] = verify_mcp_install(descriptor, &options.client); +) -> serde_json::Value { + if !options.verify || options.dry_run { + return payload; + } + if let Some(results) = payload + .get_mut("results") + .and_then(serde_json::Value::as_array_mut) + { + for result in results { + attach_result_verification(result); + } + } else { + attach_result_verification(&mut payload); } - Ok(payload) + payload +} + +fn attach_result_verification(result: &mut serde_json::Value) { + if result.get("error").is_some() { + return; + } + let client = result + .get("client") + .and_then(serde_json::Value::as_str) + .unwrap_or("generic"); + let descriptor = result.get("descriptor").cloned().unwrap_or_default(); + result["verification"] = verify_mcp_install(&descriptor, client); } pub(in crate::adapters::cli) fn verify_mcp_install( - descriptor: &NativeMcpDescriptor, + descriptor: &serde_json::Value, client: &str, ) -> serde_json::Value { let stdio = verify_stdio(descriptor); - let visibility = verify_client_visibility(client, &descriptor.name); + let server_name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let visibility = verify_client_visibility(client, server_name); json!({ "ok": stdio.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false) && visibility.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(true), @@ -32,10 +59,14 @@ pub(in crate::adapters::cli) fn verify_mcp_install( }) } -pub(in crate::adapters::cli) fn verify_stdio( - descriptor: &NativeMcpDescriptor, -) -> serde_json::Value { - let command = descriptor_command(descriptor); +pub(in crate::adapters::cli) fn verify_stdio(descriptor: &serde_json::Value) -> serde_json::Value { + let Some(command) = descriptor_command(descriptor) else { + return json!({"ok": false, "error": "install response did not contain a valid descriptor"}); + }; + let descriptor_name = descriptor + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); let payload = [ stdio_json_rpc_message( "initialize", @@ -50,7 +81,7 @@ pub(in crate::adapters::cli) fn verify_stdio( ), stdio_json_rpc_message( "tools/call", - json!({"name": "graph_search", "arguments": {"query": descriptor.name, "limit": 1}}), + json!({"name": "graph_search", "arguments": {"query": descriptor_name, "limit": 1}}), 4, ), stdio_json_rpc_message( @@ -128,11 +159,18 @@ pub(in crate::adapters::cli) fn verify_client_visibility( } pub(in crate::adapters::cli) fn descriptor_command( - descriptor: &NativeMcpDescriptor, -) -> Vec { - let mut command = vec![descriptor.command.clone()]; - command.extend(descriptor.args.clone()); - command + descriptor: &serde_json::Value, +) -> Option> { + let mut command = vec![descriptor.get("command")?.as_str()?.to_string()]; + command.extend( + descriptor + .get("args")? + .as_array()? + .iter() + .map(|value| value.as_str().map(str::to_string)) + .collect::>>()?, + ); + Some(command) } pub(in crate::adapters::cli) fn stdio_json_rpc_message( @@ -234,3 +272,13 @@ pub(in crate::adapters::cli) fn visibility_command(client: &str) -> Option None, } } + +fn executable_in_path(executable: &str) -> bool { + let path = Path::new(executable); + if path.components().count() > 1 { + return path.is_file(); + } + env::var_os("PATH") + .map(|paths| env::split_paths(&paths).any(|dir| dir.join(executable).is_file())) + .unwrap_or(false) +} diff --git a/src/adapters/cli/mod.rs b/src/adapters/cli/mod.rs index 7d83467..f8a6657 100644 --- a/src/adapters/cli/mod.rs +++ b/src/adapters/cli/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod constants; mod dispatch; pub(crate) mod format; pub(crate) mod graph; diff --git a/src/adapters/cli/setup.rs b/src/adapters/cli/setup.rs index 001bb90..2e3051e 100644 --- a/src/adapters/cli/setup.rs +++ b/src/adapters/cli/setup.rs @@ -5,8 +5,6 @@ use crate::api::contracts::{ use crate::api::CodebaseGraphApi; use std::io::Write; -pub(crate) use crate::api::lifecycle::{safe_name, GraphStatePaths}; - pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), String> { let options = SetupOptions::parse(args)?; if options.help { diff --git a/src/adapters/cli/uninstall.rs b/src/adapters/cli/uninstall.rs index 75628c4..7067cde 100644 --- a/src/adapters/cli/uninstall.rs +++ b/src/adapters/cli/uninstall.rs @@ -1,7 +1,4 @@ -use super::{ - format::uninstall_help, - install::{supported_install_clients, supported_install_clients_with_all}, -}; +use super::format::uninstall_help; use crate::adapters::required_arg; use crate::api::{ contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, @@ -49,12 +46,6 @@ impl UninstallOptions { } "--mcp-client" => { let client = required_arg(args, index, "--mcp-client")?; - if client != "all" && !supported_install_clients().contains(&client) { - return Err(format!( - "--mcp-client must be one of {}", - supported_install_clients_with_all().join(", ") - )); - } options.mcp_client = client.to_string(); index += 2; } diff --git a/src/adapters/cli/util.rs b/src/adapters/cli/util.rs index be9adc8..794cda3 100644 --- a/src/adapters/cli/util.rs +++ b/src/adapters/cli/util.rs @@ -1,165 +1,5 @@ -use std::{ - env, fs, - path::{Path, PathBuf}, -}; - -pub(super) fn read_json_file(path: &Path) -> Result { - let text = fs::read_to_string(path) - .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?; - serde_json::from_str(&text) - .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display())) -} - -pub(super) fn resolve_repo_root(explicit: Option<&Path>) -> Result { - resolve_root(explicit, "repo root") -} - -fn resolve_root(explicit: Option<&Path>, label: &str) -> Result { - if let Some(path) = explicit { - return path - .canonicalize() - .map_err(|error| format!("failed to resolve {label}: {error}")); - } - - let current_dir = - env::current_dir().map_err(|error| format!("failed to read current directory: {error}"))?; - if let Some(root) = discover_codebase_graph_root(¤t_dir)? { - return Ok(root); - } - if let Some(root) = discover_git_root(¤t_dir) { - return Ok(root); - } - Ok(current_dir) -} - -fn discover_codebase_graph_root(start: &Path) -> Result, String> { - for ancestor in start.ancestors() { - let config_path = ancestor.join(".codebaseGraph").join("config.json"); - if !config_path.exists() { - continue; - } - let config = read_json_file(&config_path)?; - if let Some(repo_root) = config.get("repo_root").and_then(serde_json::Value::as_str) { - let path = PathBuf::from(repo_root); - return Ok(Some(path.canonicalize().unwrap_or(path))); - } - return Ok(Some(ancestor.to_path_buf())); - } - Ok(None) -} - -fn discover_git_root(start: &Path) -> Option { - start - .ancestors() - .find(|ancestor| ancestor.join(".git").exists()) - .map(Path::to_path_buf) -} - pub(super) fn parse_usize_arg(args: &[String], index: usize, name: &str) -> Result { crate::adapters::required_arg(args, index, name)? .parse::() .map_err(|error| format!("{name} must be an integer: {error}")) } - -#[cfg(test)] -mod tests { - use super::*; - - fn unique_temp_dir(prefix: &str) -> PathBuf { - env::temp_dir().join(format!( - "{prefix}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )) - } - - #[test] - fn resolve_repo_root_uses_explicit_path() { - let root = unique_temp_dir("codebase-graph-explicit-root"); - let nested = root.join("nested"); - fs::create_dir_all(&nested).unwrap(); - - let resolved = resolve_repo_root(Some(&nested)).unwrap(); - - assert_eq!(resolved, nested.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_finds_codebase_graph_config_ancestor() { - let root = unique_temp_dir("codebase-graph-config-root"); - let nested = root.join("src").join("cli"); - fs::create_dir_all(root.join(".codebaseGraph")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - fs::write(root.join(".codebaseGraph").join("config.json"), "{}").unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_uses_repo_root_from_config() { - let root = unique_temp_dir("codebase-graph-config-repo-root"); - let real_root = root.join("real"); - let command_root = root.join("command"); - let nested = command_root.join("src"); - fs::create_dir_all(&real_root).unwrap(); - fs::create_dir_all(command_root.join(".codebaseGraph")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - fs::write( - command_root.join(".codebaseGraph").join("config.json"), - serde_json::to_string(&serde_json::json!({ - "repo_root": real_root - })) - .unwrap(), - ) - .unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, real_root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_falls_back_to_git_ancestor() { - let root = unique_temp_dir("codebase-graph-git-root"); - let nested = root.join("src").join("cli"); - fs::create_dir_all(root.join(".git")).unwrap(); - fs::create_dir_all(&nested).unwrap(); - - let resolved = resolve_root_from(&nested).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn resolve_root_falls_back_to_start_directory() { - let root = unique_temp_dir("codebase-graph-current-root"); - fs::create_dir_all(&root).unwrap(); - - let resolved = resolve_root_from(&root).unwrap(); - - assert_eq!(resolved, root.canonicalize().unwrap()); - let _ = fs::remove_dir_all(root); - } - - fn resolve_root_from(start: &Path) -> Result { - let start = start - .canonicalize() - .map_err(|error| format!("failed to resolve test root: {error}"))?; - if let Some(root) = discover_codebase_graph_root(&start)? { - return Ok(root); - } - if let Some(root) = discover_git_root(&start) { - return Ok(root); - } - Ok(start) - } -} diff --git a/src/adapters/cli/watch/options.rs b/src/adapters/cli/watch/options.rs index 34f9c7c..ba756ca 100644 --- a/src/adapters/cli/watch/options.rs +++ b/src/adapters/cli/watch/options.rs @@ -1,6 +1,4 @@ -use crate::adapters::cli::{ - format::setup_help, install::supported_install_clients, materialization::MaterializeOptions, -}; +use crate::adapters::cli::{format::setup_help, materialization::MaterializeOptions}; use std::path::PathBuf; #[derive(Debug)] @@ -167,12 +165,6 @@ impl SetupOptions { let value = args .get(index + 1) .ok_or_else(|| "--mcp-client requires a client id".to_string())?; - if value != "none" && !supported_install_clients().contains(&value.as_str()) { - return Err(format!( - "--mcp-client must be none or one of {}", - supported_install_clients().join(", ") - )); - } options.mcp_client = value.clone(); index += 2; } diff --git a/src/api/contracts.rs b/src/api/contracts.rs index de2c244..0b7178a 100644 --- a/src/api/contracts.rs +++ b/src/api/contracts.rs @@ -32,6 +32,7 @@ pub enum OperationRequest { Setup(RepositoryLifecycleRequest), Reinstall(RepositoryLifecycleRequest), Uninstall(RepositoryLifecycleRequest), + InstallMcp(McpInstallRequest), Refresh(RefreshRequest), } @@ -48,6 +49,7 @@ impl OperationRequest { Self::Setup(_) => "setup", Self::Reinstall(_) => "reinstall", Self::Uninstall(_) => "uninstall", + Self::InstallMcp(_) => "mcp-install", Self::Refresh(_) => "refresh", } } @@ -64,6 +66,7 @@ impl OperationRequest { Self::Setup(request) => Some(&request.repo), Self::Reinstall(request) => Some(&request.repo), Self::Uninstall(request) => Some(&request.repo), + Self::InstallMcp(request) => Some(&request.repo), Self::Refresh(request) => Some(&request.repo), } } @@ -80,6 +83,7 @@ impl OperationRequest { | Self::Setup(RepositoryLifecycleRequest { output_format, .. }) | Self::Reinstall(RepositoryLifecycleRequest { output_format, .. }) | Self::Uninstall(RepositoryLifecycleRequest { output_format, .. }) + | Self::InstallMcp(McpInstallRequest { output_format, .. }) | Self::Refresh(RefreshRequest { output_format, .. }) => *output_format, } } @@ -172,6 +176,17 @@ pub struct RepositoryLifecycleRequest { pub semantic_provider_mode: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpInstallRequest { + pub repo: RepoSelector, + pub client: String, + pub scope: String, + pub name: Option, + pub client_config_path: Option, + pub dry_run: bool, + pub output_format: OutputFormat, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefreshRequest { pub repo: RepoSelector, @@ -338,6 +353,15 @@ mod tests { OperationRequest::Setup(lifecycle("setup")), OperationRequest::Reinstall(lifecycle("reinstall")), OperationRequest::Uninstall(lifecycle("uninstall")), + OperationRequest::InstallMcp(McpInstallRequest { + repo: repository(), + client: "generic".to_string(), + scope: "local".to_string(), + name: Some("codebase_graph".to_string()), + client_config_path: Some(PathBuf::from("/tmp/mcp.json")), + dry_run: true, + output_format: OutputFormat::Typed, + }), OperationRequest::Refresh(RefreshRequest { repo: repository(), paths: vec!["src/lib.rs".to_string()], diff --git a/src/api/core.rs b/src/api/core.rs index 5639f6f..56bbd3f 100644 --- a/src/api/core.rs +++ b/src/api/core.rs @@ -9,7 +9,8 @@ use crate::api::graph_read::{ validate_read_only_statement, GraphSearchRequest, }; use crate::api::lifecycle::{ - refresh_repository, reinstall_repository, setup_repository, uninstall_repository, + install_mcp_client, refresh_repository, reinstall_repository, setup_repository, + uninstall_repository, }; use crate::api::materialization::{ build_request, execute_materialization_request, plan_materialization_payload, read_request, @@ -152,6 +153,14 @@ impl ApiCore { supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], mcp_tool_name: None, }, + OperationDescriptor { + id: "mcp-install", + summary: "Register the graph tool with a supported client", + request_schema: empty_request_schema, + surfaces: &["api", "cli"], + supported_outputs: &[OutputFormat::Typed, OutputFormat::Block], + mcp_tool_name: None, + }, OperationDescriptor { id: "refresh", summary: "Refresh changed paths via incremental materialization", @@ -218,6 +227,7 @@ fn build_registry(descriptors: impl IntoIterator) -> "setup" => handle_setup, "reinstall" => handle_reinstall, "uninstall" => handle_uninstall, + "mcp-install" => handle_mcp_install, "refresh" => handle_refresh, id => panic!("operation {id} does not have a handler registration"), }; @@ -443,6 +453,20 @@ fn handle_uninstall( )) } +fn handle_mcp_install( + request: &OperationRequest, + runtime: Option<&RepoRuntime>, +) -> Result { + let OperationRequest::InstallMcp(request) = request else { + return Err(invalid_request("mcp-install", request)); + }; + Ok(OperationResponse::from_payload( + "mcp-install", + OutputFormat::Typed, + install_mcp_client(request, required_runtime("mcp-install", runtime)?)?, + )) +} + fn handle_refresh( request: &OperationRequest, runtime: Option<&RepoRuntime>, @@ -693,8 +717,21 @@ fn materialization_payload( #[cfg(test)] mod tests { - use crate::api::contracts::OperationRequest; + use crate::api::contracts::{McpInstallRequest, OperationRequest, OutputFormat, RepoSelector}; use crate::api::core::ApiCore; + use serde_json::json; + use std::{fs, path::PathBuf}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } #[test] fn operation_registry_is_unique_and_sorted() { @@ -758,4 +795,49 @@ mod tests { .expect("registered schema handler should execute"); assert_eq!(response.operation, "schema"); } + + #[test] + fn mcp_install_operation_preserves_unrelated_client_configuration() { + let root = unique_temp_dir("codebase-graph-api-mcp-install"); + let client_config = root.join("client").join("mcp.json"); + fs::create_dir_all(client_config.parent().unwrap()).unwrap(); + fs::write( + &client_config, + serde_json::to_string_pretty(&json!({ + "mcpServers": { + "unrelated": {"command": "keep", "args": []} + } + })) + .unwrap(), + ) + .unwrap(); + + let response = ApiCore::new() + .execute(&OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector { + repo_root: Some(root.clone()), + config_path: None, + db_path: None, + manifest_path: None, + }, + client: "generic".to_string(), + scope: "local".to_string(), + name: Some("codebase_graph_test".to_string()), + client_config_path: Some(client_config.clone()), + dry_run: false, + output_format: OutputFormat::Typed, + })) + .expect("API MCP install should succeed"); + + assert_eq!(response.operation, "mcp-install"); + assert_eq!(response.payload["action"], "updated"); + let payload: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&client_config).unwrap()).unwrap(); + assert_eq!(payload["mcpServers"]["unrelated"]["command"], "keep"); + assert_eq!( + payload["mcpServers"]["codebase_graph_test"]["command"], + "codebase-graph" + ); + let _ = fs::remove_dir_all(root); + } } diff --git a/src/api/lifecycle.rs b/src/api/lifecycle.rs index 3210c97..677881b 100644 --- a/src/api/lifecycle.rs +++ b/src/api/lifecycle.rs @@ -1,5 +1,7 @@ use crate::api::context::RepoRuntime; -use crate::api::contracts::{ApiError, RefreshRequest, RepositoryLifecycleRequest}; +use crate::api::contracts::{ + ApiError, McpInstallRequest, RefreshRequest, RepositoryLifecycleRequest, +}; use crate::api::materialization::{ build_request, default_excluded_parts, execute_candidate_materialization, execute_materialization, MaterializeOptions, @@ -42,6 +44,49 @@ pub(crate) fn uninstall_repository( .map_err(|error| ApiError::new("uninstall_failed", error)) } +pub(crate) fn install_mcp_client( + request: &McpInstallRequest, + runtime: &RepoRuntime, +) -> Result { + let config_path = runtime.config_path.clone(); + let install = |client: &str| { + apply_mcp_client_configuration( + client, + &request.scope, + request.name.clone(), + config_path.clone(), + request.client_config_path.clone(), + Some(runtime.repo_root.clone()), + request.dry_run, + ) + }; + if request.client == "all" { + let results = supported_mcp_clients() + .iter() + .copied() + .map(|client| { + install(client).unwrap_or_else(|error| { + json!({ + "action": "failed", + "client": client, + "scope": install_scope(client, &request.scope), + "server_name": request.name.clone().unwrap_or_else(|| "codebase_graph".to_string()), + "method": serde_json::Value::Null, + "path": serde_json::Value::Null, + "command": serde_json::Value::Null, + "descriptor": {}, + "entry": {}, + "error": error, + }) + }) + }) + .collect::>(); + Ok(json!({ "results": results })) + } else { + install(&request.client).map_err(|error| ApiError::new("mcp_install_failed", error)) + } +} + fn validate_lifecycle_action( request: &RepositoryLifecycleRequest, expected: &str, @@ -517,7 +562,7 @@ fn setup_mcp_config( })); } - install_mcp_client( + apply_mcp_client_configuration( &options.mcp_client, if options.mcp_client == "claude-project" { "project" @@ -610,8 +655,9 @@ fn uninstall_mcp_clients( dry_run: bool, ) -> Result, String> { let clients = if mcp_client == "all" { - supported_install_clients() - .into_iter() + supported_mcp_clients() + .iter() + .copied() .map(str::to_string) .collect::>() } else { @@ -1213,7 +1259,7 @@ fn build_mcp_descriptor( }) } -fn install_mcp_client( +fn apply_mcp_client_configuration( client: &str, scope: &str, name: Option, @@ -1402,8 +1448,8 @@ fn default_client_config_path( } } -fn supported_install_clients() -> Vec<&'static str> { - vec![ +pub fn supported_mcp_clients() -> &'static [&'static str] { + &[ "claude", "claude-project", "codex", @@ -1975,7 +2021,7 @@ fn write_text_atomic(path: &Path, text: &str) -> Result<(), String> { .map_err(|error| format!("failed to replace config {}: {error}", path.display())) } -fn expand_path(value: &str) -> PathBuf { +pub(crate) fn expand_path(value: &str) -> PathBuf { if let Some(rest) = value.strip_prefix("~/") { return home_dir().join(rest); } diff --git a/src/api/mod.rs b/src/api/mod.rs index 32631b1..18e80e6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -18,12 +18,13 @@ pub mod presenter; pub(crate) mod refresh; pub use contracts::{ - ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationRequest, - OperationResponse, OutputFormat, QueryRequest, RefreshRequest, RepoSelector, + ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, + OperationRequest, OperationResponse, OutputFormat, QueryRequest, RefreshRequest, RepoSelector, RepositoryLifecycleRequest, SearchRequest, }; pub use core::OperationDescriptor; pub use facade::{CodebaseGraphApi, OperationExecutor}; +pub use lifecycle::supported_mcp_clients; #[cfg(test)] mod boundary_tests; diff --git a/src/api/normalization.rs b/src/api/normalization.rs index 8347b2a..5cd640e 100644 --- a/src/api/normalization.rs +++ b/src/api/normalization.rs @@ -1,6 +1,8 @@ use crate::api::contracts::{ - ApiError, MaterializationRequest, OperationRequest, RefreshRequest, RepositoryLifecycleRequest, + ApiError, MaterializationRequest, McpInstallRequest, OperationRequest, RefreshRequest, + RepositoryLifecycleRequest, }; +use crate::api::lifecycle::{expand_path, supported_mcp_clients}; use crate::api::materialization::MaterializeOptions; const DEFAULT_PROFILE: &str = "brief"; @@ -38,6 +40,7 @@ pub(crate) fn normalize_request(request: &OperationRequest) -> OperationRequest | OperationRequest::Uninstall(request) => { normalize_lifecycle(request); } + OperationRequest::InstallMcp(request) => normalize_mcp_install(request), OperationRequest::Refresh(request) => normalize_refresh(request), OperationRequest::Catalog { kind, group, .. } => { *kind = kind.trim().to_string(); @@ -113,6 +116,7 @@ pub(crate) fn validate_request(request: &OperationRequest) -> Result<(), ApiErro OperationRequest::Setup(request) => validate_lifecycle(request, "setup"), OperationRequest::Reinstall(request) => validate_lifecycle(request, "reinstall"), OperationRequest::Uninstall(request) => validate_lifecycle(request, "uninstall"), + OperationRequest::InstallMcp(request) => validate_mcp_install(request), OperationRequest::Catalog { kind, .. } => { if kind.is_empty() { Err(ApiError::new( @@ -169,6 +173,26 @@ fn normalize_lifecycle(request: &mut RepositoryLifecycleRequest) { request.instructions_target = normalized_optional_string(request.instructions_target.take()); } +fn normalize_mcp_install(request: &mut McpInstallRequest) { + request.client = request.client.trim().to_ascii_lowercase(); + request.scope = request.scope.trim().to_ascii_lowercase(); + request.name = normalized_optional_string(request.name.take()); + request.client_config_path = request + .client_config_path + .take() + .map(|path| expand_path(&path.to_string_lossy())); + request.repo.repo_root = request + .repo + .repo_root + .take() + .map(|path| expand_path(&path.to_string_lossy())); + request.repo.config_path = request + .repo + .config_path + .take() + .map(|path| expand_path(&path.to_string_lossy())); +} + fn validate_search_fields(query: &str, detail: &str, limit: usize) -> Result<(), ApiError> { if query.trim().is_empty() { return Err(ApiError::new( @@ -232,7 +256,42 @@ fn validate_lifecycle( &request.mode, &request.semantic_provider_mode, expected_action, - ) + )?; + if let Some(client) = request.mcp_client.as_deref() { + let special_allowed = match expected_action { + "uninstall" => client == "all", + _ => client == "none", + }; + if !special_allowed && !supported_mcp_clients().contains(&client) { + return Err(ApiError::new( + "invalid_mcp_client", + format!("unsupported MCP client: {client}"), + )); + } + } + Ok(()) +} + +fn validate_mcp_install(request: &McpInstallRequest) -> Result<(), ApiError> { + if request.client != "all" && !supported_mcp_clients().contains(&request.client.as_str()) { + return Err(ApiError::new( + "invalid_mcp_client", + format!("unsupported MCP client: {}", request.client), + )); + } + if !matches!(request.scope.as_str(), "local" | "user" | "project") { + return Err(ApiError::new( + "invalid_mcp_scope", + "MCP install scope must be local, user, or project", + )); + } + if request.client == "all" && request.client_config_path.is_some() { + return Err(ApiError::new( + "invalid_mcp_config_path", + "client_config_path requires one selected MCP client", + )); + } + Ok(()) } fn default_string(value: &mut String, default: &str) { @@ -263,8 +322,8 @@ fn normalize_paths(paths: &mut Vec) { mod tests { use super::{normalize_request, required_fields, validate_request}; use crate::api::contracts::{ - ContextRequest, MaterializationRequest, OperationRequest, OutputFormat, RepoSelector, - SearchRequest, + ContextRequest, MaterializationRequest, McpInstallRequest, OperationRequest, OutputFormat, + RepoSelector, SearchRequest, }; #[test] @@ -337,6 +396,29 @@ mod tests { assert_eq!(error.code, "invalid_node_reference"); } + #[test] + fn validate_request_rejects_unsupported_mcp_clients_and_scopes() { + let request = |client: &str, scope: &str| { + OperationRequest::InstallMcp(McpInstallRequest { + repo: RepoSelector::default(), + client: client.to_string(), + scope: scope.to_string(), + name: None, + client_config_path: None, + dry_run: true, + output_format: OutputFormat::Typed, + }) + }; + + let client_error = validate_request(&request("unknown-client", "local")) + .expect_err("unsupported clients should be rejected by the API"); + assert_eq!(client_error.code, "invalid_mcp_client"); + + let scope_error = validate_request(&request("generic", "unknown-scope")) + .expect_err("unsupported scopes should be rejected by the API"); + assert_eq!(scope_error.code, "invalid_mcp_scope"); + } + #[test] fn operation_schema_requirements_share_normalization_policy() { assert_eq!(required_fields("search"), ["query"]); From 309a16512ad548dd198555a5ac97ae916076dde4 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 17:21:23 +0930 Subject: [PATCH 07/19] refactor(api): keep MCP adapters transport-only Move registered invocation preparation, canonical defaults, MCP operation lookup, and refresh-safe graph reads behind the public API facade. MCP now parses transport fields and frames responses without constructing product requests or selecting read-lock policy. Constraint: Preserve existing MCP JSON-RPC schemas and response framing. Rejected: Keep hardcoded MCP dispatch with shared constants | operation aliases and refresh policy would still drift across boundaries. Confidence: high Scope-risk: moderate Directive: Keep product request defaults and refresh consistency policy under src/api. Tested: cargo test --all-targets --all-features; cargo clippy --all-targets --all-features -- -D warnings; cargo fmt --all -- --check Not-tested: External third-party MCP client interoperability beyond repository stdio and HTTP suites. --- src/adapters/mcp/options.rs | 10 ++ src/adapters/mcp/refresh.rs | 12 +- src/adapters/mcp/tools.rs | 233 ++++++------------------------------ src/api/boundary_tests.rs | 24 ++++ src/api/contracts.rs | 20 ++++ src/api/core.rs | 125 ++++++++++++++++--- src/api/facade.rs | 22 +++- src/api/mod.rs | 4 +- src/api/normalization.rs | 148 +++++++++++++++++++++-- 9 files changed, 362 insertions(+), 236 deletions(-) diff --git a/src/adapters/mcp/options.rs b/src/adapters/mcp/options.rs index 20b6737..f975c54 100644 --- a/src/adapters/mcp/options.rs +++ b/src/adapters/mcp/options.rs @@ -1,6 +1,7 @@ use super::http::is_local_host; use super::refresh::McpRefreshState; use crate::adapters::required_arg; +use crate::api::RepoSelector; use std::{env, net::TcpListener, path::PathBuf, sync::Arc}; #[derive(Clone, Debug)] @@ -49,6 +50,15 @@ impl McpServeOptions { } Ok(options) } + + pub(in crate::adapters) fn repo_selector(&self) -> RepoSelector { + RepoSelector { + repo_root: self.repo_root.clone(), + config_path: self.config.clone(), + db_path: self.db.clone(), + manifest_path: self.manifest.clone(), + } + } } #[derive(Clone, Debug)] diff --git a/src/adapters/mcp/refresh.rs b/src/adapters/mcp/refresh.rs index 31932aa..ef0cb72 100644 --- a/src/adapters/mcp/refresh.rs +++ b/src/adapters/mcp/refresh.rs @@ -1,17 +1,9 @@ use super::options::McpServeOptions; -use crate::api::{ - contracts::RepoSelector, - refresh::{start_refresh_service, RefreshState}, -}; +use crate::api::refresh::{start_refresh_service, RefreshState}; use std::sync::Arc; pub(in crate::adapters) type McpRefreshState = RefreshState; pub(in crate::adapters) fn start_auto_refresh(options: &McpServeOptions) -> Arc { - start_refresh_service(RepoSelector { - repo_root: options.repo_root.clone(), - config_path: options.config.clone(), - db_path: options.db.clone(), - manifest_path: options.manifest.clone(), - }) + start_refresh_service(options.repo_selector()) } diff --git a/src/adapters/mcp/tools.rs b/src/adapters/mcp/tools.rs index 72a771f..84e01f6 100644 --- a/src/adapters/mcp/tools.rs +++ b/src/adapters/mcp/tools.rs @@ -1,10 +1,7 @@ use super::{block::serialize_error_block, options::McpServeOptions}; use crate::api::normalization::required_fields; use crate::api::{ - contracts::{ - ApiError, ContextRequest, HealthRequest, OperationRequest, OperationResponse, OutputFormat, - QueryRequest, RepoSelector, SearchRequest, - }, + contracts::{ApiError, OperationInvocation, OperationResponse, OutputFormat}, CodebaseGraphApi, OperationDescriptor, }; use serde_json::json; @@ -64,16 +61,8 @@ pub(in crate::adapters) fn mcp_call_tool_result( arguments: &serde_json::Value, options: &McpServeOptions, ) -> Result { - let output_format = arguments - .get("output_format") - .and_then(serde_json::Value::as_str) - .unwrap_or("block"); - let output_format = match output_format { - "json" => "json", - "block" => "block", - _ => return Err("graph tool output_format must be \"json\" or \"block\"".to_string()), - }; - let response = mcp_tool_payload(tool_name, arguments, options); + let output_format = parse_output_format(arguments).map_err(|error| error.message)?; + let response = mcp_tool_payload(tool_name, arguments, options, output_format); let include_structured = arguments .get("include_structured_content") .and_then(serde_json::Value::as_bool) @@ -81,7 +70,7 @@ pub(in crate::adapters) fn mcp_call_tool_result( match response { Ok(response) => { let payload = response.payload; - let (text, structured) = if output_format == "json" { + let (text, structured) = if output_format == OutputFormat::Typed { ( serde_json::to_string(&payload).map_err(|error| error.to_string())?, payload.clone(), @@ -107,15 +96,10 @@ pub(in crate::adapters) fn mcp_call_tool_result( } Ok(result) } - Err(error) - if tool_name.is_empty() - || error.message.starts_with("Unknown codebaseGraph MCP tool") => - { - Err(error.message) - } + Err(error) if tool_name.is_empty() || error.code == "unknown_tool" => Err(error.message), Err(error) => { let payload = map_error_to_transport(tool_name, &error); - let text = if output_format == "json" { + let text = if output_format == OutputFormat::Typed { serde_json::to_string(&payload).map_err(|error| error.to_string())? } else { serialize_error_block(&payload) @@ -136,192 +120,40 @@ pub(in crate::adapters) fn mcp_tool_payload( tool_name: &str, arguments: &serde_json::Value, options: &McpServeOptions, + output_format: OutputFormat, ) -> Result { - let _refresh_read_guard = if matches!( - tool_name, - "graph_health" | "graph_search" | "graph_context" | "graph_query" - ) { - options - .refresh - .as_ref() - .map(|refresh| refresh.read_guard()) - .transpose() - .map_err(|error| ApiError::new("refresh_lock_failed", error))? - } else { - None - }; - let output_format = match arguments + let api = CodebaseGraphApi::with_refresh_state(options.refresh.clone()); + let operation = api.resolve_mcp_operation(tool_name).ok_or_else(|| { + ApiError::new( + "unknown_tool", + format!("Unknown codebaseGraph MCP tool: {tool_name}"), + ) + })?; + api.execute_invocation( + operation.id, + &OperationInvocation { + repo: options.repo_selector(), + arguments: arguments.clone(), + output_format, + }, + ) +} + +fn parse_output_format(arguments: &serde_json::Value) -> Result { + match arguments .get("output_format") .and_then(serde_json::Value::as_str) .unwrap_or("block") { - "json" => OutputFormat::Typed, - "block" => OutputFormat::Block, - _ => { - return Err(ApiError::new( - "invalid_output_format", - "graph tool output_format must be \"json\" or \"block\"", - )) - } - }; - match tool_name { - "graph_health" => execute_api_request(OperationRequest::Health(HealthRequest { - repo: repo_selector_from_mcp_options(options), - refresh_status: options - .refresh - .as_ref() - .map(|refresh| serde_json::to_value(refresh.as_json()).unwrap_or_default()), - output_format, - })), - "graph_schema" => execute_api_request(OperationRequest::Catalog { - kind: "schema".to_string(), - group: None, - output_format, - }), - "graph_query_helpers" => execute_api_request(OperationRequest::Catalog { - kind: "query-helpers".to_string(), - group: None, - output_format, - }), - "graph_architecture_queries" => execute_api_request(OperationRequest::Catalog { - kind: "architecture-queries".to_string(), - group: arguments - .get("group") - .and_then(serde_json::Value::as_str) - .map(str::to_string), - output_format, - }), - "graph_search" => { - let search = search_request_from_mcp(arguments, options, output_format); - execute_api_request(OperationRequest::Search(search)) - } - "graph_context" => { - let context = context_request_from_mcp(arguments, options, output_format); - execute_api_request(OperationRequest::Context(context)) - } - "graph_query" => { - let statement = arguments - .get("statement") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let parameters = arguments - .get("parameters") - .cloned() - .unwrap_or_else(|| json!({})); - let limit = arguments - .get("limit") - .and_then(serde_json::Value::as_u64) - .unwrap_or(100) as usize; - execute_api_request(OperationRequest::Query(QueryRequest { - repo: repo_selector_from_mcp_options(options), - statement, - parameters, - limit, - output_format, - })) - } + "json" => Ok(OutputFormat::Typed), + "block" => Ok(OutputFormat::Block), _ => Err(ApiError::new( - "unknown_tool", - format!("Unknown codebaseGraph MCP tool: {tool_name}"), + "invalid_output_format", + "graph tool output_format must be \"json\" or \"block\"", )), } } -fn execute_api_request(request: OperationRequest) -> Result { - CodebaseGraphApi::new().execute_operation(&request) -} - -fn repo_selector_from_mcp_options(options: &McpServeOptions) -> RepoSelector { - RepoSelector { - repo_root: options.repo_root.clone(), - config_path: options.config.clone(), - db_path: options.db.clone(), - manifest_path: options.manifest.clone(), - } -} - -fn search_request_from_mcp( - arguments: &serde_json::Value, - options: &McpServeOptions, - output_format: OutputFormat, -) -> SearchRequest { - let query = arguments - .get("query") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let detail = arguments - .get("detail") - .and_then(serde_json::Value::as_str) - .unwrap_or("standard"); - SearchRequest { - repo: repo_selector_from_mcp_options(options), - query, - limit: json_usize(arguments, "limit", 3), - profile: arguments - .get("profile") - .and_then(serde_json::Value::as_str) - .unwrap_or("brief") - .to_string(), - budget: json_usize(arguments, "budget", 600), - context_limit: json_usize(arguments, "context_limit", 3), - max_depth: arguments - .get("max_depth") - .and_then(serde_json::Value::as_u64) - .map(|value| value as usize), - detail: detail.to_string(), - output_format, - } -} - -fn context_request_from_mcp( - arguments: &serde_json::Value, - options: &McpServeOptions, - output_format: OutputFormat, -) -> ContextRequest { - let node_id = arguments - .get("node_id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let node_type = arguments - .get("node_type") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let search = search_request_from_mcp(arguments, options, output_format); - ContextRequest { - repo: search.repo, - query: if search.query.is_empty() { - None - } else { - Some(search.query) - }, - profile: search.profile, - limit: search.limit, - budget: search.budget, - context_limit: search.context_limit, - max_depth: search.max_depth, - detail: search.detail, - node_id, - node_type, - output_format, - } -} - -pub(in crate::adapters) fn json_usize( - arguments: &serde_json::Value, - key: &str, - default: usize, -) -> usize { - arguments - .get(key) - .and_then(serde_json::Value::as_u64) - .map(|value| value as usize) - .unwrap_or(default) -} - #[cfg(test)] mod tests { use super::*; @@ -369,6 +201,11 @@ mod tests { search["inputSchema"]["properties"]["detail"]["enum"], json!(["standard", "slim"]) ); + assert_eq!( + search["inputSchema"]["properties"]["profile"]["default"], + "brief" + ); + assert_eq!(search["inputSchema"]["properties"]["limit"]["default"], 3); assert_eq!(search["inputSchema"]["required"], json!(["query"])); } } diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs index ed41e59..dae33bf 100644 --- a/src/api/boundary_tests.rs +++ b/src/api/boundary_tests.rs @@ -114,6 +114,30 @@ fn transport_adapters_do_not_import_product_execution_services() { } } +#[test] +fn mcp_tools_leave_product_request_and_refresh_policy_in_the_api() { + let source = include_str!("../adapters/mcp/tools.rs"); + let forbidden = [ + "SearchRequest", + "ContextRequest", + "QueryRequest", + ".read_guard()", + "\"graph_search\" =>", + "unwrap_or(\"brief\")", + "unwrap_or(\"standard\")", + ]; + + for policy in forbidden { + assert!( + !source.contains(policy), + "MCP tools contain API-owned product policy: {policy}" + ); + } + assert!(source.contains("execute_invocation")); + assert!(source.contains("resolve_mcp_operation")); + assert!(!source.contains("mcp_tool_name == Some(tool_name)")); +} + #[test] fn only_graph_writer_submits_database_updates() { let graph_writer = include_str!("../staging_writer/writer.rs"); diff --git a/src/api/contracts.rs b/src/api/contracts.rs index 0b7178a..0ff9317 100644 --- a/src/api/contracts.rs +++ b/src/api/contracts.rs @@ -36,6 +36,13 @@ pub enum OperationRequest { Refresh(RefreshRequest), } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OperationInvocation { + pub repo: RepoSelector, + pub arguments: serde_json::Value, + pub output_format: OutputFormat, +} + impl OperationRequest { pub fn operation_name(&self) -> &str { match self { @@ -421,5 +428,18 @@ mod tests { let source = include_str!("contracts.rs"); assert!(!source.contains(&["crate", "::cli"].concat())); assert!(!source.contains(&["mcp", "::"].concat())); + + let invocation = OperationInvocation { + repo: repository(), + arguments: json!({"query": "needle"}), + output_format: OutputFormat::Block, + }; + let encoded = serde_json::to_value(&invocation).expect("invocation should serialize"); + let decoded: OperationInvocation = + serde_json::from_value(encoded.clone()).expect("invocation should deserialize"); + assert_eq!( + serde_json::to_value(decoded).expect("decoded invocation should serialize"), + encoded + ); } } diff --git a/src/api/core.rs b/src/api/core.rs index 56bbd3f..30802e9 100644 --- a/src/api/core.rs +++ b/src/api/core.rs @@ -1,8 +1,8 @@ use crate::api::catalog::{filter_catalog, load_catalog}; use crate::api::context::{resolve_runtime, RepoRuntime}; use crate::api::contracts::{ - ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationRequest, - OperationResponse, OutputFormat, QueryRequest, SearchRequest, + ApiError, ContextRequest, HealthRequest, MaterializationRequest, OperationInvocation, + OperationRequest, OperationResponse, OutputFormat, QueryRequest, SearchRequest, }; use crate::api::graph_read::{ count_graph_nodes, execute_graph_context, execute_graph_search, execute_read_only_query, @@ -16,12 +16,17 @@ use crate::api::materialization::{ build_request, execute_materialization_request, plan_materialization_payload, read_request, MaterializeOptions, }; -use crate::api::normalization::{normalize_request, validate_request}; +use crate::api::normalization::{ + normalize_request, prepare_operation_request, validate_request, DEFAULT_CONTEXT_LIMIT, + DEFAULT_DETAIL, DEFAULT_PROFILE, DEFAULT_QUERY_LIMIT, DEFAULT_SEARCH_BUDGET, + DEFAULT_SEARCH_LIMIT, +}; use crate::api::presenter::present_operation_response; +use crate::api::refresh::RefreshState; use crate::error::NativeError; use crate::protocol::{NativeSyntaxMaterializationRequest, NativeSyntaxMaterializationResponse}; use serde_json::json; -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc}; #[derive(Debug, Clone, Copy)] pub struct OperationDescriptor { @@ -48,11 +53,17 @@ pub(crate) struct RegisteredOperation { } #[derive(Debug, Clone, Default)] -pub struct ApiCore; +pub struct ApiCore { + refresh: Option>, +} impl ApiCore { pub fn new() -> Self { - Self + Self::default() + } + + pub(crate) fn with_refresh_state(refresh: Option>) -> Self { + Self { refresh } } pub(crate) fn register_operations(&self) -> OperationRegistry { @@ -178,9 +189,29 @@ impl ApiCore { self.register_operations().operations.get(id).copied() } + pub(crate) fn resolve_mcp_operation(&self, tool_name: &str) -> Option { + self.operations() + .into_iter() + .find(|operation| operation.mcp_tool_name == Some(tool_name)) + } + pub fn execute(&self, request: &OperationRequest) -> Result { - let request = normalize_request(request); + let mut request = normalize_request(request); + if let (Some(refresh), OperationRequest::Health(health)) = (&self.refresh, &mut request) { + if health.refresh_status.is_none() { + health.refresh_status = Some(refresh.as_json()); + } + } validate_request(&request)?; + let _refresh_read_guard = if operation_requires_consistent_graph_read(&request) { + self.refresh + .as_ref() + .map(|refresh| refresh.read_guard()) + .transpose() + .map_err(|error| ApiError::new("refresh_lock_failed", error))? + } else { + None + }; let operation = self .resolve_operation(request.operation_name()) .ok_or_else(|| { @@ -201,6 +232,15 @@ impl ApiCore { )) } + pub(crate) fn execute_invocation( + &self, + operation_id: &str, + invocation: &OperationInvocation, + ) -> Result { + let request = prepare_operation_request(operation_id, invocation)?; + self.execute(&request) + } + pub(crate) fn operations(&self) -> Vec { let mut items: Vec<_> = self .register_operations() @@ -265,12 +305,12 @@ fn empty_request_schema() -> serde_json::Value { fn search_request_schema() -> serde_json::Value { json!({ "query": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1}, - "profile": {"type": "string"}, - "budget": {"type": "integer", "minimum": 0}, - "context_limit": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "default": DEFAULT_SEARCH_LIMIT}, + "profile": {"type": "string", "default": DEFAULT_PROFILE}, + "budget": {"type": "integer", "minimum": 0, "default": DEFAULT_SEARCH_BUDGET}, + "context_limit": {"type": "integer", "minimum": 0, "default": DEFAULT_CONTEXT_LIMIT}, "max_depth": {"type": "integer", "minimum": 0}, - "detail": {"type": "string", "enum": ["standard", "slim"]}, + "detail": {"type": "string", "enum": ["standard", "slim"], "default": DEFAULT_DETAIL}, }) } @@ -279,12 +319,12 @@ fn context_request_schema() -> serde_json::Value { "query": {"type": "string"}, "node_id": {"type": "string"}, "node_type": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1}, - "profile": {"type": "string"}, - "budget": {"type": "integer", "minimum": 0}, - "context_limit": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "default": DEFAULT_SEARCH_LIMIT}, + "profile": {"type": "string", "default": DEFAULT_PROFILE}, + "budget": {"type": "integer", "minimum": 0, "default": DEFAULT_SEARCH_BUDGET}, + "context_limit": {"type": "integer", "minimum": 0, "default": DEFAULT_CONTEXT_LIMIT}, "max_depth": {"type": "integer", "minimum": 0}, - "detail": {"type": "string", "enum": ["standard", "slim"]}, + "detail": {"type": "string", "enum": ["standard", "slim"], "default": DEFAULT_DETAIL}, }) } @@ -293,7 +333,7 @@ fn query_request_schema() -> serde_json::Value { "statement": {"type": "string"}, "parameters": {"type": "object"}, "query": {"type": "string"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": DEFAULT_QUERY_LIMIT}, }) } @@ -311,6 +351,16 @@ fn dispatch_operation( (operation.handler)(request, runtime) } +fn operation_requires_consistent_graph_read(request: &OperationRequest) -> bool { + matches!( + request, + OperationRequest::Health(_) + | OperationRequest::Search(_) + | OperationRequest::Context(_) + | OperationRequest::Query(_) + ) +} + fn required_runtime<'a>( operation: &str, runtime: Option<&'a RepoRuntime>, @@ -796,6 +846,45 @@ mod tests { assert_eq!(response.operation, "schema"); } + #[test] + fn resolve_mcp_operation_uses_registered_operation_metadata() { + let core = ApiCore::new(); + let operation = core + .resolve_mcp_operation("graph_search") + .expect("registered MCP operation should resolve"); + assert_eq!(operation.id, "search"); + assert!(core.resolve_mcp_operation("graph_missing").is_none()); + } + + #[test] + fn refresh_read_policy_covers_repository_graph_reads() { + let repo = RepoSelector::default(); + let typed = OutputFormat::Typed; + assert!(super::operation_requires_consistent_graph_read( + &OperationRequest::Health(crate::api::contracts::HealthRequest { + repo: repo.clone(), + refresh_status: None, + output_format: typed, + }) + )); + assert!(super::operation_requires_consistent_graph_read( + &OperationRequest::Query(crate::api::contracts::QueryRequest { + repo, + statement: "MATCH (n) RETURN n".to_string(), + parameters: json!({}), + limit: 1, + output_format: typed, + }) + )); + assert!(!super::operation_requires_consistent_graph_read( + &OperationRequest::Catalog { + kind: "schema".to_string(), + group: None, + output_format: typed, + } + )); + } + #[test] fn mcp_install_operation_preserves_unrelated_client_configuration() { let root = unique_temp_dir("codebase-graph-api-mcp-install"); diff --git a/src/api/facade.rs b/src/api/facade.rs index c834470..3417418 100644 --- a/src/api/facade.rs +++ b/src/api/facade.rs @@ -1,7 +1,9 @@ use crate::api::{ - contracts::{ApiError, OperationRequest, OperationResponse}, + contracts::{ApiError, OperationInvocation, OperationRequest, OperationResponse}, core::{ApiCore, OperationDescriptor}, + refresh::RefreshState, }; +use std::sync::Arc; pub trait OperationExecutor { fn execute(&self, request: &OperationRequest) -> Result; @@ -28,6 +30,24 @@ impl CodebaseGraphApi { pub fn operation_descriptors(&self) -> Vec { self.core.operations() } + + pub fn resolve_mcp_operation(&self, tool_name: &str) -> Option { + self.core.resolve_mcp_operation(tool_name) + } + + pub(crate) fn with_refresh_state(refresh: Option>) -> Self { + Self { + core: ApiCore::with_refresh_state(refresh), + } + } + + pub fn execute_invocation( + &self, + operation_id: &str, + invocation: &OperationInvocation, + ) -> Result { + self.core.execute_invocation(operation_id, invocation) + } } impl CodebaseGraphApi { diff --git a/src/api/mod.rs b/src/api/mod.rs index 18e80e6..1909ce8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -19,8 +19,8 @@ pub(crate) mod refresh; pub use contracts::{ ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, - OperationRequest, OperationResponse, OutputFormat, QueryRequest, RefreshRequest, RepoSelector, - RepositoryLifecycleRequest, SearchRequest, + OperationInvocation, OperationRequest, OperationResponse, OutputFormat, QueryRequest, + RefreshRequest, RepoSelector, RepositoryLifecycleRequest, SearchRequest, }; pub use core::OperationDescriptor; pub use facade::{CodebaseGraphApi, OperationExecutor}; diff --git a/src/api/normalization.rs b/src/api/normalization.rs index 5cd640e..4d09321 100644 --- a/src/api/normalization.rs +++ b/src/api/normalization.rs @@ -1,15 +1,88 @@ use crate::api::contracts::{ - ApiError, MaterializationRequest, McpInstallRequest, OperationRequest, RefreshRequest, - RepositoryLifecycleRequest, + ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, + OperationInvocation, OperationRequest, QueryRequest, RefreshRequest, + RepositoryLifecycleRequest, SearchRequest, }; use crate::api::lifecycle::{expand_path, supported_mcp_clients}; use crate::api::materialization::MaterializeOptions; +use serde_json::json; -const DEFAULT_PROFILE: &str = "brief"; -const DEFAULT_DETAIL: &str = "standard"; +pub(crate) const DEFAULT_PROFILE: &str = "brief"; +pub(crate) const DEFAULT_DETAIL: &str = "standard"; +pub(crate) const DEFAULT_SEARCH_LIMIT: usize = 3; +pub(crate) const DEFAULT_SEARCH_BUDGET: usize = 600; +pub(crate) const DEFAULT_CONTEXT_LIMIT: usize = 3; +pub(crate) const DEFAULT_QUERY_LIMIT: usize = 100; const DEFAULT_MATERIALIZATION_MODE: &str = "changed"; const DEFAULT_SEMANTIC_PROVIDER_MODE: &str = "local_only"; +pub(crate) fn prepare_operation_request( + operation_id: &str, + invocation: &OperationInvocation, +) -> Result { + let arguments = &invocation.arguments; + let request = match operation_id { + "health" => OperationRequest::Health(HealthRequest { + repo: invocation.repo.clone(), + refresh_status: None, + output_format: invocation.output_format, + }), + "search" => OperationRequest::Search(SearchRequest { + repo: invocation.repo.clone(), + query: argument_string(arguments, "query"), + profile: argument_string(arguments, "profile"), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_SEARCH_LIMIT), + budget: argument_usize(arguments, "budget").unwrap_or(DEFAULT_SEARCH_BUDGET), + context_limit: argument_usize(arguments, "context_limit") + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + max_depth: argument_usize(arguments, "max_depth"), + detail: argument_string(arguments, "detail"), + output_format: invocation.output_format, + }), + "context" => OperationRequest::Context(ContextRequest { + repo: invocation.repo.clone(), + query: argument_optional_string(arguments, "query"), + profile: argument_string(arguments, "profile"), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_SEARCH_LIMIT), + budget: argument_usize(arguments, "budget").unwrap_or(DEFAULT_SEARCH_BUDGET), + context_limit: argument_usize(arguments, "context_limit") + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + max_depth: argument_usize(arguments, "max_depth"), + detail: argument_string(arguments, "detail"), + node_id: argument_optional_string(arguments, "node_id"), + node_type: argument_optional_string(arguments, "node_type"), + output_format: invocation.output_format, + }), + "query" => OperationRequest::Query(QueryRequest { + repo: invocation.repo.clone(), + statement: argument_string(arguments, "statement"), + parameters: arguments + .get("parameters") + .cloned() + .unwrap_or_else(|| json!({})), + limit: argument_usize(arguments, "limit").unwrap_or(DEFAULT_QUERY_LIMIT), + output_format: invocation.output_format, + }), + "schema" | "query-helpers" => OperationRequest::Catalog { + kind: operation_id.to_string(), + group: None, + output_format: invocation.output_format, + }, + "architecture-queries" => OperationRequest::Catalog { + kind: operation_id.to_string(), + group: argument_optional_string(arguments, "group"), + output_format: invocation.output_format, + }, + _ => { + return Err(ApiError::new( + "unsupported_operation", + format!("unsupported registered operation input: {operation_id}"), + )) + } + }; + Ok(normalize_request(&request)) +} + pub(crate) fn normalize_request(request: &OperationRequest) -> OperationRequest { let mut normalized = request.clone(); match &mut normalized { @@ -309,6 +382,28 @@ fn normalized_optional_string(value: Option) -> Option { .filter(|value| !value.is_empty()) } +fn argument_string(arguments: &serde_json::Value, key: &str) -> String { + arguments + .get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string() +} + +fn argument_optional_string(arguments: &serde_json::Value, key: &str) -> Option { + arguments + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn argument_usize(arguments: &serde_json::Value, key: &str) -> Option { + arguments + .get(key) + .and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} + fn normalize_paths(paths: &mut Vec) { for path in paths.iter_mut() { *path = path.trim().trim_start_matches("./").replace('\\', "/"); @@ -320,11 +415,16 @@ fn normalize_paths(paths: &mut Vec) { #[cfg(test)] mod tests { - use super::{normalize_request, required_fields, validate_request}; + use super::{ + normalize_request, prepare_operation_request, required_fields, validate_request, + DEFAULT_CONTEXT_LIMIT, DEFAULT_DETAIL, DEFAULT_PROFILE, DEFAULT_QUERY_LIMIT, + DEFAULT_SEARCH_BUDGET, DEFAULT_SEARCH_LIMIT, + }; use crate::api::contracts::{ - ContextRequest, MaterializationRequest, McpInstallRequest, OperationRequest, OutputFormat, - RepoSelector, SearchRequest, + ContextRequest, MaterializationRequest, McpInstallRequest, OperationInvocation, + OperationRequest, OutputFormat, RepoSelector, SearchRequest, }; + use serde_json::json; #[test] fn normalize_request_applies_canonical_defaults() { @@ -376,6 +476,40 @@ mod tests { ); } + #[test] + fn prepare_operation_request_applies_canonical_registered_input_defaults() { + let invocation = OperationInvocation { + repo: RepoSelector::default(), + arguments: json!({"query": "needle"}), + output_format: OutputFormat::Typed, + }; + + let request = prepare_operation_request("search", &invocation) + .expect("registered search input should prepare"); + let OperationRequest::Search(search) = request else { + panic!("registered search input should produce a search request"); + }; + assert_eq!(search.profile, DEFAULT_PROFILE); + assert_eq!(search.detail, DEFAULT_DETAIL); + assert_eq!(search.limit, DEFAULT_SEARCH_LIMIT); + assert_eq!(search.budget, DEFAULT_SEARCH_BUDGET); + assert_eq!(search.context_limit, DEFAULT_CONTEXT_LIMIT); + + let query = prepare_operation_request( + "query", + &OperationInvocation { + arguments: json!({"statement": "MATCH (n) RETURN n"}), + ..invocation + }, + ) + .expect("registered query input should prepare"); + let OperationRequest::Query(query) = query else { + panic!("registered query input should produce a query request"); + }; + assert_eq!(query.limit, DEFAULT_QUERY_LIMIT); + assert_eq!(query.parameters, json!({})); + } + #[test] fn validate_request_rejects_invalid_operation_rules_before_execution() { let error = validate_request(&OperationRequest::Context(ContextRequest { From 7b55793242e70de0b9bf1d94410d2218be0f08e5 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 18:56:19 +0930 Subject: [PATCH 08/19] refactor(api): enforce facade-only adapter access Route CLI and MCP product behavior through CodebaseGraphApi, move process transport selection into a bootstrap boundary, and expose refresh watch data as public API contracts. Extract shared command request mapping so build and watch adapters do not depend on sibling behavior. Constraint: Transport adapters may consume public API contract types but must not import API implementation modules. Rejected: Expose refresh and materialization internals through root re-exports | preserves hidden coupling instead of enforcing the facade boundary Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep production adapter behavior behind CodebaseGraphApi and extend boundary_tests.rs when adding a new interface path. Tested: cargo test; cargo check --all-targets; cargo clippy --all-targets -- -D warnings; cargo fmt --all -- --check; Scryer validation, health, pending, and drift checks Not-tested: Manual long-running MCP HTTP server session outside automated coverage --- .../plans/interface-api-facade-enforcement.md | 52 +++++++ src/adapters/cli/dispatch.rs | 44 +----- src/adapters/cli/format/help.rs | 2 +- src/adapters/cli/format/mod.rs | 3 +- src/adapters/cli/install/command.rs | 3 +- src/adapters/cli/install/verify.rs | 6 +- src/adapters/cli/materialization/command.rs | 22 +-- src/adapters/cli/materialization/mod.rs | 2 - .../request.rs => materialization_input.rs} | 124 ++++++++++++++- src/adapters/cli/mod.rs | 3 +- src/adapters/cli/reinstall.rs | 3 +- src/adapters/cli/setup.rs | 5 +- src/adapters/cli/tests/fixtures.rs | 5 +- src/adapters/cli/tests/mcp.rs | 4 +- src/adapters/cli/tests/mod.rs | 2 +- src/adapters/cli/uninstall.rs | 3 +- src/adapters/cli/watch/command.rs | 54 +++++-- src/adapters/cli/watch/mod.rs | 2 +- src/adapters/cli/watch/options.rs | 2 +- src/adapters/cli/watch/output.rs | 12 +- src/adapters/mcp/http.rs | 4 +- src/adapters/mcp/mod.rs | 8 +- src/adapters/mcp/options.rs | 19 ++- src/adapters/mcp/protocol.rs | 5 +- src/adapters/mcp/refresh.rs | 9 +- src/adapters/mcp/stdio.rs | 4 +- src/adapters/mcp/tools.rs | 9 +- src/api/boundary_tests.rs | 140 +++++++++++++++-- src/api/contracts.rs | 53 ++++++- src/api/core.rs | 28 +--- src/api/facade.rs | 26 +++- src/api/materialization.rs | 40 ++++- src/api/mod.rs | 15 +- src/api/refresh.rs | 143 +++++++----------- src/bin/codebase-graph.rs | 2 +- src/bootstrap.rs | 33 ++++ src/lib.rs | 2 + 37 files changed, 620 insertions(+), 273 deletions(-) create mode 100644 .omx/plans/interface-api-facade-enforcement.md rename src/adapters/cli/{materialization/request.rs => materialization_input.rs} (55%) create mode 100644 src/bootstrap.rs diff --git a/.omx/plans/interface-api-facade-enforcement.md b/.omx/plans/interface-api-facade-enforcement.md new file mode 100644 index 0000000..26455c7 --- /dev/null +++ b/.omx/plans/interface-api-facade-enforcement.md @@ -0,0 +1,52 @@ +# Interface API Facade Enforcement + +## Requirements Summary + +- Every production CLI and MCP adapter must invoke product behavior through `CodebaseGraphApi`. +- Adapters may consume public request, response, configuration, and observer contracts re-exported from `crate::api`, but must not import API implementation modules or legacy protocol types directly. +- CLI and MCP transports must remain peer adapters; process-level transport selection belongs outside both adapters. +- Command subadapters must not import sibling behavior; shared input translation belongs to a neutral command request mapper. +- The Scryer change `chg-1` is the authoritative architecture plan and must be folded only after code and tests satisfy it. + +## Acceptance Criteria + +1. Production files under `src/adapters/` contain no imports from `crate::api::refresh`, `crate::api::materialization`, `crate::api::normalization`, or `crate::protocol`. +2. CLI watch execution in `src/adapters/cli/watch/command.rs` invokes a `CodebaseGraphApi` facade method and receives only public refresh summary data. +3. MCP auto-refresh in `src/adapters/mcp/refresh.rs` obtains a refresh-enabled `CodebaseGraphApi` instead of a `RefreshState`. +4. MCP tool schema generation in `src/adapters/mcp/tools.rs` derives required fields from operation metadata returned by `CodebaseGraphApi`. +5. CLI materialization owns its command-line option shape and constructs a public `MaterializationRequest` without importing `api::materialization`. +6. Process transport routing moves out of `src/adapters/cli/dispatch.rs`; the CLI and MCP adapters no longer import one another. +7. Repository boundary tests fail on any new direct adapter dependency on API internals, protocol data, product execution services, a peer transport adapter, or sibling command behavior. +8. Existing CLI watch, materialization, MCP stdio/HTTP, lifecycle, and API boundary tests pass. +9. Scryer reports a structurally clean model, no pending work for `chg-1`, no drift, and no broken or untested anchors. + +## Implementation Steps + +1. Extend the public contracts in `src/api/contracts.rs:151` with refresh watch configuration, observer, and summary types. Re-export them from `src/api/mod.rs:8`. +2. Extend `CodebaseGraphApi` in `src/api/facade.rs:23` with facade methods for watch execution, refresh-enabled API construction, and shared interface metadata. +3. Refactor `src/api/refresh.rs:480` to consume public watch contracts and translate native materialization results into public summaries before notifying adapters. +4. Move CLI materialization option ownership from `src/api/materialization.rs:16` to `src/adapters/cli/materialization_input.rs`, and build `MaterializationRequest` in a neutral command request mapper shared by build and watch parsing. +5. Refactor `src/adapters/cli/watch/command.rs:5`, `src/adapters/mcp/refresh.rs:2`, and `src/adapters/mcp/tools.rs:2` so behavior flows only through `CodebaseGraphApi`. +6. Move process transport selection from `src/adapters/cli/dispatch.rs:30` into `src/bootstrap.rs`, and update `src/bin/codebase-graph.rs:1`. +7. Strengthen `src/api/boundary_tests.rs:74` to enforce facade-only adapter behavior, peer transport isolation, and sibling command-adapter isolation. +8. Run formatting, targeted regression tests, the full test suite, and Scryer validation; then fold `chg-1` with implementation and test anchors. + +## Risks and Mitigations + +- Moving watch contracts can accidentally change CLI output. Preserve the existing output fields and verify existing watch integration tests byte-for-byte. +- Replacing refresh state with a refresh-enabled facade can lose health synchronization. Keep the same shared `Arc` inside `ApiCore` and verify MCP health output. +- Moving CLI option ownership can change defaults. Copy existing defaults and parsing behavior before deleting adapter imports. +- Transport bootstrap movement can alter blocking stdio/HTTP behavior. Preserve command dispatch branches and run MCP stdio and HTTP tests. +- Scryer's dependency audit may infer false direct links from ambiguous Rust call resolution. Do not add skip-stage links unless source imports or resolved calls confirm them. + +## Verification + +- `cargo fmt --check` +- `cargo test api::boundary_tests` +- `cargo test watch_once_runs_single_refresh_and_exits` +- `cargo test watch_auto_backend_refreshes_after_probe_resolution` +- `cargo test mcp_stdio_serves_tools_and_tool_errors` +- `cargo test mcp_http_handles_initialize_list_call_and_protocol_errors` +- `cargo test materialize_empty_project_from_native_request` +- `cargo test` +- Scryer `validate_model`, `get_pending`, `get_drift`, and `get_health` diff --git a/src/adapters/cli/dispatch.rs b/src/adapters/cli/dispatch.rs index 8267675..a4e1ba6 100644 --- a/src/adapters/cli/dispatch.rs +++ b/src/adapters/cli/dispatch.rs @@ -1,7 +1,7 @@ use super::{ format::{ graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, - graph_query_helpers_help, graph_schema_help, graph_search_help, mcp_help, top_level_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, top_level_help, }, graph::{ ArchitectureQueryOptions, GraphContextOptions, GraphQueryOptions, GraphSearchOptions, @@ -14,43 +14,11 @@ use super::{ uninstall::run_uninstall, watch::run_watch, }; -use crate::adapters::mcp::{serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}; use crate::api::{ - contracts::{ - ContextRequest, HealthRequest, OperationRequest, OutputFormat, QueryRequest, RepoSelector, - SearchRequest, - }, - CodebaseGraphApi, -}; -use std::{ - env, - io::{self, Write}, + CodebaseGraphApi, ContextRequest, HealthRequest, OperationRequest, OperationResponse, + OutputFormat, QueryRequest, RepoSelector, SearchRequest, }; - -pub fn run_from_env() -> Result<(), String> { - let args: Vec = env::args().skip(1).collect(); - run_process_args(args) -} - -pub fn run_process_args(args: Vec) -> Result<(), String> { - if args.is_empty() { - return run(args, &mut io::stdout()); - } - if args.first().map(String::as_str) == Some("mcp") { - match args.get(1).map(String::as_str) { - Some("start") => { - let options = McpServeOptions::parse(&args[2..], mcp_help())?; - return serve_mcp_stdio(&options, io::stdin().lock(), &mut io::stdout()); - } - Some("http") => { - let options = McpHttpOptions::parse(&args[2..], mcp_help())?; - return serve_mcp_http(&options); - } - _ => {} - } - } - run(args, &mut io::stdout()) -} +use std::io::Write; pub fn run(args: I, stdout: &mut W) -> Result<(), String> where @@ -253,9 +221,7 @@ fn run_graph_query(args: &[String], stdout: &mut W) -> Result<(), Stri write_api_response(stdout, &response.payload, &options.json, false) } -fn execute_api_request( - request: OperationRequest, -) -> Result { +fn execute_api_request(request: OperationRequest) -> Result { CodebaseGraphApi::new() .execute_operation(&request) .map_err(|error| error.message) diff --git a/src/adapters/cli/format/help.rs b/src/adapters/cli/format/help.rs index 090ee9c..198d88a 100644 --- a/src/adapters/cli/format/help.rs +++ b/src/adapters/cli/format/help.rs @@ -2,7 +2,7 @@ pub(in crate::adapters::cli) fn top_level_help() -> &'static str { "codebase-graph native CLI\n\nUSAGE:\n codebase-graph [options]\n\nCOMMANDS:\n install Perform first-time graph setup and write .codebaseGraph/config.json\n reinstall Remove existing graph state and run install again\n uninstall Remove repo graph state, instructions, and MCP registrations\n build Materialize a graph through the Rust native engine\n plan Preview files that would rebuild, delete, skip, or ignore\n watch Watch for file changes and refresh after a debounce window\n check-health Check whether the native graph database is readable\n schema Return ontology schema, indexes, profiles, and helpers\n query-helpers Return named read-only graph query helpers\n codebase-architecture-queries Return the architecture-discovery query catalog\n codebase-search Search the code graph with compact context\n codebase-context Return compact graph context\n graph-query Execute a restricted read-only graph query\n mcp Serve codebaseGraph MCP over stdio or HTTP\n\nRun `codebase-graph --help` for command options." } -pub(in crate::adapters::cli) fn mcp_help() -> &'static str { +pub(crate) fn mcp_help() -> &'static str { "codebase-graph mcp\n\nUSAGE:\n codebase-graph mcp install [--client ] [--scope ] [--config-path ] [--client-config-path ] [--dry-run] [--json]\n codebase-graph mcp start [--repo-root ] [--config ] [--db ] [--manifest ]\n codebase-graph mcp http [--repo-root ] [--config ] [--db ] [--manifest ] [--host ] [--port ] [--path ] [--allow-remote] [--auth-token |--auth-token-env ]\n\nOPTIONS:\n --repo-root Repository root override; auto-detected when omitted\n --config Setup config path; defaults to .codebaseGraph/config.json\n --db Ladybug database path override\n --manifest Manifest path override\n --host HTTP bind host; defaults to 127.0.0.1\n --port HTTP bind port; defaults to 8765\n --path HTTP endpoint path; defaults to /mcp\n --allow-remote Permit non-local HTTP bind when an auth token is supplied\n --auth-token Bearer token required for HTTP requests\n --auth-token-env Environment variable containing the bearer token" } diff --git a/src/adapters/cli/format/mod.rs b/src/adapters/cli/format/mod.rs index ff1f18d..f0d1482 100644 --- a/src/adapters/cli/format/mod.rs +++ b/src/adapters/cli/format/mod.rs @@ -1,8 +1,9 @@ mod help; +pub(crate) use help::mcp_help; pub(in crate::adapters::cli) use help::{ graph_architecture_queries_help, graph_context_help, graph_health_help, graph_query_help, - graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, mcp_help, + graph_query_helpers_help, graph_schema_help, graph_search_help, materialize_help, mcp_install_help, metadata_help, plan_help, reinstall_help, setup_help, top_level_help, uninstall_help, watch_help, }; diff --git a/src/adapters/cli/install/command.rs b/src/adapters/cli/install/command.rs index 257f98e..27da992 100644 --- a/src/adapters/cli/install/command.rs +++ b/src/adapters/cli/install/command.rs @@ -1,8 +1,7 @@ use super::{attach_install_verification, options::McpInstallOptions}; use crate::adapters::cli::format::mcp_install_help; use crate::api::{ - contracts::{McpInstallRequest, OperationRequest, OutputFormat, RepoSelector}, - CodebaseGraphApi, + CodebaseGraphApi, McpInstallRequest, OperationRequest, OutputFormat, RepoSelector, }; use std::io::Write; diff --git a/src/adapters/cli/install/verify.rs b/src/adapters/cli/install/verify.rs index a981365..e95abae 100644 --- a/src/adapters/cli/install/verify.rs +++ b/src/adapters/cli/install/verify.rs @@ -1,5 +1,5 @@ use super::McpInstallOptions; -use crate::adapters::mcp::LATEST_PROTOCOL_VERSION; +use crate::api::CodebaseGraphApi; use serde_json::json; use std::{ collections::{BTreeMap, BTreeSet}, @@ -70,7 +70,7 @@ pub(in crate::adapters::cli) fn verify_stdio(descriptor: &serde_json::Value) -> let payload = [ stdio_json_rpc_message( "initialize", - json!({"protocolVersion": LATEST_PROTOCOL_VERSION}), + json!({"protocolVersion": CodebaseGraphApi::latest_mcp_protocol_version()}), 1, ), stdio_json_rpc_message("tools/list", json!({}), 2), @@ -221,7 +221,7 @@ pub(in crate::adapters::cli) fn stdio_checks( .get(&1) .and_then(|value| value.pointer("/result/protocolVersion")) .and_then(serde_json::Value::as_str) - == Some(LATEST_PROTOCOL_VERSION), + == Some(CodebaseGraphApi::latest_mcp_protocol_version()), ); checks.insert( "tools_list".to_string(), diff --git a/src/adapters/cli/materialization/command.rs b/src/adapters/cli/materialization/command.rs index 0087133..e485093 100644 --- a/src/adapters/cli/materialization/command.rs +++ b/src/adapters/cli/materialization/command.rs @@ -1,11 +1,8 @@ -use crate::adapters::cli::format::{materialize_help, plan_help}; -use crate::api::{ - contracts::{MaterializationRequest, OperationRequest, OutputFormat}, - materialization::{ - materialization_request as build_public_materialization_request, MaterializeOptions, - }, - CodebaseGraphApi, +use crate::adapters::cli::{ + format::{materialize_help, plan_help}, + materialization_input::{materialize_request, MaterializeOptions}, }; +use crate::api::{CodebaseGraphApi, OperationRequest, OutputFormat}; use std::io::Write; pub(in crate::adapters::cli) fn run_materialize( @@ -18,7 +15,7 @@ pub(in crate::adapters::cli) fn run_materialize( return Ok(()); } let api = CodebaseGraphApi::new(); - let request = materialize_request(&options, OutputFormat::Typed)?; + let request = materialize_request(&options, OutputFormat::Typed); let response = api .execute_operation(&OperationRequest::Materialize(request)) .map_err(|error| error.message)?; @@ -45,7 +42,7 @@ pub(in crate::adapters::cli) fn run_plan( } else { OutputFormat::Block }, - )?; + ); let response = api .execute_operation(&OperationRequest::Plan(request)) .map_err(|error| error.message)?; @@ -65,10 +62,3 @@ pub(in crate::adapters::cli) fn run_plan( write!(stdout, "{text}").map_err(|error| error.to_string()) } } - -fn materialize_request( - options: &MaterializeOptions, - output_format: OutputFormat, -) -> Result { - build_public_materialization_request(options, output_format) -} diff --git a/src/adapters/cli/materialization/mod.rs b/src/adapters/cli/materialization/mod.rs index 4536178..bd85de8 100644 --- a/src/adapters/cli/materialization/mod.rs +++ b/src/adapters/cli/materialization/mod.rs @@ -1,5 +1,3 @@ mod command; -mod request; -pub(crate) use crate::api::materialization::MaterializeOptions; pub(in crate::adapters::cli) use command::{run_materialize, run_plan}; diff --git a/src/adapters/cli/materialization/request.rs b/src/adapters/cli/materialization_input.rs similarity index 55% rename from src/adapters/cli/materialization/request.rs rename to src/adapters/cli/materialization_input.rs index 3aa487f..485b084 100644 --- a/src/adapters/cli/materialization/request.rs +++ b/src/adapters/cli/materialization_input.rs @@ -1,7 +1,56 @@ use crate::adapters::cli::format::{materialize_help, plan_help, watch_help}; -use crate::api::materialization::MaterializeOptions; +use crate::api::{MaterializationRequest, OutputFormat, RepoSelector}; use std::path::PathBuf; +#[derive(Clone, Debug)] +pub(crate) struct MaterializeOptions { + pub(crate) native_request: Option, + pub(crate) source_root: Option, + pub(crate) config: Option, + pub(crate) db: Option, + pub(crate) manifest: Option, + pub(crate) mode: String, + pub(crate) include_fts: bool, + pub(crate) semantic_enrichment: bool, + pub(crate) semantic_provider_mode: String, + pub(crate) use_git: bool, + pub(crate) git_diff: bool, + pub(crate) git_base: Option, + pub(crate) include_patterns: Vec, + pub(crate) exclude_patterns: Vec, + pub(crate) candidate_paths: Vec, + pub(crate) parallel: bool, + pub(crate) progress: bool, + pub(crate) help: bool, + pub(crate) json_output: bool, +} + +impl Default for MaterializeOptions { + fn default() -> Self { + Self { + native_request: None, + source_root: None, + config: None, + db: None, + manifest: None, + mode: String::new(), + include_fts: false, + semantic_enrichment: false, + semantic_provider_mode: String::new(), + use_git: false, + git_diff: false, + git_base: None, + include_patterns: Vec::new(), + exclude_patterns: Vec::new(), + candidate_paths: Vec::new(), + parallel: true, + progress: false, + help: false, + json_output: false, + } + } +} + impl MaterializeOptions { pub(crate) fn parse(args: &[String]) -> Result { Self::parse_with_command(args, "build") @@ -12,7 +61,6 @@ impl MaterializeOptions { include_fts: true, semantic_enrichment: true, use_git: true, - plan_only: command_name == "plan", ..Self::default() }; let mut index = 0; @@ -137,3 +185,75 @@ pub(in crate::adapters::cli) fn materialize_like_help(command_name: &str) -> &'s _ => materialize_help(), } } + +pub(in crate::adapters::cli) fn materialize_request( + options: &MaterializeOptions, + output_format: OutputFormat, +) -> MaterializationRequest { + MaterializationRequest { + repo: RepoSelector { + repo_root: options.source_root.clone(), + config_path: options.config.clone(), + db_path: options.db.clone(), + manifest_path: options.manifest.clone(), + }, + native_request_path: options.native_request.clone(), + source_root: options + .source_root + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + mode: options.mode.clone(), + include_fts: options.include_fts, + semantic_enrichment: options.semantic_enrichment, + semantic_provider_mode: options.semantic_provider_mode.clone(), + use_git: options.use_git, + git_diff: options.git_diff, + git_base: options.git_base.clone(), + include_patterns: options.include_patterns.clone(), + exclude_patterns: options.exclude_patterns.clone(), + candidate_paths: options.candidate_paths.clone(), + parallel: options.parallel, + progress: options.progress, + output_format, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn materialize_request_maps_command_options_to_public_contract() { + let options = MaterializeOptions::parse(&[ + "--repo-root".to_string(), + "/tmp/project".to_string(), + "--db".to_string(), + "/tmp/project/graph.db".to_string(), + "--mode".to_string(), + "incremental".to_string(), + "--no-fts".to_string(), + "--git-diff".to_string(), + "--include".to_string(), + "src/**/*.rs".to_string(), + "--single-thread".to_string(), + ]) + .expect("command options should parse"); + + let request = materialize_request(&options, OutputFormat::Block); + + assert_eq!( + request.repo.repo_root.as_deref(), + Some(std::path::Path::new("/tmp/project")) + ); + assert_eq!( + request.repo.db_path.as_deref(), + Some(std::path::Path::new("/tmp/project/graph.db")) + ); + assert_eq!(request.mode, "incremental"); + assert!(!request.include_fts); + assert!(request.git_diff); + assert_eq!(request.include_patterns, ["src/**/*.rs"]); + assert!(!request.parallel); + assert_eq!(request.output_format, OutputFormat::Block); + } +} diff --git a/src/adapters/cli/mod.rs b/src/adapters/cli/mod.rs index f8a6657..6fbcfd6 100644 --- a/src/adapters/cli/mod.rs +++ b/src/adapters/cli/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod format; pub(crate) mod graph; mod install; pub(crate) mod materialization; +mod materialization_input; mod mcp_command; pub(crate) mod reinstall; pub(crate) mod setup; @@ -10,7 +11,7 @@ pub(crate) mod uninstall; mod util; mod watch; -pub use dispatch::{error_exit_code, run, run_from_env, run_process_args}; +pub use dispatch::{error_exit_code, run}; #[cfg(test)] mod tests; diff --git a/src/adapters/cli/reinstall.rs b/src/adapters/cli/reinstall.rs index 8824e41..059cc88 100644 --- a/src/adapters/cli/reinstall.rs +++ b/src/adapters/cli/reinstall.rs @@ -1,7 +1,6 @@ use super::{format::reinstall_help, watch::SetupOptions}; use crate::api::{ - contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, - CodebaseGraphApi, + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, }; use std::io::Write; diff --git a/src/adapters/cli/setup.rs b/src/adapters/cli/setup.rs index 2e3051e..c9bf262 100644 --- a/src/adapters/cli/setup.rs +++ b/src/adapters/cli/setup.rs @@ -1,8 +1,7 @@ use super::{format::setup_help, watch::SetupOptions}; -use crate::api::contracts::{ - OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, +use crate::api::{ + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, }; -use crate::api::CodebaseGraphApi; use std::io::Write; pub(super) fn run_setup(args: &[String], stdout: &mut W) -> Result<(), String> { diff --git a/src/adapters/cli/tests/fixtures.rs b/src/adapters/cli/tests/fixtures.rs index 6ebdda3..2df82eb 100644 --- a/src/adapters/cli/tests/fixtures.rs +++ b/src/adapters/cli/tests/fixtures.rs @@ -44,7 +44,7 @@ pub(super) fn test_http_options(root: PathBuf, auth_token: Option<&str>) -> McpH config: None, db: None, manifest: None, - refresh: None, + api: None, }, host: "127.0.0.1".to_string(), port: 8765, @@ -98,7 +98,8 @@ pub(super) fn watch_filter_for(root: &Path, extra_args: &[&str]) -> WatchEventFi ]; args.extend(extra_args.iter().map(|arg| arg.to_string())); let options = MaterializeOptions::parse_with_command(&args, "watch").unwrap(); - WatchEventFilter::from_options(&source_root, &options).unwrap() + let request = materialize_request(&options, crate::api::OutputFormat::Typed); + WatchEventFilter::from_request(&source_root, &request).unwrap() } pub(super) fn watch_test_event(root: &Path, kind: EventKind, paths: &[&str]) -> Event { diff --git a/src/adapters/cli/tests/mcp.rs b/src/adapters/cli/tests/mcp.rs index 7c0b0ab..9dd105e 100644 --- a/src/adapters/cli/tests/mcp.rs +++ b/src/adapters/cli/tests/mcp.rs @@ -12,7 +12,7 @@ fn mcp_graph_query_binds_json_parameters() { config: None, db: None, manifest: None, - refresh: None, + api: None, }; let result = mcp_call_tool_result( "graph_query", @@ -106,7 +106,7 @@ fn mcp_stdio_serves_tools_and_tool_errors() { config: None, db: None, manifest: None, - refresh: None, + api: None, }; let mut output = Vec::new(); serve_mcp_stdio(&options, std::io::Cursor::new(input), &mut output).unwrap(); diff --git a/src/adapters/cli/tests/mod.rs b/src/adapters/cli/tests/mod.rs index 7a628cc..0fe540a 100644 --- a/src/adapters/cli/tests/mod.rs +++ b/src/adapters/cli/tests/mod.rs @@ -1,5 +1,5 @@ use super::*; -use super::{materialization::*, watch::*}; +use super::{materialization_input::*, watch::*}; use crate::adapters::mcp::*; use notify::{Event, EventKind}; use serde_json::json; diff --git a/src/adapters/cli/uninstall.rs b/src/adapters/cli/uninstall.rs index 7067cde..391266a 100644 --- a/src/adapters/cli/uninstall.rs +++ b/src/adapters/cli/uninstall.rs @@ -1,8 +1,7 @@ use super::format::uninstall_help; use crate::adapters::required_arg; use crate::api::{ - contracts::{OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest}, - CodebaseGraphApi, + CodebaseGraphApi, OperationRequest, OutputFormat, RepoSelector, RepositoryLifecycleRequest, }; use std::{io::Write, path::PathBuf}; diff --git a/src/adapters/cli/watch/command.rs b/src/adapters/cli/watch/command.rs index 4cfb03a..a46b57d 100644 --- a/src/adapters/cli/watch/command.rs +++ b/src/adapters/cli/watch/command.rs @@ -2,13 +2,10 @@ use super::{ options::{WatchBackend, WatchOptions}, output::{write_watch_event, write_watch_status}, }; -use crate::{ - api::refresh::{ - run_refresh_watch, watch_error_reason, RefreshBackend, RefreshLoopConfig, - RefreshWatchConfig, RefreshWatchObserver, - }, - cli::format::watch_help, - protocol::NativeSyntaxMaterializationResponse, +use crate::adapters::cli::{format::watch_help, materialization_input::materialize_request}; +use crate::api::{ + CodebaseGraphApi, OutputFormat, RefreshBackend, RefreshLoopConfig, RefreshWatchConfig, + RefreshWatchObserver, RefreshWatchSummary, }; use std::{io::Write, time::Duration}; @@ -38,11 +35,10 @@ pub(in crate::adapters::cli) fn run_watch( }, once: options.once, }; - run_refresh_watch( - &options.materialize, - config, - &mut CliRefreshObserver { stdout }, - ) + let request = materialize_request(&options.materialize, OutputFormat::Typed); + CodebaseGraphApi::new() + .watch_repository(&request, config, &mut CliRefreshObserver { stdout }) + .map_err(|error| error.message) } struct CliRefreshObserver<'a, W> { @@ -53,7 +49,7 @@ impl RefreshWatchObserver for CliRefreshObserver<'_, W> { fn on_success( &mut self, backend: Option<&str>, - response: &NativeSyntaxMaterializationResponse, + summary: &RefreshWatchSummary, event_count: usize, changed_paths: usize, ) -> Result<(), String> { @@ -63,7 +59,7 @@ impl RefreshWatchObserver for CliRefreshObserver<'_, W> { backend, event_count, changed_paths, - response, + summary, ) } @@ -79,7 +75,7 @@ impl RefreshWatchObserver for CliRefreshObserver<'_, W> { self.stdout, if retrying { "retrying" } else { "error" }, backend, - Some(&watch_error_reason(error)), + Some(&error_reason(error)), ) } @@ -87,3 +83,31 @@ impl RefreshWatchObserver for CliRefreshObserver<'_, W> { write_watch_status(self.stdout, "fallback", backend, Some(reason)) } } + +fn error_reason(error: &str) -> String { + let reason = error.lines().next().unwrap_or("refresh_failed").trim(); + if reason.is_empty() { + "refresh_failed".to_string() + } else { + reason + .split_whitespace() + .collect::>() + .join("_") + .chars() + .take(160) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::error_reason; + + #[test] + fn watch_error_reason_compacts_multiline_errors() { + assert_eq!( + error_reason("IO exception: Could not set lock\nSee docs"), + "IO_exception:_Could_not_set_lock" + ); + } +} diff --git a/src/adapters/cli/watch/mod.rs b/src/adapters/cli/watch/mod.rs index d04eb85..23c4e83 100644 --- a/src/adapters/cli/watch/mod.rs +++ b/src/adapters/cli/watch/mod.rs @@ -6,7 +6,7 @@ pub(in crate::adapters::cli) use command::run_watch; pub(in crate::adapters::cli) use options::SetupOptions; #[cfg(test)] -pub(super) use crate::api::refresh::{ +pub(super) use crate::api::{ apply_watch_message, collect_poll_batch, collect_watch_batch, probe_native_watcher, watch_file_snapshot, watch_snapshot_diff, WatchChangeBatch, WatchEventFilter, WatchMessage, }; diff --git a/src/adapters/cli/watch/options.rs b/src/adapters/cli/watch/options.rs index ba756ca..04c0397 100644 --- a/src/adapters/cli/watch/options.rs +++ b/src/adapters/cli/watch/options.rs @@ -1,4 +1,4 @@ -use crate::adapters::cli::{format::setup_help, materialization::MaterializeOptions}; +use crate::adapters::cli::{format::setup_help, materialization_input::MaterializeOptions}; use std::path::PathBuf; #[derive(Debug)] diff --git a/src/adapters/cli/watch/output.rs b/src/adapters/cli/watch/output.rs index 872480a..2094f67 100644 --- a/src/adapters/cli/watch/output.rs +++ b/src/adapters/cli/watch/output.rs @@ -1,4 +1,4 @@ -use crate::protocol::NativeSyntaxMaterializationResponse; +use crate::api::RefreshWatchSummary; use std::io::Write; pub(in crate::adapters::cli) fn write_watch_event( @@ -7,7 +7,7 @@ pub(in crate::adapters::cli) fn write_watch_event( backend: Option<&str>, event_count: usize, changed_paths: usize, - response: &NativeSyntaxMaterializationResponse, + summary: &RefreshWatchSummary, ) -> Result<(), String> { let backend = backend .map(|backend| format!(" backend={backend}")) @@ -19,10 +19,10 @@ pub(in crate::adapters::cli) fn write_watch_event( backend, event_count, changed_paths, - response.diff.rebuild_paths().len(), - response.diff.deleted.len(), - response.skipped, - response.database_written + summary.rebuilt, + summary.deleted, + summary.skipped, + summary.database_written ) .map_err(|error| error.to_string()) } diff --git a/src/adapters/mcp/http.rs b/src/adapters/mcp/http.rs index 38381be..8756588 100644 --- a/src/adapters/mcp/http.rs +++ b/src/adapters/mcp/http.rs @@ -13,10 +13,10 @@ use std::{ const MAX_HTTP_BODY_BYTES: usize = 1_000_000; -pub(in crate::adapters) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { +pub(crate) fn serve_mcp_http(options: &McpHttpOptions) -> Result<(), String> { let listener = options.bind_listener()?; let mut options = options.clone(); - options.serve.refresh = Some(start_auto_refresh(&options.serve)); + options.serve.api = Some(start_auto_refresh(&options.serve)); serve_mcp_http_listener(&options, listener, None) } diff --git a/src/adapters/mcp/mod.rs b/src/adapters/mcp/mod.rs index fcd1e1b..c209356 100644 --- a/src/adapters/mcp/mod.rs +++ b/src/adapters/mcp/mod.rs @@ -7,10 +7,10 @@ mod state; mod stdio; mod tools; -pub(in crate::adapters) use http::serve_mcp_http; -pub(in crate::adapters) use options::{McpHttpOptions, McpServeOptions}; -pub(in crate::adapters) use protocol::{McpSession, LATEST_PROTOCOL_VERSION}; -pub(in crate::adapters) use stdio::serve_mcp_stdio; +pub(crate) use http::serve_mcp_http; +pub(crate) use options::{McpHttpOptions, McpServeOptions}; +pub(in crate::adapters) use protocol::McpSession; +pub(crate) use stdio::serve_mcp_stdio; #[cfg(test)] pub(super) use http::{handle_mcp_http_request, HttpRequest}; diff --git a/src/adapters/mcp/options.rs b/src/adapters/mcp/options.rs index f975c54..5fb6bff 100644 --- a/src/adapters/mcp/options.rs +++ b/src/adapters/mcp/options.rs @@ -1,26 +1,25 @@ use super::http::is_local_host; -use super::refresh::McpRefreshState; use crate::adapters::required_arg; -use crate::api::RepoSelector; -use std::{env, net::TcpListener, path::PathBuf, sync::Arc}; +use crate::api::{CodebaseGraphApi, RepoSelector}; +use std::{env, net::TcpListener, path::PathBuf}; #[derive(Clone, Debug)] -pub(in crate::adapters) struct McpServeOptions { +pub(crate) struct McpServeOptions { pub(in crate::adapters) repo_root: Option, pub(in crate::adapters) config: Option, pub(in crate::adapters) db: Option, pub(in crate::adapters) manifest: Option, - pub(in crate::adapters) refresh: Option>, + pub(in crate::adapters) api: Option, } impl McpServeOptions { - pub(in crate::adapters) fn parse(args: &[String], help: &str) -> Result { + pub(crate) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { repo_root: None, config: None, db: None, manifest: None, - refresh: None, + api: None, }; let mut index = 0; while index < args.len() { @@ -62,7 +61,7 @@ impl McpServeOptions { } #[derive(Clone, Debug)] -pub(in crate::adapters) struct McpHttpOptions { +pub(crate) struct McpHttpOptions { pub(in crate::adapters) serve: McpServeOptions, pub(in crate::adapters) host: String, pub(in crate::adapters) port: u16, @@ -72,14 +71,14 @@ pub(in crate::adapters) struct McpHttpOptions { } impl McpHttpOptions { - pub(in crate::adapters) fn parse(args: &[String], help: &str) -> Result { + pub(crate) fn parse(args: &[String], help: &str) -> Result { let mut options = Self { serve: McpServeOptions { repo_root: None, config: None, db: None, manifest: None, - refresh: None, + api: None, }, host: "127.0.0.1".to_string(), port: 8765, diff --git a/src/adapters/mcp/protocol.rs b/src/adapters/mcp/protocol.rs index 51b48a8..3523bec 100644 --- a/src/adapters/mcp/protocol.rs +++ b/src/adapters/mcp/protocol.rs @@ -1,9 +1,8 @@ use super::tools::generate_mcp_specs; use super::{options::McpServeOptions, tools::mcp_call_tool_result}; +use crate::api::CodebaseGraphApi; use serde_json::json; -pub(in crate::adapters) const LATEST_PROTOCOL_VERSION: &str = "2025-11-25"; - pub(in crate::adapters) fn handle_mcp_message( message: serde_json::Value, session: &mut McpSession, @@ -86,7 +85,7 @@ pub(in crate::adapters) fn parse_mcp_payload(data: &[u8]) -> Result String { match requested { "2025-11-25" | "2025-06-18" | "2025-03-26" | "2024-11-05" => requested.to_string(), - _ => LATEST_PROTOCOL_VERSION.to_string(), + _ => CodebaseGraphApi::latest_mcp_protocol_version().to_string(), } } diff --git a/src/adapters/mcp/refresh.rs b/src/adapters/mcp/refresh.rs index ef0cb72..ab036ab 100644 --- a/src/adapters/mcp/refresh.rs +++ b/src/adapters/mcp/refresh.rs @@ -1,9 +1,6 @@ use super::options::McpServeOptions; -use crate::api::refresh::{start_refresh_service, RefreshState}; -use std::sync::Arc; +use crate::api::CodebaseGraphApi; -pub(in crate::adapters) type McpRefreshState = RefreshState; - -pub(in crate::adapters) fn start_auto_refresh(options: &McpServeOptions) -> Arc { - start_refresh_service(options.repo_selector()) +pub(in crate::adapters) fn start_auto_refresh(options: &McpServeOptions) -> CodebaseGraphApi { + CodebaseGraphApi::with_auto_refresh(options.repo_selector()) } diff --git a/src/adapters/mcp/stdio.rs b/src/adapters/mcp/stdio.rs index 9d28580..4fb64ed 100644 --- a/src/adapters/mcp/stdio.rs +++ b/src/adapters/mcp/stdio.rs @@ -6,13 +6,13 @@ use super::{ use serde_json::json; use std::io::{BufRead, Write}; -pub(in crate::adapters) fn serve_mcp_stdio( +pub(crate) fn serve_mcp_stdio( options: &McpServeOptions, mut input: R, output: &mut W, ) -> Result<(), String> { let mut options = options.clone(); - options.refresh = Some(start_auto_refresh(&options)); + options.api = Some(start_auto_refresh(&options)); let mut session = McpSession::default(); while let Some(message) = read_mcp_message(&mut input, output)? { if let Some(response) = handle_mcp_message(message, &mut session, &options) { diff --git a/src/adapters/mcp/tools.rs b/src/adapters/mcp/tools.rs index 84e01f6..074b17a 100644 --- a/src/adapters/mcp/tools.rs +++ b/src/adapters/mcp/tools.rs @@ -1,8 +1,7 @@ use super::{block::serialize_error_block, options::McpServeOptions}; -use crate::api::normalization::required_fields; use crate::api::{ - contracts::{ApiError, OperationInvocation, OperationResponse, OutputFormat}, - CodebaseGraphApi, OperationDescriptor, + ApiError, CodebaseGraphApi, OperationDescriptor, OperationInvocation, OperationResponse, + OutputFormat, }; use serde_json::json; use serde_json::Map; @@ -38,7 +37,7 @@ fn operation_spec_from_descriptor(descriptor: OperationDescriptor) -> Option Result { - let api = CodebaseGraphApi::with_refresh_state(options.refresh.clone()); + let api = options.api.clone().unwrap_or_default(); let operation = api.resolve_mcp_operation(tool_name).ok_or_else(|| { ApiError::new( "unknown_tool", diff --git a/src/api/boundary_tests.rs b/src/api/boundary_tests.rs index dae33bf..73e4ec7 100644 --- a/src/api/boundary_tests.rs +++ b/src/api/boundary_tests.rs @@ -40,34 +40,152 @@ fn command_and_mcp_transports_are_peer_adapters() { assert!(source_root.join("adapters/mcp").is_dir()); assert!(!source_root.join("cli").exists()); - let mut pending = vec![source_root.join("adapters/mcp")]; + let adapters = [ + ( + "MCP", + source_root.join("adapters/mcp"), + [ + ["crate", "::cli"].concat(), + ["crate", "::adapters::cli"].concat(), + ], + ), + ( + "CLI", + source_root.join("adapters/cli"), + [ + ["crate", "::mcp"].concat(), + ["crate", "::adapters::mcp"].concat(), + ], + ), + ]; + + for (name, root, forbidden) in adapters { + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + for entry in + std::fs::read_dir(&path).expect("transport adapter directory should be readable") + { + let path = entry + .expect("transport adapter directory entry should be readable") + .path(); + if path.is_dir() { + if path.file_name().and_then(std::ffi::OsStr::to_str) != Some("tests") { + pending.push(path); + } + continue; + } + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { + continue; + } + let source = std::fs::read_to_string(&path) + .expect("transport adapter source should be readable"); + for adapter in &forbidden { + assert!( + !source.contains(adapter), + "{} imports a peer adapter: {name}", + path.display() + ); + } + } + } + } +} + +#[test] +fn transport_adapters_use_only_the_public_api_facade() { let forbidden = [ - ["crate", "::cli"].concat(), - ["crate", "::adapters::cli"].concat(), + ["crate", "::api::contracts"].concat(), + ["crate", "::api::materialization"].concat(), + ["crate", "::api::normalization"].concat(), + ["crate", "::api::refresh"].concat(), + ["crate", "::protocol"].concat(), + ["crate", "::db_writer"].concat(), + ["crate", "::execution"].concat(), + ["crate", "::scan"].concat(), + ["crate", "::semantic_enrichment"].concat(), + ["crate", "::staging_writer"].concat(), ]; + let adapters_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adapters"); + let mut pending = vec![adapters_root]; + while let Some(path) = pending.pop() { - for entry in std::fs::read_dir(&path).expect("MCP adapter directory should be readable") { + for entry in + std::fs::read_dir(&path).expect("transport adapter directory should be readable") + { let path = entry - .expect("MCP adapter directory entry should be readable") + .expect("transport adapter directory entry should be readable") .path(); if path.is_dir() { - pending.push(path); + if path.file_name().and_then(std::ffi::OsStr::to_str) != Some("tests") { + pending.push(path); + } continue; } if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { continue; } - let source = - std::fs::read_to_string(&path).expect("MCP adapter source should be readable"); - for adapter in &forbidden { + let source = std::fs::read_to_string(&path) + .expect("transport adapter source should be readable"); + for dependency in &forbidden { assert!( - !source.contains(adapter), - "{} imports the CLI adapter", + !source.contains(dependency), + "{} bypasses CodebaseGraphApi through {dependency}", path.display() ); } } } + + for source in [ + include_str!("../adapters/cli/dispatch.rs"), + include_str!("../adapters/cli/setup.rs"), + include_str!("../adapters/cli/reinstall.rs"), + include_str!("../adapters/cli/uninstall.rs"), + include_str!("../adapters/cli/install/command.rs"), + include_str!("../adapters/cli/install/verify.rs"), + include_str!("../adapters/cli/materialization/command.rs"), + include_str!("../adapters/cli/watch/command.rs"), + include_str!("../adapters/mcp/refresh.rs"), + include_str!("../adapters/mcp/tools.rs"), + ] { + assert!( + source.contains("CodebaseGraphApi"), + "product-facing adapter does not use CodebaseGraphApi" + ); + } +} + +#[test] +fn command_subadapters_do_not_import_sibling_behavior() { + let adapters = [ + ( + "watch", + [ + include_str!("../adapters/cli/watch/command.rs"), + include_str!("../adapters/cli/watch/options.rs"), + ], + ["materialization::", "materialization::{"], + ), + ( + "materialization", + [ + include_str!("../adapters/cli/materialization/command.rs"), + include_str!("../adapters/cli/materialization/mod.rs"), + ], + ["watch::", "watch::{"], + ), + ]; + + for (name, sources, forbidden) in adapters { + for source in sources { + for dependency in forbidden { + assert!( + !source.contains(dependency), + "{name} adapter imports sibling behavior through {dependency}" + ); + } + } + } } #[test] diff --git a/src/api/contracts.rs b/src/api/contracts.rs index 0ff9317..0d0bb5c 100644 --- a/src/api/contracts.rs +++ b/src/api/contracts.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RepoSelector { @@ -207,6 +207,57 @@ pub struct RefreshRequest { pub output_format: OutputFormat, } +#[derive(Clone, Copy, Debug)] +pub struct RefreshLoopConfig { + pub poll_interval: Duration, + pub debounce: Duration, + pub max_wait: Duration, + pub max_iterations: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshBackend { + Auto, + Native, + Poll, +} + +#[derive(Clone, Copy, Debug)] +pub struct RefreshWatchConfig { + pub backend: RefreshBackend, + pub loop_config: RefreshLoopConfig, + pub once: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshWatchSummary { + pub rebuilt: usize, + pub deleted: usize, + pub skipped: bool, + pub database_written: bool, +} + +pub trait RefreshWatchObserver { + fn on_success( + &mut self, + backend: Option<&str>, + summary: &RefreshWatchSummary, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_error( + &mut self, + backend: &str, + error: &str, + retrying: bool, + event_count: usize, + changed_paths: usize, + ) -> Result<(), String>; + + fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String>; +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OperationResponse { pub operation: String, diff --git a/src/api/core.rs b/src/api/core.rs index 30802e9..db22b3c 100644 --- a/src/api/core.rs +++ b/src/api/core.rs @@ -38,6 +38,12 @@ pub struct OperationDescriptor { pub mcp_tool_name: Option<&'static str>, } +impl OperationDescriptor { + pub fn required_fields(&self) -> &'static [&'static str] { + crate::api::normalization::required_fields(self.id) + } +} + #[derive(Debug, Clone)] pub(crate) struct OperationRegistry { pub(crate) operations: BTreeMap<&'static str, RegisteredOperation>, @@ -696,27 +702,7 @@ fn execute_materialization( dry_plan: bool, ) -> Result { let output_format = request.output_format; - let mut materialize_options = MaterializeOptions { - source_root: Some(runtime.repo_root.clone()), - config: runtime.config_path.clone(), - db: Some(runtime.db_path.clone()), - manifest: Some(runtime.manifest_path.clone()), - mode: request.mode.clone(), - include_fts: request.include_fts, - semantic_enrichment: request.semantic_enrichment, - semantic_provider_mode: request.semantic_provider_mode.clone(), - use_git: request.use_git, - git_diff: request.git_diff, - git_base: request.git_base.clone(), - include_patterns: request.include_patterns.clone(), - exclude_patterns: request.exclude_patterns.clone(), - candidate_paths: request.candidate_paths.clone(), - parallel: request.parallel, - progress: request.progress, - plan_only: dry_plan, - ..MaterializeOptions::default() - }; - materialize_options.parallel = request.parallel; + let materialize_options = MaterializeOptions::from_request(request, runtime, dry_plan); let mut native_request = if let Some(request_path) = request.native_request_path.as_ref() { read_request(request_path) diff --git a/src/api/facade.rs b/src/api/facade.rs index 3417418..3c47d8d 100644 --- a/src/api/facade.rs +++ b/src/api/facade.rs @@ -1,9 +1,11 @@ use crate::api::{ - contracts::{ApiError, OperationInvocation, OperationRequest, OperationResponse}, + contracts::{ + ApiError, MaterializationRequest, OperationInvocation, OperationRequest, OperationResponse, + RefreshWatchConfig, RefreshWatchObserver, RepoSelector, + }, core::{ApiCore, OperationDescriptor}, - refresh::RefreshState, + refresh::{run_refresh_watch, start_refresh_service}, }; -use std::sync::Arc; pub trait OperationExecutor { fn execute(&self, request: &OperationRequest) -> Result; @@ -35,12 +37,26 @@ impl CodebaseGraphApi { self.core.resolve_mcp_operation(tool_name) } - pub(crate) fn with_refresh_state(refresh: Option>) -> Self { + pub(crate) fn with_auto_refresh(selector: RepoSelector) -> Self { Self { - core: ApiCore::with_refresh_state(refresh), + core: ApiCore::with_refresh_state(Some(start_refresh_service(selector))), } } + pub(crate) fn watch_repository( + &self, + request: &MaterializationRequest, + config: RefreshWatchConfig, + observer: &mut impl RefreshWatchObserver, + ) -> Result<(), ApiError> { + run_refresh_watch(request, config, observer) + .map_err(|error| ApiError::new("refresh_watch_failed", error)) + } + + pub(crate) fn latest_mcp_protocol_version() -> &'static str { + "2025-11-25" + } + pub fn execute_invocation( &self, operation_id: &str, diff --git a/src/api/materialization.rs b/src/api/materialization.rs index 572ce72..033d1ad 100644 --- a/src/api/materialization.rs +++ b/src/api/materialization.rs @@ -1,6 +1,8 @@ +#[cfg(test)] +use crate::api::contracts::{OutputFormat, RepoSelector}; use crate::api::{ - context::{resolve_repository_root, RepoPaths}, - contracts::{MaterializationRequest, OutputFormat, RepoSelector}, + context::{resolve_repository_root, RepoPaths, RepoRuntime}, + contracts::MaterializationRequest, }; use crate::protocol::{ ManifestDiff, ManifestEntry, NativeManifest, NativeSyntaxMaterializationRequest, @@ -32,8 +34,6 @@ pub(crate) struct MaterializeOptions { pub(crate) parallel: bool, pub(crate) progress: bool, pub(crate) plan_only: bool, - pub(crate) help: bool, - pub(crate) json_output: bool, } impl Default for MaterializeOptions { @@ -57,8 +57,35 @@ impl Default for MaterializeOptions { parallel: true, progress: false, plan_only: false, - help: false, - json_output: false, + } + } +} + +impl MaterializeOptions { + pub(crate) fn from_request( + request: &MaterializationRequest, + runtime: &RepoRuntime, + plan_only: bool, + ) -> Self { + Self { + source_root: Some(runtime.repo_root.clone()), + config: runtime.config_path.clone(), + db: Some(runtime.db_path.clone()), + manifest: Some(runtime.manifest_path.clone()), + mode: request.mode.clone(), + include_fts: request.include_fts, + semantic_enrichment: request.semantic_enrichment, + semantic_provider_mode: request.semantic_provider_mode.clone(), + use_git: request.use_git, + git_diff: request.git_diff, + git_base: request.git_base.clone(), + include_patterns: request.include_patterns.clone(), + exclude_patterns: request.exclude_patterns.clone(), + candidate_paths: request.candidate_paths.clone(), + parallel: request.parallel, + progress: request.progress, + plan_only, + ..Self::default() } } } @@ -69,6 +96,7 @@ pub(crate) struct ConfigScanRules { pub(crate) exclude_patterns: Vec, } +#[cfg(test)] pub(crate) fn materialization_request( options: &MaterializeOptions, output_format: OutputFormat, diff --git a/src/api/mod.rs b/src/api/mod.rs index 1909ce8..8ec264a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,19 +12,26 @@ pub mod core; pub mod facade; pub(crate) mod graph_read; pub mod lifecycle; -pub(crate) mod materialization; -pub(crate) mod normalization; +mod materialization; +mod normalization; pub mod presenter; -pub(crate) mod refresh; +mod refresh; pub use contracts::{ ApiError, ContextRequest, HealthRequest, MaterializationRequest, McpInstallRequest, OperationInvocation, OperationRequest, OperationResponse, OutputFormat, QueryRequest, - RefreshRequest, RepoSelector, RepositoryLifecycleRequest, SearchRequest, + RefreshBackend, RefreshLoopConfig, RefreshRequest, RefreshWatchConfig, RefreshWatchObserver, + RefreshWatchSummary, RepoSelector, RepositoryLifecycleRequest, SearchRequest, }; pub use core::OperationDescriptor; pub use facade::{CodebaseGraphApi, OperationExecutor}; pub use lifecycle::supported_mcp_clients; +#[cfg(test)] +pub(crate) use refresh::{ + apply_watch_message, collect_poll_batch, collect_watch_batch, probe_native_watcher, + watch_file_snapshot, watch_snapshot_diff, WatchChangeBatch, WatchEventFilter, WatchMessage, +}; + #[cfg(test)] mod boundary_tests; diff --git a/src/api/refresh.rs b/src/api/refresh.rs index d33912b..ed3bf39 100644 --- a/src/api/refresh.rs +++ b/src/api/refresh.rs @@ -1,7 +1,10 @@ use crate::{ api::{ context::{resolve_runtime, RepoPaths}, - contracts::RepoSelector, + contracts::{ + MaterializationRequest, RefreshBackend, RefreshLoopConfig, RefreshWatchConfig, + RefreshWatchObserver, RefreshWatchSummary, RepoSelector, + }, lifecycle::is_retryable_refresh_failure, materialization::{ default_excluded_parts, execute_candidate_materialization, read_codebase_graph_ignore, @@ -39,19 +42,41 @@ pub(crate) struct WatchEventFilter { } impl WatchEventFilter { + #[cfg(test)] + pub(crate) fn from_request( + source_root: &Path, + request: &MaterializationRequest, + ) -> Result { + Self::from_patterns( + source_root, + request.repo.config_path.clone(), + request.include_patterns.clone(), + request.exclude_patterns.clone(), + ) + } + pub(crate) fn from_options( source_root: &Path, options: &MaterializeOptions, ) -> Result { - let config_path = options - .config - .clone() - .unwrap_or_else(|| config_path_for(source_root)); + Self::from_patterns( + source_root, + options.config.clone(), + options.include_patterns.clone(), + options.exclude_patterns.clone(), + ) + } + + fn from_patterns( + source_root: &Path, + config_path: Option, + mut include_patterns: Vec, + mut exclude_patterns: Vec, + ) -> Result { + let config_path = config_path.unwrap_or_else(|| config_path_for(source_root)); let config_rules = read_materialization_config_rules(&config_path)?; - let mut include_patterns = config_rules.include_patterns; - include_patterns.extend(options.include_patterns.clone()); - let mut exclude_patterns = config_rules.exclude_patterns; - exclude_patterns.extend(options.exclude_patterns.clone()); + include_patterns.splice(0..0, config_rules.include_patterns); + exclude_patterns.splice(0..0, config_rules.exclude_patterns); Ok(Self { source_root: source_root.to_path_buf(), current_dir: env::current_dir().unwrap_or_else(|_| source_root.to_path_buf()), @@ -476,70 +501,18 @@ pub(crate) fn collect_poll_batch( } } -#[derive(Clone, Copy, Debug)] -pub(crate) struct RefreshLoopConfig { - pub(crate) poll_interval: Duration, - pub(crate) debounce: Duration, - pub(crate) max_wait: Duration, - pub(crate) max_iterations: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum RefreshBackend { - Auto, - Native, - Poll, -} - -#[derive(Clone, Copy, Debug)] -pub(crate) struct RefreshWatchConfig { - pub(crate) backend: RefreshBackend, - pub(crate) loop_config: RefreshLoopConfig, - pub(crate) once: bool, -} - -pub(crate) trait RefreshWatchObserver { - fn on_success( - &mut self, - backend: Option<&str>, - response: &NativeSyntaxMaterializationResponse, - event_count: usize, - changed_paths: usize, - ) -> Result<(), String>; - - fn on_error( - &mut self, - backend: &str, - error: &str, - retrying: bool, - event_count: usize, - changed_paths: usize, - ) -> Result<(), String>; - - fn on_fallback(&mut self, backend: &str, reason: &str) -> Result<(), String>; -} - pub(crate) fn run_refresh_watch( - options: &MaterializeOptions, + request: &MaterializationRequest, config: RefreshWatchConfig, observer: &mut impl RefreshWatchObserver, ) -> Result<(), String> { - let runtime = resolve_runtime(&RepoSelector { - repo_root: options.source_root.clone(), - config_path: options.config.clone(), - db_path: options.db.clone(), - manifest_path: options.manifest.clone(), - })?; - let mut materialize_options = options.clone(); + let runtime = resolve_runtime(&request.repo)?; + let mut materialize_options = MaterializeOptions::from_request(request, &runtime, false); normalize_materialize_options(&mut materialize_options); - materialize_options.source_root = Some(runtime.repo_root.clone()); - materialize_options.config = runtime.config_path; - materialize_options.db = Some(runtime.db_path); - materialize_options.manifest = Some(runtime.manifest_path); if config.once { let response = execute_refresh_operation(&materialize_options, Vec::new())?; - return observer.on_success(None, &response, 0, 0); + return observer.on_success(None, &refresh_watch_summary(&response), 0, 0); } let filter = WatchEventFilter::from_options(&runtime.repo_root, &materialize_options)?; @@ -619,8 +592,12 @@ impl RefreshObserver for BoundRefreshWatchObserver<'_, event_count: usize, changed_paths: usize, ) -> Result<(), String> { - self.observer - .on_success(Some(self.backend), response, event_count, changed_paths) + self.observer.on_success( + Some(self.backend), + &refresh_watch_summary(response), + event_count, + changed_paths, + ) } fn on_error( @@ -635,6 +612,15 @@ impl RefreshObserver for BoundRefreshWatchObserver<'_, } } +fn refresh_watch_summary(response: &NativeSyntaxMaterializationResponse) -> RefreshWatchSummary { + RefreshWatchSummary { + rebuilt: response.diff.rebuild_paths().len(), + deleted: response.diff.deleted.len(), + skipped: response.skipped, + database_written: response.database_written, + } +} + pub(crate) fn run_poll_watch( config: RefreshLoopConfig, filter: &WatchEventFilter, @@ -775,21 +761,6 @@ pub(crate) fn execute_refresh_with_policy( } } -pub(crate) fn watch_error_reason(error: &str) -> String { - let reason = error.lines().next().unwrap_or("refresh_failed").trim(); - if reason.is_empty() { - "refresh_failed".to_string() - } else { - reason - .split_whitespace() - .collect::>() - .join("_") - .chars() - .take(160) - .collect() - } -} - #[derive(Clone, Debug)] pub(crate) struct RefreshStatus { pub(crate) enabled: bool, @@ -1352,12 +1323,4 @@ mod tests { ); assert!(observer.successes.is_empty()); } - - #[test] - fn watch_error_reason_compacts_multiline_errors() { - assert_eq!( - watch_error_reason("IO exception: Could not set lock\nSee docs"), - "IO_exception:_Could_not_set_lock" - ); - } } diff --git a/src/bin/codebase-graph.rs b/src/bin/codebase-graph.rs index 335e6c5..9ce91d0 100644 --- a/src/bin/codebase-graph.rs +++ b/src/bin/codebase-graph.rs @@ -1,5 +1,5 @@ fn main() { - if let Err(error) = codebase_graph::adapters::cli::run_from_env() { + if let Err(error) = codebase_graph::run_from_env() { eprintln!("{error}"); std::process::exit(codebase_graph::adapters::cli::error_exit_code(&error)); } diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..52bfadd --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,33 @@ +use crate::adapters::{ + cli::{format::mcp_help, run}, + mcp::{serve_mcp_http, serve_mcp_stdio, McpHttpOptions, McpServeOptions}, +}; +use std::{ + env, + io::{self}, +}; + +pub fn run_from_env() -> Result<(), String> { + let args: Vec = env::args().skip(1).collect(); + run_process_args(args) +} + +pub(crate) fn run_process_args(args: Vec) -> Result<(), String> { + if args.is_empty() { + return run(args, &mut io::stdout()); + } + if args.first().map(String::as_str) == Some("mcp") { + match args.get(1).map(String::as_str) { + Some("start") => { + let options = McpServeOptions::parse(&args[2..], mcp_help())?; + return serve_mcp_stdio(&options, io::stdin().lock(), &mut io::stdout()); + } + Some("http") => { + let options = McpHttpOptions::parse(&args[2..], mcp_help())?; + return serve_mcp_http(&options); + } + _ => {} + } + } + run(args, &mut io::stdout()) +} diff --git a/src/lib.rs b/src/lib.rs index 818bd56..725b31c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod adapters; pub mod api; +mod bootstrap; pub mod db_writer; pub mod error; mod execution; @@ -20,3 +21,4 @@ pub use execution::{execute_materialization_pipeline, plan_materialization}; pub use protocol::{MaterializationInput, MaterializationResult}; pub use adapters::cli; +pub use bootstrap::run_from_env; From 54215a8cabf35c7545fc7196b6b3e83cb48cd6c3 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Fri, 24 Jul 2026 19:29:21 +0930 Subject: [PATCH 09/19] Centralize API boundaries behind the facade --- .claude/settings.local.json | 52 ++++++ .omx/plans/api-architecture-redesign.md | 201 ++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .omx/plans/api-architecture-redesign.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c7c57ea --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,52 @@ +{ + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ], + "matcher": "Read" + }, + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ], + "matcher": "Edit|Write|NotebookEdit" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 10, + "type": "command" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" hook", + "timeout": 15, + "type": "command" + } + ] + } + ] + }, + "statusLine": { + "command": "\"/Users/rabii/Projects/Repositories/scryer/target/release/bundle/macos/scryer.app/Contents/MacOS/scryer-mcp\" statusline", + "type": "command" + } +} \ No newline at end of file diff --git a/.omx/plans/api-architecture-redesign.md b/.omx/plans/api-architecture-redesign.md new file mode 100644 index 0000000..8dc000f --- /dev/null +++ b/.omx/plans/api-architecture-redesign.md @@ -0,0 +1,201 @@ +# API and Architecture Redesign + +Scryer change: `chg-1` + +Rationale: Centralize every CLI, MCP, and library operation behind a stable Public API and a linear materialization pipeline. + +## Requirements Summary + +- Developers may use the CLI or embed the Public API directly. +- Developers using MCP interact through an MCP Host, which invokes the MCP Server Adapter. +- The CLI and MCP adapters must invoke the Public API Facade before any product operation. +- The Public API Facade must delegate transport-neutral execution to the Unified API Core. +- The Unified API Core must own operation registration, repository runtime resolution, error normalization, graph reads, catalogs, response presentation, repository lifecycle operations, and materialization dispatch. +- Typed responses and compact block responses are equally supported public formats. Block output is not a legacy or compatibility-only format. +- The materialization engine must be linear: + + `Materialization API -> Source Scanner -> Execution Planner -> Semantic Enricher -> Graph Writer -> Graph Store` + +- The Execution Planner must receive scanned source snapshots in its input and carry all source data required by later phases. No later phase may rescan the repository. +- Existing CLI flags, MCP tool names, exit codes, graph semantics, and block output must remain stable unless separately approved. + +## Planned Architecture + +The Scryer plan keeps one `Graph Runtime` container because the executable and embedded library are one runtime boundary. Components are organized into three modules: + +- `Transport Adapters`: CLI Adapter, Repository Lifecycle Adapter, CLI Materialization Adapter, Repository Refresh Adapter, MCP Server Adapter. +- `Unified Public API`: Public API Facade, Public API Contracts, Unified API Core, Graph Read Service, Catalog Provider, Response Presenter, Repository Lifecycle Service. +- `Materialization Engine`: Materialization API, Source Scanner, Execution Planner, Semantic Enricher, Graph Writer. + +`Graph Store` remains the persistence boundary used by Graph Read Service and Graph Writer. + +## Acceptance Criteria + +1. CLI graph, lifecycle, refresh, and materialization commands call `CodebaseGraphApi::execute_operation` and do not import graph-read, materialization, catalog, or persistence services directly. +2. MCP tool calls enter through `mcp_call_tool_result`, map to an `OperationRequest`, invoke the Public API Facade, and map only the returned response or error into MCP protocol types. +3. `OperationRegistry` is the single authoritative operation catalog. Adding an operation requires one descriptor and one handler registration; MCP tool metadata is generated from that catalog. +4. Public request and response types contain no CLI or MCP protocol types. +5. Every supported operation can return typed output or compact block output through `OutputFormat`. +6. Search, context, query, metadata, and materialization block outputs remain byte-for-byte compatible with existing snapshots. +7. `ApiError.code` values are stable and transport-neutral; CLI exit codes and MCP failures are mapped only in their adapters. +8. Repository selection is resolved once into `RepoRuntime` before an operation handler runs. +9. Materialization passes a self-contained plan from Source Scanner to Execution Planner to Semantic Enricher to Graph Writer. +10. No filesystem source read occurs after Source Scanner completes. +11. Only Graph Writer submits materialized graph updates to Graph Store. +12. Existing full and incremental materialization tests produce the same nodes, relationships, manifests, diagnostics, and deterministic ordering. +13. Scryer validation is clean and all implemented `chg-1` claims are folded with source anchors and attached tests. + +## Implementation Steps + +### 1. Establish Public API Contracts + +Create `src/api/contracts.rs` and define the planned Scryer symbols: + +- `RepoSelector`, `NodeRef`, and `RepoRuntime` selection inputs. +- `OperationRequest` with Health, Search, Context, Query, Materialize, Catalog, Setup, Reinstall, Uninstall, and Refresh variants. +- Typed request records for search, context, query, materialization, lifecycle, and refresh operations. +- `OutputFormat::{Typed, Block}`. +- `OperationResponse` and `ApiError`. + +Move engine-only materialization types out of the public contract. The current starting declarations are `NativeSyntaxMaterializationRequest` in `src/protocol.rs:7` and `NativeSyntaxMaterializationResponse` in `src/protocol.rs:151`. + +Verification: + +- Serialization round trips for every public data type. +- Exhaustive operation and output-format matching. +- Compile-time checks that public contracts do not depend on `src/cli/**`. + +### 2. Build the Unified API Core + +Create `src/api/core.rs` and `src/api/context.rs`: + +- Implement `OperationDescriptor`, `OperationRegistry`, `register_operations`, `resolve_operation`, and `dispatch_operation`. +- Implement `resolve_runtime` so repository, graph, and manifest paths are selected once. +- Register graph reads, catalog reads, lifecycle operations, refresh, planning, and materialization behind transport-neutral handlers. +- Reject duplicate operation identifiers and unsupported surface exposure. + +The first graph-read handlers wrap the existing operations at `src/cli/graph/search.rs:16`, `src/cli/graph/search.rs:88`, and `src/cli/graph/query.rs:114`. + +Verification: + +- Registry uniqueness and deterministic ordering tests. +- Runtime selection tests for repository defaults and explicit graph paths. +- Unknown operation and invalid request tests with stable `ApiError.code` values. + +### 3. Add the Public API Facade + +Create `src/api/facade.rs`: + +- Implement `CodebaseGraphApi`. +- Implement `execute_operation` as the only public execution entry point. +- Validate the public request, delegate once to Unified API Core, and return `OperationResponse`. +- Keep transport formatting out of the facade. + +Verification: + +- An injected core spy observes exactly one dispatch per facade call. +- Public API integration tests cover every operation variant and both output formats. + +### 4. Separate Catalog and Response Presentation + +Create `src/api/catalog.rs` and `src/api/presenter.rs`: + +- Move catalog loading from `metadata_payload` at `src/cli/format/metadata.rs:4`. +- Keep schema and query catalogs transport-neutral. +- Move typed and block presentation behind `present_operation_response`. +- Retain the existing compact serializers, beginning with `serialize_search_block` at `src/cli/format/blocks.rs:111`. +- Do not introduce a legacy payload mapper. Block output remains a first-class presentation strategy. + +Verification: + +- Existing block snapshots remain byte-for-byte unchanged. +- Typed results preserve all fields represented in block output. +- Catalog output is stable and operation descriptors expose allowed surfaces and formats. + +### 5. Convert CLI and MCP into Adapters + +Update the current command and MCP dispatch paths: + +- CLI command routing remains in `src/cli/dispatch.rs`, but product operations call the Public API Facade. +- `materialize_request` in `src/cli/build/command.rs:51` becomes a facade call and command-output mapping. +- `mcp_call_tool_result` in `src/cli/mcp/tools.rs:18` maps MCP arguments to public requests and public responses back to MCP content. +- Implement `generate_mcp_specs` from public operation metadata. +- Implement `map_error_to_transport` for MCP errors; keep CLI exit-code mapping in the CLI adapter. +- Preserve MCP server startup and transport negotiation as adapter responsibilities. + +Verification: + +- Dependency tests fail if CLI or MCP modules directly import graph-read, materialization, catalog, or persistence modules. +- Existing CLI dispatch and MCP negotiation suites pass unchanged. +- Tool names, schemas, and protocol error behavior remain stable. + +### 6. Extract Repository Lifecycle Service + +Create `src/api/lifecycle.rs`: + +- Move installation behavior behind `setup_repository`, `reinstall_repository`, and `uninstall_repository`. +- Keep client-specific MCP registration and configuration rendering in the Repository Lifecycle Adapter. +- Route lifecycle commands through the Public API Facade. + +Verification: + +- Setup, reinstall, and uninstall behavior matches current repository-state tests. +- Reinstall preserves all recoverable data covered by existing tests. +- Client configuration tests remain adapter-level tests. + +### 7. Linearize Materialization + +Refactor from the current orchestration in `src/execution/run.rs:12`: + +- `Materialization API`: rename the public engine entry points to `execute_materialization_pipeline` and `plan_materialization`; invoke only Source Scanner. +- `Source Scanner`: rename `scan_source_state` at `src/scan.rs:16` to `scan_sources`; return source snapshots, manifest diff, selected profiles, and immutable execution inputs. +- `Execution Planner`: consolidate parsing, syntax graph construction, and partition planning. Rename `build_partitions` at `src/execution/parallel.rs:18` to `build_execution_plan`. +- `Semantic Enricher`: rename `enrich_partitions` at `src/semantic_enrichment/mod.rs:37` to `enrich_semantics`; consume only the execution plan. +- `Graph Writer`: assemble deterministic rows, connectors, manifests, and Graph Store writes through `write_graph_rows`. +- `Graph Store`: retain database concurrency, deletion, schema, and write mechanics. + +Remove the old Source Parser and Graph Row Model component boundaries after their symbols are rehomed under Execution Planner. + +Verification: + +- A test removes or makes the source repository unreadable after scanning; planning, enrichment, and writing still complete from the scanned payload. +- Call-graph and dependency checks show one forward edge between each adjacent stage and no backward or skip-stage edge. +- Sequential and parallel plans produce identical ordered output. +- Full, incremental, deletion, semantic-enrichment, connector, and database-write suites pass. + +### 8. Complete the Migration + +- Remove obsolete direct links and imports after every adapter uses the facade. +- Preserve temporary internal wrappers only while callers are migrated; do not expose them as a second public API. +- Update crate exports and user-facing API documentation. +- Attach unit tests to every conditional symbol-level Scryer claim. +- Fold `chg-1` incrementally with `mark_implemented`, anchors, and tests. + +Recommended fold order: + +1. Public API Contracts, Unified API Core, and Public API Facade. +2. Graph Read Service, Catalog Provider, Response Presenter, and Repository Lifecycle Service. +3. CLI and MCP adapters. +4. Materialization API and each linear materialization stage. +5. Obsolete component and link deletions. + +## Risks and Mitigations + +- Public API becomes a generic untyped dispatcher: keep operation-specific request structs and typed handler registration. +- Registry centralization couples core to transports: descriptors declare neutral metadata and allowed surfaces; adapters generate transport schemas. +- Block output regresses during presenter extraction: retain byte-for-byte snapshot tests before moving serializers. +- Materialization plan grows too large: carry immutable shared source buffers and measure peak memory against the current pipeline. +- Lifecycle extraction changes filesystem side effects: lock existing setup, reinstall, and uninstall behavior with regression tests before moving logic. +- A second internal execution path survives migration: enforce import-boundary tests and remove direct adapter-to-service links before folding the Scryer change. + +## Verification Commands + +Run the repository's established formatting, lint, test, and architecture checks. At minimum: + +```text +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets --all-features +``` + +Then run Scryer validation, inspect `get_pending { change: "chg-1" }`, and fold only claims whose code and tests are complete. From dbf693baaa81276a6898706b61279b9786168b7c Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 11:34:39 +0930 Subject: [PATCH 10/19] fix(ci): make lbug static builds resolve OpenSSL The prebuilt lbug archive exposes HTTPFS OpenSSL symbols without its transitive linker requirements. CI now supplies those requirements on Linux and provisions static Windows aliases that match lbug's expected names. Constraint: lbug 0.17.1 prebuilt archives omit OpenSSL link metadata Rejected: Upgrade to lbug 0.18.3 | its existing CI run still fails on Windows and macos-15-intel Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the OpenSSL aliases synchronized with lbug's ssl/crypto linker names Tested: cargo fmt --check; cargo test --workspace --locked; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings; YAML parse; git diff --check Not-tested: GitHub-hosted Linux and Windows runners --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ build.rs | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 150f0f8..9683a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,19 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - name: Prepare OpenSSL static libraries + if: runner.os == 'Windows' + shell: pwsh + run: | + $triplet = 'x64-windows-static' + vcpkg install "openssl:$triplet" + $vcpkgRoot = if ($env:VCPKG_INSTALLATION_ROOT) { $env:VCPKG_INSTALLATION_ROOT } else { 'C:\vcpkg' } + $libDir = Join-Path $vcpkgRoot "installed\$triplet\lib" + $linkDir = Join-Path $env:RUNNER_TEMP 'lbug-openssl' + New-Item -ItemType Directory -Force -Path $linkDir | Out-Null + Copy-Item (Join-Path $libDir 'libssl.lib') (Join-Path $linkDir 'ssl.lib') + Copy-Item (Join-Path $libDir 'libcrypto.lib') (Join-Path $linkDir 'crypto.lib') + "LIB=$linkDir;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Prepare prebuilt liblbug if: ${{ matrix.prebuilt_lbug }} shell: bash @@ -158,6 +171,19 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + - name: Prepare OpenSSL static libraries + if: runner.os == 'Windows' + shell: pwsh + run: | + $triplet = 'x64-windows-static' + vcpkg install "openssl:$triplet" + $vcpkgRoot = if ($env:VCPKG_INSTALLATION_ROOT) { $env:VCPKG_INSTALLATION_ROOT } else { 'C:\vcpkg' } + $libDir = Join-Path $vcpkgRoot "installed\$triplet\lib" + $linkDir = Join-Path $env:RUNNER_TEMP 'lbug-openssl' + New-Item -ItemType Directory -Force -Path $linkDir | Out-Null + Copy-Item (Join-Path $libDir 'libssl.lib') (Join-Path $linkDir 'ssl.lib') + Copy-Item (Join-Path $libDir 'libcrypto.lib') (Join-Path $linkDir 'crypto.lib') + "LIB=$linkDir;$env:LIB" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Prepare prebuilt liblbug if: ${{ matrix.prebuilt_lbug }} shell: bash diff --git a/build.rs b/build.rs index a22c5c9..8ba6df5 100644 --- a/build.rs +++ b/build.rs @@ -2,4 +2,11 @@ fn main() { if !cfg!(windows) && std::env::var_os("LBUG_SHARED").is_none() { println!("cargo:rustc-link-arg=-rdynamic"); } + + // lbug's prebuilt static library includes the HTTPFS extension, which + // depends on OpenSSL without exporting its linker requirements. + if cfg!(target_os = "linux") || cfg!(windows) { + println!("cargo:rustc-link-lib=dylib=ssl"); + println!("cargo:rustc-link-lib=dylib=crypto"); + } } From 9fd96cd1644c5f301fd3d7215bf6db4a7c600af5 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 11:45:14 +0930 Subject: [PATCH 11/19] fix(ci): pin Linux lbug archive to the lockfile version The lbug build script downloaded its latest Linux archive, whose embedded extension metadata requested JSON from an incompatible v0.19.0 endpoint. Linux test and package jobs now fetch the v0.17.1 compatibility archive that matches Cargo.lock, as the macOS jobs already do. Constraint: lbug 0.17.1 defaults to a moving latest prebuilt archive on Linux Rejected: Change the smoke test to avoid JSON | it would conceal the release artifact incompatibility Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep every prebuilt lbug archive pinned to the version recorded in Cargo.lock Tested: release asset contents; cargo fmt --check; cargo test --workspace --locked; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings; YAML parse; git diff --check Not-tested: GitHub-hosted Linux and Windows runners --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9683a4a..304c49b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,8 @@ jobs: include: - os: ubuntu-latest rustflags: "" - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-linux-x86_64-compat.tar.gz test_args: "" - os: macos-latest rustflags: "" @@ -145,8 +145,8 @@ jobs: - os: ubuntu-latest binary: codebase-graph archive: codebase-graph-linux-x86_64.tar.gz - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-linux-x86_64-compat.tar.gz rustflags: "" - os: macos-latest binary: codebase-graph From 32946c522ce96c0664c3d0afef03f3533d3000f6 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:17:08 +0930 Subject: [PATCH 12/19] docs(okf): plan the wiki as a bounded subsystem Record the Scryer-backed architecture and phased implementation contract for a complete read-only OKF wiki. Keep OKF semantics, projection state, and transports in an adjacent workspace package while composing code context only through CodebaseGraphApi. Constraint: Existing graph public requests, MCP names, and .codebaseGraph state must remain backward compatible Rejected: Reuse DocumentationSource nodes as canonical OKF concepts | the current materializer omits frontmatter, reserved-file, identity, and link semantics Rejected: Include browser authoring in the first subsystem | authoring requires a separate trust and Git-governance boundary Confidence: high Scope-risk: broad Directive: Implement Scryer change chg-1 phase by phase and attach tests before folding each condition-shaped responsibility Tested: Scryer structural validation and git diff --cached --check Not-tested: No production implementation exists yet; runtime verification is specified in the plan --- .omx/plans/okf-wiki-full-implementation.md | 926 +++++++++++++++++++++ 1 file changed, 926 insertions(+) create mode 100644 .omx/plans/okf-wiki-full-implementation.md diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md new file mode 100644 index 0000000..f666770 --- /dev/null +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -0,0 +1,926 @@ +# OKF Wiki Subsystem — Full Implementation Plan + +## Plan status + +- Scryer change: `chg-1` +- Scryer scope: `codebaseGraph / Knowledge Wiki` (`node-164`) +- Model validation: structurally clean +- Implementation status: not started +- Sign-off status: awaiting user approval + +This plan turns the OKF v0.1 study into an implementable subsystem while +preserving the existing graph runtime and public API contracts. Production code +must not be changed until this plan is approved. + +## Decision summary + +Build a new workspace package at `crates/okf-wiki/` as a separately deployable, +read-only Rust wiki service. It owns OKF parsing, conformance, compilation, +projection storage, search, rendering, refresh, HTTP, CLI, and agent-tool +behavior. It consumes source-graph context exclusively through +`CodebaseGraphApi`. + +The package must not reinterpret `DocumentationSource` or +`DocumentationChunk` as OKF concepts. The existing Markdown materializer only +captures documents and heading chunks (`src/parser/markdown.rs:8-173`, +`src/profiles.rs:69-82`), while the wiki requires lossless frontmatter, +reserved-file semantics, path-derived concept identity, links, backlinks, +citations, history, and permissive conformance. + +## Scope + +### Included + +- One or more OKF v0.1 bundles under configured repository roots. +- Bundle discovery and stable namespacing. +- Lossless YAML frontmatter and Markdown-body parsing. +- `index.md` and `log.md` reserved-file semantics. +- Root `index.md` `okf_version` frontmatter exception. +- Consume, conformant, and recommended validation profiles. +- Stable concept IDs derived from bundle-relative paths. +- Absolute, relative, parent-relative, fragment, external, broken, and unsafe + link handling. +- Directory navigation, synthetic indexes, backlinks, citations, scoped + history, diagnostics, type facets, and tag facets. +- Versioned normalized projection artifacts stored outside `.codebaseGraph`. +- Deterministic full builds and content-hash incremental builds. +- Concept-aware full-text search. +- Static site generation and a local read-only HTTP preview/API server. +- CLI commands for validation, building, serving, inspection, and link checks. +- Read-only MCP/agent tools over the same normalized wiki API. +- Optional source-code context through the existing graph public API. +- Accessibility, content sanitization, safe path handling, security headers, + observability, release packaging, and performance verification. + +### Excluded + +- Browser-based authoring or direct source mutation. +- Authentication-backed multi-user editing. +- Pull-request creation. +- Dataplex or other catalog synchronization. +- Service-backed distributed search. +- Replacing the existing Markdown materializer or graph documentation nodes. +- Changing existing graph `OperationRequest` variants or current MCP tool names. + +Authoring is excluded because it introduces a separate trust, authorization, +conflict-resolution, and Git-governance product boundary. It can be planned as +a later subsystem after the read model is stable. + +## Existing constraints + +1. The workspace currently contains the root package and `crates/xtask`; + `crates/okf-wiki` must be added as a third member + (`Cargo.toml:15-17`). +2. `CodebaseGraphApi::execute_operation` is the stable embedded graph entry + point (`src/api/facade.rs:20-75`). +3. Public graph requests are transport-neutral and already distinguish typed + and block outputs (`src/api/contracts.rs:18-104`). +4. The graph operation registry is authoritative for graph operations + (`src/api/core.rs:75-202`). +5. Existing boundary tests prevent transport adapters from bypassing the public + facade (`src/api/boundary_tests.rs:1-138`). +6. Graph MCP schemas are generated from operation metadata + (`src/adapters/mcp/tools.rs:9-43`). +7. The graph refresh service already implements filtering, filesystem event + normalization, and locking behavior (`src/api/refresh.rs:34-118`); the wiki + may reuse public refresh behavior but must not import graph refresh + internals. +8. Remote serving is local-first. Any remote listener requires an explicit + security design rather than inheriting incomplete HTTP assumptions + (`SECURITY.md:21-31`). +9. CI requires workspace formatting, tests, Clippy, advisory scanning, package + dry-runs, and artifact smoke tests (`docs/release.md:22-31`). +10. New dependencies require explicit approval. Approval of this plan + authorizes only the minimal dependency set recorded in Phase 0. + +## Scryer architecture + +```mermaid +flowchart LR + R["Knowledge Reader"] --> W["Knowledge Wiki"] + H["MCP Host"] --> W + W --> S["Source Repository"] + W --> G["Graph Runtime public API"] + + subgraph W["Knowledge Wiki"] + CLI["Wiki CLI Adapter"] --> API["Wiki Public API"] + HTTP["Wiki HTTP Server"] --> API + MCP["Wiki Agent Adapter"] --> API + API --> REF["Refresh Coordinator"] + API --> STORE["Projection Store"] + API --> SEARCH["Concept Search"] + API --> CTX["Graph Context Adapter"] + API --> RENDER["Wiki Renderer"] + REF --> COMP["Knowledge Compiler"] + COMP --> READ["Bundle Reader"] + COMP --> VALID["Conformance Validator"] + COMP --> STORE + SEARCH --> STORE + RENDER --> STORE + end + + READ --> S + REF --> S + REF --> G + CTX --> G +``` + +### Scryer nodes + +| Node | Scryer ID | Accountability | +| --- | --- | --- | +| Knowledge Wiki | `node-164` | Deployable OKF publishing boundary | +| Wiki Public API | `node-165` | Transport-neutral wiki operations | +| Bundle Reader | `node-166` | Bundle discovery and lossless document reading | +| Conformance Validator | `node-167` | Normative and advisory OKF checks | +| Knowledge Compiler | `node-168` | Concepts, directories, relationships, and history | +| Projection Store | `node-169` | Atomic versioned wiki artifacts | +| Concept Search | `node-170` | Search ranking, facets, and snippets | +| Graph Context Adapter | `node-171` | Optional `CodebaseGraphApi` composition | +| Wiki Renderer | `node-172` | Secure accessible static/browser presentation | +| Refresh Coordinator | `node-173` | Incremental rebuilds and refresh coordination | +| Wiki CLI Adapter | `node-174` | Command transport | +| Wiki HTTP Server | `node-175` | Local browser and JSON transport | +| Wiki Agent Adapter | `node-176` | Read-only agent tools | + +## Package and module layout + +```text +crates/okf-wiki/ + Cargo.toml + src/ + lib.rs + model.rs + diagnostic.rs + api/ + mod.rs + contracts.rs + registry.rs + facade.rs + bundle/ + mod.rs + discover.rs + document.rs + reserved.rs + conformance.rs + compiler/ + mod.rs + identity.rs + links.rs + navigation.rs + citations.rs + history.rs + projection/ + mod.rs + manifest.rs + store.rs + cache.rs + search/ + mod.rs + index.rs + ranking.rs + graph_context.rs + render/ + mod.rs + markdown.rs + routes.rs + templates.rs + refresh.rs + adapters/ + mod.rs + cli.rs + http.rs + mcp.rs + bin/ + okf-wiki.rs + templates/ + assets/ + tests/ + fixtures/ + minimal/ + comprehensive/ + malformed/ + malicious/ + multi_bundle/ + bundle_reader.rs + conformance.rs + compilation.rs + projection.rs + search.rs + graph_context.rs + rendering.rs + transports.rs + refresh.rs + end_to_end.rs +``` + +The final module names may change only when implementation evidence shows a +different cohesion boundary. Any such change must update Scryer before code is +kept. + +## Normalized model + +The public projection schema is versioned independently from OKF: + +```text +WikiProjection + schema_version + generated_at + source_revision + bundles[] + +Bundle + id + root_path + okf_version + title + source_revision + directories[] + concepts[] + diagnostics[] + +Directory + path + title + description + index_source(authored|synthetic) + body_markdown + child_directories[] + concept_ids[] + log_entries[] + +Concept + id + bundle_id + source_path + type + title + description + resource + tags[] + timestamp + extensions + body_markdown + headings[] + outbound_links[] + backlinks[] + citations[] + +Link + source_id + raw_href + normalized_target_id + fragment + status(resolved|broken|external|rejected) + context + +Diagnostic + severity(error|warning|info) + code + source_path + line + message + +LogEntry + scope_path + date + category + text + links[] +``` + +Rules: + +- Source concepts do not gain an explicit required ID; identity remains the + path without `.md`. +- Bundle IDs namespace concept IDs but do not alter their within-bundle value. +- Unknown frontmatter is stored losslessly in `extensions`. +- Broken links remain in the projection and are not build-fatal under the + consume profile. +- Rendered HTML is derived output, not canonical source data. +- Source paths are display-safe relative paths; absolute filesystem paths do + not enter public responses. + +## Wiki public API + +Create a parallel `OkfWikiApi`; do not add wiki variants to the graph +`OperationRequest` in `src/api/contracts.rs:18-37`. + +### Requests + +```text +WikiOperationRequest + Health + ValidateBundle + BuildProjection + ListBundles + GetDirectory + GetConcept + SearchConcepts + GetBacklinks + GetNeighborhood + GetDiagnostics + GetRecentChanges + RenderSite +``` + +Every request carries a typed record. Repository/bundle selectors must be +resolved once before dispatch. The wiki operation registry is the single source +for CLI, HTTP, and agent-tool metadata, mirroring the graph registry pattern at +`src/api/core.rs:75-202` without importing it. + +### Responses and errors + +- `WikiOperationResponse` is a typed enum, not an unstructured transport value. +- JSON adapters serialize the typed response at the boundary. +- `WikiApiError` contains a stable code, safe message, optional structured + details, and retryability. +- Public errors must not contain absolute paths, source contents, secrets, or + raw parser failures. +- Stable error codes include: + `invalid_request`, `bundle_not_found`, `concept_not_found`, + `path_outside_repository`, `invalid_frontmatter`, + `projection_unavailable`, `build_in_progress`, `render_failed`, and + `graph_context_unavailable`. + +### Graph composition + +`GraphContextAdapter` may construct only public `SearchRequest`, +`ContextRequest`, and `HealthRequest` values and call +`CodebaseGraphApi::execute_operation` (`src/api/facade.rs:69-75`). + +- End-user routes must not issue arbitrary graph `QueryRequest`. +- Graph results are translated into bounded wiki summaries. +- Graph failure never blocks OKF concept rendering. +- No wiki component imports `src/api/core.rs`, graph storage, CLI adapters, or + MCP adapters. +- Add boundary tests equivalent to + `transport_adapters_use_only_the_public_api_facade` + (`src/api/boundary_tests.rs:94-138`). + +## Storage and artifact contract + +Use a separate repository-local state root: + +```text +.okfWiki/ + manifest.json + projections/ + .json + search/ + .idx + cache/ + .json + site/ + ... + diagnostics.json +``` + +The manifest records schema version, OKF version, bundle roots, source +revision, content hashes, dependency edges, output hashes, build duration, and +the last successful generation. + +Publication protocol: + +1. Build into a uniquely named staging directory under `.okfWiki`. +2. Validate all declared artifacts and route manifests. +3. Atomically replace the published manifest and projection pointer. +4. Remove obsolete staging data only after the new generation is visible. +5. Preserve the last valid generation after parse, render, or graph failures. + +No existing `.codebaseGraph` database or manifest migration is required. + +## Routes + +```text +/ +/b/:bundle/ +/b/:bundle/d/:directory-path +/b/:bundle/c/:concept-id +/b/:bundle/type/:type +/b/:bundle/tag/:tag +/b/:bundle/search +/b/:bundle/graph +/b/:bundle/changes +/b/:bundle/diagnostics +/api/v1/health +/api/v1/bundles +/api/v1/bundles/:bundle/directories/:path +/api/v1/bundles/:bundle/concepts/:id +/api/v1/bundles/:bundle/search +/api/v1/bundles/:bundle/diagnostics +``` + +Route segments use reversible percent-encoding. Path normalization must not +permit escaping a bundle root, and concept URLs remain stable across releases. + +## Implementation sequence + +### Phase 0 — Contracts, fixtures, workspace, and dependency gate + +Files: + +- `Cargo.toml` +- `Cargo.lock` +- `crates/okf-wiki/Cargo.toml` +- `crates/okf-wiki/src/lib.rs` +- `crates/okf-wiki/src/model.rs` +- `crates/okf-wiki/src/diagnostic.rs` +- `crates/okf-wiki/tests/fixtures/**` + +Work: + +1. Add `crates/okf-wiki` to the workspace. +2. Add library and `okf-wiki` binary targets. +3. Define the normalized data model, diagnostics, schema version, and fixture + corpus. +4. Record the attached OKF v0.1 draft as the conformance source. +5. Select the minimal maintained dependencies for: + - YAML parsing, + - CommonMark/GFM events, + - HTML sanitization, + - compile-time templates, + - async local HTTP, + - CLI parsing, + - static search. +6. Require an explicit dependency review before adding packages. Prefer + pure-Rust, memory-safe, cross-platform libraries and reuse workspace + dependencies where suitable. + +Exit criteria: + +- `cargo metadata --no-deps` resolves the new package on all supported targets. +- Fixture coverage maps every normative OKF v0.1 rule to at least one test + case. +- Public projection serialization is deterministic and round-trips. +- No production graph behavior changes. + +### Phase 1 — Bundle Reader and Conformance Validator + +Scryer responsibilities: + +- Bundle Reader: `resp-168` through `resp-172` +- Conformance Validator: `resp-173` through `resp-176` + +Files: + +- `src/bundle/**` +- `src/conformance.rs` +- `tests/bundle_reader.rs` +- `tests/conformance.rs` + +Work: + +1. Discover configured bundle roots without following escaping symlinks. +2. Decode UTF-8, split frontmatter from body, and parse safe YAML values. +3. Parse required `type`, optional standard fields, and lossless extensions. +4. Classify normal concepts, `index.md`, and `log.md`. +5. Allow only bundle-root `index.md` to carry version metadata. +6. Implement `consume`, `conformant`, and `recommended` profiles. +7. Emit line-aware stable diagnostics. + +Exit criteria: + +- Every non-reserved Markdown file is classified exactly once. +- Unknown types and keys remain consumable. +- Only missing/invalid required semantics fail the conformant profile. +- Traversal and symlink escape fixtures are rejected. + +Scryer close: + +- Add symbol nodes only for implemented public definitions and data shapes. +- Attach every condition-shaped test to its matching responsibility. +- Fold only `resp-168` through `resp-176`. + +### Phase 2 — Knowledge Compiler + +Scryer responsibilities: `resp-177` through `resp-181` + +Files: + +- `src/compiler/**` +- `tests/compilation.rs` + +Work: + +1. Derive concept IDs and namespaced route identities. +2. Build directory trees and authored/synthetic indexes. +3. Resolve bundle-absolute, document-relative, parent-relative, and fragment + links with traversal prevention. +4. Build outbound links and backlinks. +5. Retain unresolved links as diagnostics and visible graph edges. +6. Extract headings and numbered citation sections. +7. Parse ISO date-grouped `log.md` entries and aggregate scoped history. +8. Produce deterministically ordered normalized projections. + +Exit criteria: + +- Repeated compilation produces byte-identical normalized JSON when source and + build metadata are fixed. +- Link and backlink tests cover add, change, remove, broken, fragment, and + escaping targets. +- Synthetic navigation is stable. + +Scryer close: fold `resp-177` through `resp-181` with unit and integration test +attachments. + +### Phase 3 — Projection Store and incremental invalidation + +Scryer responsibilities: + +- Projection Store: `resp-182` through `resp-185` +- Refresh Coordinator foundation: `resp-198`, `resp-200`, `resp-201` + +Files: + +- `src/projection/**` +- `src/refresh.rs` +- `tests/projection.rs` +- `tests/refresh.rs` + +Work: + +1. Define `.okfWiki` manifest and generation schemas. +2. Add content-hash cache keys and dependency-aware invalidation. +3. Publish complete generations atomically. +4. Preserve the last valid generation after failure. +5. Serialize competing builds so stale work cannot overwrite newer state. +6. Invalidate: + - one concept page, its search document, outgoing edges, and affected + backlink pages after concept changes; + - the scoped directory and dependent ancestors after `index.md` changes; + - scoped and aggregate history after `log.md` changes. + +Exit criteria: + +- Kill/failure injection never exposes a partial generation. +- An unchanged rebuild is a cache hit. +- Concurrent build tests prove the newest source generation wins. +- `.codebaseGraph` state remains untouched. + +Scryer close: fold the implemented store responsibilities and only the refresh +responsibilities completed in this phase. + +### Phase 4 — Concept Search + +Scryer responsibilities: `resp-186` through `resp-189` + +Files: + +- `src/search/**` +- `tests/search.rs` + +Work: + +1. Index concept ID, title, type, tags, description, headings, body, + citations, and selected scalar extensions. +2. Rank exact title and concept-ID matches above prefix, metadata, heading, and + body matches. +3. Add bundle, type, and tag filters. +4. Return bounded highlighted snippets and deterministic tie-breaking. +5. Keep graph search separate from concept search. + +Exit criteria: + +- Golden ranking cases pass. +- Search output order is deterministic. +- A 10,000-concept fixture stays within the agreed memory and build budgets. + +Scryer close: fold `resp-186` through `resp-189`. + +### Phase 5 — Wiki Public API and graph context + +Scryer responsibilities: + +- Wiki Public API: `resp-165` through `resp-167` +- Graph Context Adapter: `resp-190` through `resp-192` + +Files: + +- `src/api/**` +- `src/graph_context.rs` +- `tests/graph_context.rs` +- API boundary tests + +Work: + +1. Implement typed request, response, selector, descriptor, and error contracts. +2. Implement one registry and one facade execution path. +3. Route build, read, search, diagnostics, changes, and render operations. +4. Implement optional source-graph context using only `CodebaseGraphApi`. +5. Bound graph result count, context depth, and exposed fields. +6. Add import-boundary tests preventing direct graph-core/storage access. +7. Verify graph failures degrade context only. + +Exit criteria: + +- A facade spy observes one dispatch per wiki request, matching the graph facade + invariant at `src/api/facade.rs:105-124`. +- Every operation serializes and validates. +- No wiki module imports graph internals or transport adapters. +- Existing graph API and MCP contract snapshots remain unchanged. + +Scryer close: fold `resp-165` through `resp-167` and `resp-190` through +`resp-192`. + +### Phase 6 — Secure renderer and reader experience + +Scryer responsibilities: `resp-193` through `resp-197` + +Files: + +- `src/render/**` +- `templates/**` +- `assets/**` +- `tests/rendering.rs` + +Work: + +1. Render all declared routes from normalized projections. +2. Sanitize raw HTML, event handlers, scripts, unsafe SVG, and unsafe URL + schemes. +3. Add breadcrumbs, table of contents, metadata, body, backlinks, citations, + source links, related graph context, and diagnostics. +4. Render hierarchy, type, tag, search, graph-neighborhood, change, and + diagnostic views. +5. Provide server-rendered navigation and reading without JavaScript. +6. Add progressive enhancement for search and graph exploration. +7. Meet WCAG 2.2 AA for core reading and navigation. +8. Use stable versioned route helpers rather than template-local URL logic. + +Exit criteria: + +- Malicious fixtures cannot execute active content. +- Keyboard-only journeys cover home, directory, concept, search, and + diagnostics. +- Automated accessibility checks report no critical or serious violations. +- Generated internal links all resolve or appear as explicit broken-link + diagnostics. + +Scryer visual gate: + +- Capture and accept representative desktop and narrow-width fixtures for the + visual `Wiki Renderer` component before folding appearance. + +Scryer close: fold `resp-193` through `resp-197` with security, accessibility, +and route tests attached. + +### Phase 7 — CLI, HTTP, and agent adapters + +Scryer responsibilities: + +- Wiki CLI Adapter: `resp-202`, `resp-203` +- Wiki HTTP Server: `resp-204` through `resp-207` +- Wiki Agent Adapter: `resp-208`, `resp-209` + +Files: + +- `src/adapters/**` +- `src/bin/okf-wiki.rs` +- `tests/transports.rs` + +CLI: + +```text +okf-wiki validate [--profile consume|conformant|recommended] [--json] +okf-wiki build --out [--base-url ] +okf-wiki serve [--host 127.0.0.1] [--port 4321] +okf-wiki inspect --concept +okf-wiki check-links [--include-external] +``` + +Agent tools: + +```text +wiki_list_bundles +wiki_list_directory +wiki_get_concept +wiki_search_concepts +wiki_get_backlinks +wiki_get_neighborhood +wiki_get_diagnostics +wiki_get_recent_changes +``` + +Work: + +1. Generate transport schemas from the wiki operation registry. +2. Keep CLI, HTTP, and MCP mappings thin and peer-level. +3. Bind HTTP to localhost by default. +4. Require a separate approved security change before remote binding. +5. Apply CSP, `nosniff`, referrer, framing, and cache headers. +6. Return safe error bodies without absolute paths. +7. Preserve error codes across transports. + +Exit criteria: + +- Every adapter dispatches exactly once through `OkfWikiApi`. +- Registry metadata and advertised tools match. +- HTTP and agent protocol conformance tests pass. +- Remote binding is impossible without an explicit configuration gate. + +Scryer close: fold `resp-202` through `resp-209`. + +### Phase 8 — Coordinated refresh + +Scryer responsibility: `resp-199` plus any remaining `resp-198` through +`resp-201` + +Files: + +- `src/refresh.rs` +- graph/watcher composition tests + +Work: + +1. Reuse public graph refresh operations rather than importing + `src/api/refresh.rs`. +2. Debounce repository changes once when both graph and wiki refresh are + enabled. +3. Partition changed paths for graph, wiki, or both. +4. Keep graph and wiki locks independent. +5. Surface per-consumer status and retryability. +6. Continue serving the last valid wiki projection during graph or wiki + refresh failures. + +Exit criteria: + +- One file event produces at most one wiki build generation and one graph + refresh request. +- Graph failure does not block wiki publication. +- Wiki failure does not block graph reads. +- Burst, rename, deletion, and shutdown tests pass. + +Scryer close: fold remaining refresh responsibilities. + +### Phase 9 — Packaging, release, performance, and end-to-end proof + +Files: + +- `README.md` +- `SECURITY.md` +- `docs/okf-wiki.md` +- `docs/release.md` +- `.github/workflows/**` +- `crates/xtask/src/main.rs` +- artifact smoke fixtures + +Work: + +1. Include templates and assets in release archives. +2. Add isolated package smoke tests. +3. Extend workspace CI and release gates. +4. Document state, commands, routes, validation profiles, threat boundaries, + rollback, and troubleshooting. +5. Benchmark 1,000, 10,000, and 50,000 concept bundles. +6. Emit build timings, document/edge counts, broken-link counts, cache hits, + generation IDs, and artifact sizes. +7. Run end-to-end static build, local server, HTTP, agent-tool, security, and + accessibility journeys. + +Exit criteria: + +- The release package builds and serves a package-owned fixture without source + tree assets. +- Workspace CI remains green on Linux, macOS, and Windows. +- A 10,000-concept full build completes in under two minutes on the selected CI + runner; an unchanged incremental rebuild completes in under ten seconds. +- Static reading remains functional when graph context, search enhancement, + and the preview server are unavailable. + +Final Scryer close: + +- Fold container responsibilities `resp-161` through `resp-164`. +- Fold reader responsibility `resp-160`. +- Fold the broadened system responsibility `resp-5`. +- Fold all links whose endpoints are implemented. +- Attach system-level end-to-end tests. +- Run `validate_model`, `get_health(node-164)`, and `get_pending(chg-1)`. +- Close `chg-1` only when its queue is empty and tests are attached to every + implemented condition-shaped claim. + +## Acceptance criteria + +1. Every non-reserved `.md` file appears exactly once as a concept. +2. Concept IDs equal bundle-relative paths without `.md`. +3. `index.md` and `log.md` never appear as concepts. +4. Root `index.md` accepts `okf_version`; nested indexes reject frontmatter + under the conformant profile. +5. A document containing only non-empty `type` plus a body is conformant. +6. Missing optional fields never prevent consume-mode rendering. +7. Unknown types render generically and remain searchable. +8. Unknown frontmatter keys survive parse and serialization round trips. +9. Absolute, relative, parent-relative, and fragment links resolve to canonical + routes. +10. Broken internal links remain visible and generate diagnostics. +11. Paths and symlinks cannot escape configured repository roots. +12. Backlinks update after link addition, change, removal, or target rename. +13. Authored indexes render; missing indexes receive deterministic synthesized + navigation. +14. Scoped logs aggregate newest-first without becoming concepts. +15. Search ranks exact title and concept-ID matches above body-only matches and + supports bundle/type/tag filters. +16. Duplicate within-bundle IDs fail deterministically; identical concept IDs + across bundles remain namespaced. +17. A failed compile or render leaves the last valid projection readable. +18. Repeated builds are deterministic apart from explicitly declared volatile + metadata. +19. Malicious Markdown, HTML, links, YAML tags, and SVG cannot execute code or + escape the output root. +20. Core reading and navigation pass WCAG 2.2 AA checks and keyboard-only + journeys. +21. HTTP binds locally by default and emits the required security headers. +22. CLI, HTTP, and agent adapters return the same semantic result and error + codes for equivalent requests. +23. Graph context uses only `CodebaseGraphApi` and degrades without blocking + concept delivery. +24. Existing graph public requests, MCP names, block snapshots, and + `.codebaseGraph` state remain backward compatible. +25. Full and incremental performance budgets pass on the agreed CI runner. + +## Test strategy + +### Unit + +- Frontmatter boundaries, YAML values, timestamp parsing, extension + preservation. +- Reserved-file classification and root-index exception. +- Concept ID and route encoding. +- Link normalization, fragments, traversal, safe schemes. +- Conformance severity and profile behavior. +- Log and citation parsing. +- Search tokenization, field boosts, filtering, snippets, tie-breaking. +- Cache keys and invalidation sets. +- HTML sanitization and header construction. + +### Integration + +- Bundle discovery through normalized projection. +- Projection persistence and atomic rollback. +- Incremental backlink and search updates. +- Multi-bundle identity isolation. +- Wiki public API registry and dispatch. +- Graph public API composition and degraded mode. +- Static route manifest and output verification. +- Transport parity across CLI, HTTP, and MCP. + +### End to end + +- Validate, build, browse, search, follow links, inspect backlinks, view graph + context, view changes, and inspect diagnostics. +- Repeat with graph runtime unavailable. +- Repeat with malformed and malicious fixtures. +- Run on Windows path fixtures and symlink-capable Unix fixtures. +- Package, extract, build a fixture, start preview, and issue HTTP/agent calls. + +### Observability + +- Full and incremental build duration by stage. +- Files scanned, parsed, reused, and rejected. +- Concepts, directories, links, backlinks, citations, logs, and diagnostics. +- Cache hit ratio and output bytes. +- Refresh generation, queue depth, coalesced event count, and last success. +- Graph context latency and degraded-call count. + +## Required verification commands + +```bash +cargo fmt --check +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --locked +cargo test -p okf-wiki --all-features --locked +cargo run -p okf-wiki -- validate crates/okf-wiki/tests/fixtures/comprehensive +cargo run -p okf-wiki -- build crates/okf-wiki/tests/fixtures/comprehensive --out /tmp/okf-wiki-site +cargo run -p xtask -- release-gate +``` + +Security, accessibility, determinism, isolated-package, and performance +commands must be added to the package as stable scripts or `xtask` operations +rather than remaining undocumented one-off commands. + +## Risks and mitigations + +| Risk | Mitigation | +| --- | --- | +| OKF 0.1 changes while implementation is underway | Version the projection and validator; isolate spec rules in fixtures | +| YAML/Markdown libraries expand the attack surface | Minimal maintained dependencies, safe modes, sanitizer, advisory scan | +| Existing graph API leaks into wiki domain contracts | Dedicated adapter and import-boundary tests | +| Two refresh loops race or duplicate work | One debounced event stream, independent locks, generation tokens | +| Static search payload grows too large | Separate compressed indexes, lazy loading, measured escalation threshold | +| Broken or partial builds replace valid output | Staging generation plus atomic publish | +| Unknown extensions are lost | Lossless value map and round-trip tests | +| URLs change after refactors | Central route contract and snapshot tests | +| Graph outages make wiki unreadable | Optional bounded context and explicit degraded responses | +| Remote HTTP is mistaken for multi-user security | Localhost default; separate approved security change for remote mode | +| New package breaks release portability | Pure-Rust dependencies, cross-platform CI, isolated artifact smoke | +| UI becomes the source of truth | Read-only UI; generated output remains disposable | + +## Sign-off gate + +Approval of this plan authorizes implementation against Scryer change `chg-1`, +phase by phase. It does not authorize browser authoring, direct source writes, +remote multi-user serving, or catalog synchronization. + +After approval, implementation must start with Phase 0 and must update Scryer +with concrete symbols, anchors, and attached tests as each responsibility is +built. No unimplemented responsibility may be anchored or folded. From fde8fd13a5f61b1b5ca8986f198d06fc19a4d831 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:28:03 +0930 Subject: [PATCH 13/19] docs(okf): restore the original system actor Keep Developer or Agent as the sole person in the wiki architecture and make the existing person-to-system relationship explicit across the Scryer plan and implementation document. Constraint: Persons connect to the software system at the system-context level. Rejected: Retain a separate Knowledge Reader persona | the approved actor model uses Developer or Agent only Confidence: high Scope-risk: narrow Reversibility: clean Tested: Scryer structural validation; pending-plan inspection; markdown diff check Not-tested: Production code, because this change only corrects the implementation plan --- .omx/plans/okf-wiki-full-implementation.md | 53 +++++++++++++--------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md index f666770..06662e9 100644 --- a/.omx/plans/okf-wiki-full-implementation.md +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -97,28 +97,34 @@ a later subsystem after the read model is stable. ```mermaid flowchart LR - R["Knowledge Reader"] --> W["Knowledge Wiki"] - H["MCP Host"] --> W - W --> S["Source Repository"] - W --> G["Graph Runtime public API"] - - subgraph W["Knowledge Wiki"] - CLI["Wiki CLI Adapter"] --> API["Wiki Public API"] - HTTP["Wiki HTTP Server"] --> API - MCP["Wiki Agent Adapter"] --> API - API --> REF["Refresh Coordinator"] - API --> STORE["Projection Store"] - API --> SEARCH["Concept Search"] - API --> CTX["Graph Context Adapter"] - API --> RENDER["Wiki Renderer"] - REF --> COMP["Knowledge Compiler"] - COMP --> READ["Bundle Reader"] - COMP --> VALID["Conformance Validator"] - COMP --> STORE - SEARCH --> STORE - RENDER --> STORE + D["Developer or Agent"] --> SYS + H["MCP Host"] --> SYS + SYS --> S["Source Repository"] + + subgraph SYS["codebaseGraph"] + G["Graph Runtime public API"] + + subgraph W["Knowledge Wiki"] + CLI["Wiki CLI Adapter"] --> API["Wiki Public API"] + HTTP["Wiki HTTP Server"] --> API + MCP["Wiki Agent Adapter"] --> API + API --> REF["Refresh Coordinator"] + API --> STORE["Projection Store"] + API --> SEARCH["Concept Search"] + API --> CTX["Graph Context Adapter"] + API --> RENDER["Wiki Renderer"] + REF --> COMP["Knowledge Compiler"] + COMP --> READ["Bundle Reader"] + COMP --> VALID["Conformance Validator"] + COMP --> STORE + SEARCH --> STORE + RENDER --> STORE + end end + H --> W + W --> S + W --> G READ --> S REF --> S REF --> G @@ -129,6 +135,8 @@ flowchart LR | Node | Scryer ID | Accountability | | --- | --- | --- | +| Developer or Agent | `person-developer` | Uses repository graph and wiki capabilities | +| codebaseGraph | `system-codebase-graph` | Exposes repository knowledge to developers and agents | | Knowledge Wiki | `node-164` | Deployable OKF publishing boundary | | Wiki Public API | `node-165` | Transport-neutral wiki operations | | Bundle Reader | `node-166` | Bundle discovery and lossless document reading | @@ -623,7 +631,7 @@ Exit criteria: Scryer close: fold `resp-165` through `resp-167` and `resp-190` through `resp-192`. -### Phase 6 — Secure renderer and reader experience +### Phase 6 — Secure renderer and developer/agent experience Scryer responsibilities: `resp-193` through `resp-197` @@ -791,8 +799,9 @@ Exit criteria: Final Scryer close: - Fold container responsibilities `resp-161` through `resp-164`. -- Fold reader responsibility `resp-160`. +- Fold actor responsibility `resp-1`. - Fold the broadened system responsibility `resp-5`. +- Fold the restored actor-to-system relationship `link-developer-system`. - Fold all links whose endpoints are implemented. - Attach system-level end-to-end tests. - Run `validate_model`, `get_health(node-164)`, and `get_pending(chg-1)`. From f5dd87c640dfb8480c33fa15809f94b8b49808ad Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:39:28 +0930 Subject: [PATCH 14/19] docs(okf): fix the wiki MCP display name Record Knowledge Wiki as the exact user-facing MCP server name while keeping internal package and tool identifiers independent. Constraint: MCP clients must display the server as Knowledge Wiki Confidence: high Scope-risk: narrow Reversibility: clean Tested: Scryer structural validation; markdown diff check Not-tested: Production code, because implementation has not started --- .omx/plans/okf-wiki-full-implementation.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md index 06662e9..1b2cfde 100644 --- a/.omx/plans/okf-wiki-full-implementation.md +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -149,7 +149,7 @@ flowchart LR | Refresh Coordinator | `node-173` | Incremental rebuilds and refresh coordination | | Wiki CLI Adapter | `node-174` | Command transport | | Wiki HTTP Server | `node-175` | Local browser and JSON transport | -| Wiki Agent Adapter | `node-176` | Read-only agent tools | +| Wiki Agent Adapter | `node-176` | Read-only MCP server displayed as `Knowledge Wiki` | ## Package and module layout @@ -710,6 +710,10 @@ wiki_get_diagnostics wiki_get_recent_changes ``` +The MCP initialization metadata must use the exact user-facing server display +name `Knowledge Wiki`. Internal package, binary, and tool identifiers do not +alter this display name. + Work: 1. Generate transport schemas from the wiki operation registry. @@ -724,6 +728,7 @@ Exit criteria: - Every adapter dispatches exactly once through `OkfWikiApi`. - Registry metadata and advertised tools match. +- MCP clients display the server name exactly as `Knowledge Wiki`. - HTTP and agent protocol conformance tests pass. - Remote binding is impossible without an explicit configuration gate. From e6c18da4a2c143638346c4158c4003cf8359e087 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:41:30 +0930 Subject: [PATCH 15/19] fix(ci): pin the Windows lbug archive The Windows package job downloaded LadybugDB's moving latest static library, whose extension metadata requested JSON from an unavailable v0.19.0 endpoint. Pinning the archive to Cargo.lock keeps the runtime and extension protocol aligned. Constraint: The v0.17.1 Windows release asset is a ZIP that exports lbug.lib Rejected: Leave automatic lbug download enabled | it retrieves the latest release and causes an extension-version mismatch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep static lbug assets tied to Cargo.lock across every CI platform Tested: cargo fmt --check; CI YAML matrix validation; cargo test --workspace --locked; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings Not-tested: GitHub-hosted Windows runner package smoke test --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 304c49b..eae46a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,8 +51,8 @@ jobs: test_args: "" - os: windows-2022 rustflags: "" - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-windows-x86_64.zip test_args: "--release" steps: - name: Check out repository @@ -84,8 +84,20 @@ jobs: curl --retry 5 --retry-all-errors --connect-timeout 20 -fSL \ "https://github.com/LadybugDB/ladybug/releases/download/v${lbug_version}/${archive}" \ -o "$RUNNER_TEMP/$archive" - tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" - test -f "$lib_dir/liblbug.a" + case "$archive" in + *.tar.gz) + tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" + test -f "$lib_dir/liblbug.a" + ;; + *.zip) + unzip -o "$RUNNER_TEMP/$archive" -d "$lib_dir" + test -f "$lib_dir/lbug.lib" + ;; + *) + echo "Unsupported liblbug archive: $archive" >&2 + exit 1 + ;; + esac test -f "$lib_dir/lbug.h" { echo "LBUG_LIBRARY_DIR=$lib_dir" @@ -163,8 +175,8 @@ jobs: - os: windows-2022 binary: codebase-graph.exe archive: codebase-graph-windows-x86_64.tar.gz - prebuilt_lbug: false - lbug_archive: "" + prebuilt_lbug: true + lbug_archive: liblbug-static-windows-x86_64.zip rustflags: "" steps: - name: Check out repository @@ -196,8 +208,20 @@ jobs: curl --retry 5 --retry-all-errors --connect-timeout 20 -fSL \ "https://github.com/LadybugDB/ladybug/releases/download/v${lbug_version}/${archive}" \ -o "$RUNNER_TEMP/$archive" - tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" - test -f "$lib_dir/liblbug.a" + case "$archive" in + *.tar.gz) + tar xzf "$RUNNER_TEMP/$archive" -C "$lib_dir" + test -f "$lib_dir/liblbug.a" + ;; + *.zip) + unzip -o "$RUNNER_TEMP/$archive" -d "$lib_dir" + test -f "$lib_dir/lbug.lib" + ;; + *) + echo "Unsupported liblbug archive: $archive" >&2 + exit 1 + ;; + esac test -f "$lib_dir/lbug.h" { echo "LBUG_LIBRARY_DIR=$lib_dir" From a4d88bef3b8fd06f0accb4b4ce8557b44e32415a Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:45:27 +0930 Subject: [PATCH 16/19] docs(okf): define the public wiki MCP catalog Constrain the initial Knowledge Wiki MCP surface to five read-only tools and record their exact user-facing display labels. Constraint: Public MCP metadata must match the approved identifiers and labels exactly Rejected: Advertise directory, neighborhood, and recent-change tools initially | they were not included in the approved public catalog Confidence: high Scope-risk: narrow Reversibility: clean Tested: Scryer structural validation; model readback; markdown diff check Not-tested: Production code, because implementation has not started --- .omx/plans/okf-wiki-full-implementation.md | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md index 1b2cfde..e93fcf8 100644 --- a/.omx/plans/okf-wiki-full-implementation.md +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -697,22 +697,20 @@ okf-wiki inspect --concept okf-wiki check-links [--include-external] ``` -Agent tools: +Publicly advertised read-only MCP tools: -```text -wiki_list_bundles -wiki_list_directory -wiki_get_concept -wiki_search_concepts -wiki_get_backlinks -wiki_get_neighborhood -wiki_get_diagnostics -wiki_get_recent_changes -``` +| Tool identifier | Display name | +| --- | --- | +| `wiki_list_bundles` | `List Bundles` | +| `wiki_get_concept` | `Get Concept` | +| `wiki_search_concepts` | `Search Concepts` | +| `wiki_get_backlinks` | `Get Backlinks` | +| `wiki_get_diagnostics` | `Get Diagnostics` | The MCP initialization metadata must use the exact user-facing server display name `Knowledge Wiki`. Internal package, binary, and tool identifiers do not -alter this display name. +alter this display name. Directory, neighborhood, and recent-change operations +remain internal API operations and are not advertised as initial MCP tools. Work: @@ -727,7 +725,8 @@ Work: Exit criteria: - Every adapter dispatches exactly once through `OkfWikiApi`. -- Registry metadata and advertised tools match. +- Registry metadata advertises exactly the five identifiers and display names + declared above. - MCP clients display the server name exactly as `Knowledge Wiki`. - HTTP and agent protocol conformance tests pass. - Remote binding is impossible without an explicit configuration gate. From 697c2da129b38b687fa40d577c2e456cb859d50e Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 12:56:01 +0930 Subject: [PATCH 17/19] docs(okf): plan controlled MCP authoring Restore the full read-tool catalog and extend Knowledge Wiki with bounded MCP operations for creating bundles, creating pages, and populating page content. Constraint: Authoring is limited to configured OKF roots and typed wiki operations Rejected: Direct writes from the MCP adapter | bypasses validation, conflict detection, and the public API boundary Rejected: Remove directory, neighborhood, and recent-change tools | the earlier removal was an incorrect inference from display-name mappings Confidence: high Scope-risk: broad Reversibility: clean Tested: Scryer structural validation; chg-2 pending-plan inspection; markdown diff check Not-tested: Production code, because implementation has not started --- .omx/plans/okf-wiki-full-implementation.md | 204 ++++++++++++++++----- 1 file changed, 159 insertions(+), 45 deletions(-) diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md index e93fcf8..2f5d183 100644 --- a/.omx/plans/okf-wiki-full-implementation.md +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -2,7 +2,7 @@ ## Plan status -- Scryer change: `chg-1` +- Scryer changes: `chg-1` (wiki subsystem), `chg-2` (controlled MCP authoring) - Scryer scope: `codebaseGraph / Knowledge Wiki` (`node-164`) - Model validation: structurally clean - Implementation status: not started @@ -14,11 +14,12 @@ must not be changed until this plan is approved. ## Decision summary -Build a new workspace package at `crates/okf-wiki/` as a separately deployable, -read-only Rust wiki service. It owns OKF parsing, conformance, compilation, -projection storage, search, rendering, refresh, HTTP, CLI, and agent-tool -behavior. It consumes source-graph context exclusively through -`CodebaseGraphApi`. +Build a new workspace package at `crates/okf-wiki/` as a separately deployable +Rust wiki service. It owns OKF parsing, conformance, compilation, controlled +source authoring, projection storage, search, rendering, refresh, HTTP, CLI, +and agent-tool behavior. Its MCP surface supports knowledge discovery plus +bounded creation and population of OKF bundles and pages. It consumes +source-graph context exclusively through `CodebaseGraphApi`. The package must not reinterpret `DocumentationSource` or `DocumentationChunk` as OKF concepts. The existing Markdown materializer only @@ -47,14 +48,19 @@ citations, history, and permissive conformance. - Concept-aware full-text search. - Static site generation and a local read-only HTTP preview/API server. - CLI commands for validation, building, serving, inspection, and link checks. -- Read-only MCP/agent tools over the same normalized wiki API. +- Read-only MCP tools for bundles, directories, concepts, search, backlinks, + neighborhoods, diagnostics, and recent changes. +- Controlled MCP tools for creating bundles, creating pages, and populating + page content. - Optional source-code context through the existing graph public API. - Accessibility, content sanitization, safe path handling, security headers, observability, release packaging, and performance verification. ### Excluded -- Browser-based authoring or direct source mutation. +- Browser-based authoring. +- Arbitrary source mutation outside configured OKF bundle roots and declared + authoring operations. - Authentication-backed multi-user editing. - Pull-request creation. - Dataplex or other catalog synchronization. @@ -62,9 +68,11 @@ citations, history, and permissive conformance. - Replacing the existing Markdown materializer or graph documentation nodes. - Changing existing graph `OperationRequest` variants or current MCP tool names. -Authoring is excluded because it introduces a separate trust, authorization, -conflict-resolution, and Git-governance product boundary. It can be planned as -a later subsystem after the read model is stable. +MCP authoring is intentionally narrower than a general editor: it accepts typed +bundle and page operations, validates the resulting OKF content, enforces +configured repository roots, detects stale writes, and publishes each source +change atomically. Interactive editing, arbitrary file writes, Git operations, +and multi-user conflict resolution remain outside this subsystem. ## Existing constraints @@ -113,6 +121,9 @@ flowchart LR API --> SEARCH["Concept Search"] API --> CTX["Graph Context Adapter"] API --> RENDER["Wiki Renderer"] + API --> AUTHOR["Bundle Authoring"] + AUTHOR --> READ + AUTHOR --> VALID REF --> COMP["Knowledge Compiler"] COMP --> READ["Bundle Reader"] COMP --> VALID["Conformance Validator"] @@ -127,6 +138,7 @@ flowchart LR W --> G READ --> S REF --> S + AUTHOR --> S REF --> G CTX --> G ``` @@ -137,10 +149,11 @@ flowchart LR | --- | --- | --- | | Developer or Agent | `person-developer` | Uses repository graph and wiki capabilities | | codebaseGraph | `system-codebase-graph` | Exposes repository knowledge to developers and agents | -| Knowledge Wiki | `node-164` | Deployable OKF publishing boundary | -| Wiki Public API | `node-165` | Transport-neutral wiki operations | +| Knowledge Wiki | `node-164` | OKF publishing and controlled authoring boundary | +| Wiki Public API | `node-165` | Transport-neutral read and authoring operations | | Bundle Reader | `node-166` | Bundle discovery and lossless document reading | | Conformance Validator | `node-167` | Normative and advisory OKF checks | +| Bundle Authoring | `node-177` | Controlled bundle and page source writes | | Knowledge Compiler | `node-168` | Concepts, directories, relationships, and history | | Projection Store | `node-169` | Atomic versioned wiki artifacts | | Concept Search | `node-170` | Search ranking, facets, and snippets | @@ -149,7 +162,7 @@ flowchart LR | Refresh Coordinator | `node-173` | Incremental rebuilds and refresh coordination | | Wiki CLI Adapter | `node-174` | Command transport | | Wiki HTTP Server | `node-175` | Local browser and JSON transport | -| Wiki Agent Adapter | `node-176` | Read-only MCP server displayed as `Knowledge Wiki` | +| Wiki Agent Adapter | `node-176` | Read/write MCP server displayed as `Knowledge Wiki` | ## Package and module layout @@ -171,6 +184,11 @@ crates/okf-wiki/ document.rs reserved.rs conformance.rs + authoring/ + mod.rs + bundle.rs + page.rs + write.rs compiler/ mod.rs identity.rs @@ -212,6 +230,7 @@ crates/okf-wiki/ multi_bundle/ bundle_reader.rs conformance.rs + authoring.rs compilation.rs projection.rs search.rs @@ -320,6 +339,9 @@ Create a parallel `OkfWikiApi`; do not add wiki variants to the graph WikiOperationRequest Health ValidateBundle + CreateBundle + CreatePage + PopulatePage BuildProjection ListBundles GetDirectory @@ -346,10 +368,27 @@ for CLI, HTTP, and agent-tool metadata, mirroring the graph registry pattern at - Public errors must not contain absolute paths, source contents, secrets, or raw parser failures. - Stable error codes include: - `invalid_request`, `bundle_not_found`, `concept_not_found`, - `path_outside_repository`, `invalid_frontmatter`, - `projection_unavailable`, `build_in_progress`, `render_failed`, and - `graph_context_unavailable`. + `invalid_request`, `bundle_not_found`, `bundle_exists`, + `concept_not_found`, `concept_exists`, `path_outside_repository`, + `invalid_frontmatter`, `write_conflict`, `projection_unavailable`, + `build_in_progress`, `render_failed`, and `graph_context_unavailable`. + +### Authoring contract + +- `CreateBundle` initializes a bundle only under a configured repository root + and writes its root `index.md` with the selected OKF version. +- `CreatePage` creates one concept from a validated bundle-relative path and + fails rather than overwriting an existing source file. +- `PopulatePage` writes typed frontmatter and Markdown content to an existing + page while preserving unknown frontmatter fields unless explicitly replaced. +- Populate requests carry the source revision or content hash observed by the + caller; a mismatch returns `write_conflict`. +- All authoring paths are resolved beneath the selected bundle without + following escaping symlinks. +- Content is validated before publication and written through a same-directory + temporary file plus atomic replacement. +- A successful write enters the normal refresh pipeline; write tools do not + edit projection artifacts directly. ### Graph composition @@ -500,7 +539,46 @@ Scryer close: - Attach every condition-shaped test to its matching responsibility. - Fold only `resp-168` through `resp-176`. -### Phase 2 — Knowledge Compiler +### Phase 2 — Controlled bundle and page authoring + +Scryer responsibilities: `resp-210` through `resp-215` + +Files: + +- `src/authoring/**` +- `tests/authoring.rs` + +Work: + +1. Resolve every write against an explicitly configured repository and bundle + root. +2. Implement `CreateBundle` with a conformant root index and fail-if-present + semantics. +3. Implement `CreatePage` with bundle-relative identity validation and + fail-if-present semantics. +4. Implement `PopulatePage` with typed frontmatter, Markdown content, extension + preservation, and an expected source revision or content hash. +5. Validate authored content before replacing its destination. +6. Write through a same-directory temporary file, flush it, and atomically + replace the destination. +7. Reject absolute paths, traversal, escaping symlinks, invalid reserved-file + targets, stale revisions, and writes outside permitted roots. +8. Notify the refresh coordinator only after a successful source write. + +Exit criteria: + +- Agents cannot write outside configured OKF roots or target arbitrary + repository files. +- Creating an existing bundle or page fails without modifying it. +- A stale populate request returns `write_conflict` without losing either + version. +- Failure injection never leaves truncated or partially written Markdown. +- Successful writes become readable through the normal projection pipeline. + +Scryer close: fold `resp-210` through `resp-215` with path-safety, conflict, +validation, and atomic-write tests attached. + +### Phase 3 — Knowledge Compiler Scryer responsibilities: `resp-177` through `resp-181` @@ -532,7 +610,7 @@ Exit criteria: Scryer close: fold `resp-177` through `resp-181` with unit and integration test attachments. -### Phase 3 — Projection Store and incremental invalidation +### Phase 4 — Projection Store and incremental invalidation Scryer responsibilities: @@ -569,7 +647,7 @@ Exit criteria: Scryer close: fold the implemented store responsibilities and only the refresh responsibilities completed in this phase. -### Phase 4 — Concept Search +### Phase 5 — Concept Search Scryer responsibilities: `resp-186` through `resp-189` @@ -596,7 +674,7 @@ Exit criteria: Scryer close: fold `resp-186` through `resp-189`. -### Phase 5 — Wiki Public API and graph context +### Phase 6 — Wiki Public API and graph context Scryer responsibilities: @@ -614,7 +692,8 @@ Work: 1. Implement typed request, response, selector, descriptor, and error contracts. 2. Implement one registry and one facade execution path. -3. Route build, read, search, diagnostics, changes, and render operations. +3. Route authoring, build, read, search, diagnostics, changes, and render + operations. 4. Implement optional source-graph context using only `CodebaseGraphApi`. 5. Bound graph result count, context depth, and exposed fields. 6. Add import-boundary tests preventing direct graph-core/storage access. @@ -631,7 +710,7 @@ Exit criteria: Scryer close: fold `resp-165` through `resp-167` and `resp-190` through `resp-192`. -### Phase 6 — Secure renderer and developer/agent experience +### Phase 7 — Secure renderer and developer/agent experience Scryer responsibilities: `resp-193` through `resp-197` @@ -673,7 +752,7 @@ Scryer visual gate: Scryer close: fold `resp-193` through `resp-197` with security, accessibility, and route tests attached. -### Phase 7 — CLI, HTTP, and agent adapters +### Phase 8 — CLI, HTTP, and agent adapters Scryer responsibilities: @@ -697,20 +776,25 @@ okf-wiki inspect --concept okf-wiki check-links [--include-external] ``` -Publicly advertised read-only MCP tools: +Publicly advertised MCP tools: -| Tool identifier | Display name | -| --- | --- | -| `wiki_list_bundles` | `List Bundles` | -| `wiki_get_concept` | `Get Concept` | -| `wiki_search_concepts` | `Search Concepts` | -| `wiki_get_backlinks` | `Get Backlinks` | -| `wiki_get_diagnostics` | `Get Diagnostics` | +| Tool identifier | Display name | Access | +| --- | --- | --- | +| `wiki_list_bundles` | `List Bundles` | Read | +| `wiki_list_directory` | `List Directory` | Read | +| `wiki_get_concept` | `Get Concept` | Read | +| `wiki_search_concepts` | `Search Concepts` | Read | +| `wiki_get_backlinks` | `Get Backlinks` | Read | +| `wiki_get_neighborhood` | `Get Neighborhood` | Read | +| `wiki_get_diagnostics` | `Get Diagnostics` | Read | +| `wiki_get_recent_changes` | `Get Recent Changes` | Read | +| `wiki_create_bundle` | `Create Bundle` | Write | +| `wiki_create_page` | `Create Page` | Write | +| `wiki_populate_page` | `Populate Page` | Write | The MCP initialization metadata must use the exact user-facing server display name `Knowledge Wiki`. Internal package, binary, and tool identifiers do not -alter this display name. Directory, neighborhood, and recent-change operations -remain internal API operations and are not advertised as initial MCP tools. +alter this display name. Work: @@ -721,19 +805,25 @@ Work: 5. Apply CSP, `nosniff`, referrer, framing, and cache headers. 6. Return safe error bodies without absolute paths. 7. Preserve error codes across transports. +8. Mark the eight discovery tools as read-only and the three authoring tools as + write operations in MCP metadata. +9. Route every authoring tool through `OkfWikiApi` and `Bundle Authoring`; + adapters must never write repository files directly. Exit criteria: - Every adapter dispatches exactly once through `OkfWikiApi`. -- Registry metadata advertises exactly the five identifiers and display names - declared above. +- Registry metadata advertises exactly the eleven identifiers, display names, + and access modes declared above. - MCP clients display the server name exactly as `Knowledge Wiki`. +- Write-tool tests cover allowed roots, traversal, symlink escape, existing + targets, stale revisions, validation failures, and atomic replacement. - HTTP and agent protocol conformance tests pass. - Remote binding is impossible without an explicit configuration gate. Scryer close: fold `resp-202` through `resp-209`. -### Phase 8 — Coordinated refresh +### Phase 9 — Coordinated refresh Scryer responsibility: `resp-199` plus any remaining `resp-198` through `resp-201` @@ -765,7 +855,7 @@ Exit criteria: Scryer close: fold remaining refresh responsibilities. -### Phase 9 — Packaging, release, performance, and end-to-end proof +### Phase 10 — Packaging, release, performance, and end-to-end proof Files: @@ -808,9 +898,10 @@ Final Scryer close: - Fold the restored actor-to-system relationship `link-developer-system`. - Fold all links whose endpoints are implemented. - Attach system-level end-to-end tests. -- Run `validate_model`, `get_health(node-164)`, and `get_pending(chg-1)`. -- Close `chg-1` only when its queue is empty and tests are attached to every - implemented condition-shaped claim. +- Run `validate_model`, `get_health(node-164)`, `get_pending(chg-1)`, and + `get_pending(chg-2)`. +- Close `chg-1` and `chg-2` only when both queues are empty and tests are + attached to every implemented condition-shaped claim. ## Acceptance criteria @@ -850,6 +941,18 @@ Final Scryer close: 24. Existing graph public requests, MCP names, block snapshots, and `.codebaseGraph` state remain backward compatible. 25. Full and incremental performance budgets pass on the agreed CI runner. +26. `wiki_create_bundle` creates a conformant bundle only beneath a configured + repository root and never overwrites an existing bundle. +27. `wiki_create_page` creates one valid concept at a bundle-relative identity + and never overwrites an existing page. +28. `wiki_populate_page` atomically updates validated frontmatter and Markdown + while preserving unknown fields. +29. Write tools reject traversal, absolute paths, escaping symlinks, invalid + reserved-file targets, and destinations outside permitted bundle roots. +30. Stale populate requests return `write_conflict` without changing source + content. +31. MCP initialization advertises the exact server name, tool identifiers, + display names, and read/write access modes declared in Phase 8. ## Test strategy @@ -865,6 +968,8 @@ Final Scryer close: - Search tokenization, field boosts, filtering, snippets, tie-breaking. - Cache keys and invalidation sets. - HTML sanitization and header construction. +- Authoring path resolution, reserved-file rules, revision preconditions, and + temporary-file publication. ### Integration @@ -873,6 +978,8 @@ Final Scryer close: - Incremental backlink and search updates. - Multi-bundle identity isolation. - Wiki public API registry and dispatch. +- Bundle creation, page creation, page population, conflict rejection, and + post-write refresh. - Graph public API composition and degraded mode. - Static route manifest and output verification. - Transport parity across CLI, HTTP, and MCP. @@ -881,6 +988,8 @@ Final Scryer close: - Validate, build, browse, search, follow links, inspect backlinks, view graph context, view changes, and inspect diagnostics. +- Create a bundle and page through MCP, populate it, then read and search the + resulting concept through MCP. - Repeat with graph runtime unavailable. - Repeat with malformed and malicious fixtures. - Run on Windows path fixtures and symlink-capable Unix fixtures. @@ -894,6 +1003,8 @@ Final Scryer close: - Cache hit ratio and output bytes. - Refresh generation, queue depth, coalesced event count, and last success. - Graph context latency and degraded-call count. +- Authoring attempts, successful writes, validation failures, and write + conflicts without recording source content. ## Required verification commands @@ -924,15 +1035,18 @@ rather than remaining undocumented one-off commands. | Unknown extensions are lost | Lossless value map and round-trip tests | | URLs change after refactors | Central route contract and snapshot tests | | Graph outages make wiki unreadable | Optional bounded context and explicit degraded responses | +| Agent writes overwrite repository knowledge | Permitted roots, fail-if-present creation, revision preconditions, validation, and atomic replacement | | Remote HTTP is mistaken for multi-user security | Localhost default; separate approved security change for remote mode | | New package breaks release portability | Pure-Rust dependencies, cross-platform CI, isolated artifact smoke | | UI becomes the source of truth | Read-only UI; generated output remains disposable | ## Sign-off gate -Approval of this plan authorizes implementation against Scryer change `chg-1`, -phase by phase. It does not authorize browser authoring, direct source writes, -remote multi-user serving, or catalog synchronization. +Approval of this plan authorizes implementation against Scryer changes `chg-1` +and `chg-2`, phase by phase. It authorizes only the declared MCP bundle/page +source writes beneath configured roots; it does not authorize browser +authoring, arbitrary file writes, Git operations, remote multi-user serving, or +catalog synchronization. After approval, implementation must start with Phase 0 and must update Scryer with concrete symbols, anchors, and attached tests as each responsibility is From 23d186ea677e1f94d436554edd3db76f2d0aea17 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 13:01:22 +0930 Subject: [PATCH 18/19] docs(okf): adopt the k-wiki storage paths Align the planned wiki package boundary and generated-state root with the approved crates/k-wiki and .kWiki names. Constraint: The Cargo package and binary names remain unchanged Confidence: high Scope-risk: narrow Reversibility: clean Tested: Scryer structural validation; boundary readback; stale-name search; markdown diff check Not-tested: Production code, because implementation has not started --- .omx/plans/okf-wiki-full-implementation.md | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.omx/plans/okf-wiki-full-implementation.md b/.omx/plans/okf-wiki-full-implementation.md index 2f5d183..e75ca8f 100644 --- a/.omx/plans/okf-wiki-full-implementation.md +++ b/.omx/plans/okf-wiki-full-implementation.md @@ -14,7 +14,7 @@ must not be changed until this plan is approved. ## Decision summary -Build a new workspace package at `crates/okf-wiki/` as a separately deployable +Build a new workspace package at `crates/k-wiki/` as a separately deployable Rust wiki service. It owns OKF parsing, conformance, compilation, controlled source authoring, projection storage, search, rendering, refresh, HTTP, CLI, and agent-tool behavior. Its MCP surface supports knowledge discovery plus @@ -77,7 +77,7 @@ and multi-user conflict resolution remain outside this subsystem. ## Existing constraints 1. The workspace currently contains the root package and `crates/xtask`; - `crates/okf-wiki` must be added as a third member + `crates/k-wiki` must be added as a third member (`Cargo.toml:15-17`). 2. `CodebaseGraphApi::execute_operation` is the stable embedded graph entry point (`src/api/facade.rs:20-75`). @@ -167,7 +167,7 @@ flowchart LR ## Package and module layout ```text -crates/okf-wiki/ +crates/k-wiki/ Cargo.toml src/ lib.rs @@ -410,7 +410,7 @@ for CLI, HTTP, and agent-tool metadata, mirroring the graph registry pattern at Use a separate repository-local state root: ```text -.okfWiki/ +.kWiki/ manifest.json projections/ .json @@ -429,7 +429,7 @@ the last successful generation. Publication protocol: -1. Build into a uniquely named staging directory under `.okfWiki`. +1. Build into a uniquely named staging directory under `.kWiki`. 2. Validate all declared artifacts and route manifests. 3. Atomically replace the published manifest and projection pointer. 4. Remove obsolete staging data only after the new generation is visible. @@ -469,15 +469,15 @@ Files: - `Cargo.toml` - `Cargo.lock` -- `crates/okf-wiki/Cargo.toml` -- `crates/okf-wiki/src/lib.rs` -- `crates/okf-wiki/src/model.rs` -- `crates/okf-wiki/src/diagnostic.rs` -- `crates/okf-wiki/tests/fixtures/**` +- `crates/k-wiki/Cargo.toml` +- `crates/k-wiki/src/lib.rs` +- `crates/k-wiki/src/model.rs` +- `crates/k-wiki/src/diagnostic.rs` +- `crates/k-wiki/tests/fixtures/**` Work: -1. Add `crates/okf-wiki` to the workspace. +1. Add `crates/k-wiki` to the workspace. 2. Add library and `okf-wiki` binary targets. 3. Define the normalized data model, diagnostics, schema version, and fixture corpus. @@ -626,7 +626,7 @@ Files: Work: -1. Define `.okfWiki` manifest and generation schemas. +1. Define `.kWiki` manifest and generation schemas. 2. Add content-hash cache keys and dependency-aware invalidation. 3. Publish complete generations atomically. 4. Preserve the last valid generation after failure. @@ -1013,8 +1013,8 @@ cargo fmt --check cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo test --workspace --locked cargo test -p okf-wiki --all-features --locked -cargo run -p okf-wiki -- validate crates/okf-wiki/tests/fixtures/comprehensive -cargo run -p okf-wiki -- build crates/okf-wiki/tests/fixtures/comprehensive --out /tmp/okf-wiki-site +cargo run -p okf-wiki -- validate crates/k-wiki/tests/fixtures/comprehensive +cargo run -p okf-wiki -- build crates/k-wiki/tests/fixtures/comprehensive --out /tmp/okf-wiki-site cargo run -p xtask -- release-gate ``` From c9c19788098ef9013095ba75d81cf9079faf9865 Mon Sep 17 00:00:00 2001 From: rabii-chaarani Date: Thu, 30 Jul 2026 13:22:34 +0930 Subject: [PATCH 19/19] fix(ci): export native Windows lbug paths The prebuilt archive was extracted successfully, but Git Bash exported its MSYS path to the MSVC build. Converting only the Windows environment value lets cl.exe resolve lbug.h while preserving Unix paths. Constraint: Archive preparation runs in Git Bash while the Rust C++ bridge compiles with MSVC Rejected: Disable the pinned Windows archive | that restores the incompatible moving LadybugDB release Confidence: high Scope-risk: narrow Reversibility: clean Directive: Convert Bash paths before exposing native-toolchain include or library directories on Windows Tested: CI YAML path-conversion validation; cargo fmt --check; cargo test --workspace --locked; cargo clippy --workspace --all-targets --all-features --locked -- -D warnings Not-tested: GitHub-hosted Windows runner --- .github/workflows/ci.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae46a9..b56e3d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,9 +99,13 @@ jobs: ;; esac test -f "$lib_dir/lbug.h" + lib_dir_env="$lib_dir" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + lib_dir_env="$(cygpath -m "$lib_dir")" + fi { - echo "LBUG_LIBRARY_DIR=$lib_dir" - echo "LBUG_INCLUDE_DIR=$lib_dir" + echo "LBUG_LIBRARY_DIR=$lib_dir_env" + echo "LBUG_INCLUDE_DIR=$lib_dir_env" } >> "$GITHUB_ENV" - name: Run tests env: @@ -223,9 +227,13 @@ jobs: ;; esac test -f "$lib_dir/lbug.h" + lib_dir_env="$lib_dir" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + lib_dir_env="$(cygpath -m "$lib_dir")" + fi { - echo "LBUG_LIBRARY_DIR=$lib_dir" - echo "LBUG_INCLUDE_DIR=$lib_dir" + echo "LBUG_LIBRARY_DIR=$lib_dir_env" + echo "LBUG_INCLUDE_DIR=$lib_dir_env" } >> "$GITHUB_ENV" - name: Build Rust production binary env: