1mod task_registry;
5
6use task_registry::{
7 McpTaskOperation, McpTaskProgress, McpTaskRecord, McpTaskRegistry, McpTaskState,
8};
9
10use crate::atlas_map::{
11 AtlasMapConfig, IgnoreEntryKind, LintOptions, MAX_MAP_RESPONSE_BYTES, add_ignore_entry,
12 effective_config_report, enforce_map_response_limit, init_gitignore, init_project_with_config,
13 list_ignore_entries, load_atlas_config, load_atlas_config_for_root, remove_ignore_entry,
14 render_map, write_json_map,
15};
16use crate::runtime::{
17 DEFAULT_HEALTH_LIMIT, INDEX_WORKER_SAFE_CEILING, IndexInitRequired, IndexProjectMismatch,
18 IndexRefreshRequired, IndexVerificationIncomplete, InitBootstrapOptions, InitHydrationPhase,
19 InitHydrationStatus, InitPhaseStatus, InitScanPhase, InitSetupReport, MAX_HEALTH_LIMIT,
20 MAX_SYMBOL_FILE_BYTES, ProjectWorktreeRequired, PurposeCuratorHandoff, PurposeLintLevel,
21 PurposeReviewRequest, ResetIndexReport, ScanReport, ScanRuntimePlan,
22 SettingsClassifiedNavigationReport, SourceObservationRegistry, SourceReadAdmission,
23 SymbolBuildOptions, UsageRuntimeInstance, VerifiedReadOutcome, VerifiedReadStamp, WatchReport,
24 build_settings_report, byte_count_to_tokens, canonical_project_root,
25 canonical_source_project_root, classified_navigation_capabilities,
26 classified_ranked_file_nodes_with_reasons, config_root_mismatch_error,
27 default_mcp_project_root, estimated_source_tokens_for_indexed_files,
28 estimated_source_tokens_for_paths, federated_worktree_error, index_init_required,
29 index_work_control, init_config_path, init_next_steps, lint_project,
30 load_synchronized_repository_token_report, lossless_native_path_display,
31 lossless_project_root_display, next_step_report_payload, next_step_report_with_selection,
32 normalized_folder_filter, open_atlas_store_for_project, open_atlas_store_read_only_for_project,
33 open_existing_atlas_store_for_project, open_federated_atlas_stores_for_project,
34 preflight_existing_project_binding, publication_contract_matches, purpose_curation_page,
35 purpose_curator_handoff, ranked_file_nodes_with_reasons, ranked_folder_nodes_with_reasons,
36 read_indexed_file_content, reconcile_hydrated_index_controlled,
37 record_directory_walk_usage_estimate, record_usage_estimate, record_usage_text,
38 refresh_index_from_assessment_controlled, render_classified_ranked_file_rows,
39 render_classified_symbol_rows, render_health_page, render_purpose_curation_page,
40 render_purpose_review_report, require_current_worktree_usage_snapshot,
41 require_registered_worktree_lifecycle, reset_index_files, reset_index_files_with_revalidation,
42 review_purposes, run_init_bootstrap, run_scan_pipeline_controlled,
43 run_single_watch_refresh_controlled, run_symbol_build_pipeline_controlled,
44 strip_legacy_purpose, telemetry_disabled, validate_purpose_review_admission,
45 validated_indexed_file_key, watcher_status_report,
46};
47#[cfg(all(test, unix))]
48use crate::runtime::{IndexReadStatus, IndexRefreshReason, IndexRefreshScope};
49#[cfg(test)]
50use crate::runtime::{
51 PURPOSE_CURATOR_RECOMMENDED_REASONING, db_sidecar_path, mcp_config_path_for_db,
52 run_scan_pipeline, synchronize_registered_worktree_usage,
53};
54use crate::token_tui::{
55 TokenDashboardTheme, render_token_dashboard_plain_with_theme,
56 render_token_trend_dashboard_plain_with_theme,
57};
58use crate::{
59 AgentErrorKind, CliError, DEFAULT_FILE_SUMMARY_LIMIT, DatabaseFilesystemErrorPayload,
60 HarnessConfig, OutputFormat, RootTransition, RuntimeInfoReport, SchemaMigrationRequiredPayload,
61 SchemaVersionMismatchPayload, SearchRetrievalModeArg, build_harness_mcp_config_report,
62 build_parity_report, build_repository_control_report, build_root_report, build_runtime_info,
63 controlled_named_output, database_filesystem_error_payload, finalize_coverage_output,
64 render_code_slice, render_file_summary, render_parity_report, render_repository_control_report,
65 render_root_report, render_runtime_info, render_search_report, render_watch_status,
66 schema_migration_required_payload, schema_version_mismatch_payload,
67};
68use projectatlas_core::graph::{
69 Completeness, ConfidenceClass, CoverageRecord, DocumentTargetUnresolvedReason, EntitySelector,
70 ExternalSelector, GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind,
71 ProjectInstanceId, RelationOccurrence, RelationResolution, RepositoryFilePath,
72 ReusableTargetSelector, SourceSpan,
73};
74use projectatlas_core::health::Severity;
75use projectatlas_core::language::{ContentClassification, ContentSelection};
76use projectatlas_core::outline::build_outline;
77use projectatlas_core::symbols::ParserKind;
78use projectatlas_core::telemetry::{
79 TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
80 TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
81 TokenTrendWindow, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
82};
83use projectatlas_core::toon::{
84 encode_agent_payload, render_outline, render_overview, render_ranked_nodes,
85 render_symbol_relations, render_token_overview, render_token_trends,
86};
87use projectatlas_core::{
88 CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
89 MAX_GIT_WORKTREE_REGISTRATIONS, NavigationNextCall, NavigationNextCapability, Overview,
90 PurposeSource, PurposeStatus, RankedConnection, RankedConnectionCount, RankedConnectionKind,
91 RankedConnectionTarget, RankedNode, RankedReasonCode, normalize_native_path_display,
92 normalize_native_path_display_str, normalize_repo_path, normalize_repo_path_prefix,
93 validated_repo_file_key, validated_repo_node_key,
94};
95use projectatlas_db::{
96 ActiveWorktreeRegistrationGuard, AtlasStore, DbError, HealthQuery, HealthResolution,
97 HealthScope, PreparedWorktreeHydrationCandidate, RepositoryCoverageQuery, WorktreeAlias,
98 WorktreeHydrationActivation, WorktreeRegistration, WorktreeRegistrationState,
99 WorktreeUsageSnapshot, WorktreeUsageSyncState, read_legacy_project_root_candidate_read_only,
100 read_project_root_identity_read_only, verify_project_database,
101};
102use projectatlas_fs::worktree::{
103 GitRepositoryStructure, GitWorktreeEntry, GitWorktreeRole, GitWorktreeState,
104 RepositoryStructure, discover_repository_structure, git_administrative_identity,
105};
106#[cfg(test)]
107use projectatlas_service::build_file_summary_from_source;
108use projectatlas_service::{
109 COVERAGE_PAGE_MAX_LIMIT, CodeSliceBudget, CoverageDigest, CoverageTrustState,
110 DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery, DetailedRelationReport,
111 DetailedRelationRow, DetailedRelationWork, EntrypointProfile, FederatedDetailedRelationReport,
112 FederatedParticipant, FederatedRelationWork, FederatedRendezvous, FederatedStore,
113 FileCallSummary, FileSummaryReport, FileSymbolSummary, GitImpactSelection,
114 RelationAnalysisMode, RelationAnalysisQuery, RelationAnchor, RelationDirection,
115 RelationNextCall, RelationPurpose, RelationTotalState, SearchQuery, ServiceError,
116 SymbolSliceSelector, TokenReport, TokenReportRequest,
117 build_file_summary_from_source_with_selection, load_coverage_discovery_controlled,
118 load_detailed_relation_page, load_federated_detailed_relations,
119 load_federated_relation_analysis, load_relation_analysis, load_token_report,
120 parse_coverage_parser, parse_coverage_relation, parse_coverage_state,
121 parse_relation_confidence, parse_relation_direction, parse_relation_resolution,
122 parse_symbol_kind, read_indexed_code_slice_from_source_bounded_with_selection,
123 read_symbol_slice_from_source_bounded_with_selection, search_indexed_files_with_control,
124 validate_federated_root_count,
125};
126use rmcp::handler::server::{
127 router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters,
128};
129use rmcp::model::{CallToolResponse, Implementation, ServerCapabilities, ServerConfig};
130use rmcp::schemars;
131use rmcp::service::RequestContext;
132use rmcp::{RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
133use serde::{Deserialize, Serialize};
134use std::collections::{HashSet, VecDeque};
135use std::fs;
136use std::path::{Component, Path, PathBuf};
137use std::sync::{
138 Arc, Mutex, RwLock,
139 atomic::{AtomicU64, Ordering},
140};
141use std::thread;
142use std::time::{Duration, SystemTime, UNIX_EPOCH};
143
144#[derive(Debug, PartialEq, Eq)]
146struct McpToolTextResult(Result<String, String>);
147
148impl std::ops::Deref for McpToolTextResult {
149 type Target = str;
150
151 fn deref(&self) -> &Self::Target {
152 match &self.0 {
153 Ok(text) | Err(text) => text,
154 }
155 }
156}
157
158impl std::fmt::Display for McpToolTextResult {
159 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 formatter.write_str(self)
161 }
162}
163
164impl IntoCallToolResult for McpToolTextResult {
165 fn into_call_tool_result(self) -> Result<CallToolResponse, rmcp::ErrorData> {
166 self.0.into_call_tool_result()
167 }
168}
169
170pub(crate) const REQUIRED_MCP_TOOL_NAMES: &[&str] = &[
172 MCP_TOOL_ATLAS_SET_PROJECT_PATH,
173 MCP_TOOL_ATLAS_WORKTREE_LIST,
174 MCP_TOOL_ATLAS_WORKTREE_ADD,
175 MCP_TOOL_ATLAS_WORKTREE_REMOVE,
176 MCP_TOOL_ATLAS_INIT,
177 MCP_TOOL_ATLAS_MAP,
178 MCP_TOOL_ATLAS_ROOT,
179 MCP_TOOL_ATLAS_ROOT_SET,
180 MCP_TOOL_ATLAS_CONFIG,
181 MCP_TOOL_ATLAS_IGNORE_LIST,
182 MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE,
183 MCP_TOOL_ATLAS_IGNORE_ADD,
184 MCP_TOOL_ATLAS_IGNORE_REMOVE,
185 MCP_TOOL_ATLAS_SCAN,
186 MCP_TOOL_ATLAS_OVERVIEW,
187 MCP_TOOL_ATLAS_FOLDERS,
188 MCP_TOOL_ATLAS_FILES,
189 MCP_TOOL_ATLAS_NEXT,
190 MCP_TOOL_ATLAS_OUTLINE,
191 MCP_TOOL_ATLAS_FILE_SUMMARY,
192 MCP_TOOL_ATLAS_SEARCH,
193 MCP_TOOL_ATLAS_SLICE,
194 MCP_TOOL_ATLAS_SYMBOLS_BUILD,
195 MCP_TOOL_ATLAS_SYMBOLS,
196 MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
197 MCP_TOOL_ATLAS_HEALTH,
198 MCP_TOOL_ATLAS_HEALTH_RESOLVE,
199 MCP_TOOL_ATLAS_LINT,
200 MCP_TOOL_ATLAS_TOKEN_REPORT,
201 MCP_TOOL_ATLAS_PARITY_REPORT,
202 MCP_TOOL_ATLAS_SETTINGS,
203 MCP_TOOL_ATLAS_WATCH_STATUS,
204 MCP_TOOL_ATLAS_WATCH_ONCE,
205 MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE,
206 MCP_TOOL_ATLAS_RESET_INDEX,
207 MCP_TOOL_ATLAS_MCP_CONFIG,
208 MCP_TOOL_ATLAS_RUNTIME_INFO,
209 MCP_TOOL_ATLAS_SESSION_BRIEF,
210 MCP_TOOL_ATLAS_TASK_STATUS,
211 MCP_TOOL_ATLAS_TASK_CANCEL,
212 MCP_TOOL_ATLAS_PURPOSE_QUEUE,
213 MCP_TOOL_ATLAS_PURPOSE_SET,
214 MCP_TOOL_ATLAS_PURPOSE_REVIEW,
215];
216
217const MCP_TOOL_ATLAS_SET_PROJECT_PATH: &str = "atlas_set_project_path";
219const MCP_TOOL_ATLAS_WORKTREE_LIST: &str = "atlas_worktree_list";
221const MCP_TOOL_ATLAS_WORKTREE_ADD: &str = "atlas_worktree_add";
223const MCP_TOOL_ATLAS_WORKTREE_REMOVE: &str = "atlas_worktree_remove";
225const MCP_TOOL_ATLAS_INIT: &str = "atlas_init";
227const MCP_TOOL_ATLAS_MAP: &str = "atlas_map";
229const MCP_TOOL_ATLAS_ROOT: &str = "atlas_root";
231const MCP_TOOL_ATLAS_ROOT_SET: &str = "atlas_root_set";
233const MCP_TOOL_ATLAS_CONFIG: &str = "atlas_config";
235const MCP_TOOL_ATLAS_IGNORE_LIST: &str = "atlas_ignore_list";
237const MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE: &str = "atlas_ignore_init_gitignore";
239const MCP_TOOL_ATLAS_IGNORE_ADD: &str = "atlas_ignore_add";
241const MCP_TOOL_ATLAS_IGNORE_REMOVE: &str = "atlas_ignore_remove";
243const MCP_TOOL_ATLAS_SCAN: &str = "atlas_scan";
245const MCP_TOOL_ATLAS_OVERVIEW: &str = "atlas_overview";
247const MCP_TOOL_ATLAS_FOLDERS: &str = "atlas_folders";
249const MCP_TOOL_ATLAS_FILES: &str = "atlas_files";
251const MCP_TOOL_ATLAS_NEXT: &str = "atlas_next";
253const MCP_TOOL_ATLAS_OUTLINE: &str = "atlas_outline";
255const MCP_TOOL_ATLAS_FILE_SUMMARY: &str = "atlas_file_summary";
257const MCP_TOOL_ATLAS_SEARCH: &str = "atlas_search";
259const MCP_TOOL_ATLAS_SLICE: &str = "atlas_slice";
261const MCP_TOOL_ATLAS_SYMBOLS_BUILD: &str = "atlas_symbols_build";
263const MCP_TOOL_ATLAS_SYMBOLS: &str = "atlas_symbols";
265const MCP_TOOL_ATLAS_SYMBOL_RELATIONS: &str = "atlas_symbol_relations";
267const MCP_TOOL_ATLAS_HEALTH: &str = "atlas_health";
269const MCP_TOOL_ATLAS_HEALTH_RESOLVE: &str = "atlas_health_resolve";
271const MCP_TOOL_ATLAS_LINT: &str = "atlas_lint";
273const MCP_TOOL_ATLAS_TOKEN_REPORT: &str = "atlas_token_report";
275const MCP_TOOL_ATLAS_PARITY_REPORT: &str = "atlas_parity_report";
277const MCP_TOOL_ATLAS_SETTINGS: &str = "atlas_settings";
279const MCP_TOOL_ATLAS_WATCH_STATUS: &str = "atlas_watch_status";
281const MCP_TOOL_ATLAS_WATCH_ONCE: &str = "atlas_watch_once";
283const MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE: &str = "atlas_strip_legacy_purpose";
285const MCP_TOOL_ATLAS_RESET_INDEX: &str = "atlas_reset_index";
287const MCP_TOOL_ATLAS_MCP_CONFIG: &str = "atlas_mcp_config";
289const MCP_TOOL_ATLAS_RUNTIME_INFO: &str = "atlas_runtime_info";
291const MCP_TOOL_ATLAS_SESSION_BRIEF: &str = "atlas_session_brief";
293const MCP_TOOL_ATLAS_TASK_STATUS: &str = "atlas_task_status";
295const MCP_TOOL_ATLAS_TASK_CANCEL: &str = "atlas_task_cancel";
297const MCP_TOOL_ATLAS_PURPOSE_QUEUE: &str = "atlas_purpose_queue";
299const MCP_TOOL_ATLAS_PURPOSE_SET: &str = "atlas_purpose_set";
301const MCP_TOOL_ATLAS_PURPOSE_REVIEW: &str = "atlas_purpose_review";
303const PROJECTATLAS_DIR_NAME: &str = ".projectatlas";
305const PROJECTATLAS_DB_FILE_NAME: &str = "projectatlas.db";
307const PROJECTATLAS_CONFIG_FILE_NAME: &str = "config.toml";
309const PROJECTATLAS_FLAT_CONFIG_FILE_NAME: &str = "projectatlas.toml";
311const MCP_TELEMETRY_PROJECT_BINDING_LIMIT: usize = 64;
313const MCP_SETTINGS_RESPONSE_MAX_BYTES: usize = 64_000;
315const MCP_SETTINGS_RESPONSE_LIMIT_PREFIX: &str = "settings response requires ";
317const MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR: &str = " bytes, exceeding the ";
319const MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX: &str = "-byte diagnostic limit";
321const MCP_SOURCE_TOKEN_BASELINE_LIMIT: usize = 128;
323const MCP_DEFAULT_CONFIG_SERVER_NAME: &str = "projectatlas";
325const SELECTED_ROOT_ASSERTION_GUIDANCE: &str = "pass project_path or call atlas_set_project_path for another repository, or use normal filesystem tools such as Get-Content or rg for files inside the selected project";
327const OUTSIDE_SELECTED_PROJECT_GUIDANCE: &str = "pass project_path or call atlas_set_project_path for that repository, or use normal filesystem tools such as Get-Content or rg for files outside the selected ProjectAtlas project";
329const CURRENT_DIR_ALIAS: &str = ".";
331const MCP_SERVER_NAME: &str = "ProjectAtlas";
333const MCP_CANCELLATION_MONITOR_THREAD_NAME: &str = "projectatlas-mcp-cancel";
335const MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX: &str =
337 "MCP request cancellation monitor could not start: ";
338const MCP_ERROR_REGISTERED_WORKTREE_ROOT_INVALID_PREFIX: &str =
340 "registered worktree root is invalid: ";
341const MCP_PROJECT_STATE_LOCK_POISONED: &str = "MCP project state lock poisoned";
343const MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX: &str = "error: ";
345const MCP_PAYLOAD_SCAN: &str = "scan";
347const MCP_PAYLOAD_INIT: &str = "init";
349const MCP_PAYLOAD_WORKTREES: &str = "worktrees";
351const MCP_PAYLOAD_WORKTREE: &str = "worktree";
353const MCP_PAYLOAD_MAP: &str = "map";
355const MCP_PAYLOAD_CONFIG: &str = "config";
357const MCP_PAYLOAD_IGNORE: &str = "ignore";
359const MCP_PAYLOAD_GITIGNORE: &str = "gitignore";
361const MCP_PAYLOAD_LINT: &str = "lint";
363const MCP_PAYLOAD_SYMBOLS_BUILD: &str = "symbols_build";
365const MCP_PAYLOAD_SYMBOL_RELATIONS: &str = "symbol_relations";
367const MCP_PAYLOAD_HEALTH_RESOLUTION: &str = "health_resolution";
369const MCP_PAYLOAD_TOKEN_TRENDS: &str = "token_trends";
371const MCP_PAYLOAD_TOKEN_SAVINGS: &str = "token_savings";
373const MCP_PAYLOAD_CHART: &str = "chart";
375const MCP_PAYLOAD_WATCH: &str = "watch";
377const MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION: &str = "legacy_purpose_migration";
379const MCP_PAYLOAD_RESET_INDEX: &str = "reset_index";
381const MCP_PAYLOAD_MCP_CONFIG: &str = "mcp_config";
383const MCP_PAYLOAD_NEXT: &str = "next";
385const MCP_PAYLOAD_SELECTED_PROJECT: &str = "selected_project";
387const MCP_PAYLOAD_SETTINGS: &str = "settings";
389const MCP_PAYLOAD_SESSION_BRIEF: &str = "session_brief";
391const MCP_FILE_SOURCE_STATUS_LIVE: &str = "live-source";
393const MCP_PAYLOAD_TASK_START: &str = "task_start";
395const MCP_PAYLOAD_TASK_STATUS: &str = "task_status";
397const MCP_PAYLOAD_TASK_CANCEL: &str = "task_cancel";
399const MCP_PAYLOAD_SESSION_CAPABILITIES: &str = "mcp_session";
401const MCP_BRIEF_ARG_PROJECT_PATH: &str = "project_path";
403const MCP_BRIEF_ARG_WORKTREE: &str = "worktree";
405const MCP_BRIEF_ARG_FILE: &str = "file";
407const MCP_BRIEF_ARG_PATTERN: &str = "pattern";
409const MCP_BRIEF_ARG_VIEW: &str = "view";
411const MCP_BRIEF_ARG_LIMIT: &str = "limit";
413const MCP_BRIEF_ARG_TASK: &str = "task";
415const MCP_BRIEF_ARG_COMPACT: &str = "compact";
417const MCP_BRIEF_TARGET_FILESYSTEM_TOOLS: &str = "filesystem_tools";
419const MCP_BRIEF_REASON_SELECTED_INDEX_MISSING: &str = "selected_index_missing";
421const MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX: &str =
423 "use_filesystem_until_projectatlas_index_exists";
424const MCP_BRIEF_REASON_RANKED_FILE_SUMMARY: &str = "ranked_file_ready_for_summary";
426const MCP_BRIEF_REASON_RANKED_FILE_RELATIONS: &str = "ranked_file_ready_for_relations";
428const MCP_BRIEF_REASON_SEARCH_FALLBACK: &str = "no_ranked_file_candidate_search_index";
430const MCP_BRIEF_REASON_NO_FILE_CANDIDATE: &str = "no_ranked_file_candidate";
432const MCP_BRIEF_REASON_HEALTH_BLOCKERS: &str = "unresolved_health_blockers_present";
434const MCP_BRIEF_REASON_PURPOSE_QUEUE: &str = "purpose_queue_ready";
436const MCP_TASK_PROGRESS_CONTRACT_MESSAGE: &str = "task progress contract available";
438const MCP_EVENT_ATLAS_OVERVIEW: &str = "mcp.atlas_overview";
440const MCP_EVENT_ATLAS_FOLDERS: &str = "mcp.atlas_folders";
442const MCP_EVENT_ATLAS_FILES: &str = "mcp.atlas_files";
444const MCP_EVENT_ATLAS_NEXT: &str = "mcp.atlas_next";
446const MCP_EVENT_ATLAS_OUTLINE: &str = "mcp.atlas_outline";
448const MCP_EVENT_ATLAS_FILE_SUMMARY: &str = "mcp.atlas_file_summary";
450const MCP_EVENT_ATLAS_SEARCH: &str = "mcp.atlas_search";
452const MCP_EVENT_ATLAS_SLICE: &str = "mcp.atlas_slice";
454const MCP_EVENT_ATLAS_SYMBOLS: &str = "mcp.atlas_symbols";
456const MCP_EVENT_ATLAS_SYMBOL_RELATIONS: &str = "mcp.atlas_symbol_relations";
458const MCP_SYMBOL_RELATION_VIEW_LEGACY: &str = "legacy";
460const MCP_SYMBOL_RELATION_VIEW_DETAILED: &str = "detailed";
462const MCP_SYMBOL_RELATION_VIEW_ANALYSIS: &str = "analysis";
464const MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE: &str = "architecture";
466const MCP_RELATION_ANALYSIS_MODE_IMPACT: &str = "impact";
468const MCP_RELATION_ANALYSIS_MODE_TRACE: &str = "trace";
470const MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT: &str = "entrypoint";
472const MCP_ENTRYPOINT_PROFILE_DEFAULT_NAME: &str = "entrypoint-profile";
474const MCP_ERROR_ENTRYPOINT_FEDERATED: &str = "entrypoint profiles require one project root";
476const MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX: &str =
478 "entrypoints must contain exact RelationAnchor JSON objects: ";
479const MCP_ERROR_ENTRYPOINT_CONTROLS_MODE: &str =
481 "entrypoint profile controls require analysis_mode=entrypoint";
482const MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR: &str =
484 "entrypoint anchors cannot be combined with detailed symbol selectors";
485const MCP_RELATION_ANALYSIS_VCS_WORKING_TREE: &str = "working_tree";
487const MCP_RELATION_ANALYSIS_VCS_INDEX: &str = "index";
489const MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE: &str = "revision_range";
491const MCP_SYMBOL_RELATION_DIRECTION_DEFAULT: &str = "outbound";
493const MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT: &str = "low";
495const MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT: &str = "any";
497const MCP_ERROR_SYMBOL_RELATION_VIEW: &str = "unsupported symbol relation view";
499const MCP_ERROR_DETAILED_RELATION_QUERY: &str =
501 "detailed symbol relations use exact symbol selectors, not query";
502const MCP_ERROR_LEGACY_RELATION_CONTROLS: &str = "graph selectors, federation, traversal and continuation controls require view detailed or analysis";
504const MCP_ERROR_LEGACY_RELATION_BUDGET: &str = "legacy relation budget was not admitted";
506const MCP_ERROR_DETAILED_RELATION_FILE: &str = "detailed relations require an exact file anchor; discover candidates with atlas_files (limit 3), then call atlas_symbol_relations with file and optional symbol";
508const MCP_ERROR_DETAILED_RELATION_SYMBOL: &str = "detailed relation symbol must not be empty";
510const MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR: &str = "symbol disambiguators require symbol";
512const MCP_ERROR_DETAILED_RELATION_LIMIT: &str = "detailed relation limit exceeds the u32 range";
514const MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW: &str =
516 "compact symbol relations require view=detailed";
517const MCP_ERROR_CONTENT_SELECTION_RELATION_VIEW: &str =
519 "content_selection requires view=detailed or view=analysis";
520const MCP_ERROR_ANALYSIS_VIEW_REQUIRED: &str = "analysis controls require view=analysis";
522const MCP_ERROR_FEDERATED_RELATION_VIEW: &str =
524 "roots or worktrees require the detailed or analysis relation view";
525const MCP_ERROR_FEDERATED_SELECTOR_CONFLICT: &str =
527 "roots and worktrees are mutually exclusive federation selectors";
528const MCP_ERROR_FEDERATED_PROJECT_PATH_CONFLICT: &str =
530 "worktrees federation cannot be combined with project_path";
531const MCP_ERROR_FEDERATED_PRIMARY_CONFLICT: &str =
533 "worktree must match the first ordered worktrees alias";
534const MCP_ERROR_TRACE_TARGET_KIND_REQUIRED: &str = "symbol trace targets require trace_target_kind";
536const MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED: &str =
538 "symbol trace targets require trace_target_signature";
539const MCP_ERROR_TRACE_TARGET_REQUIRED: &str =
541 "trace target symbol disambiguators require trace_target";
542const MCP_ERROR_TRACE_TARGET_FILE_REQUIRED: &str = "trace_target requires trace_target_file";
544const MCP_ERROR_VCS_REVISION_FIELDS: &str = "vcs_base and vcs_head require vcs=revision_range";
546const MCP_ERROR_VCS_BASE_REQUIRED: &str = "vcs=revision_range requires vcs_base";
548const MCP_ERROR_VCS_HEAD_REQUIRED: &str = "vcs=revision_range requires vcs_head";
550const MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS: &str = "unsupported analysis VCS selection";
552const MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE: &str = "unsupported relation analysis mode";
554const MCP_EVENT_ATLAS_HEALTH: &str = "mcp.atlas_health";
556const MCP_EVENT_ATLAS_PURPOSE_QUEUE: &str = "mcp.atlas_purpose_queue";
558const MCP_IGNORE_KIND_DIR_NAME: &str = "dir-name";
560const MCP_IGNORE_KIND_DIR_NAME_ALIAS: &str = "dir_name";
562const MCP_IGNORE_KIND_PATH_PREFIX: &str = "path-prefix";
564const MCP_IGNORE_KIND_PATH_PREFIX_ALIAS: &str = "path_prefix";
566const MCP_PURPOSE_LEVEL_LOW: &str = "low";
568const MCP_PURPOSE_LEVEL_MEDIUM: &str = "medium";
570const MCP_PURPOSE_LEVEL_STRICT: &str = "strict";
572const MCP_PURPOSE_TASK_SESSION_STARTUP: &str = "session-startup";
574const MCP_PURPOSE_TASK_QUEUE: &str = "purpose-curation";
576const MCP_HARNESS_MCP_JSON: &str = "mcp-json";
578const MCP_HARNESS_MCP_JSON_ALIAS: &str = "mcp_json";
580const MCP_HARNESS_CODEX: &str = "codex";
582const MCP_HARNESS_CLAUDE_CODE: &str = "claude-code";
584const MCP_HARNESS_CLAUDE_CODE_ALIAS: &str = "claude_code";
586const MCP_HARNESS_OPENCODE: &str = "opencode";
588const MCP_ERROR_IGNORE_KIND_REQUIRED: &str =
590 "ignore kind is required; expected dir-name or path-prefix";
591const MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD: &str = "ignore kind is required for atlas_ignore_add";
593const MCP_ERROR_ROOT_CONTROL_CONFLICT: &str =
595 "control_root cannot be combined with project_path or verify";
596const MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX: &str = "coverage start index is too large: ";
598const MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX: &str = "coverage limit is too large: ";
600const MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE: &str = "coverage filters require coverage=true";
602const MCP_ENV_CI: &str = "CI";
604const MCP_ENV_GITHUB_ACTIONS: &str = "GITHUB_ACTIONS";
606const MCP_MAP_SKIPPED_IN_CI_REASON: &str =
608 "JSON sidecar skipped in CI; pass force=true to write it";
609const MCP_NO_ROOT_PLACEHOLDER: &str = "none";
611const MCP_ERROR_INVALID_IGNORE_KIND_PREFIX: &str = "invalid ignore kind '";
613const MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX: &str = "'; expected dir-name or path-prefix";
615const MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX: &str = "invalid purpose_level '";
617const MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX: &str = "'; expected low, medium, or strict";
619const MCP_ERROR_INVALID_HARNESS_PREFIX: &str = "invalid harness '";
621const MCP_ERROR_INVALID_HARNESS_SUFFIX: &str =
623 "'; expected mcp-json, codex, claude-code, or opencode";
624const MCP_ERROR_FOR_PATH_FRAGMENT: &str = " for '";
626const MCP_ERROR_LEXICAL_ROOT_FRAGMENT: &str = "'; lexical root: '";
628const MCP_ERROR_RESOLVED_ROOT_FRAGMENT: &str = "'; resolved root: '";
630const MCP_ERROR_GUIDANCE_FRAGMENT: &str = "'; ";
632const NODE_LABEL_FOLDERS: &str = "folders";
634const NODE_LABEL_FILES: &str = "files";
636const NODE_LABEL_SYMBOLS: &str = "symbols";
638const SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR: &str = "symbol disambiguators require symbol";
640const START_LINE_REQUIRED_ERROR: &str = "start_line is required unless symbol is provided";
642const PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
644 "path is not inside an indexed ProjectAtlas project";
645const FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
647 "folder is not inside an indexed ProjectAtlas project";
648const AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR: &str = "absolute MCP path resolves through a symlink or junction with multiple plausible ProjectAtlas roots";
650const SEVERITY_EXPECTED_SEPARATOR: &str = ", ";
652const SEVERITY_EXPECTED_FINAL_SEPARATOR: &str = ", or ";
654const TOKEN_TREND_WINDOW_ERROR_SUFFIX: &str = "expected day, week, month, or year";
656const TOKEN_TREND_BENCHMARK_ERROR: &str =
658 "benchmark_results is only supported for token overview reports";
659const TOKEN_TRENDS_RESULT_VARIANT_MISMATCH: &str = "token trend request returned an overview";
661const TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH: &str = "token overview request returned trends";
663const TOKEN_CHART_THEME_ERROR_PREFIX: &str = "unsupported token chart theme ";
665const TOKEN_CHART_THEME_ERROR_SUFFIX: &str = "; expected dark or light";
667const WATCH_STATUS_SCAN_RECOMMENDATION: &str =
669 " Run `atlas_scan` first when no ProjectAtlas index exists for this project.";
670const SESSION_BRIEF_DEFAULT_LIMIT: usize = 5;
672const COMPACT_SESSION_BRIEF_DEFAULT_LIMIT: usize = 3;
674const SESSION_BRIEF_MAX_LIMIT: usize = 8;
676const MCP_TASK_REGISTRY_CAPACITY: usize = 32;
678const MCP_TASK_ERROR_MAX_CHARS: usize = 512;
680const MCP_TASK_CONTRACT_ID: &str = "task-progress-contract";
682const MCP_TASK_REGISTRY_LOCK_POISONED: &str = "MCP task registry lock is poisoned";
684const MCP_INDEX_TASK_ID_PREFIX: &str = "index-";
686const MCP_INDEX_WORKER_NAME_PREFIX: &str = "projectatlas-";
688const MCP_INDEX_TASK_LIMIT_PREFIX: &str = "background indexing task limit ";
690const MCP_INDEX_TASK_LIMIT_SUFFIX: &str = " is already active";
692const MCP_BACKGROUND_TASK_SAFE_CEILING: usize = 4;
694const MCP_INDEX_WORKER_PANIC_ERROR: &str = "background indexing worker panicked";
696const MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX: &str = "failed to start background indexing: ";
698const MCP_TASK_PROGRESS_ACCEPTED: &str = "accepted";
700const MCP_TASK_PROGRESS_RUNNING: &str = "running";
702const MCP_TASK_PROGRESS_COMPLETE: &str = "complete";
704const MCP_TASK_PROGRESS_FAILED: &str = "failed";
706const MCP_TASK_PROGRESS_CANCELED: &str = "canceled";
708const MCP_TASK_PROGRESS_CANCELLATION_REQUESTED: &str = "cancellation_requested";
710const MCP_SERVER_INSTRUCTIONS: &str = "ProjectAtlas provides TOON-first repository orientation, folder/file ranking, structured file summaries, symbol graph lookup, exact slices, health checks, and token telemetry for coding agents.";
712const MCP_WORKTREE_PROJECT_PATH_CONFLICT: &str =
714 "worktree and project_path are mutually exclusive; choose one target selector";
715const MCP_MAIN_WORKTREE_ALIAS: &str = "main";
717const MCP_WORKTREE_LIST_MAX_ROWS: usize = (MAX_GIT_WORKTREE_REGISTRATIONS * 2) + 1;
719const MCP_WORKTREE_SELECTOR_PREFIX: &str = "wt-";
721const MCP_WORKTREE_SELECTOR_DIGEST_CHARS: usize = 16;
723const MCP_NONSOURCE_FILE_NAME: &str = "projectatlas-nonsource-files.toon";
725const MCP_HYDRATION_NO_SCAN_REASON: &str =
727 "hydration requires source reconciliation; ordinary no-scan init was requested";
728const MCP_ERROR_WORKTREE_CONTROL_REPOSITORY_REQUIRED: &str =
730 "worktree aliases require a structurally valid Git control repository";
731const MCP_ERROR_WORKTREE_PATH_NON_UTF8: &str =
733 "native worktree identity is not available as UTF-8; use its stable alias or selector";
734const MCP_ERROR_WORKTREE_IDENTITY_CONFLICT: &str =
736 "local atlas identity conflicts with its active registration";
737const MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT: &str =
739 "control atlas identity changed after worktree alias selection";
740const MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING: &str = "registered worktree atlas is missing; restore it before retrying or retiring the alias so final token totals can be synchronized";
742const MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED: &str = "bound worktree atlas cannot be reset in place; retire the alias to synchronize final totals, then register and initialize it again";
744const MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED: &str =
746 "registered worktree administrative lifecycle changed; unregister and register it again";
747const MCP_ERROR_FEDERATED_ALIAS_MISSING: &str =
749 "federated worktree resolution lost its captured alias";
750const MCP_ERROR_FEDERATED_TARGET_DUPLICATE: &str =
752 "federated worktree aliases and roots must be unique";
753const MCP_ERROR_CONTROL_ALIAS_REQUIRED: &str =
755 "the control checkout is selected through reserved alias main";
756const MCP_ERROR_WORKTREE_SELECTOR_EMPTY: &str = "worktree selector must not be empty";
758const MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE: &str = "selected worktree is no longer active";
760const MCP_WORKTREE_MISSING_RETENTION_REASON: &str =
762 "worktree is structurally missing; the last accepted telemetry total is retained";
763
764#[derive(Debug, Deserialize, schemars::JsonSchema)]
766struct AtlasProjectParams {
767 project_path: Option<String>,
769 worktree: Option<String>,
771}
772
773#[derive(Debug, Deserialize, schemars::JsonSchema)]
775struct AtlasSessionBriefParams {
776 project_path: Option<String>,
778 worktree: Option<String>,
780 query: Option<String>,
782 purpose_task: Option<String>,
784 compact: Option<bool>,
786 folder_limit: Option<usize>,
788 file_limit: Option<usize>,
790 blocker_limit: Option<usize>,
792 purpose_limit: Option<usize>,
794}
795
796#[derive(Debug, Deserialize, schemars::JsonSchema)]
798struct AtlasTaskParams {
799 task_id: String,
801}
802
803#[derive(Debug, Deserialize, schemars::JsonSchema)]
805struct AtlasSetProjectPathParams {
806 project_path: String,
808}
809
810#[derive(Debug, Deserialize, schemars::JsonSchema)]
812struct AtlasWorktreeListParams {
813 include_retired: Option<bool>,
815}
816
817#[derive(Debug, Deserialize, schemars::JsonSchema)]
819struct AtlasWorktreeAddParams {
820 worktree: String,
822 alias: Option<String>,
824}
825
826#[derive(Debug, Deserialize, schemars::JsonSchema)]
828struct AtlasWorktreeRemoveParams {
829 worktree: String,
831}
832
833#[derive(Debug, Deserialize, schemars::JsonSchema)]
835struct AtlasInitParams {
836 project_path: Option<String>,
838 worktree: Option<String>,
840 no_scan: Option<bool>,
842 force_rescan: Option<bool>,
844 text_index_max_bytes: Option<u64>,
846}
847
848#[derive(Debug, Deserialize, schemars::JsonSchema)]
850struct AtlasMapParams {
851 project_path: Option<String>,
853 worktree: Option<String>,
855 json: Option<bool>,
857 force: Option<bool>,
859}
860
861#[derive(Debug, Deserialize, schemars::JsonSchema)]
863struct AtlasRootParams {
864 project_path: Option<String>,
866 worktree: Option<String>,
868 control_root: Option<String>,
870 verify: Option<bool>,
872}
873
874#[derive(Debug, Deserialize, schemars::JsonSchema)]
876struct AtlasRootSetParams {
877 root: String,
879 transition: Option<RootTransition>,
881 nearest_project: Option<bool>,
883}
884
885#[derive(Debug, Deserialize, schemars::JsonSchema)]
887struct AtlasIgnoreMutationParams {
888 project_path: Option<String>,
890 worktree: Option<String>,
892 kind: Option<String>,
894 value: String,
896}
897
898#[derive(Debug, Deserialize, schemars::JsonSchema)]
900struct AtlasLintParams {
901 project_path: Option<String>,
903 worktree: Option<String>,
905 strict_folders: Option<bool>,
907 purpose_level: Option<String>,
909 report_untracked: Option<bool>,
911 strict_untracked: Option<bool>,
913}
914
915#[derive(Debug, Deserialize, schemars::JsonSchema)]
917struct AtlasMcpConfigParams {
918 project_path: Option<String>,
920 worktree: Option<String>,
922 server_name: Option<String>,
924 harness: Option<String>,
926 nearest_project: Option<bool>,
928}
929
930pub(crate) fn run_mcp_server(
932 db_path: PathBuf,
933 config_path: Option<PathBuf>,
934 session: String,
935 allow_nearest_project: bool,
936) -> Result<(), CliError> {
937 let server = ProjectAtlasMcpServer::new(db_path, config_path, session, allow_nearest_project);
938 let shutdown_server = server.clone();
939 let runtime = tokio::runtime::Builder::new_multi_thread()
940 .enable_all()
941 .build()
942 .map_err(|source| CliError::Mcp(source.to_string()))?;
943 let result = runtime.block_on(async move {
944 server
945 .serve(rmcp::transport::stdio())
946 .await
947 .map_err(|source| CliError::Mcp(source.to_string()))?
948 .waiting()
949 .await
950 .map_err(|source| CliError::Mcp(source.to_string()))
951 .map(|_| ())
952 });
953 shutdown_server.seal_usage_instances_for_projects();
954 result
955}
956
957pub(crate) fn required_mcp_surface_present() -> bool {
959 REQUIRED_MCP_TOOL_NAMES
960 .iter()
961 .all(|name| mcp_tool_route_present(name))
962}
963
964pub(crate) fn mcp_tool_route_present(name: &str) -> bool {
966 ProjectAtlasMcpServer::tool_router().has_route(name)
967}
968
969#[derive(Debug, Deserialize, schemars::JsonSchema)]
971struct AtlasScanParams {
972 project_path: Option<String>,
974 worktree: Option<String>,
976 path: Option<String>,
978 nearest_project: Option<bool>,
980 max_bytes: Option<u64>,
982 max_workers: Option<usize>,
984 timeout_seconds: Option<u64>,
989 text_index_max_bytes: Option<u64>,
991 background: Option<bool>,
993}
994
995#[derive(Debug, Deserialize, schemars::JsonSchema)]
997struct AtlasWatchOnceParams {
998 project_path: Option<String>,
1000 worktree: Option<String>,
1002 path: Option<String>,
1004 nearest_project: Option<bool>,
1006 max_workers: Option<usize>,
1008 timeout_seconds: Option<u64>,
1013 text_index_max_bytes: Option<u64>,
1015 background: Option<bool>,
1017}
1018
1019#[derive(Debug, Deserialize, schemars::JsonSchema)]
1021struct AtlasQueryParams {
1022 project_path: Option<String>,
1024 worktree: Option<String>,
1026 query: Option<String>,
1028 limit: Option<usize>,
1030}
1031
1032#[derive(Debug, Deserialize, schemars::JsonSchema)]
1034struct AtlasNextParams {
1035 project_path: Option<String>,
1037 worktree: Option<String>,
1039 query: Option<String>,
1041 content_selection: Option<String>,
1043 limit: Option<usize>,
1045}
1046
1047#[derive(Debug, Deserialize, schemars::JsonSchema)]
1049struct AtlasFilesParams {
1050 project_path: Option<String>,
1052 worktree: Option<String>,
1054 query: Option<String>,
1056 folder: Option<String>,
1058 nearest_project: Option<bool>,
1060 file_pattern: Option<String>,
1062 include_content: Option<bool>,
1064 content_selection: Option<String>,
1066 limit: Option<usize>,
1068}
1069
1070#[derive(Debug, Deserialize, schemars::JsonSchema)]
1072struct AtlasOutlineParams {
1073 project_path: Option<String>,
1075 worktree: Option<String>,
1077 file: String,
1079 nearest_project: Option<bool>,
1081 lines: Option<usize>,
1083}
1084
1085#[derive(Debug, Deserialize, schemars::JsonSchema)]
1087struct AtlasFileSummaryParams {
1088 project_path: Option<String>,
1090 worktree: Option<String>,
1092 file: String,
1094 nearest_project: Option<bool>,
1096 compact: Option<bool>,
1098 content_selection: Option<String>,
1100 limit: Option<usize>,
1102}
1103
1104#[derive(Debug, Deserialize, schemars::JsonSchema)]
1106struct AtlasSearchParams {
1107 project_path: Option<String>,
1109 worktree: Option<String>,
1111 pattern: String,
1113 retrieval_mode: Option<SearchRetrievalModeArg>,
1115 regex: Option<bool>,
1117 fuzzy: Option<bool>,
1119 case_sensitive: Option<bool>,
1121 file_pattern: Option<String>,
1123 context_lines: Option<usize>,
1125 start_index: Option<usize>,
1127 content_selection: Option<String>,
1129 limit: Option<usize>,
1131}
1132
1133#[derive(Debug, Deserialize, schemars::JsonSchema)]
1135struct AtlasSliceParams {
1136 project_path: Option<String>,
1138 worktree: Option<String>,
1140 file: String,
1142 nearest_project: Option<bool>,
1144 start_line: Option<usize>,
1146 end_line: Option<usize>,
1148 symbol: Option<String>,
1150 symbol_parent: Option<String>,
1152 symbol_kind: Option<String>,
1154 symbol_signature: Option<String>,
1156 symbol_line: Option<usize>,
1158 content_selection: Option<String>,
1160 output_bytes: Option<u32>,
1162}
1163
1164#[derive(Debug, Deserialize, schemars::JsonSchema)]
1166struct AtlasSymbolsParams {
1167 project_path: Option<String>,
1169 worktree: Option<String>,
1171 file: Option<String>,
1173 nearest_project: Option<bool>,
1175 query: Option<String>,
1177 content_selection: Option<String>,
1179 limit: Option<usize>,
1181}
1182
1183#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
1185struct AtlasSymbolRelationsParams {
1186 project_path: Option<String>,
1188 worktree: Option<String>,
1190 file: Option<String>,
1192 nearest_project: Option<bool>,
1194 query: Option<String>,
1196 view: Option<String>,
1198 content_selection: Option<String>,
1200 compact: Option<bool>,
1202 cursor: Option<String>,
1204 roots: Option<Vec<String>>,
1206 worktrees: Option<Vec<String>>,
1208 symbol: Option<String>,
1210 symbol_parent: Option<String>,
1212 symbol_kind: Option<String>,
1214 symbol_signature: Option<String>,
1216 direction: Option<String>,
1218 relation: Option<String>,
1220 minimum_confidence: Option<String>,
1222 resolution: Option<String>,
1224 depth: Option<u32>,
1226 include_occurrences: Option<bool>,
1228 occurrence_limit: Option<u32>,
1230 edge_limit: Option<u32>,
1232 node_limit: Option<u32>,
1234 visited_limit: Option<u32>,
1236 occurrence_total_limit: Option<u32>,
1238 intermediate_bytes: Option<u64>,
1240 deadline_ms: Option<u64>,
1242 output_bytes: Option<u32>,
1244 analysis_mode: Option<String>,
1246 profile_name: Option<String>,
1248 entrypoints: Option<Vec<String>>,
1250 profile_relations: Option<Vec<String>>,
1252 trace_target: Option<String>,
1254 trace_target_file: Option<String>,
1256 trace_target_parent: Option<String>,
1258 trace_target_kind: Option<String>,
1260 trace_target_signature: Option<String>,
1262 vcs: Option<String>,
1264 vcs_base: Option<String>,
1266 vcs_head: Option<String>,
1268 include_communities: Option<bool>,
1270 include_cycles: Option<bool>,
1272 include_dead_code: Option<bool>,
1274 limit: Option<usize>,
1276}
1277
1278fn relation_analysis_controls_present(params: &AtlasSymbolRelationsParams) -> bool {
1280 params.analysis_mode.is_some()
1281 || params.profile_name.is_some()
1282 || params
1283 .entrypoints
1284 .as_ref()
1285 .is_some_and(|items| !items.is_empty())
1286 || params
1287 .profile_relations
1288 .as_ref()
1289 .is_some_and(|items| !items.is_empty())
1290 || params.trace_target.is_some()
1291 || params.trace_target_file.is_some()
1292 || params.trace_target_parent.is_some()
1293 || params.trace_target_kind.is_some()
1294 || params.trace_target_signature.is_some()
1295 || params.vcs.is_some()
1296 || params.vcs_base.is_some()
1297 || params.vcs_head.is_some()
1298 || params.include_communities.is_some()
1299 || params.include_cycles.is_some()
1300 || params.include_dead_code.is_some()
1301}
1302
1303fn relation_analysis_trace_target(
1305 store: &AtlasStore,
1306 params: &AtlasSymbolRelationsParams,
1307) -> Result<Option<RelationAnchor>, CliError> {
1308 match (¶ms.trace_target, ¶ms.trace_target_file) {
1309 (Some(name), Some(file)) => {
1310 let file = validated_indexed_file_key(store, Path::new(file))?;
1311 let kind = params.trace_target_kind.as_deref().ok_or_else(|| {
1312 CliError::Service(ServiceError::InvalidInput(
1313 MCP_ERROR_TRACE_TARGET_KIND_REQUIRED.to_string(),
1314 ))
1315 })?;
1316 let signature = params.trace_target_signature.clone().ok_or_else(|| {
1317 CliError::Service(ServiceError::InvalidInput(
1318 MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED.to_string(),
1319 ))
1320 })?;
1321 Ok(Some(RelationAnchor::Symbol {
1322 file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1323 CliError::Service(ServiceError::InvalidInput(error.to_string()))
1324 })?,
1325 name: name.clone(),
1326 symbol_kind: Some(parse_symbol_kind(kind)?),
1327 parent: params.trace_target_parent.clone(),
1328 signature: Some(signature),
1329 }))
1330 }
1331 (None, Some(file)) => {
1332 if params.trace_target_parent.is_some()
1333 || params.trace_target_kind.is_some()
1334 || params.trace_target_signature.is_some()
1335 {
1336 return Err(CliError::Service(ServiceError::InvalidInput(
1337 MCP_ERROR_TRACE_TARGET_REQUIRED.to_string(),
1338 )));
1339 }
1340 let file = validated_indexed_file_key(store, Path::new(file))?;
1341 Ok(Some(RelationAnchor::File {
1342 file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1343 CliError::Service(ServiceError::InvalidInput(error.to_string()))
1344 })?,
1345 }))
1346 }
1347 (Some(_), None) => Err(CliError::Service(ServiceError::InvalidInput(
1348 MCP_ERROR_TRACE_TARGET_FILE_REQUIRED.to_string(),
1349 ))),
1350 (None, None) => Ok(None),
1351 }
1352}
1353
1354fn relation_analysis_vcs(
1356 params: &AtlasSymbolRelationsParams,
1357) -> Result<GitImpactSelection, CliError> {
1358 match params
1359 .vcs
1360 .as_deref()
1361 .unwrap_or(MCP_RELATION_ANALYSIS_VCS_WORKING_TREE)
1362 {
1363 MCP_RELATION_ANALYSIS_VCS_WORKING_TREE => {
1364 if params.vcs_base.is_some() || params.vcs_head.is_some() {
1365 return Err(CliError::Service(ServiceError::InvalidInput(
1366 MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1367 )));
1368 }
1369 Ok(GitImpactSelection::WorkingTree)
1370 }
1371 MCP_RELATION_ANALYSIS_VCS_INDEX => {
1372 if params.vcs_base.is_some() || params.vcs_head.is_some() {
1373 return Err(CliError::Service(ServiceError::InvalidInput(
1374 MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1375 )));
1376 }
1377 Ok(GitImpactSelection::Index)
1378 }
1379 MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE => Ok(GitImpactSelection::RevisionRange {
1380 base: params.vcs_base.clone().ok_or_else(|| {
1381 CliError::Service(ServiceError::InvalidInput(
1382 MCP_ERROR_VCS_BASE_REQUIRED.to_string(),
1383 ))
1384 })?,
1385 head: params.vcs_head.clone().ok_or_else(|| {
1386 CliError::Service(ServiceError::InvalidInput(
1387 MCP_ERROR_VCS_HEAD_REQUIRED.to_string(),
1388 ))
1389 })?,
1390 }),
1391 _unsupported => Err(CliError::Service(ServiceError::InvalidInput(
1392 MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS.to_string(),
1393 ))),
1394 }
1395}
1396
1397#[derive(Debug, Deserialize, schemars::JsonSchema)]
1399struct AtlasTokenParams {
1400 project_path: Option<String>,
1402 worktree: Option<String>,
1404 session: Option<String>,
1406 include_chart: Option<bool>,
1408 trend_window: Option<String>,
1410 benchmark_results: Option<String>,
1412 theme: Option<String>,
1414}
1415
1416#[derive(Debug, Deserialize, schemars::JsonSchema)]
1418struct AtlasHealthParams {
1419 project_path: Option<String>,
1421 worktree: Option<String>,
1423 start_index: Option<usize>,
1425 limit: Option<usize>,
1427 category: Option<String>,
1429 severity: Option<String>,
1431 path_prefix: Option<String>,
1433 summary_only: Option<bool>,
1435 source_only: Option<bool>,
1437 include_assets: Option<bool>,
1439 include_low_priority_files: Option<bool>,
1441 coverage: Option<bool>,
1443 parser: Option<String>,
1445 provider: Option<String>,
1447 relation: Option<String>,
1449 coverage_state: Option<String>,
1451 reason: Option<String>,
1453}
1454
1455#[derive(Debug, Deserialize, schemars::JsonSchema)]
1457struct AtlasPurposeQueueParams {
1458 #[serde(flatten)]
1460 health: AtlasHealthParams,
1461 task: Option<String>,
1463}
1464
1465#[derive(Debug, Deserialize, schemars::JsonSchema)]
1467struct AtlasParityParams {
1468 project_path: Option<String>,
1470 worktree: Option<String>,
1472 profile: Option<String>,
1474}
1475
1476#[derive(Debug, Deserialize, schemars::JsonSchema)]
1478struct AtlasStripLegacyParams {
1479 project_path: Option<String>,
1481 worktree: Option<String>,
1483 path: Option<String>,
1485 nearest_project: Option<bool>,
1487 apply: Option<bool>,
1489 dry_run: Option<bool>,
1491 strip_source_headers: Option<bool>,
1493}
1494
1495#[derive(Debug, Deserialize, schemars::JsonSchema)]
1497struct AtlasResetIndexParams {
1498 project_path: Option<String>,
1500 worktree: Option<String>,
1502 apply: Option<bool>,
1504 dry_run: Option<bool>,
1506 include_mcp_config: Option<bool>,
1508}
1509
1510#[derive(Debug, Deserialize, schemars::JsonSchema)]
1512struct AtlasPurposeSetParams {
1513 project_path: Option<String>,
1515 worktree: Option<String>,
1517 path: String,
1519 purpose: String,
1521}
1522
1523#[derive(Debug, Deserialize, schemars::JsonSchema)]
1525#[schemars(inline)]
1526struct AtlasPurposeReviewItem {
1527 path: String,
1529 purpose: Option<String>,
1531 confirm_existing: Option<bool>,
1533 task: Option<String>,
1535 work_key: Option<String>,
1537 state_token: Option<String>,
1539}
1540
1541#[derive(Debug, Deserialize, schemars::JsonSchema)]
1543struct AtlasPurposeReviewParams {
1544 project_path: Option<String>,
1546 worktree: Option<String>,
1548 items: Vec<AtlasPurposeReviewItem>,
1550 apply: Option<bool>,
1552}
1553
1554#[derive(Debug, Deserialize, schemars::JsonSchema)]
1556struct AtlasHealthResolveParams {
1557 project_path: Option<String>,
1559 worktree: Option<String>,
1561 finding_id: String,
1563 category: String,
1565 path: String,
1567 related_path: Option<String>,
1569 rationale: String,
1571}
1572
1573#[derive(Debug, Clone)]
1575struct McpProjectState {
1576 root: PathBuf,
1578 db_path: PathBuf,
1580 config_path: Option<PathBuf>,
1582 worktree: Option<McpWorktreeSelection>,
1584}
1585
1586#[derive(Debug, Clone, Eq, PartialEq)]
1588struct McpWorktreeSelection {
1589 alias: String,
1591 registration_id: Option<i64>,
1593 project_instance_id: Option<ProjectInstanceId>,
1595 control_project_instance_id: Option<ProjectInstanceId>,
1597}
1598
1599enum McpWorktreeHydration {
1601 Activated {
1603 hydration: InitHydrationPhase,
1605 scan: Box<ScanReport>,
1607 },
1608 Fallback(String),
1610}
1611
1612enum SymbolRelationStores<'a> {
1614 Single(&'a AtlasStore),
1616 Federated(Vec<FederatedStore>),
1618}
1619
1620impl SymbolRelationStores<'_> {
1621 fn primary(&self) -> &AtlasStore {
1623 match self {
1624 Self::Single(store) => store,
1625 Self::Federated(stores) => stores[0].store(),
1626 }
1627 }
1628}
1629
1630#[derive(Debug, Clone, Eq, Hash, PartialEq)]
1632struct McpUsageProjectBinding {
1633 root: PathBuf,
1635 db_path: PathBuf,
1637 project_instance_id: ProjectInstanceId,
1639 worktree_registration_id: Option<i64>,
1641}
1642
1643#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1645struct McpSourceTokenBaselineKey {
1646 binding: McpUsageProjectBinding,
1648 generation: projectatlas_core::IndexGeneration,
1650 folder: Option<String>,
1652 file_pattern: Option<String>,
1654}
1655
1656#[derive(Debug)]
1658struct McpUsageIntent {
1659 command: &'static str,
1661 path: Option<String>,
1663 query: Option<String>,
1665 baseline: McpUsageBaseline,
1667}
1668
1669#[derive(Debug)]
1671enum McpUsageBaseline {
1672 Estimate(usize),
1674 DirectoryWalk(usize),
1676 Text(String),
1678}
1679
1680impl McpUsageIntent {
1681 fn estimate(
1683 command: &'static str,
1684 path: Option<String>,
1685 query: Option<String>,
1686 baseline_tokens: usize,
1687 ) -> Self {
1688 Self {
1689 command,
1690 path,
1691 query,
1692 baseline: McpUsageBaseline::Estimate(baseline_tokens),
1693 }
1694 }
1695
1696 fn directory_walk(
1698 command: &'static str,
1699 path: Option<String>,
1700 query: Option<String>,
1701 baseline_tokens: usize,
1702 ) -> Self {
1703 Self {
1704 command,
1705 path,
1706 query,
1707 baseline: McpUsageBaseline::DirectoryWalk(baseline_tokens),
1708 }
1709 }
1710
1711 fn text(command: &'static str, path: Option<String>, baseline_text: String) -> Self {
1713 Self {
1714 command,
1715 path,
1716 query: None,
1717 baseline: McpUsageBaseline::Text(baseline_text),
1718 }
1719 }
1720}
1721
1722impl McpUsageProjectBinding {
1723 #[cfg(test)]
1725 fn capture(state: &McpProjectState, store: &AtlasStore) -> Result<Self, DbError> {
1726 Self::capture_with_origin(state, store, None)
1727 }
1728
1729 fn capture_with_origin(
1731 state: &McpProjectState,
1732 store: &AtlasStore,
1733 worktree_registration_id: Option<i64>,
1734 ) -> Result<Self, DbError> {
1735 let captured = store.captured_project_binding()?;
1736 Ok(Self {
1737 root: state.root.clone(),
1738 db_path: state.db_path.clone(),
1739 project_instance_id: captured.project_instance_id,
1740 worktree_registration_id,
1741 })
1742 }
1743
1744 fn same_project(&self, other: &Self) -> bool {
1746 self.root == other.root
1747 && self.db_path == other.db_path
1748 && self.project_instance_id == other.project_instance_id
1749 }
1750}
1751
1752#[derive(Clone, Debug)]
1754struct McpUsageProjectRuntime {
1755 binding: McpUsageProjectBinding,
1757 instance: Arc<Mutex<UsageRuntimeInstance>>,
1759}
1760
1761#[derive(Debug, Default)]
1763struct McpUsageRuntime {
1764 entries: Vec<McpUsageProjectRuntime>,
1766 source_token_baselines: VecDeque<(McpSourceTokenBaselineKey, usize)>,
1768}
1769
1770impl McpUsageRuntime {
1771 fn instance_for_binding(
1773 &mut self,
1774 binding: McpUsageProjectBinding,
1775 selected_store: &AtlasStore,
1776 ) -> Option<Arc<Mutex<UsageRuntimeInstance>>> {
1777 if let Some(index) = self
1778 .entries
1779 .iter()
1780 .position(|entry| entry.binding == binding)
1781 {
1782 let entry = self.entries.remove(index);
1783 let instance = Arc::clone(&entry.instance);
1784 self.entries.push(entry);
1785 return Some(instance);
1786 }
1787 let instance = Arc::new(Mutex::new(UsageRuntimeInstance::new(
1788 UsageInstanceOwner::McpProcess,
1789 )?));
1790 if self.entries.len() >= MCP_TELEMETRY_PROJECT_BINDING_LIMIT {
1791 let index = (0..self.entries.len()).find(|index| {
1792 Self::seal_inactive_entry(&self.entries[*index], &binding, selected_store)
1793 })?;
1794 self.entries.remove(index);
1795 }
1796 self.entries.push(McpUsageProjectRuntime {
1797 binding,
1798 instance: Arc::clone(&instance),
1799 });
1800 Some(instance)
1801 }
1802
1803 fn seal_inactive_entry(
1805 entry: &McpUsageProjectRuntime,
1806 selected_binding: &McpUsageProjectBinding,
1807 selected_store: &AtlasStore,
1808 ) -> bool {
1809 if Arc::strong_count(&entry.instance) != 1 {
1810 return false;
1811 }
1812 let Ok(instance) = entry.instance.try_lock() else {
1813 return false;
1814 };
1815 let seal = |store: &AtlasStore| {
1816 store.captured_project_binding().is_ok_and(|binding| {
1817 binding.project_instance_id == entry.binding.project_instance_id
1818 }) && matches!(
1819 (*instance).seal(store),
1820 Ok(()) | Err(CliError::Db(DbError::TelemetryInstanceInactive))
1821 )
1822 };
1823 if entry.binding.same_project(selected_binding) {
1824 return seal(selected_store);
1825 }
1826 open_atlas_store_for_project(&entry.binding.db_path, &entry.binding.root)
1827 .is_ok_and(|store| seal(&store))
1828 }
1829
1830 fn snapshot(&self) -> Vec<McpUsageProjectRuntime> {
1832 self.entries.clone()
1833 }
1834
1835 fn source_token_baseline(&self, key: &McpSourceTokenBaselineKey) -> Option<usize> {
1837 self.source_token_baselines
1838 .iter()
1839 .find_map(|(candidate, value)| (candidate == key).then_some(*value))
1840 }
1841
1842 fn insert_source_token_baseline(&mut self, key: McpSourceTokenBaselineKey, value: usize) {
1844 if let Some(index) = self
1845 .source_token_baselines
1846 .iter()
1847 .position(|(candidate, _value)| candidate == &key)
1848 {
1849 let _removed = self.source_token_baselines.remove(index);
1850 }
1851 while self.source_token_baselines.len() >= MCP_SOURCE_TOKEN_BASELINE_LIMIT {
1852 let _removed = self.source_token_baselines.pop_front();
1853 }
1854 self.source_token_baselines.push_back((key, value));
1855 }
1856}
1857
1858#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1860enum McpConfigValidation {
1861 Immediate,
1863 Deferred,
1865}
1866
1867#[derive(Debug, Serialize)]
1869struct McpMapReport {
1870 root: Option<String>,
1872 map_path: Option<String>,
1874 written: bool,
1876 json: bool,
1878 format: crate::OutputFormat,
1880 content: String,
1882 json_written: bool,
1884 skipped_reason: Option<String>,
1886}
1887
1888#[derive(Debug, Serialize)]
1890struct McpWorktreeListReport {
1891 control_alias: &'static str,
1893 control_root: Option<String>,
1895 common_directory: Option<String>,
1897 worktrees: Vec<McpWorktreeRow>,
1899 retired: Vec<McpRetiredWorktreeRow>,
1901 total_worktrees: usize,
1903 truncated: bool,
1905}
1906
1907#[derive(Debug, Serialize)]
1909struct McpWorktreeRow {
1910 selector: Option<String>,
1912 alias: Option<String>,
1914 role: McpGitWorktreeRole,
1916 path_display: McpWorktreePathDisplayState,
1918 git_state: McpGitWorktreeState,
1920 registration: McpWorktreeRegistrationState,
1922 administrative_directory: Option<String>,
1924 root: Option<String>,
1926 atlas_state: McpWorktreeAtlasState,
1928 telemetry_state: McpWorktreeTelemetryState,
1930 accepted_telemetry_revision: Option<u64>,
1932 local_telemetry_revision: Option<u64>,
1934 project_instance_id: Option<String>,
1936 blocker: Option<String>,
1938}
1939
1940#[derive(Debug, Serialize)]
1942struct McpRetiredWorktreeRow {
1943 alias: String,
1945 last_root: Option<String>,
1947 path_display: McpWorktreePathDisplayState,
1949 project_instance_id: Option<String>,
1951 accepted_telemetry_revision: u64,
1953}
1954
1955#[derive(Debug, Serialize)]
1957struct McpWorktreeCandidate {
1958 selector: String,
1960 root: Option<String>,
1962 path_display: McpWorktreePathDisplayState,
1964 role: McpGitWorktreeRole,
1966}
1967
1968#[derive(Debug, Serialize)]
1970struct McpWorktreeMutationReport {
1971 operation: McpWorktreeMutationOperation,
1973 status: McpWorktreeMutationStatus,
1975 selector: Option<String>,
1977 alias: Option<String>,
1979 root: Option<String>,
1981 path_display: Option<McpWorktreePathDisplayState>,
1983 registration_id: Option<i64>,
1985 telemetry_sync: Option<WorktreeUsageSyncState>,
1987 candidates: Vec<McpWorktreeCandidate>,
1989 blocker: Option<String>,
1991 git_unchanged: bool,
1993 files_unchanged: bool,
1995}
1996
1997#[derive(Clone, Copy, Debug, Serialize)]
1999#[serde(rename_all = "snake_case")]
2000enum McpGitWorktreeRole {
2001 Primary,
2003 Linked,
2005}
2006
2007impl From<GitWorktreeRole> for McpGitWorktreeRole {
2008 fn from(value: GitWorktreeRole) -> Self {
2009 match value {
2010 GitWorktreeRole::Primary => Self::Primary,
2011 GitWorktreeRole::Linked => Self::Linked,
2012 }
2013 }
2014}
2015
2016#[derive(Clone, Copy, Debug, Serialize)]
2018#[serde(rename_all = "snake_case")]
2019enum McpGitWorktreeState {
2020 Active,
2022 Missing,
2024 Invalid,
2026}
2027
2028#[derive(Clone, Copy, Debug, Serialize)]
2030#[serde(rename_all = "snake_case")]
2031enum McpWorktreeRegistrationState {
2032 Control,
2034 Registered,
2036 Unregistered,
2038}
2039
2040#[derive(Clone, Copy, Debug, Serialize)]
2042#[serde(rename_all = "snake_case")]
2043enum McpWorktreePathDisplayState {
2044 Available,
2046 Unavailable,
2048}
2049
2050#[derive(Clone, Copy, Debug, Serialize)]
2052#[serde(rename_all = "snake_case")]
2053enum McpWorktreeAtlasState {
2054 Initialized,
2056 Missing,
2058 Invalid,
2060 Unavailable,
2062}
2063
2064#[derive(Clone, Copy, Debug, Serialize)]
2066#[serde(rename_all = "snake_case")]
2067enum McpWorktreeTelemetryState {
2068 Control,
2070 Current,
2072 Pending,
2074 MissingAtlas,
2076 Unregistered,
2078 Unavailable,
2080}
2081
2082#[derive(Clone, Copy, Debug, Serialize)]
2084#[serde(rename_all = "snake_case")]
2085enum McpWorktreeMutationOperation {
2086 Add,
2088 Remove,
2090}
2091
2092#[derive(Clone, Copy, Debug, Serialize)]
2094#[serde(rename_all = "snake_case")]
2095enum McpWorktreeMutationStatus {
2096 Registered,
2098 Retired,
2100 NotFound,
2102 Ambiguous,
2104}
2105
2106struct LocalWorktreeAtlas {
2108 project_instance_id: ProjectInstanceId,
2110 snapshot: WorktreeUsageSnapshot,
2112}
2113
2114#[derive(Debug, Clone)]
2116struct McpSelectedRoot(PathBuf);
2117
2118impl McpSelectedRoot {
2119 fn from_state(state: &McpProjectState) -> Self {
2121 Self(state.root.clone())
2122 }
2123
2124 fn repo_key_for(&self, path: &McpAbsolutePath) -> Result<Option<McpRepoKey>, CliError> {
2126 if !path.as_path().starts_with(&self.0) {
2127 return Ok(None);
2128 }
2129 normalize_repo_path(&self.0, path.as_path())
2130 .map(McpRepoKey)
2131 .map(Some)
2132 .map_err(ProjectAtlasMcpServer::selected_project_path_error)
2133 }
2134}
2135
2136#[derive(Debug, Clone)]
2138struct McpIndexedRoot {
2139 root: PathBuf,
2141 db_path: PathBuf,
2143}
2144
2145#[derive(Debug, Clone)]
2147struct McpAbsolutePath(PathBuf);
2148
2149impl McpAbsolutePath {
2150 fn canonicalize(path: &Path) -> Result<Self, CliError> {
2156 let mut existing = path;
2157 let mut missing_suffix = Vec::new();
2158 while !existing.exists() {
2159 let file_name = existing
2160 .file_name()
2161 .ok_or_else(|| missing_ancestor_error(path))?;
2162 missing_suffix.push(PathBuf::from(file_name));
2163 existing = existing
2164 .parent()
2165 .ok_or_else(|| missing_ancestor_error(path))?;
2166 }
2167 let mut canonical = canonical_project_root(existing)?;
2168 for component in missing_suffix.into_iter().rev() {
2169 canonical.push(component);
2170 }
2171 Ok(Self(canonical))
2172 }
2173
2174 fn as_path(&self) -> &Path {
2176 &self.0
2177 }
2178
2179 fn nearest_search_start(&self) -> &Path {
2181 if self.0.is_dir() {
2182 &self.0
2183 } else {
2184 self.0.parent().unwrap_or(self.as_path())
2185 }
2186 }
2187}
2188
2189fn missing_ancestor_error(path: &Path) -> CliError {
2191 CliError::InvalidInput(format!(
2192 "absolute path '{}' has no existing ancestor",
2193 path.display()
2194 ))
2195}
2196
2197#[derive(Debug, Clone)]
2199struct McpRepoKey(String);
2200
2201impl McpRepoKey {
2202 fn into_string(self) -> String {
2204 self.0
2205 }
2206}
2207
2208#[derive(Debug, Clone)]
2210struct McpResolvedRepoPath {
2211 state: McpProjectState,
2213 key: String,
2215 routed_project: bool,
2217}
2218
2219#[derive(Debug, Serialize)]
2221struct McpErrorResponse {
2222 error: McpErrorPayload,
2224}
2225
2226#[derive(Debug, Serialize)]
2228struct McpErrorPayload {
2229 kind: AgentErrorKind,
2231 message: String,
2233 #[serde(skip_serializing_if = "Option::is_none")]
2235 refresh_required: Option<IndexRefreshRequired>,
2236 #[serde(skip_serializing_if = "Option::is_none")]
2238 init_required: Option<IndexInitRequired>,
2239 #[serde(skip_serializing_if = "Option::is_none")]
2241 worktree_required: Option<ProjectWorktreeRequired>,
2242 #[serde(skip_serializing_if = "Option::is_none")]
2244 verification_incomplete: Option<IndexVerificationIncomplete>,
2245 #[serde(skip_serializing_if = "Option::is_none")]
2247 project_mismatch: Option<IndexProjectMismatch>,
2248 #[serde(skip_serializing_if = "Option::is_none")]
2250 database_filesystem: Option<DatabaseFilesystemErrorPayload>,
2251 #[serde(skip_serializing_if = "Option::is_none")]
2253 schema_version_mismatch: Option<SchemaVersionMismatchPayload>,
2254 #[serde(skip_serializing_if = "Option::is_none")]
2256 schema_migration_required: Option<SchemaMigrationRequiredPayload>,
2257 #[serde(skip_serializing_if = "Option::is_none")]
2259 search_capability: Option<crate::SearchCapabilityErrorPayload>,
2260 #[serde(skip_serializing_if = "Option::is_none")]
2262 next: Option<McpNextCall>,
2263}
2264
2265#[derive(Debug, Serialize)]
2267struct McpNextCall {
2268 tool: &'static str,
2270 #[serde(skip_serializing_if = "Option::is_none")]
2272 project_path: Option<String>,
2273 #[serde(skip_serializing_if = "Option::is_none")]
2275 worktree: Option<String>,
2276}
2277
2278#[derive(Debug, Serialize)]
2280struct McpProjectStateResponse {
2281 project: McpProjectStatePayload,
2283}
2284
2285#[derive(Debug, Serialize)]
2287struct McpProjectStatePayload {
2288 #[serde(skip_serializing_if = "Option::is_none")]
2290 worktree: Option<String>,
2291 #[serde(skip_serializing_if = "Option::is_none")]
2293 registration_id: Option<i64>,
2294 root: Option<String>,
2296 db: Option<String>,
2298 config: Option<String>,
2300 status: McpProjectStatus,
2302}
2303
2304#[derive(Debug, Serialize)]
2306#[serde(rename_all = "snake_case")]
2307enum McpProjectStatus {
2308 Active,
2310}
2311
2312#[derive(Debug, Serialize)]
2314struct McpPurposeSetResponse {
2315 purpose_set: McpPurposeSetPayload,
2317}
2318
2319#[derive(Debug, Serialize)]
2321struct McpPurposeSetPayload {
2322 path: String,
2324 #[serde(skip_serializing_if = "Option::is_none")]
2326 classification: Option<ContentClassification>,
2327 status: PurposeStatus,
2329 source: PurposeSource,
2331 agent_reviewed: bool,
2333}
2334
2335#[derive(Debug, Serialize)]
2337struct McpSessionCapabilities {
2338 runtime: RuntimeInfoReport,
2340 selected_project: McpSelectedProjectCapability,
2342 startup_policy: McpStartupPolicy,
2344 path_scope: McpPathScope,
2346 scan_policy: McpScanPolicy,
2348 classified_navigation: SettingsClassifiedNavigationReport,
2350 telemetry: McpTelemetryPolicy,
2352 privacy: McpPrivacyPolicy,
2354}
2355
2356#[derive(Debug, Serialize)]
2358struct McpSelectedProjectCapability {
2359 #[serde(skip_serializing_if = "Option::is_none")]
2361 worktree: Option<String>,
2362 #[serde(skip_serializing_if = "Option::is_none")]
2364 registration_id: Option<i64>,
2365 root: Option<String>,
2367 db: Option<String>,
2369 config: Option<String>,
2371 index_status: McpIndexStatus,
2373}
2374
2375#[derive(Debug, Serialize)]
2377struct McpStartupPolicy {
2378 nearest_project: McpPolicyState,
2380}
2381
2382#[derive(Debug, Serialize)]
2384struct McpScanPolicy {
2385 implicit_scan: McpPolicyState,
2387 text_index_max_bytes: u64,
2389}
2390
2391#[derive(Debug, Serialize)]
2393struct McpTelemetryPolicy {
2394 mode: McpPolicyState,
2396}
2397
2398#[derive(Debug, Serialize)]
2400struct McpPrivacyPolicy {
2401 environment_dump: bool,
2403 secret_values: bool,
2405 projectatlas_paths_only: bool,
2407}
2408
2409#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2411#[serde(rename_all = "snake_case")]
2412enum McpPolicyState {
2413 Enabled,
2415 Disabled,
2417}
2418
2419#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2421#[serde(rename_all = "snake_case")]
2422enum McpIndexStatus {
2423 Available,
2425 Missing,
2427}
2428
2429#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2431#[serde(rename_all = "snake_case")]
2432enum McpPathScope {
2433 SelectedProject,
2435 NearestIndexedProject,
2437}
2438
2439#[derive(Debug, Serialize)]
2441struct McpFileSummaryPayload<'a> {
2442 file_summary: McpFileSummary<'a>,
2444}
2445
2446#[derive(Debug, Serialize)]
2448struct McpFileSummary<'a> {
2449 file_path: &'a str,
2451 classification: projectatlas_core::language::ContentClassification,
2453 language: &'a str,
2455 line_count: usize,
2457 #[serde(skip_serializing_if = "Option::is_none")]
2459 source_status: Option<&'a str>,
2460 #[serde(skip_serializing_if = "Option::is_none")]
2462 source_error: Option<&'a str>,
2463 parser_kind: &'a str,
2465 summary_status: &'a str,
2467 #[serde(skip_serializing_if = "Option::is_none")]
2469 file_purpose: Option<&'a str>,
2470 #[serde(skip_serializing_if = "Option::is_none")]
2472 file_purpose_status: Option<&'a str>,
2473 #[serde(skip_serializing_if = "Option::is_none")]
2475 file_purpose_source: Option<&'a str>,
2476 #[serde(skip_serializing_if = "is_false")]
2478 file_purpose_agent_reviewed: bool,
2479 content_summary: &'a str,
2481 #[serde(skip_serializing_if = "Option::is_none")]
2483 package: Option<&'a str>,
2484 #[serde(skip_serializing_if = "Option::is_none")]
2486 docstring: Option<&'a str>,
2487 #[serde(skip_serializing_if = "is_false")]
2489 truncated: bool,
2490 #[serde(skip_serializing_if = "Option::is_none")]
2492 functions: Option<Vec<McpFileSymbolSummary<'a>>>,
2493 #[serde(skip_serializing_if = "Option::is_none")]
2495 methods: Option<Vec<McpFileSymbolSummary<'a>>>,
2496 #[serde(skip_serializing_if = "Option::is_none")]
2498 classes: Option<Vec<McpFileSymbolSummary<'a>>>,
2499 #[serde(skip_serializing_if = "Option::is_none")]
2501 types: Option<Vec<McpFileSymbolSummary<'a>>>,
2502 #[serde(skip_serializing_if = "Option::is_none")]
2504 imports: Option<&'a [String]>,
2505 #[serde(skip_serializing_if = "Option::is_none")]
2507 dependencies: Option<&'a [String]>,
2508 #[serde(skip_serializing_if = "Option::is_none")]
2510 exports: Option<&'a [String]>,
2511 #[serde(skip_serializing_if = "Option::is_none")]
2513 calls: Option<&'a [FileCallSummary]>,
2514 #[serde(skip_serializing_if = "Option::is_none")]
2516 coverage: Option<McpCompactCoverageDigest<'a>>,
2517}
2518
2519#[derive(Debug, Serialize)]
2521struct McpFileSymbolSummary<'a> {
2522 name: &'a str,
2524 kind: &'a str,
2526 line: usize,
2528 end_line: usize,
2530 signature: &'a str,
2532 exported: bool,
2534 #[serde(skip_serializing_if = "Option::is_none")]
2536 documentation: Option<&'a str>,
2537 #[serde(skip_serializing_if = "Option::is_none")]
2539 parent: Option<&'a str>,
2540 #[serde(skip_serializing_if = "Option::is_none")]
2542 called_by: Option<&'a [String]>,
2543}
2544
2545impl<'a> From<&'a FileSymbolSummary> for McpFileSymbolSummary<'a> {
2546 fn from(symbol: &'a FileSymbolSummary) -> Self {
2547 Self {
2548 name: &symbol.name,
2549 kind: &symbol.kind,
2550 line: symbol.line,
2551 end_line: symbol.end_line,
2552 signature: &symbol.signature,
2553 exported: symbol.exported,
2554 documentation: nonempty_str(&symbol.documentation),
2555 parent: nonempty_str(&symbol.parent),
2556 called_by: nonempty_slice(&symbol.called_by),
2557 }
2558 }
2559}
2560
2561fn compact_file_symbols(symbols: &[FileSymbolSummary]) -> Option<Vec<McpFileSymbolSummary<'_>>> {
2563 (!symbols.is_empty()).then(|| symbols.iter().map(McpFileSymbolSummary::from).collect())
2564}
2565
2566impl<'a> From<&'a FileSummaryReport> for McpFileSummary<'a> {
2567 fn from(report: &'a FileSummaryReport) -> Self {
2568 let reviewed_purpose = report.file_purpose_agent_reviewed;
2569 let coverage_requires_attention = !report.coverage.available
2570 || report.coverage.trust != CoverageTrustState::Trusted
2571 || report.coverage.omitted > 0
2572 || report.coverage.truncated;
2573 Self {
2574 file_path: &report.file_path,
2575 classification: report.classification,
2576 language: &report.language,
2577 line_count: report.line_count,
2578 source_status: (report.source_status != MCP_FILE_SOURCE_STATUS_LIVE)
2579 .then_some(report.source_status.as_str()),
2580 source_error: nonempty_str(&report.source_error),
2581 parser_kind: &report.parser_kind,
2582 summary_status: &report.summary_status,
2583 file_purpose: nonempty_str(&report.file_purpose),
2584 file_purpose_status: (!reviewed_purpose).then_some(report.file_purpose_status.as_str()),
2585 file_purpose_source: (!reviewed_purpose).then_some(report.file_purpose_source.as_str()),
2586 file_purpose_agent_reviewed: reviewed_purpose,
2587 content_summary: &report.content_summary,
2588 package: nonempty_str(&report.package),
2589 docstring: nonempty_str(&report.docstring),
2590 truncated: report.truncated,
2591 functions: compact_file_symbols(&report.functions),
2592 methods: compact_file_symbols(&report.methods),
2593 classes: compact_file_symbols(&report.classes),
2594 types: compact_file_symbols(&report.types),
2595 imports: nonempty_slice(&report.imports),
2596 dependencies: nonempty_slice(&report.dependencies),
2597 exports: nonempty_slice(&report.exports),
2598 calls: nonempty_slice(&report.calls),
2599 coverage: coverage_requires_attention
2600 .then(|| McpCompactCoverageDigest::from(&report.coverage)),
2601 }
2602 }
2603}
2604
2605#[derive(Debug, Serialize)]
2607struct McpCompactCoverageStateCounts {
2608 #[serde(skip_serializing_if = "is_zero_u32")]
2610 complete: u32,
2611 #[serde(skip_serializing_if = "is_zero_u32")]
2613 no_candidates: u32,
2614 #[serde(skip_serializing_if = "is_zero_u32")]
2616 partial: u32,
2617 #[serde(skip_serializing_if = "is_zero_u32")]
2619 failed: u32,
2620 #[serde(skip_serializing_if = "is_zero_u32")]
2622 ignored: u32,
2623 #[serde(skip_serializing_if = "is_zero_u32")]
2625 oversized: u32,
2626 #[serde(skip_serializing_if = "is_zero_u32")]
2628 quarantined: u32,
2629 #[serde(skip_serializing_if = "is_zero_u32")]
2631 stale: u32,
2632}
2633
2634#[derive(Debug, Serialize)]
2636struct McpCompactCoverageDigest<'a> {
2637 #[serde(skip_serializing_if = "is_true")]
2639 available: bool,
2640 active_generation: &'a IndexGeneration,
2642 #[serde(skip_serializing_if = "Option::is_none")]
2644 parser: Option<&'a ParserKind>,
2645 #[serde(skip_serializing_if = "Option::is_none")]
2647 provider: Option<&'a ParserKind>,
2648 states: McpCompactCoverageStateCounts,
2650 total: u64,
2652 covered: u64,
2654 #[serde(skip_serializing_if = "is_zero_u64")]
2656 omitted: u64,
2657 #[serde(skip_serializing_if = "is_zero_u32")]
2659 relation_rows: u32,
2660 #[serde(skip_serializing_if = "is_false")]
2662 truncated: bool,
2663 trust: &'a CoverageTrustState,
2665 next_call: &'a NavigationNextCall,
2667}
2668
2669impl<'a> From<&'a CoverageDigest> for McpCompactCoverageDigest<'a> {
2670 fn from(coverage: &'a CoverageDigest) -> Self {
2671 Self {
2672 available: coverage.available,
2673 active_generation: &coverage.active_generation,
2674 parser: coverage.parser.as_ref(),
2675 provider: coverage.provider.as_ref(),
2676 states: McpCompactCoverageStateCounts {
2677 complete: coverage.states.complete,
2678 no_candidates: coverage.states.no_candidates,
2679 partial: coverage.states.partial,
2680 failed: coverage.states.failed,
2681 ignored: coverage.states.ignored,
2682 oversized: coverage.states.oversized,
2683 quarantined: coverage.states.quarantined,
2684 stale: coverage.states.stale,
2685 },
2686 total: coverage.total,
2687 covered: coverage.covered,
2688 omitted: coverage.omitted,
2689 relation_rows: coverage.relation_rows,
2690 truncated: coverage.truncated,
2691 trust: &coverage.trust,
2692 next_call: &coverage.next_call,
2693 }
2694 }
2695}
2696
2697#[derive(Debug, Serialize)]
2699struct McpCompactDetailedRelationNode<'a> {
2700 selector: &'a EntitySelector,
2702 purpose: &'a RelationPurpose,
2704 #[serde(skip_serializing_if = "Option::is_none")]
2706 coverage: Option<&'a [CoverageRecord]>,
2707}
2708
2709impl<'a> From<&'a DetailedRelationNode> for McpCompactDetailedRelationNode<'a> {
2710 fn from(node: &'a DetailedRelationNode) -> Self {
2711 Self {
2712 selector: node.entity.selector(),
2713 purpose: &node.purpose,
2714 coverage: nonempty_slice(&node.coverage),
2715 }
2716 }
2717}
2718
2719#[derive(Debug, Serialize)]
2721#[serde(tag = "status", rename_all = "snake_case")]
2722enum McpCompactRelationResolution<'a> {
2723 Resolved {
2725 selector: &'a ReusableTargetSelector,
2727 generation: IndexGeneration,
2729 },
2730 Ambiguous {
2732 reference: &'a GraphIdentityText,
2734 candidates: u32,
2736 },
2737 Unresolved {
2739 reference: &'a GraphIdentityText,
2741 },
2742 External {
2744 external: &'a ExternalSelector,
2746 generation: IndexGeneration,
2748 },
2749}
2750
2751impl<'a> From<&'a RelationResolution> for McpCompactRelationResolution<'a> {
2752 fn from(resolution: &'a RelationResolution) -> Self {
2753 match resolution {
2754 RelationResolution::Resolved {
2755 selector,
2756 generation,
2757 ..
2758 } => Self::Resolved {
2759 selector,
2760 generation: *generation,
2761 },
2762 RelationResolution::Ambiguous {
2763 reference,
2764 candidates,
2765 } => Self::Ambiguous {
2766 reference,
2767 candidates: candidates.get(),
2768 },
2769 RelationResolution::Unresolved { reference } => Self::Unresolved { reference },
2770 RelationResolution::External {
2771 external,
2772 generation,
2773 ..
2774 } => Self::External {
2775 external,
2776 generation: *generation,
2777 },
2778 }
2779 }
2780}
2781
2782#[derive(Debug, Serialize)]
2784struct McpCompactLogicalRelation<'a> {
2785 kind: GraphRelationKind,
2787 resolution: McpCompactRelationResolution<'a>,
2789 confidence: ConfidenceClass,
2791 completeness: Completeness,
2793 generation: IndexGeneration,
2795}
2796
2797#[derive(Debug, Serialize)]
2799struct McpCompactDetailedRelationRow<'a> {
2800 depth: u32,
2802 direction: RelationDirection,
2804 relation: McpCompactLogicalRelation<'a>,
2806 #[serde(skip_serializing_if = "Option::is_none")]
2808 document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
2809 source: McpCompactDetailedRelationNode<'a>,
2811 #[serde(skip_serializing_if = "Option::is_none")]
2813 target: Option<McpCompactDetailedRelationNode<'a>>,
2814 #[serde(skip_serializing_if = "Option::is_none")]
2816 target_purpose: Option<&'a RelationPurpose>,
2817 #[serde(skip_serializing_if = "Option::is_none")]
2819 path: Option<Vec<&'a EntitySelector>>,
2820 #[serde(skip_serializing_if = "Option::is_none")]
2822 occurrences: Option<Vec<McpCompactRelationOccurrence<'a>>>,
2823 #[serde(skip_serializing_if = "is_false")]
2825 occurrences_truncated: bool,
2826 #[serde(skip_serializing_if = "Option::is_none")]
2828 next_call: Option<&'a RelationNextCall>,
2829}
2830
2831#[derive(Debug, Serialize)]
2833struct McpCompactRelationOccurrence<'a> {
2834 file: &'a RepositoryFilePath,
2836 span: SourceSpan,
2838 generation: IndexGeneration,
2840}
2841
2842impl<'a> From<&'a RelationOccurrence> for McpCompactRelationOccurrence<'a> {
2843 fn from(occurrence: &'a RelationOccurrence) -> Self {
2844 Self {
2845 file: occurrence.file(),
2846 span: occurrence.span(),
2847 generation: occurrence.generation(),
2848 }
2849 }
2850}
2851
2852impl<'a> From<&'a DetailedRelationRow> for McpCompactDetailedRelationRow<'a> {
2853 fn from(row: &'a DetailedRelationRow) -> Self {
2854 Self {
2855 depth: row.depth,
2856 direction: row.direction,
2857 relation: McpCompactLogicalRelation {
2858 kind: row.relation.kind(),
2859 resolution: McpCompactRelationResolution::from(row.relation.resolution()),
2860 confidence: row.relation.confidence(),
2861 completeness: row.relation.completeness(),
2862 generation: row.relation.generation(),
2863 },
2864 document_unresolved_reason: row.document_unresolved_reason,
2865 source: McpCompactDetailedRelationNode::from(&row.source),
2866 target: row
2867 .target
2868 .as_ref()
2869 .map(McpCompactDetailedRelationNode::from),
2870 target_purpose: row.target.is_none().then_some(&row.target_purpose),
2871 path: (row.path.len() > 2)
2872 .then(|| row.path.iter().map(|node| node.entity.selector()).collect()),
2873 occurrences: (!row.occurrences.is_empty()).then(|| {
2874 row.occurrences
2875 .iter()
2876 .map(McpCompactRelationOccurrence::from)
2877 .collect()
2878 }),
2879 occurrences_truncated: row.occurrences_truncated,
2880 next_call: row.next_call.as_ref(),
2881 }
2882 }
2883}
2884
2885#[derive(Debug, Serialize)]
2887struct McpCompactDetailedRelationReport<'a> {
2888 anchor: McpCompactDetailedRelationNode<'a>,
2890 generation: IndexGeneration,
2892 authored_purpose_revision: u64,
2894 direction: RelationDirection,
2896 returned: u32,
2898 #[serde(skip_serializing_if = "is_zero_u64")]
2900 pruned_paths: u64,
2901 #[serde(skip_serializing_if = "is_false")]
2903 truncated: bool,
2904 #[serde(skip_serializing_if = "Option::is_none")]
2906 next_call: Option<McpCompactRelationContinuationCall<'a>>,
2907 total: &'a RelationTotalState,
2909 #[serde(skip_serializing_if = "Option::is_none")]
2911 reached_limits: Option<&'a [GraphLimitKind]>,
2912 work: &'a DetailedRelationWork,
2914 rows: Vec<McpCompactDetailedRelationRow<'a>>,
2916}
2917
2918#[derive(Debug, Serialize)]
2920struct McpCompactRelationContinuationCall<'a> {
2921 tool: &'static str,
2923 arguments: McpCompactRelationContinuationArguments<'a>,
2925}
2926
2927#[derive(Debug, Serialize)]
2929struct McpCompactRelationContinuationArguments<'a> {
2930 #[serde(skip_serializing_if = "Option::is_none")]
2932 project_path: Option<&'a str>,
2933 #[serde(skip_serializing_if = "Option::is_none")]
2935 worktree: Option<&'a str>,
2936 file: &'a str,
2938 #[serde(skip_serializing_if = "Option::is_none")]
2940 nearest_project: Option<bool>,
2941 #[serde(skip_serializing_if = "Option::is_none")]
2943 roots: Option<&'a [String]>,
2944 #[serde(skip_serializing_if = "Option::is_none")]
2946 worktrees: Option<&'a [String]>,
2947 view: &'static str,
2949 compact: bool,
2951 cursor: &'a str,
2953 #[serde(skip_serializing_if = "Option::is_none")]
2955 symbol: Option<&'a str>,
2956 #[serde(skip_serializing_if = "Option::is_none")]
2958 symbol_parent: Option<&'a str>,
2959 #[serde(skip_serializing_if = "Option::is_none")]
2961 symbol_kind: Option<&'a str>,
2962 #[serde(skip_serializing_if = "Option::is_none")]
2964 symbol_signature: Option<&'a str>,
2965 #[serde(skip_serializing_if = "Option::is_none")]
2967 direction: Option<&'a str>,
2968 #[serde(skip_serializing_if = "Option::is_none")]
2970 relation: Option<&'a str>,
2971 #[serde(skip_serializing_if = "Option::is_none")]
2973 minimum_confidence: Option<&'a str>,
2974 #[serde(skip_serializing_if = "Option::is_none")]
2976 resolution: Option<&'a str>,
2977 #[serde(skip_serializing_if = "Option::is_none")]
2979 depth: Option<u32>,
2980 #[serde(skip_serializing_if = "Option::is_none")]
2982 include_occurrences: Option<bool>,
2983 #[serde(skip_serializing_if = "Option::is_none")]
2985 limit: Option<usize>,
2986 #[serde(skip_serializing_if = "Option::is_none")]
2988 occurrence_limit: Option<u32>,
2989 #[serde(skip_serializing_if = "Option::is_none")]
2991 edge_limit: Option<u32>,
2992 #[serde(skip_serializing_if = "Option::is_none")]
2994 node_limit: Option<u32>,
2995 #[serde(skip_serializing_if = "Option::is_none")]
2997 visited_limit: Option<u32>,
2998 #[serde(skip_serializing_if = "Option::is_none")]
3000 occurrence_total_limit: Option<u32>,
3001 #[serde(skip_serializing_if = "Option::is_none")]
3003 intermediate_bytes: Option<u64>,
3004 #[serde(skip_serializing_if = "Option::is_none")]
3006 deadline_ms: Option<u64>,
3007 #[serde(skip_serializing_if = "Option::is_none")]
3009 output_bytes: Option<u32>,
3010}
3011
3012impl<'a> McpCompactDetailedRelationReport<'a> {
3013 fn new(
3015 report: &'a DetailedRelationReport,
3016 file: &'a str,
3017 params: &'a AtlasSymbolRelationsParams,
3018 ) -> Self {
3019 Self {
3020 anchor: McpCompactDetailedRelationNode::from(&report.anchor),
3021 generation: report.generation,
3022 authored_purpose_revision: report.authored_purpose_revision,
3023 direction: report.direction,
3024 returned: report.returned,
3025 pruned_paths: report.pruned_paths,
3026 truncated: report.truncated,
3027 next_call: report.continuation.as_deref().map(|cursor| {
3028 McpCompactRelationContinuationCall {
3029 tool: MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
3030 arguments: McpCompactRelationContinuationArguments {
3031 project_path: params.project_path.as_deref(),
3032 worktree: params.worktree.as_deref(),
3033 file,
3034 nearest_project: params.nearest_project,
3035 roots: params.roots.as_deref(),
3036 worktrees: params.worktrees.as_deref(),
3037 view: MCP_SYMBOL_RELATION_VIEW_DETAILED,
3038 compact: true,
3039 cursor,
3040 symbol: params.symbol.as_deref(),
3041 symbol_parent: params.symbol_parent.as_deref().and_then(nonempty_str),
3042 symbol_kind: params.symbol_kind.as_deref().and_then(nonempty_str),
3043 symbol_signature: params.symbol_signature.as_deref().and_then(nonempty_str),
3044 direction: params.direction.as_deref(),
3045 relation: params.relation.as_deref(),
3046 minimum_confidence: params.minimum_confidence.as_deref(),
3047 resolution: params.resolution.as_deref(),
3048 depth: params.depth,
3049 include_occurrences: params.include_occurrences,
3050 limit: params.limit,
3051 occurrence_limit: params.occurrence_limit,
3052 edge_limit: params.edge_limit,
3053 node_limit: params.node_limit,
3054 visited_limit: params.visited_limit,
3055 occurrence_total_limit: params.occurrence_total_limit,
3056 intermediate_bytes: params.intermediate_bytes,
3057 deadline_ms: params.deadline_ms,
3058 output_bytes: params.output_bytes,
3059 },
3060 }
3061 }),
3062 total: &report.total,
3063 reached_limits: nonempty_slice(&report.reached_limits),
3064 work: &report.work,
3065 rows: report
3066 .rows
3067 .iter()
3068 .map(McpCompactDetailedRelationRow::from)
3069 .collect(),
3070 }
3071 }
3072}
3073
3074#[derive(Debug, Serialize)]
3076struct McpCompactFederatedDetailedRelationReport<'a> {
3077 participants: &'a [FederatedParticipant],
3079 #[serde(skip_serializing_if = "Option::is_none")]
3081 primary_worktree: Option<&'a str>,
3082 primary: McpCompactDetailedRelationReport<'a>,
3084 #[serde(skip_serializing_if = "Option::is_none")]
3086 rendezvous: Option<&'a [FederatedRendezvous]>,
3087 #[serde(skip_serializing_if = "is_false")]
3089 truncated: bool,
3090 #[serde(skip_serializing_if = "Option::is_none")]
3092 reached_limits: Option<&'a [GraphLimitKind]>,
3093 work: &'a FederatedRelationWork,
3095}
3096
3097impl<'a> McpCompactFederatedDetailedRelationReport<'a> {
3098 fn new(
3100 report: &'a FederatedDetailedRelationReport,
3101 file: &'a str,
3102 params: &'a AtlasSymbolRelationsParams,
3103 ) -> Self {
3104 Self {
3105 participants: &report.participants,
3106 primary_worktree: report.primary_worktree.as_deref(),
3107 primary: McpCompactDetailedRelationReport::new(&report.primary, file, params),
3108 rendezvous: nonempty_slice(&report.rendezvous),
3109 truncated: report.truncated,
3110 reached_limits: nonempty_slice(&report.reached_limits),
3111 work: &report.work,
3112 }
3113 }
3114}
3115
3116fn nonempty_str(value: &str) -> Option<&str> {
3118 (!value.is_empty()).then_some(value)
3119}
3120
3121fn parse_content_selection(value: Option<&str>) -> Result<ContentSelection, CliError> {
3123 value
3124 .map(str::parse::<ContentSelection>)
3125 .transpose()
3126 .map(Option::unwrap_or_default)
3127 .map_err(|error| CliError::InvalidInput(error.to_string()))
3128}
3129
3130fn nonempty_slice<T>(value: &[T]) -> Option<&[T]> {
3132 (!value.is_empty()).then_some(value)
3133}
3134
3135#[allow(clippy::trivially_copy_pass_by_ref)]
3137const fn is_zero_u32(value: &u32) -> bool {
3138 *value == 0
3139}
3140
3141#[allow(clippy::trivially_copy_pass_by_ref)]
3143const fn is_zero_u64(value: &u64) -> bool {
3144 *value == 0
3145}
3146
3147#[derive(Debug, Serialize)]
3149struct McpSessionBrief {
3150 project: McpSelectedProjectCapability,
3152 policy: McpBriefPolicy,
3154 overview: Option<Overview>,
3156 folders: Vec<McpBriefCandidate>,
3158 files: Vec<McpBriefCandidate>,
3160 blockers: McpBriefBlockers,
3162 purpose_handoff: Option<PurposeCuratorHandoff>,
3164 recommendations: Vec<McpBriefRecommendation>,
3166 limits: McpBriefLimits,
3168}
3169
3170#[derive(Debug, Serialize)]
3172struct McpCompactSessionBrief {
3173 project: McpCompactBriefProject,
3175 #[serde(skip_serializing_if = "Option::is_none")]
3177 policy: Option<McpBriefPolicy>,
3178 #[serde(skip_serializing_if = "Option::is_none")]
3180 overview: Option<McpCompactBriefOverview>,
3181 #[serde(skip_serializing_if = "Vec::is_empty")]
3183 folders: Vec<McpCompactBriefCandidate>,
3184 #[serde(skip_serializing_if = "Vec::is_empty")]
3186 files: Vec<McpCompactBriefCandidate>,
3187 #[serde(skip_serializing_if = "Option::is_none")]
3189 blockers: Option<McpCompactBriefBlockers>,
3190 #[serde(skip_serializing_if = "Option::is_none")]
3192 purpose_handoff: Option<McpCompactBriefPurposeHandoff>,
3193 recommendations: Vec<McpCompactBriefRecommendation>,
3195 #[serde(skip_serializing_if = "Option::is_none")]
3197 limits: Option<McpCompactBriefLimits>,
3198}
3199
3200#[derive(Debug, Serialize)]
3202struct McpCompactBriefProject {
3203 #[serde(skip_serializing_if = "Option::is_none")]
3205 worktree: Option<String>,
3206 #[serde(skip_serializing_if = "Option::is_none")]
3208 registration_id: Option<i64>,
3209 root: Option<String>,
3211 index_status: McpIndexStatus,
3213}
3214
3215#[derive(Debug, Serialize)]
3217struct McpCompactBriefOverview {
3218 files: usize,
3220 folders: usize,
3222}
3223
3224#[derive(Clone, Copy, Debug, Serialize)]
3226struct McpBriefPolicy {
3227 nearest_project: McpPolicyState,
3229 path_scope: McpPathScope,
3231}
3232
3233impl McpBriefPolicy {
3234 fn is_default(self) -> bool {
3236 self.nearest_project == McpPolicyState::Disabled
3237 && self.path_scope == McpPathScope::SelectedProject
3238 }
3239}
3240
3241#[derive(Debug, Serialize)]
3243struct McpBriefCandidate {
3244 path: String,
3246 kind: String,
3248 purpose_status: PurposeStatus,
3250 purpose_source: PurposeSource,
3252 purpose_agent_reviewed: bool,
3254 purpose: Option<String>,
3256 summary: Option<String>,
3258 reasons: Vec<String>,
3260 reason_codes: Vec<RankedReasonCode>,
3262 connection_counts: Vec<RankedConnectionCount>,
3264 connections: Vec<RankedConnection>,
3266 connections_truncated: bool,
3268 next_call: NavigationNextCall,
3270}
3271
3272#[derive(Debug, Serialize)]
3274struct McpCompactBriefCandidate {
3275 path: String,
3277 #[serde(skip_serializing_if = "Option::is_none")]
3279 purpose_status: Option<PurposeStatus>,
3280 #[serde(skip_serializing_if = "Option::is_none")]
3282 purpose_source: Option<PurposeSource>,
3283 #[serde(skip_serializing_if = "is_false")]
3285 purpose_agent_reviewed: bool,
3286 #[serde(skip_serializing_if = "Option::is_none")]
3288 purpose: Option<String>,
3289 #[serde(skip_serializing_if = "Vec::is_empty")]
3291 connections: Vec<RankedConnection>,
3292 #[serde(skip_serializing_if = "is_false")]
3294 connections_truncated: bool,
3295 next_call: NavigationNextCall,
3297}
3298
3299#[derive(Debug, Serialize)]
3301#[allow(clippy::struct_excessive_bools)]
3302struct McpCompactBriefPurposeHandoff {
3303 #[serde(skip_serializing_if = "is_true")]
3305 agent_harness_expected: bool,
3306 recommended_subagent_reasoning: &'static str,
3308 instructions: Vec<String>,
3310 #[serde(skip_serializing_if = "is_true")]
3312 main_agent_fallback: bool,
3313 #[serde(skip_serializing_if = "is_false")]
3315 server_started_curator: bool,
3316 #[serde(skip_serializing_if = "is_true")]
3318 silent_on_success: bool,
3319 #[serde(skip_serializing_if = "is_false")]
3321 truncated: bool,
3322 next_call: McpCompactBriefRecommendation,
3324}
3325
3326#[derive(Clone, Debug, Serialize)]
3328struct McpBriefBlockers {
3329 total: usize,
3331 returned: usize,
3333 truncated: bool,
3335 items: Vec<McpBriefBlocker>,
3337}
3338
3339#[derive(Debug, Serialize)]
3341struct McpCompactBriefBlockers {
3342 total: usize,
3344}
3345
3346#[allow(clippy::trivially_copy_pass_by_ref)]
3348const fn is_false(value: &bool) -> bool {
3349 !*value
3350}
3351
3352#[allow(clippy::trivially_copy_pass_by_ref)]
3354const fn is_true(value: &bool) -> bool {
3355 *value
3356}
3357
3358#[derive(Clone, Debug, Serialize)]
3360struct McpBriefBlocker {
3361 id: String,
3363 severity: Severity,
3365 category: String,
3367 path: String,
3369 related_path: Option<String>,
3371 message: String,
3373 recommendation: String,
3375}
3376
3377#[derive(Debug, Serialize)]
3379struct McpBriefRecommendation {
3380 kind: McpBriefRecommendationKind,
3382 target: String,
3384 reason: String,
3386 arguments: serde_json::Value,
3388}
3389
3390#[derive(Debug, Serialize)]
3392struct McpCompactBriefRecommendation {
3393 kind: McpBriefRecommendationKind,
3395 target: String,
3397 reason: String,
3399 arguments: serde_json::Value,
3401}
3402
3403#[derive(Clone, Copy, Debug, Serialize)]
3405#[serde(rename_all = "snake_case")]
3406enum McpBriefRecommendationKind {
3407 Init,
3409 Summary,
3411 Search,
3413 Relations,
3415 Health,
3417 PurposeQueue,
3419 FilesystemTools,
3421}
3422
3423#[derive(Debug, Serialize)]
3425struct McpBriefLimits {
3426 folder_limit: usize,
3428 file_limit: usize,
3430 blocker_limit: usize,
3432 purpose_limit: usize,
3434 folders_truncated: bool,
3436 files_truncated: bool,
3438 purposes_truncated: bool,
3440}
3441
3442#[derive(Debug, Serialize)]
3444struct McpCompactBriefLimits {
3445 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3447 folder_limit: usize,
3448 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3450 file_limit: usize,
3451 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3453 blocker_limit: usize,
3454 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3456 purpose_limit: usize,
3457 #[serde(skip_serializing_if = "is_false")]
3459 folders_truncated: bool,
3460 #[serde(skip_serializing_if = "is_false")]
3462 files_truncated: bool,
3463 #[serde(skip_serializing_if = "is_false")]
3465 purposes_truncated: bool,
3466}
3467
3468#[allow(clippy::trivially_copy_pass_by_ref)]
3470const fn is_compact_brief_default_limit(value: &usize) -> bool {
3471 *value == COMPACT_SESSION_BRIEF_DEFAULT_LIMIT
3472}
3473
3474#[derive(Debug, Serialize)]
3476struct McpTaskStatusResponse {
3477 task_id: String,
3479 lookup: McpTaskLookupStatus,
3481 states: Vec<McpTaskState>,
3483 operations: Vec<McpTaskOperation>,
3485 registry_capacity: usize,
3487 task: Option<McpTaskRecord>,
3489}
3490
3491#[derive(Debug, Eq, PartialEq, Serialize)]
3493#[serde(rename_all = "snake_case")]
3494enum McpTaskLookupStatus {
3495 Found,
3497 NotFound,
3499}
3500
3501#[derive(Debug, Serialize)]
3503struct McpTaskCancelResponse {
3504 task_id: String,
3506 result: McpTaskCancelResult,
3508 registry_capacity: usize,
3510 task: Option<McpTaskRecord>,
3512}
3513
3514#[derive(Debug, Serialize)]
3516struct McpTaskStartResponse {
3517 task_id: String,
3519 operation: McpTaskOperation,
3521 state: McpTaskState,
3523 status_tool: &'static str,
3525 cancel_tool: &'static str,
3527}
3528
3529#[derive(Debug, Eq, PartialEq, Serialize)]
3531#[serde(rename_all = "snake_case")]
3532enum McpTaskCancelResult {
3533 CancellationRequested,
3535 NotFound,
3537 AlreadyFinished,
3539 NotCancelable,
3541}
3542
3543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3545struct McpBackgroundResourceEnvelope {
3546 task_limit: usize,
3548 workers_per_task: usize,
3550 total_worker_limit: usize,
3552}
3553
3554impl McpBackgroundResourceEnvelope {
3555 fn for_host() -> Self {
3557 let available_workers = thread::available_parallelism().map_or(1, usize::from);
3558 Self::from_available_workers(available_workers)
3559 }
3560
3561 fn from_available_workers(available_workers: usize) -> Self {
3563 let total_worker_limit = available_workers.clamp(1, INDEX_WORKER_SAFE_CEILING);
3564 let task_limit = total_worker_limit.min(MCP_BACKGROUND_TASK_SAFE_CEILING);
3565 let workers_per_task = (total_worker_limit / task_limit).max(1);
3566 Self {
3567 task_limit,
3568 workers_per_task,
3569 total_worker_limit,
3570 }
3571 }
3572}
3573
3574#[derive(Debug, Clone)]
3576pub(crate) struct ProjectAtlasMcpServer {
3577 control_state: McpProjectState,
3579 project_state: Arc<RwLock<McpProjectState>>,
3581 session: String,
3583 usage_runtime: Arc<Mutex<McpUsageRuntime>>,
3585 allow_nearest_project: bool,
3587 task_registry: Arc<RwLock<McpTaskRegistry>>,
3589 background_resources: McpBackgroundResourceEnvelope,
3591 next_task_sequence: Arc<AtomicU64>,
3593 source_observations: Arc<SourceObservationRegistry>,
3595 tool_router: ToolRouter<Self>,
3597}
3598
3599struct McpRequestCancellationBridge {
3601 stop: Arc<std::sync::atomic::AtomicBool>,
3603 monitor: Option<thread::JoinHandle<()>>,
3605 probe: Arc<dyn Fn() -> bool + Send + Sync>,
3607}
3608
3609impl McpRequestCancellationBridge {
3610 fn start(
3612 context: &RequestContext<RoleServer>,
3613 control: &IndexWorkControl,
3614 ) -> Result<Self, CliError> {
3615 let token = context.ct.clone();
3616 Self::start_with_probe(move || token.is_cancelled(), control)
3617 }
3618
3619 fn start_with_probe<P>(probe: P, control: &IndexWorkControl) -> Result<Self, CliError>
3621 where
3622 P: Fn() -> bool + Send + Sync + 'static,
3623 {
3624 let probe: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(probe);
3625 if probe() {
3626 control.cancel();
3627 }
3628 let observed_control = control.clone();
3629 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
3630 let monitor_stop = Arc::clone(&stop);
3631 let monitor_probe = Arc::clone(&probe);
3632 let monitor = thread::Builder::new()
3633 .name(MCP_CANCELLATION_MONITOR_THREAD_NAME.to_string())
3634 .spawn(move || {
3635 while !monitor_stop.load(Ordering::Acquire) {
3636 if monitor_probe() {
3637 observed_control.cancel();
3638 break;
3639 }
3640 thread::sleep(std::time::Duration::from_millis(5));
3641 }
3642 })
3643 .map_err(|source| {
3644 let mut message = MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX.to_string();
3645 message.push_str(&source.to_string());
3646 CliError::InvalidInput(message)
3647 })?;
3648 Ok(Self {
3649 stop,
3650 monitor: Some(monitor),
3651 probe,
3652 })
3653 }
3654
3655 fn synchronize(&self, control: &IndexWorkControl) {
3657 if (self.probe)() {
3658 control.cancel();
3659 }
3660 }
3661}
3662
3663impl Drop for McpRequestCancellationBridge {
3664 fn drop(&mut self) {
3665 self.stop.store(true, Ordering::Release);
3666 if let Some(monitor) = self.monitor.take() {
3667 drop(monitor.join());
3668 }
3669 }
3670}
3671
3672impl ProjectAtlasMcpServer {
3673 pub(crate) fn new(
3675 db_path: PathBuf,
3676 config_path: Option<PathBuf>,
3677 session: String,
3678 allow_nearest_project: bool,
3679 ) -> Self {
3680 let startup_state = Self::startup_project_state(db_path, config_path);
3681 Self {
3682 control_state: startup_state.clone(),
3683 project_state: Arc::new(RwLock::new(startup_state)),
3684 session,
3685 usage_runtime: Arc::new(Mutex::new(McpUsageRuntime::default())),
3686 allow_nearest_project,
3687 task_registry: Arc::new(RwLock::new(McpTaskRegistry::new())),
3688 background_resources: McpBackgroundResourceEnvelope::for_host(),
3689 next_task_sequence: Arc::new(AtomicU64::new(1)),
3690 source_observations: Arc::new(SourceObservationRegistry::default()),
3691 tool_router: Self::tool_router(),
3692 }
3693 }
3694
3695 fn open_read_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3697 if !state.db_path.exists() {
3698 return Err(Self::with_target_error_context(
3699 index_init_required(&state.root, &state.db_path),
3700 state,
3701 ));
3702 }
3703 let store = open_atlas_store_read_only_for_project(&state.db_path, &state.root)
3704 .map_err(|error| Self::with_target_error_context(error, state))?;
3705 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)
3706 .map_err(|error| Self::with_target_error_context(error, state))?;
3707 Ok(store)
3708 }
3709
3710 fn require_initialized_worktree_target(state: &McpProjectState) -> Result<(), CliError> {
3712 if state.worktree.is_some() && !state.db_path.is_file() {
3713 return Err(Self::with_target_error_context(
3714 index_init_required(&state.root, &state.db_path),
3715 state,
3716 ));
3717 }
3718 Ok(())
3719 }
3720
3721 fn with_target_error_context(mut error: CliError, state: &McpProjectState) -> CliError {
3723 let Some(selection) = state.worktree.as_ref() else {
3724 return error;
3725 };
3726 match &mut error {
3727 CliError::InitRequired(report) => report.worktree = Some(selection.alias.clone()),
3728 CliError::RefreshRequired(report) => report.worktree = Some(selection.alias.clone()),
3729 CliError::VerificationIncomplete(report) => {
3730 report.worktree = Some(selection.alias.clone());
3731 }
3732 CliError::ProjectMismatch(report) => {
3733 report.worktree = Some(selection.alias.clone());
3734 }
3735 _ => {}
3736 }
3737 error
3738 }
3739
3740 #[cfg(test)]
3742 fn with_fresh_store<T, F>(
3743 &self,
3744 state: &McpProjectState,
3745 query: F,
3746 ) -> Result<VerifiedReadOutcome<T>, CliError>
3747 where
3748 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3749 {
3750 self.with_fresh_store_for_request(state, None, query)
3751 }
3752
3753 fn with_fresh_store_for_request<T, F>(
3755 &self,
3756 state: &McpProjectState,
3757 context: Option<RequestContext<RoleServer>>,
3758 mut query: F,
3759 ) -> Result<VerifiedReadOutcome<T>, CliError>
3760 where
3761 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3762 {
3763 self.with_fresh_store_controlled_for_request(state, context, |store, stamp, _control| {
3764 query(store, stamp)
3765 })
3766 }
3767
3768 fn with_fresh_store_controlled_for_request<T, F>(
3770 &self,
3771 state: &McpProjectState,
3772 context: Option<RequestContext<RoleServer>>,
3773 mut query: F,
3774 ) -> Result<VerifiedReadOutcome<T>, CliError>
3775 where
3776 F: FnMut(&AtlasStore, VerifiedReadStamp, &IndexWorkControl) -> Result<T, CliError>,
3777 {
3778 if !state.db_path.is_file() {
3779 return Err(Self::with_target_error_context(
3780 index_init_required(&state.root, &state.db_path),
3781 state,
3782 ));
3783 }
3784 let control =
3785 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
3786 let bridge = context
3787 .map(|context| McpRequestCancellationBridge::start(&context, &control))
3788 .transpose()?;
3789 let result = self
3790 .source_observations
3791 .with_verified_read(
3792 &state.db_path,
3793 &state.root,
3794 state.config_path.as_deref(),
3795 &control,
3796 |store, stamp| {
3797 Self::require_captured_worktree_identity(state.worktree.as_ref(), store)?;
3798 query(store, stamp, &control)
3799 },
3800 )
3801 .map_err(|error| Self::with_target_error_context(error, state));
3802 if let Some(bridge) = bridge.as_ref() {
3803 bridge.synchronize(&control);
3804 }
3805 drop(bridge);
3806 result
3807 }
3808
3809 fn require_captured_worktree_identity(
3811 selection: Option<&McpWorktreeSelection>,
3812 store: &AtlasStore,
3813 ) -> Result<(), CliError> {
3814 let Some(expected) = selection.and_then(|selection| selection.project_instance_id) else {
3815 return Ok(());
3816 };
3817 if store.project_instance_id()? != Some(expected) {
3818 return Err(CliError::InvalidInput(
3819 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
3820 ));
3821 }
3822 Ok(())
3823 }
3824
3825 fn require_captured_control_identity(
3827 selection: Option<&McpWorktreeSelection>,
3828 control: &AtlasStore,
3829 ) -> Result<(), CliError> {
3830 let Some(expected) = selection.and_then(|selection| selection.control_project_instance_id)
3831 else {
3832 return Ok(());
3833 };
3834 if control.project_instance_id()? != Some(expected) {
3835 return Err(CliError::InvalidInput(
3836 MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT.to_string(),
3837 ));
3838 }
3839 Ok(())
3840 }
3841
3842 fn require_federated_worktree_identities(
3844 stores: Vec<FederatedStore>,
3845 selections: &[McpWorktreeSelection],
3846 ) -> Result<Vec<FederatedStore>, CliError> {
3847 if stores.len() != selections.len() {
3848 for store in stores {
3849 drop(store.finish());
3850 }
3851 return Err(CliError::InvalidInput(
3852 MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string(),
3853 ));
3854 }
3855 for (store, selection) in stores.iter().zip(selections) {
3856 if let Err(error) =
3857 Self::require_captured_worktree_identity(Some(selection), store.store())
3858 {
3859 let error = federated_worktree_error(error, &selection.alias);
3860 for store in stores {
3861 drop(store.finish());
3862 }
3863 return Err(error);
3864 }
3865 }
3866 Ok(stores)
3867 }
3868
3869 fn with_fresh_string_for_request<F>(
3871 &self,
3872 state: &McpProjectState,
3873 context: Option<RequestContext<RoleServer>>,
3874 mut query: F,
3875 ) -> Result<String, CliError>
3876 where
3877 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<String, CliError>,
3878 {
3879 self.with_fresh_string_and_usage_for_request(state, context, |store, stamp| {
3880 Ok((query(store, stamp)?, None))
3881 })
3882 }
3883
3884 fn with_fresh_string_and_usage_for_request<F>(
3886 &self,
3887 state: &McpProjectState,
3888 context: Option<RequestContext<RoleServer>>,
3889 mut query: F,
3890 ) -> Result<String, CliError>
3891 where
3892 F: FnMut(
3893 &AtlasStore,
3894 VerifiedReadStamp,
3895 ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3896 {
3897 self.with_fresh_string_and_usage_controlled_for_request(
3898 state,
3899 context,
3900 |store, stamp, _control| query(store, stamp),
3901 )
3902 }
3903
3904 fn with_fresh_string_and_usage_controlled_for_request<F>(
3906 &self,
3907 state: &McpProjectState,
3908 context: Option<RequestContext<RoleServer>>,
3909 query: F,
3910 ) -> Result<String, CliError>
3911 where
3912 F: FnMut(
3913 &AtlasStore,
3914 VerifiedReadStamp,
3915 &IndexWorkControl,
3916 ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3917 {
3918 let outcome = self.with_fresh_store_controlled_for_request(state, context, query)?;
3919 let stamp = outcome.stamp.clone();
3920 let (value, usage) = outcome.value;
3921 let output_bytes = value.len();
3922 let outcome = VerifiedReadOutcome {
3923 value,
3924 stamp,
3925 work: outcome.work,
3926 }
3927 .with_output_bytes(output_bytes);
3928 if let Some(usage) = usage {
3929 self.record_accepted_usage(state, &outcome.stamp, &usage, &outcome.value);
3930 }
3931 Ok(outcome.value)
3932 }
3933
3934 fn open_mut_store(
3936 state: &McpProjectState,
3937 control_state: &McpProjectState,
3938 ) -> Result<AtlasStore, CliError> {
3939 let Some(selection) = state
3940 .worktree
3941 .as_ref()
3942 .filter(|selection| selection.registration_id.is_some())
3943 else {
3944 let store = open_atlas_store_for_project(&state.db_path, &state.root)?;
3945 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)?;
3946 return Ok(store);
3947 };
3948 Self::open_registered_worktree_mut_store(state, control_state, selection)
3949 }
3950
3951 fn run_observed_watch_refresh(
3953 state: &McpProjectState,
3954 control_state: &McpProjectState,
3955 plan: &ScanRuntimePlan,
3956 options: &SymbolBuildOptions,
3957 control: &IndexWorkControl,
3958 observations: &SourceObservationRegistry,
3959 allow_observed_noop: bool,
3960 ) -> Result<WatchReport, CliError> {
3961 if allow_observed_noop && plan.root == state.root && state.db_path.is_file() {
3962 let current = observations.with_verified_current_read(
3963 &state.db_path,
3964 &state.root,
3965 state.config_path.as_deref(),
3966 control,
3967 |store, _stamp| {
3968 Self::require_captured_worktree_identity(state.worktree.as_ref(), store)?;
3969 publication_contract_matches(store, plan)
3970 },
3971 );
3972 match current {
3973 Ok(SourceReadAdmission::Current(outcome)) if outcome.value => {
3974 return Ok(WatchReport::unchanged(plan.text_options));
3975 }
3976 Ok(SourceReadAdmission::Reconcile(assessment)) => {
3977 let mut store = Self::open_existing_mut_store(state, control_state)?;
3978 let refresh = refresh_index_from_assessment_controlled(
3979 &mut store,
3980 plan,
3981 options,
3982 *assessment,
3983 control,
3984 )?;
3985 return Ok(WatchReport::from_refresh(refresh));
3986 }
3987 Ok(_) | Err(CliError::RefreshRequired(_) | CliError::VerificationIncomplete(_)) => {
3988 }
3989 Err(CliError::Db(source)) if source.supported_schema_migration().is_some() => {}
3990 Err(error) => return Err(error),
3991 }
3992 }
3993 let mut store = Self::open_existing_mut_store(state, control_state)?;
3994 run_single_watch_refresh_controlled(&mut store, plan, options, control)
3995 }
3996
3997 fn open_registered_worktree_mut_store(
3999 state: &McpProjectState,
4000 control_state: &McpProjectState,
4001 selection: &McpWorktreeSelection,
4002 ) -> Result<AtlasStore, CliError> {
4003 let alias = WorktreeAlias::parse(&selection.alias)?;
4004 let registration_id =
4005 selection
4006 .registration_id
4007 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
4008 alias: selection.alias.clone(),
4009 })?;
4010 let control =
4011 open_existing_atlas_store_for_project(&control_state.db_path, &control_state.root)?;
4012 Self::require_captured_control_identity(Some(selection), &control)?;
4013 control.with_active_worktree_registration(registration_id, &alias, |guard| {
4014 if let Err(error) =
4015 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4016 {
4017 return Ok(Err(error));
4018 }
4019 if !state.db_path.is_file() {
4020 return Ok(Err(Self::with_target_error_context(
4021 index_init_required(&state.root, &state.db_path),
4022 state,
4023 )));
4024 }
4025 let target = match open_existing_atlas_store_for_project(&state.db_path, &state.root) {
4026 Ok(store) => store,
4027 Err(error) => return Ok(Err(error)),
4028 };
4029 let project = match target.captured_project_binding() {
4030 Ok(binding) => binding.project_instance_id,
4031 Err(error) => return Ok(Err(error.into())),
4032 };
4033 if selection
4034 .project_instance_id
4035 .is_some_and(|expected| expected != project)
4036 {
4037 return Ok(Err(CliError::InvalidInput(
4038 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
4039 )));
4040 }
4041 match guard.registration().project_instance_id {
4042 Some(bound) if bound == project => {}
4043 Some(_) => {
4044 return Ok(Err(CliError::InvalidInput(
4045 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
4046 )));
4047 }
4048 None => {
4049 let snapshot = match target.export_worktree_usage_snapshot() {
4050 Ok(snapshot) => snapshot,
4051 Err(error) => return Ok(Err(error.into())),
4052 };
4053 if let Err(error) =
4054 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4055 {
4056 return Ok(Err(error));
4057 }
4058 if let Err(error) = require_current_worktree_usage_snapshot(
4059 &state.db_path,
4060 &state.root,
4061 &snapshot,
4062 ) {
4063 return Ok(Err(error));
4064 }
4065 guard.bind_project_with_usage_snapshot(&state.root, project, &snapshot)?;
4066 }
4067 }
4068 Ok(Ok(target))
4069 })?
4070 }
4071
4072 fn open_existing_mut_store(
4074 state: &McpProjectState,
4075 control_state: &McpProjectState,
4076 ) -> Result<AtlasStore, CliError> {
4077 if !state.db_path.is_file() {
4078 return Err(Self::with_target_error_context(
4079 index_init_required(&state.root, &state.db_path),
4080 state,
4081 ));
4082 }
4083 if state
4084 .worktree
4085 .as_ref()
4086 .is_some_and(|selection| selection.registration_id.is_some())
4087 {
4088 return Self::open_mut_store(state, control_state);
4089 }
4090 let store = open_existing_atlas_store_for_project(&state.db_path, &state.root)?;
4091 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)?;
4092 Ok(store)
4093 }
4094
4095 fn with_admitted_purpose_mutation<T>(
4097 &self,
4098 state: &McpProjectState,
4099 context: Option<RequestContext<RoleServer>>,
4100 mutation: impl FnOnce(&AtlasStore) -> Result<T, CliError>,
4101 ) -> Result<T, CliError> {
4102 if !state.db_path.is_file() {
4103 return Err(Self::with_target_error_context(
4104 index_init_required(&state.root, &state.db_path),
4105 state,
4106 ));
4107 }
4108 let control =
4109 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
4110 let bridge = context
4111 .map(|context| McpRequestCancellationBridge::start(&context, &control))
4112 .transpose()?;
4113 let result = self
4114 .with_admitted_purpose_mutation_controlled(state, &control, bridge.as_ref(), mutation)
4115 .map_err(|error| Self::with_target_error_context(error, state));
4116 drop(bridge);
4117 result
4118 }
4119
4120 fn with_admitted_purpose_mutation_controlled<T>(
4122 &self,
4123 state: &McpProjectState,
4124 control: &IndexWorkControl,
4125 bridge: Option<&McpRequestCancellationBridge>,
4126 mutation: impl FnOnce(&AtlasStore) -> Result<T, CliError>,
4127 ) -> Result<T, CliError> {
4128 if let Some(bridge) = bridge {
4129 bridge.synchronize(control);
4130 }
4131 let admission = self.source_observations.admit_mutation(
4132 &state.db_path,
4133 &state.root,
4134 state.config_path.as_deref(),
4135 control,
4136 )?;
4137 let store = Self::open_existing_mut_store(state, &self.control_state)?;
4138 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)?;
4139 let transaction = store.begin_purpose_mutation()?;
4140 let operation = (|| {
4141 let value = mutation(&store)?;
4142 if let Some(bridge) = bridge {
4143 bridge.synchronize(control);
4144 }
4145 admission.verify()?;
4146 if let Some(bridge) = bridge {
4147 bridge.synchronize(control);
4148 }
4149 control.check(IndexWorkStage::Publication)?;
4150 Ok(value)
4151 })();
4152 match operation {
4153 Ok(value) => {
4154 transaction.commit()?;
4155 Ok(value)
4156 }
4157 Err(operation) => Err(crate::rollback_rejected_purpose_mutation(
4158 transaction,
4159 operation,
4160 )),
4161 }
4162 }
4163
4164 fn telemetry_enabled() -> bool {
4166 !telemetry_disabled()
4167 }
4168
4169 fn record_usage_for_state<F>(&self, state: &McpProjectState, store: &AtlasStore, record: F)
4171 where
4172 F: FnMut(UsageRuntimeInstance) -> Result<(), CliError>,
4173 {
4174 self.record_usage_for_origin(state, store, None, record);
4175 }
4176
4177 fn record_usage_for_origin<F>(
4179 &self,
4180 state: &McpProjectState,
4181 store: &AtlasStore,
4182 worktree_registration_id: Option<i64>,
4183 mut record: F,
4184 ) where
4185 F: FnMut(UsageRuntimeInstance) -> Result<(), CliError>,
4186 {
4187 if telemetry_disabled() {
4188 return;
4189 }
4190 let Ok(binding) =
4191 McpUsageProjectBinding::capture_with_origin(state, store, worktree_registration_id)
4192 else {
4193 return;
4194 };
4195 let Some(project_instance) = self
4196 .usage_runtime
4197 .lock()
4198 .ok()
4199 .and_then(|mut runtime| runtime.instance_for_binding(binding, store))
4200 else {
4201 return;
4202 };
4203 let Ok(mut usage_instance) = project_instance.lock() else {
4204 return;
4205 };
4206 if !matches!(
4207 record(*usage_instance),
4208 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
4209 ) {
4210 return;
4211 }
4212
4213 let Some(next_instance) = UsageRuntimeInstance::new(UsageInstanceOwner::McpProcess) else {
4214 return;
4215 };
4216 if usage_instance.seal(store).is_err() {
4219 return;
4220 }
4221 *usage_instance = next_instance;
4222 drop(record(next_instance));
4223 }
4224
4225 fn record_accepted_usage(
4227 &self,
4228 state: &McpProjectState,
4229 stamp: &VerifiedReadStamp,
4230 intent: &McpUsageIntent,
4231 output: &str,
4232 ) {
4233 if !Self::telemetry_enabled() {
4234 return;
4235 }
4236 let Ok(store) = Self::open_read_store(state) else {
4237 return;
4238 };
4239 let Ok(binding) = store.captured_project_binding() else {
4240 return;
4241 };
4242 if binding.project_instance_id != stamp.project_instance_id {
4243 return;
4244 }
4245 if let Some(selection) = state
4246 .worktree
4247 .as_ref()
4248 .filter(|selection| selection.registration_id.is_some())
4249 {
4250 let Some(registration_id) = selection.registration_id else {
4251 return;
4252 };
4253 let event = match &intent.baseline {
4254 McpUsageBaseline::Estimate(baseline_tokens) => usage_from_estimates_with_context(
4255 &self.session,
4256 intent.command,
4257 intent.path.clone(),
4258 intent.query.clone(),
4259 *baseline_tokens,
4260 projectatlas_core::outline::estimate_tokens(output),
4261 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4262 TOKEN_BASELINE_SELECTED_CANDIDATES,
4263 TOKEN_CONFIDENCE_INFERRED,
4264 ),
4265 McpUsageBaseline::DirectoryWalk(baseline_tokens) => {
4266 usage_from_estimates_with_context(
4267 &self.session,
4268 intent.command,
4269 intent.path.clone(),
4270 intent.query.clone(),
4271 *baseline_tokens,
4272 projectatlas_core::outline::estimate_tokens(output),
4273 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4274 TOKEN_BASELINE_DIRECTORY_WALK,
4275 TOKEN_CONFIDENCE_POLICY_ESTIMATE,
4276 )
4277 }
4278 McpUsageBaseline::Text(baseline_text) => usage_from_text(
4279 &self.session,
4280 intent.command,
4281 intent.path.clone(),
4282 intent.query.clone(),
4283 baseline_text,
4284 output,
4285 ),
4286 };
4287 let Ok(control) = Self::open_read_store(&self.control_state) else {
4288 return;
4289 };
4290 if Self::require_captured_control_identity(Some(selection), &control).is_err() {
4291 return;
4292 }
4293 if control.finish_index_read_snapshot().is_ok() {
4294 self.record_usage_for_origin(
4295 &self.control_state,
4296 &control,
4297 Some(registration_id),
4298 |usage_instance| {
4299 usage_instance.record_for_worktree(&control, registration_id, &event)
4300 },
4301 );
4302 }
4303 return;
4304 }
4305 self.record_usage_for_state(state, &store, |usage_instance| match &intent.baseline {
4306 McpUsageBaseline::Estimate(baseline_tokens) => record_usage_estimate(
4307 &store,
4308 Some(usage_instance),
4309 &self.session,
4310 intent.command,
4311 intent.path.clone(),
4312 intent.query.clone(),
4313 *baseline_tokens,
4314 output,
4315 ),
4316 McpUsageBaseline::DirectoryWalk(baseline_tokens) => {
4317 record_directory_walk_usage_estimate(
4318 &store,
4319 Some(usage_instance),
4320 &self.session,
4321 intent.command,
4322 intent.path.clone(),
4323 intent.query.clone(),
4324 *baseline_tokens,
4325 output,
4326 )
4327 }
4328 McpUsageBaseline::Text(baseline_text) => record_usage_text(
4329 &store,
4330 Some(usage_instance),
4331 &self.session,
4332 intent.command,
4333 intent.path.clone(),
4334 intent.query.clone(),
4335 baseline_text,
4336 output,
4337 ),
4338 });
4339 }
4340
4341 fn estimated_source_tokens_cached(
4343 &self,
4344 state: &McpProjectState,
4345 store: &AtlasStore,
4346 stamp: &VerifiedReadStamp,
4347 folder: Option<&str>,
4348 file_pattern: Option<&str>,
4349 ) -> Result<usize, CliError> {
4350 let key = McpSourceTokenBaselineKey {
4351 binding: McpUsageProjectBinding {
4352 root: state.root.clone(),
4353 db_path: state.db_path.clone(),
4354 project_instance_id: stamp.project_instance_id,
4355 worktree_registration_id: None,
4356 },
4357 generation: stamp.generation,
4358 folder: folder.map(ToOwned::to_owned),
4359 file_pattern: file_pattern.map(ToOwned::to_owned),
4360 };
4361 if let Some(value) = self
4362 .usage_runtime
4363 .lock()
4364 .ok()
4365 .and_then(|runtime| runtime.source_token_baseline(&key))
4366 {
4367 return Ok(value);
4368 }
4369 let value = estimated_source_tokens_for_indexed_files(store, folder, file_pattern)?;
4370 if let Ok(mut runtime) = self.usage_runtime.lock() {
4371 runtime.insert_source_token_baseline(key, value);
4372 }
4373 Ok(value)
4374 }
4375
4376 fn seal_usage_instances_for_projects(&self) {
4378 let Some(projects) = self
4379 .usage_runtime
4380 .lock()
4381 .ok()
4382 .map(|runtime| runtime.snapshot())
4383 else {
4384 return;
4385 };
4386 for project in projects {
4387 let Ok(instance) = project.instance.lock() else {
4388 continue;
4389 };
4390 if let Ok(store) =
4391 open_atlas_store_for_project(&project.binding.db_path, &project.binding.root)
4392 {
4393 drop(instance.seal(&store));
4394 }
4395 }
4396 }
4397
4398 fn load_config_for_state(state: &McpProjectState) -> Result<AtlasMapConfig, CliError> {
4400 state
4401 .config_path
4402 .as_deref()
4403 .map_or_else(
4404 || load_atlas_config_for_root(&state.root).map_err(CliError::from),
4405 |config_path| load_atlas_config(Some(config_path)).map_err(CliError::from),
4406 )
4407 .map(|config| config.with_database_path(&state.db_path))
4408 }
4409
4410 fn init_project_root(
4412 &self,
4413 project_path: Option<String>,
4414 worktree: Option<String>,
4415 ) -> Result<McpProjectState, CliError> {
4416 self.state_for_target_with_config_validation(
4417 project_path,
4418 worktree,
4419 McpConfigValidation::Immediate,
4420 )
4421 }
4422
4423 fn run_registered_worktree_init(
4425 &self,
4426 state: &McpProjectState,
4427 config_path: &Path,
4428 options: &InitBootstrapOptions,
4429 ) -> Result<InitSetupReport, CliError> {
4430 let Some(selection) = state
4431 .worktree
4432 .as_ref()
4433 .filter(|selection| selection.registration_id.is_some())
4434 else {
4435 let report =
4436 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4437 if report.ok {
4438 self.bind_initialized_registration_for_root(state)?;
4439 }
4440 return Ok(report);
4441 };
4442 if state.db_path.is_file() {
4443 let mut report =
4444 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4445 report.hydration = Some(InitHydrationPhase {
4446 status: InitHydrationStatus::Existing,
4447 source_root: None,
4448 source_project_instance_id: None,
4449 target_project_instance_id: None,
4450 baseline_generation: None,
4451 reconciled_generation: None,
4452 fallback_reason: None,
4453 });
4454 if report.ok {
4455 self.bind_initialized_worktree(selection, state)?;
4456 }
4457 return Ok(report);
4458 }
4459
4460 let state_guard =
4461 crate::runtime::preflight_generated_state(&state.root, &state.db_path, config_path)?;
4462 preflight_existing_project_binding(&state.db_path, &state.root)?;
4463 let project_dir = state.root.join(PROJECTATLAS_DIR_NAME);
4464 let nonsource_file = project_dir.join(MCP_NONSOURCE_FILE_NAME);
4465 let project_dir_existed = project_dir.exists();
4466 let config_existed = config_path.exists();
4467 let nonsource_existed = nonsource_file.exists();
4468 init_project_with_config(&state.root, Some(config_path))?;
4469
4470 state_guard.verify().map_err(|source| CliError::Io {
4471 path: project_dir.clone(),
4472 source,
4473 })?;
4474
4475 let hydration = if options.no_scan {
4476 McpWorktreeHydration::Fallback(MCP_HYDRATION_NO_SCAN_REASON.to_string())
4477 } else {
4478 self.attempt_worktree_hydration(state, config_path, options)?
4479 };
4480 let mut report = match hydration {
4481 McpWorktreeHydration::Activated { hydration, scan } => {
4482 let mut report = run_init_bootstrap(
4483 &state.root,
4484 &state.db_path,
4485 Some(config_path),
4486 &InitBootstrapOptions {
4487 no_scan: true,
4488 force_rescan: options.force_rescan,
4489 text_index_max_bytes: options.text_index_max_bytes,
4490 },
4491 )?;
4492 report.scan = InitScanPhase {
4493 status: InitPhaseStatus::Verified,
4494 requested: true,
4495 force_rescan: options.force_rescan,
4496 report: Some(*scan),
4497 error: None,
4498 };
4499 report.next_steps =
4500 init_next_steps(false, false, report.purpose_handoff.queue.total);
4501 report.hydration = Some(hydration);
4502 report
4503 }
4504 McpWorktreeHydration::Fallback(reason) => {
4505 let mut report =
4506 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4507 report.hydration = Some(InitHydrationPhase {
4508 status: InitHydrationStatus::Fallback,
4509 source_root: lossless_project_root_display(&self.control_state.root),
4510 source_project_instance_id: None,
4511 target_project_instance_id: None,
4512 baseline_generation: None,
4513 reconciled_generation: None,
4514 fallback_reason: Some(reason),
4515 });
4516 report
4517 }
4518 };
4519 report.project_dir.status = if project_dir_existed {
4520 InitPhaseStatus::Exists
4521 } else {
4522 InitPhaseStatus::Created
4523 };
4524 report.config.status = if config_existed {
4525 InitPhaseStatus::Exists
4526 } else {
4527 InitPhaseStatus::Created
4528 };
4529 report.nonsource_files.status = if nonsource_existed {
4530 InitPhaseStatus::Exists
4531 } else {
4532 InitPhaseStatus::Created
4533 };
4534 report.db.status = InitPhaseStatus::Created;
4535 if report.ok {
4536 self.bind_initialized_worktree(selection, state)?;
4537 }
4538 Ok(report)
4539 }
4540
4541 fn attempt_worktree_hydration(
4543 &self,
4544 state: &McpProjectState,
4545 config_path: &Path,
4546 options: &InitBootstrapOptions,
4547 ) -> Result<McpWorktreeHydration, CliError> {
4548 let selection = state
4549 .worktree
4550 .as_ref()
4551 .filter(|selection| selection.registration_id.is_some())
4552 .ok_or_else(|| CliError::InvalidInput(MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string()))?;
4553 let source = match Self::open_read_store(&self.control_state) {
4554 Ok(source) => source,
4555 Err(error) if Self::hydration_can_fallback(&error) => {
4556 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4557 }
4558 Err(error) => return Err(error),
4559 };
4560 Self::require_captured_control_identity(state.worktree.as_ref(), &source)?;
4561 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
4562 let control = index_work_control(&symbol_options);
4563 let mut candidate =
4564 match source.prepare_worktree_hydration(&state.root, &state.db_path, &control) {
4565 Ok(candidate) => candidate,
4566 Err(error) => {
4567 let error = Self::hydration_db_error(error);
4568 if Self::hydration_can_fallback(&error) {
4569 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4570 }
4571 return Err(error);
4572 }
4573 };
4574 let candidate_path = candidate
4575 .path()
4576 .map_err(Self::hydration_db_error)?
4577 .to_path_buf();
4578 let mut target = open_atlas_store_for_project(&candidate_path, &state.root)?;
4579 let plan = ScanRuntimePlan::for_path_controlled(
4580 Some(config_path),
4581 &state.root,
4582 options.text_index_max_bytes,
4583 &control,
4584 )?;
4585 let (scan, source_unchanged) =
4586 reconcile_hydrated_index_controlled(&mut target, &plan, &symbol_options, &control)?;
4587 drop(target);
4588 if source_unchanged {
4589 candidate
4590 .accept_verified_source_state(&control)
4591 .map_err(Self::hydration_db_error)?;
4592 }
4593 drop(source);
4594 let candidate = candidate
4595 .prepare_activation(&control)
4596 .map_err(Self::hydration_db_error)?;
4597 let activation = match self
4598 .activate_registered_worktree_hydration(state, selection, candidate, &control)
4599 {
4600 Ok(activation) => activation,
4601 Err(CliError::Db(error @ DbError::WorktreeHydrationDestinationExists { .. })) => {
4602 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4603 }
4604 Err(error) => return Err(error),
4605 };
4606 Ok(McpWorktreeHydration::Activated {
4607 hydration: InitHydrationPhase {
4608 status: InitHydrationStatus::Hydrated,
4609 source_root: lossless_project_root_display(&self.control_state.root),
4610 source_project_instance_id: Some(activation.source_project_instance_id.to_string()),
4611 target_project_instance_id: Some(activation.target_project_instance_id.to_string()),
4612 baseline_generation: Some(activation.baseline_generation.get()),
4613 reconciled_generation: Some(activation.reconciled_generation.get()),
4614 fallback_reason: None,
4615 },
4616 scan: Box::new(scan),
4617 })
4618 }
4619
4620 fn activate_registered_worktree_hydration(
4622 &self,
4623 state: &McpProjectState,
4624 selection: &McpWorktreeSelection,
4625 candidate: PreparedWorktreeHydrationCandidate,
4626 work_control: &IndexWorkControl,
4627 ) -> Result<WorktreeHydrationActivation, CliError> {
4628 self.activate_registered_worktree_hydration_with_post_publication(
4629 state,
4630 selection,
4631 candidate,
4632 work_control,
4633 || Ok(()),
4634 )
4635 }
4636
4637 fn activate_registered_worktree_hydration_with_post_publication<F>(
4639 &self,
4640 state: &McpProjectState,
4641 selection: &McpWorktreeSelection,
4642 candidate: PreparedWorktreeHydrationCandidate,
4643 work_control: &IndexWorkControl,
4644 post_publication: F,
4645 ) -> Result<WorktreeHydrationActivation, CliError>
4646 where
4647 F: FnOnce() -> Result<(), CliError>,
4648 {
4649 let alias = WorktreeAlias::parse(&selection.alias)?;
4650 let registration_id =
4651 selection
4652 .registration_id
4653 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
4654 alias: selection.alias.clone(),
4655 })?;
4656 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
4657 Self::require_captured_control_identity(Some(selection), &control)?;
4658 control.with_active_worktree_registration(registration_id, &alias, |guard| {
4659 if let Err(error) =
4660 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4661 {
4662 return Ok(Err(error));
4663 }
4664 let activation = candidate.activate(work_control)?;
4665 if let Err(error) = post_publication() {
4666 return Ok(Err(error));
4667 }
4668 let target =
4669 match open_atlas_store_read_only_for_project(&activation.database, &state.root) {
4670 Ok(target) => target,
4671 Err(error) => return Ok(Err(error)),
4672 };
4673 let snapshot = match target.export_worktree_usage_snapshot() {
4674 Ok(snapshot) => snapshot,
4675 Err(error) => return Ok(Err(error.into())),
4676 };
4677 if let Err(error) =
4678 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4679 {
4680 return Ok(Err(error));
4681 }
4682 if let Err(error) = require_current_worktree_usage_snapshot(
4683 &activation.database,
4684 &state.root,
4685 &snapshot,
4686 ) {
4687 return Ok(Err(error));
4688 }
4689 guard.bind_project_with_usage_snapshot(
4690 &state.root,
4691 activation.target_project_instance_id,
4692 &snapshot,
4693 )?;
4694 Ok(Ok(activation))
4695 })?
4696 }
4697
4698 fn bind_initialized_worktree(
4700 &self,
4701 selection: &McpWorktreeSelection,
4702 state: &McpProjectState,
4703 ) -> Result<(), CliError> {
4704 drop(Self::open_registered_worktree_mut_store(
4705 state,
4706 &self.control_state,
4707 selection,
4708 )?);
4709 Ok(())
4710 }
4711
4712 fn bind_initialized_registration_for_root(
4714 &self,
4715 state: &McpProjectState,
4716 ) -> Result<(), CliError> {
4717 let Some(repository) = self.control_git_repository_if_present()? else {
4718 return Ok(());
4719 };
4720 let Some(entry) = repository
4721 .worktrees
4722 .iter()
4723 .find(|entry| Self::active_worktree_root(entry).is_some_and(|root| root == state.root))
4724 else {
4725 return Ok(());
4726 };
4727 let administrative_identity = git_administrative_identity(&entry.administrative_directory)?;
4728 let administrative_directory =
4729 CanonicalProjectRoot::from_path(&entry.administrative_directory)
4730 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
4731 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
4732 let control_project_instance_id = control.captured_project_binding()?.project_instance_id;
4733 let Some(registration) =
4734 control
4735 .worktree_registrations(false)?
4736 .into_iter()
4737 .find(|registration| {
4738 registration.git_administrative_directory_identity == administrative_directory
4739 && registration.git_administrative_identity == administrative_identity
4740 })
4741 else {
4742 return Ok(());
4743 };
4744 self.bind_initialized_worktree(
4745 &McpWorktreeSelection {
4746 alias: registration.alias.to_string(),
4747 registration_id: Some(registration.registration_id),
4748 project_instance_id: registration.project_instance_id,
4749 control_project_instance_id: Some(control_project_instance_id),
4750 },
4751 state,
4752 )
4753 }
4754
4755 fn hydration_can_fallback(error: &CliError) -> bool {
4757 matches!(
4758 error,
4759 CliError::Db(
4760 DbError::WorktreeHydrationInvalid { .. }
4761 | DbError::WorktreeHydrationDestinationExists { .. }
4762 | DbError::WorktreeHydrationBackupBusy { .. }
4763 | DbError::GraphPublicationUnavailable
4764 | DbError::GraphProjectIdentityMismatch { .. }
4765 | DbError::DerivedSnapshotInvalid { .. }
4766 | DbError::DerivedSnapshotLimit { .. }
4767 | DbError::SchemaVersion { .. }
4768 | DbError::SchemaVersionMissing
4769 | DbError::SchemaShape { .. }
4770 | DbError::IntegrityCheck { .. }
4771 | DbError::ProjectRootMissing
4772 | DbError::ProjectInstanceIdentityMissing
4773 )
4774 )
4775 }
4776
4777 fn hydration_db_error(error: DbError) -> CliError {
4779 match error {
4780 DbError::IndexWork(failure) => failure.into(),
4781 error => error.into(),
4782 }
4783 }
4784
4785 fn admin_project_root(
4787 &self,
4788 project_path: Option<String>,
4789 worktree: Option<String>,
4790 ) -> Result<McpProjectState, CliError> {
4791 self.state_for_target(project_path, worktree)
4792 }
4793
4794 fn parse_ignore_kind(
4796 kind: Option<&str>,
4797 required: bool,
4798 ) -> Result<Option<IgnoreEntryKind>, CliError> {
4799 let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) else {
4800 return if required {
4801 Err(CliError::InvalidInput(
4802 MCP_ERROR_IGNORE_KIND_REQUIRED.to_string(),
4803 ))
4804 } else {
4805 Ok(None)
4806 };
4807 };
4808 match kind {
4809 MCP_IGNORE_KIND_DIR_NAME | MCP_IGNORE_KIND_DIR_NAME_ALIAS => {
4810 Ok(Some(IgnoreEntryKind::DirName))
4811 }
4812 MCP_IGNORE_KIND_PATH_PREFIX | MCP_IGNORE_KIND_PATH_PREFIX_ALIAS => {
4813 Ok(Some(IgnoreEntryKind::PathPrefix))
4814 }
4815 other => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4816 MCP_ERROR_INVALID_IGNORE_KIND_PREFIX,
4817 other,
4818 MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX,
4819 ))),
4820 }
4821 }
4822
4823 fn parse_purpose_lint_level(value: Option<&str>) -> Result<PurposeLintLevel, CliError> {
4825 match value.map(str::trim).filter(|value| !value.is_empty()) {
4826 None | Some(MCP_PURPOSE_LEVEL_LOW) => Ok(PurposeLintLevel::Low),
4827 Some(MCP_PURPOSE_LEVEL_MEDIUM) => Ok(PurposeLintLevel::Medium),
4828 Some(MCP_PURPOSE_LEVEL_STRICT) => Ok(PurposeLintLevel::Strict),
4829 Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4830 MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX,
4831 other,
4832 MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX,
4833 ))),
4834 }
4835 }
4836
4837 fn parse_harness_config(value: Option<&str>) -> Result<HarnessConfig, CliError> {
4839 match value.map(str::trim).filter(|value| !value.is_empty()) {
4840 None | Some(MCP_HARNESS_MCP_JSON | MCP_HARNESS_MCP_JSON_ALIAS) => {
4841 Ok(HarnessConfig::McpJson)
4842 }
4843 Some(MCP_HARNESS_CODEX) => Ok(HarnessConfig::Codex),
4844 Some(MCP_HARNESS_CLAUDE_CODE | MCP_HARNESS_CLAUDE_CODE_ALIAS) => {
4845 Ok(HarnessConfig::ClaudeCode)
4846 }
4847 Some(MCP_HARNESS_OPENCODE) => Ok(HarnessConfig::OpenCode),
4848 Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4849 MCP_ERROR_INVALID_HARNESS_PREFIX,
4850 other,
4851 MCP_ERROR_INVALID_HARNESS_SUFFIX,
4852 ))),
4853 }
4854 }
4855
4856 fn parse_token_chart_theme(value: Option<&str>) -> Result<TokenDashboardTheme, CliError> {
4858 match value.map(str::trim).filter(|value| !value.is_empty()) {
4859 None => Ok(TokenDashboardTheme::Dark),
4860 Some(theme) => TokenDashboardTheme::parse(theme).ok_or_else(|| {
4861 CliError::InvalidInput(Self::invalid_parameter_message(
4862 TOKEN_CHART_THEME_ERROR_PREFIX,
4863 theme,
4864 TOKEN_CHART_THEME_ERROR_SUFFIX,
4865 ))
4866 }),
4867 }
4868 }
4869
4870 fn invalid_parameter_message(prefix: &str, value: &str, suffix: &str) -> String {
4872 let mut message = String::with_capacity(prefix.len() + value.len() + suffix.len());
4873 message.push_str(prefix);
4874 message.push_str(value);
4875 message.push_str(suffix);
4876 message
4877 }
4878
4879 fn build_map_report(
4881 state: &McpProjectState,
4882 json: bool,
4883 force: bool,
4884 ) -> Result<String, CliError> {
4885 Self::build_map_report_with_limit(state, json, force, MAX_MAP_RESPONSE_BYTES)
4886 }
4887
4888 fn build_map_report_with_limit(
4890 state: &McpProjectState,
4891 json: bool,
4892 force: bool,
4893 response_limit: usize,
4894 ) -> Result<String, CliError> {
4895 let config = Self::load_config_for_state(state)?;
4896 let skipped_reason = if json
4897 && !force
4898 && (crate::truthy_env(MCP_ENV_CI) || crate::truthy_env(MCP_ENV_GITHUB_ACTIONS))
4899 {
4900 Some(MCP_MAP_SKIPPED_IN_CI_REASON.to_string())
4901 } else {
4902 None
4903 };
4904 let json_written = json && skipped_reason.is_none();
4905 let response = render_map(&config, json, false)?;
4906 let report = McpMapReport {
4907 root: lossless_project_root_display(&config.root),
4908 map_path: lossless_native_path_display(&config.map_path),
4909 written: false,
4910 json,
4911 format: if json {
4912 crate::OutputFormat::Json
4913 } else {
4914 crate::OutputFormat::Toon
4915 },
4916 content: response.content,
4917 json_written,
4918 skipped_reason,
4919 };
4920 let encoded = Self::encode_named_payload(MCP_PAYLOAD_MAP, &report)?;
4921 enforce_map_response_limit(&encoded, response_limit)?;
4922 if json_written {
4923 write_json_map(&report.content, &config)?;
4924 }
4925 Ok(encoded)
4926 }
4927
4928 fn session_capabilities(
4930 &self,
4931 state: &McpProjectState,
4932 text_index_max_bytes: u64,
4933 ) -> McpSessionCapabilities {
4934 McpSessionCapabilities {
4935 runtime: build_runtime_info(),
4936 selected_project: Self::selected_project_capability(state),
4937 startup_policy: McpStartupPolicy {
4938 nearest_project: Self::policy_state(self.allow_nearest_project),
4939 },
4940 path_scope: self.path_scope(),
4941 scan_policy: McpScanPolicy {
4942 implicit_scan: McpPolicyState::Disabled,
4943 text_index_max_bytes,
4944 },
4945 classified_navigation: classified_navigation_capabilities(),
4946 telemetry: McpTelemetryPolicy {
4947 mode: Self::policy_state(!telemetry_disabled()),
4948 },
4949 privacy: McpPrivacyPolicy {
4950 environment_dump: false,
4951 secret_values: false,
4952 projectatlas_paths_only: true,
4953 },
4954 }
4955 }
4956
4957 fn render_settings_with_capabilities(
4959 &self,
4960 state: &McpProjectState,
4961 ) -> Result<String, CliError> {
4962 let admission = crate::runtime::preflight_managed_database(&state.db_path, &state.root)?;
4963 let database = crate::runtime::admitted_database_path(&state.db_path, admission.as_ref());
4964 let report =
4965 build_settings_report(database, state.config_path.as_deref(), OutputFormat::Toon)?;
4966 let capabilities = self.session_capabilities(state, report.text_index_max_bytes);
4967 let rendered = Self::encode_two_named_payloads(
4968 MCP_PAYLOAD_SETTINGS,
4969 &report,
4970 MCP_PAYLOAD_SESSION_CAPABILITIES,
4971 &capabilities,
4972 )?;
4973 if rendered.len() > MCP_SETTINGS_RESPONSE_MAX_BYTES {
4974 let mut message = MCP_SETTINGS_RESPONSE_LIMIT_PREFIX.to_string();
4975 message.push_str(&rendered.len().to_string());
4976 message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR);
4977 message.push_str(&MCP_SETTINGS_RESPONSE_MAX_BYTES.to_string());
4978 message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX);
4979 return Err(CliError::InvalidInput(message));
4980 }
4981 Ok(rendered)
4982 }
4983
4984 fn selected_project_capability(state: &McpProjectState) -> McpSelectedProjectCapability {
4986 McpSelectedProjectCapability {
4987 worktree: state
4988 .worktree
4989 .as_ref()
4990 .map(|selection| selection.alias.clone()),
4991 registration_id: state
4992 .worktree
4993 .as_ref()
4994 .and_then(|selection| selection.registration_id),
4995 root: lossless_project_root_display(&state.root),
4996 db: lossless_native_path_display(&state.db_path),
4997 config: state
4998 .config_path
4999 .as_ref()
5000 .and_then(|path| lossless_native_path_display(path)),
5001 index_status: if state.db_path.exists() {
5002 McpIndexStatus::Available
5003 } else {
5004 McpIndexStatus::Missing
5005 },
5006 }
5007 }
5008
5009 fn path_scope(&self) -> McpPathScope {
5011 if self.allow_nearest_project {
5012 McpPathScope::NearestIndexedProject
5013 } else {
5014 McpPathScope::SelectedProject
5015 }
5016 }
5017
5018 fn policy_state(enabled: bool) -> McpPolicyState {
5020 if enabled {
5021 McpPolicyState::Enabled
5022 } else {
5023 McpPolicyState::Disabled
5024 }
5025 }
5026
5027 fn brief_limit(value: Option<usize>) -> usize {
5029 value
5030 .unwrap_or(SESSION_BRIEF_DEFAULT_LIMIT)
5031 .clamp(1, SESSION_BRIEF_MAX_LIMIT)
5032 }
5033
5034 fn build_session_brief(
5036 &self,
5037 params: AtlasSessionBriefParams,
5038 context: Option<RequestContext<RoleServer>>,
5039 ) -> Result<McpSessionBrief, CliError> {
5040 let selected_project_path = params.project_path.clone();
5041 let selected_worktree = params.worktree.clone();
5042 let state =
5043 self.state_for_target(selected_project_path.clone(), selected_worktree.clone())?;
5044 let query = Self::query_or_empty(params.query);
5045 let purpose_task = params
5046 .purpose_task
5047 .unwrap_or_else(|| MCP_PURPOSE_TASK_SESSION_STARTUP.to_string());
5048 let folder_limit = Self::brief_limit(params.folder_limit);
5049 let file_limit = Self::brief_limit(params.file_limit);
5050 let blocker_limit = Self::brief_limit(params.blocker_limit);
5051 let purpose_limit = Self::brief_limit(params.purpose_limit);
5052 let project = Self::selected_project_capability(&state);
5053 if !state.db_path.exists() {
5054 let init_project_path = selected_worktree
5055 .is_none()
5056 .then(|| lossless_project_root_display(&state.root))
5057 .flatten();
5058 return Ok(McpSessionBrief {
5059 project,
5060 policy: self.brief_policy(),
5061 overview: None,
5062 folders: Vec::new(),
5063 files: Vec::new(),
5064 blockers: McpBriefBlockers {
5065 total: 0,
5066 returned: 0,
5067 truncated: false,
5068 items: Vec::new(),
5069 },
5070 purpose_handoff: None,
5071 recommendations: Self::missing_index_recommendations(
5072 init_project_path,
5073 selected_worktree,
5074 ),
5075 limits: McpBriefLimits {
5076 folder_limit,
5077 file_limit,
5078 blocker_limit,
5079 purpose_limit,
5080 folders_truncated: false,
5081 files_truncated: false,
5082 purposes_truncated: false,
5083 },
5084 });
5085 }
5086 let outcome = self.with_fresh_store_for_request(&state, context, |store, _stamp| {
5087 let overview = store.overview()?;
5088 let folder_rows =
5089 ranked_folder_nodes_with_reasons(store, &query, folder_limit.saturating_add(1))?;
5090 let file_rows = ranked_file_nodes_with_reasons(
5091 store,
5092 &query,
5093 None,
5094 None,
5095 file_limit.saturating_add(1),
5096 false,
5097 )?;
5098 let blockers = Self::brief_blockers(store, blocker_limit)?;
5099 let purpose_query = HealthQuery {
5100 start_index: 0,
5101 limit: purpose_limit,
5102 category: None,
5103 severity: None,
5104 path_prefix: None,
5105 summary_only: false,
5106 scope: HealthScope::purpose_default(),
5107 };
5108 let purpose_queue = purpose_curation_page(store, &purpose_query, &purpose_task)?;
5109 let purposes_truncated = purpose_queue.truncated;
5110 let folders_truncated = folder_rows.len() > folder_limit;
5111 let files_truncated = file_rows.len() > file_limit;
5112 let next_navigation_call = file_rows.first().map(|row| row.next_call.clone());
5113 Ok(McpSessionBrief {
5114 project: Self::selected_project_capability(&state),
5115 policy: self.brief_policy(),
5116 overview: Some(overview),
5117 folders: folder_rows
5118 .into_iter()
5119 .take(folder_limit)
5120 .map(Self::brief_candidate)
5121 .collect(),
5122 files: file_rows
5123 .into_iter()
5124 .take(file_limit)
5125 .map(Self::brief_candidate)
5126 .collect(),
5127 recommendations: Self::indexed_project_recommendations(
5128 &query,
5129 next_navigation_call,
5130 blockers.total,
5131 blocker_limit,
5132 selected_project_path.clone(),
5133 selected_worktree.clone(),
5134 ),
5135 blockers,
5136 purpose_handoff: purpose_queue
5137 .actionable
5138 .then(|| purpose_curator_handoff(purpose_queue)),
5139 limits: McpBriefLimits {
5140 folder_limit,
5141 file_limit,
5142 blocker_limit,
5143 purpose_limit,
5144 folders_truncated,
5145 files_truncated,
5146 purposes_truncated,
5147 },
5148 })
5149 })?;
5150 Ok(outcome.value)
5151 }
5152
5153 fn brief_policy(&self) -> McpBriefPolicy {
5155 McpBriefPolicy {
5156 nearest_project: Self::policy_state(self.allow_nearest_project),
5157 path_scope: self.path_scope(),
5158 }
5159 }
5160
5161 fn build_compact_session_brief(
5163 &self,
5164 mut params: AtlasSessionBriefParams,
5165 context: Option<RequestContext<RoleServer>>,
5166 ) -> Result<McpCompactSessionBrief, CliError> {
5167 let project_path = params.project_path.clone();
5168 let worktree = params.worktree.clone();
5169 params.compact = None;
5170 params
5171 .folder_limit
5172 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5173 params
5174 .file_limit
5175 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5176 params
5177 .blocker_limit
5178 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5179 params
5180 .purpose_limit
5181 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5182 let brief = self.build_session_brief(params, context)?;
5183 Ok(Self::compact_session_brief(
5184 &brief,
5185 project_path.as_deref(),
5186 worktree.as_deref(),
5187 ))
5188 }
5189
5190 fn compact_session_brief(
5192 brief: &McpSessionBrief,
5193 project_path: Option<&str>,
5194 worktree: Option<&str>,
5195 ) -> McpCompactSessionBrief {
5196 let omit_folders = !brief.files.is_empty();
5197 let folders_truncated =
5198 brief.limits.folders_truncated || (omit_folders && !brief.folders.is_empty());
5199 let compact_limits = McpCompactBriefLimits {
5200 folder_limit: brief.limits.folder_limit,
5201 file_limit: brief.limits.file_limit,
5202 blocker_limit: brief.limits.blocker_limit,
5203 purpose_limit: brief.limits.purpose_limit,
5204 folders_truncated,
5205 files_truncated: brief.limits.files_truncated,
5206 purposes_truncated: brief.limits.purposes_truncated,
5207 };
5208 let limits_are_default = is_compact_brief_default_limit(&compact_limits.folder_limit)
5209 && is_compact_brief_default_limit(&compact_limits.file_limit)
5210 && is_compact_brief_default_limit(&compact_limits.blocker_limit)
5211 && is_compact_brief_default_limit(&compact_limits.purpose_limit)
5212 && !compact_limits.folders_truncated
5213 && !compact_limits.files_truncated
5214 && !compact_limits.purposes_truncated;
5215 McpCompactSessionBrief {
5216 project: McpCompactBriefProject {
5217 worktree: brief.project.worktree.clone(),
5218 registration_id: brief.project.registration_id,
5219 root: brief.project.root.clone(),
5220 index_status: brief.project.index_status,
5221 },
5222 policy: (!brief.policy.is_default()).then_some(brief.policy),
5223 overview: brief
5224 .overview
5225 .as_ref()
5226 .map(|overview| McpCompactBriefOverview {
5227 files: overview.files,
5228 folders: overview.folders,
5229 }),
5230 folders: if omit_folders {
5231 Vec::new()
5232 } else {
5233 brief
5234 .folders
5235 .iter()
5236 .map(Self::compact_brief_candidate)
5237 .collect()
5238 },
5239 files: brief
5240 .files
5241 .iter()
5242 .map(Self::compact_brief_candidate)
5243 .collect(),
5244 blockers: (brief.blockers.total > 0).then_some(McpCompactBriefBlockers {
5245 total: brief.blockers.total,
5246 }),
5247 purpose_handoff: brief.purpose_handoff.as_ref().map(|handoff| {
5248 Self::compact_brief_purpose_handoff(
5249 handoff,
5250 project_path.map(ToString::to_string),
5251 worktree.map(ToString::to_string),
5252 )
5253 }),
5254 recommendations: brief
5255 .recommendations
5256 .iter()
5257 .map(Self::compact_brief_recommendation)
5258 .collect(),
5259 limits: (!limits_are_default).then_some(compact_limits),
5260 }
5261 }
5262
5263 fn brief_candidate(row: RankedNode) -> McpBriefCandidate {
5265 let purpose_agent_reviewed = row.node.purpose.agent_reviewed();
5266 McpBriefCandidate {
5267 path: row.node.node.path,
5268 kind: row.node.node.kind.to_string(),
5269 purpose_status: row.node.purpose.status,
5270 purpose_source: row.node.purpose.source,
5271 purpose_agent_reviewed,
5272 purpose: row.node.purpose.purpose,
5273 summary: row.node.summary,
5274 reasons: row.reasons,
5275 reason_codes: row.reason_codes,
5276 connection_counts: row.connection_counts,
5277 connections: row.connections,
5278 connections_truncated: row.connections_truncated,
5279 next_call: row.next_call,
5280 }
5281 }
5282
5283 fn compact_brief_candidate(row: &McpBriefCandidate) -> McpCompactBriefCandidate {
5285 let connections = row
5286 .connections
5287 .iter()
5288 .find(|connection| Self::brief_connection_is_crisp(connection))
5289 .cloned()
5290 .into_iter()
5291 .collect::<Vec<_>>();
5292 let next_call = if row.next_call.capability == NavigationNextCapability::Relations {
5293 NavigationNextCall {
5294 capability: NavigationNextCapability::Summary,
5295 path: row.path.clone(),
5296 }
5297 } else {
5298 row.next_call.clone()
5299 };
5300 McpCompactBriefCandidate {
5301 path: row.path.clone(),
5302 purpose_status: (!row.purpose_agent_reviewed).then_some(row.purpose_status),
5303 purpose_source: (!row.purpose_agent_reviewed).then_some(row.purpose_source),
5304 purpose_agent_reviewed: row.purpose_agent_reviewed,
5305 purpose: row.purpose.clone(),
5306 connections_truncated: row.connections_truncated
5307 || connections.len() < row.connections.len(),
5308 connections,
5309 next_call,
5310 }
5311 }
5312
5313 fn brief_connection_is_crisp(connection: &RankedConnection) -> bool {
5315 connection.kind != RankedConnectionKind::Import
5316 && !matches!(
5317 &connection.target,
5318 RankedConnectionTarget::Unresolved { .. }
5319 )
5320 }
5321
5322 fn compact_brief_purpose_handoff(
5324 handoff: &PurposeCuratorHandoff,
5325 project_path: Option<String>,
5326 worktree: Option<String>,
5327 ) -> McpCompactBriefPurposeHandoff {
5328 McpCompactBriefPurposeHandoff {
5329 agent_harness_expected: handoff.agent_harness_expected,
5330 recommended_subagent_reasoning: handoff.recommended_subagent_reasoning,
5331 instructions: handoff.instructions.first().cloned().into_iter().collect(),
5332 main_agent_fallback: handoff.main_agent_fallback,
5333 server_started_curator: handoff.server_started_curator,
5334 silent_on_success: handoff.silent_on_success,
5335 truncated: handoff.queue.truncated,
5336 next_call: McpCompactBriefRecommendation {
5337 kind: McpBriefRecommendationKind::PurposeQueue,
5338 target: MCP_TOOL_ATLAS_PURPOSE_QUEUE.to_string(),
5339 reason: MCP_BRIEF_REASON_PURPOSE_QUEUE.to_string(),
5340 arguments: Self::brief_call_arguments(
5341 project_path,
5342 worktree,
5343 &[(MCP_BRIEF_ARG_TASK, &handoff.queue.task)],
5344 Some((MCP_BRIEF_ARG_LIMIT, handoff.queue.limit)),
5345 ),
5346 },
5347 }
5348 }
5349
5350 fn compact_brief_recommendation(
5352 recommendation: &McpBriefRecommendation,
5353 ) -> McpCompactBriefRecommendation {
5354 let relation_to_summary =
5355 matches!(recommendation.kind, McpBriefRecommendationKind::Relations);
5356 let mut arguments = recommendation.arguments.clone();
5357 if let Some(object) = arguments.as_object_mut() {
5358 if relation_to_summary {
5359 object.remove(MCP_BRIEF_ARG_VIEW);
5360 }
5361 if relation_to_summary
5362 || matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
5363 {
5364 object.insert(
5365 MCP_BRIEF_ARG_COMPACT.to_string(),
5366 serde_json::Value::Bool(true),
5367 );
5368 }
5369 }
5370 McpCompactBriefRecommendation {
5371 kind: if relation_to_summary {
5372 McpBriefRecommendationKind::Summary
5373 } else {
5374 recommendation.kind
5375 },
5376 target: if relation_to_summary {
5377 MCP_TOOL_ATLAS_FILE_SUMMARY.to_string()
5378 } else {
5379 recommendation.target.clone()
5380 },
5381 reason: if relation_to_summary {
5382 MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string()
5383 } else {
5384 recommendation.reason.clone()
5385 },
5386 arguments,
5387 }
5388 }
5389
5390 fn brief_blockers(
5392 store: &AtlasStore,
5393 blocker_limit: usize,
5394 ) -> Result<McpBriefBlockers, CliError> {
5395 let query = HealthQuery {
5396 start_index: 0,
5397 limit: blocker_limit,
5398 category: None,
5399 severity: None,
5400 path_prefix: None,
5401 summary_only: false,
5402 scope: HealthScope::all(),
5403 };
5404 let page = store.unresolved_health_findings_page_current(&query)?;
5405 let total = page.total;
5406 let returned = page.returned;
5407 Ok(McpBriefBlockers {
5408 total,
5409 returned,
5410 truncated: returned < total,
5411 items: page
5412 .findings
5413 .into_iter()
5414 .map(|finding| McpBriefBlocker {
5415 id: finding.id,
5416 severity: finding.severity,
5417 category: finding.category,
5418 path: finding.path,
5419 related_path: finding.related_path,
5420 message: finding.message,
5421 recommendation: finding.recommendation,
5422 })
5423 .collect(),
5424 })
5425 }
5426
5427 fn missing_index_recommendations(
5429 project_path: Option<String>,
5430 worktree: Option<String>,
5431 ) -> Vec<McpBriefRecommendation> {
5432 vec![
5433 McpBriefRecommendation {
5434 kind: McpBriefRecommendationKind::Init,
5435 target: MCP_TOOL_ATLAS_INIT.to_string(),
5436 reason: MCP_BRIEF_REASON_SELECTED_INDEX_MISSING.to_string(),
5437 arguments: Self::target_arguments(project_path.clone(), worktree.clone()),
5438 },
5439 McpBriefRecommendation {
5440 kind: McpBriefRecommendationKind::FilesystemTools,
5441 target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
5442 reason: MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX.to_string(),
5443 arguments: Self::target_arguments(project_path, worktree),
5444 },
5445 ]
5446 }
5447
5448 fn indexed_project_recommendations(
5450 query: &str,
5451 next_navigation_call: Option<NavigationNextCall>,
5452 blocker_total: usize,
5453 blocker_limit: usize,
5454 project_path: Option<String>,
5455 worktree: Option<String>,
5456 ) -> Vec<McpBriefRecommendation> {
5457 let mut recommendations = match next_navigation_call {
5458 Some(next_call) if next_call.capability == NavigationNextCapability::Summary => {
5459 vec![McpBriefRecommendation {
5460 kind: McpBriefRecommendationKind::Summary,
5461 target: MCP_TOOL_ATLAS_FILE_SUMMARY.to_string(),
5462 reason: MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string(),
5463 arguments: Self::brief_call_arguments(
5464 project_path.clone(),
5465 worktree.clone(),
5466 &[(MCP_BRIEF_ARG_FILE, &next_call.path)],
5467 None,
5468 ),
5469 }]
5470 }
5471 Some(next_call) if next_call.capability == NavigationNextCapability::Relations => {
5472 vec![McpBriefRecommendation {
5473 kind: McpBriefRecommendationKind::Relations,
5474 target: MCP_TOOL_ATLAS_SYMBOL_RELATIONS.to_string(),
5475 reason: MCP_BRIEF_REASON_RANKED_FILE_RELATIONS.to_string(),
5476 arguments: Self::brief_call_arguments(
5477 project_path.clone(),
5478 worktree.clone(),
5479 &[
5480 (MCP_BRIEF_ARG_FILE, &next_call.path),
5481 (MCP_BRIEF_ARG_VIEW, MCP_SYMBOL_RELATION_VIEW_DETAILED),
5482 ],
5483 None,
5484 ),
5485 }]
5486 }
5487 _ if !query.trim().is_empty() => vec![McpBriefRecommendation {
5488 kind: McpBriefRecommendationKind::Search,
5489 target: MCP_TOOL_ATLAS_SEARCH.to_string(),
5490 reason: MCP_BRIEF_REASON_SEARCH_FALLBACK.to_string(),
5491 arguments: Self::brief_call_arguments(
5492 project_path.clone(),
5493 worktree.clone(),
5494 &[(MCP_BRIEF_ARG_PATTERN, query)],
5495 None,
5496 ),
5497 }],
5498 _ => vec![McpBriefRecommendation {
5499 kind: McpBriefRecommendationKind::FilesystemTools,
5500 target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
5501 reason: MCP_BRIEF_REASON_NO_FILE_CANDIDATE.to_string(),
5502 arguments: Self::target_arguments(project_path.clone(), worktree.clone()),
5503 }],
5504 };
5505 if blocker_total > 0 {
5506 recommendations.push(McpBriefRecommendation {
5507 kind: McpBriefRecommendationKind::Health,
5508 target: MCP_TOOL_ATLAS_HEALTH.to_string(),
5509 reason: MCP_BRIEF_REASON_HEALTH_BLOCKERS.to_string(),
5510 arguments: Self::brief_call_arguments(
5511 project_path,
5512 worktree,
5513 &[],
5514 Some((MCP_BRIEF_ARG_LIMIT, blocker_limit)),
5515 ),
5516 });
5517 }
5518 recommendations
5519 }
5520
5521 fn target_arguments(
5523 project_path: Option<String>,
5524 worktree: Option<String>,
5525 ) -> serde_json::Value {
5526 let mut arguments = serde_json::Map::new();
5527 if let Some(path) = project_path {
5528 arguments.insert(
5529 MCP_BRIEF_ARG_PROJECT_PATH.to_string(),
5530 serde_json::Value::String(path),
5531 );
5532 } else if let Some(alias) = worktree {
5533 arguments.insert(
5534 MCP_BRIEF_ARG_WORKTREE.to_string(),
5535 serde_json::Value::String(alias),
5536 );
5537 }
5538 serde_json::Value::Object(arguments)
5539 }
5540
5541 fn brief_call_arguments(
5543 project_path: Option<String>,
5544 worktree: Option<String>,
5545 string_args: &[(&'static str, &str)],
5546 usize_arg: Option<(&'static str, usize)>,
5547 ) -> serde_json::Value {
5548 let mut arguments = serde_json::Map::new();
5549 if let Some(path) = project_path {
5550 arguments.insert(
5551 MCP_BRIEF_ARG_PROJECT_PATH.to_string(),
5552 serde_json::Value::String(path),
5553 );
5554 } else if let Some(alias) = worktree {
5555 arguments.insert(
5556 MCP_BRIEF_ARG_WORKTREE.to_string(),
5557 serde_json::Value::String(alias),
5558 );
5559 }
5560 for (key, value) in string_args {
5561 arguments.insert(
5562 (*key).to_string(),
5563 serde_json::Value::String((*value).to_string()),
5564 );
5565 }
5566 if let Some((key, value)) = usize_arg {
5567 arguments.insert(key.to_string(), serde_json::json!(value));
5568 }
5569 serde_json::Value::Object(arguments)
5570 }
5571
5572 fn task_state_values() -> Vec<McpTaskState> {
5574 vec![
5575 McpTaskState::Pending,
5576 McpTaskState::Running,
5577 McpTaskState::Complete,
5578 McpTaskState::Failed,
5579 McpTaskState::Canceled,
5580 ]
5581 }
5582
5583 fn task_operation_values() -> Vec<McpTaskOperation> {
5585 vec![
5586 McpTaskOperation::Contract,
5587 McpTaskOperation::Scan,
5588 McpTaskOperation::WatchOnce,
5589 McpTaskOperation::SymbolsBuild,
5590 McpTaskOperation::Search,
5591 ]
5592 }
5593
5594 fn start_index_task<F>(
5596 &self,
5597 operation: McpTaskOperation,
5598 options: SymbolBuildOptions,
5599 result_ref: &'static str,
5600 work: F,
5601 ) -> Result<McpTaskStartResponse, CliError>
5602 where
5603 F: FnOnce(&IndexWorkControl, SymbolBuildOptions) -> Result<(), CliError> + Send + 'static,
5604 {
5605 let options = options.with_worker_ceiling(self.background_resources.workers_per_task);
5606 let control = index_work_control(&options);
5607 let mut task_id = MCP_INDEX_TASK_ID_PREFIX.to_string();
5608 task_id.push_str(
5609 &self
5610 .next_task_sequence
5611 .fetch_add(1, Ordering::Relaxed)
5612 .to_string(),
5613 );
5614 let now = mcp_unix_time_ms();
5615 {
5616 let mut registry = self
5617 .task_registry
5618 .write()
5619 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
5620 let active = registry.active_count();
5621 if active >= self.background_resources.task_limit {
5622 let mut message = MCP_INDEX_TASK_LIMIT_PREFIX.to_string();
5623 message.push_str(&self.background_resources.task_limit.to_string());
5624 message.push_str(MCP_INDEX_TASK_LIMIT_SUFFIX);
5625 return Err(CliError::Mcp(message));
5626 }
5627 registry.insert(McpTaskRecord {
5628 task_id: task_id.clone(),
5629 operation: operation.clone(),
5630 state: McpTaskState::Pending,
5631 created_at_ms: now,
5632 updated_at_ms: now,
5633 progress: Some(McpTaskProgress {
5634 current: None,
5635 total: None,
5636 message: Some(MCP_TASK_PROGRESS_ACCEPTED.to_string()),
5637 }),
5638 error: None,
5639 result_ref: None,
5640 cancelable: true,
5641 control: Some(control.clone()),
5642 });
5643 }
5644
5645 let registry = Arc::clone(&self.task_registry);
5646 let worker_task_id = task_id.clone();
5647 let mut worker_name = MCP_INDEX_WORKER_NAME_PREFIX.to_string();
5648 worker_name.push_str(&task_id);
5649 let spawn_result = thread::Builder::new().name(worker_name).spawn(move || {
5650 if let Ok(mut registry) = registry.write() {
5651 registry.update(&worker_task_id, |record| {
5652 record.state = McpTaskState::Running;
5653 record.updated_at_ms = mcp_unix_time_ms();
5654 record.progress = Some(McpTaskProgress {
5655 current: None,
5656 total: None,
5657 message: Some(MCP_TASK_PROGRESS_RUNNING.to_string()),
5658 });
5659 });
5660 }
5661 let outcome =
5662 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&control, options)));
5663 let (state, progress, error, completed_result_ref) = match outcome {
5664 Ok(Ok(())) => (
5665 McpTaskState::Complete,
5666 MCP_TASK_PROGRESS_COMPLETE,
5667 None,
5668 Some(result_ref.to_string()),
5669 ),
5670 Ok(Err(error)) => {
5671 let state = if task_error_is_canceled(&error) {
5672 McpTaskState::Canceled
5673 } else {
5674 McpTaskState::Failed
5675 };
5676 (
5677 state,
5678 if state == McpTaskState::Canceled {
5679 MCP_TASK_PROGRESS_CANCELED
5680 } else {
5681 MCP_TASK_PROGRESS_FAILED
5682 },
5683 Some(bounded_task_error(&error)),
5684 None,
5685 )
5686 }
5687 Err(_panic) => (
5688 McpTaskState::Failed,
5689 MCP_TASK_PROGRESS_FAILED,
5690 Some(MCP_INDEX_WORKER_PANIC_ERROR.to_string()),
5691 None,
5692 ),
5693 };
5694 if let Ok(mut registry) = registry.write() {
5695 registry.update(&worker_task_id, |record| {
5696 record.state = state;
5697 record.updated_at_ms = mcp_unix_time_ms();
5698 record.progress = Some(McpTaskProgress {
5699 current: None,
5700 total: None,
5701 message: Some(progress.to_string()),
5702 });
5703 record.error = error;
5704 record.result_ref = completed_result_ref;
5705 record.cancelable = false;
5706 record.control = None;
5707 });
5708 }
5709 });
5710 if let Err(source) = spawn_result {
5711 let mut spawn_error = MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX.to_string();
5712 spawn_error.push_str(&source.to_string());
5713 if let Ok(mut registry) = self.task_registry.write() {
5714 registry.update(&task_id, |record| {
5715 record.state = McpTaskState::Failed;
5716 record.updated_at_ms = mcp_unix_time_ms();
5717 record.progress = Some(McpTaskProgress {
5718 current: None,
5719 total: None,
5720 message: Some(MCP_TASK_PROGRESS_FAILED.to_string()),
5721 });
5722 record.error = Some(spawn_error.clone());
5723 record.cancelable = false;
5724 record.control = None;
5725 });
5726 }
5727 return Err(CliError::Mcp(spawn_error));
5728 }
5729
5730 Ok(McpTaskStartResponse {
5731 task_id,
5732 operation,
5733 state: McpTaskState::Pending,
5734 status_tool: MCP_TOOL_ATLAS_TASK_STATUS,
5735 cancel_tool: MCP_TOOL_ATLAS_TASK_CANCEL,
5736 })
5737 }
5738
5739 fn task_status(&self, task_id: String) -> Result<McpTaskStatusResponse, CliError> {
5741 let registry = self
5742 .task_registry
5743 .read()
5744 .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
5745 let task = registry.get(&task_id);
5746 Ok(McpTaskStatusResponse {
5747 task_id,
5748 lookup: if task.is_some() {
5749 McpTaskLookupStatus::Found
5750 } else {
5751 McpTaskLookupStatus::NotFound
5752 },
5753 states: Self::task_state_values(),
5754 operations: Self::task_operation_values(),
5755 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5756 task,
5757 })
5758 }
5759
5760 fn task_cancel(&self, task_id: String) -> Result<McpTaskCancelResponse, CliError> {
5762 let mut registry = self
5763 .task_registry
5764 .write()
5765 .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
5766 let Some(record) = registry.get(&task_id) else {
5767 return Ok(McpTaskCancelResponse {
5768 task_id,
5769 result: McpTaskCancelResult::NotFound,
5770 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5771 task: None,
5772 });
5773 };
5774 if matches!(
5775 record.state,
5776 McpTaskState::Complete | McpTaskState::Failed | McpTaskState::Canceled
5777 ) {
5778 return Ok(McpTaskCancelResponse {
5779 task_id,
5780 result: McpTaskCancelResult::AlreadyFinished,
5781 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5782 task: Some(record),
5783 });
5784 }
5785 if !record.cancelable {
5786 return Ok(McpTaskCancelResponse {
5787 task_id,
5788 result: McpTaskCancelResult::NotCancelable,
5789 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5790 task: Some(record),
5791 });
5792 }
5793 let task = registry.update(&task_id, |record| {
5794 if let Some(control) = &record.control {
5795 control.cancel();
5796 }
5797 record.updated_at_ms = mcp_unix_time_ms();
5798 record.progress = Some(McpTaskProgress {
5799 current: None,
5800 total: None,
5801 message: Some(MCP_TASK_PROGRESS_CANCELLATION_REQUESTED.to_string()),
5802 });
5803 record.cancelable = false;
5804 });
5805 Ok(McpTaskCancelResponse {
5806 task_id,
5807 result: McpTaskCancelResult::CancellationRequested,
5808 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5809 task,
5810 })
5811 }
5812
5813 fn lint_report_for_state(
5815 state: &McpProjectState,
5816 params: &AtlasLintParams,
5817 ) -> Result<crate::runtime::LintReport, CliError> {
5818 let config = Self::load_config_for_state(state)?;
5819 let purpose_level = Self::parse_purpose_lint_level(params.purpose_level.as_deref())?;
5820 lint_project(
5821 &config,
5822 &state.db_path,
5823 state.config_path.as_deref(),
5824 LintOptions {
5825 strict_folders: params.strict_folders.unwrap_or(false),
5826 report_untracked: params.report_untracked.unwrap_or(false),
5827 strict_untracked: params.strict_untracked.unwrap_or(false),
5828 },
5829 purpose_level,
5830 )
5831 }
5832
5833 fn startup_project_state(db_path: PathBuf, config_path: Option<PathBuf>) -> McpProjectState {
5835 let root = Self::startup_project_root(&db_path, config_path.as_deref());
5836 let config_path = config_path.filter(|path| Self::config_matches_project_root(&root, path));
5837 McpProjectState {
5838 root,
5839 db_path,
5840 config_path,
5841 worktree: None,
5842 }
5843 }
5844
5845 fn startup_project_root(db_path: &Path, config_path: Option<&Path>) -> PathBuf {
5847 if let Ok(root) = default_mcp_project_root(db_path, config_path) {
5848 return root;
5849 }
5850 if let Some(root) = Self::project_root_from_default_db_path(db_path) {
5851 return root;
5852 }
5853 std::env::current_dir()
5854 .ok()
5855 .and_then(|root| canonical_project_root(&root).ok())
5856 .unwrap_or_else(|| PathBuf::from(CURRENT_DIR_ALIAS))
5857 }
5858
5859 fn project_root_from_default_db_path(db_path: &Path) -> Option<PathBuf> {
5861 let atlas_dir = db_path.parent()?;
5862 if atlas_dir.file_name()? != PROJECTATLAS_DIR_NAME {
5863 return None;
5864 }
5865 let root = atlas_dir.parent()?;
5866 canonical_project_root(root)
5867 .ok()
5868 .or_else(|| Some(root.to_path_buf()))
5869 }
5870
5871 fn active_project_state(&self) -> Result<McpProjectState, CliError> {
5873 let state = self
5874 .project_state
5875 .read()
5876 .map(|state| state.clone())
5877 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
5878 canonical_source_project_root(&state.root)?;
5879 Ok(state)
5880 }
5881
5882 fn set_active_project_state(&self, state: McpProjectState) -> Result<(), CliError> {
5884 *self
5885 .project_state
5886 .write()
5887 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))? =
5888 state;
5889 Ok(())
5890 }
5891
5892 fn control_git_repository(&self) -> Result<GitRepositoryStructure, CliError> {
5894 self.control_git_repository_if_present()?.ok_or_else(|| {
5895 CliError::InvalidInput(MCP_ERROR_WORKTREE_CONTROL_REPOSITORY_REQUIRED.to_string())
5896 })
5897 }
5898
5899 fn control_git_repository_if_present(
5901 &self,
5902 ) -> Result<Option<GitRepositoryStructure>, CliError> {
5903 let repository = match discover_repository_structure(&self.control_state.root)? {
5904 RepositoryStructure::Git(repository) => repository,
5905 RepositoryStructure::NonGit { .. } => return Ok(None),
5906 RepositoryStructure::InvalidGit { issue, .. } => {
5907 return Err(CliError::InvalidInput(format!(
5908 "invalid control-repository Git evidence at '{}': {:?}",
5909 normalize_native_path_display(issue.path),
5910 issue.kind
5911 )));
5912 }
5913 };
5914 let control_present = repository.worktrees.iter().any(|entry| {
5915 matches!(
5916 &entry.state,
5917 GitWorktreeState::Active { root, .. } if root == &self.control_state.root
5918 )
5919 });
5920 if !control_present {
5921 return Err(CliError::InvalidInput(format!(
5922 "selected control root '{}' is not one active worktree in its reciprocal Git inventory",
5923 normalize_native_path_display(&self.control_state.root)
5924 )));
5925 }
5926 Ok(Some(repository))
5927 }
5928
5929 fn worktree_candidate_selector(entry: &GitWorktreeEntry) -> String {
5931 Self::worktree_candidate_selector_from_path(&entry.administrative_directory)
5932 }
5933
5934 fn worktree_candidate_selector_from_path(identity: &Path) -> String {
5936 if let Some(identity) = identity.to_str() {
5937 return Self::worktree_candidate_selector_from_identity(
5938 &normalize_native_path_display_str(identity),
5939 );
5940 }
5941 Self::worktree_candidate_selector_from_native_identity(
5942 identity.as_os_str().as_encoded_bytes(),
5943 )
5944 }
5945
5946 fn worktree_candidate_selector_from_identity(identity: &str) -> String {
5948 Self::worktree_candidate_selector_from_native_identity(identity.as_bytes())
5949 }
5950
5951 fn worktree_candidate_selector_from_native_identity(identity: &[u8]) -> String {
5953 let digest = blake3::hash(identity).to_hex();
5954 let mut selector = String::with_capacity(
5955 MCP_WORKTREE_SELECTOR_PREFIX.len() + MCP_WORKTREE_SELECTOR_DIGEST_CHARS,
5956 );
5957 selector.push_str(MCP_WORKTREE_SELECTOR_PREFIX);
5958 selector.push_str(&digest.as_str()[..MCP_WORKTREE_SELECTOR_DIGEST_CHARS]);
5959 selector
5960 }
5961
5962 fn worktree_candidate_selector_from_canonical_identity(
5966 identity: &CanonicalProjectRoot,
5967 ) -> String {
5968 Self::worktree_candidate_selector_from_path(identity.as_path())
5969 }
5970
5971 fn active_worktree_root(entry: &GitWorktreeEntry) -> Option<&Path> {
5973 match &entry.state {
5974 GitWorktreeState::Active { root, .. } => Some(root),
5975 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
5976 }
5977 }
5978
5979 fn worktree_candidate_matches(entry: &GitWorktreeEntry, selector: &str) -> bool {
5981 if Self::worktree_candidate_selector(entry) == selector {
5982 return true;
5983 }
5984 let root_name = Self::active_worktree_root(entry)
5985 .and_then(Path::file_name)
5986 .and_then(std::ffi::OsStr::to_str);
5987 let administrative_name = entry
5988 .administrative_directory
5989 .file_name()
5990 .and_then(std::ffi::OsStr::to_str);
5991 root_name.is_some_and(|name| name.eq_ignore_ascii_case(selector))
5992 || administrative_name.is_some_and(|name| name.eq_ignore_ascii_case(selector))
5993 }
5994
5995 fn matching_worktree_candidates<'a>(
5997 &'a self,
5998 repository: &'a GitRepositoryStructure,
5999 selector: &str,
6000 ) -> Vec<&'a GitWorktreeEntry> {
6001 repository
6002 .worktrees
6003 .iter()
6004 .filter(|entry| {
6005 Self::active_worktree_root(entry)
6006 .is_some_and(|root| root != self.control_state.root)
6007 })
6008 .filter(|entry| Self::worktree_candidate_matches(entry, selector))
6009 .collect()
6010 }
6011
6012 fn revalidate_worktree_candidate(
6014 &self,
6015 expected_repository: &GitRepositoryStructure,
6016 expected_entry: &GitWorktreeEntry,
6017 expected_identity: &str,
6018 ) -> Result<(GitRepositoryStructure, GitWorktreeEntry), CliError> {
6019 let expected_root = Self::active_worktree_root(expected_entry).ok_or_else(|| {
6020 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
6021 })?;
6022 let repository = self.control_git_repository()?;
6023 let entry = repository
6024 .worktrees
6025 .iter()
6026 .find(|entry| entry.administrative_directory == expected_entry.administrative_directory)
6027 .cloned()
6028 .ok_or_else(|| {
6029 CliError::InvalidInput(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
6030 })?;
6031 let root = Self::active_worktree_root(&entry).ok_or_else(|| {
6032 CliError::InvalidInput(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
6033 })?;
6034 let identity = git_administrative_identity(&entry.administrative_directory)?;
6035 if repository.common_directory != expected_repository.common_directory
6036 || entry.role != expected_entry.role
6037 || root != expected_root
6038 || identity != expected_identity
6039 {
6040 return Err(CliError::InvalidInput(
6041 MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string(),
6042 ));
6043 }
6044 Ok((repository, entry))
6045 }
6046
6047 fn worktree_candidate(
6049 common_directory: &Path,
6050 entry: &GitWorktreeEntry,
6051 ) -> Option<McpWorktreeCandidate> {
6052 let root = Self::active_worktree_root(entry)?;
6053 Some(McpWorktreeCandidate {
6054 selector: Self::worktree_candidate_selector(entry),
6055 path_display: if common_directory.to_str().is_some()
6056 && entry.administrative_directory.to_str().is_some()
6057 && root.to_str().is_some()
6058 {
6059 McpWorktreePathDisplayState::Available
6060 } else {
6061 McpWorktreePathDisplayState::Unavailable
6062 },
6063 root: lossless_project_root_display(root),
6064 role: entry.role.into(),
6065 })
6066 }
6067
6068 fn default_worktree_alias(root: &Path) -> Result<WorktreeAlias, CliError> {
6070 let name = root
6071 .file_name()
6072 .and_then(std::ffi::OsStr::to_str)
6073 .ok_or_else(|| CliError::InvalidInput(MCP_ERROR_WORKTREE_PATH_NON_UTF8.to_string()))?;
6074 WorktreeAlias::parse(&name.to_ascii_lowercase()).map_err(|source| {
6075 CliError::InvalidInput(format!(
6076 "selected worktree directory cannot be used as an alias; provide alias explicitly: {source}"
6077 ))
6078 })
6079 }
6080
6081 fn open_local_worktree_atlas(root: &Path) -> Result<Option<AtlasStore>, CliError> {
6083 let db_path = Self::projectatlas_db_path(root);
6084 if !db_path.exists() {
6085 return Ok(None);
6086 }
6087 Ok(Some(open_atlas_store_read_only_for_project(
6088 &db_path, root,
6089 )?))
6090 }
6091
6092 fn local_worktree_project_instance_id(
6094 store: &AtlasStore,
6095 db_path: &Path,
6096 ) -> Result<ProjectInstanceId, CliError> {
6097 let project_instance_id = store.project_instance_id()?.ok_or_else(|| {
6098 CliError::InvalidInput(format!(
6099 "worktree atlas '{}' has no exact project identity",
6100 normalize_native_path_display(db_path)
6101 ))
6102 })?;
6103 Ok(project_instance_id)
6104 }
6105
6106 fn local_worktree_usage_snapshot(
6108 store: &AtlasStore,
6109 db_path: &Path,
6110 project_instance_id: ProjectInstanceId,
6111 ) -> Result<WorktreeUsageSnapshot, CliError> {
6112 let snapshot = store.export_worktree_usage_snapshot()?;
6113 if snapshot.project_instance_id() != project_instance_id {
6114 return Err(CliError::InvalidInput(format!(
6115 "worktree atlas '{}' telemetry identity does not match its project identity",
6116 normalize_native_path_display(db_path)
6117 )));
6118 }
6119 Ok(snapshot)
6120 }
6121
6122 fn local_worktree_atlas(root: &Path) -> Result<Option<LocalWorktreeAtlas>, CliError> {
6124 let db_path = Self::projectatlas_db_path(root);
6125 let Some(store) = Self::open_local_worktree_atlas(root)? else {
6126 return Ok(None);
6127 };
6128 let project_instance_id = Self::local_worktree_project_instance_id(&store, &db_path)?;
6129 let snapshot = Self::local_worktree_usage_snapshot(&store, &db_path, project_instance_id)?;
6130 Ok(Some(LocalWorktreeAtlas {
6131 project_instance_id,
6132 snapshot,
6133 }))
6134 }
6135
6136 fn revalidate_local_worktree_atlas_identity(
6138 root: &Path,
6139 expected: Option<ProjectInstanceId>,
6140 ) -> Result<(), CliError> {
6141 let Some(expected) = expected else {
6142 return Ok(());
6143 };
6144 let db_path = Self::projectatlas_db_path(root);
6145 let Some(store) = Self::open_local_worktree_atlas(root)? else {
6146 return Err(CliError::InvalidInput(
6147 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6148 ));
6149 };
6150 if Self::local_worktree_project_instance_id(&store, &db_path)? != expected {
6151 return Err(CliError::InvalidInput(
6152 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6153 ));
6154 }
6155 Ok(())
6156 }
6157
6158 fn retire_registered_worktree(
6160 control: &AtlasStore,
6161 registration: &WorktreeRegistration,
6162 root: Option<&Path>,
6163 retired_at_epoch: u64,
6164 initial_blocker: Option<String>,
6165 ) -> Result<
6166 (
6167 WorktreeRegistration,
6168 Option<WorktreeUsageSyncState>,
6169 Option<String>,
6170 ),
6171 CliError,
6172 > {
6173 Self::retire_registered_worktree_with_pre_open(
6174 control,
6175 registration,
6176 root,
6177 retired_at_epoch,
6178 initial_blocker,
6179 || Ok(()),
6180 )
6181 }
6182
6183 fn retire_registered_worktree_with_pre_open<F>(
6185 control: &AtlasStore,
6186 registration: &WorktreeRegistration,
6187 root: Option<&Path>,
6188 retired_at_epoch: u64,
6189 initial_blocker: Option<String>,
6190 pre_open: F,
6191 ) -> Result<
6192 (
6193 WorktreeRegistration,
6194 Option<WorktreeUsageSyncState>,
6195 Option<String>,
6196 ),
6197 CliError,
6198 >
6199 where
6200 F: FnOnce() -> Result<(), CliError>,
6201 {
6202 control.with_active_worktree_registration(
6203 registration.registration_id,
6204 ®istration.alias,
6205 |guard| {
6206 let Some(root) = root else {
6207 let retired = guard.retire(retired_at_epoch)?;
6208 return Ok(Ok((retired, None, initial_blocker)));
6209 };
6210 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6211 return Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch)
6212 .map(Ok);
6213 }
6214 if let Err(error) = pre_open() {
6215 return Ok(Err(error));
6216 }
6217 let local = match Self::open_local_worktree_atlas(root) {
6218 Ok(local) => local,
6219 Err(error) => {
6220 return Self::classify_retirement_failure(
6221 guard,
6222 root,
6223 retired_at_epoch,
6224 error,
6225 );
6226 }
6227 };
6228 let Some(local) = local else {
6229 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6230 return Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch)
6231 .map(Ok);
6232 }
6233 if guard.registration().project_instance_id.is_some() {
6234 return Ok(Err(CliError::InvalidInput(
6235 MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING.to_string(),
6236 )));
6237 }
6238 let retired = guard.retire(retired_at_epoch)?;
6239 return Ok(Ok((retired, None, initial_blocker)));
6240 };
6241 let db_path = Self::projectatlas_db_path(root);
6242 let project_instance_id =
6243 match Self::local_worktree_project_instance_id(&local, &db_path) {
6244 Ok(project_instance_id) => project_instance_id,
6245 Err(error) => {
6246 return Self::classify_retirement_failure(
6247 guard,
6248 root,
6249 retired_at_epoch,
6250 error,
6251 );
6252 }
6253 };
6254 let finalization = match local.with_exclusive_worktree_usage_snapshot(|snapshot| {
6255 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6256 return Ok(None);
6257 }
6258 guard
6259 .retire_with_usage_snapshot(
6260 root,
6261 project_instance_id,
6262 snapshot,
6263 retired_at_epoch,
6264 )
6265 .map(Some)
6266 }) {
6267 Ok(finalization) => finalization,
6268 Err(error) => {
6269 return Self::classify_retirement_failure(
6270 guard,
6271 root,
6272 retired_at_epoch,
6273 error.into(),
6274 );
6275 }
6276 };
6277 if let Some((retired, synchronized)) = finalization {
6278 return Ok(Ok((retired, Some(synchronized), initial_blocker)));
6279 }
6280 Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch).map(Ok)
6281 },
6282 )?
6283 }
6284
6285 fn classify_retirement_failure(
6287 guard: &mut ActiveWorktreeRegistrationGuard<'_>,
6288 root: &Path,
6289 retired_at_epoch: u64,
6290 error: CliError,
6291 ) -> Result<
6292 Result<
6293 (
6294 WorktreeRegistration,
6295 Option<WorktreeUsageSyncState>,
6296 Option<String>,
6297 ),
6298 CliError,
6299 >,
6300 DbError,
6301 > {
6302 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6303 Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch).map(Ok)
6304 } else {
6305 Ok(Err(error))
6306 }
6307 }
6308
6309 fn retire_changed_worktree_lifecycle(
6311 guard: &mut ActiveWorktreeRegistrationGuard<'_>,
6312 retired_at_epoch: u64,
6313 ) -> Result<
6314 (
6315 WorktreeRegistration,
6316 Option<WorktreeUsageSyncState>,
6317 Option<String>,
6318 ),
6319 DbError,
6320 > {
6321 let retired = guard.retire(retired_at_epoch)?;
6322 Ok((
6323 retired,
6324 None,
6325 Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string()),
6326 ))
6327 }
6328
6329 fn reset_registered_worktree_index(
6331 &self,
6332 state: &McpProjectState,
6333 selection: &McpWorktreeSelection,
6334 include_mcp_config: bool,
6335 ) -> Result<ResetIndexReport, CliError> {
6336 self.reset_registered_worktree_index_with_post_validation(
6337 state,
6338 selection,
6339 include_mcp_config,
6340 || Ok(()),
6341 )
6342 }
6343
6344 fn reset_registered_worktree_index_with_post_validation<F>(
6346 &self,
6347 state: &McpProjectState,
6348 selection: &McpWorktreeSelection,
6349 include_mcp_config: bool,
6350 post_validation: F,
6351 ) -> Result<ResetIndexReport, CliError>
6352 where
6353 F: FnOnce() -> Result<(), CliError>,
6354 {
6355 let alias = WorktreeAlias::parse(&selection.alias)?;
6356 let registration_id =
6357 selection
6358 .registration_id
6359 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
6360 alias: selection.alias.clone(),
6361 })?;
6362 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
6363 Self::require_captured_control_identity(Some(selection), &control)?;
6364 let admission = crate::runtime::preflight_managed_reset(&state.db_path, &state.root)?;
6365 let database = crate::runtime::admitted_database_path(&state.db_path, admission.as_ref());
6366 match control.with_unbound_worktree_registration(registration_id, &alias, |registration| {
6367 require_registered_worktree_lifecycle(registration, &state.root)?;
6368 post_validation()?;
6369 reset_index_files_with_revalidation(
6370 database,
6371 include_mcp_config,
6372 admission.as_ref(),
6373 || require_registered_worktree_lifecycle(registration, &state.root),
6374 )
6375 }) {
6376 Ok(result) => result,
6377 Err(DbError::WorktreeRegistrationConflict { .. }) => Err(CliError::InvalidInput(
6378 MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED.to_string(),
6379 )),
6380 Err(error) => Err(error.into()),
6381 }
6382 }
6383
6384 fn worktree_administrative_path_identity(
6386 entry: &GitWorktreeEntry,
6387 ) -> Option<CanonicalProjectRoot> {
6388 match &entry.state {
6389 GitWorktreeState::Invalid { .. } => {
6390 CanonicalProjectRoot::from_persisted_path(entry.administrative_directory.clone())
6391 .ok()
6392 }
6393 _ => CanonicalProjectRoot::from_path(&entry.administrative_directory).ok(),
6394 }
6395 }
6396
6397 fn worktree_list_row(
6399 &self,
6400 common_directory: &Path,
6401 entry: &GitWorktreeEntry,
6402 registrations: &[WorktreeRegistration],
6403 ) -> McpWorktreeRow {
6404 let administrative_directory =
6405 lossless_native_path_display(&entry.administrative_directory);
6406 let common_identity = CanonicalProjectRoot::from_path(common_directory).ok();
6407 let administrative_identity = Self::worktree_administrative_path_identity(entry);
6408 let registration = registrations.iter().find(|registration| {
6409 registration.state == WorktreeRegistrationState::Active
6410 && common_identity
6411 .as_ref()
6412 .is_some_and(|identity| *identity == registration.git_common_directory_identity)
6413 && administrative_identity.as_ref().is_some_and(|identity| {
6414 *identity == registration.git_administrative_directory_identity
6415 })
6416 });
6417 let administrative_identity = git_administrative_identity(&entry.administrative_directory);
6418 let lifecycle_matches = registration.is_none_or(|registration| {
6419 administrative_identity
6420 .as_ref()
6421 .is_ok_and(|identity| identity == ®istration.git_administrative_identity)
6422 });
6423 let root = Self::active_worktree_root(entry);
6424 let display_root = match &entry.state {
6425 GitWorktreeState::Missing { .. } => {
6426 registration.map(|registration| registration.last_root_identity.as_path())
6427 }
6428 _ => root,
6429 };
6430 let path_display = if common_directory.to_str().is_some()
6431 && entry.administrative_directory.to_str().is_some()
6432 && display_root.is_none_or(|root| root.to_str().is_some())
6433 {
6434 McpWorktreePathDisplayState::Available
6435 } else {
6436 McpWorktreePathDisplayState::Unavailable
6437 };
6438 let control = root.is_some_and(|root| root == self.control_state.root);
6439 let alias = if control {
6440 Some(MCP_MAIN_WORKTREE_ALIAS.to_string())
6441 } else {
6442 registration.map(|registration| registration.alias.to_string())
6443 };
6444 let registration_state = if control {
6445 McpWorktreeRegistrationState::Control
6446 } else if registration.is_some() {
6447 McpWorktreeRegistrationState::Registered
6448 } else {
6449 McpWorktreeRegistrationState::Unregistered
6450 };
6451 let mut atlas_state = McpWorktreeAtlasState::Unavailable;
6452 let mut telemetry_state = McpWorktreeTelemetryState::Unavailable;
6453 let mut local_telemetry_revision = None;
6454 let mut project_instance_id = None;
6455 let mut blocker = (!lifecycle_matches)
6456 .then(|| MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
6457 .or_else(|| administrative_identity.err().map(|error| error.to_string()));
6458 if matches!(path_display, McpWorktreePathDisplayState::Unavailable) && blocker.is_none() {
6459 blocker = Some(MCP_ERROR_WORKTREE_PATH_NON_UTF8.to_string());
6460 }
6461 let git_state = match &entry.state {
6462 GitWorktreeState::Active { root, .. } => {
6463 if lifecycle_matches {
6464 match Self::local_worktree_atlas(root) {
6465 Ok(Some(local)) => {
6466 let identity_matches = registration
6467 .and_then(|registration| registration.project_instance_id)
6468 .is_none_or(|expected| expected == local.project_instance_id);
6469 if identity_matches {
6470 atlas_state = McpWorktreeAtlasState::Initialized;
6471 local_telemetry_revision = Some(local.snapshot.revision());
6472 project_instance_id = Some(local.project_instance_id.to_string());
6473 telemetry_state = if control {
6474 McpWorktreeTelemetryState::Control
6475 } else if let Some(registration) = registration {
6476 if local.snapshot.revision()
6477 > registration.accepted_telemetry_revision
6478 {
6479 McpWorktreeTelemetryState::Pending
6480 } else {
6481 McpWorktreeTelemetryState::Current
6482 }
6483 } else {
6484 McpWorktreeTelemetryState::Unregistered
6485 };
6486 } else {
6487 atlas_state = McpWorktreeAtlasState::Invalid;
6488 blocker = Some(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string());
6489 }
6490 }
6491 Ok(None) => {
6492 atlas_state = McpWorktreeAtlasState::Missing;
6493 telemetry_state = if registration.is_some() {
6494 McpWorktreeTelemetryState::MissingAtlas
6495 } else {
6496 McpWorktreeTelemetryState::Unregistered
6497 };
6498 }
6499 Err(error) => {
6500 atlas_state = McpWorktreeAtlasState::Invalid;
6501 blocker = Some(error.to_string());
6502 }
6503 }
6504 } else {
6505 atlas_state = McpWorktreeAtlasState::Invalid;
6506 telemetry_state = McpWorktreeTelemetryState::Unavailable;
6507 }
6508 McpGitWorktreeState::Active
6509 }
6510 GitWorktreeState::Missing { git_control_path } => {
6511 blocker = Some(format!(
6512 "Git registration target is missing at '{}'",
6513 normalize_native_path_display(git_control_path)
6514 ));
6515 McpGitWorktreeState::Missing
6516 }
6517 GitWorktreeState::Invalid { issue } => {
6518 blocker = Some(format!(
6519 "invalid Git evidence at '{}': {:?}",
6520 normalize_native_path_display(&issue.path),
6521 issue.kind
6522 ));
6523 McpGitWorktreeState::Invalid
6524 }
6525 };
6526 McpWorktreeRow {
6527 selector: Some(Self::worktree_candidate_selector(entry)),
6528 alias,
6529 role: entry.role.into(),
6530 path_display,
6531 git_state,
6532 registration: registration_state,
6533 administrative_directory,
6534 root: display_root.and_then(lossless_project_root_display),
6535 atlas_state,
6536 telemetry_state,
6537 accepted_telemetry_revision: registration
6538 .map(|registration| registration.accepted_telemetry_revision),
6539 local_telemetry_revision,
6540 project_instance_id,
6541 blocker,
6542 }
6543 }
6544
6545 fn missing_registered_worktree_row(registration: &WorktreeRegistration) -> McpWorktreeRow {
6547 let path_display = if registration
6548 .git_common_directory_identity
6549 .display_string()
6550 .is_ok()
6551 && registration.last_root_identity.display_string().is_ok()
6552 && registration
6553 .git_administrative_directory_identity
6554 .display_string()
6555 .is_ok()
6556 {
6557 McpWorktreePathDisplayState::Available
6558 } else {
6559 McpWorktreePathDisplayState::Unavailable
6560 };
6561 McpWorktreeRow {
6562 selector: Some(Self::worktree_candidate_selector_from_canonical_identity(
6563 ®istration.git_administrative_directory_identity,
6564 )),
6565 alias: Some(registration.alias.to_string()),
6566 role: McpGitWorktreeRole::Linked,
6567 path_display,
6568 git_state: McpGitWorktreeState::Missing,
6569 registration: McpWorktreeRegistrationState::Registered,
6570 administrative_directory: registration
6571 .git_administrative_directory_identity
6572 .display_string()
6573 .ok(),
6574 root: registration.last_root_identity.display_string().ok(),
6575 atlas_state: McpWorktreeAtlasState::Unavailable,
6576 telemetry_state: McpWorktreeTelemetryState::Unavailable,
6577 accepted_telemetry_revision: Some(registration.accepted_telemetry_revision),
6578 local_telemetry_revision: None,
6579 project_instance_id: registration
6580 .project_instance_id
6581 .map(|identity| identity.to_string()),
6582 blocker: Some(MCP_WORKTREE_MISSING_RETENTION_REASON.to_string()),
6583 }
6584 }
6585
6586 fn current_epoch_seconds() -> Result<u64, CliError> {
6588 u64::try_from(mcp_unix_time_ms().saturating_div(1_000)).map_err(|source| {
6589 CliError::InvalidInput(format!(
6590 "current Unix epoch exceeds the supported worktree registry range: {source}"
6591 ))
6592 })
6593 }
6594
6595 fn state_for_target(
6597 &self,
6598 project_path: Option<String>,
6599 worktree: Option<String>,
6600 ) -> Result<McpProjectState, CliError> {
6601 let state = self.state_for_target_with_config_validation(
6602 project_path,
6603 worktree,
6604 McpConfigValidation::Immediate,
6605 )?;
6606 Self::require_initialized_worktree_target(&state)?;
6607 Ok(state)
6608 }
6609
6610 fn federated_worktree_roots(
6612 &self,
6613 worktrees: &[String],
6614 ) -> Result<(Vec<PathBuf>, Vec<McpWorktreeSelection>), CliError> {
6615 validate_federated_root_count(worktrees.len()).map_err(CliError::Service)?;
6616 let mut roots = Vec::with_capacity(worktrees.len());
6617 let mut selections = Vec::with_capacity(worktrees.len());
6618 for worktree in worktrees {
6619 let state = self.state_for_target(None, Some(worktree.clone()))?;
6620 let selection = state.worktree.ok_or_else(|| {
6621 CliError::InvalidInput(MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string())
6622 })?;
6623 if selections
6624 .iter()
6625 .any(|captured: &McpWorktreeSelection| captured.alias == selection.alias)
6626 || roots.contains(&state.root)
6627 {
6628 return Err(CliError::Service(ServiceError::InvalidInput(
6629 MCP_ERROR_FEDERATED_TARGET_DUPLICATE.to_string(),
6630 )));
6631 }
6632 selections.push(selection);
6633 roots.push(state.root);
6634 }
6635 Ok((roots, selections))
6636 }
6637
6638 fn state_for_target_with_config_validation(
6640 &self,
6641 project_path: Option<String>,
6642 worktree: Option<String>,
6643 validation: McpConfigValidation,
6644 ) -> Result<McpProjectState, CliError> {
6645 let project_path = Self::normalized_optional_path(project_path);
6646 let worktree = worktree.map(|alias| alias.trim().to_string());
6647 if project_path.is_some() && worktree.is_some() {
6648 return Err(CliError::InvalidInput(
6649 MCP_WORKTREE_PROJECT_PATH_CONFLICT.to_string(),
6650 ));
6651 }
6652 let Some(alias) = worktree else {
6653 return self.state_for_project_path_with_config_validation(project_path, validation);
6654 };
6655 if alias.is_empty() {
6656 return Err(CliError::InvalidInput(
6657 MCP_ERROR_WORKTREE_SELECTOR_EMPTY.to_string(),
6658 ));
6659 }
6660 if alias == MCP_MAIN_WORKTREE_ALIAS {
6661 let mut state = self.control_state.clone();
6662 let project_instance_id = if state.db_path.is_file() {
6663 Self::open_read_store(&state)?.project_instance_id()?
6664 } else {
6665 None
6666 };
6667 state.worktree = Some(McpWorktreeSelection {
6668 alias,
6669 registration_id: None,
6670 project_instance_id,
6671 control_project_instance_id: project_instance_id,
6672 });
6673 return Ok(state);
6674 }
6675 let alias = WorktreeAlias::parse(&alias)?;
6676 self.resolve_registered_worktree(&alias, validation)
6677 }
6678
6679 fn resolve_registered_worktree(
6681 &self,
6682 alias: &WorktreeAlias,
6683 validation: McpConfigValidation,
6684 ) -> Result<McpProjectState, CliError> {
6685 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
6686 let control_project_instance_id = control.captured_project_binding()?.project_instance_id;
6687 let registration = control.worktree_registration(alias)?;
6688 let repository = self.control_git_repository()?;
6689 let common_identity = CanonicalProjectRoot::from_path(&repository.common_directory)
6690 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6691 if common_identity != registration.git_common_directory_identity {
6692 return Err(CliError::InvalidInput(format!(
6693 "registered worktree '{}' belongs to a different Git common directory",
6694 alias.as_str()
6695 )));
6696 }
6697 let entry = repository
6698 .worktrees
6699 .iter()
6700 .find(|entry| {
6701 Self::worktree_administrative_path_identity(entry).is_some_and(|identity| {
6702 identity == registration.git_administrative_directory_identity
6703 })
6704 })
6705 .ok_or_else(|| {
6706 CliError::InvalidInput(format!(
6707 "registered worktree '{}' is no longer present in the bounded Git inventory",
6708 alias.as_str()
6709 ))
6710 })?;
6711 let administrative_identity = git_administrative_identity(&entry.administrative_directory)?;
6712 if administrative_identity != registration.git_administrative_identity {
6713 return Err(CliError::InvalidInput(
6714 MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string(),
6715 ));
6716 }
6717 let root = match &entry.state {
6718 GitWorktreeState::Active { root, .. } => root.clone(),
6719 GitWorktreeState::Missing { .. } => {
6720 return Err(CliError::InvalidInput(format!(
6721 "registered worktree '{}' is missing; restore it through Git or unregister it",
6722 alias.as_str()
6723 )));
6724 }
6725 GitWorktreeState::Invalid { issue } => {
6726 return Err(CliError::InvalidInput(format!(
6727 "registered worktree '{}' has invalid Git evidence at '{}': {:?}",
6728 alias.as_str(),
6729 normalize_native_path_display(&issue.path),
6730 issue.kind
6731 )));
6732 }
6733 };
6734 if root == self.control_state.root {
6735 return Err(CliError::InvalidInput(
6736 MCP_ERROR_CONTROL_ALIAS_REQUIRED.to_string(),
6737 ));
6738 }
6739 CanonicalProjectRoot::from_path(&root).map_err(|source| {
6740 let mut message = MCP_ERROR_REGISTERED_WORKTREE_ROOT_INVALID_PREFIX.to_string();
6741 message.push_str(&source.to_string());
6742 CliError::InvalidInput(message)
6743 })?;
6744 let current_registry_root = CanonicalProjectRoot::from_path(&root)
6745 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6746 if let Some(expected) = registration.project_instance_id {
6751 let store = Self::open_local_worktree_atlas(&root)?.ok_or_else(|| {
6752 CliError::InvalidInput(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING.to_string())
6753 })?;
6754 let db_path = Self::projectatlas_db_path(&root);
6755 let observed = Self::local_worktree_project_instance_id(&store, &db_path)?;
6756 if observed != expected {
6757 return Err(CliError::InvalidInput(
6758 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6759 ));
6760 }
6761 }
6762 let registration_root_matches = current_registry_root == registration.last_root_identity;
6763 let registration = if registration_root_matches {
6764 registration
6765 } else {
6766 control.refresh_worktree_root(®istration, &root)?
6767 };
6768 let mut state = Self::project_state_from_root_with_config_validation(&root, validation)?;
6769 state.worktree = Some(McpWorktreeSelection {
6770 alias: alias.to_string(),
6771 registration_id: Some(registration.registration_id),
6772 project_instance_id: registration.project_instance_id,
6773 control_project_instance_id: Some(control_project_instance_id),
6774 });
6775 Ok(state)
6776 }
6777
6778 fn state_for_project_path_with_config_validation(
6780 &self,
6781 project_path: Option<String>,
6782 validation: McpConfigValidation,
6783 ) -> Result<McpProjectState, CliError> {
6784 let project_path = Self::normalized_optional_path(project_path);
6785 project_path.map_or_else(
6786 || self.active_project_state(),
6787 |path| {
6788 Self::project_state_from_root_with_config_validation(Path::new(&path), validation)
6789 },
6790 )
6791 }
6792
6793 fn nearest_project_enabled(&self, override_value: Option<bool>) -> bool {
6795 override_value.unwrap_or(self.allow_nearest_project)
6796 }
6797
6798 fn state_and_root_path(
6800 &self,
6801 project_path: Option<String>,
6802 worktree: Option<String>,
6803 path: Option<String>,
6804 nearest_project: bool,
6805 ) -> Result<(McpProjectState, PathBuf), CliError> {
6806 self.state_and_root_path_with_config_validation(
6807 project_path,
6808 worktree,
6809 path,
6810 nearest_project,
6811 McpConfigValidation::Immediate,
6812 )
6813 }
6814
6815 fn background_state_and_root_path(
6817 &self,
6818 project_path: Option<String>,
6819 worktree: Option<String>,
6820 path: Option<String>,
6821 nearest_project: bool,
6822 ) -> Result<(McpProjectState, PathBuf), CliError> {
6823 self.state_and_root_path_with_config_validation(
6824 project_path,
6825 worktree,
6826 path,
6827 nearest_project,
6828 McpConfigValidation::Deferred,
6829 )
6830 }
6831
6832 fn state_and_root_path_with_config_validation(
6834 &self,
6835 project_path: Option<String>,
6836 worktree: Option<String>,
6837 path: Option<String>,
6838 nearest_project: bool,
6839 validation: McpConfigValidation,
6840 ) -> Result<(McpProjectState, PathBuf), CliError> {
6841 let explicit_target = project_path.is_some() || worktree.is_some();
6842 let state = self.state_for_target_with_config_validation(
6843 project_path.clone(),
6844 worktree,
6845 validation,
6846 )?;
6847 Self::require_initialized_worktree_target(&state)?;
6848 let root = match (
6849 Self::normalized_optional_path(project_path),
6850 Self::normalized_optional_path(path),
6851 ) {
6852 (None, Some(path)) if !explicit_target => {
6853 match Self::path_or_project_root(&state, Some(path.clone())) {
6854 Ok(root) => root,
6855 Err(active_error) => {
6856 if !nearest_project {
6857 return Err(active_error);
6858 }
6859 if Self::absolute_path_inside_selected_root(&state, &path)? {
6860 return Err(active_error);
6861 }
6862 let Some(indexed_state) =
6863 Self::nearest_root_state_for_root_argument_with_config_validation(
6864 Path::new(&path),
6865 validation,
6866 )?
6867 else {
6868 return Err(active_error);
6869 };
6870 let root = indexed_state.root.clone();
6871 return Ok((indexed_state, root));
6872 }
6873 }
6874 }
6875 (_, path) => Self::path_or_project_root(&state, path)?,
6876 };
6877 Ok((state, root))
6878 }
6879
6880 fn absolute_path_inside_selected_root(
6882 state: &McpProjectState,
6883 path: &str,
6884 ) -> Result<bool, CliError> {
6885 let candidate = PathBuf::from(path);
6886 if !candidate.is_absolute() {
6887 return Ok(false);
6888 }
6889 let resolved = canonical_project_root(&candidate)?;
6890 Ok(resolved != state.root && resolved.starts_with(&state.root))
6891 }
6892
6893 fn nearest_root_state_for_root_argument_with_config_validation(
6895 path: &Path,
6896 validation: McpConfigValidation,
6897 ) -> Result<Option<McpProjectState>, CliError> {
6898 let Ok(addressed_root) = canonical_project_root(path) else {
6899 return Ok(None);
6900 };
6901 let Some(indexed_state) =
6902 Self::project_state_from_nearest_indexed_path_with_config_validation(path, validation)?
6903 else {
6904 return Ok(None);
6905 };
6906 if addressed_root == indexed_state.root {
6907 Ok(Some(indexed_state))
6908 } else {
6909 Ok(None)
6910 }
6911 }
6912
6913 fn state_and_file_key(
6915 &self,
6916 project_path: Option<&str>,
6917 worktree: Option<&str>,
6918 file: &str,
6919 nearest_project: bool,
6920 ) -> Result<McpResolvedRepoPath, CliError> {
6921 let state = self.state_for_target(
6922 project_path.map(ToString::to_string),
6923 worktree.map(ToString::to_string),
6924 )?;
6925 let file_path = PathBuf::from(&file);
6926 if !file_path.is_absolute() {
6927 let file_key = validated_repo_file_key(&file_path)
6928 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6929 return Ok(McpResolvedRepoPath {
6930 state,
6931 key: file_key,
6932 routed_project: false,
6933 });
6934 }
6935 if nearest_project && project_path.is_none() && worktree.is_none() {
6936 let resolved = Self::nearest_state_and_repo_key(&state, file)?.ok_or_else(|| {
6937 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
6938 })?;
6939 let file_key = validated_repo_file_key(Path::new(&resolved.key))
6940 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6941 return Ok(McpResolvedRepoPath {
6942 key: file_key,
6943 ..resolved
6944 });
6945 }
6946 if let Some(file_key) = Self::absolute_path_key_in_selected_project(&state, &file_path)? {
6947 let file_key = validated_repo_file_key(Path::new(&file_key))
6948 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6949 return Ok(McpResolvedRepoPath {
6950 state,
6951 key: file_key,
6952 routed_project: false,
6953 });
6954 }
6955 if project_path.is_some() || worktree.is_some() {
6956 return Err(Self::selected_project_path_error(
6957 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6958 ));
6959 }
6960 if !nearest_project {
6961 return Err(Self::selected_project_path_error(
6962 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6963 ));
6964 }
6965 Err(Self::selected_project_path_error(
6966 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6967 ))
6968 }
6969
6970 fn state_and_optional_file_key(
6972 &self,
6973 project_path: Option<&str>,
6974 worktree: Option<&str>,
6975 file: Option<&str>,
6976 nearest_project: bool,
6977 ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
6978 let Some(file) = file else {
6979 return self
6980 .state_for_target(
6981 project_path.map(ToString::to_string),
6982 worktree.map(ToString::to_string),
6983 )
6984 .map(|state| (state, None, false));
6985 };
6986 let resolved = self.state_and_file_key(project_path, worktree, file, nearest_project)?;
6987 Ok((resolved.state, Some(resolved.key), resolved.routed_project))
6988 }
6989
6990 fn state_and_optional_folder_filter(
6992 &self,
6993 project_path: Option<&str>,
6994 worktree: Option<&str>,
6995 folder: Option<&str>,
6996 nearest_project: bool,
6997 ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
6998 let state = self.state_for_target(
6999 project_path.map(ToString::to_string),
7000 worktree.map(ToString::to_string),
7001 )?;
7002 let Some(folder) = folder.map(str::trim).filter(|folder| !folder.is_empty()) else {
7003 return Ok((state, None, false));
7004 };
7005 let folder_path = PathBuf::from(&folder);
7006 if !folder_path.is_absolute() {
7007 let folder_filter = normalized_folder_filter(folder)?;
7008 return Ok((state, Some(folder_filter), false));
7009 }
7010 if nearest_project && project_path.is_none() && worktree.is_none() {
7011 let resolved = Self::nearest_state_and_repo_key(&state, folder)?.ok_or_else(|| {
7012 Self::selected_project_path_error(FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR)
7013 })?;
7014 let folder_filter = normalized_folder_filter(&resolved.key)?;
7015 return Ok((resolved.state, Some(folder_filter), resolved.routed_project));
7016 }
7017 if let Some(folder_filter) =
7018 Self::absolute_path_key_in_selected_project(&state, &folder_path)?
7019 {
7020 let folder_filter = normalized_folder_filter(&folder_filter)?;
7021 return Ok((state, Some(folder_filter), false));
7022 }
7023 if project_path.is_some() || worktree.is_some() {
7024 return Err(Self::selected_project_path_error(
7025 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
7026 ));
7027 }
7028 if !nearest_project {
7029 return Err(Self::selected_project_path_error(
7030 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
7031 ));
7032 }
7033 Err(Self::selected_project_path_error(
7034 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
7035 ))
7036 }
7037
7038 fn nearest_state_and_repo_key(
7040 active_state: &McpProjectState,
7041 path: &str,
7042 ) -> Result<Option<McpResolvedRepoPath>, CliError> {
7043 let path = Path::new(path);
7044 let absolute_path = McpAbsolutePath::canonicalize(path)?;
7045 let lexical_state = Self::project_state_from_nearest_lexical_indexed_path(path)?;
7046 let canonical_state = Self::project_state_from_nearest_indexed_path(path)?;
7047 Self::reject_ambiguous_nearest_project_path(
7048 path,
7049 lexical_state.as_ref(),
7050 canonical_state.as_ref(),
7051 &absolute_path,
7052 )?;
7053 let Some(state) = canonical_state else {
7054 return Ok(None);
7055 };
7056 if state.root == active_state.root {
7057 let key = McpSelectedRoot::from_state(active_state)
7058 .repo_key_for(&absolute_path)?
7059 .ok_or_else(|| {
7060 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
7061 })?;
7062 return Ok(Some(McpResolvedRepoPath {
7063 state: active_state.clone(),
7064 key: key.into_string(),
7065 routed_project: false,
7066 }));
7067 }
7068 let key = McpSelectedRoot::from_state(&state)
7069 .repo_key_for(&absolute_path)?
7070 .ok_or_else(|| {
7071 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
7072 })?;
7073 Ok(Some(McpResolvedRepoPath {
7074 state,
7075 key: key.into_string(),
7076 routed_project: true,
7077 }))
7078 }
7079
7080 fn absolute_path_key_in_selected_project(
7082 state: &McpProjectState,
7083 path: &Path,
7084 ) -> Result<Option<String>, CliError> {
7085 let absolute_path = McpAbsolutePath::canonicalize(path)?;
7086 McpSelectedRoot::from_state(state)
7087 .repo_key_for(&absolute_path)
7088 .map(|key| key.map(McpRepoKey::into_string))
7089 }
7090
7091 fn normalized_optional_path(path: Option<String>) -> Option<String> {
7093 path.map(|path| path.trim().to_string())
7094 .filter(|path| !path.is_empty())
7095 }
7096
7097 fn project_state_from_root(root: &Path) -> Result<McpProjectState, CliError> {
7099 Self::project_state_from_root_with_config_validation(root, McpConfigValidation::Immediate)
7100 }
7101
7102 fn project_state_from_root_with_config_validation(
7104 root: &Path,
7105 validation: McpConfigValidation,
7106 ) -> Result<McpProjectState, CliError> {
7107 let root = canonical_source_project_root(root)?;
7108 if !root.is_dir() {
7109 return Err(CliError::InvalidInput(format!(
7110 "project path '{}' is not a directory",
7111 root.display()
7112 )));
7113 }
7114 let db_path = Self::projectatlas_db_path(&root);
7115 let config_path = Self::config_path_for_project_root(&root, validation)?;
7116 Ok(McpProjectState {
7117 root,
7118 db_path,
7119 config_path,
7120 worktree: None,
7121 })
7122 }
7123
7124 fn project_state_from_nearest_indexed_path(
7126 path: &Path,
7127 ) -> Result<Option<McpProjectState>, CliError> {
7128 Self::project_state_from_nearest_indexed_path_with_config_validation(
7129 path,
7130 McpConfigValidation::Immediate,
7131 )
7132 }
7133
7134 fn project_state_from_nearest_indexed_path_with_config_validation(
7136 path: &Path,
7137 validation: McpConfigValidation,
7138 ) -> Result<Option<McpProjectState>, CliError> {
7139 let Ok(absolute_path) = McpAbsolutePath::canonicalize(path) else {
7140 return Ok(None);
7141 };
7142 let mut candidate = absolute_path.nearest_search_start();
7143 loop {
7144 if let Some(indexed_root) = Self::indexed_root_from_candidate(candidate) {
7145 let config_path =
7146 Self::config_path_for_project_root(&indexed_root.root, validation)?;
7147 return Ok(Some(McpProjectState {
7148 root: indexed_root.root,
7149 db_path: indexed_root.db_path,
7150 config_path,
7151 worktree: None,
7152 }));
7153 }
7154 let Some(parent) = candidate.parent() else {
7155 return Ok(None);
7156 };
7157 candidate = parent;
7158 }
7159 }
7160
7161 fn project_state_from_nearest_lexical_indexed_path(
7163 path: &Path,
7164 ) -> Result<Option<McpProjectState>, CliError> {
7165 Self::project_state_from_nearest_lexical_indexed_path_with_config_validation(
7166 path,
7167 McpConfigValidation::Immediate,
7168 )
7169 }
7170
7171 fn project_state_from_nearest_lexical_indexed_path_with_config_validation(
7173 path: &Path,
7174 validation: McpConfigValidation,
7175 ) -> Result<Option<McpProjectState>, CliError> {
7176 if !path.is_absolute() {
7177 return Ok(None);
7178 }
7179 let lexical_path = Self::lexically_normalized_absolute_path(path);
7180 let mut candidate = if lexical_path.is_dir() {
7181 lexical_path
7182 } else {
7183 lexical_path
7184 .parent()
7185 .unwrap_or(lexical_path.as_path())
7186 .to_path_buf()
7187 };
7188 loop {
7189 if let Some(indexed_root) = Self::indexed_root_from_lexical_candidate(&candidate) {
7190 let config_path =
7191 Self::config_path_for_project_root(&indexed_root.root, validation)?;
7192 return Ok(Some(McpProjectState {
7193 root: indexed_root.root,
7194 db_path: indexed_root.db_path,
7195 config_path,
7196 worktree: None,
7197 }));
7198 }
7199 let Some(parent) = candidate.parent() else {
7200 return Ok(None);
7201 };
7202 candidate = parent.to_path_buf();
7203 }
7204 }
7205
7206 fn indexed_root_from_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
7208 let Ok(root) = canonical_project_root(candidate) else {
7209 return None;
7210 };
7211 let Ok(root_identity) = CanonicalProjectRoot::from_path(&root) else {
7212 return None;
7213 };
7214 let db_path = Self::projectatlas_db_path(&root);
7215 if !db_path.is_file() || !Self::nearest_indexed_db_matches_root(&db_path, &root_identity) {
7216 return None;
7217 }
7218 Some(McpIndexedRoot { root, db_path })
7219 }
7220
7221 fn indexed_root_from_lexical_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
7223 if Self::path_has_symlink_component(candidate) {
7224 return None;
7225 }
7226 let Ok(root) = canonical_project_root(candidate) else {
7227 return None;
7228 };
7229 let Ok(candidate_identity) = CanonicalProjectRoot::from_path(candidate) else {
7230 return None;
7231 };
7232 let Ok(root_identity) = CanonicalProjectRoot::from_path(&root) else {
7233 return None;
7234 };
7235 if candidate_identity != root_identity {
7236 return None;
7237 }
7238 let db_path = Self::projectatlas_db_path(&root);
7239 if !db_path.is_file() || !Self::nearest_indexed_db_matches_root(&db_path, &root_identity) {
7240 return None;
7241 }
7242 Some(McpIndexedRoot { root, db_path })
7243 }
7244
7245 fn lexically_normalized_absolute_path(path: &Path) -> PathBuf {
7247 let mut normalized = PathBuf::new();
7248 for component in path.components() {
7249 match component {
7250 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
7251 Component::RootDir => normalized.push(component.as_os_str()),
7252 Component::CurDir => {}
7253 Component::ParentDir => {
7254 normalized.pop();
7255 }
7256 Component::Normal(segment) => normalized.push(segment),
7257 }
7258 }
7259 normalized
7260 }
7261
7262 fn path_has_symlink_component(path: &Path) -> bool {
7264 let mut current = PathBuf::new();
7265 for component in path.components() {
7266 match component {
7267 Component::Prefix(prefix) => current.push(prefix.as_os_str()),
7268 Component::RootDir => current.push(component.as_os_str()),
7269 Component::CurDir => {}
7270 Component::ParentDir => {
7271 current.pop();
7272 }
7273 Component::Normal(segment) => {
7274 current.push(segment);
7275 if fs::symlink_metadata(¤t)
7276 .is_ok_and(|metadata| Self::metadata_is_symlink_or_reparse_point(&metadata))
7277 {
7278 return true;
7279 }
7280 }
7281 }
7282 }
7283 false
7284 }
7285
7286 fn metadata_is_symlink_or_reparse_point(metadata: &fs::Metadata) -> bool {
7288 if metadata.file_type().is_symlink() {
7289 return true;
7290 }
7291 #[cfg(windows)]
7292 {
7293 use std::os::windows::fs::MetadataExt;
7294 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
7295 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
7296 }
7297 #[cfg(not(windows))]
7298 {
7299 false
7300 }
7301 }
7302
7303 fn reject_ambiguous_nearest_project_path(
7305 path: &Path,
7306 lexical_state: Option<&McpProjectState>,
7307 canonical_state: Option<&McpProjectState>,
7308 canonical_path: &McpAbsolutePath,
7309 ) -> Result<(), CliError> {
7310 if let (Some(lexical_state), Some(canonical_state)) = (lexical_state, canonical_state) {
7311 if lexical_state.root != canonical_state.root {
7312 return Err(Self::ambiguous_nearest_project_path_error(
7313 path,
7314 Some(lexical_state),
7315 Some(canonical_state),
7316 ));
7317 }
7318 return Ok(());
7319 }
7320 let lexical_path = Self::lexically_normalized_absolute_path(path);
7321 if Self::path_has_symlink_component(&lexical_path)
7322 || lexical_state.is_some_and(|state| !canonical_path.as_path().starts_with(&state.root))
7323 {
7324 return Err(Self::ambiguous_nearest_project_path_error(
7325 path,
7326 lexical_state,
7327 canonical_state,
7328 ));
7329 }
7330 Ok(())
7331 }
7332
7333 fn ambiguous_nearest_project_path_error(
7335 path: &Path,
7336 lexical_state: Option<&McpProjectState>,
7337 canonical_state: Option<&McpProjectState>,
7338 ) -> CliError {
7339 let lexical_root = lexical_state.map_or_else(
7340 || MCP_NO_ROOT_PLACEHOLDER.to_string(),
7341 |state| normalize_native_path_display(&state.root),
7342 );
7343 let resolved_root = canonical_state.map_or_else(
7344 || MCP_NO_ROOT_PLACEHOLDER.to_string(),
7345 |state| normalize_native_path_display(&state.root),
7346 );
7347 let path_display = normalize_native_path_display(path);
7348 let mut message = String::new();
7349 message.push_str(AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR);
7350 message.push_str(MCP_ERROR_FOR_PATH_FRAGMENT);
7351 message.push_str(&path_display);
7352 message.push_str(MCP_ERROR_LEXICAL_ROOT_FRAGMENT);
7353 message.push_str(&lexical_root);
7354 message.push_str(MCP_ERROR_RESOLVED_ROOT_FRAGMENT);
7355 message.push_str(&resolved_root);
7356 message.push_str(MCP_ERROR_GUIDANCE_FRAGMENT);
7357 message.push_str(OUTSIDE_SELECTED_PROJECT_GUIDANCE);
7358 CliError::InvalidInput(message)
7359 }
7360
7361 fn nearest_indexed_db_matches_root(db_path: &Path, root: &CanonicalProjectRoot) -> bool {
7363 let Ok(admission) = crate::runtime::preflight_managed_database(db_path, root.as_path())
7364 else {
7365 return false;
7366 };
7367 let db_path = crate::runtime::admitted_database_path(db_path, admission.as_ref());
7368 match read_project_root_identity_read_only(db_path) {
7369 Ok(Some(stored_root)) => Self::nearest_existing_roots_match(&stored_root, root),
7370 Ok(None) => {
7371 let Ok(Some(legacy_root)) = read_legacy_project_root_candidate_read_only(db_path)
7372 else {
7373 return false;
7374 };
7375 let Ok(legacy_root) = CanonicalProjectRoot::from_path(Path::new(&legacy_root))
7376 else {
7377 return false;
7378 };
7379 Self::nearest_existing_roots_match(&legacy_root, root)
7380 }
7381 Err(_) => false,
7382 }
7383 }
7384
7385 fn nearest_existing_roots_match(
7387 persisted_root: &CanonicalProjectRoot,
7388 selected_root: &CanonicalProjectRoot,
7389 ) -> bool {
7390 let Ok(persisted_root) = CanonicalProjectRoot::from_path(persisted_root.as_path()) else {
7391 return false;
7392 };
7393 let Ok(selected_root) = CanonicalProjectRoot::from_path(selected_root.as_path()) else {
7394 return false;
7395 };
7396 persisted_root == selected_root
7397 }
7398
7399 fn projectatlas_db_path(root: &Path) -> PathBuf {
7401 root.join(PROJECTATLAS_DIR_NAME)
7402 .join(PROJECTATLAS_DB_FILE_NAME)
7403 }
7404
7405 fn projectatlas_nested_config_path(root: &Path) -> PathBuf {
7407 root.join(PROJECTATLAS_DIR_NAME)
7408 .join(PROJECTATLAS_CONFIG_FILE_NAME)
7409 }
7410
7411 fn projectatlas_flat_config_path(root: &Path) -> PathBuf {
7413 root.join(PROJECTATLAS_FLAT_CONFIG_FILE_NAME)
7414 }
7415
7416 fn config_path_for_project_root(
7418 root: &Path,
7419 validation: McpConfigValidation,
7420 ) -> Result<Option<PathBuf>, CliError> {
7421 for config_path in [
7422 Self::projectatlas_nested_config_path(root),
7423 Self::projectatlas_flat_config_path(root),
7424 ] {
7425 if config_path.exists() {
7426 if validation == McpConfigValidation::Immediate {
7427 Self::validate_project_config_root(root, &config_path)?;
7428 }
7429 return Ok(Some(config_path));
7430 }
7431 }
7432 Ok(None)
7433 }
7434
7435 fn validate_project_config_root(root: &Path, config_path: &Path) -> Result<(), CliError> {
7437 let config = load_atlas_config(Some(config_path))?;
7438 let config_root = canonical_project_root(&config.root)?;
7439 if config_root != root {
7440 return Err(config_root_mismatch_error(config_path, &config_root, root));
7441 }
7442 Ok(())
7443 }
7444
7445 fn config_matches_project_root(root: &Path, config_path: &Path) -> bool {
7447 Self::validate_project_config_root(root, config_path).is_ok()
7448 }
7449
7450 fn render_project_state(state: &McpProjectState) -> Result<String, CliError> {
7452 let payload = McpProjectStateResponse {
7453 project: Self::project_state_payload(state),
7454 };
7455 Self::encode_serialized_payload(payload)
7456 }
7457
7458 fn project_state_payload(state: &McpProjectState) -> McpProjectStatePayload {
7460 McpProjectStatePayload {
7461 worktree: state
7462 .worktree
7463 .as_ref()
7464 .map(|selection| selection.alias.clone()),
7465 registration_id: state
7466 .worktree
7467 .as_ref()
7468 .and_then(|selection| selection.registration_id),
7469 root: lossless_project_root_display(&state.root),
7470 db: lossless_native_path_display(&state.db_path),
7471 config: state
7472 .config_path
7473 .as_ref()
7474 .and_then(|path| lossless_native_path_display(path)),
7475 status: McpProjectStatus::Active,
7476 }
7477 }
7478
7479 fn with_selected_project_audit(
7481 state: &McpProjectState,
7482 routed_project: bool,
7483 toon: String,
7484 ) -> Result<String, CliError> {
7485 if !routed_project {
7486 return Ok(toon);
7487 }
7488 let prefix = Self::encode_named_payload(
7489 MCP_PAYLOAD_SELECTED_PROJECT,
7490 &Self::project_state_payload(state),
7491 )?;
7492 let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
7493 audited.push_str(&prefix);
7494 audited.push('\n');
7495 audited.push_str(&toon);
7496 Ok(audited)
7497 }
7498
7499 fn with_selected_project_audit_controlled(
7501 state: &McpProjectState,
7502 routed_project: bool,
7503 toon: String,
7504 control: &IndexWorkControl,
7505 ) -> Result<String, CliError> {
7506 control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
7507 if !routed_project {
7508 return Ok(toon);
7509 }
7510 let prefix = controlled_named_output(
7511 OutputFormat::Toon,
7512 MCP_PAYLOAD_SELECTED_PROJECT,
7513 &Self::project_state_payload(state),
7514 control,
7515 )?;
7516 let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
7517 audited.push_str(&prefix);
7518 audited.push('\n');
7519 audited.push_str(&toon);
7520 control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
7521 Ok(audited)
7522 }
7523
7524 fn path_or_project_root(
7526 state: &McpProjectState,
7527 path: Option<String>,
7528 ) -> Result<PathBuf, CliError> {
7529 let Some(value) = path else {
7530 return Ok(state.root.clone());
7531 };
7532 if value.is_empty() {
7533 return Ok(state.root.clone());
7534 }
7535 let original = value.clone();
7536 let candidate = PathBuf::from(value);
7537 let resolved = if candidate.is_absolute() {
7538 candidate
7539 } else {
7540 state.root.join(candidate)
7541 };
7542 let resolved = canonical_project_root(&resolved)?;
7543 if resolved == state.root {
7544 Ok(resolved)
7545 } else if resolved.starts_with(&state.root) {
7546 let resolved_display = resolved.display();
7547 let project_root_display = state.root.display();
7548 Err(CliError::InvalidInput(format!(
7549 "MCP path '{original}' resolves to '{resolved_display}', not the selected project root '{project_root_display}'; {SELECTED_ROOT_ASSERTION_GUIDANCE}"
7550 )))
7551 } else {
7552 let resolved_display = resolved.display();
7553 let project_root_display = state.root.display();
7554 Err(CliError::InvalidInput(format!(
7555 "MCP path '{original}' resolves to '{resolved_display}', outside the selected project root '{project_root_display}'; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"
7556 )))
7557 }
7558 }
7559
7560 fn preflight_purpose_path(state: &McpProjectState, path: &str) -> Result<String, CliError> {
7562 let node_key = validated_repo_node_key(std::path::Path::new(path))
7563 .map_err(Self::selected_project_path_error)?;
7564 let store = Self::open_read_store(state)?;
7565 let indexed = store.load_node_by_path(&node_key)?.is_some();
7566 store.finish_index_read_snapshot()?;
7567 let source = state.root.join(&node_key);
7568 if !indexed
7569 && !source.try_exists().map_err(|source_error| CliError::Io {
7570 path: source,
7571 source: source_error,
7572 })?
7573 {
7574 return Err(CliError::InvalidInput(format!(
7575 "path {node_key:?} is not indexed in the MCP-bound project"
7576 )));
7577 }
7578 Ok(node_key)
7579 }
7580
7581 fn require_indexed_purpose_path(store: &AtlasStore, node_key: &str) -> Result<(), CliError> {
7583 if store.load_node_by_path(node_key)?.is_none() {
7584 return Err(CliError::InvalidInput(format!(
7585 "path {node_key:?} is not indexed in the MCP-bound project"
7586 )));
7587 }
7588 Ok(())
7589 }
7590
7591 fn selected_project_path_error(message: impl std::fmt::Display) -> CliError {
7593 CliError::InvalidInput(format!("{message}; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"))
7594 }
7595
7596 fn query_or_empty(query: Option<String>) -> String {
7598 query.unwrap_or_default()
7599 }
7600
7601 fn encode_serialized_payload<T>(payload: T) -> Result<String, CliError>
7603 where
7604 T: Serialize,
7605 {
7606 Ok(encode_agent_payload(&serde_json::to_value(payload)?))
7607 }
7608
7609 fn encode_named_payload<T>(key: &str, payload: &T) -> Result<String, CliError>
7611 where
7612 T: Serialize,
7613 {
7614 let mut object = serde_json::Map::new();
7615 object.insert(key.to_string(), serde_json::to_value(payload)?);
7616 Ok(encode_agent_payload(&serde_json::Value::Object(object)))
7617 }
7618
7619 fn encode_two_named_payloads<T, U>(
7621 first_key: &str,
7622 first_payload: &T,
7623 second_key: &str,
7624 second_payload: &U,
7625 ) -> Result<String, CliError>
7626 where
7627 T: Serialize,
7628 U: Serialize,
7629 {
7630 let mut object = serde_json::Map::new();
7631 object.insert(first_key.to_string(), serde_json::to_value(first_payload)?);
7632 object.insert(
7633 second_key.to_string(),
7634 serde_json::to_value(second_payload)?,
7635 );
7636 Ok(encode_agent_payload(&serde_json::Value::Object(object)))
7637 }
7638
7639 fn encode_error_payload(error: &CliError) -> String {
7641 if let Some(response) = crate::legacy_relation_error_response(error) {
7642 return encode_agent_payload(&response);
7643 }
7644 let schema_version_mismatch = schema_version_mismatch_payload(error);
7645 let schema_migration_required = schema_migration_required_payload(error);
7646 let message = schema_migration_required.as_ref().map_or_else(
7647 || error.to_string(),
7648 SchemaMigrationRequiredPayload::message,
7649 );
7650 let (
7651 kind,
7652 refresh_required,
7653 init_required,
7654 worktree_required,
7655 verification_incomplete,
7656 project_mismatch,
7657 database_filesystem,
7658 search_capability,
7659 next,
7660 ) = match error {
7661 CliError::AtlasMap(crate::atlas_map::AtlasMapError::ResponseLimitExceeded {
7662 ..
7663 }) => (
7664 AgentErrorKind::ResponseLimitExceeded,
7665 None,
7666 None,
7667 None,
7668 None,
7669 None,
7670 None,
7671 None,
7672 None,
7673 ),
7674 CliError::InitRequired(report) => (
7675 AgentErrorKind::InitRequired,
7676 None,
7677 Some(report.as_ref().clone()),
7678 None,
7679 None,
7680 None,
7681 None,
7682 None,
7683 Some(McpNextCall {
7684 tool: MCP_TOOL_ATLAS_INIT,
7685 project_path: report
7686 .worktree
7687 .is_none()
7688 .then(|| report.project_root.clone())
7689 .flatten(),
7690 worktree: report.worktree.clone(),
7691 }),
7692 ),
7693 CliError::WorktreeRequired(report) => (
7694 AgentErrorKind::WorktreeRequired,
7695 None,
7696 None,
7697 Some(report.as_ref().clone()),
7698 None,
7699 None,
7700 None,
7701 None,
7702 None,
7703 ),
7704 CliError::RefreshRequired(report) => (
7705 AgentErrorKind::RefreshRequired,
7706 Some(report.as_ref().clone()),
7707 None,
7708 None,
7709 None,
7710 None,
7711 None,
7712 None,
7713 Some(McpNextCall {
7714 tool: MCP_TOOL_ATLAS_WATCH_ONCE,
7715 project_path: report
7716 .worktree
7717 .is_none()
7718 .then(|| report.project_root.clone())
7719 .flatten(),
7720 worktree: report.worktree.clone(),
7721 }),
7722 ),
7723 CliError::VerificationIncomplete(report) => (
7724 AgentErrorKind::VerificationIncomplete,
7725 None,
7726 None,
7727 None,
7728 Some(report.as_ref().clone()),
7729 None,
7730 None,
7731 None,
7732 None,
7733 ),
7734 CliError::ProjectMismatch(report) => (
7735 AgentErrorKind::ProjectMismatch,
7736 None,
7737 None,
7738 None,
7739 None,
7740 Some(report.as_ref().clone()),
7741 None,
7742 None,
7743 None,
7744 ),
7745 CliError::Service(ServiceError::SearchCapabilityUnavailable {
7746 requested_mode,
7747 state,
7748 guidance,
7749 }) => (
7750 AgentErrorKind::SearchCapabilityUnavailable,
7751 None,
7752 None,
7753 None,
7754 None,
7755 None,
7756 None,
7757 Some(crate::SearchCapabilityErrorPayload {
7758 requested_mode: *requested_mode,
7759 state,
7760 recovery: guidance,
7761 }),
7762 None,
7763 ),
7764 _ if schema_version_mismatch.is_some() => (
7765 AgentErrorKind::SchemaVersionMismatch,
7766 None,
7767 None,
7768 None,
7769 None,
7770 None,
7771 None,
7772 None,
7773 None,
7774 ),
7775 _ if schema_migration_required.is_some() => (
7776 AgentErrorKind::SchemaMigrationRequired,
7777 None,
7778 None,
7779 None,
7780 None,
7781 None,
7782 None,
7783 None,
7784 None,
7785 ),
7786 _ => database_filesystem_error_payload(error).map_or(
7787 (
7788 AgentErrorKind::Error,
7789 None,
7790 None,
7791 None,
7792 None,
7793 None,
7794 None,
7795 None,
7796 None,
7797 ),
7798 |(kind, database_filesystem)| {
7799 (
7800 kind,
7801 None,
7802 None,
7803 None,
7804 None,
7805 None,
7806 Some(database_filesystem),
7807 None,
7808 None,
7809 )
7810 },
7811 ),
7812 };
7813 let payload = McpErrorResponse {
7814 error: McpErrorPayload {
7815 kind,
7816 message,
7817 refresh_required,
7818 init_required,
7819 worktree_required,
7820 verification_incomplete,
7821 project_mismatch,
7822 database_filesystem,
7823 schema_version_mismatch,
7824 schema_migration_required,
7825 search_capability,
7826 next,
7827 },
7828 };
7829 serde_json::to_value(payload).map_or_else(
7830 |source| {
7831 let mut message = MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX.to_string();
7832 message.push_str(&source.to_string());
7833 message
7834 },
7835 |value| encode_agent_payload(&value),
7836 )
7837 }
7838
7839 fn as_mcp_text(result: Result<String, CliError>) -> McpToolTextResult {
7841 match result {
7842 Ok(text) => McpToolTextResult(Ok(text)),
7843 Err(error) => {
7844 if let Some(response) = crate::legacy_relation_error_response(&error) {
7845 return McpToolTextResult(Err(encode_agent_payload(&response)));
7846 }
7847 let payload = Self::encode_error_payload(&error);
7848 if schema_version_mismatch_payload(&error).is_some() {
7849 McpToolTextResult(Err(payload))
7850 } else {
7851 McpToolTextResult(Ok(payload))
7852 }
7853 }
7854 }
7855 }
7856}
7857
7858fn health_query_from_params(
7860 params: &AtlasHealthParams,
7861 scope: HealthScope,
7862) -> Result<HealthQuery, CliError> {
7863 Ok(HealthQuery {
7864 start_index: params.start_index.unwrap_or(0),
7865 limit: params
7866 .limit
7867 .filter(|value| *value > 0)
7868 .unwrap_or(DEFAULT_HEALTH_LIMIT)
7869 .min(MAX_HEALTH_LIMIT),
7870 category: trimmed_filter(params.category.as_deref()),
7871 severity: trimmed_filter(params.severity.as_deref())
7872 .as_deref()
7873 .map(parse_health_severity)
7874 .transpose()?,
7875 path_prefix: trimmed_filter(params.path_prefix.as_deref())
7876 .map(|value| normalize_repo_path_prefix(&value)),
7877 summary_only: params.summary_only.unwrap_or(false),
7878 scope,
7879 })
7880}
7881
7882fn has_coverage_filters(params: &AtlasHealthParams) -> bool {
7884 params.parser.is_some()
7885 || params.provider.is_some()
7886 || params.relation.is_some()
7887 || params.coverage_state.is_some()
7888 || params.reason.is_some()
7889}
7890
7891fn coverage_query_from_params(
7893 params: &AtlasHealthParams,
7894) -> Result<RepositoryCoverageQuery, CliError> {
7895 let limit = params
7896 .limit
7897 .filter(|value| *value > 0)
7898 .unwrap_or(DEFAULT_HEALTH_LIMIT)
7899 .min(COVERAGE_PAGE_MAX_LIMIT as usize);
7900 Ok(RepositoryCoverageQuery {
7901 start_index: u32::try_from(params.start_index.unwrap_or(0)).map_err(|error| {
7902 let mut message = String::from(MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX);
7903 message.push_str(&error.to_string());
7904 CliError::InvalidInput(message)
7905 })?,
7906 limit: u32::try_from(limit).map_err(|error| {
7907 let mut message = String::from(MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX);
7908 message.push_str(&error.to_string());
7909 CliError::InvalidInput(message)
7910 })?,
7911 path_prefix: trimmed_filter(params.path_prefix.as_deref())
7912 .map(|value| normalize_repo_path_prefix(&value)),
7913 parser: trimmed_filter(params.parser.as_deref())
7914 .as_deref()
7915 .map(parse_coverage_parser)
7916 .transpose()?,
7917 provider: trimmed_filter(params.provider.as_deref())
7918 .as_deref()
7919 .map(parse_coverage_parser)
7920 .transpose()?,
7921 relation: trimmed_filter(params.relation.as_deref())
7922 .as_deref()
7923 .map(parse_coverage_relation)
7924 .transpose()?,
7925 state: trimmed_filter(params.coverage_state.as_deref())
7926 .as_deref()
7927 .map(parse_coverage_state)
7928 .transpose()?,
7929 reason: trimmed_filter(params.reason.as_deref()),
7930 })
7931}
7932
7933fn purpose_queue_scope(params: &AtlasHealthParams) -> HealthScope {
7935 match (
7936 params.include_assets.unwrap_or(false),
7937 params.include_low_priority_files.unwrap_or(false),
7938 ) {
7939 (false, false) => HealthScope::purpose_default(),
7940 (true, false) => HealthScope::purpose_with_assets(),
7941 (false, true) => HealthScope::purpose_with_source_files(),
7942 (true, true) => HealthScope::all(),
7943 }
7944}
7945
7946fn trimmed_filter(value: Option<&str>) -> Option<String> {
7948 value
7949 .map(str::trim)
7950 .filter(|value| !value.is_empty())
7951 .map(ToString::to_string)
7952}
7953
7954fn parse_health_severity(value: &str) -> Result<Severity, CliError> {
7956 let trimmed = value.trim();
7957 trimmed.parse::<Severity>().map_err(|_source| {
7958 let expected = expected_health_severity_names();
7959 CliError::InvalidInput(format!(
7960 "invalid health severity '{trimmed}'; expected {expected}"
7961 ))
7962 })
7963}
7964
7965fn expected_health_severity_names() -> String {
7967 let mut expected = Severity::Info.as_str().to_string();
7968 expected.push_str(SEVERITY_EXPECTED_SEPARATOR);
7969 expected.push_str(Severity::Warning.as_str());
7970 expected.push_str(SEVERITY_EXPECTED_FINAL_SEPARATOR);
7971 expected.push_str(Severity::Error.as_str());
7972 expected
7973}
7974
7975#[tool_router(router = tool_router)]
7976impl ProjectAtlasMcpServer {
7977 #[tool(
7979 name = "atlas_set_project_path",
7980 description = "Select the active ProjectAtlas project root for later MCP calls that omit project_path."
7981 )]
7982 fn atlas_set_project_path(
7983 &self,
7984 Parameters(params): Parameters<AtlasSetProjectPathParams>,
7985 ) -> McpToolTextResult {
7986 Self::as_mcp_text((|| {
7987 let state = Self::project_state_from_root(Path::new(¶ms.project_path))?;
7988 self.set_active_project_state(state.clone())?;
7989 Self::render_project_state(&state)
7990 })())
7991 }
7992
7993 #[tool(
7995 name = "atlas_worktree_list",
7996 description = "List structurally discovered Git worktrees, short ProjectAtlas aliases, atlas availability, and telemetry synchronization state without changing Git or files."
7997 )]
7998 fn atlas_worktree_list(
7999 &self,
8000 Parameters(params): Parameters<AtlasWorktreeListParams>,
8001 ) -> McpToolTextResult {
8002 Self::as_mcp_text((|| {
8003 let repository = self.control_git_repository()?;
8004 let store = open_atlas_store_read_only_for_project(
8005 &self.control_state.db_path,
8006 &self.control_state.root,
8007 )?;
8008 let include_retired = params.include_retired.unwrap_or(false);
8009 let registrations = store.worktree_registrations(include_retired)?;
8010 let structural_identities = repository
8011 .worktrees
8012 .iter()
8013 .filter_map(Self::worktree_administrative_path_identity)
8014 .collect::<HashSet<_>>();
8015 let (mut worktrees, unregistered): (Vec<_>, Vec<_>) = repository
8016 .worktrees
8017 .iter()
8018 .map(|entry| {
8019 self.worktree_list_row(&repository.common_directory, entry, ®istrations)
8020 })
8021 .partition(|row| {
8022 !matches!(row.registration, McpWorktreeRegistrationState::Unregistered)
8023 });
8024 worktrees.extend(
8025 registrations
8026 .iter()
8027 .filter(|registration| {
8028 registration.state == WorktreeRegistrationState::Active
8029 && !structural_identities
8030 .contains(®istration.git_administrative_directory_identity)
8031 })
8032 .map(Self::missing_registered_worktree_row),
8033 );
8034 let total_worktrees = worktrees.len() + unregistered.len();
8035 worktrees.extend(
8036 unregistered
8037 .into_iter()
8038 .take(MCP_WORKTREE_LIST_MAX_ROWS.saturating_sub(worktrees.len())),
8039 );
8040 let truncated = total_worktrees > worktrees.len();
8041 let retired = registrations
8042 .iter()
8043 .filter(|registration| registration.state == WorktreeRegistrationState::Retired)
8044 .map(|registration| McpRetiredWorktreeRow {
8045 alias: registration.alias.to_string(),
8046 path_display: if registration.last_root_identity.display_string().is_ok() {
8047 McpWorktreePathDisplayState::Available
8048 } else {
8049 McpWorktreePathDisplayState::Unavailable
8050 },
8051 last_root: registration.last_root_identity.display_string().ok(),
8052 project_instance_id: registration
8053 .project_instance_id
8054 .map(|identity| identity.to_string()),
8055 accepted_telemetry_revision: registration.accepted_telemetry_revision,
8056 })
8057 .collect();
8058 Self::encode_named_payload(
8059 MCP_PAYLOAD_WORKTREES,
8060 &McpWorktreeListReport {
8061 control_alias: MCP_MAIN_WORKTREE_ALIAS,
8062 control_root: lossless_project_root_display(&self.control_state.root),
8063 common_directory: lossless_native_path_display(&repository.common_directory),
8064 worktrees,
8065 retired,
8066 total_worktrees,
8067 truncated,
8068 },
8069 )
8070 })())
8071 }
8072
8073 #[tool(
8075 name = "atlas_worktree_add",
8076 description = "Register one structurally discovered Git worktree under a short ProjectAtlas alias without creating, moving, or switching Git worktrees."
8077 )]
8078 fn atlas_worktree_add(
8079 &self,
8080 Parameters(params): Parameters<AtlasWorktreeAddParams>,
8081 ) -> McpToolTextResult {
8082 Self::as_mcp_text((|| {
8083 let requested = params.worktree.trim();
8084 if requested.is_empty() {
8085 return Err(CliError::InvalidInput(
8086 MCP_ERROR_WORKTREE_SELECTOR_EMPTY.to_string(),
8087 ));
8088 }
8089 let repository = self.control_git_repository()?;
8090 let candidates = self.matching_worktree_candidates(&repository, requested);
8091 if candidates.len() != 1 {
8092 let ambiguous = !candidates.is_empty();
8093 let candidate_rows: Vec<McpWorktreeCandidate> = if candidates.is_empty() {
8094 repository
8095 .worktrees
8096 .iter()
8097 .filter(|entry| {
8098 Self::active_worktree_root(entry)
8099 .is_some_and(|root| root != self.control_state.root)
8100 })
8101 .filter_map(|entry| {
8102 Self::worktree_candidate(&repository.common_directory, entry)
8103 })
8104 .take(MCP_WORKTREE_LIST_MAX_ROWS)
8105 .collect()
8106 } else {
8107 candidates
8108 .into_iter()
8109 .filter_map(|entry| {
8110 Self::worktree_candidate(&repository.common_directory, entry)
8111 })
8112 .take(MCP_WORKTREE_LIST_MAX_ROWS)
8113 .collect()
8114 };
8115 return Self::encode_named_payload(
8116 MCP_PAYLOAD_WORKTREE,
8117 &McpWorktreeMutationReport {
8118 operation: McpWorktreeMutationOperation::Add,
8119 status: if ambiguous {
8120 McpWorktreeMutationStatus::Ambiguous
8121 } else {
8122 McpWorktreeMutationStatus::NotFound
8123 },
8124 selector: None,
8125 alias: None,
8126 root: None,
8127 path_display: None,
8128 registration_id: None,
8129 telemetry_sync: None,
8130 candidates: candidate_rows,
8131 blocker: Some(format!(
8132 "selector {requested:?} did not identify exactly one active non-control worktree"
8133 )),
8134 git_unchanged: true,
8135 files_unchanged: true,
8136 },
8137 );
8138 }
8139 let entry = candidates[0];
8140 let root = Self::active_worktree_root(entry).ok_or_else(|| {
8141 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
8142 })?;
8143 let administrative_identity =
8144 git_administrative_identity(&entry.administrative_directory)?;
8145 let alias = match params.alias.as_deref() {
8146 Some(alias) => WorktreeAlias::parse(alias.trim())?,
8147 None => Self::default_worktree_alias(root)?,
8148 };
8149 let db_path = Self::projectatlas_db_path(root);
8150 let mut project_instance_id = None;
8151 let local = (|| {
8152 let Some(store) = Self::open_local_worktree_atlas(root)? else {
8153 return Ok(None);
8154 };
8155 let identity = Self::local_worktree_project_instance_id(&store, &db_path)?;
8156 project_instance_id = Some(identity);
8157 let snapshot = Self::local_worktree_usage_snapshot(&store, &db_path, identity)?;
8158 Ok::<_, CliError>(Some(LocalWorktreeAtlas {
8159 project_instance_id: identity,
8160 snapshot,
8161 }))
8162 })();
8163 let (local, blocker) = match local {
8164 Ok(local) => (local, None),
8165 Err(error) if project_instance_id.is_some() => return Err(error),
8166 Err(error) => (
8167 None,
8168 Some(format!(
8169 "registration committed without local telemetry import: {error}"
8170 )),
8171 ),
8172 };
8173 let (repository, entry) =
8174 self.revalidate_worktree_candidate(&repository, entry, &administrative_identity)?;
8175 let root = Self::active_worktree_root(&entry).ok_or_else(|| {
8176 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
8177 })?;
8178 Self::revalidate_local_worktree_atlas_identity(root, project_instance_id)?;
8179 if let Some(local) = local.as_ref() {
8180 require_current_worktree_usage_snapshot(&db_path, root, &local.snapshot)?;
8181 }
8182 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
8183 let created_at_epoch = Self::current_epoch_seconds()?;
8184 let (registration, telemetry_sync) = if let Some(local) = local.as_ref() {
8185 match control.register_worktree_with_usage_snapshot(
8186 &alias,
8187 &repository.common_directory,
8188 &entry.administrative_directory,
8189 &administrative_identity,
8190 root,
8191 local.project_instance_id,
8192 &local.snapshot,
8193 created_at_epoch,
8194 ) {
8195 Ok((registration, state)) => (registration, Some(state)),
8196 Err(error) => return Err(error.into()),
8197 }
8198 } else {
8199 (
8200 control.register_worktree(
8201 &alias,
8202 &repository.common_directory,
8203 &entry.administrative_directory,
8204 &administrative_identity,
8205 root,
8206 project_instance_id,
8207 created_at_epoch,
8208 )?,
8209 None,
8210 )
8211 };
8212 Self::encode_named_payload(
8213 MCP_PAYLOAD_WORKTREE,
8214 &McpWorktreeMutationReport {
8215 operation: McpWorktreeMutationOperation::Add,
8216 status: McpWorktreeMutationStatus::Registered,
8217 selector: Some(Self::worktree_candidate_selector(&entry)),
8218 alias: Some(alias.to_string()),
8219 root: lossless_project_root_display(root),
8220 path_display: Some(
8221 if root.to_str().is_some()
8222 && repository.common_directory.to_str().is_some()
8223 && entry.administrative_directory.to_str().is_some()
8224 {
8225 McpWorktreePathDisplayState::Available
8226 } else {
8227 McpWorktreePathDisplayState::Unavailable
8228 },
8229 ),
8230 registration_id: Some(registration.registration_id),
8231 telemetry_sync,
8232 candidates: Vec::new(),
8233 blocker,
8234 git_unchanged: true,
8235 files_unchanged: true,
8236 },
8237 )
8238 })())
8239 }
8240
8241 #[tool(
8243 name = "atlas_worktree_remove",
8244 description = "Final-sync and retire one ProjectAtlas worktree alias while preserving retained token totals and leaving Git, source files, .projectatlas, and SQLite files untouched."
8245 )]
8246 fn atlas_worktree_remove(
8247 &self,
8248 Parameters(params): Parameters<AtlasWorktreeRemoveParams>,
8249 ) -> McpToolTextResult {
8250 Self::as_mcp_text((|| {
8251 let alias = WorktreeAlias::parse(params.worktree.trim())?;
8252 let repository = self.control_git_repository()?;
8253 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
8254 let registration = control.worktree_registration(&alias)?;
8255 let mut blocker = None;
8256 let entry = repository.worktrees.iter().find(|entry| {
8257 Self::worktree_administrative_path_identity(entry).is_some_and(|identity| {
8258 identity == registration.git_administrative_directory_identity
8259 })
8260 });
8261 let entry = match entry {
8262 Some(entry) if matches!(entry.state, GitWorktreeState::Invalid { .. }) => {
8263 Some(entry)
8264 }
8265 Some(entry) => match git_administrative_identity(&entry.administrative_directory) {
8266 Ok(identity) if identity == registration.git_administrative_identity => {
8267 Some(entry)
8268 }
8269 Ok(_) => {
8270 blocker = Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string());
8271 None
8272 }
8273 Err(error) => {
8274 blocker = Some(error.to_string());
8275 None
8276 }
8277 },
8278 None => None,
8279 };
8280 let retired_at_epoch = Self::current_epoch_seconds()?;
8281 let retained_path_display =
8282 Self::missing_registered_worktree_row(®istration).path_display;
8283 let (root_display, active_root) = match entry.map(|entry| &entry.state) {
8284 Some(GitWorktreeState::Active { root, .. }) => {
8285 (lossless_project_root_display(root), Some(root.as_path()))
8286 }
8287 Some(GitWorktreeState::Invalid { issue }) => {
8288 return Err(CliError::InvalidInput(format!(
8289 "cannot retire worktree '{}' while its Git evidence is invalid at '{}': {:?}",
8290 alias,
8291 normalize_native_path_display(&issue.path),
8292 issue.kind
8293 )));
8294 }
8295 Some(GitWorktreeState::Missing { .. }) | None => {
8296 blocker
8297 .get_or_insert_with(|| MCP_WORKTREE_MISSING_RETENTION_REASON.to_string());
8298 (registration.last_root_identity.display_string().ok(), None)
8299 }
8300 };
8301 let (retired, telemetry_sync, final_blocker) = Self::retire_registered_worktree(
8302 &control,
8303 ®istration,
8304 active_root,
8305 retired_at_epoch,
8306 blocker,
8307 )?;
8308 Self::encode_named_payload(
8309 MCP_PAYLOAD_WORKTREE,
8310 &McpWorktreeMutationReport {
8311 operation: McpWorktreeMutationOperation::Remove,
8312 status: McpWorktreeMutationStatus::Retired,
8313 selector: entry.map(Self::worktree_candidate_selector),
8314 alias: Some(alias.to_string()),
8315 root: root_display,
8316 path_display: Some(entry.map_or_else(
8317 || retained_path_display,
8318 |entry| match &entry.state {
8319 GitWorktreeState::Active { root, .. }
8320 if root.to_str().is_some()
8321 && repository.common_directory.to_str().is_some()
8322 && entry.administrative_directory.to_str().is_some() =>
8323 {
8324 McpWorktreePathDisplayState::Available
8325 }
8326 GitWorktreeState::Missing { .. } => retained_path_display,
8327 GitWorktreeState::Active { .. } | GitWorktreeState::Invalid { .. } => {
8328 McpWorktreePathDisplayState::Unavailable
8329 }
8330 },
8331 )),
8332 registration_id: Some(retired.registration_id),
8333 telemetry_sync,
8334 candidates: Vec::new(),
8335 blocker: final_blocker,
8336 git_unchanged: true,
8337 files_unchanged: true,
8338 },
8339 )
8340 })())
8341 }
8342
8343 #[tool(
8345 name = "atlas_init",
8346 description = "Initialize ProjectAtlas project-local config, database, host MCP configs, scan/index, and purpose handoff."
8347 )]
8348 fn atlas_init(&self, Parameters(params): Parameters<AtlasInitParams>) -> McpToolTextResult {
8349 Self::as_mcp_text((|| {
8350 let state = self.init_project_root(params.project_path, params.worktree)?;
8351 let config_path = init_config_path(&state.root, state.config_path.as_deref());
8352 let mut report = self.run_registered_worktree_init(
8353 &state,
8354 &config_path,
8355 &InitBootstrapOptions {
8356 no_scan: params.no_scan.unwrap_or(false),
8357 force_rescan: params.force_rescan.unwrap_or(false),
8358 text_index_max_bytes: params.text_index_max_bytes,
8359 },
8360 )?;
8361 crate::write_init_mcp_config_files(
8362 &mut report,
8363 &state.root.join(PROJECTATLAS_DIR_NAME),
8364 &state.db_path,
8365 &config_path,
8366 false,
8367 );
8368 Self::encode_named_payload(MCP_PAYLOAD_INIT, &report)
8369 })())
8370 }
8371
8372 #[tool(
8374 name = "atlas_map",
8375 description = "Return the current ProjectAtlas map; optionally write a JSON sidecar."
8376 )]
8377 fn atlas_map(&self, Parameters(params): Parameters<AtlasMapParams>) -> McpToolTextResult {
8378 Self::as_mcp_text((|| {
8379 let state = self.admin_project_root(params.project_path, params.worktree)?;
8380 Self::build_map_report(
8381 &state,
8382 params.json.unwrap_or(false),
8383 params.force.unwrap_or(false),
8384 )
8385 })())
8386 }
8387
8388 #[tool(
8390 name = "atlas_root",
8391 description = "Show or verify ProjectAtlas root, DB, config, and runtime identity."
8392 )]
8393 fn atlas_root(&self, Parameters(params): Parameters<AtlasRootParams>) -> McpToolTextResult {
8394 Self::as_mcp_text((|| {
8395 if let Some(control_root) = params.control_root.as_deref() {
8396 if params.project_path.is_some()
8397 || params.worktree.is_some()
8398 || params.verify.unwrap_or(false)
8399 {
8400 return Err(CliError::InvalidInput(
8401 MCP_ERROR_ROOT_CONTROL_CONFLICT.to_owned(),
8402 ));
8403 }
8404 let report = build_repository_control_report(Path::new(control_root))?;
8405 return Ok(render_repository_control_report(&report));
8406 }
8407 let state = self.admin_project_root(params.project_path, params.worktree)?;
8408 let admission =
8409 crate::runtime::preflight_managed_database(&state.db_path, &state.root)?;
8410 let database =
8411 crate::runtime::admitted_database_path(&state.db_path, admission.as_ref());
8412 let report = build_root_report(database, state.config_path.as_deref())?;
8413 if params.verify.unwrap_or(false) && report.verified {
8414 let _admission =
8415 crate::runtime::preflight_managed_database(&state.db_path, &state.root)?;
8416 verify_project_database(database, &state.root)?;
8417 }
8418 Self::with_selected_project_audit(
8419 &state,
8420 state.worktree.is_some(),
8421 render_root_report(&report),
8422 )
8423 })())
8424 }
8425
8426 #[tool(
8428 name = "atlas_root_set",
8429 description = "Bind a repository root, generate project-local MCP configs, and make it active for later MCP calls."
8430 )]
8431 fn atlas_root_set(
8432 &self,
8433 Parameters(params): Parameters<AtlasRootSetParams>,
8434 ) -> McpToolTextResult {
8435 Self::as_mcp_text((|| {
8436 let root = canonical_project_root(Path::new(¶ms.root))?;
8437 let report = crate::bind_project_root(
8438 &root,
8439 params.transition.unwrap_or(RootTransition::Bind),
8440 params.nearest_project.unwrap_or(false),
8441 )?;
8442 let state = Self::project_state_from_root(&root)?;
8443 self.set_active_project_state(state)?;
8444 Ok(render_root_report(&report))
8445 })())
8446 }
8447
8448 #[tool(
8450 name = "atlas_config",
8451 description = "Return the effective ProjectAtlas scan, purpose, and output configuration."
8452 )]
8453 fn atlas_config(
8454 &self,
8455 Parameters(params): Parameters<AtlasProjectParams>,
8456 ) -> McpToolTextResult {
8457 Self::as_mcp_text((|| {
8458 let state = self.admin_project_root(params.project_path, params.worktree)?;
8459 let report = effective_config_report(&Self::load_config_for_state(&state)?);
8460 Self::encode_named_payload(MCP_PAYLOAD_CONFIG, &report)
8461 })())
8462 }
8463
8464 #[tool(
8466 name = "atlas_ignore_list",
8467 description = "List effective ProjectAtlas manual ignore policy and inherited .gitignore status."
8468 )]
8469 fn atlas_ignore_list(
8470 &self,
8471 Parameters(params): Parameters<AtlasProjectParams>,
8472 ) -> McpToolTextResult {
8473 Self::as_mcp_text((|| {
8474 let state = self.admin_project_root(params.project_path, params.worktree)?;
8475 let report = list_ignore_entries(state.config_path.as_deref(), &state.root)?;
8476 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8477 })())
8478 }
8479
8480 #[tool(
8482 name = "atlas_ignore_init_gitignore",
8483 description = "Create a project-root .gitignore when it is missing."
8484 )]
8485 fn atlas_ignore_init_gitignore(
8486 &self,
8487 Parameters(params): Parameters<AtlasProjectParams>,
8488 ) -> McpToolTextResult {
8489 Self::as_mcp_text((|| {
8490 let state = self.admin_project_root(params.project_path, params.worktree)?;
8491 let report = init_gitignore(state.config_path.as_deref(), &state.root)?;
8492 Self::encode_named_payload(MCP_PAYLOAD_GITIGNORE, &report)
8493 })())
8494 }
8495
8496 #[tool(
8498 name = "atlas_ignore_add",
8499 description = "Add one manual ProjectAtlas ignore entry to the selected project's config."
8500 )]
8501 fn atlas_ignore_add(
8502 &self,
8503 Parameters(params): Parameters<AtlasIgnoreMutationParams>,
8504 ) -> McpToolTextResult {
8505 Self::as_mcp_text((|| {
8506 let state = self.admin_project_root(params.project_path, params.worktree)?;
8507 let kind = Self::parse_ignore_kind(params.kind.as_deref(), true)?.ok_or_else(|| {
8508 CliError::InvalidInput(MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD.to_owned())
8509 })?;
8510 let report = add_ignore_entry(
8511 state.config_path.as_deref(),
8512 &state.root,
8513 kind,
8514 ¶ms.value,
8515 )?;
8516 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8517 })())
8518 }
8519
8520 #[tool(
8522 name = "atlas_ignore_remove",
8523 description = "Remove one manual ProjectAtlas ignore entry from the selected project's config."
8524 )]
8525 fn atlas_ignore_remove(
8526 &self,
8527 Parameters(params): Parameters<AtlasIgnoreMutationParams>,
8528 ) -> McpToolTextResult {
8529 Self::as_mcp_text((|| {
8530 let state = self.admin_project_root(params.project_path, params.worktree)?;
8531 let kind = Self::parse_ignore_kind(params.kind.as_deref(), false)?;
8532 let report = remove_ignore_entry(
8533 state.config_path.as_deref(),
8534 &state.root,
8535 kind,
8536 ¶ms.value,
8537 )?;
8538 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8539 })())
8540 }
8541
8542 #[tool(
8544 name = "atlas_scan",
8545 description = "Scan repository structure, import ProjectAtlas purpose metadata, rebuild symbols, and return a TOON overview."
8546 )]
8547 fn atlas_scan(&self, Parameters(params): Parameters<AtlasScanParams>) -> McpToolTextResult {
8548 Self::as_mcp_text((|| {
8549 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8550 let background = params.background.unwrap_or(false);
8551 let (state, path) = if background {
8552 self.background_state_and_root_path(
8553 params.project_path,
8554 params.worktree,
8555 params.path,
8556 nearest_project,
8557 )?
8558 } else {
8559 self.state_and_root_path(
8560 params.project_path,
8561 params.worktree,
8562 params.path,
8563 nearest_project,
8564 )?
8565 };
8566 let symbol_options = SymbolBuildOptions::new(
8567 params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
8568 params.max_workers,
8569 params.timeout_seconds,
8570 );
8571 let text_index_max_bytes = params.text_index_max_bytes;
8572 if background {
8573 let control_state = self.control_state.clone();
8574 let task = self.start_index_task(
8575 McpTaskOperation::Scan,
8576 symbol_options,
8577 MCP_TOOL_ATLAS_OVERVIEW,
8578 move |control, symbol_options| {
8579 let plan = ScanRuntimePlan::for_path_controlled(
8580 state.config_path.as_deref(),
8581 &path,
8582 text_index_max_bytes,
8583 control,
8584 )?;
8585 let mut store = Self::open_mut_store(&state, &control_state)?;
8586 run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, control)?;
8587 Ok(())
8588 },
8589 )?;
8590 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
8591 }
8592 let control = index_work_control(&symbol_options);
8593 let plan = ScanRuntimePlan::for_path_controlled(
8594 state.config_path.as_deref(),
8595 &path,
8596 text_index_max_bytes,
8597 &control,
8598 )?;
8599 let mut store = Self::open_mut_store(&state, &self.control_state)?;
8600 let report =
8601 run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control)?;
8602 Self::encode_named_payload(MCP_PAYLOAD_SCAN, &report)
8603 })())
8604 }
8605
8606 fn atlas_overview_response(
8608 &self,
8609 params: AtlasProjectParams,
8610 context: Option<RequestContext<RoleServer>>,
8611 ) -> McpToolTextResult {
8612 Self::as_mcp_text((|| {
8613 let state = self.state_for_target(params.project_path, params.worktree)?;
8614 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8615 let overview = store.overview()?;
8616 let toon = render_overview(&overview);
8617 let usage = Self::telemetry_enabled()
8618 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8619 .and_then(Result::ok)
8620 .map(|baseline_tokens| {
8621 McpUsageIntent::directory_walk(
8622 MCP_EVENT_ATLAS_OVERVIEW,
8623 None,
8624 None,
8625 baseline_tokens,
8626 )
8627 });
8628 Ok((toon, usage))
8629 })
8630 })())
8631 }
8632
8633 #[tool(
8635 name = "atlas_overview",
8636 description = "Return a compact TOON overview of indexed files, folders, and purpose coverage."
8637 )]
8638 fn atlas_overview(
8639 &self,
8640 Parameters(params): Parameters<AtlasProjectParams>,
8641 context: RequestContext<RoleServer>,
8642 ) -> McpToolTextResult {
8643 self.atlas_overview_response(params, Some(context))
8644 }
8645
8646 #[tool(
8648 name = "atlas_folders",
8649 description = "Rank repository folders by query and purpose so agents choose a work area before opening files."
8650 )]
8651 fn atlas_folders(
8652 &self,
8653 Parameters(params): Parameters<AtlasQueryParams>,
8654 context: RequestContext<RoleServer>,
8655 ) -> McpToolTextResult {
8656 self.atlas_folders_response(params, Some(context))
8657 }
8658
8659 fn atlas_folders_response(
8661 &self,
8662 params: AtlasQueryParams,
8663 context: Option<RequestContext<RoleServer>>,
8664 ) -> McpToolTextResult {
8665 Self::as_mcp_text((|| {
8666 let state = self.state_for_target(params.project_path, params.worktree)?;
8667 let query = Self::query_or_empty(params.query);
8668 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8669 let selected =
8670 ranked_folder_nodes_with_reasons(store, &query, params.limit.unwrap_or(10))?;
8671 let toon = render_ranked_nodes(NODE_LABEL_FOLDERS, &selected);
8672 let usage = Self::telemetry_enabled()
8673 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8674 .and_then(Result::ok)
8675 .map(|baseline_tokens| {
8676 McpUsageIntent::directory_walk(
8677 MCP_EVENT_ATLAS_FOLDERS,
8678 None,
8679 Some(query.clone()),
8680 baseline_tokens,
8681 )
8682 });
8683 Ok((toon, usage))
8684 })
8685 })())
8686 }
8687
8688 #[tool(
8690 name = "atlas_files",
8691 description = "Rank repository files by query, purpose, optional folder, and optional indexed text fallback before an agent opens source."
8692 )]
8693 fn atlas_files(
8694 &self,
8695 Parameters(params): Parameters<AtlasFilesParams>,
8696 context: RequestContext<RoleServer>,
8697 ) -> McpToolTextResult {
8698 self.atlas_files_response(params, Some(context))
8699 }
8700
8701 fn atlas_files_response(
8703 &self,
8704 params: AtlasFilesParams,
8705 context: Option<RequestContext<RoleServer>>,
8706 ) -> McpToolTextResult {
8707 Self::as_mcp_text((|| {
8708 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8709 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8710 let (state, folder_filter, routed_project) = self.state_and_optional_folder_filter(
8711 params.project_path.as_deref(),
8712 params.worktree.as_deref(),
8713 params.folder.as_deref(),
8714 nearest_project,
8715 )?;
8716 let query = Self::query_or_empty(params.query);
8717 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8718 let selected = classified_ranked_file_nodes_with_reasons(
8719 store,
8720 &query,
8721 folder_filter.as_deref(),
8722 params.file_pattern.as_deref(),
8723 params.limit.unwrap_or(10),
8724 params.include_content.unwrap_or(false),
8725 content_selection,
8726 )?;
8727 let toon = Self::with_selected_project_audit(
8728 &state,
8729 routed_project,
8730 encode_agent_payload(&serde_json::json!({
8731 NODE_LABEL_FILES: render_classified_ranked_file_rows(&selected),
8732 })),
8733 )?;
8734 let usage = Self::telemetry_enabled()
8735 .then(|| {
8736 self.estimated_source_tokens_cached(
8737 &state,
8738 store,
8739 &stamp,
8740 folder_filter.as_deref(),
8741 params.file_pattern.as_deref(),
8742 )
8743 })
8744 .and_then(Result::ok)
8745 .map(|baseline_tokens| {
8746 McpUsageIntent::estimate(
8747 MCP_EVENT_ATLAS_FILES,
8748 params
8749 .file_pattern
8750 .clone()
8751 .or_else(|| folder_filter.clone()),
8752 Some(query.clone()),
8753 baseline_tokens,
8754 )
8755 });
8756 Ok((toon, usage))
8757 })
8758 })())
8759 }
8760
8761 #[tool(
8763 name = "atlas_next",
8764 description = "Recommend top indexed folders/files with reasons and deterministic follow-up commands for a task query."
8765 )]
8766 fn atlas_next(
8767 &self,
8768 Parameters(params): Parameters<AtlasNextParams>,
8769 context: RequestContext<RoleServer>,
8770 ) -> McpToolTextResult {
8771 Self::as_mcp_text((|| {
8772 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8773 let state = self.state_for_target(params.project_path, params.worktree)?;
8774 let query = Self::query_or_empty(params.query);
8775 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
8776 let report = next_step_report_with_selection(
8777 store,
8778 &query,
8779 params.limit,
8780 content_selection,
8781 )?;
8782 let payload = next_step_report_payload(&report);
8783 let toon = Self::encode_named_payload(MCP_PAYLOAD_NEXT, &payload)?;
8784 let usage = Self::telemetry_enabled()
8785 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8786 .and_then(Result::ok)
8787 .map(|baseline_tokens| {
8788 McpUsageIntent::directory_walk(
8789 MCP_EVENT_ATLAS_NEXT,
8790 None,
8791 Some(query.clone()),
8792 baseline_tokens,
8793 )
8794 });
8795 Ok((toon, usage))
8796 })
8797 })())
8798 }
8799
8800 #[tool(
8802 name = "atlas_outline",
8803 description = "Return compact TOON outline and preview context for a selected file."
8804 )]
8805 fn atlas_outline(
8806 &self,
8807 Parameters(params): Parameters<AtlasOutlineParams>,
8808 context: RequestContext<RoleServer>,
8809 ) -> McpToolTextResult {
8810 Self::as_mcp_text((|| {
8811 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8812 let resolved = self.state_and_file_key(
8813 params.project_path.as_deref(),
8814 params.worktree.as_deref(),
8815 ¶ms.file,
8816 nearest_project,
8817 )?;
8818 let state = resolved.state;
8819 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
8820 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8821 let content = read_indexed_file_content(store, &file_key)?;
8822 let language = store
8823 .load_node_by_path(&file_key)?
8824 .and_then(|node| node.node.language);
8825 let outline =
8826 build_outline(&file_key, language, &content, params.lines.unwrap_or(12));
8827 let toon = Self::with_selected_project_audit(
8828 &state,
8829 resolved.routed_project,
8830 render_outline(&outline),
8831 )?;
8832 let usage = Some(McpUsageIntent::text(
8833 MCP_EVENT_ATLAS_OUTLINE,
8834 Some(file_key),
8835 content,
8836 ));
8837 Ok((toon, usage))
8838 })
8839 })())
8840 }
8841
8842 fn atlas_file_summary_response(
8844 &self,
8845 params: &AtlasFileSummaryParams,
8846 context: Option<RequestContext<RoleServer>>,
8847 ) -> McpToolTextResult {
8848 Self::as_mcp_text((|| {
8849 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8850 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8851 let resolved = self.state_and_file_key(
8852 params.project_path.as_deref(),
8853 params.worktree.as_deref(),
8854 ¶ms.file,
8855 nearest_project,
8856 )?;
8857 let state = resolved.state;
8858 self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
8859 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8860 let content = read_indexed_file_content(store, &file_key)?;
8861 let report = build_file_summary_from_source_with_selection(
8862 store,
8863 Path::new(&file_key),
8864 params.limit.unwrap_or(DEFAULT_FILE_SUMMARY_LIMIT),
8865 &content,
8866 content_selection,
8867 )?;
8868 let rendered = if params.compact.unwrap_or(false) {
8869 encode_agent_payload(&McpFileSummaryPayload {
8870 file_summary: McpFileSummary::from(&report),
8871 })
8872 } else {
8873 render_file_summary(&report)
8874 };
8875 let toon =
8876 Self::with_selected_project_audit(&state, resolved.routed_project, rendered)?;
8877 let usage = Some(McpUsageIntent::text(
8878 MCP_EVENT_ATLAS_FILE_SUMMARY,
8879 Some(report.file_path),
8880 content,
8881 ));
8882 Ok((toon, usage))
8883 })
8884 })())
8885 }
8886
8887 #[tool(
8889 name = "atlas_file_summary",
8890 description = "Return structured TOON file intelligence: file purpose, content summary, imports, symbols, line ranges, and calls."
8891 )]
8892 fn atlas_file_summary(
8893 &self,
8894 Parameters(params): Parameters<AtlasFileSummaryParams>,
8895 context: RequestContext<RoleServer>,
8896 ) -> McpToolTextResult {
8897 self.atlas_file_summary_response(¶ms, Some(context))
8898 }
8899
8900 #[tool(
8902 name = "atlas_search",
8903 description = "Search indexed files with literal, regex, or fuzzy matching, file filters, pagination, and TOON results."
8904 )]
8905 fn atlas_search(
8906 &self,
8907 Parameters(params): Parameters<AtlasSearchParams>,
8908 context: RequestContext<RoleServer>,
8909 ) -> McpToolTextResult {
8910 Self::as_mcp_text((|| {
8911 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8912 let state =
8913 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
8914 self.with_fresh_string_and_usage_controlled_for_request(
8915 &state,
8916 Some(context),
8917 |store, _stamp, control| {
8918 let report = search_indexed_files_with_control(
8919 store,
8920 &SearchQuery {
8921 pattern: ¶ms.pattern,
8922 regex: params.regex.unwrap_or(false),
8923 fuzzy: params.fuzzy.unwrap_or(false),
8924 case_sensitive: params.case_sensitive.unwrap_or(false),
8925 file_pattern: params.file_pattern.as_deref(),
8926 context_lines: params.context_lines.unwrap_or(0),
8927 start_index: params.start_index.unwrap_or(0),
8928 limit: params.limit.unwrap_or(20),
8929 content_selection,
8930 retrieval_mode: params.retrieval_mode.unwrap_or_default().into(),
8931 },
8932 Some(control),
8933 )?;
8934 let toon = render_search_report(&report);
8935 let usage = Some(McpUsageIntent::estimate(
8936 MCP_EVENT_ATLAS_SEARCH,
8937 params.file_pattern.clone(),
8938 Some(params.pattern.clone()),
8939 byte_count_to_tokens(report.searched_bytes),
8940 ));
8941 Ok((toon, usage))
8942 },
8943 )
8944 })())
8945 }
8946
8947 #[tool(
8949 name = "atlas_slice",
8950 description = "Return exact source for a selected line range or indexed symbol, after folder/file orientation."
8951 )]
8952 fn atlas_slice(
8953 &self,
8954 Parameters(params): Parameters<AtlasSliceParams>,
8955 context: RequestContext<RoleServer>,
8956 ) -> McpToolTextResult {
8957 Self::as_mcp_text((|| {
8958 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8959 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8960 let resolved = self.state_and_file_key(
8961 params.project_path.as_deref(),
8962 params.worktree.as_deref(),
8963 ¶ms.file,
8964 nearest_project,
8965 )?;
8966 let state = resolved.state;
8967 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
8968 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8969 let file = PathBuf::from(&file_key);
8970 let content = read_indexed_file_content(store, &file_key)?;
8971 let output_budget = CodeSliceBudget::new(
8972 params
8973 .output_bytes
8974 .unwrap_or(CodeSliceBudget::DEFAULT_OUTPUT_BYTES),
8975 )?;
8976 let report = if let Some(symbol) = params.symbol.as_ref() {
8977 read_symbol_slice_from_source_bounded_with_selection(
8978 store,
8979 &file,
8980 &SymbolSliceSelector {
8981 name: symbol,
8982 parent: params.symbol_parent.as_deref().and_then(nonempty_str),
8983 kind: params.symbol_kind.as_deref().and_then(nonempty_str),
8984 signature: params.symbol_signature.as_deref().and_then(nonempty_str),
8985 line: params.symbol_line,
8986 },
8987 &content,
8988 output_budget,
8989 content_selection,
8990 )?
8991 } else {
8992 if params
8993 .symbol_parent
8994 .as_deref()
8995 .and_then(nonempty_str)
8996 .is_some()
8997 || params
8998 .symbol_kind
8999 .as_deref()
9000 .and_then(nonempty_str)
9001 .is_some()
9002 || params
9003 .symbol_signature
9004 .as_deref()
9005 .and_then(nonempty_str)
9006 .is_some()
9007 || params.symbol_line.is_some()
9008 {
9009 return Err(CliError::InvalidInput(
9010 SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR.to_string(),
9011 ));
9012 }
9013 let start_line = params.start_line.ok_or_else(|| {
9014 CliError::InvalidInput(START_LINE_REQUIRED_ERROR.to_string())
9015 })?;
9016 read_indexed_code_slice_from_source_bounded_with_selection(
9017 store,
9018 &file,
9019 start_line,
9020 params.end_line,
9021 &content,
9022 output_budget,
9023 content_selection,
9024 )?
9025 };
9026 let toon = report.fit_output(|report| {
9027 Self::with_selected_project_audit(
9028 &state,
9029 resolved.routed_project,
9030 render_code_slice(report),
9031 )
9032 })?;
9033 let usage = Some(McpUsageIntent::text(
9034 MCP_EVENT_ATLAS_SLICE,
9035 Some(report.slice().path.clone()),
9036 content,
9037 ));
9038 Ok((toon, usage))
9039 })
9040 })())
9041 }
9042
9043 #[tool(
9045 name = "atlas_symbols_build",
9046 description = "Rebuild ProjectAtlas symbol graphs for indexed files and return a TOON build report."
9047 )]
9048 fn atlas_symbols_build(
9049 &self,
9050 Parameters(params): Parameters<AtlasScanParams>,
9051 ) -> McpToolTextResult {
9052 Self::as_mcp_text((|| {
9053 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9054 let background = params.background.unwrap_or(false);
9055 let (state, path) = if background {
9056 self.background_state_and_root_path(
9057 params.project_path,
9058 params.worktree,
9059 params.path,
9060 nearest_project,
9061 )?
9062 } else {
9063 self.state_and_root_path(
9064 params.project_path,
9065 params.worktree,
9066 params.path,
9067 nearest_project,
9068 )?
9069 };
9070 let options = SymbolBuildOptions::new(
9071 params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
9072 params.max_workers,
9073 params.timeout_seconds,
9074 );
9075 let text_index_max_bytes = params.text_index_max_bytes;
9076 if background {
9077 let control_state = self.control_state.clone();
9078 let task = self.start_index_task(
9079 McpTaskOperation::SymbolsBuild,
9080 options,
9081 MCP_TOOL_ATLAS_SYMBOLS,
9082 move |control, options| {
9083 let plan = ScanRuntimePlan::for_path_controlled(
9084 state.config_path.as_deref(),
9085 &path,
9086 text_index_max_bytes,
9087 control,
9088 )?;
9089 let mut store = Self::open_mut_store(&state, &control_state)?;
9090 run_symbol_build_pipeline_controlled(
9091 &mut store, &plan, &options, None, control,
9092 )?;
9093 Ok(())
9094 },
9095 )?;
9096 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
9097 }
9098 let control = index_work_control(&options);
9099 let plan = ScanRuntimePlan::for_path_controlled(
9100 state.config_path.as_deref(),
9101 &path,
9102 text_index_max_bytes,
9103 &control,
9104 )?;
9105 let mut store = Self::open_mut_store(&state, &self.control_state)?;
9106 let report =
9107 run_symbol_build_pipeline_controlled(&mut store, &plan, &options, None, &control)?;
9108 Self::encode_named_payload(MCP_PAYLOAD_SYMBOLS_BUILD, &report)
9109 })())
9110 }
9111
9112 fn atlas_symbols_response(
9114 &self,
9115 params: &AtlasSymbolsParams,
9116 context: Option<RequestContext<RoleServer>>,
9117 ) -> McpToolTextResult {
9118 Self::as_mcp_text((|| {
9119 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
9120 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9121 let (state, file, routed_project) = self.state_and_optional_file_key(
9122 params.project_path.as_deref(),
9123 params.worktree.as_deref(),
9124 params.file.as_deref(),
9125 nearest_project,
9126 )?;
9127 self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
9128 let file = file
9129 .as_deref()
9130 .map(|path| validated_indexed_file_key(store, Path::new(path)))
9131 .transpose()?;
9132 let symbols = store.load_classified_symbols(
9133 file.as_deref(),
9134 params.query.as_deref(),
9135 content_selection,
9136 params.limit.unwrap_or(50),
9137 )?;
9138 let toon = Self::with_selected_project_audit(
9139 &state,
9140 routed_project,
9141 encode_agent_payload(&serde_json::json!({
9142 NODE_LABEL_SYMBOLS: render_classified_symbol_rows(&symbols),
9143 })),
9144 )?;
9145 let usage = Self::telemetry_enabled()
9146 .then(|| {
9147 estimated_source_tokens_for_paths(
9148 store,
9149 symbols
9150 .iter()
9151 .map(|classified| classified.symbol.path.as_str()),
9152 )
9153 })
9154 .and_then(Result::ok)
9155 .map(|baseline_tokens| {
9156 McpUsageIntent::estimate(
9157 MCP_EVENT_ATLAS_SYMBOLS,
9158 file.clone(),
9159 params.query.clone(),
9160 baseline_tokens,
9161 )
9162 });
9163 Ok((toon, usage))
9164 })
9165 })())
9166 }
9167
9168 #[tool(
9170 name = "atlas_symbols",
9171 description = "List indexed symbols by optional file and query as compact TOON."
9172 )]
9173 fn atlas_symbols(
9174 &self,
9175 Parameters(params): Parameters<AtlasSymbolsParams>,
9176 context: RequestContext<RoleServer>,
9177 ) -> McpToolTextResult {
9178 self.atlas_symbols_response(¶ms, Some(context))
9179 }
9180
9181 fn detailed_symbol_relations_response(
9183 state: &McpProjectState,
9184 routed_project: bool,
9185 file: &str,
9186 params: &AtlasSymbolRelationsParams,
9187 content_selection: ContentSelection,
9188 analysis: bool,
9189 stores: SymbolRelationStores<'_>,
9190 control: &IndexWorkControl,
9191 ) -> Result<(String, Option<McpUsageIntent>), CliError> {
9192 if params.query.is_some() {
9193 return Err(CliError::Service(ServiceError::InvalidInput(
9194 MCP_ERROR_DETAILED_RELATION_QUERY.to_string(),
9195 )));
9196 }
9197 let primary = stores.primary();
9198 let file = validated_indexed_file_key(primary, Path::new(file))?;
9199 let graph_file = RepositoryFilePath::new(Path::new(&file))
9200 .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
9201 let anchor = if let Some(symbol) = params.symbol.as_ref() {
9202 if symbol.is_empty() {
9203 return Err(CliError::Service(ServiceError::InvalidInput(
9204 MCP_ERROR_DETAILED_RELATION_SYMBOL.to_string(),
9205 )));
9206 }
9207 RelationAnchor::Symbol {
9208 file: graph_file,
9209 name: symbol.clone(),
9210 symbol_kind: params
9211 .symbol_kind
9212 .as_deref()
9213 .and_then(nonempty_str)
9214 .map(parse_symbol_kind)
9215 .transpose()?,
9216 parent: params
9217 .symbol_parent
9218 .as_deref()
9219 .and_then(nonempty_str)
9220 .map(ToString::to_string),
9221 signature: params
9222 .symbol_signature
9223 .as_deref()
9224 .and_then(nonempty_str)
9225 .map(ToString::to_string),
9226 }
9227 } else {
9228 if params
9229 .symbol_parent
9230 .as_deref()
9231 .and_then(nonempty_str)
9232 .is_some()
9233 || params
9234 .symbol_kind
9235 .as_deref()
9236 .and_then(nonempty_str)
9237 .is_some()
9238 || params
9239 .symbol_signature
9240 .as_deref()
9241 .and_then(nonempty_str)
9242 .is_some()
9243 {
9244 return Err(CliError::Service(ServiceError::InvalidInput(
9245 MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR.to_string(),
9246 )));
9247 }
9248 RelationAnchor::File { file: graph_file }
9249 };
9250 let policy = if analysis {
9251 projectatlas_service::RelationNavigationView::Analysis
9252 } else {
9253 projectatlas_service::RelationNavigationView::Detailed
9254 };
9255 let rows =
9256 u32::try_from(params.limit.unwrap_or(policy.default_rows())).map_err(|_overflow| {
9257 CliError::Service(ServiceError::InvalidInput(
9258 MCP_ERROR_DETAILED_RELATION_LIMIT.to_string(),
9259 ))
9260 })?;
9261 let limits = GraphLimits::new(
9262 rows,
9263 params.occurrence_limit.unwrap_or(25),
9264 params.depth.unwrap_or(1),
9265 policy.output_bytes(params.output_bytes)?,
9266 )
9267 .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
9268 let relations = DetailedRelationQuery {
9269 anchor,
9270 direction: parse_relation_direction(
9271 params
9272 .direction
9273 .as_deref()
9274 .unwrap_or(MCP_SYMBOL_RELATION_DIRECTION_DEFAULT),
9275 )?,
9276 relation: params
9277 .relation
9278 .as_deref()
9279 .map(parse_coverage_relation)
9280 .transpose()?,
9281 minimum_confidence: parse_relation_confidence(
9282 params
9283 .minimum_confidence
9284 .as_deref()
9285 .unwrap_or(MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT),
9286 )?,
9287 resolution: parse_relation_resolution(
9288 params
9289 .resolution
9290 .as_deref()
9291 .unwrap_or(MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT),
9292 )?,
9293 content_selection,
9294 include_occurrences: params.include_occurrences.unwrap_or(false),
9295 budget: DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
9296 params.edge_limit,
9297 params.node_limit,
9298 params.visited_limit,
9299 params.occurrence_total_limit,
9300 params.intermediate_bytes,
9301 params.deadline_ms,
9302 )?,
9303 cursor: params.cursor.clone(),
9304 };
9305 let usage = matches!(&stores, SymbolRelationStores::Single(_))
9306 .then(|| {
9307 Self::telemetry_enabled()
9308 .then(|| {
9309 estimated_source_tokens_for_paths(primary, std::iter::once(file.as_str()))
9310 })
9311 .and_then(Result::ok)
9312 .map(|baseline_tokens| {
9313 McpUsageIntent::estimate(
9314 MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
9315 Some(file.clone()),
9316 params.symbol.clone(),
9317 baseline_tokens,
9318 )
9319 })
9320 })
9321 .flatten();
9322
9323 if analysis {
9324 let mode = match params
9325 .analysis_mode
9326 .as_deref()
9327 .unwrap_or(MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE)
9328 {
9329 MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE => RelationAnalysisMode::Architecture,
9330 MCP_RELATION_ANALYSIS_MODE_IMPACT => RelationAnalysisMode::Impact,
9331 MCP_RELATION_ANALYSIS_MODE_TRACE => RelationAnalysisMode::Trace,
9332 MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT => RelationAnalysisMode::Entrypoint,
9333 _unsupported => {
9334 return Err(CliError::Service(ServiceError::InvalidInput(
9335 MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE.to_string(),
9336 )));
9337 }
9338 };
9339 let trace_target = relation_analysis_trace_target(primary, params)?;
9340 let vcs_explicit =
9341 params.vcs.is_some() || params.vcs_base.is_some() || params.vcs_head.is_some();
9342 let vcs = relation_analysis_vcs(params)?;
9343 if mode == RelationAnalysisMode::Entrypoint
9344 && matches!(&stores, SymbolRelationStores::Federated(_))
9345 {
9346 return Err(CliError::Service(ServiceError::InvalidInput(
9347 MCP_ERROR_ENTRYPOINT_FEDERATED.to_string(),
9348 )));
9349 }
9350 if mode == RelationAnalysisMode::Entrypoint
9351 && params
9352 .entrypoints
9353 .as_ref()
9354 .is_some_and(|items| !items.is_empty())
9355 && (params.symbol.is_some()
9356 || params.symbol_parent.is_some()
9357 || params.symbol_kind.is_some()
9358 || params.symbol_signature.is_some())
9359 {
9360 return Err(CliError::Service(ServiceError::InvalidInput(
9361 MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR.to_string(),
9362 )));
9363 }
9364 let entrypoint_profile = if mode == RelationAnalysisMode::Entrypoint {
9365 let anchors = match params.entrypoints.as_ref() {
9366 Some(values) if !values.is_empty() => values
9367 .iter()
9368 .map(|value| serde_json::from_str::<RelationAnchor>(value))
9369 .collect::<Result<Vec<_>, _>>()
9370 .map_err(|error| {
9371 let mut message = MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX.to_string();
9372 message.push_str(&error.to_string());
9373 CliError::Service(ServiceError::InvalidInput(message))
9374 })?,
9375 _ => vec![relations.anchor.clone()],
9376 };
9377 let relation_families = match params.profile_relations.as_ref() {
9378 Some(values) if !values.is_empty() => values
9379 .iter()
9380 .map(|value| parse_coverage_relation(value))
9381 .collect::<Result<Vec<_>, _>>()?,
9382 _ => GraphRelationKind::ALL.to_vec(),
9383 };
9384 Some(EntrypointProfile {
9385 name: params
9386 .profile_name
9387 .clone()
9388 .unwrap_or_else(|| MCP_ENTRYPOINT_PROFILE_DEFAULT_NAME.to_string()),
9389 anchors,
9390 relations: relation_families,
9391 })
9392 } else {
9393 if params.profile_name.is_some()
9394 || params
9395 .entrypoints
9396 .as_ref()
9397 .is_some_and(|items| !items.is_empty())
9398 || params
9399 .profile_relations
9400 .as_ref()
9401 .is_some_and(|items| !items.is_empty())
9402 {
9403 return Err(CliError::Service(ServiceError::InvalidInput(
9404 MCP_ERROR_ENTRYPOINT_CONTROLS_MODE.to_string(),
9405 )));
9406 }
9407 None
9408 };
9409 let query = RelationAnalysisQuery {
9410 relations,
9411 mode,
9412 trace_target,
9413 vcs: (mode == RelationAnalysisMode::Impact || vcs_explicit).then_some(vcs),
9414 include_communities: params.include_communities.unwrap_or(false),
9415 include_cycles: params.include_cycles.unwrap_or(false),
9416 include_dead_code: params.include_dead_code.unwrap_or(false),
9417 entrypoint_profile,
9418 };
9419 let toon = match stores {
9420 SymbolRelationStores::Single(store) => {
9421 let draft = load_relation_analysis(store, &query, Some(control))?;
9422 draft
9423 .fit_output(|report, control| {
9424 Self::with_selected_project_audit_controlled(
9425 state,
9426 routed_project,
9427 controlled_named_output(
9428 OutputFormat::Toon,
9429 MCP_PAYLOAD_SYMBOL_RELATIONS,
9430 report,
9431 control,
9432 )?,
9433 control,
9434 )
9435 })?
9436 .1
9437 }
9438 SymbolRelationStores::Federated(stores) => {
9439 let draft = load_federated_relation_analysis(stores, &query, Some(control))?;
9440 draft
9441 .fit_output(|report, control| {
9442 Self::with_selected_project_audit_controlled(
9443 state,
9444 routed_project,
9445 controlled_named_output(
9446 OutputFormat::Toon,
9447 MCP_PAYLOAD_SYMBOL_RELATIONS,
9448 report,
9449 control,
9450 )?,
9451 control,
9452 )
9453 })?
9454 .1
9455 }
9456 };
9457 return Ok((toon, usage));
9458 }
9459
9460 let toon = match stores {
9461 SymbolRelationStores::Single(store) => {
9462 let draft = load_detailed_relation_page(store, &relations, Some(control))?;
9463 draft
9464 .fit_output(Some(control), |report| {
9465 let payload = if params.compact.unwrap_or(false) {
9466 Self::encode_named_payload(
9467 MCP_PAYLOAD_SYMBOL_RELATIONS,
9468 &McpCompactDetailedRelationReport::new(report, &file, params),
9469 )?
9470 } else {
9471 Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
9472 };
9473 Self::with_selected_project_audit(state, routed_project, payload)
9474 })?
9475 .1
9476 }
9477 SymbolRelationStores::Federated(stores) => {
9478 let draft = load_federated_detailed_relations(stores, &relations, Some(control))?;
9479 draft
9480 .fit_output(Some(control), |report| {
9481 let payload = if params.compact.unwrap_or(false) {
9482 Self::encode_named_payload(
9483 MCP_PAYLOAD_SYMBOL_RELATIONS,
9484 &McpCompactFederatedDetailedRelationReport::new(
9485 report, &file, params,
9486 ),
9487 )?
9488 } else {
9489 Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
9490 };
9491 Self::with_selected_project_audit(state, routed_project, payload)
9492 })?
9493 .1
9494 }
9495 };
9496 Ok((toon, usage))
9497 }
9498
9499 fn atlas_symbol_relations_response(
9501 &self,
9502 params: &AtlasSymbolRelationsParams,
9503 context: Option<RequestContext<RoleServer>>,
9504 ) -> McpToolTextResult {
9505 Self::as_mcp_text((|| {
9506 let (detailed, analysis) = match params
9507 .view
9508 .as_deref()
9509 .unwrap_or(MCP_SYMBOL_RELATION_VIEW_DETAILED)
9510 {
9511 MCP_SYMBOL_RELATION_VIEW_LEGACY => (false, false),
9512 MCP_SYMBOL_RELATION_VIEW_DETAILED => (true, false),
9513 MCP_SYMBOL_RELATION_VIEW_ANALYSIS => (true, true),
9514 _unsupported => {
9515 return Err(CliError::Service(ServiceError::InvalidInput(
9516 MCP_ERROR_SYMBOL_RELATION_VIEW.to_string(),
9517 )));
9518 }
9519 };
9520 if params.compact.unwrap_or(false) && (!detailed || analysis) {
9521 return Err(CliError::Service(ServiceError::InvalidInput(
9522 MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW.to_string(),
9523 )));
9524 }
9525 if !analysis && relation_analysis_controls_present(params) {
9526 return Err(CliError::Service(ServiceError::InvalidInput(
9527 MCP_ERROR_ANALYSIS_VIEW_REQUIRED.to_string(),
9528 )));
9529 }
9530 let legacy_limits = if detailed {
9531 None
9532 } else {
9533 if params.roots.is_some()
9534 || params.worktrees.is_some()
9535 || params.compact.is_some()
9536 || params.cursor.is_some()
9537 || params.symbol.is_some()
9538 || params.symbol_parent.is_some()
9539 || params.symbol_kind.is_some()
9540 || params.symbol_signature.is_some()
9541 || params.direction.is_some()
9542 || params.relation.is_some()
9543 || params.minimum_confidence.is_some()
9544 || params.resolution.is_some()
9545 || params.depth.is_some()
9546 || params.include_occurrences.is_some()
9547 || params.occurrence_limit.is_some()
9548 || params.edge_limit.is_some()
9549 || params.node_limit.is_some()
9550 || params.visited_limit.is_some()
9551 || params.occurrence_total_limit.is_some()
9552 {
9553 return Err(CliError::Service(ServiceError::InvalidInput(
9554 MCP_ERROR_LEGACY_RELATION_CONTROLS.to_string(),
9555 )));
9556 }
9557 Some((
9558 projectatlas_db::LegacyRelationReadBudget::new(
9559 params.limit.unwrap_or(
9560 projectatlas_service::RelationNavigationView::Legacy.default_rows(),
9561 ),
9562 Duration::from_millis(params.deadline_ms.unwrap_or(1_000)),
9563 params.intermediate_bytes.unwrap_or(
9564 projectatlas_db::LegacyRelationReadBudget::MAX_INTERMEDIATE_BYTES,
9565 ),
9566 )?,
9567 projectatlas_service::RelationNavigationView::Legacy
9568 .output_bytes(params.output_bytes)?,
9569 ))
9570 };
9571 if detailed && !analysis && params.file.is_none() {
9572 return Err(CliError::Service(ServiceError::InvalidInput(
9573 MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
9574 )));
9575 }
9576 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
9577 if !detailed && params.content_selection.is_some() {
9578 return Err(CliError::Service(ServiceError::InvalidInput(
9579 MCP_ERROR_CONTENT_SELECTION_RELATION_VIEW.to_string(),
9580 )));
9581 }
9582 if params.roots.is_some() && params.worktrees.is_some() {
9583 return Err(CliError::Service(ServiceError::InvalidInput(
9584 MCP_ERROR_FEDERATED_SELECTOR_CONFLICT.to_string(),
9585 )));
9586 }
9587 let federated_worktrees = params.worktrees.as_deref();
9588 if let Some(worktrees) = federated_worktrees {
9589 validate_federated_root_count(worktrees.len()).map_err(CliError::Service)?;
9590 if params.project_path.is_some() {
9591 return Err(CliError::Service(ServiceError::InvalidInput(
9592 MCP_ERROR_FEDERATED_PROJECT_PATH_CONFLICT.to_string(),
9593 )));
9594 }
9595 if params.worktree.as_deref().is_some_and(|primary| {
9596 worktrees
9597 .first()
9598 .is_none_or(|first| primary.trim() != first.trim())
9599 }) {
9600 return Err(CliError::Service(ServiceError::InvalidInput(
9601 MCP_ERROR_FEDERATED_PRIMARY_CONFLICT.to_string(),
9602 )));
9603 }
9604 }
9605 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9606 let selected_worktree = federated_worktrees
9607 .and_then(|worktrees| worktrees.first())
9608 .map(String::as_str)
9609 .or(params.worktree.as_deref());
9610 let profile_file = if analysis
9611 && params.analysis_mode.as_deref() == Some(MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT)
9612 {
9613 params
9614 .entrypoints
9615 .as_ref()
9616 .and_then(|values| values.first())
9617 .map(|value| serde_json::from_str::<RelationAnchor>(value))
9618 .transpose()
9619 .map_err(|error| {
9620 let mut message = MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX.to_string();
9621 message.push_str(&error.to_string());
9622 CliError::Service(ServiceError::InvalidInput(message))
9623 })?
9624 .map(|anchor| match anchor {
9625 RelationAnchor::File { file } | RelationAnchor::Symbol { file, .. } => {
9626 file.as_str().to_string()
9627 }
9628 })
9629 } else {
9630 None
9631 };
9632 let (state, file, routed_project) = self.state_and_optional_file_key(
9633 params.project_path.as_deref(),
9634 selected_worktree,
9635 params.file.as_deref().or(profile_file.as_deref()),
9636 nearest_project,
9637 )?;
9638 if params.roots.is_some() || federated_worktrees.is_some() {
9639 if !detailed {
9640 return Err(CliError::Service(ServiceError::InvalidInput(
9641 MCP_ERROR_FEDERATED_RELATION_VIEW.to_string(),
9642 )));
9643 }
9644 let file = file.as_deref().ok_or_else(|| {
9645 CliError::Service(ServiceError::InvalidInput(
9646 MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
9647 ))
9648 })?;
9649 let control =
9650 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None))
9651 .with_timeout_ceiling(Duration::from_millis(
9652 params.deadline_ms.unwrap_or(10_000).clamp(1, 60_000),
9653 ));
9654 let bridge = context
9655 .map(|context| McpRequestCancellationBridge::start(&context, &control))
9656 .transpose()?;
9657 let (roots, worktree_selections) = if let Some(worktrees) = federated_worktrees {
9658 let (roots, selections) = self.federated_worktree_roots(worktrees)?;
9659 (roots, Some(selections))
9660 } else {
9661 (
9662 params
9663 .roots
9664 .as_deref()
9665 .unwrap_or_default()
9666 .iter()
9667 .map(PathBuf::from)
9668 .collect::<Vec<_>>(),
9669 None,
9670 )
9671 };
9672 let worktree_labels = worktree_selections.as_ref().map(|selections| {
9673 selections
9674 .iter()
9675 .map(|selection| selection.alias.clone())
9676 .collect::<Vec<_>>()
9677 });
9678 let stores = open_federated_atlas_stores_for_project(
9679 &state.db_path,
9680 &state.root,
9681 state.config_path.as_deref(),
9682 &roots,
9683 worktree_labels.as_deref(),
9684 &control,
9685 )?;
9686 let stores = if let Some(selections) = worktree_selections.as_deref() {
9687 Self::require_federated_worktree_identities(stores, selections)?
9688 } else {
9689 stores
9690 };
9691 let result = Self::detailed_symbol_relations_response(
9692 &state,
9693 routed_project,
9694 file,
9695 params,
9696 content_selection,
9697 analysis,
9698 SymbolRelationStores::Federated(stores),
9699 &control,
9700 )
9701 .map(|(toon, _usage)| toon);
9702 if let Some(bridge) = bridge.as_ref() {
9703 bridge.synchronize(&control);
9704 }
9705 drop(bridge);
9706 return result;
9707 }
9708 self.with_fresh_string_and_usage_controlled_for_request(
9709 &state,
9710 context,
9711 |store, _stamp, control| {
9712 let file = file
9713 .as_deref()
9714 .map(|path| validated_indexed_file_key(store, Path::new(path)))
9715 .transpose()?;
9716 if detailed {
9717 let file = file.as_deref().ok_or_else(|| {
9718 CliError::Service(ServiceError::InvalidInput(
9719 MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
9720 ))
9721 })?;
9722 return Self::detailed_symbol_relations_response(
9723 &state,
9724 routed_project,
9725 file,
9726 params,
9727 content_selection,
9728 analysis,
9729 SymbolRelationStores::Single(store),
9730 control,
9731 );
9732 }
9733
9734 let (budget, output_limit) = legacy_limits.ok_or_else(|| {
9735 CliError::Service(ServiceError::InvalidInput(
9736 MCP_ERROR_LEGACY_RELATION_BUDGET.to_string(),
9737 ))
9738 })?;
9739 let relations = store
9740 .load_symbol_relations_bounded(
9741 file.as_deref(),
9742 params.query.as_deref(),
9743 budget,
9744 control,
9745 )?
9746 .relations;
9747 let toon = Self::with_selected_project_audit(
9748 &state,
9749 routed_project,
9750 render_symbol_relations(&relations),
9751 )?;
9752 projectatlas_service::check_legacy_relation_output(&toon, output_limit)?;
9753 let usage = Self::telemetry_enabled()
9754 .then(|| {
9755 estimated_source_tokens_for_paths(
9756 store,
9757 relations.iter().map(|relation| relation.path.as_str()),
9758 )
9759 })
9760 .and_then(Result::ok)
9761 .map(|baseline_tokens| {
9762 McpUsageIntent::estimate(
9763 MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
9764 file.clone(),
9765 params.query.clone(),
9766 baseline_tokens,
9767 )
9768 });
9769 Ok((toon, usage))
9770 },
9771 )
9772 })())
9773 }
9774
9775 #[tool(
9777 name = "atlas_symbol_relations",
9778 description = "Navigate anchored detailed relation pages (10 rows, 32 KiB by default); explicit legacy returns only complete small arrays, and analysis remains opt-in."
9779 )]
9780 fn atlas_symbol_relations(
9781 &self,
9782 Parameters(params): Parameters<AtlasSymbolRelationsParams>,
9783 context: RequestContext<RoleServer>,
9784 ) -> McpToolTextResult {
9785 self.atlas_symbol_relations_response(¶ms, Some(context))
9786 }
9787
9788 #[tool(
9790 name = "atlas_health",
9791 description = "Return a bounded ProjectAtlas structural health page with optional category, severity, and path-prefix filters."
9792 )]
9793 fn atlas_health(
9794 &self,
9795 Parameters(params): Parameters<AtlasHealthParams>,
9796 context: RequestContext<RoleServer>,
9797 ) -> McpToolTextResult {
9798 Self::as_mcp_text((|| {
9799 let state =
9800 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
9801 if params.coverage.unwrap_or(false) {
9802 let query = coverage_query_from_params(¶ms)?;
9803 return self.with_fresh_string_and_usage_controlled_for_request(
9804 &state,
9805 Some(context),
9806 |store, stamp, control| {
9807 let mut report =
9808 load_coverage_discovery_controlled(store, query.clone(), control)?;
9809 let toon = finalize_coverage_output(OutputFormat::Toon, &mut report)?;
9810 let usage = Self::telemetry_enabled()
9811 .then(|| {
9812 self.estimated_source_tokens_cached(
9813 &state, store, &stamp, None, None,
9814 )
9815 })
9816 .and_then(Result::ok)
9817 .map(|baseline_tokens| {
9818 McpUsageIntent::directory_walk(
9819 MCP_EVENT_ATLAS_HEALTH,
9820 None,
9821 None,
9822 baseline_tokens,
9823 )
9824 });
9825 Ok((toon, usage))
9826 },
9827 );
9828 }
9829 if has_coverage_filters(¶ms) {
9830 return Err(CliError::InvalidInput(
9831 MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE.to_string(),
9832 ));
9833 }
9834 let scope = if params.source_only.unwrap_or(false) {
9835 HealthScope::source_only()
9836 } else {
9837 HealthScope::all()
9838 };
9839 let query = health_query_from_params(¶ms, scope)?;
9840 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
9841 let page = store.unresolved_health_findings_page_current(&query)?;
9842 let toon = render_health_page(&page, &query);
9843 let usage = Self::telemetry_enabled()
9844 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
9845 .and_then(Result::ok)
9846 .map(|baseline_tokens| {
9847 McpUsageIntent::directory_walk(
9848 MCP_EVENT_ATLAS_HEALTH,
9849 None,
9850 None,
9851 baseline_tokens,
9852 )
9853 });
9854 Ok((toon, usage))
9855 })
9856 })())
9857 }
9858
9859 #[tool(
9861 name = "atlas_health_resolve",
9862 description = "Mark a deterministic ProjectAtlas health finding as agent-resolved with rationale."
9863 )]
9864 fn atlas_health_resolve(
9865 &self,
9866 Parameters(params): Parameters<AtlasHealthResolveParams>,
9867 ) -> McpToolTextResult {
9868 Self::as_mcp_text((|| {
9869 let state = self.state_for_target(params.project_path, params.worktree)?;
9870 let store = Self::open_existing_mut_store(&state, &self.control_state)?;
9871 let resolution = HealthResolution {
9872 finding_id: params.finding_id,
9873 category: params.category,
9874 path: params.path,
9875 related_path: params.related_path,
9876 rationale: params.rationale,
9877 };
9878 store.resolve_health_finding(&resolution)?;
9879 Self::encode_named_payload(MCP_PAYLOAD_HEALTH_RESOLUTION, &resolution)
9880 })())
9881 }
9882
9883 #[tool(
9885 name = "atlas_lint",
9886 description = "Run ProjectAtlas lint checks and return an ok flag, CLI-compatible exit code, and report text."
9887 )]
9888 fn atlas_lint(&self, Parameters(params): Parameters<AtlasLintParams>) -> McpToolTextResult {
9889 Self::as_mcp_text((|| {
9890 let state =
9891 self.admin_project_root(params.project_path.clone(), params.worktree.clone())?;
9892 let report = Self::lint_report_for_state(&state, ¶ms)?;
9893 Self::encode_named_payload(MCP_PAYLOAD_LINT, &report)
9894 })())
9895 }
9896
9897 #[tool(
9899 name = "atlas_token_report",
9900 description = "Return ProjectAtlas token-savings telemetry for the whole index or one session."
9901 )]
9902 fn atlas_token_report(
9903 &self,
9904 Parameters(params): Parameters<AtlasTokenParams>,
9905 ) -> McpToolTextResult {
9906 Self::as_mcp_text((|| {
9907 let state =
9908 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
9909 let repository_scope = params.session.is_none()
9910 && state.root == self.control_state.root
9911 && state.db_path == self.control_state.db_path;
9912 let load_report = |request: TokenReportRequest<'_>| {
9913 if repository_scope {
9914 load_synchronized_repository_token_report(
9915 &state.db_path,
9916 &state.root,
9917 state
9918 .worktree
9919 .as_ref()
9920 .and_then(|selection| selection.control_project_instance_id),
9921 request,
9922 )
9923 } else {
9924 let store = Self::open_read_store(&state)?;
9925 load_token_report(&store, request).map_err(CliError::from)
9926 }
9927 };
9928 let include_chart = params.include_chart.unwrap_or(false);
9929 let chart_theme = Self::parse_token_chart_theme(params.theme.as_deref())?;
9930 if let Some(window) = params.trend_window.as_deref() {
9931 if params.benchmark_results.is_some() {
9932 return Err(CliError::InvalidInput(
9933 TOKEN_TREND_BENCHMARK_ERROR.to_string(),
9934 ));
9935 }
9936 let window = TokenTrendWindow::parse(window).ok_or_else(|| {
9937 CliError::InvalidInput(format!(
9938 "unsupported token trend window {window:?}; {TOKEN_TREND_WINDOW_ERROR_SUFFIX}"
9939 ))
9940 })?;
9941 let request = if repository_scope {
9942 TokenReportRequest::RepositoryTrends { window }
9943 } else {
9944 TokenReportRequest::Trends {
9945 caller_label: params.session.as_deref(),
9946 window,
9947 }
9948 };
9949 let report = match load_report(request)? {
9950 TokenReport::Trends(report) => report,
9951 TokenReport::Overview(_) => {
9952 return Err(CliError::InvalidInput(
9953 TOKEN_TRENDS_RESULT_VARIANT_MISMATCH.to_string(),
9954 ));
9955 }
9956 };
9957 if include_chart {
9958 let chart = render_token_trend_dashboard_plain_with_theme(&report, chart_theme);
9959 let output = Self::encode_two_named_payloads(
9960 MCP_PAYLOAD_TOKEN_TRENDS,
9961 &report,
9962 MCP_PAYLOAD_CHART,
9963 &chart,
9964 )?;
9965 return Self::with_selected_project_audit(
9966 &state,
9967 state.worktree.is_some(),
9968 output,
9969 );
9970 }
9971 return Self::with_selected_project_audit(
9972 &state,
9973 state.worktree.is_some(),
9974 render_token_trends(&report),
9975 );
9976 }
9977 let request = if repository_scope {
9978 TokenReportRequest::RepositoryOverview {
9979 benchmark_results: params.benchmark_results.as_deref().map(Path::new),
9980 }
9981 } else {
9982 TokenReportRequest::Overview {
9983 caller_label: params.session.as_deref(),
9984 benchmark_results: params.benchmark_results.as_deref().map(Path::new),
9985 }
9986 };
9987 let overview = match load_report(request)? {
9988 TokenReport::Overview(overview) => overview,
9989 TokenReport::Trends(_) => {
9990 return Err(CliError::InvalidInput(
9991 TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH.to_string(),
9992 ));
9993 }
9994 };
9995 if include_chart {
9996 let chart = render_token_dashboard_plain_with_theme(
9997 &overview,
9998 params.session.as_deref(),
9999 chart_theme,
10000 );
10001 let output = Self::encode_two_named_payloads(
10002 MCP_PAYLOAD_TOKEN_SAVINGS,
10003 &overview,
10004 MCP_PAYLOAD_CHART,
10005 &chart,
10006 )?;
10007 return Self::with_selected_project_audit(&state, state.worktree.is_some(), output);
10008 }
10009 Self::with_selected_project_audit(
10010 &state,
10011 state.worktree.is_some(),
10012 render_token_overview(&overview),
10013 )
10014 })())
10015 }
10016
10017 #[tool(
10019 name = "atlas_parity_report",
10020 description = "Return a ProjectAtlas repository-intelligence parity gate report for release and agent-runtime readiness."
10021 )]
10022 fn atlas_parity_report(
10023 &self,
10024 Parameters(params): Parameters<AtlasParityParams>,
10025 context: RequestContext<RoleServer>,
10026 ) -> McpToolTextResult {
10027 Self::as_mcp_text((|| {
10028 let state = self.state_for_target(params.project_path, params.worktree)?;
10029 let profile = params
10030 .profile
10031 .unwrap_or_else(|| crate::REPOSITORY_INTELLIGENCE_PROFILE.to_string());
10032 self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
10033 Ok(render_parity_report(&build_parity_report(store, &profile)?))
10034 })
10035 })())
10036 }
10037
10038 #[tool(
10040 name = "atlas_settings",
10041 description = "Return ProjectAtlas local settings, config, and durable index paths."
10042 )]
10043 fn atlas_settings(
10044 &self,
10045 Parameters(params): Parameters<AtlasProjectParams>,
10046 ) -> McpToolTextResult {
10047 Self::as_mcp_text((|| {
10048 let state = self.state_for_target(params.project_path, params.worktree)?;
10049 self.render_settings_with_capabilities(&state)
10050 })())
10051 }
10052
10053 #[tool(
10055 name = "atlas_watch_status",
10056 description = "Return ProjectAtlas watcher availability and current operating mode."
10057 )]
10058 fn atlas_watch_status(
10059 &self,
10060 Parameters(params): Parameters<AtlasProjectParams>,
10061 ) -> McpToolTextResult {
10062 let state = match self.state_for_target(params.project_path, params.worktree) {
10063 Ok(state) => state,
10064 Err(error) => return Self::as_mcp_text(Err(error)),
10065 };
10066 let mut report = watcher_status_report(false);
10067 if !state.db_path.exists() {
10068 report
10069 .recommendation
10070 .push_str(WATCH_STATUS_SCAN_RECOMMENDATION);
10071 }
10072 Self::as_mcp_text(Ok(render_watch_status(&report)))
10073 }
10074
10075 #[tool(
10077 name = "atlas_watch_once",
10078 description = "Run one MCP-safe watcher refresh pass over the repository and rebuild changed symbols, with optional worker, timeout, and text-index size controls."
10079 )]
10080 fn atlas_watch_once(
10081 &self,
10082 Parameters(params): Parameters<AtlasWatchOnceParams>,
10083 ) -> McpToolTextResult {
10084 Self::as_mcp_text((|| {
10085 let nearest_project = self.nearest_project_enabled(params.nearest_project);
10086 let background = params.background.unwrap_or(false);
10087 let (state, path) = if background {
10088 self.background_state_and_root_path(
10089 params.project_path,
10090 params.worktree,
10091 params.path,
10092 nearest_project,
10093 )?
10094 } else {
10095 self.state_and_root_path(
10096 params.project_path,
10097 params.worktree,
10098 params.path,
10099 nearest_project,
10100 )?
10101 };
10102 let symbol_options = SymbolBuildOptions::new(
10103 MAX_SYMBOL_FILE_BYTES,
10104 params.max_workers,
10105 params.timeout_seconds,
10106 );
10107 let text_index_max_bytes = params.text_index_max_bytes;
10108 if background {
10109 let control_state = self.control_state.clone();
10110 let observations = Arc::clone(&self.source_observations);
10111 let task = self.start_index_task(
10112 McpTaskOperation::WatchOnce,
10113 symbol_options,
10114 MCP_TOOL_ATLAS_OVERVIEW,
10115 move |control, symbol_options| {
10116 let plan = ScanRuntimePlan::for_path_controlled(
10117 state.config_path.as_deref(),
10118 &path,
10119 text_index_max_bytes,
10120 control,
10121 )?;
10122 Self::run_observed_watch_refresh(
10123 &state,
10124 &control_state,
10125 &plan,
10126 &symbol_options,
10127 control,
10128 &observations,
10129 text_index_max_bytes.is_none(),
10130 )?;
10131 Ok(())
10132 },
10133 )?;
10134 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
10135 }
10136 let control = index_work_control(&symbol_options);
10137 let plan = ScanRuntimePlan::for_path_controlled(
10138 state.config_path.as_deref(),
10139 &path,
10140 text_index_max_bytes,
10141 &control,
10142 )?;
10143 let report = Self::run_observed_watch_refresh(
10144 &state,
10145 &self.control_state,
10146 &plan,
10147 &symbol_options,
10148 &control,
10149 &self.source_observations,
10150 text_index_max_bytes.is_none(),
10151 )?;
10152 Self::encode_named_payload(MCP_PAYLOAD_WATCH, &report)
10153 })())
10154 }
10155
10156 #[tool(
10158 name = "atlas_strip_legacy_purpose",
10159 description = "Preview or remove legacy .purpose files after their metadata has been imported to SQLite."
10160 )]
10161 fn atlas_strip_legacy_purpose(
10162 &self,
10163 Parameters(params): Parameters<AtlasStripLegacyParams>,
10164 ) -> McpToolTextResult {
10165 Self::as_mcp_text((|| {
10166 let nearest_project = self.nearest_project_enabled(params.nearest_project);
10167 let (state, path) = self.state_and_root_path(
10168 params.project_path,
10169 params.worktree,
10170 params.path,
10171 nearest_project,
10172 )?;
10173 let report = strip_legacy_purpose(
10174 &path,
10175 state.config_path.as_deref(),
10176 params.apply.unwrap_or(false),
10177 params.dry_run.unwrap_or(false),
10178 params
10179 .strip_source_headers
10180 .unwrap_or_else(|| state.config_path.is_some()),
10181 )?;
10182 Self::encode_named_payload(MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION, &report)
10183 })())
10184 }
10185
10186 #[tool(
10188 name = "atlas_reset_index",
10189 description = "Preview or clear ProjectAtlas local SQLite index/cache files for recovery."
10190 )]
10191 fn atlas_reset_index(
10192 &self,
10193 Parameters(params): Parameters<AtlasResetIndexParams>,
10194 ) -> McpToolTextResult {
10195 Self::as_mcp_text((|| {
10196 let state = self.state_for_target(params.project_path, params.worktree)?;
10197 let apply = params.apply.unwrap_or(false);
10198 let dry_run = params.dry_run.unwrap_or(false);
10199 let include_mcp_config = params.include_mcp_config.unwrap_or(false);
10200 let admission = crate::runtime::preflight_managed_reset(&state.db_path, &state.root)?;
10201 let database =
10202 crate::runtime::admitted_database_path(&state.db_path, admission.as_ref());
10203 let report = if apply && !dry_run {
10204 if let Some(selection) = state
10205 .worktree
10206 .as_ref()
10207 .filter(|selection| selection.registration_id.is_some())
10208 {
10209 self.reset_registered_worktree_index(&state, selection, include_mcp_config)?
10210 } else {
10211 reset_index_files(
10212 database,
10213 true,
10214 false,
10215 include_mcp_config,
10216 admission.as_ref(),
10217 )?
10218 }
10219 } else {
10220 reset_index_files(
10221 database,
10222 apply,
10223 dry_run,
10224 include_mcp_config,
10225 admission.as_ref(),
10226 )?
10227 };
10228 Self::encode_named_payload(MCP_PAYLOAD_RESET_INDEX, &report)
10229 })())
10230 }
10231
10232 #[tool(
10234 name = "atlas_mcp_config",
10235 description = "Return a generated ProjectAtlas MCP config document for mcp-json, codex, claude-code, or opencode hosts."
10236 )]
10237 fn atlas_mcp_config(
10238 &self,
10239 Parameters(params): Parameters<AtlasMcpConfigParams>,
10240 ) -> McpToolTextResult {
10241 Self::as_mcp_text((|| {
10242 let state = self.admin_project_root(params.project_path, params.worktree)?;
10243 let harness = Self::parse_harness_config(params.harness.as_deref())?;
10244 let server_name = params
10245 .server_name
10246 .unwrap_or_else(|| MCP_DEFAULT_CONFIG_SERVER_NAME.to_string());
10247 let report = build_harness_mcp_config_report(
10248 harness,
10249 &server_name,
10250 &state.db_path,
10251 state.config_path.as_deref(),
10252 params.nearest_project.unwrap_or(false),
10253 )?;
10254 Self::encode_named_payload(MCP_PAYLOAD_MCP_CONFIG, &report)
10255 })())
10256 }
10257
10258 #[tool(
10260 name = "atlas_runtime_info",
10261 description = "Return ProjectAtlas runtime identity, version, capabilities, and compiled MCP tool names."
10262 )]
10263 fn atlas_runtime_info(
10264 &self,
10265 Parameters(_params): Parameters<AtlasProjectParams>,
10266 ) -> McpToolTextResult {
10267 let _session_scope = self.session.as_str();
10268 Self::as_mcp_text(Ok(render_runtime_info(&build_runtime_info())))
10269 }
10270
10271 #[tool(
10273 name = "atlas_session_brief",
10274 description = "Return selected project identity, index state, ranked candidates, blockers, and typed next-call recommendations for agent startup."
10275 )]
10276 fn atlas_session_brief(
10277 &self,
10278 Parameters(params): Parameters<AtlasSessionBriefParams>,
10279 context: RequestContext<RoleServer>,
10280 ) -> McpToolTextResult {
10281 Self::as_mcp_text((|| {
10282 if params.compact.unwrap_or(false) {
10283 let brief = self.build_compact_session_brief(params, Some(context))?;
10284 Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
10285 } else {
10286 let brief = self.build_session_brief(params, Some(context))?;
10287 Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
10288 }
10289 })())
10290 }
10291
10292 #[tool(
10294 name = "atlas_task_status",
10295 description = "Return typed status for a bounded MCP task-progress record."
10296 )]
10297 fn atlas_task_status(
10298 &self,
10299 Parameters(params): Parameters<AtlasTaskParams>,
10300 ) -> McpToolTextResult {
10301 Self::as_mcp_text((|| {
10302 let status = self.task_status(params.task_id)?;
10303 Self::encode_named_payload(MCP_PAYLOAD_TASK_STATUS, &status)
10304 })())
10305 }
10306
10307 #[tool(
10309 name = "atlas_task_cancel",
10310 description = "Request cancellation for a bounded MCP task-progress record."
10311 )]
10312 fn atlas_task_cancel(
10313 &self,
10314 Parameters(params): Parameters<AtlasTaskParams>,
10315 ) -> McpToolTextResult {
10316 Self::as_mcp_text((|| {
10317 let cancel = self.task_cancel(params.task_id)?;
10318 Self::encode_named_payload(MCP_PAYLOAD_TASK_CANCEL, &cancel)
10319 })())
10320 }
10321
10322 #[tool(
10324 name = "atlas_purpose_queue",
10325 description = "Return a bounded folder-first queue of ProjectAtlas paths that need agent purpose curation."
10326 )]
10327 fn atlas_purpose_queue(
10328 &self,
10329 Parameters(params): Parameters<AtlasPurposeQueueParams>,
10330 context: RequestContext<RoleServer>,
10331 ) -> McpToolTextResult {
10332 Self::as_mcp_text((|| {
10333 let state = self.state_for_target(
10334 params.health.project_path.clone(),
10335 params.health.worktree.clone(),
10336 )?;
10337 let query =
10338 health_query_from_params(¶ms.health, purpose_queue_scope(¶ms.health))?;
10339 let task = params
10340 .task
10341 .as_deref()
10342 .unwrap_or(MCP_PURPOSE_TASK_QUEUE)
10343 .to_string();
10344 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
10345 let page = purpose_curation_page(store, &query, &task)?;
10346 let toon = render_purpose_curation_page(&page);
10347 let usage = Self::telemetry_enabled()
10348 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
10349 .and_then(Result::ok)
10350 .map(|baseline_tokens| {
10351 McpUsageIntent::directory_walk(
10352 MCP_EVENT_ATLAS_PURPOSE_QUEUE,
10353 None,
10354 None,
10355 baseline_tokens,
10356 )
10357 });
10358 Ok((toon, usage))
10359 })
10360 })())
10361 }
10362
10363 #[tool(
10365 name = "atlas_purpose_set",
10366 description = "Set agent-approved ProjectAtlas purpose metadata for one indexed path."
10367 )]
10368 fn atlas_purpose_set(
10369 &self,
10370 Parameters(params): Parameters<AtlasPurposeSetParams>,
10371 context: RequestContext<RoleServer>,
10372 ) -> McpToolTextResult {
10373 Self::as_mcp_text((|| {
10374 let state = self.state_for_target(params.project_path, params.worktree)?;
10375 let node_key = Self::preflight_purpose_path(&state, ¶ms.path)?;
10376 self.with_admitted_purpose_mutation(&state, Some(context), |store| {
10377 Self::require_indexed_purpose_path(store, &node_key)?;
10378 store.set_purpose(&node_key, ¶ms.purpose, PurposeSource::Agent)?;
10379 let classification = if store
10380 .load_node_by_path(&node_key)?
10381 .is_some_and(|node| node.node.kind == projectatlas_core::NodeKind::File)
10382 {
10383 store
10384 .file_content_classifications_for_paths(std::slice::from_ref(&node_key))?
10385 .first()
10386 .map(|row| row.classification)
10387 } else {
10388 None
10389 };
10390 Self::encode_serialized_payload(McpPurposeSetResponse {
10391 purpose_set: McpPurposeSetPayload {
10392 path: node_key,
10393 classification,
10394 status: PurposeStatus::Approved,
10395 source: PurposeSource::Agent,
10396 agent_reviewed: true,
10397 },
10398 })
10399 })
10400 })())
10401 }
10402
10403 #[tool(
10405 name = "atlas_purpose_review",
10406 description = "Preview or apply agent-reviewed ProjectAtlas purpose metadata for multiple indexed paths."
10407 )]
10408 fn atlas_purpose_review(
10409 &self,
10410 Parameters(params): Parameters<AtlasPurposeReviewParams>,
10411 context: RequestContext<RoleServer>,
10412 ) -> McpToolTextResult {
10413 Self::as_mcp_text((|| {
10414 let apply = params.apply.unwrap_or(false);
10415 let requests = params
10416 .items
10417 .into_iter()
10418 .map(|item| PurposeReviewRequest {
10419 path: item.path,
10420 purpose: item.purpose,
10421 confirm_existing: item.confirm_existing.unwrap_or(false),
10422 task: item.task,
10423 work_key: item.work_key,
10424 state_token: item.state_token,
10425 })
10426 .collect::<Vec<_>>();
10427 validate_purpose_review_admission(&requests)?;
10428 let state = self.state_for_target(params.project_path, params.worktree)?;
10429 if apply {
10430 return self.with_admitted_purpose_mutation(&state, Some(context), |store| {
10431 let report = review_purposes(store, &requests, true)?;
10432 Ok(render_purpose_review_report(&report))
10433 });
10434 }
10435 self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
10436 let report = review_purposes(store, &requests, false)?;
10437 Ok(render_purpose_review_report(&report))
10438 })
10439 })())
10440 }
10441}
10442
10443#[allow(clippy::unused_async_trait_impl)]
10444#[tool_handler(router = self.tool_router)]
10445impl ServerHandler for ProjectAtlasMcpServer {
10446 fn get_info(&self) -> ServerConfig {
10447 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
10448 .with_server_info(Implementation::new(
10449 MCP_SERVER_NAME,
10450 env!("CARGO_PKG_VERSION"),
10451 ))
10452 .with_instructions(MCP_SERVER_INSTRUCTIONS)
10453 }
10454}
10455
10456fn mcp_unix_time_ms() -> u128 {
10458 SystemTime::now()
10459 .duration_since(UNIX_EPOCH)
10460 .map_or(0, |duration| duration.as_millis())
10461}
10462
10463fn task_error_is_canceled(error: &CliError) -> bool {
10465 std::iter::successors(
10466 Some(error as &(dyn std::error::Error + 'static)),
10467 |source| source.source(),
10468 )
10469 .any(|source| {
10470 matches!(
10471 source.downcast_ref::<IndexWorkFailure>(),
10472 Some(IndexWorkFailure::Cancelled { .. })
10473 )
10474 })
10475}
10476
10477fn bounded_task_error(error: &CliError) -> String {
10479 error
10480 .to_string()
10481 .chars()
10482 .take(MCP_TASK_ERROR_MAX_CHARS)
10483 .collect()
10484}
10485
10486#[cfg(test)]
10487mod tests {
10488 use super::*;
10489 use crate::atlas_map::init_project_with_config;
10490 use notify::{Event, EventKind, event::ModifyKind};
10491 use projectatlas_core::graph::{
10492 Completeness, ConfidenceClass, EntitySelector, ExtendedRelationKind, ExternalSelector,
10493 GraphEntity, GraphIdentityText, GraphRelationKind, LogicalRelation, PackageSelector,
10494 RelationResolution, RepositoryFilePath,
10495 };
10496 use projectatlas_core::symbols::RelationKind;
10497 use projectatlas_core::{
10498 CanonicalProjectRoot, IndexCancellation, IndexWorkStage, RankedConnectionDirection,
10499 RankedConnectionKind, RankedConnectionTarget,
10500 };
10501 use projectatlas_db::ProjectRootTransition;
10502 use std::collections::BTreeSet;
10503 use std::fs;
10504 use std::io;
10505 #[cfg(unix)]
10506 use std::os::unix::ffi::OsStringExt;
10507 use std::process::Command as StdCommand;
10508 use std::time::{Duration, Instant};
10509
10510 fn require(condition: bool, message: &str) -> Result<(), Box<dyn std::error::Error>> {
10511 if condition {
10512 Ok(())
10513 } else {
10514 Err(io::Error::other(message.to_string()).into())
10515 }
10516 }
10517
10518 #[test]
10519 fn mcp_map_response_limit_checks_encoded_envelope_and_typed_error()
10520 -> Result<(), Box<dyn std::error::Error>> {
10521 let error = CliError::AtlasMap(crate::atlas_map::AtlasMapError::ResponseLimitExceeded {
10522 observed: 5,
10523 limit: 4,
10524 });
10525 let payload: serde_json::Value =
10526 toon_format::decode_default(&ProjectAtlasMcpServer::encode_error_payload(&error))?;
10527 require(
10528 payload["error"]["kind"] == "response_limit_exceeded"
10529 && payload["error"]["message"]
10530 .as_str()
10531 .is_some_and(|message| message.contains("observed 5, limit 4")),
10532 "MCP map response limit lost its typed error",
10533 )?;
10534
10535 let report = McpMapReport {
10536 root: None,
10537 map_path: None,
10538 written: false,
10539 json: false,
10540 format: crate::OutputFormat::Toon,
10541 content: "\"".repeat(2_100_000),
10542 json_written: false,
10543 skipped_reason: None,
10544 };
10545 let encoded = ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_MAP, &report)?;
10546 require(
10547 report.content.len() < 4 * 1024 * 1024
10548 && matches!(
10549 enforce_map_response_limit(&encoded, MAX_MAP_RESPONSE_BYTES),
10550 Err(crate::atlas_map::AtlasMapError::ResponseLimitExceeded { .. })
10551 ),
10552 "MCP map accepted an encoded envelope larger than the response limit",
10553 )?;
10554
10555 let temp = tempfile::tempdir()?;
10556 fs::write(
10557 temp.path().join("source.rs"),
10558 "// Purpose: MCP envelope limit fixture.\n",
10559 )?;
10560 let state = McpProjectState {
10561 root: fs::canonicalize(temp.path())?,
10562 db_path: temp.path().join(".projectatlas").join("projectatlas.db"),
10563 config_path: None,
10564 worktree: None,
10565 };
10566 require(
10567 matches!(
10568 ProjectAtlasMcpServer::build_map_report_with_limit(&state, true, true, 1),
10569 Err(CliError::AtlasMap(
10570 crate::atlas_map::AtlasMapError::ResponseLimitExceeded { limit: 1, .. }
10571 ))
10572 ) && !temp.path().join(".projectatlas").exists(),
10573 "MCP envelope refusal created a JSON sidecar or legacy TOON file",
10574 )
10575 }
10576
10577 fn drop_native_worktree_identity_schema(
10579 connection: &rusqlite::Connection,
10580 ) -> rusqlite::Result<()> {
10581 connection.execute_batch(
10582 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10583 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10584 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10585 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10586 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10587 )
10588 }
10589
10590 fn run_fixture_command(command: &mut StdCommand) -> Result<String, Box<dyn std::error::Error>> {
10592 let output = command.output()?;
10593 if !output.status.success() {
10594 return Err(io::Error::other(format!(
10595 "fixture command failed: {}{}",
10596 String::from_utf8_lossy(&output.stdout),
10597 String::from_utf8_lossy(&output.stderr)
10598 ))
10599 .into());
10600 }
10601 Ok(String::from_utf8(output.stdout)?)
10602 }
10603
10604 struct RegisteredWorktreeRaceFixture {
10605 _temp: tempfile::TempDir,
10606 primary: PathBuf,
10607 control_root: PathBuf,
10608 linked: PathBuf,
10609 control_db: PathBuf,
10610 target_db: PathBuf,
10611 server: ProjectAtlasMcpServer,
10612 alias: WorktreeAlias,
10613 registration: WorktreeRegistration,
10614 selection: McpWorktreeSelection,
10615 state: McpProjectState,
10616 administrative_directory: PathBuf,
10617 administrative_identity: String,
10618 }
10619
10620 fn registered_worktree_race_fixture(
10621 alias: &str,
10622 ) -> Result<RegisteredWorktreeRaceFixture, Box<dyn std::error::Error>> {
10623 let temp = tempfile::tempdir()?;
10624 let primary = temp.path().join("control");
10625 let linked = temp.path().join("linked");
10626 fs::create_dir_all(primary.join("src"))?;
10627 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
10628 for (key, value) in [
10629 ("user.name", "ProjectAtlas Test"),
10630 ("user.email", "projectatlas@example.invalid"),
10631 ("commit.gpgsign", "false"),
10632 ("core.autocrlf", "false"),
10633 ] {
10634 run_fixture_command(
10635 StdCommand::new("git")
10636 .current_dir(&primary)
10637 .args(["config", key, value]),
10638 )?;
10639 }
10640 fs::write(primary.join("src/lib.rs"), "pub fn control() {}\n")?;
10641 run_fixture_command(
10642 StdCommand::new("git")
10643 .current_dir(&primary)
10644 .args(["add", "."]),
10645 )?;
10646 run_fixture_command(
10647 StdCommand::new("git")
10648 .current_dir(&primary)
10649 .args(["commit", "-m", "fixture"]),
10650 )?;
10651 run_fixture_command(
10652 StdCommand::new("git")
10653 .current_dir(&primary)
10654 .args(["worktree", "add", "-b", "captured"])
10655 .arg(&linked),
10656 )?;
10657
10658 let control_root = primary.canonicalize()?;
10659 let control_config = control_root.join(PROJECTATLAS_DIR_NAME).join("config.toml");
10660 let control_db = control_root
10661 .join(PROJECTATLAS_DIR_NAME)
10662 .join(PROJECTATLAS_DB_FILE_NAME);
10663 init_project_with_config(&control_root, Some(&control_config))?;
10664 let mut control = AtlasStore::open_for_project(&control_db, &control_root)?;
10665 let plan = ScanRuntimePlan::for_path(Some(&control_config), &control_root, None)?;
10666 run_scan_pipeline(
10667 &mut control,
10668 &plan,
10669 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
10670 )?;
10671 let control_project_instance_id = control
10672 .project_instance_id()?
10673 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10674 let server = ProjectAtlasMcpServer::new(
10675 control_db.clone(),
10676 Some(control_config),
10677 "worktree-race".to_string(),
10678 false,
10679 );
10680 let repository = server.control_git_repository()?;
10681 let canonical_linked = linked.canonicalize()?;
10682 let entry = repository
10683 .worktrees
10684 .iter()
10685 .find(|entry| {
10686 ProjectAtlasMcpServer::active_worktree_root(entry)
10687 == Some(canonical_linked.as_path())
10688 })
10689 .ok_or_else(|| io::Error::other("linked worktree was not discovered"))?;
10690 let administrative_directory = entry.administrative_directory.clone();
10691 let administrative_identity = git_administrative_identity(&administrative_directory)?;
10692 let alias = WorktreeAlias::parse(alias)?;
10693 let registration = control.register_worktree(
10694 &alias,
10695 &repository.common_directory,
10696 &administrative_directory,
10697 &administrative_identity,
10698 &canonical_linked,
10699 None,
10700 1,
10701 )?;
10702 drop(control);
10703 let target_db = canonical_linked
10704 .join(PROJECTATLAS_DIR_NAME)
10705 .join(PROJECTATLAS_DB_FILE_NAME);
10706 let selection = McpWorktreeSelection {
10707 alias: alias.to_string(),
10708 registration_id: Some(registration.registration_id),
10709 project_instance_id: None,
10710 control_project_instance_id: Some(control_project_instance_id),
10711 };
10712 let state = McpProjectState {
10713 root: canonical_linked,
10714 db_path: target_db.clone(),
10715 config_path: Some(linked.join(PROJECTATLAS_DIR_NAME).join("config.toml")),
10716 worktree: Some(selection.clone()),
10717 };
10718 Ok(RegisteredWorktreeRaceFixture {
10719 _temp: temp,
10720 primary: control_root.clone(),
10721 control_root,
10722 linked,
10723 control_db,
10724 target_db,
10725 server,
10726 alias,
10727 registration,
10728 selection,
10729 state,
10730 administrative_directory,
10731 administrative_identity,
10732 })
10733 }
10734
10735 fn replace_registered_worktree(
10736 fixture: &RegisteredWorktreeRaceFixture,
10737 branch: &str,
10738 ) -> Result<GitWorktreeEntry, Box<dyn std::error::Error>> {
10739 run_fixture_command(
10740 StdCommand::new("git")
10741 .current_dir(&fixture.primary)
10742 .args(["worktree", "remove", "--force"])
10743 .arg(&fixture.linked),
10744 )?;
10745 run_fixture_command(
10746 StdCommand::new("git")
10747 .current_dir(&fixture.primary)
10748 .args(["worktree", "add", "-b", branch])
10749 .arg(&fixture.linked),
10750 )?;
10751 let repository = fixture.server.control_git_repository()?;
10752 repository
10753 .worktrees
10754 .into_iter()
10755 .find(|entry| {
10756 ProjectAtlasMcpServer::active_worktree_root(entry)
10757 == Some(fixture.state.root.as_path())
10758 })
10759 .ok_or_else(|| io::Error::other("replacement worktree was not discovered").into())
10760 }
10761
10762 fn prepared_hydration_candidate(
10764 fixture: &RegisteredWorktreeRaceFixture,
10765 ) -> Result<
10766 (
10767 PreparedWorktreeHydrationCandidate,
10768 PathBuf,
10769 IndexWorkControl,
10770 ),
10771 Box<dyn std::error::Error>,
10772 > {
10773 fs::create_dir_all(
10774 fixture
10775 .target_db
10776 .parent()
10777 .ok_or_else(|| io::Error::other("target database has no parent"))?,
10778 )?;
10779 let source =
10780 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
10781 let work_control =
10782 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
10783 let mut candidate = source.prepare_worktree_hydration(
10784 &fixture.state.root,
10785 &fixture.target_db,
10786 &work_control,
10787 )?;
10788 let candidate_path = candidate.path()?.to_path_buf();
10789 candidate.accept_verified_source_state(&work_control)?;
10790 let candidate = candidate.prepare_activation(&work_control)?;
10791 Ok((candidate, candidate_path, work_control))
10792 }
10793
10794 #[test]
10795 fn task_errors_classify_only_typed_cancellation_as_canceled() {
10796 let stage = IndexWorkStage::RepositoryTraversal;
10797 assert!(task_error_is_canceled(&CliError::IndexWork(
10798 IndexWorkFailure::Cancelled { stage },
10799 )));
10800 assert!(task_error_is_canceled(&CliError::Fs(
10801 projectatlas_fs::FsError::IndexWork(IndexWorkFailure::Cancelled { stage }),
10802 )));
10803 assert!(task_error_is_canceled(&CliError::Db(DbError::IndexWork(
10804 IndexWorkFailure::Cancelled { stage },
10805 ))));
10806 assert!(task_error_is_canceled(&CliError::Service(
10807 ServiceError::Db(DbError::IndexWork(IndexWorkFailure::Cancelled { stage })),
10808 )));
10809 assert!(!task_error_is_canceled(&CliError::IndexWork(
10810 IndexWorkFailure::DeadlineExceeded { stage },
10811 )));
10812 assert!(!task_error_is_canceled(&CliError::Db(DbError::IndexWork(
10813 IndexWorkFailure::DeadlineExceeded { stage },
10814 ))));
10815 assert!(!task_error_is_canceled(&CliError::Fs(
10816 projectatlas_fs::FsError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10817 stage,
10818 resource: projectatlas_core::IndexWorkResource::Entries,
10819 limit: 1,
10820 observed: 2,
10821 }),
10822 )));
10823 }
10824
10825 fn usage_test_project(
10826 parent: &Path,
10827 name: &str,
10828 ) -> Result<(McpProjectState, AtlasStore), Box<dyn std::error::Error>> {
10829 let root = parent.join(name);
10830 fs::create_dir_all(root.join(".projectatlas"))?;
10831 let db_path = root.join(".projectatlas").join("projectatlas.db");
10832 let store = AtlasStore::open_for_project(&db_path, &root)?;
10833 Ok((
10834 McpProjectState {
10835 root,
10836 db_path,
10837 config_path: None,
10838 worktree: None,
10839 },
10840 store,
10841 ))
10842 }
10843
10844 fn usage_runtime_identity(
10845 server: &ProjectAtlasMcpServer,
10846 state: &McpProjectState,
10847 store: &AtlasStore,
10848 ) -> Result<UsageRuntimeInstance, Box<dyn std::error::Error>> {
10849 let binding = McpUsageProjectBinding::capture(state, store)?;
10850 let project_instance = server
10851 .usage_runtime
10852 .lock()
10853 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
10854 .entries
10855 .iter()
10856 .find(|entry| entry.binding == binding)
10857 .map(|entry| Arc::clone(&entry.instance))
10858 .ok_or_else(|| io::Error::other("operating-system entropy was unavailable"))?;
10859 let identity = *project_instance
10860 .lock()
10861 .map_err(|_poisoned| io::Error::other("project usage lock poisoned"))?;
10862 Ok(identity)
10863 }
10864
10865 #[test]
10866 fn mcp_server_clones_share_one_runtime_identity() -> Result<(), Box<dyn std::error::Error>> {
10867 let temp = tempfile::tempdir()?;
10868 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
10869 let first = ProjectAtlasMcpServer::new(
10870 state.db_path.clone(),
10871 None,
10872 "shared-label".to_string(),
10873 false,
10874 );
10875 first.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
10876 let first_identity = usage_runtime_identity(&first, &state, &store)?;
10877 let cloned = first.clone();
10878 require(
10879 usage_runtime_identity(&first, &state, &store)? == first_identity,
10880 "cloning an MCP server changed the original telemetry identity",
10881 )?;
10882 let restarted = ProjectAtlasMcpServer::new(
10883 state.db_path.clone(),
10884 None,
10885 "shared-label".to_string(),
10886 false,
10887 );
10888 restarted.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
10889 let restarted_identity = usage_runtime_identity(&restarted, &state, &store)?;
10890
10891 require(
10892 usage_runtime_identity(&cloned, &state, &store)? == first_identity,
10893 "cloning an MCP server changed its process-scoped telemetry identity",
10894 )?;
10895 require(
10896 restarted_identity != first_identity,
10897 "a separately constructed MCP server reused the prior runtime identity",
10898 )
10899 }
10900
10901 #[test]
10902 fn mcp_request_cancellation_bridge_reaches_index_work() -> Result<(), Box<dyn std::error::Error>>
10903 {
10904 let cancellation = Arc::new(std::sync::atomic::AtomicBool::new(false));
10905 let observed = Arc::clone(&cancellation);
10906 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10907 let bridge = McpRequestCancellationBridge::start_with_probe(
10908 move || observed.load(Ordering::Acquire),
10909 &control,
10910 )?;
10911
10912 cancellation.store(true, Ordering::Release);
10913 let mut canceled = false;
10914 for _attempt in 0..100 {
10915 if matches!(
10916 control.check(IndexWorkStage::Publication),
10917 Err(IndexWorkFailure::Cancelled { .. })
10918 ) {
10919 canceled = true;
10920 break;
10921 }
10922 thread::sleep(Duration::from_millis(2));
10923 }
10924 drop(bridge);
10925
10926 require(
10927 canceled,
10928 "RMCP cancellation probe did not reach the shared index work control",
10929 )
10930 }
10931
10932 #[test]
10933 fn purpose_preflight_preserves_admitted_mutations_and_saved_source_repair()
10934 -> Result<(), Box<dyn std::error::Error>> {
10935 let temp = tempfile::tempdir()?;
10936 let root = temp.path();
10937 fs::create_dir(root.join(".projectatlas"))?;
10938 fs::write(root.join("source.rs"), "fn current() {}\n")?;
10939 let db_path = root.join(".projectatlas/projectatlas.db");
10940 let plan = ScanRuntimePlan::for_path(None, root, None)?;
10941 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
10942 run_scan_pipeline(
10943 &mut store,
10944 &plan,
10945 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
10946 )?;
10947 drop(store);
10948 let server =
10949 ProjectAtlasMcpServer::new(db_path, None, "purpose-preflight".to_owned(), false);
10950 let state = server.state_for_target(Some(normalize_native_path_display(root)), None)?;
10951 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10952 let admission = server.source_observations.admit_mutation(
10953 &state.db_path,
10954 &state.root,
10955 state.config_path.as_deref(),
10956 &control,
10957 )?;
10958 let store = ProjectAtlasMcpServer::open_existing_mut_store(&state, &server.control_state)?;
10959 let transaction = store.begin_purpose_mutation()?;
10960 store.set_purpose(".", "Accepted repository purpose", PurposeSource::Agent)?;
10961 let absolute = normalize_native_path_display(root.join("source.rs"));
10962 for path in [absolute.as_str(), "", "missing.rs"] {
10963 require(
10964 matches!(
10965 ProjectAtlasMcpServer::preflight_purpose_path(&state, path),
10966 Err(CliError::InvalidInput(_))
10967 ),
10968 "invalid purpose path passed admission preflight",
10969 )?;
10970 admission.verify()?;
10971 }
10972 transaction.commit()?;
10973 require(
10974 store.load_node_by_path(".")?.is_some_and(|node| {
10975 node.purpose.purpose.as_deref() == Some("Accepted repository purpose")
10976 }),
10977 "rejected purpose requests prevented the admitted mutation from committing",
10978 )?;
10979 let identity = store.captured_project_binding()?.project_instance_id;
10980 drop(store);
10981 let saved_path = root.join("added.rs");
10982 let saved_source = "fn added() {}\n";
10983 fs::write(&saved_path, saved_source)?;
10984 let node_key = ProjectAtlasMcpServer::preflight_purpose_path(&state, "added.rs")?;
10985 server.with_admitted_purpose_mutation_controlled(&state, &control, None, |store| {
10986 ProjectAtlasMcpServer::require_indexed_purpose_path(store, &node_key)?;
10987 store.set_purpose(&node_key, "New saved source", PurposeSource::Agent)?;
10988 Ok(())
10989 })?;
10990 let store = ProjectAtlasMcpServer::open_read_store(&state)?;
10991 require(
10992 store.captured_project_binding()?.project_instance_id == identity
10993 && store.load_node_by_path("added.rs")?.is_some_and(|node| {
10994 node.purpose.purpose.as_deref() == Some("New saved source")
10995 })
10996 && fs::read_to_string(saved_path)? == saved_source,
10997 "purpose preflight prevented exact saved-source repair or changed source identity",
10998 )?;
10999 store.finish_index_read_snapshot()?;
11000 Ok(())
11001 }
11002
11003 #[test]
11004 fn purpose_preflight_preserves_selected_worktree_error_context()
11005 -> Result<(), Box<dyn std::error::Error>> {
11006 let fixture = registered_worktree_race_fixture("purpose-target")?;
11007 fs::create_dir_all(fixture.state.root.join(PROJECTATLAS_DIR_NAME))?;
11008 drop(AtlasStore::open_for_project(
11010 &fixture.target_db,
11011 &fixture.control_root,
11012 )?);
11013 let before = fs::read(&fixture.target_db)?;
11014 let error = ProjectAtlasMcpServer::preflight_purpose_path(&fixture.state, "src/lib.rs")
11015 .err()
11016 .ok_or_else(|| io::Error::other("mismatched preflight was accepted"))?;
11017 require(
11018 matches!(&error, CliError::ProjectMismatch(report)
11019 if report.worktree.as_deref() == Some("purpose-target")),
11020 "purpose preflight lost the selected worktree context",
11021 )?;
11022 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11023 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11024 require(
11025 value
11026 .pointer("/error/kind")
11027 .and_then(serde_json::Value::as_str)
11028 == Some("project_mismatch")
11029 && value
11030 .pointer("/error/project_mismatch/worktree")
11031 .and_then(serde_json::Value::as_str)
11032 == Some("purpose-target"),
11033 "MCP mismatch payload lost the selected worktree alias",
11034 )?;
11035 let mut explicit = fixture.state.clone();
11036 explicit.worktree = None;
11037 require(
11038 matches!(
11039 ProjectAtlasMcpServer::preflight_purpose_path(&explicit, "src/lib.rs"),
11040 Err(CliError::ProjectMismatch(report)) if report.worktree.is_none()
11041 ),
11042 "explicit-project preflight acquired an unrelated worktree alias",
11043 )?;
11044 require(
11045 fs::read(&fixture.target_db)? == before,
11046 "rejected purpose preflight changed the mismatched database",
11047 )?;
11048 Ok(())
11049 }
11050
11051 #[test]
11052 fn purpose_mutation_synchronously_rolls_back_request_cancellation()
11053 -> Result<(), Box<dyn std::error::Error>> {
11054 let temp = tempfile::tempdir()?;
11055 let root = temp.path().join("purpose-cancellation");
11056 fs::create_dir_all(root.join(".projectatlas"))?;
11057 fs::write(root.join("source.rs"), "fn current() {}\n")?;
11058 let db_path = root.join(".projectatlas/projectatlas.db");
11059 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
11060 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
11061 run_scan_pipeline(
11062 &mut store,
11063 &plan,
11064 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
11065 )?;
11066 let before_revision = store.authored_purpose_revision()?;
11067 let before_purpose = store
11068 .load_node_by_path("source.rs")?
11069 .ok_or_else(|| io::Error::other("indexed cancellation source missing"))?
11070 .purpose;
11071 drop(store);
11072
11073 let server = ProjectAtlasMcpServer::new(
11074 db_path.clone(),
11075 None,
11076 "purpose-cancellation".to_string(),
11077 false,
11078 );
11079 let state = server.state_for_target(Some(normalize_native_path_display(&root)), None)?;
11080 let control = IndexWorkControl::new(IndexCancellation::new(), None);
11081 let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
11082 let cancellation_probe = Arc::clone(&cancelled);
11083 let bridge = McpRequestCancellationBridge::start_with_probe(
11084 move || cancellation_probe.load(Ordering::Acquire),
11085 &control,
11086 )?;
11087 let result = server.with_admitted_purpose_mutation_controlled(
11088 &state,
11089 &control,
11090 Some(&bridge),
11091 |store| {
11092 store.set_purpose("source.rs", "Canceled purpose", PurposeSource::Agent)?;
11093 cancelled.store(true, Ordering::Release);
11094 Ok(())
11095 },
11096 );
11097 drop(bridge);
11098
11099 require(
11100 matches!(result, Err(CliError::IndexWork(_))),
11101 "request cancellation did not reject the purpose transaction",
11102 )?;
11103 let store = open_atlas_store_for_project(&db_path, &state.root)?;
11104 require(
11105 store.authored_purpose_revision()? == before_revision,
11106 "request cancellation advanced the authored-purpose revision",
11107 )?;
11108 require(
11109 store
11110 .load_node_by_path("source.rs")?
11111 .is_some_and(|node| node.purpose == before_purpose),
11112 "request cancellation persisted the rejected purpose",
11113 )
11114 }
11115
11116 #[test]
11117 fn mcp_same_path_project_identity_rotation_starts_a_distinct_runtime_entry()
11118 -> Result<(), Box<dyn std::error::Error>> {
11119 if telemetry_disabled() {
11120 return Ok(());
11121 }
11122 let temp = tempfile::tempdir()?;
11123 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
11124 let server = ProjectAtlasMcpServer::new(
11125 state.db_path.clone(),
11126 None,
11127 "shared-label".to_string(),
11128 false,
11129 );
11130 let old_project_identity = store.captured_project_binding()?.project_instance_id;
11131 server.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
11132 let old_runtime_identity = usage_runtime_identity(&server, &state, &store)?;
11133 drop(store);
11134
11135 AtlasStore::transition_project_root(
11136 &state.db_path,
11137 &state.root,
11138 projectatlas_db::ProjectRootTransition::Detach,
11139 )?;
11140 let detached_store = AtlasStore::open_for_project(&state.db_path, &state.root)?;
11141 let detached_project_identity = detached_store
11142 .captured_project_binding()?
11143 .project_instance_id;
11144 server.record_usage_for_state(&state, &detached_store, |_usage_instance| Ok(()));
11145 let detached_runtime_identity = usage_runtime_identity(&server, &state, &detached_store)?;
11146 let tracked = server
11147 .usage_runtime
11148 .lock()
11149 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
11150 .entries
11151 .len();
11152
11153 require(
11154 detached_project_identity != old_project_identity,
11155 "detach did not rotate the captured project identity",
11156 )?;
11157 require(
11158 detached_runtime_identity != old_runtime_identity,
11159 "same-path detach reused the previous project's telemetry identity",
11160 )?;
11161 require(
11162 tracked == 2,
11163 "same-path project identities did not retain distinct bounded runtime entries",
11164 )
11165 }
11166
11167 #[test]
11168 fn mcp_telemetry_rotates_inactive_bindings_without_dropping_later_worktrees()
11169 -> Result<(), Box<dyn std::error::Error>> {
11170 if telemetry_disabled() {
11171 return Ok(());
11172 }
11173 let temp = tempfile::tempdir()?;
11174 let (state, control) = usage_test_project(temp.path(), "control")?;
11175 let server =
11176 ProjectAtlasMcpServer::new(state.db_path, None, "worktree-capacity".to_string(), false);
11177 let common = temp.path().join("common.git");
11178 let event = usage_from_estimates_with_context(
11179 "worktree-capacity",
11180 "atlas_overview",
11181 None,
11182 None,
11183 100,
11184 10,
11185 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
11186 TOKEN_BASELINE_SELECTED_CANDIDATES,
11187 TOKEN_CONFIDENCE_INFERRED,
11188 );
11189 for index in 0..=MCP_TELEMETRY_PROJECT_BINDING_LIMIT {
11190 let registration = control.register_worktree(
11191 &WorktreeAlias::parse(&format!("worktree-{index:03}"))?,
11192 &common,
11193 &common.join(format!("worktrees/{index:03}")),
11194 &format!("{:064x}", index + 1),
11195 &temp.path().join(format!("worktree-{index:03}")),
11196 Some(ProjectInstanceId::from_bytes(
11197 [u8::try_from(index + 1)?; 16],
11198 )?),
11199 u64::try_from(index + 1)?,
11200 )?;
11201 server.record_usage_for_origin(
11202 &server.control_state,
11203 &control,
11204 Some(registration.registration_id),
11205 |usage_instance| {
11206 usage_instance.record_for_worktree(
11207 &control,
11208 registration.registration_id,
11209 &event,
11210 )
11211 },
11212 );
11213 }
11214
11215 require(
11216 control.repository_token_overview()?.calls == MCP_TELEMETRY_PROJECT_BINDING_LIMIT + 1,
11217 "a worktree beyond the in-memory telemetry bound lost its accepted event",
11218 )?;
11219 let runtime = server
11220 .usage_runtime
11221 .lock()
11222 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?;
11223 require(
11224 runtime.entries.len() == MCP_TELEMETRY_PROJECT_BINDING_LIMIT,
11225 "telemetry project binding registry exceeded its hard bound",
11226 )?;
11227 drop(runtime);
11228 require(
11229 control.telemetry_retention_state()?.active_instance_rows
11230 == MCP_TELEMETRY_PROJECT_BINDING_LIMIT,
11231 "inactive binding rotation did not seal before replacement",
11232 )?;
11233 server.seal_usage_instances_for_projects();
11234 require(
11235 control.telemetry_retention_state()?.active_instance_rows == 0,
11236 "rotated telemetry bindings were not sealed at MCP shutdown",
11237 )
11238 }
11239
11240 #[test]
11241 fn routed_worktree_telemetry_preserves_session_baseline_identity()
11242 -> Result<(), Box<dyn std::error::Error>> {
11243 if telemetry_disabled() {
11244 return Ok(());
11245 }
11246 let temp = tempfile::tempdir()?;
11247 let (state, control) = usage_test_project(temp.path(), "control")?;
11248 let server =
11249 ProjectAtlasMcpServer::new(state.db_path, None, "worktree-scale".to_string(), false);
11250 let common = temp.path().join("common.git");
11251 let event = usage_from_estimates_with_context(
11252 "worktree-scale",
11253 "atlas_overview",
11254 None,
11255 None,
11256 100,
11257 10,
11258 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
11259 TOKEN_BASELINE_SELECTED_CANDIDATES,
11260 TOKEN_CONFIDENCE_INFERRED,
11261 );
11262 let registration = control.register_worktree(
11263 &WorktreeAlias::parse("worktree-001")?,
11264 &common,
11265 &common.join("worktrees/001"),
11266 &format!("{:064x}", 1),
11267 &temp.path().join("worktree-001"),
11268 Some(ProjectInstanceId::from_bytes([1; 16])?),
11269 1,
11270 )?;
11271 for _ in 0..2 {
11272 server.record_usage_for_origin(
11273 &server.control_state,
11274 &control,
11275 Some(registration.registration_id),
11276 |usage_instance| {
11277 usage_instance.record_for_worktree(
11278 &control,
11279 registration.registration_id,
11280 &event,
11281 )
11282 },
11283 );
11284 }
11285
11286 let overview = control.repository_token_overview()?;
11287
11288 require(
11289 overview.calls == 2,
11290 "alias-routed modeled usage did not retain both accepted calls",
11291 )?;
11292 require(
11293 overview.deduped_modeled_tokens_avoided == 80,
11294 "alias-routed modeled usage did not reuse the session baseline witness",
11295 )?;
11296 require(
11297 overview.repeated_baselines_deduped == 1,
11298 "alias-routed modeled usage did not classify the repeated baseline",
11299 )?;
11300 require(
11301 control.telemetry_retention_state()?.active_instance_rows == 1,
11302 "alias-routed usage did not retain one bounded session identity",
11303 )?;
11304 server.seal_usage_instances_for_projects();
11305 require(
11306 control.telemetry_retention_state()?.active_instance_rows == 0,
11307 "alias-routed session identity was not sealed at MCP shutdown",
11308 )
11309 }
11310
11311 #[test]
11312 fn mcp_telemetry_busy_project_does_not_block_another_project()
11313 -> Result<(), Box<dyn std::error::Error>> {
11314 if telemetry_disabled() {
11315 return Ok(());
11316 }
11317 let server = ProjectAtlasMcpServer::new(
11318 PathBuf::from("startup.db"),
11319 None,
11320 "shared-label".to_string(),
11321 false,
11322 );
11323 let temp = tempfile::tempdir()?;
11324 let (state_a, store_a) = usage_test_project(temp.path(), "project-a")?;
11325 let (state_b, store_b) = usage_test_project(temp.path(), "project-b")?;
11326 let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1);
11327 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
11328 let server_a = server.clone();
11329 let a_handle = std::thread::spawn(move || -> Result<(), String> {
11330 server_a.record_usage_for_state(&state_a, &store_a, |_usage_instance| {
11331 entered_tx.send(()).map_err(|error| {
11332 CliError::InvalidInput(format!("test coordination failed: {error}"))
11333 })?;
11334 release_rx
11335 .recv_timeout(Duration::from_secs(5))
11336 .map_err(|error| {
11337 CliError::InvalidInput(format!("test coordination failed: {error}"))
11338 })?;
11339 Ok(())
11340 });
11341 Ok(())
11342 });
11343 entered_rx.recv_timeout(Duration::from_secs(2))?;
11344
11345 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
11346 let server_b = server;
11347 let b_handle = std::thread::spawn(move || -> Result<(), String> {
11348 server_b.record_usage_for_state(&state_b, &store_b, |_usage_instance| {
11349 done_tx.send(()).map_err(|error| {
11350 CliError::InvalidInput(format!("test coordination failed: {error}"))
11351 })?;
11352 Ok(())
11353 });
11354 Ok(())
11355 });
11356 let project_b_completed = done_rx.recv_timeout(Duration::from_secs(1)).is_ok();
11357 release_tx.send(())?;
11358 a_handle
11359 .join()
11360 .map_err(|_panic| io::Error::other("project A telemetry thread panicked"))?
11361 .map_err(io::Error::other)?;
11362 b_handle
11363 .join()
11364 .map_err(|_panic| io::Error::other("project B telemetry thread panicked"))?
11365 .map_err(io::Error::other)?;
11366
11367 require(
11368 project_b_completed,
11369 "one project's blocked telemetry delayed another project",
11370 )
11371 }
11372
11373 #[test]
11374 fn mcp_telemetry_keeps_identity_when_capacity_seal_fails()
11375 -> Result<(), Box<dyn std::error::Error>> {
11376 if telemetry_disabled() {
11377 return Ok(());
11378 }
11379 let temp = tempfile::tempdir()?;
11380 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
11381 let server = ProjectAtlasMcpServer::new(
11382 state.db_path.clone(),
11383 None,
11384 "shared-label".to_string(),
11385 false,
11386 );
11387 server.record_usage_for_state(&state, &store, |usage_instance| {
11388 record_usage_estimate(
11389 &store,
11390 Some(usage_instance),
11391 "seal-failure-test",
11392 MCP_EVENT_ATLAS_OVERVIEW,
11393 None,
11394 None,
11395 8,
11396 "overview:\n files: 1\n",
11397 )
11398 });
11399 let initial_identity = usage_runtime_identity(&server, &state, &store)?;
11400 let busy_connection = rusqlite::Connection::open(&state.db_path)?;
11401 busy_connection.execute_batch("BEGIN IMMEDIATE")?;
11402 let mut calls = 0usize;
11403
11404 server.record_usage_for_state(&state, &store, |_usage_instance| {
11405 calls += 1;
11406 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
11407 });
11408 busy_connection.execute_batch("ROLLBACK")?;
11409
11410 require(calls == 1, "failed sealing unexpectedly retried the event")?;
11411 require(
11412 usage_runtime_identity(&server, &state, &store)? == initial_identity,
11413 "failed sealing replaced the still-active project identity",
11414 )
11415 }
11416
11417 #[test]
11418 fn mcp_telemetry_rotates_and_retries_once_when_baselines_reach_capacity()
11419 -> Result<(), Box<dyn std::error::Error>> {
11420 if telemetry_disabled() {
11421 return Ok(());
11422 }
11423 let temp = tempfile::tempdir()?;
11424 let root = temp.path().join("selected-project");
11425 fs::create_dir_all(root.join(".projectatlas"))?;
11426 let db_path = root.join(".projectatlas").join("projectatlas.db");
11427 let store = AtlasStore::open_for_project(&db_path, &root)?;
11428 let state = McpProjectState {
11429 root,
11430 db_path: db_path.clone(),
11431 config_path: None,
11432 worktree: None,
11433 };
11434 let other_root = temp.path().join("other-project");
11435 fs::create_dir_all(other_root.join(".projectatlas"))?;
11436 let other_db_path = other_root.join(".projectatlas").join("projectatlas.db");
11437 let other_store = AtlasStore::open_for_project(&other_db_path, &other_root)?;
11438 let other_state = McpProjectState {
11439 root: other_root,
11440 db_path: other_db_path,
11441 config_path: None,
11442 worktree: None,
11443 };
11444 let server = ProjectAtlasMcpServer::new(db_path, None, "shared-label".to_string(), false);
11445 server.record_usage_for_state(&state, &store, |usage_instance| {
11446 record_usage_estimate(
11447 &store,
11448 Some(usage_instance),
11449 "rotation-test",
11450 MCP_EVENT_ATLAS_OVERVIEW,
11451 None,
11452 None,
11453 8,
11454 "overview:\n files: 1\n",
11455 )
11456 });
11457 server.record_usage_for_state(&other_state, &other_store, |_usage_instance| Ok(()));
11458 let initial_identity = usage_runtime_identity(&server, &state, &store)?;
11459 let other_identity = usage_runtime_identity(&server, &other_state, &other_store)?;
11460 let mut calls = 0usize;
11461
11462 server.record_usage_for_state(&state, &store, |_usage_instance| {
11463 calls += 1;
11464 if calls == 1 {
11465 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
11466 } else {
11467 Ok(())
11468 }
11469 });
11470
11471 let tracked = server
11472 .usage_runtime
11473 .lock()
11474 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
11475 .entries
11476 .len();
11477 let rotated_identity = usage_runtime_identity(&server, &state, &store)?;
11478 require(calls == 2, "capacity handling did not retry exactly once")?;
11479 require(
11480 rotated_identity != initial_identity,
11481 "capacity handling did not rotate the bounded runtime identity",
11482 )?;
11483 require(
11484 tracked == 2,
11485 "rotation changed the bounded project binding inventory",
11486 )?;
11487 require(
11488 usage_runtime_identity(&server, &other_state, &other_store)? == other_identity,
11489 "one project's capacity rotation changed another project's identity",
11490 )
11491 }
11492
11493 #[test]
11494 fn navigation_result_survives_telemetry_write_failure() -> Result<(), Box<dyn std::error::Error>>
11495 {
11496 if telemetry_disabled() {
11497 return Ok(());
11498 }
11499 let temp = tempfile::tempdir()?;
11500 let repo = temp.path().join("repo");
11501 fs::create_dir_all(repo.join("src"))?;
11502 fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
11503 let config_path = repo.join(".projectatlas").join("config.toml");
11504 init_project_with_config(&repo, Some(&config_path))?;
11505 let db_path = repo.join(".projectatlas").join("projectatlas.db");
11506 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
11507 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
11508 run_scan_pipeline(
11509 &mut store,
11510 &plan,
11511 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
11512 )?;
11513 drop(store);
11514
11515 let connection = rusqlite::Connection::open(&db_path)?;
11516 connection.execute_batch("BEGIN IMMEDIATE")?;
11517
11518 let server = ProjectAtlasMcpServer::new(
11519 db_path,
11520 Some(config_path),
11521 "shared-label".to_string(),
11522 false,
11523 );
11524 let result = server.atlas_overview_response(
11525 AtlasProjectParams {
11526 project_path: None,
11527 worktree: None,
11528 },
11529 None,
11530 );
11531 connection.execute_batch("ROLLBACK")?;
11532
11533 if result.contains("overview:") && result.contains("files:") {
11534 Ok(())
11535 } else {
11536 Err(io::Error::other(format!(
11537 "telemetry failure replaced an already-built navigation result: {result}"
11538 ))
11539 .into())
11540 }
11541 }
11542
11543 #[test]
11544 fn mcp_calls_share_server_identity_and_new_server_uses_another()
11545 -> Result<(), Box<dyn std::error::Error>> {
11546 if telemetry_disabled() {
11547 return Ok(());
11548 }
11549 let temp = tempfile::tempdir()?;
11550 let repo = temp.path().join("repo");
11551 fs::create_dir_all(repo.join("src"))?;
11552 fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
11553 let config_path = repo.join(".projectatlas").join("config.toml");
11554 init_project_with_config(&repo, Some(&config_path))?;
11555 let db_path = repo.join(".projectatlas").join("projectatlas.db");
11556 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
11557 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
11558 run_scan_pipeline(
11559 &mut store,
11560 &plan,
11561 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
11562 )?;
11563 drop(store);
11564
11565 let call_overview = |server: &ProjectAtlasMcpServer| {
11566 server.atlas_overview_response(
11567 AtlasProjectParams {
11568 project_path: None,
11569 worktree: None,
11570 },
11571 None,
11572 )
11573 };
11574 let first = ProjectAtlasMcpServer::new(
11575 db_path.clone(),
11576 Some(config_path.clone()),
11577 "shared-label".to_string(),
11578 false,
11579 );
11580 require(
11581 call_overview(&first).contains("overview:"),
11582 "first MCP call failed",
11583 )?;
11584 require(
11585 call_overview(&first).contains("overview:"),
11586 "second MCP call failed",
11587 )?;
11588 let restarted = ProjectAtlasMcpServer::new(
11589 db_path.clone(),
11590 Some(config_path),
11591 "shared-label".to_string(),
11592 false,
11593 );
11594 require(
11595 call_overview(&restarted).contains("overview:"),
11596 "restarted MCP call failed",
11597 )?;
11598
11599 let connection = rusqlite::Connection::open(db_path)?;
11600 let instances: i64 = connection.query_row(
11601 "SELECT COUNT(*) FROM usage_instances WHERE owner = 'mcp_process' AND caller_label = 'shared-label'",
11602 [],
11603 |row| row.get(0),
11604 )?;
11605 let events: i64 =
11606 connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
11607 require(
11608 instances == 2 && events == 3,
11609 "MCP calls did not reuse one identity per server construction",
11610 )?;
11611 drop(connection);
11612 drop(restarted);
11613 drop(first);
11614 fs::remove_dir_all(&repo)?;
11615 require(!repo.exists(), "MCP teardown left its repository in use")
11616 }
11617
11618 #[test]
11619 fn mcp_database_filesystem_failures_are_typed_and_actionable()
11620 -> Result<(), Box<dyn std::error::Error>> {
11621 let error = CliError::Db(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11622 path: PathBuf::from("project")
11623 .join(".projectatlas")
11624 .join("projectatlas.db"),
11625 mount_point: Some(PathBuf::from("project")),
11626 filesystem_type: Some("nfs".to_string()),
11627 });
11628 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11629 require(
11630 payload.contains("kind: database_filesystem_unsupported")
11631 && payload.contains("filesystem_type: nfs")
11632 && payload.contains("supported local filesystem"),
11633 "MCP TOON lost typed filesystem details or recovery guidance",
11634 )
11635 }
11636
11637 #[test]
11638 fn mcp_project_mismatch_preserves_lossless_store_roots()
11639 -> Result<(), Box<dyn std::error::Error>> {
11640 let temp = tempfile::tempdir()?;
11641 let selected_root = temp.path().join("selected-root");
11642 let indexed_root = temp.path().join("indexed-root");
11643 fs::create_dir_all(&selected_root)?;
11644 fs::create_dir_all(&indexed_root)?;
11645 let database = indexed_root.join(".projectatlas").join("projectatlas.db");
11646 fs::create_dir_all(
11647 database
11648 .parent()
11649 .ok_or_else(|| io::Error::other("indexed database path has no parent"))?,
11650 )?;
11651 drop(AtlasStore::open_for_project(&database, &indexed_root)?);
11652
11653 let Err(error) = open_atlas_store_for_project(&database, &selected_root) else {
11654 return Err(io::Error::other("wrong-root store open unexpectedly succeeded").into());
11655 };
11656 let selected_display = CanonicalProjectRoot::from_path(&selected_root)?.display_string()?;
11657 let indexed_display = CanonicalProjectRoot::from_path(&indexed_root)?.display_string()?;
11658 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11659 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11660 require(
11661 value.pointer("/error/project_mismatch/selected_project_root")
11662 == Some(&serde_json::Value::String(selected_display.clone()))
11663 && value.pointer("/error/project_mismatch/indexed_project_root")
11664 == Some(&serde_json::Value::String(indexed_display.clone())),
11665 "MCP omitted lossless roots from a store mismatch",
11666 )?;
11667 require(
11668 payload.contains(&selected_display) && payload.contains(&indexed_display),
11669 "MCP TOON did not retain lossless store roots",
11670 )
11671 }
11672
11673 #[cfg(unix)]
11674 #[test]
11675 fn mcp_project_mismatch_keeps_native_display_unavailable_typed()
11676 -> Result<(), Box<dyn std::error::Error>> {
11677 let temp = tempfile::tempdir()?;
11678 let raw_root = temp
11679 .path()
11680 .join(std::ffi::OsString::from_vec(b"raw-root-\x80".to_vec()));
11681 let replacement_root = temp.path().join("raw-root-�");
11682 fs::create_dir(&raw_root)?;
11683 fs::create_dir(&replacement_root)?;
11684 let raw_identity = CanonicalProjectRoot::from_path(&raw_root)?;
11685 let replacement_identity = CanonicalProjectRoot::from_path(&replacement_root)?;
11686 let error = CliError::ProjectMismatch(Box::new(IndexProjectMismatch::from_native_roots(
11687 &raw_identity,
11688 &replacement_identity,
11689 )));
11690 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11691 require(
11692 payload.contains("selected_project_root: null")
11693 && payload.contains("indexed_project_root:")
11694 && payload.contains("raw-root-�"),
11695 "MCP promoted a lossy native root into an ambiguous structured value",
11696 )?;
11697
11698 let mapped = crate::runtime::project_store_error(DbError::ProjectRootMismatch {
11699 expected: raw_root.to_string_lossy().into_owned(),
11700 found: replacement_root.to_string_lossy().into_owned(),
11701 identities: None,
11702 });
11703 let mapped_payload = ProjectAtlasMcpServer::encode_error_payload(&mapped);
11704 require(
11705 mapped_payload.contains("selected_project_root: null")
11706 && mapped_payload.contains("indexed_project_root: null")
11707 && mapped_payload.contains("does not match"),
11708 "MCP promoted lossy store mismatch text into structured roots",
11709 )
11710 }
11711
11712 #[cfg(unix)]
11713 #[test]
11714 fn mcp_recovery_and_session_reports_omit_unavailable_native_root_selectors()
11715 -> Result<(), Box<dyn std::error::Error>> {
11716 let temp = tempfile::tempdir()?;
11717 let raw_root = temp
11718 .path()
11719 .join(std::ffi::OsString::from_vec(b"repo-\x80".to_vec()));
11720 let replacement_root = temp.path().join("repo-�");
11721 fs::create_dir(&raw_root)?;
11722 fs::create_dir(&replacement_root)?;
11723 let raw_db = raw_root.join(".projectatlas").join("projectatlas.db");
11724 let replacement_db = replacement_root
11725 .join(".projectatlas")
11726 .join("projectatlas.db");
11727 let replacement_display = lossless_project_root_display(&replacement_root)
11728 .ok_or_else(|| io::Error::other("replacement root lost its UTF-8 display"))?;
11729
11730 let init_errors = [
11731 (
11732 crate::runtime::index_init_required(&raw_root, &raw_db),
11733 false,
11734 ),
11735 (
11736 crate::runtime::index_init_required(&replacement_root, &replacement_db),
11737 true,
11738 ),
11739 ];
11740 for (error, displayable) in init_errors {
11741 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11742 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11743 if displayable {
11744 let expected = serde_json::Value::String(replacement_display.clone());
11745 require(
11746 value.pointer("/error/init_required/project_root") == Some(&expected)
11747 && value.pointer("/error/next/project_path") == Some(&expected),
11748 "MCP init recovery lost a displayable root selector",
11749 )?;
11750 } else {
11751 require(
11752 value
11753 .pointer("/error/init_required/project_root")
11754 .is_some_and(serde_json::Value::is_null)
11755 && value.pointer("/error/next/project_path").is_none()
11756 && !payload.contains("repo-�"),
11757 "MCP init recovery exposed a lossy raw-root selector",
11758 )?;
11759 }
11760 }
11761
11762 let refresh_errors = [
11763 (
11764 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
11765 project_root: lossless_project_root_display(&raw_root),
11766 worktree: None,
11767 status: IndexReadStatus::RefreshRequired,
11768 reason: IndexRefreshReason::SourceChanged,
11769 scope: IndexRefreshScope::Incremental,
11770 changed: 1,
11771 added: 0,
11772 removed: 0,
11773 modified: 1,
11774 sample_paths: vec!["src/lib.rs".to_string()],
11775 })),
11776 false,
11777 ),
11778 (
11779 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
11780 project_root: lossless_project_root_display(&replacement_root),
11781 worktree: None,
11782 status: IndexReadStatus::RefreshRequired,
11783 reason: IndexRefreshReason::SourceChanged,
11784 scope: IndexRefreshScope::Incremental,
11785 changed: 1,
11786 added: 0,
11787 removed: 0,
11788 modified: 1,
11789 sample_paths: vec!["src/lib.rs".to_string()],
11790 })),
11791 true,
11792 ),
11793 ];
11794 for (error, displayable) in refresh_errors {
11795 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11796 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11797 if displayable {
11798 let expected = serde_json::Value::String(replacement_display.clone());
11799 require(
11800 value.pointer("/error/refresh_required/project_root") == Some(&expected)
11801 && value.pointer("/error/next/project_path") == Some(&expected),
11802 "MCP refresh recovery lost a displayable root selector",
11803 )?;
11804 } else {
11805 require(
11806 value
11807 .pointer("/error/refresh_required/project_root")
11808 .is_some_and(serde_json::Value::is_null)
11809 && value.pointer("/error/next/project_path").is_none()
11810 && !payload.contains("repo-�"),
11811 "MCP refresh recovery exposed a lossy raw-root selector",
11812 )?;
11813 }
11814 }
11815
11816 let raw_server = ProjectAtlasMcpServer::new(raw_db, None, "raw-session".to_string(), false);
11817 let raw_project = serde_json::to_value(ProjectAtlasMcpServer::project_state_payload(
11818 &raw_server.control_state,
11819 ))?;
11820 let raw_capability = serde_json::to_value(
11821 ProjectAtlasMcpServer::selected_project_capability(&raw_server.control_state),
11822 )?;
11823 require(
11824 raw_project.get("root") == Some(&serde_json::Value::Null)
11825 && raw_project.get("db") == Some(&serde_json::Value::Null)
11826 && raw_capability.get("root") == Some(&serde_json::Value::Null)
11827 && raw_capability.get("db") == Some(&serde_json::Value::Null)
11828 && !raw_project.to_string().contains("repo-�")
11829 && !raw_capability.to_string().contains("repo-�"),
11830 "MCP selected-project reports exposed a lossy raw-root projection",
11831 )?;
11832 let raw_brief = raw_server.build_session_brief(
11833 AtlasSessionBriefParams {
11834 project_path: None,
11835 worktree: None,
11836 query: None,
11837 purpose_task: None,
11838 compact: None,
11839 folder_limit: None,
11840 file_limit: None,
11841 blocker_limit: None,
11842 purpose_limit: None,
11843 },
11844 None,
11845 )?;
11846 let raw_value = serde_json::to_value(&raw_brief)?;
11847 require(
11848 raw_value.pointer("/project/root") == Some(&serde_json::Value::Null)
11849 && raw_value
11850 .pointer("/recommendations/0/arguments/project_path")
11851 .is_none()
11852 && raw_value
11853 .pointer("/recommendations/0/arguments/worktree")
11854 .is_none()
11855 && !raw_value.to_string().contains("repo-�"),
11856 "MCP session brief offered a lossy sibling selector for a raw root",
11857 )?;
11858
11859 let replacement_server = ProjectAtlasMcpServer::new(
11860 replacement_db,
11861 None,
11862 "replacement-session".to_string(),
11863 false,
11864 );
11865 let replacement_project = serde_json::to_value(
11866 ProjectAtlasMcpServer::project_state_payload(&replacement_server.control_state),
11867 )?;
11868 let replacement_capability = serde_json::to_value(
11869 ProjectAtlasMcpServer::selected_project_capability(&replacement_server.control_state),
11870 )?;
11871 require(
11872 replacement_project
11873 .get("root")
11874 .and_then(|value| value.as_str())
11875 == Some(replacement_display.as_str())
11876 && replacement_capability
11877 .get("root")
11878 .and_then(|value| value.as_str())
11879 == Some(replacement_display.as_str()),
11880 "MCP selected-project reports lost the displayable root",
11881 )?;
11882 let replacement_brief = replacement_server.build_session_brief(
11883 AtlasSessionBriefParams {
11884 project_path: None,
11885 worktree: None,
11886 query: None,
11887 purpose_task: None,
11888 compact: None,
11889 folder_limit: None,
11890 file_limit: None,
11891 blocker_limit: None,
11892 purpose_limit: None,
11893 },
11894 None,
11895 )?;
11896 let replacement_value = serde_json::to_value(&replacement_brief)?;
11897 require(
11898 replacement_value
11899 .pointer("/project/root")
11900 .and_then(|value| value.as_str())
11901 == Some(replacement_display.as_str())
11902 && replacement_value
11903 .pointer("/recommendations/0/arguments/project_path")
11904 .and_then(|value| value.as_str())
11905 == Some(replacement_display.as_str()),
11906 "MCP session brief lost the displayable root recovery selector",
11907 )?;
11908 require(
11909 !raw_root.join(".projectatlas").exists()
11910 && !replacement_root.join(".projectatlas").exists(),
11911 "MCP missing-index recovery mutated a project root",
11912 )
11913 }
11914
11915 #[test]
11916 fn mcp_schema_version_mismatches_are_typed_and_content_free()
11917 -> Result<(), Box<dyn std::error::Error>> {
11918 let supported = projectatlas_db::CURRENT_SCHEMA_VERSION;
11919 let future = supported + 1;
11920 let error = CliError::Db(DbError::SchemaVersion {
11921 found: future,
11922 expected: supported,
11923 });
11924 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11925 require(
11926 payload.contains("kind: schema_version_mismatch")
11927 && payload.contains(&format!("found_schema_version: {future}"))
11928 && payload.contains(&format!("supported_schema_version: {supported}"))
11929 && payload.contains(env!("CARGO_PKG_VERSION"))
11930 && payload.contains("do not reset")
11931 && !payload.contains(".projectatlas")
11932 && !payload.contains("session_id")
11933 && !payload.contains("project_root"),
11934 "MCP TOON lost typed schema-version details or exposed database context",
11935 )?;
11936
11937 let predecessor = CliError::Service(ServiceError::Db(DbError::SchemaVersion {
11938 found: 8,
11939 expected: supported,
11940 }));
11941 let McpToolTextResult(predecessor_result) =
11942 ProjectAtlasMcpServer::as_mcp_text(Err(predecessor));
11943 let predecessor_payload = predecessor_result.map_err(std::io::Error::other)?;
11944 require(
11945 predecessor_payload.contains("kind: schema_migration_required")
11946 && predecessor_payload.contains("found_schema_version: 8")
11947 && predecessor_payload.contains(&format!("supported_schema_version: {supported}"))
11948 && predecessor_payload
11949 .contains(&format!("migration_steps_remaining: {}", supported - 8))
11950 && predecessor_payload.contains("projectatlas init")
11951 && predecessor_payload.contains("atlas_init")
11952 && predecessor_payload.contains("same global `--db`/`--config` selection")
11953 && predecessor_payload.contains("same MCP server/database binding")
11954 && !predecessor_payload.contains("schema_version_mismatch")
11955 && !predecessor_payload.contains(crate::SCHEMA_VERSION_MISMATCH_RECOVERY),
11956 "MCP omitted the supported-predecessor migration handoff",
11957 )
11958 }
11959
11960 #[test]
11961 fn mcp_search_capability_failures_are_typed_and_actionable()
11962 -> Result<(), Box<dyn std::error::Error>> {
11963 let error = CliError::Service(ServiceError::SearchCapabilityUnavailable {
11964 requested_mode: projectatlas_service::SearchRetrievalMode::Hybrid,
11965 state: "not-installed",
11966 guidance: "install and build a compatible semantic generation",
11967 });
11968 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11969 require(
11970 payload.contains("kind: search_capability_unavailable")
11971 && payload.contains("requested_mode")
11972 && payload.contains("hybrid")
11973 && payload.contains("state")
11974 && payload.contains("not-installed")
11975 && payload.contains("compatible semantic generation"),
11976 "MCP TOON lost typed search-capability state or recovery guidance",
11977 )
11978 }
11979
11980 #[test]
11981 fn mcp_records_usage_only_for_the_accepted_verified_attempt()
11982 -> Result<(), Box<dyn std::error::Error>> {
11983 if telemetry_disabled() {
11984 return Ok(());
11985 }
11986 let temp = tempfile::tempdir()?;
11987 let repo = temp.path().join("repo");
11988 let source = repo.join("src").join("lib.rs");
11989 fs::create_dir_all(
11990 source
11991 .parent()
11992 .ok_or_else(|| io::Error::other("missing parent"))?,
11993 )?;
11994 let original = "pub fn original() {}\n";
11995 let revised = "pub fn revised() {}\n";
11996 fs::write(&source, original)?;
11997 let config_path = repo.join(".projectatlas").join("config.toml");
11998 init_project_with_config(&repo, Some(&config_path))?;
11999 let db_path = repo.join(".projectatlas").join("projectatlas.db");
12000 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
12001 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
12002 run_scan_pipeline(
12003 &mut store,
12004 &plan,
12005 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
12006 )?;
12007 drop(store);
12008 let state = McpProjectState {
12009 root: repo.clone(),
12010 db_path: db_path.clone(),
12011 config_path: Some(config_path.clone()),
12012 worktree: None,
12013 };
12014 let server = ProjectAtlasMcpServer::new(
12015 db_path.clone(),
12016 Some(config_path.clone()),
12017 "accepted-attempt".to_string(),
12018 false,
12019 );
12020 let mut attempts = 0_u64;
12021
12022 let response =
12023 server.with_fresh_string_and_usage_for_request(&state, None, |store, _stamp| {
12024 attempts = attempts.saturating_add(1);
12025 let hash = store
12026 .load_node_by_path("src/lib.rs")?
12027 .and_then(|node| node.node.content_hash)
12028 .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
12029 if attempts == 1 {
12030 fs::write(&source, revised).map_err(|source_error| CliError::Io {
12031 path: source.clone(),
12032 source: source_error,
12033 })?;
12034 server.source_observations.inject_test_event(
12035 &db_path,
12036 &repo,
12037 Some(&config_path),
12038 Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
12039 )?;
12040 }
12041 Ok((
12042 hash,
12043 Some(McpUsageIntent::estimate(
12044 MCP_EVENT_ATLAS_OVERVIEW,
12045 None,
12046 None,
12047 1,
12048 )),
12049 ))
12050 })?;
12051
12052 require(
12053 attempts >= 2,
12054 "mid-query source edit did not retry the query",
12055 )?;
12056 require(
12057 response == blake3::hash(revised.as_bytes()).to_hex().to_string(),
12058 "MCP returned the provisional pre-edit result",
12059 )?;
12060 let connection = rusqlite::Connection::open(db_path)?;
12061 let events: i64 =
12062 connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
12063 require(
12064 events == 1,
12065 "MCP telemetry recorded a discarded provisional attempt",
12066 )
12067 }
12068
12069 fn wait_for_background_task(
12071 server: &ProjectAtlasMcpServer,
12072 task_id: &str,
12073 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
12074 for _attempt in 0..5_000 {
12075 let status = server.task_status(task_id.to_string())?;
12076 if let Some(record) = status.task.filter(McpTaskRecord::is_terminal_state) {
12077 return Ok(record);
12078 }
12079 thread::sleep(Duration::from_millis(1));
12080 }
12081 Err(io::Error::other("background task did not reach a terminal state").into())
12082 }
12083
12084 fn run_successful_background_task(
12086 server: &ProjectAtlasMcpServer,
12087 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
12088 let task = server.start_index_task(
12089 McpTaskOperation::Scan,
12090 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
12091 MCP_TOOL_ATLAS_OVERVIEW,
12092 |_control, _options| Ok(()),
12093 )?;
12094 wait_for_background_task(server, &task.task_id)
12095 }
12096
12097 fn wait_for_background_operation(
12099 server: &ProjectAtlasMcpServer,
12100 operation: &McpTaskOperation,
12101 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
12102 let task_id = server
12103 .task_registry
12104 .read()
12105 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
12106 .latest_task_id(operation)
12107 .ok_or_else(|| io::Error::other("background task was not admitted"))?;
12108 wait_for_background_task(server, &task_id)
12109 }
12110
12111 fn require_agent_index_reads(
12113 server: &ProjectAtlasMcpServer,
12114 project_path: &str,
12115 expected_symbol: &str,
12116 ) -> Result<(), Box<dyn std::error::Error>> {
12117 let overview = server.atlas_overview_response(
12118 AtlasProjectParams {
12119 project_path: Some(project_path.to_string()),
12120 worktree: None,
12121 },
12122 None,
12123 );
12124 require(
12125 overview.contains("overview:"),
12126 "agent overview did not read the published index",
12127 )?;
12128
12129 let summary = server.atlas_file_summary_response(
12130 &AtlasFileSummaryParams {
12131 project_path: Some(project_path.to_string()),
12132 worktree: None,
12133 file: "src/lib.rs".to_string(),
12134 nearest_project: Some(false),
12135 compact: None,
12136 content_selection: None,
12137 limit: Some(25),
12138 },
12139 None,
12140 );
12141 require(
12142 summary.contains("file_summary:")
12143 && summary.contains("src/lib.rs")
12144 && summary.contains(expected_symbol),
12145 "agent file summary omitted published source facts",
12146 )?;
12147
12148 let symbols = server.atlas_symbols_response(
12149 &AtlasSymbolsParams {
12150 project_path: Some(project_path.to_string()),
12151 worktree: None,
12152 file: Some("src/lib.rs".to_string()),
12153 nearest_project: Some(false),
12154 query: None,
12155 content_selection: None,
12156 limit: Some(50),
12157 },
12158 None,
12159 );
12160 require(
12161 symbols.contains("symbols[") && symbols.contains(expected_symbol),
12162 "agent symbol read omitted published parser output",
12163 )?;
12164
12165 let relations = server.atlas_symbol_relations_response(
12166 &AtlasSymbolRelationsParams {
12167 project_path: Some(project_path.to_string()),
12168 file: Some("src/lib.rs".to_string()),
12169 symbol: Some(expected_symbol.to_string()),
12170 nearest_project: Some(false),
12171 query: None,
12172 limit: Some(50),
12173 ..AtlasSymbolRelationsParams::default()
12174 },
12175 None,
12176 );
12177 require(
12178 relations.contains("symbol_relations:") && relations.contains(expected_symbol),
12179 "agent relation read omitted published graph output",
12180 )?;
12181 let explicit_legacy = server.atlas_symbol_relations_response(
12182 &AtlasSymbolRelationsParams {
12183 project_path: Some(project_path.to_string()),
12184 file: Some("src/lib.rs".to_string()),
12185 nearest_project: Some(false),
12186 view: Some("legacy".to_string()),
12187 query: None,
12188 limit: Some(50),
12189 ..AtlasSymbolRelationsParams::default()
12190 },
12191 None,
12192 );
12193 require(
12194 explicit_legacy.contains("relations[") && explicit_legacy.contains(expected_symbol),
12195 "explicit MCP legacy relation view lost its complete small array",
12196 )?;
12197 let compact_legacy = server.atlas_symbol_relations_response(
12198 &AtlasSymbolRelationsParams {
12199 project_path: Some(project_path.to_string()),
12200 file: Some("src/lib.rs".to_string()),
12201 nearest_project: Some(false),
12202 view: Some("legacy".to_string()),
12203 compact: Some(true),
12204 limit: Some(50),
12205 ..AtlasSymbolRelationsParams::default()
12206 },
12207 None,
12208 );
12209 require(
12210 compact_legacy.contains(MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW),
12211 "compact relation projection did not reject the legacy view",
12212 )?;
12213 let zero_limit_legacy = server.atlas_symbol_relations_response(
12214 &AtlasSymbolRelationsParams {
12215 project_path: Some(project_path.to_string()),
12216 file: Some("src/lib.rs".to_string()),
12217 nearest_project: Some(false),
12218 view: Some("legacy".to_string()),
12219 limit: Some(0),
12220 ..AtlasSymbolRelationsParams::default()
12221 },
12222 None,
12223 );
12224 require(
12225 zero_limit_legacy.contains("1..200"),
12226 "MCP legacy zero row bound was accepted",
12227 )?;
12228 let detailed = server.atlas_symbol_relations_response(
12229 &AtlasSymbolRelationsParams {
12230 project_path: Some(project_path.to_string()),
12231 file: Some("src/lib.rs".to_string()),
12232 nearest_project: Some(false),
12233 view: Some("detailed".to_string()),
12234 direction: Some("outbound".to_string()),
12235 limit: Some(50),
12236 ..AtlasSymbolRelationsParams::default()
12237 },
12238 None,
12239 );
12240 require(
12241 detailed.contains("symbol_relations:") && detailed.contains("anchor:"),
12242 "detailed MCP relation route did not return the bounded graph envelope",
12243 )?;
12244 if expected_symbol == "third" {
12245 let compact_detailed = server.atlas_symbol_relations_response(
12246 &AtlasSymbolRelationsParams {
12247 project_path: Some(project_path.to_string()),
12248 file: Some("src/lib.rs".to_string()),
12249 nearest_project: Some(false),
12250 view: Some("detailed".to_string()),
12251 compact: Some(true),
12252 symbol: Some("first".to_string()),
12253 direction: Some("outbound".to_string()),
12254 include_occurrences: Some(true),
12255 limit: Some(1),
12256 output_bytes: Some(8 * 1_024),
12257 ..AtlasSymbolRelationsParams::default()
12258 },
12259 None,
12260 );
12261 require(
12262 compact_detailed.len() <= 8 * 1_024
12263 && compact_detailed.contains("returned: 1")
12264 && compact_detailed.contains("status: resolved")
12265 && compact_detailed.contains("confidence: exact")
12266 && compact_detailed.contains("completeness: complete")
12267 && compact_detailed.contains("Own café λ relation navigation")
12268 && compact_detailed.contains("next_call:")
12269 && compact_detailed.contains("occurrences[1]:")
12270 && !compact_detailed.contains("occurrences[1]:\n - relation:"),
12271 "compact detailed relation omitted trust, purpose, occurrence, next-call, or bounded-output behavior",
12272 )?;
12273 let first_detailed_page = server.atlas_symbol_relations_response(
12274 &AtlasSymbolRelationsParams {
12275 project_path: Some(project_path.to_string()),
12276 file: Some("src/lib.rs".to_string()),
12277 nearest_project: Some(false),
12278 view: Some("detailed".to_string()),
12279 symbol: Some("first".to_string()),
12280 direction: Some("outbound".to_string()),
12281 depth: Some(2),
12282 limit: Some(1),
12283 output_bytes: Some(64 * 1024),
12284 ..AtlasSymbolRelationsParams::default()
12285 },
12286 None,
12287 );
12288 let first_detailed_value: serde_json::Value =
12289 toon_format::decode_default(&first_detailed_page)?;
12290 let first_detailed_report = first_detailed_value
12291 .get("symbol_relations")
12292 .ok_or_else(|| io::Error::other("first detailed MCP page omitted its envelope"))?;
12293 let continuation = first_detailed_report
12294 .get("continuation")
12295 .and_then(serde_json::Value::as_str)
12296 .ok_or_else(|| io::Error::other("first detailed MCP page omitted its cursor"))?;
12297 require(
12298 first_detailed_report
12299 .get("returned")
12300 .and_then(serde_json::Value::as_u64)
12301 == Some(1)
12302 && first_detailed_report
12303 .get("rows")
12304 .and_then(serde_json::Value::as_array)
12305 .is_some_and(|rows| rows.len() == 1),
12306 "first detailed MCP page was not a nonempty bounded symbol result",
12307 )?;
12308 let second_detailed_page = server.atlas_symbol_relations_response(
12309 &AtlasSymbolRelationsParams {
12310 project_path: Some(project_path.to_string()),
12311 file: Some("src/lib.rs".to_string()),
12312 nearest_project: Some(false),
12313 view: Some("detailed".to_string()),
12314 cursor: Some(continuation.to_string()),
12315 symbol: Some("first".to_string()),
12316 direction: Some("outbound".to_string()),
12317 depth: Some(2),
12318 limit: Some(1),
12319 output_bytes: Some(64 * 1024),
12320 ..AtlasSymbolRelationsParams::default()
12321 },
12322 None,
12323 );
12324 let second_detailed_value: serde_json::Value =
12325 toon_format::decode_default(&second_detailed_page)?;
12326 let second_detailed_report = second_detailed_value
12327 .get("symbol_relations")
12328 .ok_or_else(|| io::Error::other("second detailed MCP page omitted its envelope"))?;
12329 require(
12330 second_detailed_report
12331 .get("returned")
12332 .and_then(serde_json::Value::as_u64)
12333 == Some(1)
12334 && second_detailed_report.get("rows") != first_detailed_report.get("rows")
12335 && second_detailed_page.contains("Own café λ relation navigation"),
12336 "detailed MCP cursor did not resume a distinct Unicode-safe symbol row",
12337 )?;
12338 let compact_continuation_page = server.atlas_symbol_relations_response(
12339 &AtlasSymbolRelationsParams {
12340 project_path: Some(project_path.to_string()),
12341 file: Some("src/lib.rs".to_string()),
12342 nearest_project: Some(false),
12343 view: Some("detailed".to_string()),
12344 compact: Some(true),
12345 symbol: Some("first".to_string()),
12346 symbol_parent: Some(String::new()),
12347 direction: Some("outbound".to_string()),
12348 depth: Some(2),
12349 limit: Some(1),
12350 output_bytes: Some(64 * 1024),
12351 ..AtlasSymbolRelationsParams::default()
12352 },
12353 None,
12354 );
12355 let compact_continuation_value: serde_json::Value =
12356 toon_format::decode_default(&compact_continuation_page)?;
12357 let compact_continuation_report = compact_continuation_value
12358 .get("symbol_relations")
12359 .ok_or_else(|| io::Error::other("compact relation page omitted its envelope"))?;
12360 let compact_next_call = compact_continuation_report
12361 .get("next_call")
12362 .ok_or_else(|| io::Error::other("compact relation page omitted its next call"))?;
12363 require(
12364 compact_next_call
12365 .get("tool")
12366 .and_then(serde_json::Value::as_str)
12367 == Some(MCP_TOOL_ATLAS_SYMBOL_RELATIONS),
12368 "compact relation continuation did not name its owning MCP tool",
12369 )?;
12370 let compact_next_arguments = compact_next_call
12371 .get("arguments")
12372 .cloned()
12373 .ok_or_else(|| io::Error::other("compact next call omitted its arguments"))?;
12374 require(
12375 compact_next_arguments.get("cursor").is_some()
12376 && compact_next_arguments.get("symbol_parent").is_none(),
12377 "compact next call did not preserve its cursor or normalize an empty parent",
12378 )?;
12379 let compact_next_params: AtlasSymbolRelationsParams =
12380 serde_json::from_value(compact_next_arguments)?;
12381 let compact_resumed_page =
12382 server.atlas_symbol_relations_response(&compact_next_params, None);
12383 require(
12384 compact_resumed_page.contains("symbol_relations:")
12385 && !compact_resumed_page.contains("cursor does not match query")
12386 && !compact_resumed_page.contains("graph symbol anchor is not available"),
12387 "compact relation next call was not directly reusable",
12388 )?;
12389 }
12390 let bounded_output_bytes = 4 * 1024_u32;
12391 let bounded = server.atlas_symbol_relations_response(
12392 &AtlasSymbolRelationsParams {
12393 project_path: Some(project_path.to_string()),
12394 file: Some("src/lib.rs".to_string()),
12395 nearest_project: Some(false),
12396 view: Some("detailed".to_string()),
12397 direction: Some("outbound".to_string()),
12398 limit: Some(50),
12399 edge_limit: Some(50),
12400 node_limit: Some(50),
12401 visited_limit: Some(50),
12402 occurrence_total_limit: Some(50),
12403 intermediate_bytes: Some(128 * 1024),
12404 deadline_ms: Some(2_000),
12405 output_bytes: Some(bounded_output_bytes),
12406 ..AtlasSymbolRelationsParams::default()
12407 },
12408 None,
12409 );
12410 require(
12411 bounded.contains("symbol_relations:")
12412 && bounded.len() <= bounded_output_bytes as usize
12413 && bounded.contains("Own café λ relation navigation")
12414 && bounded.contains(&format!("rendered_output_bytes: {}", bounded.len())),
12415 "detailed MCP relation output did not enforce or report the exact routed envelope bytes",
12416 )?;
12417
12418 let analysis = server.atlas_symbol_relations_response(
12419 &AtlasSymbolRelationsParams {
12420 project_path: Some(project_path.to_string()),
12421 file: Some("src/lib.rs".to_string()),
12422 nearest_project: Some(false),
12423 view: Some("analysis".to_string()),
12424 symbol: Some("first".to_string()),
12425 direction: Some("outbound".to_string()),
12426 depth: Some(2),
12427 limit: Some(50),
12428 output_bytes: Some(64 * 1024),
12429 include_communities: Some(true),
12430 include_cycles: Some(true),
12431 ..AtlasSymbolRelationsParams::default()
12432 },
12433 None,
12434 );
12435 require(
12436 analysis.contains("symbol_relations:")
12437 && analysis.contains("mode: architecture")
12438 && analysis.contains("findings[")
12439 && analysis.contains("next_call:")
12440 && analysis.contains("work:"),
12441 "MCP relation analysis omitted its closed mode, findings, work, or reusable next call",
12442 )?;
12443 let entrypoint_anchor = serde_json::to_string(&RelationAnchor::File {
12444 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
12445 })?;
12446 let entrypoint = server.atlas_symbol_relations_response(
12447 &AtlasSymbolRelationsParams {
12448 project_path: Some(project_path.to_string()),
12449 file: None,
12450 nearest_project: Some(false),
12451 view: Some("analysis".to_string()),
12452 direction: Some("outbound".to_string()),
12453 resolution: Some("any".to_string()),
12454 depth: Some(2),
12455 limit: Some(50),
12456 output_bytes: Some(64 * 1024),
12457 analysis_mode: Some("entrypoint".to_string()),
12458 profile_name: Some("mcp-entrypoint".to_string()),
12459 entrypoints: Some(vec![entrypoint_anchor.clone()]),
12460 profile_relations: Some(vec!["calls".to_string()]),
12461 ..AtlasSymbolRelationsParams::default()
12462 },
12463 None,
12464 );
12465 require(
12466 entrypoint.contains("symbol_relations:")
12467 && entrypoint.contains("mode: entrypoint")
12468 && entrypoint.contains("entrypoint_profile:")
12469 && entrypoint.contains("coverage:"),
12470 "MCP entrypoint analysis did not serialize the shared profile contract",
12471 )?;
12472 let entrypoint_symbol_conflict = server.atlas_symbol_relations_response(
12473 &AtlasSymbolRelationsParams {
12474 project_path: Some(project_path.to_string()),
12475 file: None,
12476 nearest_project: Some(false),
12477 view: Some("analysis".to_string()),
12478 direction: Some("outbound".to_string()),
12479 resolution: Some("any".to_string()),
12480 depth: Some(2),
12481 limit: Some(50),
12482 output_bytes: Some(64 * 1024),
12483 analysis_mode: Some("entrypoint".to_string()),
12484 symbol: Some("first".to_string()),
12485 entrypoints: Some(vec![entrypoint_anchor]),
12486 profile_relations: Some(vec!["calls".to_string()]),
12487 ..AtlasSymbolRelationsParams::default()
12488 },
12489 None,
12490 );
12491 require(
12492 entrypoint_symbol_conflict.contains(MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR),
12493 "MCP silently ignored a symbol selector alongside explicit entrypoint anchors",
12494 )?;
12495
12496 let state = ProjectAtlasMcpServer::project_state_from_root(Path::new(project_path))?;
12497 let publication_before = ProjectAtlasMcpServer::open_read_store(&state)?
12498 .index_publication()?
12499 .ok_or_else(|| io::Error::other("MCP impact fixture publication missing"))?;
12500 let task_records_before = server
12501 .task_registry
12502 .read()
12503 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
12504 .len();
12505 let impact_started = Instant::now();
12506 let impact = server.atlas_symbol_relations_response(
12507 &AtlasSymbolRelationsParams {
12508 project_path: Some(project_path.to_string()),
12509 file: Some("src/lib.rs".to_string()),
12510 nearest_project: Some(false),
12511 view: Some("analysis".to_string()),
12512 symbol: Some("first".to_string()),
12513 direction: Some("outbound".to_string()),
12514 depth: Some(2),
12515 limit: Some(8),
12516 edge_limit: Some(8),
12517 node_limit: Some(16),
12518 visited_limit: Some(16),
12519 occurrence_total_limit: Some(16),
12520 intermediate_bytes: Some(128 * 1_024),
12521 deadline_ms: Some(1_000),
12522 output_bytes: Some(64 * 1_024),
12523 analysis_mode: Some("impact".to_string()),
12524 vcs: Some("working_tree".to_string()),
12525 include_dead_code: Some(true),
12526 ..AtlasSymbolRelationsParams::default()
12527 },
12528 None,
12529 );
12530 let impact_elapsed = impact_started.elapsed();
12531 require(
12532 impact_elapsed <= Duration::from_secs(5)
12533 && impact.contains("mode: impact")
12534 && (impact.contains("state: available") || impact.contains("state: unavailable")),
12535 "MCP impact analysis exceeded its elapsed tolerance or omitted typed mode/VCS state",
12536 )?;
12537 let impact_value: serde_json::Value = toon_format::decode_default(&impact)?;
12538 let impact_report = impact_value
12539 .get(MCP_PAYLOAD_SYMBOL_RELATIONS)
12540 .ok_or_else(|| io::Error::other("MCP impact response omitted its envelope"))?;
12541 let bounded_work = [
12542 ("/returned", 8_u64),
12543 ("/work/relations/inspected_edges", 8),
12544 ("/work/relations/active_nodes", 16),
12545 ("/work/relations/visited_nodes", 16),
12546 ("/work/analyzed_nodes", 16),
12547 ("/work/analyzed_edges", 8),
12548 ("/work/peak_intermediate_bytes", 128 * 1_024),
12549 ("/work/rendered_output_bytes", 64 * 1_024),
12550 ];
12551 require(
12552 impact.len() <= 64 * 1_024
12553 && bounded_work.iter().all(|(path, limit)| {
12554 impact_report
12555 .pointer(path)
12556 .and_then(serde_json::Value::as_u64)
12557 .is_some_and(|observed| observed <= *limit)
12558 }),
12559 "MCP impact analysis crossed or omitted a declared row/node/edge/visited/intermediate/output budget",
12560 )?;
12561 require(
12562 ProjectAtlasMcpServer::open_read_store(&state)?
12563 .index_publication()?
12564 .as_ref()
12565 == Some(&publication_before)
12566 && server
12567 .task_registry
12568 .read()
12569 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
12570 .len()
12571 == task_records_before,
12572 "read-only MCP impact analysis changed publication or retained a task record",
12573 )?;
12574 let follow_up_started = Instant::now();
12575 let follow_up = server.atlas_overview_response(
12576 AtlasProjectParams {
12577 project_path: Some(project_path.to_string()),
12578 worktree: None,
12579 },
12580 None,
12581 );
12582 require(
12583 follow_up_started.elapsed() <= Duration::from_secs(2)
12584 && follow_up.contains("overview:"),
12585 "immediate MCP follow-up read was not responsive after bounded impact analysis",
12586 )?;
12587
12588 let trace = server.atlas_symbol_relations_response(
12589 &AtlasSymbolRelationsParams {
12590 project_path: Some(project_path.to_string()),
12591 file: Some("src/lib.rs".to_string()),
12592 nearest_project: Some(false),
12593 view: Some("analysis".to_string()),
12594 symbol: Some("first".to_string()),
12595 direction: Some("outbound".to_string()),
12596 depth: Some(2),
12597 limit: Some(50),
12598 analysis_mode: Some("trace".to_string()),
12599 trace_target: Some("second".to_string()),
12600 trace_target_file: Some("src/lib.rs".to_string()),
12601 trace_target_kind: Some("function".to_string()),
12602 trace_target_signature: Some("fn second ( )".to_string()),
12603 ..AtlasSymbolRelationsParams::default()
12604 },
12605 None,
12606 );
12607 require(
12608 trace.contains("mode: trace")
12609 && trace.contains("kind: static_trace")
12610 && trace.contains("status: confirmed")
12611 && trace.contains("name: second")
12612 && trace.contains("capability: symbol_slice"),
12613 "MCP trace analysis omitted its confirmed path or reusable exact selector",
12614 )?;
12615
12616 let misplaced = server.atlas_symbol_relations_response(
12617 &AtlasSymbolRelationsParams {
12618 project_path: Some(project_path.to_string()),
12619 file: Some("src/lib.rs".to_string()),
12620 nearest_project: Some(false),
12621 view: Some("detailed".to_string()),
12622 analysis_mode: Some("impact".to_string()),
12623 ..AtlasSymbolRelationsParams::default()
12624 },
12625 None,
12626 );
12627 require(
12628 misplaced.contains("analysis controls require view=analysis"),
12629 "MCP detailed relation view accepted analysis-only controls",
12630 )?;
12631
12632 let missing_trace_target = server.atlas_symbol_relations_response(
12633 &AtlasSymbolRelationsParams {
12634 project_path: Some(project_path.to_string()),
12635 file: Some("src/lib.rs".to_string()),
12636 nearest_project: Some(false),
12637 view: Some("analysis".to_string()),
12638 analysis_mode: Some("trace".to_string()),
12639 ..AtlasSymbolRelationsParams::default()
12640 },
12641 None,
12642 );
12643 require(
12644 missing_trace_target.contains("analysis trace requires an exact file or symbol target"),
12645 "MCP trace analysis accepted a missing exact target",
12646 )?;
12647
12648 let misplaced_vcs = server.atlas_symbol_relations_response(
12649 &AtlasSymbolRelationsParams {
12650 project_path: Some(project_path.to_string()),
12651 file: Some("src/lib.rs".to_string()),
12652 nearest_project: Some(false),
12653 view: Some("analysis".to_string()),
12654 analysis_mode: Some("architecture".to_string()),
12655 vcs: Some("working_tree".to_string()),
12656 ..AtlasSymbolRelationsParams::default()
12657 },
12658 None,
12659 );
12660 require(
12661 misplaced_vcs.contains("VCS selection is valid only for impact analysis"),
12662 "MCP silently dropped an explicit VCS selector outside impact mode",
12663 )
12664 }
12665
12666 #[test]
12667 fn current_dir_alias_paths_use_active_mcp_project() -> Result<(), Box<dyn std::error::Error>> {
12668 let temp = tempfile::tempdir()?;
12669 let repo = temp.path().join("repo-a");
12670 fs::create_dir(&repo)?;
12671 let db_path = repo.join(".projectatlas").join("projectatlas.db");
12672 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
12673 let expected_root = canonical_project_root(&repo)?;
12674
12675 let (_state, root) =
12676 server.state_and_root_path(None, None, Some("./".to_string()), false)?;
12677 require(
12678 root == expected_root,
12679 "current-dir alias did not use active root",
12680 )?;
12681
12682 #[cfg(windows)]
12683 {
12684 let (_state, root) =
12685 server.state_and_root_path(None, None, Some(".\\".to_string()), false)?;
12686 require(
12687 root == expected_root,
12688 "windows current-dir alias did not use active root",
12689 )?;
12690 }
12691
12692 Ok(())
12693 }
12694
12695 #[test]
12696 fn worktree_list_retains_retired_rows_at_structural_capacity()
12697 -> Result<(), Box<dyn std::error::Error>> {
12698 let temp = tempfile::tempdir()?;
12699 let primary = temp.path().join("control");
12700 fs::create_dir(&primary)?;
12701 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
12702 let git_directory = primary.join(".git");
12703 let structural_registrations = git_directory.join("worktrees");
12704 fs::create_dir(&structural_registrations)?;
12705 for index in 0..MAX_GIT_WORKTREE_REGISTRATIONS {
12706 let administrative_directory =
12707 structural_registrations.join(format!("missing-{index:04}"));
12708 fs::create_dir(&administrative_directory)?;
12709 fs::write(
12710 administrative_directory.join("gitdir"),
12711 temp.path()
12712 .join(format!("missing-{index:04}"))
12713 .join(".git")
12714 .to_string_lossy()
12715 .as_bytes(),
12716 )?;
12717 }
12718
12719 let database = primary.join(".projectatlas").join("projectatlas.db");
12720 fs::create_dir_all(
12721 database
12722 .parent()
12723 .ok_or_else(|| io::Error::other("control database has no parent"))?,
12724 )?;
12725 let store = AtlasStore::open_for_project(&database, &primary)?;
12726 let retired_alias = WorktreeAlias::parse("retired-at-capacity")?;
12727 let retired_registration = store.register_worktree(
12728 &retired_alias,
12729 &git_directory,
12730 &structural_registrations.join("retired"),
12731 &"ab".repeat(32),
12732 &temp.path().join("retired"),
12733 None,
12734 1,
12735 )?;
12736 drop(store);
12737
12738 let server =
12739 ProjectAtlasMcpServer::new(database.clone(), None, "worktree-list".to_string(), false);
12740 let active = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12741 include_retired: Some(false),
12742 }));
12743 require(
12744 active.contains("total_worktrees: 1026")
12745 && active.contains("truncated: false")
12746 && active.contains("retired-at-capacity")
12747 && active.contains("missing-1023"),
12748 "combined capacity starved a structural or active missing registration",
12749 )?;
12750
12751 let store = AtlasStore::open_for_project(&database, &primary)?;
12752 store.retire_worktree(retired_registration.registration_id, &retired_alias, 2)?;
12753 drop(store);
12754
12755 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12756 include_retired: Some(true),
12757 }));
12758 require(
12759 listed.contains("total_worktrees: 1025") && listed.contains("retired-at-capacity"),
12760 "full structural inventory starved the requested retired registration",
12761 )
12762 }
12763
12764 #[test]
12765 fn worktree_remove_retains_display_for_git_known_missing_registration()
12766 -> Result<(), Box<dyn std::error::Error>> {
12767 let fixture = registered_worktree_race_fixture("missing-remove")?;
12768 fs::remove_file(fixture.linked.join(".git"))?;
12769 let repository = fixture.server.control_git_repository()?;
12770 let missing_entry = repository
12771 .worktrees
12772 .iter()
12773 .find(|entry| entry.administrative_directory == fixture.administrative_directory)
12774 .ok_or_else(|| io::Error::other("missing Git worktree entry was not discovered"))?;
12775 require(
12776 matches!(missing_entry.state, GitWorktreeState::Missing { .. }),
12777 "Git-known missing worktree did not retain its structural entry",
12778 )?;
12779 let root_display = normalize_native_path_display(&fixture.linked.canonicalize()?);
12780 let registrations =
12781 AtlasStore::open_for_project(&fixture.control_db, &fixture.server.control_state.root)?
12782 .worktree_registrations(false)?;
12783 let listed = fixture.server.worktree_list_row(
12784 &repository.common_directory,
12785 missing_entry,
12786 ®istrations,
12787 );
12788 require(
12789 listed.root.as_deref() == Some(root_display.as_str())
12790 && matches!(listed.path_display, McpWorktreePathDisplayState::Available)
12791 && matches!(listed.git_state, McpGitWorktreeState::Missing)
12792 && matches!(
12793 listed.registration,
12794 McpWorktreeRegistrationState::Registered
12795 ),
12796 "Git-known missing worktree listing lost its retained root",
12797 )?;
12798 let removed = fixture
12799 .server
12800 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
12801 worktree: fixture.alias.to_string(),
12802 }));
12803 require(
12804 removed.contains("status: retired")
12805 && removed.contains("path_display: available")
12806 && removed.contains(&root_display)
12807 && removed.contains(MCP_WORKTREE_MISSING_RETENTION_REASON),
12808 &format!(
12809 "Git-known missing worktree retirement lost its retained display identity: {removed}"
12810 ),
12811 )?;
12812 Ok(())
12813 }
12814
12815 #[test]
12816 fn worktree_list_retains_one_invalid_registration_after_administrative_replacement()
12817 -> Result<(), Box<dyn std::error::Error>> {
12818 for replacement in ["file", "link"] {
12819 let fixture = registered_worktree_race_fixture("invalid-registration")?;
12820 let preserved = fixture.primary.join("preserved-administrative");
12821 let target = fixture.primary.join("unrelated-target");
12822 fs::create_dir(&target)?;
12823 fs::write(target.join("canary"), "unrelated")?;
12824 fs::rename(&fixture.administrative_directory, &preserved)?;
12825 if replacement == "file" {
12826 fs::write(&fixture.administrative_directory, "replacement")?;
12827 } else {
12828 #[cfg(unix)]
12829 std::os::unix::fs::symlink(&target, &fixture.administrative_directory)?;
12830 #[cfg(windows)]
12831 {
12832 let parent = fixture
12833 .administrative_directory
12834 .parent()
12835 .ok_or_else(|| io::Error::other("administrative path has no parent"))?;
12836 let name = fixture
12837 .administrative_directory
12838 .file_name()
12839 .ok_or_else(|| io::Error::other("administrative path has no name"))?;
12840 run_fixture_command(
12841 StdCommand::new("cmd")
12842 .args(["/D", "/C", "mklink", "/J"])
12843 .arg(parent.canonicalize()?.join(name))
12844 .arg(target.canonicalize()?),
12845 )?;
12846 }
12847 }
12848 let listed = fixture
12849 .server
12850 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12851 include_retired: Some(false),
12852 }));
12853 let value: serde_json::Value = toon_format::decode_default(&listed)?;
12854 let rows = value
12855 .pointer("/worktrees/worktrees")
12856 .and_then(serde_json::Value::as_array)
12857 .ok_or_else(|| io::Error::other(format!("missing worktree rows: {listed}")))?;
12858 require(
12859 rows.len() == 2
12860 && rows
12861 .iter()
12862 .filter(|row| {
12863 row.get("alias").and_then(serde_json::Value::as_str)
12864 == Some(fixture.alias.as_str())
12865 && row.get("git_state").and_then(serde_json::Value::as_str)
12866 == Some("invalid")
12867 && row.get("registration").and_then(serde_json::Value::as_str)
12868 == Some("registered")
12869 && row.get("atlas_state").and_then(serde_json::Value::as_str)
12870 == Some("unavailable")
12871 })
12872 .count()
12873 == 1,
12874 &format!("{replacement} replacement split its registered invalid row: {listed}"),
12875 )?;
12876 let removed =
12877 fixture
12878 .server
12879 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
12880 worktree: fixture.alias.to_string(),
12881 }));
12882 let control =
12883 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
12884 require(
12885 removed.contains("cannot retire worktree")
12886 && removed.contains("Git evidence is invalid")
12887 && control.worktree_registration(&fixture.alias)?.state
12888 == WorktreeRegistrationState::Active,
12889 &format!(
12890 "{replacement} invalid evidence allowed registration retirement: {removed}"
12891 ),
12892 )?;
12893 require(
12894 fixture
12895 .server
12896 .state_for_target(None, Some(fixture.alias.to_string()))
12897 .is_err()
12898 && fs::read_to_string(target.join("canary"))? == "unrelated"
12899 && !target.join(PROJECTATLAS_DIR_NAME).exists(),
12900 "invalid administrative replacement became an actionable target",
12901 )?;
12902 }
12903 Ok(())
12904 }
12905
12906 #[cfg(windows)]
12907 #[test]
12908 fn windows_worktree_selector_survives_live_to_retained_transition()
12909 -> Result<(), Box<dyn std::error::Error>> {
12910 let common = PathBuf::from(r"C:\repo\.git");
12911 let root = PathBuf::from(r"C:\repo\linked");
12912 for (index, administrative_directory) in [
12913 PathBuf::from(r"C:\repo\.git\worktrees\linked"),
12914 PathBuf::from(r"\\?\C:\repo\.git\worktrees\CON"),
12915 ]
12916 .into_iter()
12917 .enumerate()
12918 {
12919 let entry = GitWorktreeEntry {
12920 role: GitWorktreeRole::Linked,
12921 administrative_directory: administrative_directory.clone(),
12922 state: GitWorktreeState::Active {
12923 git_control_path: root.join(".git"),
12924 root: root.clone(),
12925 },
12926 };
12927 let selector = ProjectAtlasMcpServer::worktree_candidate_selector(&entry);
12928 let alias = WorktreeAlias::parse(&format!("retained-{index}"))?;
12929 let registration = WorktreeRegistration {
12930 registration_id: i64::try_from(index + 1)?,
12931 alias: alias.clone(),
12932 state: WorktreeRegistrationState::Active,
12933 git_common_directory: normalize_native_path_display(&common),
12934 git_common_directory_identity: CanonicalProjectRoot::from_persisted_path(
12935 common.clone(),
12936 )?,
12937 git_administrative_directory: normalize_native_path_display(
12938 &administrative_directory,
12939 ),
12940 git_administrative_directory_identity: CanonicalProjectRoot::from_persisted_path(
12941 administrative_directory,
12942 )?,
12943 git_administrative_identity: "ab".repeat(32),
12944 last_root: normalize_native_path_display(&root),
12945 last_root_identity: CanonicalProjectRoot::from_persisted_path(root.clone())?,
12946 project_instance_id: None,
12947 accepted_telemetry_revision: 0,
12948 created_at_epoch: 1,
12949 retired_at_epoch: None,
12950 };
12951 let retained = ProjectAtlasMcpServer::missing_registered_worktree_row(®istration);
12952 require(
12953 retained.selector.as_deref() == Some(selector.as_str())
12954 && retained.alias.as_deref() == Some(alias.as_str()),
12955 "live-to-retained transition changed the selector or lost the removal alias",
12956 )?;
12957 }
12958 Ok(())
12959 }
12960
12961 #[cfg(unix)]
12962 #[test]
12963 fn non_utf8_worktree_identities_retain_native_join_and_display_state()
12964 -> Result<(), Box<dyn std::error::Error>> {
12965 let temp = tempfile::tempdir()?;
12966 let control_root = temp.path().join("control");
12967 let common_directory = control_root.join(".git");
12968 fs::create_dir_all(&common_directory)?;
12969 let database = control_root.join(".projectatlas").join("projectatlas.db");
12970 fs::create_dir_all(
12971 database
12972 .parent()
12973 .ok_or_else(|| io::Error::other("control database has no parent"))?,
12974 )?;
12975 let store = AtlasStore::open_for_project(&database, &control_root)?;
12976 let server =
12977 ProjectAtlasMcpServer::new(database, None, "non-utf8-worktrees".to_string(), false);
12978 let mut entries = Vec::new();
12979
12980 for (index, terminal_byte) in [0xff, 0xfe].into_iter().enumerate() {
12981 let name = std::ffi::OsString::from_vec(vec![b'w', b't', b'-', terminal_byte]);
12982 let administrative_directory = common_directory.join("worktrees").join(&name);
12983 let root = temp.path().join(name);
12984 fs::create_dir_all(&administrative_directory)?;
12985 fs::create_dir_all(&root)?;
12986 let entry = GitWorktreeEntry {
12987 role: GitWorktreeRole::Linked,
12988 administrative_directory: administrative_directory.clone(),
12989 state: GitWorktreeState::Active {
12990 git_control_path: root.join(".git"),
12991 root: root.clone(),
12992 },
12993 };
12994 let shadow = WorktreeRegistration {
12995 registration_id: i64::try_from(index + 1)?,
12996 alias: WorktreeAlias::parse(&format!("shadow-{index}"))?,
12997 state: WorktreeRegistrationState::Active,
12998 git_common_directory: normalize_native_path_display(&common_directory),
12999 git_common_directory_identity: CanonicalProjectRoot::from_path(&common_directory)?,
13000 git_administrative_directory: normalize_native_path_display(
13001 &administrative_directory,
13002 ),
13003 git_administrative_directory_identity: CanonicalProjectRoot::from_path(
13004 &administrative_directory,
13005 )?,
13006 git_administrative_identity: "ab".repeat(32),
13007 last_root: normalize_native_path_display(&root),
13008 last_root_identity: CanonicalProjectRoot::from_path(&root)?,
13009 project_instance_id: None,
13010 accepted_telemetry_revision: 0,
13011 created_at_epoch: 1,
13012 retired_at_epoch: None,
13013 };
13014 let row = server.worktree_list_row(&common_directory, &entry, &[shadow]);
13015 require(
13016 row.selector.is_some()
13017 && row
13018 .alias
13019 .as_deref()
13020 .is_some_and(|alias| alias == format!("shadow-{index}"))
13021 && matches!(row.registration, McpWorktreeRegistrationState::Registered)
13022 && matches!(row.atlas_state, McpWorktreeAtlasState::Invalid)
13023 && row.root.is_none()
13024 && matches!(row.path_display, McpWorktreePathDisplayState::Unavailable),
13025 "non-UTF-8 structural row lost its native selector or typed display state",
13026 )?;
13027 entries.push(entry);
13028 }
13029
13030 let first_selector = ProjectAtlasMcpServer::worktree_candidate_selector(&entries[0]);
13031 require(
13032 first_selector != ProjectAtlasMcpServer::worktree_candidate_selector(&entries[1]),
13033 "native administrative identities collapsed into one selector",
13034 )?;
13035 let repository = GitRepositoryStructure {
13036 common_directory,
13037 selection: projectatlas_fs::worktree::GitRepositorySelection::CommonManager {
13038 source_selection: projectatlas_fs::worktree::GitManagerSourceSelection::Ambiguous {
13039 worktree_count: entries.len(),
13040 },
13041 },
13042 worktrees: entries,
13043 };
13044 let candidates = server.matching_worktree_candidates(&repository, &first_selector);
13045 require(
13046 candidates.len() == 1
13047 && ProjectAtlasMcpServer::worktree_candidate(
13048 &repository.common_directory,
13049 candidates[0],
13050 )
13051 .is_some_and(|candidate| {
13052 candidate.selector == first_selector
13053 && candidate.root.is_none()
13054 && matches!(
13055 candidate.path_display,
13056 McpWorktreePathDisplayState::Unavailable
13057 )
13058 }),
13059 "non-UTF-8 structural identity was not selectable with typed display state",
13060 )?;
13061 require(
13062 store.worktree_registrations(true)?.is_empty(),
13063 "non-UTF-8 structural identity reached the registration store",
13064 )?;
13065
13066 let non_utf8_common = temp
13067 .path()
13068 .join(std::ffi::OsString::from_vec(b"common-\xff".to_vec()));
13069 let non_utf8_administrative = non_utf8_common.join("worktrees/utf8");
13070 let non_utf8_root = temp.path().join("utf8");
13071 fs::create_dir_all(&non_utf8_administrative)?;
13072 fs::create_dir(&non_utf8_root)?;
13073 let utf8_entry = GitWorktreeEntry {
13074 role: GitWorktreeRole::Linked,
13075 administrative_directory: non_utf8_administrative,
13076 state: GitWorktreeState::Active {
13077 git_control_path: non_utf8_root.join(".git"),
13078 root: non_utf8_root,
13079 },
13080 };
13081 let row = server.worktree_list_row(&non_utf8_common, &utf8_entry, &[]);
13082 require(
13083 row.selector.is_some()
13084 && row.root.is_some()
13085 && matches!(row.path_display, McpWorktreePathDisplayState::Unavailable)
13086 && row.blocker.as_deref() == Some(MCP_ERROR_WORKTREE_PATH_NON_UTF8)
13087 && ProjectAtlasMcpServer::worktree_candidate(&non_utf8_common, &utf8_entry)
13088 .is_some_and(|candidate| {
13089 candidate.root.is_some()
13090 && matches!(
13091 candidate.path_display,
13092 McpWorktreePathDisplayState::Unavailable
13093 )
13094 }),
13095 "non-UTF-8 common-directory identity lost its selector or display state",
13096 )
13097 }
13098
13099 #[cfg(unix)]
13100 fn relocate_fixture_administrative_directory(
13101 linked: &Path,
13102 current: &Path,
13103 name: std::ffi::OsString,
13104 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
13105 let relocated = current
13106 .parent()
13107 .ok_or_else(|| io::Error::other("worktree administrative directory has no parent"))?
13108 .join(name);
13109 fs::rename(current, &relocated)?;
13110 let relocated = relocated.canonicalize()?;
13111 let mut pointer = b"gitdir: ".to_vec();
13112 pointer.extend_from_slice(relocated.as_os_str().as_encoded_bytes());
13113 pointer.push(b'\n');
13114 fs::write(linked.join(".git"), pointer)?;
13115 Ok(relocated)
13116 }
13117
13118 #[cfg(unix)]
13119 fn require_real_native_worktree_lifecycle(
13120 case: &str,
13121 primary_name: std::ffi::OsString,
13122 common_name: Option<std::ffi::OsString>,
13123 linked_name: std::ffi::OsString,
13124 administrative_name: Option<std::ffi::OsString>,
13125 expected_utf8: (bool, bool, bool),
13126 ) -> Result<(), Box<dyn std::error::Error>> {
13127 let temp = tempfile::tempdir()?;
13128 let primary = temp.path().join(primary_name);
13129 let linked = temp.path().join(linked_name);
13130 fs::create_dir_all(primary.join("src"))?;
13131 if let Some(common_name) = common_name {
13132 run_fixture_command(
13133 StdCommand::new("git")
13134 .current_dir(temp.path())
13135 .args(["init", "--quiet", "--separate-git-dir"])
13136 .arg(temp.path().join(common_name))
13137 .arg(&primary),
13138 )?;
13139 } else {
13140 run_fixture_command(
13141 StdCommand::new("git")
13142 .current_dir(&primary)
13143 .args(["init", "--quiet"]),
13144 )?;
13145 }
13146 for (key, value) in [
13147 ("user.name", "ProjectAtlas Test"),
13148 ("user.email", "projectatlas@example.invalid"),
13149 ("commit.gpgsign", "false"),
13150 ("core.autocrlf", "false"),
13151 ] {
13152 run_fixture_command(
13153 StdCommand::new("git")
13154 .current_dir(&primary)
13155 .args(["config", key, value]),
13156 )?;
13157 }
13158 fs::write(primary.join("src/lib.rs"), "pub fn baseline() {}\n")?;
13159 run_fixture_command(
13160 StdCommand::new("git")
13161 .current_dir(&primary)
13162 .args(["add", "."]),
13163 )?;
13164 run_fixture_command(
13165 StdCommand::new("git")
13166 .current_dir(&primary)
13167 .args(["commit", "--quiet", "-m", "fixture"]),
13168 )?;
13169 run_fixture_command(
13170 StdCommand::new("git")
13171 .current_dir(&primary)
13172 .args(["worktree", "add", "--quiet", "-b", case])
13173 .arg(&linked),
13174 )?;
13175
13176 let linked = linked.canonicalize()?;
13177 if let Some(name) = administrative_name {
13178 let RepositoryStructure::Git(initial) = discover_repository_structure(&linked)? else {
13179 return Err(io::Error::other("fixture was not discovered as Git").into());
13180 };
13181 let current = initial
13182 .worktrees
13183 .iter()
13184 .find(|entry| {
13185 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(linked.as_path())
13186 })
13187 .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?
13188 .administrative_directory
13189 .clone();
13190 relocate_fixture_administrative_directory(&linked, ¤t, name)?;
13191 }
13192 require(
13193 run_fixture_command(
13194 StdCommand::new("git")
13195 .current_dir(&linked)
13196 .args(["status", "--short"]),
13197 )?
13198 .is_empty(),
13199 "Git could not use the native fixture paths",
13200 )?;
13201
13202 let primary = primary.canonicalize()?;
13203 let control_config = primary.join(PROJECTATLAS_DIR_NAME).join("config.toml");
13204 let control_db = primary
13205 .join(PROJECTATLAS_DIR_NAME)
13206 .join(PROJECTATLAS_DB_FILE_NAME);
13207 init_project_with_config(&primary, Some(&control_config))?;
13208 let mut control = AtlasStore::open_for_project(&control_db, &primary)?;
13209 let plan = ScanRuntimePlan::for_path(Some(&control_config), &primary, None)?;
13210 run_scan_pipeline(
13211 &mut control,
13212 &plan,
13213 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None),
13214 )?;
13215 drop(control);
13216 let server = ProjectAtlasMcpServer::new(
13217 control_db.clone(),
13218 Some(control_config),
13219 format!("native-{case}"),
13220 false,
13221 );
13222 let repository = server.control_git_repository()?;
13223 let entry = repository
13224 .worktrees
13225 .iter()
13226 .find(|entry| {
13227 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(linked.as_path())
13228 })
13229 .ok_or_else(|| io::Error::other("native linked worktree was not discovered"))?;
13230 let common_identity = CanonicalProjectRoot::from_path(&repository.common_directory)?;
13231 let administrative_identity =
13232 CanonicalProjectRoot::from_path(&entry.administrative_directory)?;
13233 let root_identity = CanonicalProjectRoot::from_path(&linked)?;
13234 require(
13235 (
13236 linked.to_str().is_some(),
13237 repository.common_directory.to_str().is_some(),
13238 entry.administrative_directory.to_str().is_some(),
13239 ) == expected_utf8,
13240 "fixture did not isolate the expected native path component",
13241 )?;
13242 let selector = ProjectAtlasMcpServer::worktree_candidate_selector(entry);
13243 let display_available = expected_utf8 == (true, true, true);
13244 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
13245 include_retired: Some(false),
13246 }));
13247 let listed_value: serde_json::Value = toon_format::decode_default(&listed)?;
13248 let expected_display = if display_available {
13249 "available"
13250 } else {
13251 "unavailable"
13252 };
13253 require(
13254 listed_value
13255 .pointer("/worktrees/worktrees")
13256 .and_then(serde_json::Value::as_array)
13257 .is_some_and(|rows| {
13258 rows.iter().any(|row| {
13259 row.get("selector").and_then(serde_json::Value::as_str)
13260 == Some(selector.as_str())
13261 && row.get("path_display").and_then(serde_json::Value::as_str)
13262 == Some(expected_display)
13263 })
13264 }),
13265 &format!("native worktree discovery was not publicly selectable: {listed}"),
13266 )?;
13267 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
13268 worktree: selector,
13269 alias: Some(case.to_string()),
13270 }));
13271 require(
13272 added.contains("status: registered")
13273 && added.contains(&format!("alias: \"{case}\""))
13274 && added.contains(if display_available {
13275 "path_display: available"
13276 } else {
13277 "path_display: unavailable"
13278 }),
13279 &format!("native worktree registration failed: {added}"),
13280 )?;
13281 let alias = WorktreeAlias::parse(case)?;
13282 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
13283 let registration = control.worktree_registration(&alias)?;
13284 require(
13285 registration.git_common_directory_identity == common_identity
13286 && registration.git_administrative_directory_identity == administrative_identity
13287 && registration.last_root_identity == root_identity,
13288 "registration did not retain the exact native identities",
13289 )?;
13290 drop(control);
13291
13292 let initialized = server.atlas_init(Parameters(AtlasInitParams {
13293 project_path: None,
13294 worktree: Some(case.to_string()),
13295 no_scan: Some(false),
13296 force_rescan: Some(false),
13297 text_index_max_bytes: None,
13298 }));
13299 require(
13300 initialized.contains("status: hydrated"),
13301 &format!("alias-routed native initialization failed: {initialized}"),
13302 )?;
13303 fs::write(linked.join("src/native.rs"), "pub fn native_refresh() {}\n")?;
13304 let watched = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
13305 project_path: None,
13306 worktree: Some(case.to_string()),
13307 path: None,
13308 nearest_project: Some(false),
13309 max_workers: Some(1),
13310 timeout_seconds: None,
13311 text_index_max_bytes: None,
13312 background: Some(false),
13313 }));
13314 require(
13315 watched.contains(MCP_PAYLOAD_WATCH),
13316 &format!("alias-routed native watcher failed: {watched}"),
13317 )?;
13318 let target_db = linked
13319 .join(PROJECTATLAS_DIR_NAME)
13320 .join(PROJECTATLAS_DB_FILE_NAME);
13321 let target = open_atlas_store_read_only_for_project(&target_db, &linked)?;
13322 require(
13323 target.load_node_by_path("src/native.rs")?.is_some(),
13324 "alias-routed watcher did not publish the changed native worktree",
13325 )?;
13326 drop(target);
13327 require(
13328 run_fixture_command(StdCommand::new("git").current_dir(&linked).args([
13329 "status",
13330 "--short",
13331 "--untracked-files=all",
13332 ]))?
13333 .contains("src/native.rs"),
13334 "Git command invocation lost the native worktree path",
13335 )?;
13336
13337 let git_pointer = fs::read(linked.join(".git"))?;
13338 fs::remove_file(linked.join(".git"))?;
13339 let missing_repository = server.control_git_repository()?;
13340 let missing_entry = missing_repository
13341 .worktrees
13342 .iter()
13343 .find(|entry| entry.administrative_directory == administrative_identity.as_path())
13344 .ok_or_else(|| io::Error::other("native missing worktree entry was not discovered"))?;
13345 let missing_row = server.worktree_list_row(
13346 &missing_repository.common_directory,
13347 missing_entry,
13348 std::slice::from_ref(®istration),
13349 );
13350 require(
13351 matches!(missing_row.git_state, McpGitWorktreeState::Missing)
13352 && missing_row.alias.as_deref() == Some(case)
13353 && missing_row.root == root_identity.display_string().ok()
13354 && matches!(
13355 missing_row.path_display,
13356 McpWorktreePathDisplayState::Available
13357 ) == display_available,
13358 "Git-known missing native worktree lost its retained root or display state",
13359 )?;
13360 fs::write(linked.join(".git"), git_pointer)?;
13361
13362 let preserved_administrative = temp.path().join("preserved-administrative");
13363 fs::rename(administrative_identity.as_path(), &preserved_administrative)?;
13364 std::os::unix::fs::symlink(&preserved_administrative, administrative_identity.as_path())?;
13365 let invalid_listing = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
13366 include_retired: Some(false),
13367 }));
13368 let invalid_value: serde_json::Value = toon_format::decode_default(&invalid_listing)?;
13369 let invalid_rows = invalid_value
13370 .pointer("/worktrees/worktrees")
13371 .and_then(serde_json::Value::as_array)
13372 .ok_or_else(|| io::Error::other("native invalid worktree rows are missing"))?;
13373 require(
13374 invalid_rows.len() == 2
13375 && invalid_rows.iter().any(|row| {
13376 row.get("alias").and_then(serde_json::Value::as_str) == Some(case)
13377 && row.get("git_state").and_then(serde_json::Value::as_str)
13378 == Some("invalid")
13379 && row.get("registration").and_then(serde_json::Value::as_str)
13380 == Some("registered")
13381 })
13382 && server
13383 .state_for_target(None, Some(case.to_string()))
13384 .is_err(),
13385 &format!("native invalid registration was split or admitted: {invalid_listing}"),
13386 )?;
13387 let invalid_removal = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13388 worktree: case.to_string(),
13389 }));
13390 require(
13391 invalid_removal.contains("cannot retire worktree")
13392 && invalid_removal.contains("Git evidence is invalid"),
13393 &format!("native invalid evidence allowed retirement: {invalid_removal}"),
13394 )?;
13395 fs::remove_file(administrative_identity.as_path())?;
13396 fs::rename(&preserved_administrative, administrative_identity.as_path())?;
13397
13398 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13399 worktree: case.to_string(),
13400 }));
13401 require(
13402 removed.contains("status: retired")
13403 && removed.contains(if display_available {
13404 "path_display: available"
13405 } else {
13406 "path_display: unavailable"
13407 }),
13408 &format!("native worktree retirement failed: {removed}"),
13409 )?;
13410 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
13411 let retired = control
13412 .worktree_registrations(true)?
13413 .into_iter()
13414 .find(|registration| registration.alias == alias)
13415 .ok_or_else(|| io::Error::other("retired native registration is missing"))?;
13416 require(
13417 retired.state == WorktreeRegistrationState::Retired
13418 && retired.git_common_directory_identity == common_identity
13419 && retired.git_administrative_directory_identity == administrative_identity
13420 && retired.last_root_identity == root_identity,
13421 "retirement changed the persisted native identities",
13422 )?;
13423 require(
13424 server
13425 .state_for_target(None, Some(case.to_string()))
13426 .is_err(),
13427 "retired native alias remained routable",
13428 )
13429 }
13430
13431 #[cfg(all(unix, not(target_os = "macos")))]
13433 #[test]
13434 fn real_non_utf8_worktree_lifecycle_routes_alias_and_preserves_native_identities()
13435 -> Result<(), Box<dyn std::error::Error>> {
13436 for (case, primary, common, linked, administrative, expected_utf8) in [
13437 (
13438 "invalid-root",
13439 std::ffi::OsString::from("root-control"),
13440 None,
13441 std::ffi::OsString::from_vec(b"root-\xff".to_vec()),
13442 Some(std::ffi::OsString::from("root-administrative")),
13443 (false, true, true),
13444 ),
13445 (
13446 "invalid-common",
13447 std::ffi::OsString::from("common-control"),
13448 Some(std::ffi::OsString::from_vec(b"common-\xfe ".to_vec())),
13449 std::ffi::OsString::from("common-linked"),
13450 None,
13451 (true, false, false),
13452 ),
13453 (
13454 "invalid-administrative",
13455 std::ffi::OsString::from("administrative-control"),
13456 None,
13457 std::ffi::OsString::from("administrative-linked"),
13458 Some(std::ffi::OsString::from_vec(
13459 b"administrative-\xfd\t".to_vec(),
13460 )),
13461 (true, true, false),
13462 ),
13463 ] {
13464 require_real_native_worktree_lifecycle(
13465 case,
13466 primary,
13467 common,
13468 linked,
13469 administrative,
13470 expected_utf8,
13471 )?;
13472 }
13473 Ok(())
13474 }
13475
13476 #[cfg(unix)]
13477 #[test]
13478 fn real_unicode_worktree_lifecycle_routes_alias_and_preserves_native_identities()
13479 -> Result<(), Box<dyn std::error::Error>> {
13480 require_real_native_worktree_lifecycle(
13481 "valid-unicode",
13482 std::ffi::OsString::from("unicode-control-λ"),
13483 Some(std::ffi::OsString::from("unicode-common-共同 ")),
13484 std::ffi::OsString::from("unicode-linked-工作树"),
13485 Some(std::ffi::OsString::from("unicode-administrative-管理\t")),
13486 (true, true, true),
13487 )
13488 }
13489
13490 #[test]
13491 fn worktree_registration_revalidates_captured_local_atlas_identity()
13492 -> Result<(), Box<dyn std::error::Error>> {
13493 let temp = tempfile::tempdir()?;
13494 let root = temp.path().join("worktree");
13495 fs::create_dir(&root)?;
13496 let state = root.join(PROJECTATLAS_DIR_NAME);
13497 let db_path = state.join(PROJECTATLAS_DB_FILE_NAME);
13498 let config_path = state.join("config.toml");
13499 init_project_with_config(&root, Some(&config_path))?;
13500 drop(AtlasStore::open_for_project(&db_path, &root)?);
13501 let captured = ProjectAtlasMcpServer::local_worktree_atlas(&root)?
13502 .ok_or_else(|| io::Error::other("captured worktree atlas is missing"))?;
13503 ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13504 &root,
13505 Some(captured.project_instance_id),
13506 )?;
13507
13508 let preserved = root.join(".projectatlas-captured-registration");
13509 fs::rename(&state, &preserved)?;
13510 fs::create_dir(&state)?;
13511 let replacement = AtlasStore::open_for_project(&db_path, &root)?;
13512 require(
13513 replacement.project_instance_id()? != Some(captured.project_instance_id),
13514 "replacement atlas reused the captured registration identity",
13515 )?;
13516 drop(replacement);
13517 let rejected = ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13518 &root,
13519 Some(captured.project_instance_id),
13520 );
13521 require(
13522 rejected.as_ref().is_err_and(|error| {
13523 error
13524 .to_string()
13525 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
13526 }),
13527 "registration guard accepted a replacement local atlas",
13528 )?;
13529 fs::remove_dir_all(&state)?;
13530 let missing = ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13531 &root,
13532 Some(captured.project_instance_id),
13533 );
13534 require(
13535 missing.as_ref().is_err_and(|error| {
13536 error
13537 .to_string()
13538 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
13539 }),
13540 "registration guard accepted a missing captured local atlas",
13541 )?;
13542
13543 let uninitialized = temp.path().join("uninitialized");
13544 fs::create_dir(&uninitialized)?;
13545 ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(&uninitialized, None)
13546 .map_err(Into::into)
13547 }
13548
13549 #[test]
13550 fn registered_init_rejects_replaced_git_lifecycle_before_or_during_activation_and_binding()
13551 -> Result<(), Box<dyn std::error::Error>> {
13552 let fixture = registered_worktree_race_fixture("init-race")?;
13553 let (candidate, candidate_path, work_control) = prepared_hydration_candidate(&fixture)?;
13554
13555 let replacement_entry = replace_registered_worktree(&fixture, "replacement-init")?;
13556 require(
13557 replacement_entry.administrative_directory == fixture.administrative_directory
13558 && git_administrative_identity(&replacement_entry.administrative_directory)?
13559 != fixture.administrative_identity,
13560 "replacement fixture did not reuse the administrative path with a new lifecycle",
13561 )?;
13562 fs::create_dir_all(
13563 fixture
13564 .target_db
13565 .parent()
13566 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13567 )?;
13568 let replacement = AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13569 let replacement_project = replacement
13570 .project_instance_id()?
13571 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13572 drop(replacement);
13573
13574 let activation = fixture.server.activate_registered_worktree_hydration(
13575 &fixture.state,
13576 &fixture.selection,
13577 candidate,
13578 &work_control,
13579 );
13580 require(
13581 activation.as_ref().is_err_and(|error| {
13582 error
13583 .to_string()
13584 .contains("administrative lifecycle changed")
13585 }),
13586 "hydration candidate activated into a replacement Git lifecycle",
13587 )?;
13588 require(
13589 !candidate_path.exists()
13590 && open_atlas_store_read_only_for_project(&fixture.target_db, &fixture.state.root)?
13591 .project_instance_id()?
13592 == Some(replacement_project),
13593 "rejected hydration changed the replacement atlas or retained its candidate",
13594 )?;
13595
13596 let binding = fixture
13597 .server
13598 .bind_initialized_worktree(&fixture.selection, &fixture.state);
13599 require(
13600 binding.as_ref().is_err_and(|error| {
13601 error
13602 .to_string()
13603 .contains("administrative lifecycle changed")
13604 }),
13605 "final init binding accepted a replacement Git lifecycle",
13606 )?;
13607 let control =
13608 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
13609 require(
13610 control
13611 .worktree_registration(&fixture.alias)?
13612 .project_instance_id
13613 .is_none(),
13614 "failed activation or binding attached the replacement atlas",
13615 )?;
13616
13617 let during = registered_worktree_race_fixture("init-publish-race")?;
13618 let (candidate, _candidate_path, work_control) = prepared_hydration_candidate(&during)?;
13619 let replacement_project = std::cell::Cell::new(None);
13620 let activation = during
13621 .server
13622 .activate_registered_worktree_hydration_with_post_publication(
13623 &during.state,
13624 &during.selection,
13625 candidate,
13626 &work_control,
13627 || {
13628 replace_registered_worktree(&during, "replacement-during-publish")
13629 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13630 fs::create_dir_all(during.target_db.parent().ok_or_else(|| {
13631 CliError::InvalidInput("replacement database has no parent".to_string())
13632 })?)
13633 .map_err(|source| CliError::Io {
13634 path: during.state.root.clone(),
13635 source,
13636 })?;
13637 let replacement =
13638 AtlasStore::open_for_project(&during.target_db, &during.state.root)?;
13639 replacement_project.set(Some(
13640 replacement
13641 .project_instance_id()?
13642 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
13643 ));
13644 Ok(())
13645 },
13646 );
13647 let activation_error = activation.as_ref().err().map(ToString::to_string);
13648 require(
13649 activation_error
13650 .as_deref()
13651 .is_some_and(|error| error.contains("administrative lifecycle changed")),
13652 &format!(
13653 "hydration bound a lifecycle replaced after candidate publication: {activation_error:?}"
13654 ),
13655 )?;
13656 let replacement =
13657 open_atlas_store_read_only_for_project(&during.target_db, &during.state.root)?;
13658 let control =
13659 open_atlas_store_read_only_for_project(&during.control_db, &during.control_root)?;
13660 require(
13661 replacement.project_instance_id()? == replacement_project.get()
13662 && control
13663 .worktree_registration(&during.alias)?
13664 .project_instance_id
13665 .is_none(),
13666 "post-publication lifecycle rejection changed or bound the replacement atlas",
13667 )
13668 }
13669
13670 #[test]
13671 fn final_retirement_does_not_import_replacement_lifecycle_telemetry()
13672 -> Result<(), Box<dyn std::error::Error>> {
13673 let fixture = registered_worktree_race_fixture("retire-race")?;
13674 let replacement_entry = replace_registered_worktree(&fixture, "replacement-retire")?;
13675 fs::create_dir_all(
13676 fixture
13677 .target_db
13678 .parent()
13679 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13680 )?;
13681 let replacement = AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13682 replacement.record_usage(&usage_from_text(
13683 "replacement",
13684 "atlas_overview",
13685 None,
13686 None,
13687 "pub fn replacement() {}",
13688 "repository overview",
13689 ))?;
13690 let replacement_project = replacement
13691 .project_instance_id()?
13692 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13693 drop(replacement);
13694 let control = open_atlas_store_for_project(&fixture.control_db, &fixture.control_root)?;
13695 let (retired, synchronized, blocker) = ProjectAtlasMcpServer::retire_registered_worktree(
13696 &control,
13697 &fixture.registration,
13698 Some(&fixture.state.root),
13699 2,
13700 None,
13701 )?;
13702 require(
13703 retired.state == WorktreeRegistrationState::Retired
13704 && retired.project_instance_id.is_none()
13705 && retired.accepted_telemetry_revision == 0
13706 && synchronized.is_none()
13707 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
13708 "replacement lifecycle telemetry was bound, imported, or hidden during retirement",
13709 )?;
13710
13711 let repository = fixture.server.control_git_repository()?;
13712 let replacement_alias = WorktreeAlias::parse("replacement-owner")?;
13713 let replacement_registration = control.register_worktree(
13714 &replacement_alias,
13715 &repository.common_directory,
13716 &replacement_entry.administrative_directory,
13717 &git_administrative_identity(&replacement_entry.administrative_directory)?,
13718 &fixture.state.root,
13719 Some(replacement_project),
13720 3,
13721 )?;
13722 require(
13723 replacement_registration.project_instance_id == Some(replacement_project),
13724 "retired origin stranded the replacement atlas identity",
13725 )?;
13726
13727 let open_race = registered_worktree_race_fixture("retire-open-race")?;
13728 let control = open_atlas_store_for_project(&open_race.control_db, &open_race.primary)?;
13729 let sentinel = b"replacement atlas must remain untouched";
13730 let (retired, synchronized, blocker) =
13731 ProjectAtlasMcpServer::retire_registered_worktree_with_pre_open(
13732 &control,
13733 &open_race.registration,
13734 Some(&open_race.state.root),
13735 2,
13736 None,
13737 || {
13738 replace_registered_worktree(&open_race, "replacement-before-local-open")
13739 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13740 fs::create_dir_all(open_race.target_db.parent().ok_or_else(|| {
13741 CliError::InvalidInput("replacement database has no parent".to_string())
13742 })?)
13743 .map_err(|source| CliError::Io {
13744 path: open_race.state.root.clone(),
13745 source,
13746 })?;
13747 fs::write(&open_race.target_db, sentinel).map_err(|source| CliError::Io {
13748 path: open_race.target_db.clone(),
13749 source,
13750 })
13751 },
13752 )?;
13753 require(
13754 retired.state == WorktreeRegistrationState::Retired
13755 && retired.project_instance_id.is_none()
13756 && synchronized.is_none()
13757 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
13758 && fs::read(&open_race.target_db)? == sentinel,
13759 "replacement database failure blocked stale retirement or changed replacement bytes",
13760 )
13761 }
13762
13763 #[test]
13764 fn retirement_reclassifies_identity_and_snapshot_failures_after_lifecycle_replacement()
13765 -> Result<(), Box<dyn std::error::Error>> {
13766 for (suffix, failure) in [
13767 ("retire-identity-failure", "project identity read failed"),
13768 ("retire-snapshot-failure", "usage snapshot export failed"),
13769 ] {
13770 let fixture = registered_worktree_race_fixture(suffix)?;
13771 let replacement_entry = replace_registered_worktree(&fixture, suffix)?;
13772 fs::create_dir_all(
13773 fixture
13774 .target_db
13775 .parent()
13776 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13777 )?;
13778 let replacement =
13779 AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13780 replacement.record_usage(&usage_from_text(
13781 suffix,
13782 "atlas_token_report",
13783 None,
13784 None,
13785 "pub fn replacement() {}",
13786 "replacement telemetry",
13787 ))?;
13788 let replacement_project = replacement
13789 .project_instance_id()?
13790 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13791 drop(replacement);
13792
13793 let control = open_atlas_store_for_project(&fixture.control_db, &fixture.control_root)?;
13794 let classified = control.with_active_worktree_registration(
13795 fixture.registration.registration_id,
13796 &fixture.alias,
13797 |guard| {
13798 ProjectAtlasMcpServer::classify_retirement_failure(
13799 guard,
13800 &fixture.state.root,
13801 2,
13802 CliError::InvalidInput(failure.to_string()),
13803 )
13804 },
13805 )?;
13806 let (retired, synchronized, blocker) = classified?;
13807 let replacement =
13808 open_atlas_store_read_only_for_project(&fixture.target_db, &fixture.state.root)?;
13809 require(
13810 retired.state == WorktreeRegistrationState::Retired
13811 && retired.project_instance_id.is_none()
13812 && synchronized.is_none()
13813 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
13814 && replacement.project_instance_id()? == Some(replacement_project)
13815 && replacement.token_overview(Some(suffix))?.calls == 1,
13816 "retirement failure classification changed or imported replacement state",
13817 )?;
13818
13819 let repository = fixture.server.control_git_repository()?;
13820 let replacement_alias = WorktreeAlias::parse(&format!("{suffix}-owner"))?;
13821 let replacement_registration = control.register_worktree(
13822 &replacement_alias,
13823 &repository.common_directory,
13824 &replacement_entry.administrative_directory,
13825 &git_administrative_identity(&replacement_entry.administrative_directory)?,
13826 &fixture.state.root,
13827 Some(replacement_project),
13828 3,
13829 )?;
13830 require(
13831 replacement_registration.project_instance_id == Some(replacement_project),
13832 "retirement failure classification stranded replacement ownership",
13833 )?;
13834 }
13835 Ok(())
13836 }
13837
13838 #[test]
13839 fn registered_reset_and_binding_linearize_without_recreating_a_reset_winner()
13840 -> Result<(), Box<dyn std::error::Error>> {
13841 let bind_wins = registered_worktree_race_fixture("reset-bind-wins")?;
13842 fs::create_dir_all(
13843 bind_wins
13844 .target_db
13845 .parent()
13846 .ok_or_else(|| io::Error::other("bind-wins database has no parent"))?,
13847 )?;
13848 let target = AtlasStore::open_for_project(&bind_wins.target_db, &bind_wins.state.root)?;
13849 let project = target
13850 .project_instance_id()?
13851 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13852 drop(target);
13853 let before = fs::read(&bind_wins.target_db)?;
13854 let control = open_atlas_store_for_project(&bind_wins.control_db, &bind_wins.primary)?;
13855 control.bind_worktree_project(
13856 bind_wins.registration.registration_id,
13857 &bind_wins.alias,
13858 &bind_wins.state.root,
13859 project,
13860 )?;
13861 let rejected = bind_wins.server.reset_registered_worktree_index(
13862 &bind_wins.state,
13863 &bind_wins.selection,
13864 false,
13865 );
13866 require(
13867 rejected.as_ref().is_err_and(|error| {
13868 error
13869 .to_string()
13870 .contains(MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED)
13871 }) && fs::read(&bind_wins.target_db)? == before,
13872 "reset deleted a target after a concurrent binding won",
13873 )?;
13874
13875 let reset_wins = registered_worktree_race_fixture("reset-delete-wins")?;
13876 fs::create_dir_all(
13877 reset_wins
13878 .target_db
13879 .parent()
13880 .ok_or_else(|| io::Error::other("reset-wins database has no parent"))?,
13881 )?;
13882 drop(AtlasStore::open_for_project(
13883 &reset_wins.target_db,
13884 &reset_wins.state.root,
13885 )?);
13886 reset_wins.server.reset_registered_worktree_index(
13887 &reset_wins.state,
13888 &reset_wins.selection,
13889 false,
13890 )?;
13891 require(
13892 !reset_wins.target_db.exists(),
13893 "winning reset did not delete the unbound target atlas",
13894 )?;
13895 let late_bind = ProjectAtlasMcpServer::open_registered_worktree_mut_store(
13896 &reset_wins.state,
13897 &reset_wins.server.control_state,
13898 &reset_wins.selection,
13899 );
13900 let control =
13901 open_atlas_store_read_only_for_project(&reset_wins.control_db, &reset_wins.primary)?;
13902 require(
13903 late_bind.is_err()
13904 && !reset_wins.target_db.exists()
13905 && control
13906 .worktree_registration(&reset_wins.alias)?
13907 .project_instance_id
13908 .is_none(),
13909 "late binding recreated or attached the atlas deleted by reset",
13910 )?;
13911
13912 let replaced = registered_worktree_race_fixture("reset-replaced-lifecycle")?;
13913 let replacement_files = [
13914 replaced.target_db.clone(),
13915 db_sidecar_path(&replaced.target_db, "wal"),
13916 db_sidecar_path(&replaced.target_db, "shm"),
13917 db_sidecar_path(&replaced.target_db, "journal"),
13918 mcp_config_path_for_db(&replaced.target_db),
13919 ];
13920 let replacement_bytes = replacement_files
13921 .iter()
13922 .enumerate()
13923 .map(|(index, _)| format!("replacement-owned-{index}").into_bytes())
13924 .collect::<Vec<_>>();
13925 let rejected = replaced
13926 .server
13927 .reset_registered_worktree_index_with_post_validation(
13928 &replaced.state,
13929 &replaced.selection,
13930 true,
13931 || {
13932 replace_registered_worktree(&replaced, "replacement-during-reset")
13933 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13934 fs::create_dir_all(replaced.target_db.parent().ok_or_else(|| {
13935 CliError::InvalidInput("replacement database has no parent".to_string())
13936 })?)
13937 .map_err(|source| CliError::Io {
13938 path: replaced.state.root.clone(),
13939 source,
13940 })?;
13941 for (path, bytes) in replacement_files.iter().zip(&replacement_bytes) {
13942 fs::write(path, bytes).map_err(|source| CliError::Io {
13943 path: path.clone(),
13944 source,
13945 })?;
13946 }
13947 Ok(())
13948 },
13949 );
13950 let control =
13951 open_atlas_store_read_only_for_project(&replaced.control_db, &replaced.primary)?;
13952 require(
13953 rejected.as_ref().is_err_and(|error| {
13954 error
13955 .to_string()
13956 .contains("administrative lifecycle changed")
13957 }) && replacement_files
13958 .iter()
13959 .zip(&replacement_bytes)
13960 .all(|(path, bytes)| {
13961 fs::read(path).is_ok_and(|found| found.as_slice() == bytes.as_slice())
13962 })
13963 && control
13964 .worktree_registration(&replaced.alias)?
13965 .project_instance_id
13966 .is_none(),
13967 "guarded reset deleted replacement lifecycle database, sidecars, or MCP config",
13968 )
13969 }
13970
13971 #[test]
13972 fn registered_worktree_missing_atlas_does_not_refresh_control_root()
13973 -> Result<(), Box<dyn std::error::Error>> {
13974 let fixture = registered_worktree_race_fixture("missing-atlas")?;
13975 let expected_project = ProjectInstanceId::from_bytes([0xA; 16])?;
13976 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
13977 control.bind_worktree_project(
13978 fixture.registration.registration_id,
13979 &fixture.alias,
13980 &fixture.state.root,
13981 expected_project,
13982 )?;
13983 drop(control);
13984
13985 let sidecars = ["-wal", "-shm", "-journal"].map(|suffix| {
13986 let mut path = fixture.control_db.as_os_str().to_os_string();
13987 path.push(suffix);
13988 PathBuf::from(path)
13989 });
13990 let before = std::iter::once(&fixture.control_db)
13991 .chain(sidecars.iter())
13992 .map(|path| fs::read(path).ok())
13993 .collect::<Vec<_>>();
13994 let result = fixture
13995 .server
13996 .state_for_target(None, Some(fixture.alias.to_string()));
13997 require(
13998 result.is_err(),
13999 "missing bound atlas was accepted during registered resolution",
14000 )?;
14001 let after = std::iter::once(&fixture.control_db)
14002 .chain(sidecars.iter())
14003 .map(|path| fs::read(path).ok())
14004 .collect::<Vec<_>>();
14005 require(
14006 before == after,
14007 "failed atlas validation refreshed the control registration",
14008 )?;
14009 Ok(())
14010 }
14011
14012 #[test]
14013 fn unbound_registered_worktree_move_refreshes_root_before_missing_retirement()
14014 -> Result<(), Box<dyn std::error::Error>> {
14015 let fixture = registered_worktree_race_fixture("unbound-move")?;
14016 let original_root_display = normalize_native_path_display(&fixture.state.root);
14017 let moved = fixture
14018 .primary
14019 .parent()
14020 .ok_or_else(|| io::Error::other("moved worktree has no parent"))?
14021 .join("moved-unbound-worktree");
14022 run_fixture_command(
14023 StdCommand::new("git")
14024 .current_dir(&fixture.primary)
14025 .args(["worktree", "move"])
14026 .arg(&fixture.linked)
14027 .arg(&moved),
14028 )?;
14029 let moved_root = moved.canonicalize()?;
14030 let unresolved = fixture
14031 .server
14032 .state_for_target(None, Some(fixture.alias.to_string()));
14033 require(
14034 unresolved.is_err(),
14035 "unbound moved worktree without an atlas was accepted as initialized",
14036 )?;
14037
14038 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
14039 let refreshed = control.worktree_registration(&fixture.alias)?;
14040 require(
14041 refreshed.project_instance_id.is_none()
14042 && refreshed.last_root == normalize_native_path_display(&moved_root),
14043 "unbound Git move did not refresh its registered root",
14044 )?;
14045 drop(control);
14046
14047 run_fixture_command(
14048 StdCommand::new("git")
14049 .current_dir(&fixture.primary)
14050 .args(["worktree", "remove", "--force"])
14051 .arg(&moved),
14052 )?;
14053 let missing = fixture
14054 .server
14055 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
14056 include_retired: Some(false),
14057 }));
14058 require(
14059 missing.contains(&normalize_native_path_display(&moved_root))
14060 && !missing.contains(&original_root_display)
14061 && missing.contains("\"unbound-move\",linked,available,missing,registered"),
14062 &format!("missing worktree reporting retained the pre-move root: {missing}"),
14063 )?;
14064 let retired = fixture
14065 .server
14066 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14067 worktree: fixture.alias.to_string(),
14068 }));
14069 require(
14070 retired.contains("status: retired")
14071 && retired.contains(&normalize_native_path_display(&moved_root))
14072 && !retired.contains(&original_root_display),
14073 &format!("retirement reporting retained the pre-move root: {retired}"),
14074 )
14075 }
14076
14077 #[test]
14078 fn registered_worktree_move_refreshes_registry_root_after_local_rebind()
14079 -> Result<(), Box<dyn std::error::Error>> {
14080 let fixture = registered_worktree_race_fixture("moved-root")?;
14081 let original_root = fixture.state.root.clone();
14082 let original_root_display = normalize_native_path_display(&original_root);
14083 let target_config = fixture
14084 .linked
14085 .join(PROJECTATLAS_DIR_NAME)
14086 .join(PROJECTATLAS_CONFIG_FILE_NAME);
14087 init_project_with_config(&fixture.linked, Some(&target_config))?;
14088 let target_store = AtlasStore::open_for_project(&fixture.target_db, &fixture.linked)?;
14089 let target_project = target_store
14090 .project_instance_id()?
14091 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
14092 drop(target_store);
14093
14094 let registered = {
14095 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
14096 control.register_worktree(
14097 &fixture.alias,
14098 &fixture.control_root.join(".git"),
14099 &fixture.administrative_directory,
14100 &fixture.registration.git_administrative_identity,
14101 &original_root,
14102 Some(target_project),
14103 1,
14104 )?
14105 };
14106 require(
14107 registered.project_instance_id == Some(target_project),
14108 "moved worktree fixture remained unbound",
14109 )?;
14110 let original_registration = registered;
14111
14112 let moved = fixture
14113 .primary
14114 .parent()
14115 .ok_or_else(|| io::Error::other("moved worktree has no parent"))?
14116 .join("moved-worktree");
14117 run_fixture_command(
14118 StdCommand::new("git")
14119 .current_dir(&fixture.primary)
14120 .args(["worktree", "move"])
14121 .arg(&fixture.linked)
14122 .arg(&moved),
14123 )?;
14124 let moved_root = moved.canonicalize()?;
14125 let moved_db = moved_root
14126 .join(PROJECTATLAS_DIR_NAME)
14127 .join(PROJECTATLAS_DB_FILE_NAME);
14128 let moved_binding = AtlasStore::transition_project_root(
14129 &moved_db,
14130 &moved_root,
14131 ProjectRootTransition::Move,
14132 )?;
14133 require(
14134 moved_binding.project_instance_id == target_project,
14135 "local root rebind changed the registered project identity",
14136 )?;
14137 require(
14138 read_project_root_identity_read_only(&moved_db)?
14139 == Some(CanonicalProjectRoot::from_path(&moved_root)?),
14140 "local root rebind did not establish the moved native identity",
14141 )?;
14142
14143 let resolved = fixture
14144 .server
14145 .state_for_target(None, Some(fixture.alias.to_string()))?;
14146 require(
14147 resolved.root == moved_root,
14148 "alias resolution did not use the moved Git root",
14149 )?;
14150 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
14151 let refreshed = control.worktree_registration(&fixture.alias)?;
14152 require(
14153 refreshed.last_root == normalize_native_path_display(&moved_root)
14154 && refreshed.last_root != original_root_display,
14155 "alias resolution retained the stale registry root",
14156 )?;
14157 require(
14158 refreshed.registration_id == original_registration.registration_id
14159 && refreshed.alias == original_registration.alias
14160 && refreshed.git_common_directory == original_registration.git_common_directory
14161 && refreshed.git_administrative_directory
14162 == original_registration.git_administrative_directory
14163 && refreshed.git_administrative_identity
14164 == original_registration.git_administrative_identity
14165 && refreshed.project_instance_id == original_registration.project_instance_id
14166 && refreshed.accepted_telemetry_revision
14167 == original_registration.accepted_telemetry_revision,
14168 "moved root refresh changed administrative identity or telemetry state",
14169 )?;
14170 drop(control);
14171
14172 run_fixture_command(
14173 StdCommand::new("git")
14174 .current_dir(&fixture.primary)
14175 .args(["worktree", "remove", "--force"])
14176 .arg(&moved),
14177 )?;
14178 let missing = fixture
14179 .server
14180 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
14181 include_retired: Some(false),
14182 }));
14183 require(
14184 missing.contains(&normalize_native_path_display(&moved_root))
14185 && !missing.contains(&original_root_display)
14186 && missing.contains("\"moved-root\",linked,available,missing,registered"),
14187 &format!("missing worktree reporting regressed to the pre-move root: {missing}"),
14188 )?;
14189 let retired = fixture
14190 .server
14191 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14192 worktree: fixture.alias.to_string(),
14193 }));
14194 require(
14195 retired.contains("status: retired")
14196 && retired.contains(&normalize_native_path_display(&moved_root))
14197 && !retired.contains(&original_root_display),
14198 &format!("retirement did not preserve the moved root: {retired}"),
14199 )
14200 }
14201
14202 #[test]
14203 fn worktree_tools_register_route_and_retire_without_git_or_file_lifecycle_mutation()
14204 -> Result<(), Box<dyn std::error::Error>> {
14205 let temp = tempfile::tempdir()?;
14206 let primary = temp.path().join("selected control");
14207 let worktree_a = temp.path().join("unrelated one").join("checkout");
14208 let worktree_b = temp.path().join("unrelated two").join("checkout");
14209 fs::create_dir_all(&primary)?;
14210 fs::create_dir_all(
14211 worktree_a
14212 .parent()
14213 .ok_or_else(|| io::Error::other("worktree A has no parent"))?,
14214 )?;
14215 fs::create_dir_all(
14216 worktree_b
14217 .parent()
14218 .ok_or_else(|| io::Error::other("worktree B has no parent"))?,
14219 )?;
14220 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
14221 for (key, value) in [
14222 ("user.name", "ProjectAtlas Test"),
14223 ("user.email", "projectatlas@example.invalid"),
14224 ("commit.gpgsign", "false"),
14225 ("core.autocrlf", "false"),
14226 ] {
14227 run_fixture_command(
14228 StdCommand::new("git")
14229 .current_dir(&primary)
14230 .args(["config", key, value]),
14231 )?;
14232 }
14233 fs::create_dir(primary.join("src"))?;
14234 fs::write(
14235 primary.join("src").join("lib.rs"),
14236 "mod child;\npub fn main_only() { child::helper(); }\n",
14237 )?;
14238 fs::write(primary.join("src").join("child.rs"), "pub fn helper() {}\n")?;
14239 run_fixture_command(
14240 StdCommand::new("git")
14241 .current_dir(&primary)
14242 .args(["add", "."]),
14243 )?;
14244 run_fixture_command(
14245 StdCommand::new("git")
14246 .current_dir(&primary)
14247 .args(["commit", "-m", "fixture"]),
14248 )?;
14249 run_fixture_command(
14250 StdCommand::new("git")
14251 .current_dir(&primary)
14252 .args(["worktree", "add", "-b", "issue-430-a"])
14253 .arg(&worktree_a),
14254 )?;
14255 run_fixture_command(
14256 StdCommand::new("git")
14257 .current_dir(&primary)
14258 .args(["worktree", "add", "-b", "issue-430-b"])
14259 .arg(&worktree_b),
14260 )?;
14261 fs::write(
14262 worktree_a.join("src").join("branch.rs"),
14263 "pub fn worktree_only() {}\n",
14264 )?;
14265 let git_before = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
14266 "worktree",
14267 "list",
14268 "--porcelain",
14269 ]))?;
14270
14271 let control_db = primary.join(".projectatlas").join("projectatlas.db");
14272 let control_config = primary.join(".projectatlas").join("config.toml");
14273 init_project_with_config(&primary, Some(&control_config))?;
14274 let mut control_store = AtlasStore::open_for_project(&control_db, &primary)?;
14275 let control_plan = ScanRuntimePlan::for_path(Some(&control_config), &primary, None)?;
14276 run_scan_pipeline(
14277 &mut control_store,
14278 &control_plan,
14279 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
14280 )?;
14281 control_store.set_purpose(
14282 "src/lib.rs",
14283 "Own the shared library contract.",
14284 PurposeSource::Agent,
14285 )?;
14286 drop(control_store);
14287 let server = ProjectAtlasMcpServer::new(
14288 control_db.clone(),
14289 Some(control_config),
14290 "worktree-tools".to_string(),
14291 false,
14292 );
14293 let control_before_blank_selector = fs::read(&control_db)?;
14294 let blank_selector = server.atlas_reset_index(Parameters(AtlasResetIndexParams {
14295 project_path: None,
14296 worktree: Some(" ".to_string()),
14297 apply: Some(true),
14298 dry_run: Some(false),
14299 include_mcp_config: Some(true),
14300 }));
14301 require(
14302 blank_selector.contains(MCP_ERROR_WORKTREE_SELECTOR_EMPTY),
14303 "blank worktree selector fell back to the control atlas",
14304 )?;
14305 require(
14306 fs::read(&control_db)? == control_before_blank_selector,
14307 "blank worktree selector changed the control database",
14308 )?;
14309 let repository = server.control_git_repository()?;
14310 let canonical_a = worktree_a.canonicalize()?;
14311 let entry_a = repository
14312 .worktrees
14313 .iter()
14314 .find(|entry| {
14315 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(canonical_a.as_path())
14316 })
14317 .ok_or_else(|| io::Error::other("worktree A was not structurally discovered"))?;
14318 let selector_a = ProjectAtlasMcpServer::worktree_candidate_selector(entry_a);
14319 let canonical_b = worktree_b.canonicalize()?;
14320 let entry_b = repository
14321 .worktrees
14322 .iter()
14323 .find(|entry| {
14324 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(canonical_b.as_path())
14325 })
14326 .ok_or_else(|| io::Error::other("worktree B was not structurally discovered"))?;
14327 let selector_b = ProjectAtlasMcpServer::worktree_candidate_selector(entry_b);
14328
14329 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
14330 include_retired: Some(false),
14331 }));
14332 require(
14333 listed.contains("control_alias: main")
14334 && listed.contains(&selector_a)
14335 && listed.contains(&normalize_native_path_display(&canonical_a)),
14336 "worktree list omitted control, stable selector, or arbitrary exact root",
14337 )?;
14338 let ambiguous = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14339 worktree: "checkout".to_string(),
14340 alias: Some("issue-430".to_string()),
14341 }));
14342 require(
14343 ambiguous.contains("status: ambiguous")
14344 && ambiguous.matches(MCP_WORKTREE_SELECTOR_PREFIX).count() >= 2,
14345 "ambiguous human selector guessed or omitted bounded stable candidates",
14346 )?;
14347 let control_before_blank_alias = fs::read(&control_db)?;
14348 let blank_alias = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14349 worktree: selector_a.clone(),
14350 alias: Some(" ".to_string()),
14351 }));
14352 require(
14353 blank_alias.contains("alias is empty"),
14354 "blank explicit alias fell back to the selected directory name",
14355 )?;
14356 require(
14357 fs::read(&control_db)? == control_before_blank_alias,
14358 "blank explicit alias changed the control database",
14359 )?;
14360
14361 let target_b_config = worktree_b.join(PROJECTATLAS_DIR_NAME).join("config.toml");
14362 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14363 let target_b_db = worktree_b
14364 .join(PROJECTATLAS_DIR_NAME)
14365 .join(PROJECTATLAS_DB_FILE_NAME);
14366 let target_b_store = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14367 target_b_store.record_usage(&usage_from_text(
14368 "snapshot-blocked",
14369 "atlas_overview",
14370 None,
14371 None,
14372 "pub fn main_only() {}",
14373 "repository overview",
14374 ))?;
14375 let target_b_project = target_b_store
14376 .project_instance_id()?
14377 .ok_or_else(|| io::Error::other("worktree B project identity is missing"))?;
14378 drop(target_b_store);
14379 let corrupt = rusqlite::Connection::open(&target_b_db)?;
14380 corrupt.execute_batch("PRAGMA ignore_check_constraints = ON;")?;
14381 let corrupted = corrupt.execute("UPDATE usage_global_aggregates SET calls = -1", [])?;
14382 require(
14383 corrupted > 0,
14384 "worktree B fixture did not invalidate an aggregate row",
14385 )?;
14386 drop(corrupt);
14387 let rejected_snapshot = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14388 worktree: selector_b.clone(),
14389 alias: Some("snapshot-blocked".to_string()),
14390 }));
14391 require(
14392 rejected_snapshot.contains("telemetry integer overflow")
14393 && !rejected_snapshot.contains("status: registered"),
14394 &format!(
14395 "worktree registration survived a failed local usage snapshot: {rejected_snapshot}"
14396 ),
14397 )?;
14398 let control_after_rejection =
14399 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14400 require(
14401 matches!(
14402 control_after_rejection
14403 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?),
14404 Err(DbError::WorktreeRegistrationNotFound { .. })
14405 ),
14406 "telemetry export failure committed a partial worktree registration",
14407 )?;
14408 drop(control_after_rejection);
14409 let repaired = rusqlite::Connection::open(&target_b_db)?;
14410 repaired.execute("UPDATE usage_global_aggregates SET calls = 1", [])?;
14411 drop(repaired);
14412 let registered_snapshot = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14413 worktree: selector_b.clone(),
14414 alias: Some("snapshot-blocked".to_string()),
14415 }));
14416 require(
14417 registered_snapshot.contains("status: registered"),
14418 &format!(
14419 "repaired local usage snapshot did not register atomically: {registered_snapshot}"
14420 ),
14421 )?;
14422 let control_after_snapshot = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14423 require(
14424 control_after_snapshot
14425 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?)?
14426 .project_instance_id
14427 == Some(target_b_project)
14428 && control_after_snapshot
14429 .registered_worktree_token_overview(&WorktreeAlias::parse("snapshot-blocked")?)?
14430 .calls
14431 == 1,
14432 "successful registration did not bind identity and import telemetry atomically",
14433 )?;
14434 drop(control_after_snapshot);
14435 let target_b_state = worktree_b.join(PROJECTATLAS_DIR_NAME);
14436 let preserved_target_b_state = worktree_b.join(".projectatlas-snapshot-blocked");
14437 fs::rename(&target_b_state, &preserved_target_b_state)?;
14438 fs::create_dir(&target_b_state)?;
14439 let replacement_b = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14440 require(
14441 replacement_b.project_instance_id()? != Some(target_b_project),
14442 "worktree B replacement reused the registered project identity",
14443 )?;
14444 drop(replacement_b);
14445 let replacement_b_error =
14446 server.state_for_target(None, Some("snapshot-blocked".to_string()));
14447 require(
14448 replacement_b_error.as_ref().is_err_and(|error| {
14449 error
14450 .to_string()
14451 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14452 }),
14453 "snapshot-backed registration routed to a replacement atlas",
14454 )?;
14455 fs::remove_dir_all(&target_b_state)?;
14456 let refused_snapshot =
14457 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14458 worktree: "snapshot-blocked".to_string(),
14459 }));
14460 require(
14461 refused_snapshot.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
14462 &format!(
14463 "bound registration retired without its required final snapshot: {refused_snapshot}"
14464 ),
14465 )?;
14466 let control_after_refusal = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14467 require(
14468 control_after_refusal
14469 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?)?
14470 .state
14471 == WorktreeRegistrationState::Active,
14472 "missing bound atlas retirement changed the active registration",
14473 )?;
14474 drop(control_after_refusal);
14475 fs::rename(&preserved_target_b_state, &target_b_state)?;
14476 let retired_snapshot =
14477 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14478 worktree: "snapshot-blocked".to_string(),
14479 }));
14480 require(
14481 retired_snapshot.contains("status: retired"),
14482 &format!(
14483 "restored bound atlas did not permit final-sync retirement: {retired_snapshot}"
14484 ),
14485 )?;
14486 fs::remove_dir_all(&target_b_state)?;
14487
14488 let legacy_added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14489 worktree: selector_b.clone(),
14490 alias: Some("legacy-init".to_string()),
14491 }));
14492 require(
14493 legacy_added.contains("status: registered"),
14494 &format!("legacy-init fixture registration failed: {legacy_added}"),
14495 )?;
14496 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14497 let control_before_legacy_sync =
14498 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14499 require(
14500 control_before_legacy_sync
14501 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14502 .project_instance_id
14503 .is_none(),
14504 "independent exact-path init unexpectedly mutated the control registration",
14505 )?;
14506 drop(control_before_legacy_sync);
14507 let legacy_target = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14508 legacy_target.record_usage(&usage_from_text(
14509 "legacy-init",
14510 "atlas_overview",
14511 None,
14512 None,
14513 "pub fn independent() {}",
14514 "repository overview",
14515 ))?;
14516 let legacy_project = legacy_target
14517 .project_instance_id()?
14518 .ok_or_else(|| io::Error::other("legacy-init target identity is missing"))?;
14519 drop(legacy_target);
14520 let legacy_state = server.state_for_target(None, Some("legacy-init".to_string()))?;
14521 let legacy_selection = legacy_state
14522 .worktree
14523 .as_ref()
14524 .ok_or_else(|| io::Error::other("legacy-init selection is missing"))?;
14525 let corrupt = rusqlite::Connection::open(&target_b_db)?;
14526 corrupt.execute_batch("PRAGMA ignore_check_constraints = ON;")?;
14527 corrupt.execute("UPDATE usage_global_aggregates SET calls = -1", [])?;
14528 drop(corrupt);
14529 let rejected_bind = ProjectAtlasMcpServer::open_registered_worktree_mut_store(
14530 &legacy_state,
14531 &server.control_state,
14532 legacy_selection,
14533 );
14534 let control_after_rejected_bind =
14535 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14536 require(
14537 rejected_bind
14538 .as_ref()
14539 .is_err_and(|error| error.to_string().contains("telemetry integer overflow"))
14540 && control_after_rejected_bind
14541 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14542 .project_instance_id
14543 .is_none()
14544 && control_after_rejected_bind
14545 .registered_worktree_token_overview(&WorktreeAlias::parse("legacy-init")?)?
14546 .calls
14547 == 0,
14548 "failed deferred snapshot synchronization committed its project binding or aggregate",
14549 )?;
14550 drop(control_after_rejected_bind);
14551 let repaired = rusqlite::Connection::open(&target_b_db)?;
14552 repaired.execute("UPDATE usage_global_aggregates SET calls = 1", [])?;
14553 drop(repaired);
14554 drop(ProjectAtlasMcpServer::open_registered_worktree_mut_store(
14555 &legacy_state,
14556 &server.control_state,
14557 legacy_selection,
14558 )?);
14559 let control_after_legacy_init =
14560 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14561 require(
14562 control_after_legacy_init
14563 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14564 .project_instance_id
14565 == Some(legacy_project),
14566 "first alias mutation did not bind the independently initialized alias",
14567 )?;
14568 require(
14569 control_after_legacy_init
14570 .registered_worktree_token_overview(&WorktreeAlias::parse("legacy-init")?)?
14571 .calls
14572 == 1,
14573 "first alias mutation omitted independently initialized worktree usage",
14574 )?;
14575 drop(control_after_legacy_init);
14576 let preserved_legacy_state = worktree_b.join(".projectatlas-legacy-init");
14577 fs::rename(&target_b_state, &preserved_legacy_state)?;
14578 fs::create_dir(&target_b_state)?;
14579 let legacy_replacement = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14580 require(
14581 legacy_replacement.project_instance_id()? != Some(legacy_project),
14582 "legacy-init replacement reused the registered project identity",
14583 )?;
14584 drop(legacy_replacement);
14585 let legacy_replacement_error =
14586 server.state_for_target(None, Some("legacy-init".to_string()));
14587 require(
14588 legacy_replacement_error.as_ref().is_err_and(|error| {
14589 error
14590 .to_string()
14591 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14592 }),
14593 "legacy exact-path init left its alias unbound to a replacement atlas",
14594 )?;
14595 fs::remove_dir_all(&target_b_state)?;
14596 let refused_legacy = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14597 worktree: "legacy-init".to_string(),
14598 }));
14599 require(
14600 refused_legacy.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
14601 &format!("legacy-init registration retired without its atlas: {refused_legacy}"),
14602 )?;
14603 fs::rename(&preserved_legacy_state, &target_b_state)?;
14604 let retired_legacy = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14605 worktree: "legacy-init".to_string(),
14606 }));
14607 require(
14608 retired_legacy.contains("status: retired"),
14609 &format!("restored legacy-init registration could not be retired: {retired_legacy}"),
14610 )?;
14611 fs::remove_dir_all(&target_b_state)?;
14612
14613 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14614 let migratable_store = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14615 let migratable_project = migratable_store
14616 .project_instance_id()?
14617 .ok_or_else(|| io::Error::other("migratable target identity is missing"))?;
14618 drop(migratable_store);
14619 let incomplete_current = rusqlite::Connection::open(&target_b_db)?;
14620 incomplete_current.execute(
14621 "UPDATE metadata SET value = ?1 WHERE key = 'project_root'",
14622 [worktree_b.join(".").to_string_lossy().into_owned()],
14623 )?;
14624 incomplete_current.execute_batch(
14630 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
14631 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
14632 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
14633 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
14634 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;
14635 DROP TABLE graph_identity_rejections;
14636 UPDATE metadata SET value = '20' WHERE key = 'schema_version';",
14637 )?;
14638 drop(incomplete_current);
14639 let migratable_added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14640 worktree: selector_b,
14641 alias: Some("migratable-atlas".to_string()),
14642 }));
14643 require(
14644 migratable_added.contains("status: registered")
14645 && migratable_added
14646 .contains("registration committed without local telemetry import"),
14647 &format!(
14648 "current repair atlas did not retain its explicit unbound registration: {migratable_added}"
14649 ),
14650 )?;
14651 let control_before_migration =
14652 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14653 require(
14654 control_before_migration
14655 .worktree_registration(&WorktreeAlias::parse("migratable-atlas")?)?
14656 .project_instance_id
14657 .is_none(),
14658 "current repair fixture unexpectedly bound before scan",
14659 )?;
14660 drop(control_before_migration);
14661 let migrated = server.atlas_scan(Parameters(AtlasScanParams {
14662 project_path: None,
14663 worktree: Some("migratable-atlas".to_string()),
14664 path: None,
14665 nearest_project: Some(false),
14666 max_bytes: None,
14667 max_workers: Some(1),
14668 timeout_seconds: None,
14669 text_index_max_bytes: None,
14670 background: Some(false),
14671 }));
14672 require(
14673 migrated.contains("scan:"),
14674 &format!("alias-routed current repair did not complete its scan: {migrated}"),
14675 )?;
14676 let migrated_target = open_atlas_store_read_only_for_project(&target_b_db, &worktree_b)?;
14677 require(
14678 migrated_target.project_instance_id()? == Some(migratable_project),
14679 "current repair changed the worktree project identity",
14680 )?;
14681 drop(migrated_target);
14682 let control_after_migration =
14683 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14684 require(
14685 control_after_migration
14686 .worktree_registration(&WorktreeAlias::parse("migratable-atlas")?)?
14687 .project_instance_id
14688 == Some(migratable_project),
14689 "alias-routed current repair left its registration unbound",
14690 )?;
14691 drop(control_after_migration);
14692 let retired_migratable =
14693 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14694 worktree: "migratable-atlas".to_string(),
14695 }));
14696 require(
14697 retired_migratable.contains("status: retired"),
14698 &format!("current-repair registration could not be retired: {retired_migratable}"),
14699 )?;
14700
14701 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14702 worktree: selector_a,
14703 alias: Some("issue-430".to_string()),
14704 }));
14705 require(
14706 added.contains("status: registered")
14707 && added.contains("alias: \"issue-430\"")
14708 && added.contains("git_unchanged: true")
14709 && added.contains("files_unchanged: true"),
14710 &format!("stable selector did not create one lifecycle-neutral registration: {added}"),
14711 )?;
14712 require(
14713 !worktree_a.join(".projectatlas").exists(),
14714 "registration created a target atlas before explicit init",
14715 )?;
14716
14717 let missing = server.atlas_overview_response(
14718 AtlasProjectParams {
14719 project_path: None,
14720 worktree: Some("issue-430".to_string()),
14721 },
14722 None,
14723 );
14724 require(
14725 missing.contains("init_required")
14726 && missing.contains("tool: atlas_init")
14727 && missing.contains("worktree: \"issue-430\"")
14728 && !missing.contains("project_path:"),
14729 "missing alias target did not preserve its short selector in typed init guidance",
14730 )?;
14731 let conflict = server.atlas_overview_response(
14732 AtlasProjectParams {
14733 project_path: Some(primary.to_string_lossy().to_string()),
14734 worktree: Some("issue-430".to_string()),
14735 },
14736 None,
14737 );
14738 require(
14739 conflict.contains(MCP_WORKTREE_PROJECT_PATH_CONFLICT),
14740 "worktree/project_path conflict was not rejected by the shared resolver",
14741 )?;
14742
14743 let initialized = server.atlas_init(Parameters(AtlasInitParams {
14744 project_path: None,
14745 worktree: Some("issue-430".to_string()),
14746 no_scan: Some(false),
14747 force_rescan: Some(false),
14748 text_index_max_bytes: None,
14749 }));
14750 require(
14751 initialized.contains("status: hydrated")
14752 && initialized.contains("source_project_instance_id:")
14753 && initialized.contains("target_project_instance_id:")
14754 && initialized.contains("reconciled_generation:")
14755 && initialized.contains("parsed: 1"),
14756 &format!("registered alias init did not expose one completed hydration: {initialized}"),
14757 )?;
14758 let target_db = worktree_a.join(".projectatlas").join("projectatlas.db");
14759 let target_store = open_atlas_store_read_only_for_project(&target_db, &worktree_a)?;
14760 require(
14761 target_store.load_node_by_path("src/branch.rs")?.is_some(),
14762 "hydration reconciliation omitted a target-only dirty source file",
14763 )?;
14764 require(
14765 target_store
14766 .load_node_by_path("src/lib.rs")?
14767 .is_some_and(|node| {
14768 node.purpose.purpose.as_deref() == Some("Own the shared library contract.")
14769 && node.purpose.source == PurposeSource::Agent
14770 }),
14771 "hydration did not preserve an applicable approved main purpose",
14772 )?;
14773 let target_project = target_store
14774 .project_instance_id()?
14775 .ok_or_else(|| io::Error::other("hydrated target identity is missing"))?;
14776 drop(target_store);
14777 let captured_alias_state = server.state_for_target(None, Some("issue-430".to_string()))?;
14778 let captured_federated_aliases = ["main".to_string(), "issue-430".to_string()];
14779 let (captured_federated_roots, captured_federated_selections) =
14780 server.federated_worktree_roots(&captured_federated_aliases)?;
14781 let captured_main_state = server.state_for_target(None, Some("main".to_string()))?;
14782 let control_after_init = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14783 let control_project = control_after_init
14784 .project_instance_id()?
14785 .ok_or_else(|| io::Error::other("control project identity is missing"))?;
14786 require(
14787 captured_main_state
14788 .worktree
14789 .as_ref()
14790 .and_then(|selection| selection.project_instance_id)
14791 == Some(control_project),
14792 "main alias did not capture the current control atlas identity",
14793 )?;
14794 require(
14795 [&captured_main_state, &captured_alias_state]
14796 .iter()
14797 .all(|state| {
14798 state
14799 .worktree
14800 .as_ref()
14801 .and_then(|selection| selection.control_project_instance_id)
14802 == Some(control_project)
14803 }),
14804 "alias selection did not capture its control atlas identity",
14805 )?;
14806 require(
14807 control_after_init
14808 .load_node_by_path("src/branch.rs")?
14809 .is_none(),
14810 "target-only source state bled into the control graph",
14811 )?;
14812 require(
14813 control_after_init
14814 .worktree_registration(&WorktreeAlias::parse("issue-430")?)?
14815 .project_instance_id
14816 == Some(target_project),
14817 "successful alias init did not bind the exact target atlas identity",
14818 )?;
14819 drop(control_after_init);
14820 let target_before_reset = fs::read(&target_db)?;
14821 let bound_reset = server.atlas_reset_index(Parameters(AtlasResetIndexParams {
14822 project_path: None,
14823 worktree: Some("issue-430".to_string()),
14824 apply: Some(true),
14825 dry_run: Some(false),
14826 include_mcp_config: Some(true),
14827 }));
14828 require(
14829 bound_reset.contains(MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED),
14830 "applied reset did not reject a bound worktree alias",
14831 )?;
14832 require(
14833 fs::read(&target_db)? == target_before_reset,
14834 "rejected bound worktree reset changed its database",
14835 )?;
14836 let control_state = primary.join(PROJECTATLAS_DIR_NAME);
14837 let preserved_control_state = primary.join(".projectatlas-captured-main");
14838 fs::rename(&control_state, &preserved_control_state)?;
14839 fs::create_dir(&control_state)?;
14840 init_project_with_config(&primary, captured_main_state.config_path.as_deref())?;
14841 let mut replacement_control = AtlasStore::open_for_project(&control_db, &primary)?;
14842 let replacement_control_plan =
14843 ScanRuntimePlan::for_path(captured_main_state.config_path.as_deref(), &primary, None)?;
14844 run_scan_pipeline(
14845 &mut replacement_control,
14846 &replacement_control_plan,
14847 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
14848 )?;
14849 require(
14850 replacement_control.project_instance_id()? != Some(control_project),
14851 "replacement control atlas reused the captured main identity",
14852 )?;
14853 let captured_registration_id = captured_alias_state
14854 .worktree
14855 .as_ref()
14856 .and_then(|selection| selection.registration_id)
14857 .ok_or_else(|| io::Error::other("captured alias registration identity is missing"))?;
14858 let common = primary.join(".git");
14859 let mut replacement_alias = None;
14860 for index in 1..=captured_registration_id {
14861 let alias = WorktreeAlias::parse(&format!("replacement-{index}"))?;
14862 let registration = replacement_control.register_worktree(
14863 &alias,
14864 &common,
14865 &common
14866 .join("worktrees")
14867 .join(format!("replacement-{index}")),
14868 &format!("{index:064x}"),
14869 &temp.path().join(format!("replacement-worktree-{index}")),
14870 Some(ProjectInstanceId::from_bytes([u8::try_from(index)?; 16])?),
14871 u64::try_from(index)?,
14872 )?;
14873 require(
14874 registration.registration_id == index,
14875 "replacement control did not reuse the expected registration identity",
14876 )?;
14877 if index == captured_registration_id {
14878 replacement_alias = Some(alias);
14879 }
14880 }
14881 let replacement_alias = replacement_alias
14882 .ok_or_else(|| io::Error::other("replacement alias was not created"))?;
14883 let replacement_calls_before = replacement_control
14884 .registered_worktree_token_overview(&replacement_alias)?
14885 .calls;
14886 require(
14887 ProjectAtlasMcpServer::require_captured_control_identity(
14888 captured_alias_state.worktree.as_ref(),
14889 &replacement_control,
14890 )
14891 .as_ref()
14892 .is_err_and(|error| {
14893 error
14894 .to_string()
14895 .contains(MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT)
14896 }),
14897 "captured alias accepted a replacement control atlas",
14898 )?;
14899 drop(replacement_control);
14900 let accepted = server.with_fresh_string_and_usage_for_request(
14901 &captured_alias_state,
14902 None,
14903 |_store, _stamp| {
14904 Ok((
14905 "accepted result".to_string(),
14906 Some(McpUsageIntent::estimate(
14907 MCP_EVENT_ATLAS_OVERVIEW,
14908 None,
14909 None,
14910 1,
14911 )),
14912 ))
14913 },
14914 )?;
14915 require(
14916 accepted == "accepted result",
14917 "accepted target read was lost",
14918 )?;
14919 let replacement_control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14920 require(
14921 replacement_control
14922 .registered_worktree_token_overview(&replacement_alias)?
14923 .calls
14924 == replacement_calls_before,
14925 "deferred telemetry was attributed through a replacement control catalog",
14926 )?;
14927 drop(replacement_control);
14928 require(
14929 matches!(
14930 synchronize_registered_worktree_usage(
14931 &captured_main_state.db_path,
14932 &captured_main_state.root,
14933 Some(control_project),
14934 ),
14935 Err(CliError::InvalidInput(message))
14936 if message.contains("control atlas identity changed")
14937 ),
14938 "main token synchronization did not revalidate its captured project identity",
14939 )?;
14940 let captured_main_direct_read =
14941 ProjectAtlasMcpServer::open_read_store(&captured_main_state);
14942 require(
14943 captured_main_direct_read.as_ref().is_err_and(|error| {
14944 error
14945 .to_string()
14946 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14947 }),
14948 "main direct token read did not revalidate its captured project identity",
14949 )?;
14950 let captured_main_read =
14951 server.with_fresh_store(&captured_main_state, |_store, _stamp| Ok(()));
14952 require(
14953 captured_main_read.as_ref().is_err_and(|error| {
14954 error
14955 .to_string()
14956 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14957 }),
14958 "main read snapshot did not revalidate its captured project identity",
14959 )?;
14960 let captured_main_write = ProjectAtlasMcpServer::open_existing_mut_store(
14961 &captured_main_state,
14962 &server.control_state,
14963 );
14964 require(
14965 captured_main_write.as_ref().is_err_and(|error| {
14966 error
14967 .to_string()
14968 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14969 }),
14970 "main mutation did not revalidate its captured project identity",
14971 )?;
14972 let captured_federated_labels = captured_federated_selections
14973 .iter()
14974 .map(|selection| selection.alias.clone())
14975 .collect::<Vec<_>>();
14976 let federated_control =
14977 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
14978 let captured_federated_stores = open_federated_atlas_stores_for_project(
14979 &captured_main_state.db_path,
14980 &captured_main_state.root,
14981 captured_main_state.config_path.as_deref(),
14982 &captured_federated_roots,
14983 Some(&captured_federated_labels),
14984 &federated_control,
14985 )?;
14986 let captured_main_federation = ProjectAtlasMcpServer::require_federated_worktree_identities(
14987 captured_federated_stores,
14988 &captured_federated_selections,
14989 );
14990 require(
14991 captured_main_federation.as_ref().is_err_and(|error| {
14992 error
14993 .to_string()
14994 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14995 && error.to_string().contains(MCP_MAIN_WORKTREE_ALIAS)
14996 }),
14997 "federation did not revalidate the captured main identity",
14998 )?;
14999 fs::remove_dir_all(&control_state)?;
15000 fs::rename(&preserved_control_state, &control_state)?;
15001 let target_state = worktree_a.join(PROJECTATLAS_DIR_NAME);
15002 let preserved_target_state = worktree_a.join(".projectatlas-registered");
15003 fs::rename(&target_state, &preserved_target_state)?;
15004 fs::create_dir(&target_state)?;
15005 init_project_with_config(&worktree_a, captured_alias_state.config_path.as_deref())?;
15006 let mut replacement_store = AtlasStore::open_for_project(&target_db, &worktree_a)?;
15007 let replacement_plan = ScanRuntimePlan::for_path(
15008 captured_alias_state.config_path.as_deref(),
15009 &worktree_a,
15010 None,
15011 )?;
15012 run_scan_pipeline(
15013 &mut replacement_store,
15014 &replacement_plan,
15015 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
15016 )?;
15017 let replacement_project = replacement_store
15018 .project_instance_id()?
15019 .ok_or_else(|| io::Error::other("replacement target identity is missing"))?;
15020 require(
15021 replacement_project != target_project,
15022 "replacement atlas reused the registered project identity",
15023 )?;
15024 drop(replacement_store);
15025 let captured_read = server.with_fresh_store(&captured_alias_state, |_store, _stamp| Ok(()));
15026 require(
15027 captured_read.as_ref().is_err_and(|error| {
15028 error
15029 .to_string()
15030 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
15031 }),
15032 "alias read snapshot did not revalidate its captured project identity",
15033 )?;
15034 let captured_federated_labels = captured_federated_selections
15035 .iter()
15036 .map(|selection| selection.alias.clone())
15037 .collect::<Vec<_>>();
15038 let federated_control =
15039 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
15040 let captured_federated_stores = open_federated_atlas_stores_for_project(
15041 &captured_main_state.db_path,
15042 &captured_main_state.root,
15043 captured_main_state.config_path.as_deref(),
15044 &captured_federated_roots,
15045 Some(&captured_federated_labels),
15046 &federated_control,
15047 )?;
15048 let captured_federation = ProjectAtlasMcpServer::require_federated_worktree_identities(
15049 captured_federated_stores,
15050 &captured_federated_selections,
15051 );
15052 require(
15053 captured_federation.as_ref().is_err_and(|error| {
15054 error
15055 .to_string()
15056 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
15057 && error.to_string().contains("issue-430")
15058 }),
15059 "federated snapshots did not retain their captured alias identities",
15060 )?;
15061 let replacement_error = server.state_for_target(None, Some("issue-430".to_string()));
15062 require(
15063 replacement_error.as_ref().is_err_and(|error| {
15064 error
15065 .to_string()
15066 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
15067 }),
15068 "alias routing accepted a replacement atlas at the registered root",
15069 )?;
15070 fs::remove_dir_all(&target_state)?;
15071 fs::rename(&preserved_target_state, &target_state)?;
15072 let selected = server.state_for_target(None, Some("issue-430".to_string()))?;
15073 require(
15074 selected.root == canonical_a
15075 && selected
15076 .worktree
15077 .as_ref()
15078 .is_some_and(|selection| selection.alias == "issue-430"),
15079 "short alias did not capture the exact target root and identity",
15080 )?;
15081 let selected_diagnostics = ProjectAtlasMcpServer::render_project_state(&selected)?;
15082 require(
15083 selected_diagnostics.contains("worktree: \"issue-430\"")
15084 && selected_diagnostics.contains("registration_id:"),
15085 &format!(
15086 "selected-project diagnostics omitted the captured alias or registration identity: {selected_diagnostics}"
15087 ),
15088 )?;
15089 let settings = server.atlas_settings(Parameters(AtlasProjectParams {
15090 project_path: None,
15091 worktree: Some("issue-430".to_string()),
15092 }));
15093 require(
15094 settings.contains("worktree: \"issue-430\"") && settings.contains("registration_id:"),
15095 &format!(
15096 "alias-routed settings omitted the captured alias or registration identity: {settings}"
15097 ),
15098 )?;
15099 let root_report = server.atlas_root(Parameters(AtlasRootParams {
15100 project_path: None,
15101 worktree: Some("issue-430".to_string()),
15102 verify: Some(false),
15103 control_root: None,
15104 }));
15105 require(
15106 root_report.contains("worktree: \"issue-430\"")
15107 && root_report.contains("registration_id:"),
15108 &format!(
15109 "alias-routed root diagnostics omitted the captured alias or registration identity: {root_report}"
15110 ),
15111 )?;
15112 let routed_overview = server.atlas_overview_response(
15113 AtlasProjectParams {
15114 project_path: None,
15115 worktree: Some("issue-430".to_string()),
15116 },
15117 None,
15118 );
15119 require(
15120 routed_overview.contains("overview:") && routed_overview.contains("files:"),
15121 &format!("alias-routed overview failed: {routed_overview}"),
15122 )?;
15123 let alias = WorktreeAlias::parse("issue-430")?;
15124 let control_after_routed = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15125 require(
15126 control_after_routed
15127 .registered_worktree_token_overview(&alias)?
15128 .calls
15129 == 1
15130 && control_after_routed.repository_token_overview()?.calls == 3,
15131 "alias-routed MCP usage or retained independently initialized usage was miscounted",
15132 )?;
15133 drop(control_after_routed);
15134 let local_event = usage_from_text(
15135 "worktree-local",
15136 "atlas_summary",
15137 Some("src/lib.rs".to_string()),
15138 None,
15139 "pub fn main_only() { child::helper(); }",
15140 "Own the shared library contract.",
15141 );
15142 let local_target = AtlasStore::open_for_project(&target_db, &worktree_a)?;
15143 local_target.record_usage(&local_event)?;
15144 require(
15145 local_target.token_overview(None)?.calls == 1,
15146 "independent worktree usage was not retained in its exact local atlas",
15147 )?;
15148 drop(local_target);
15149 let repository_tokens = server.atlas_token_report(Parameters(AtlasTokenParams {
15150 project_path: None,
15151 worktree: Some("main".to_string()),
15152 session: None,
15153 include_chart: Some(false),
15154 trend_window: None,
15155 benchmark_results: None,
15156 theme: None,
15157 }));
15158 require(
15159 repository_tokens.contains("worktree: main") && repository_tokens.contains("calls: 4"),
15160 &format!(
15161 "control token report did not combine routed and synchronized worktree usage: {repository_tokens}"
15162 ),
15163 )?;
15164 let worktree_tokens = server.atlas_token_report(Parameters(AtlasTokenParams {
15165 project_path: None,
15166 worktree: Some("issue-430".to_string()),
15167 session: None,
15168 include_chart: Some(false),
15169 trend_window: None,
15170 benchmark_results: None,
15171 theme: None,
15172 }));
15173 require(
15174 worktree_tokens.contains("worktree: \"issue-430\"")
15175 && worktree_tokens.contains("calls: 1"),
15176 &format!(
15177 "exact worktree token report included routed or sibling usage: {worktree_tokens}"
15178 ),
15179 )?;
15180 let federated = server.atlas_symbol_relations_response(
15181 &AtlasSymbolRelationsParams {
15182 file: Some("src/lib.rs".to_string()),
15183 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
15184 compact: Some(true),
15185 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
15186 limit: Some(1),
15187 ..AtlasSymbolRelationsParams::default()
15188 },
15189 None,
15190 );
15191 require(
15192 federated.contains("primary_worktree: main")
15193 && federated.contains("participants[2]{order,worktree")
15194 && federated.contains("0,main,")
15195 && federated.contains("1,\"issue-430\","),
15196 &format!(
15197 "alias federation did not label the primary and every participant: {federated}"
15198 ),
15199 )?;
15200 let federation_conflict = server.atlas_symbol_relations_response(
15201 &AtlasSymbolRelationsParams {
15202 file: Some("src/lib.rs".to_string()),
15203 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
15204 roots: Some(vec![
15205 normalize_native_path_display(&primary),
15206 normalize_native_path_display(&worktree_a),
15207 ]),
15208 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
15209 ..AtlasSymbolRelationsParams::default()
15210 },
15211 None,
15212 );
15213 require(
15214 federation_conflict.contains(MCP_ERROR_FEDERATED_SELECTOR_CONFLICT),
15215 "alias federation did not reject legacy roots before opening participants",
15216 )?;
15217 let target_database_before_blocker = fs::read(&target_db)?;
15218 fs::write(
15219 worktree_a.join("src").join("branch.rs"),
15220 "pub fn worktree_only_changed() {}\n",
15221 )?;
15222 let blocked_federation = server.atlas_symbol_relations_response(
15223 &AtlasSymbolRelationsParams {
15224 file: Some("src/lib.rs".to_string()),
15225 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
15226 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
15227 ..AtlasSymbolRelationsParams::default()
15228 },
15229 None,
15230 );
15231 require(
15232 blocked_federation.contains("refresh_required")
15233 && blocked_federation.contains("worktree: \"issue-430\""),
15234 &format!(
15235 "stale federated participant blocker omitted its exact worktree alias: {blocked_federation}"
15236 ),
15237 )?;
15238 require(
15239 fs::read(&target_db)? == target_database_before_blocker,
15240 "read-only alias federation repaired or changed a sibling database",
15241 )?;
15242 let main = server.state_for_target(None, Some("main".to_string()))?;
15243 require(
15244 main.root == primary.canonicalize()? && selected.root != main.root,
15245 "interleaved main and worktree selections bled into one target",
15246 )?;
15247 let source_before = fs::read(worktree_a.join("src").join("lib.rs"))?;
15248 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
15249 worktree: "issue-430".to_string(),
15250 }));
15251 require(
15252 removed.contains("status: retired")
15253 && removed.contains("git_unchanged: true")
15254 && removed.contains("files_unchanged: true"),
15255 "unregister did not retire the alias with lifecycle-neutral status",
15256 )?;
15257 require(
15258 target_db.is_file()
15259 && worktree_a.join(".git").is_file()
15260 && fs::read(worktree_a.join("src").join("lib.rs"))? == source_before,
15261 "unregister deleted or modified target-owned Git, source, or atlas state",
15262 )?;
15263 let git_after = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15264 "worktree",
15265 "list",
15266 "--porcelain",
15267 ]))?;
15268 require(
15269 git_after == git_before,
15270 "ProjectAtlas worktree operations changed Git lifecycle state",
15271 )?;
15272 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15273 let registrations = control.worktree_registrations(true)?;
15274 require(
15275 registrations.iter().any(|registration| {
15276 registration.alias.as_str() == "issue-430"
15277 && registration.state == WorktreeRegistrationState::Retired
15278 }),
15279 "retired registration and retained telemetry identity were not durable",
15280 )?;
15281 require(
15282 server
15283 .state_for_target(None, Some("issue-430".to_string()))
15284 .is_err(),
15285 "retired alias remained selectable for source operations",
15286 )
15287 }
15288
15289 #[test]
15290 fn uninitialized_alias_rejects_a_reused_git_administrative_path()
15291 -> Result<(), Box<dyn std::error::Error>> {
15292 let temp = tempfile::tempdir()?;
15293 let primary = temp.path().join("control");
15294 let original = temp.path().join("first location").join("checkout");
15295 let replacement = temp.path().join("second location").join("checkout");
15296 fs::create_dir_all(&primary)?;
15297 fs::create_dir_all(
15298 original
15299 .parent()
15300 .ok_or_else(|| io::Error::other("original worktree has no parent"))?,
15301 )?;
15302 fs::create_dir_all(
15303 replacement
15304 .parent()
15305 .ok_or_else(|| io::Error::other("replacement worktree has no parent"))?,
15306 )?;
15307 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15308 for (key, value) in [
15309 ("user.name", "ProjectAtlas Test"),
15310 ("user.email", "projectatlas@example.invalid"),
15311 ("commit.gpgsign", "false"),
15312 ] {
15313 run_fixture_command(
15314 StdCommand::new("git")
15315 .current_dir(&primary)
15316 .args(["config", key, value]),
15317 )?;
15318 }
15319 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15320 run_fixture_command(
15321 StdCommand::new("git")
15322 .current_dir(&primary)
15323 .args(["add", "."]),
15324 )?;
15325 run_fixture_command(
15326 StdCommand::new("git")
15327 .current_dir(&primary)
15328 .args(["commit", "-m", "fixture"]),
15329 )?;
15330 run_fixture_command(
15331 StdCommand::new("git")
15332 .current_dir(&primary)
15333 .args(["worktree", "add", "-b", "original"])
15334 .arg(&original),
15335 )?;
15336
15337 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15338 let control_config = primary.join(".projectatlas").join("config.toml");
15339 init_project_with_config(&primary, Some(&control_config))?;
15340 drop(AtlasStore::open_for_project(&control_db, &primary)?);
15341 let server = ProjectAtlasMcpServer::new(
15342 control_db,
15343 Some(control_config),
15344 "worktree-lifecycle".to_string(),
15345 false,
15346 );
15347 let repository = server.control_git_repository()?;
15348 let original_root = original.canonicalize()?;
15349 let original_entry = repository
15350 .worktrees
15351 .iter()
15352 .find(|entry| {
15353 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(original_root.as_path())
15354 })
15355 .ok_or_else(|| io::Error::other("original worktree was not discovered"))?;
15356 let administrative_directory = original_entry.administrative_directory.clone();
15357 let administrative_identity = git_administrative_identity(&administrative_directory)?;
15358 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
15359 worktree: ProjectAtlasMcpServer::worktree_candidate_selector(original_entry),
15360 alias: Some("replacement-check".to_string()),
15361 }));
15362 require(
15363 added.contains("status: registered"),
15364 &format!("original uninitialized worktree was not registered: {added}"),
15365 )?;
15366
15367 run_fixture_command(
15368 StdCommand::new("git")
15369 .current_dir(&primary)
15370 .args(["worktree", "remove", "--force"])
15371 .arg(&original),
15372 )?;
15373 let missing_registration =
15374 server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
15375 include_retired: Some(false),
15376 }));
15377 require(
15378 missing_registration
15379 .contains("\"replacement-check\",linked,available,missing,registered")
15380 && missing_registration.contains(&normalize_native_path_display(&original))
15381 && missing_registration.contains(",unavailable,unavailable,0,")
15382 && missing_registration.contains(MCP_WORKTREE_MISSING_RETENTION_REASON),
15383 &format!(
15384 "active alias disappeared after external Git worktree removal: {missing_registration}"
15385 ),
15386 )?;
15387 run_fixture_command(
15388 StdCommand::new("git")
15389 .current_dir(&primary)
15390 .args(["worktree", "add", "-b", "replacement"])
15391 .arg(&replacement),
15392 )?;
15393 let replacement_repository = server.control_git_repository()?;
15394 let replacement_root = replacement.canonicalize()?;
15395 let replacement_entry = replacement_repository
15396 .worktrees
15397 .iter()
15398 .find(|entry| {
15399 ProjectAtlasMcpServer::active_worktree_root(entry)
15400 == Some(replacement_root.as_path())
15401 })
15402 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
15403 require(
15404 replacement_entry.administrative_directory == administrative_directory,
15405 "Git fixture did not reuse the administrative path",
15406 )?;
15407 require(
15408 git_administrative_identity(&replacement_entry.administrative_directory)?
15409 != administrative_identity,
15410 "replacement Git worktree reused the prior lifecycle identity",
15411 )?;
15412 let add_revalidation = server.revalidate_worktree_candidate(
15413 &repository,
15414 original_entry,
15415 &administrative_identity,
15416 );
15417 require(
15418 add_revalidation.as_ref().is_err_and(|error| {
15419 error
15420 .to_string()
15421 .contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
15422 }),
15423 "add revalidation combined the old root with a replacement lifecycle",
15424 )?;
15425
15426 let resolution_error = server
15427 .state_for_target(None, Some("replacement-check".to_string()))
15428 .err()
15429 .ok_or_else(|| io::Error::other("reused administrative path was accepted"))?;
15430 require(
15431 resolution_error
15432 .to_string()
15433 .contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
15434 "reused administrative path did not return the lifecycle blocker",
15435 )?;
15436 require(
15437 !replacement.join(".projectatlas").exists(),
15438 "failed lifecycle validation initialized the replacement worktree",
15439 )?;
15440 let before_remove =
15441 run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15442 "worktree",
15443 "list",
15444 "--porcelain",
15445 ]))?;
15446 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
15447 worktree: "replacement-check".to_string(),
15448 }));
15449 require(
15450 removed.contains("status: retired")
15451 && removed.contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
15452 &format!("stale registration could not be retired safely: {removed}"),
15453 )?;
15454 let after_remove =
15455 run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15456 "worktree",
15457 "list",
15458 "--porcelain",
15459 ]))?;
15460 require(
15461 after_remove == before_remove,
15462 "retiring a stale registration changed Git lifecycle state",
15463 )
15464 }
15465
15466 #[test]
15467 fn reused_git_administrative_path_cannot_synchronize_replacement_telemetry()
15468 -> Result<(), Box<dyn std::error::Error>> {
15469 let temp = tempfile::tempdir()?;
15470 let primary = temp.path().join("control");
15471 let worktree = temp.path().join("checkout");
15472 fs::create_dir_all(&primary)?;
15473 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15474 for (key, value) in [
15475 ("user.name", "ProjectAtlas Test"),
15476 ("user.email", "projectatlas@example.invalid"),
15477 ("commit.gpgsign", "false"),
15478 ] {
15479 run_fixture_command(
15480 StdCommand::new("git")
15481 .current_dir(&primary)
15482 .args(["config", key, value]),
15483 )?;
15484 }
15485 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15486 run_fixture_command(
15487 StdCommand::new("git")
15488 .current_dir(&primary)
15489 .args(["add", "."]),
15490 )?;
15491 run_fixture_command(
15492 StdCommand::new("git")
15493 .current_dir(&primary)
15494 .args(["commit", "-m", "fixture"]),
15495 )?;
15496 run_fixture_command(
15497 StdCommand::new("git")
15498 .current_dir(&primary)
15499 .args(["worktree", "add", "-b", "original-telemetry"])
15500 .arg(&worktree),
15501 )?;
15502
15503 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15504 let target_db = worktree.join(".projectatlas").join("projectatlas.db");
15505 fs::create_dir_all(
15506 control_db
15507 .parent()
15508 .ok_or_else(|| io::Error::other("control database has no parent"))?,
15509 )?;
15510 fs::create_dir_all(
15511 target_db
15512 .parent()
15513 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15514 )?;
15515 let event = usage_from_text(
15516 "worktree-lifecycle",
15517 "atlas_overview",
15518 None,
15519 None,
15520 "pub fn source() {}",
15521 "repository overview",
15522 );
15523 let target = AtlasStore::open_for_project(&target_db, &worktree)?;
15524 target.record_usage(&event)?;
15525 let target_project = target
15526 .project_instance_id()?
15527 .ok_or_else(|| io::Error::other("target project identity is missing"))?;
15528 drop(target);
15529
15530 let RepositoryStructure::Git(repository) = discover_repository_structure(&primary)? else {
15531 return Err(io::Error::other("Git repository was not discovered").into());
15532 };
15533 let canonical_worktree = worktree.canonicalize()?;
15534 let entry = repository
15535 .worktrees
15536 .iter()
15537 .find(|entry| {
15538 ProjectAtlasMcpServer::active_worktree_root(entry)
15539 == Some(canonical_worktree.as_path())
15540 })
15541 .ok_or_else(|| io::Error::other("worktree was not discovered"))?;
15542 let administrative_directory = entry.administrative_directory.clone();
15543 let administrative_identity = git_administrative_identity(&administrative_directory)?;
15544 let alias = WorktreeAlias::parse("lifecycle-telemetry")?;
15545 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15546 control.register_worktree(
15547 &alias,
15548 &repository.common_directory,
15549 &administrative_directory,
15550 &administrative_identity,
15551 &canonical_worktree,
15552 Some(target_project),
15553 1,
15554 )?;
15555 drop(control);
15556 synchronize_registered_worktree_usage(&control_db, &primary, None)?;
15557 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15558 require(
15559 control.registered_worktree_token_overview(&alias)?.calls == 1,
15560 "initial worktree telemetry was not synchronized",
15561 )?;
15562 drop(control);
15563
15564 let saved_db = temp.path().join("saved-projectatlas.db");
15565 fs::copy(&target_db, &saved_db)?;
15566 run_fixture_command(
15567 StdCommand::new("git")
15568 .current_dir(&primary)
15569 .args(["worktree", "remove", "--force"])
15570 .arg(&worktree),
15571 )?;
15572 require(
15573 matches!(
15574 synchronize_registered_worktree_usage(&control_db, &primary, None),
15575 Err(CliError::InvalidInput(message))
15576 if message.contains("aggregate token totals cannot be synchronized")
15577 ),
15578 "externally removed bound worktree reported stale aggregate success",
15579 )?;
15580 run_fixture_command(
15581 StdCommand::new("git")
15582 .current_dir(&primary)
15583 .args(["worktree", "add", "-b", "replacement-telemetry"])
15584 .arg(&worktree),
15585 )?;
15586 fs::create_dir_all(
15587 target_db
15588 .parent()
15589 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15590 )?;
15591 fs::copy(&saved_db, &target_db)?;
15592 let replacement = AtlasStore::open_for_project(&target_db, &worktree)?;
15593 replacement.record_usage(&event)?;
15594 drop(replacement);
15595
15596 let RepositoryStructure::Git(replacement_repository) =
15597 discover_repository_structure(&primary)?
15598 else {
15599 return Err(io::Error::other("replacement Git repository was not discovered").into());
15600 };
15601 let replacement_entry = replacement_repository
15602 .worktrees
15603 .iter()
15604 .find(|entry| {
15605 ProjectAtlasMcpServer::active_worktree_root(entry)
15606 == Some(canonical_worktree.as_path())
15607 })
15608 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
15609 require(
15610 replacement_entry.administrative_directory == administrative_directory,
15611 "Git fixture did not reuse the administrative path",
15612 )?;
15613 require(
15614 git_administrative_identity(&replacement_entry.administrative_directory)?
15615 != administrative_identity,
15616 "replacement Git worktree reused the prior lifecycle identity",
15617 )?;
15618
15619 require(
15620 matches!(
15621 synchronize_registered_worktree_usage(&control_db, &primary, None),
15622 Err(CliError::InvalidInput(message))
15623 if message.contains("aggregate token totals cannot be synchronized")
15624 ),
15625 "replacement lifecycle reported stale aggregate success",
15626 )?;
15627 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15628 require(
15629 control.registered_worktree_token_overview(&alias)?.calls == 1,
15630 "replacement lifecycle telemetry was imported into the retired origin",
15631 )
15632 }
15633
15634 #[test]
15635 fn aggregate_synchronization_propagates_local_atlas_and_identity_failures()
15636 -> Result<(), Box<dyn std::error::Error>> {
15637 let temp = tempfile::tempdir()?;
15638 let primary = temp.path().join("control");
15639 let worktree = temp.path().join("external").join("worktree");
15640 fs::create_dir_all(&primary)?;
15641 fs::create_dir_all(
15642 worktree
15643 .parent()
15644 .ok_or_else(|| io::Error::other("worktree has no parent"))?,
15645 )?;
15646 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15647 for (key, value) in [
15648 ("user.name", "ProjectAtlas Test"),
15649 ("user.email", "projectatlas@example.invalid"),
15650 ("commit.gpgsign", "false"),
15651 ] {
15652 run_fixture_command(
15653 StdCommand::new("git")
15654 .current_dir(&primary)
15655 .args(["config", key, value]),
15656 )?;
15657 }
15658 fs::write(primary.join("README.md"), "# fixture\n")?;
15659 run_fixture_command(
15660 StdCommand::new("git")
15661 .current_dir(&primary)
15662 .args(["add", "."]),
15663 )?;
15664 run_fixture_command(
15665 StdCommand::new("git")
15666 .current_dir(&primary)
15667 .args(["commit", "-m", "fixture"]),
15668 )?;
15669 run_fixture_command(
15670 StdCommand::new("git")
15671 .current_dir(&primary)
15672 .args(["worktree", "add", "-b", "sync-failure"])
15673 .arg(&worktree),
15674 )?;
15675
15676 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15677 let target_db = worktree.join(".projectatlas").join("projectatlas.db");
15678 fs::create_dir_all(
15679 control_db
15680 .parent()
15681 .ok_or_else(|| io::Error::other("control database has no parent"))?,
15682 )?;
15683 fs::create_dir_all(
15684 target_db
15685 .parent()
15686 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15687 )?;
15688 let target = AtlasStore::open_for_project(&target_db, &worktree)?;
15689 target.record_usage(&usage_from_text(
15690 "worktree-sync",
15691 "atlas_overview",
15692 None,
15693 None,
15694 "pub fn source() {}",
15695 "repository overview",
15696 ))?;
15697 let target_project = target
15698 .project_instance_id()?
15699 .ok_or_else(|| io::Error::other("target project identity is missing"))?;
15700 drop(target);
15701
15702 let RepositoryStructure::Git(repository) = discover_repository_structure(&primary)? else {
15703 return Err(io::Error::other("Git repository was not discovered").into());
15704 };
15705 let canonical_worktree = worktree.canonicalize()?;
15706 let entry = repository
15707 .worktrees
15708 .iter()
15709 .find(|entry| {
15710 ProjectAtlasMcpServer::active_worktree_root(entry)
15711 == Some(canonical_worktree.as_path())
15712 })
15713 .ok_or_else(|| io::Error::other("worktree was not discovered"))?;
15714 let alias = WorktreeAlias::parse("sync-failure")?;
15715 let foreign_project = if target_project == ProjectInstanceId::from_bytes([0x7f; 16])? {
15716 ProjectInstanceId::from_bytes([0x7e; 16])?
15717 } else {
15718 ProjectInstanceId::from_bytes([0x7f; 16])?
15719 };
15720 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15721 control.register_worktree(
15722 &alias,
15723 &repository.common_directory,
15724 &entry.administrative_directory,
15725 &git_administrative_identity(&entry.administrative_directory)?,
15726 &canonical_worktree,
15727 Some(foreign_project),
15728 1,
15729 )?;
15730 drop(control);
15731
15732 require(
15733 matches!(
15734 synchronize_registered_worktree_usage(&control_db, &primary, None),
15735 Err(CliError::Db(
15736 DbError::WorktreeTelemetryProjectMismatch { .. }
15737 ))
15738 ),
15739 "aggregate synchronization hid the project identity failure behind stale success",
15740 )?;
15741
15742 let git_directory = primary.join(".git");
15743 let unavailable_git_directory = temp.path().join("unavailable-control-git");
15744 fs::rename(&git_directory, &unavailable_git_directory)?;
15745 require(
15746 matches!(
15747 synchronize_registered_worktree_usage(&control_db, &primary, None),
15748 Err(CliError::InvalidInput(message))
15749 if message.contains("requires Git control evidence")
15750 ),
15751 "aggregate synchronization treated missing control Git evidence as success",
15752 )?;
15753 fs::rename(&unavailable_git_directory, &git_directory)?;
15754
15755 let control_head = git_directory.join("HEAD");
15756 let valid_control_head = temp.path().join("valid-control-head");
15757 fs::rename(&control_head, &valid_control_head)?;
15758 fs::create_dir(&control_head)?;
15759 require(
15760 matches!(
15761 synchronize_registered_worktree_usage(&control_db, &primary, None),
15762 Err(CliError::InvalidInput(message))
15763 if message.contains("invalid Git evidence")
15764 ),
15765 "aggregate synchronization treated invalid control Git evidence as success",
15766 )?;
15767 fs::remove_dir(&control_head)?;
15768 fs::rename(&valid_control_head, &control_head)?;
15769
15770 let corrupt = rusqlite::Connection::open(&target_db)?;
15771 corrupt.pragma_update(None, "ignore_check_constraints", true)?;
15772 corrupt.execute("UPDATE usage_aggregate_revisions SET revision = -1", [])?;
15773 drop(corrupt);
15774 require(
15775 matches!(
15776 synchronize_registered_worktree_usage(&control_db, &primary, None),
15777 Err(CliError::Db(DbError::TelemetryIntegerOverflow {
15778 field: "usage_aggregate_revisions.revision"
15779 }))
15780 ),
15781 "aggregate synchronization hid the local snapshot export failure",
15782 )?;
15783
15784 fs::remove_file(&target_db)?;
15785 require(
15786 matches!(
15787 synchronize_registered_worktree_usage(&control_db, &primary, None),
15788 Err(CliError::InvalidInput(message))
15789 if message.contains("aggregate token totals cannot be synchronized")
15790 ),
15791 "aggregate synchronization reported stale success for a bound missing atlas",
15792 )?;
15793 fs::create_dir(&target_db)?;
15794 require(
15795 synchronize_registered_worktree_usage(&control_db, &primary, None).is_err(),
15796 "aggregate synchronization hid the existing local atlas open failure",
15797 )
15798 }
15799
15800 #[test]
15801 fn registered_worktree_init_falls_back_from_incomplete_control_and_preserves_existing_atlas()
15802 -> Result<(), Box<dyn std::error::Error>> {
15803 let temp = tempfile::tempdir()?;
15804 let primary = temp.path().join("control");
15805 let linked = temp.path().join("external").join("linked");
15806 fs::create_dir_all(&primary)?;
15807 fs::create_dir_all(
15808 linked
15809 .parent()
15810 .ok_or_else(|| io::Error::other("linked worktree has no parent"))?,
15811 )?;
15812 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15813 for (key, value) in [
15814 ("user.name", "ProjectAtlas Test"),
15815 ("user.email", "projectatlas@example.invalid"),
15816 ("commit.gpgsign", "false"),
15817 ] {
15818 run_fixture_command(
15819 StdCommand::new("git")
15820 .current_dir(&primary)
15821 .args(["config", key, value]),
15822 )?;
15823 }
15824 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15825 run_fixture_command(
15826 StdCommand::new("git")
15827 .current_dir(&primary)
15828 .args(["add", "."]),
15829 )?;
15830 run_fixture_command(
15831 StdCommand::new("git")
15832 .current_dir(&primary)
15833 .args(["commit", "-m", "fixture"]),
15834 )?;
15835 run_fixture_command(
15836 StdCommand::new("git")
15837 .current_dir(&primary)
15838 .args(["worktree", "add", "-b", "fallback"])
15839 .arg(&linked),
15840 )?;
15841 let git_before = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15842 "worktree",
15843 "list",
15844 "--porcelain",
15845 ]))?;
15846
15847 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15848 fs::create_dir_all(
15849 control_db
15850 .parent()
15851 .ok_or_else(|| io::Error::other("control DB has no parent"))?,
15852 )?;
15853 drop(AtlasStore::open_for_project(&control_db, &primary)?);
15854 let server = ProjectAtlasMcpServer::new(
15855 control_db.clone(),
15856 None,
15857 "worktree-fallback".to_string(),
15858 false,
15859 );
15860 let repository = server.control_git_repository()?;
15861 let canonical_linked = linked.canonicalize()?;
15862 let entry = repository
15863 .worktrees
15864 .iter()
15865 .find(|entry| {
15866 ProjectAtlasMcpServer::active_worktree_root(entry)
15867 == Some(canonical_linked.as_path())
15868 })
15869 .ok_or_else(|| io::Error::other("linked worktree was not discovered"))?;
15870 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
15871 worktree: ProjectAtlasMcpServer::worktree_candidate_selector(entry),
15872 alias: Some("fallback".to_string()),
15873 }));
15874 require(
15875 added.contains("status: registered"),
15876 "fallback fixture registration failed",
15877 )?;
15878
15879 let initialized = server.atlas_init(Parameters(AtlasInitParams {
15880 project_path: None,
15881 worktree: Some("fallback".to_string()),
15882 no_scan: Some(false),
15883 force_rescan: Some(false),
15884 text_index_max_bytes: None,
15885 }));
15886 require(
15887 initialized.contains("status: fallback")
15888 && initialized.contains("fallback_reason:")
15889 && initialized.contains("repository graph is unavailable"),
15890 &format!(
15891 "incomplete control atlas did not produce visible ordinary fallback: {initialized}"
15892 ),
15893 )?;
15894 let target_db = linked.join(".projectatlas").join("projectatlas.db");
15895 let target = open_atlas_store_read_only_for_project(&target_db, &linked)?;
15896 let identity = target
15897 .project_instance_id()?
15898 .ok_or_else(|| io::Error::other("fallback target identity is missing"))?;
15899 require(
15900 target.index_publication()?.is_some_and(|publication| {
15901 publication.state == projectatlas_db::IndexPublicationState::Complete
15902 }),
15903 "ordinary fallback did not publish a complete target index",
15904 )?;
15905 drop(target);
15906
15907 let repeated = server.atlas_init(Parameters(AtlasInitParams {
15908 project_path: None,
15909 worktree: Some("fallback".to_string()),
15910 no_scan: Some(true),
15911 force_rescan: Some(false),
15912 text_index_max_bytes: None,
15913 }));
15914 require(
15915 repeated.contains("status: existing"),
15916 &format!("repeat init did not preserve the valid target atlas: {repeated}"),
15917 )?;
15918 let preserved = open_atlas_store_read_only_for_project(&target_db, &linked)?;
15919 require(
15920 preserved.project_instance_id()? == Some(identity),
15921 "repeat init replaced the valid target atlas identity",
15922 )?;
15923 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15924 require(
15925 control
15926 .worktree_registration(&WorktreeAlias::parse("fallback")?)?
15927 .project_instance_id
15928 == Some(identity),
15929 "fallback init did not bind the exact target identity",
15930 )?;
15931 let git_after = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15932 "worktree",
15933 "list",
15934 "--porcelain",
15935 ]))?;
15936 require(
15937 git_after == git_before,
15938 "fallback or repeat init changed Git lifecycle state",
15939 )?;
15940
15941 let config_path = init_config_path(&linked, None);
15942 let config_before = fs::read(&config_path)?;
15943 drop(preserved);
15944 drop(control);
15945 fs::remove_file(&target_db)?;
15946 let refused = server.atlas_init(Parameters(AtlasInitParams {
15947 project_path: None,
15948 worktree: Some("fallback".to_string()),
15949 no_scan: Some(false),
15950 force_rescan: Some(false),
15951 text_index_max_bytes: None,
15952 }));
15953 require(
15954 refused.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
15955 &format!("bound missing atlas did not fail before initialization: {refused}"),
15956 )?;
15957 require(
15958 !target_db.exists() && fs::read(&config_path)? == config_before,
15959 "bound missing atlas refusal changed target ProjectAtlas state",
15960 )
15961 }
15962
15963 #[test]
15964 fn bare_startup_and_root_set_preserve_worktree_required_without_state()
15965 -> Result<(), Box<dyn std::error::Error>> {
15966 let temp = tempfile::tempdir()?;
15967 let bare = temp.path().join("repository.git");
15968 let output = StdCommand::new("git")
15969 .args(["init", "--bare"])
15970 .arg(&bare)
15971 .output()?;
15972 require(output.status.success(), "git init --bare failed")?;
15973 let db_path = bare.join(".projectatlas").join("projectatlas.db");
15974 let server =
15975 ProjectAtlasMcpServer::new(db_path, None, "mcp-bare-root-test".to_string(), false);
15976
15977 let Err(error) = server.active_project_state() else {
15978 return Err(io::Error::other("bare MCP startup state was exposed as active").into());
15979 };
15980 if !matches!(error, CliError::WorktreeRequired(_)) {
15981 return Err(io::Error::other(format!(
15982 "bare MCP startup did not preserve typed worktree_required state: {error:?}"
15983 ))
15984 .into());
15985 }
15986
15987 let response = server.atlas_root_set(Parameters(AtlasRootSetParams {
15988 root: bare.to_string_lossy().to_string(),
15989 transition: None,
15990 nearest_project: None,
15991 }));
15992 require(
15993 response.contains("worktree_required"),
15994 "atlas_root_set did not reject a bare Git control root",
15995 )?;
15996 require(
15997 !bare.join(".projectatlas").exists(),
15998 "bare MCP startup or root-set refusal created project state",
15999 )
16000 }
16001
16002 #[test]
16003 fn selected_project_config_cannot_redirect_root() -> Result<(), Box<dyn std::error::Error>> {
16004 let temp = tempfile::tempdir()?;
16005 let repo_a = temp.path().join("repo-a");
16006 let repo_b = temp.path().join("repo-b");
16007 fs::create_dir(&repo_a)?;
16008 fs::create_dir(&repo_b)?;
16009 fs::create_dir(repo_b.join(".projectatlas"))?;
16010 let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
16011 fs::write(
16012 repo_b.join(".projectatlas").join("config.toml"),
16013 format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
16014 )?;
16015
16016 let Err(error) = ProjectAtlasMcpServer::project_state_from_root(&repo_b) else {
16017 return Err(io::Error::other("stale selected-project config was accepted").into());
16018 };
16019 require(
16020 error.to_string().contains("outside selected project root"),
16021 "stale selected-project config error was not root-scoped",
16022 )?;
16023
16024 Ok(())
16025 }
16026
16027 #[test]
16028 fn startup_config_mismatch_cannot_bind_one_root_to_another_db()
16029 -> Result<(), Box<dyn std::error::Error>> {
16030 let temp = tempfile::tempdir()?;
16031 let repo_a = temp.path().join("repo-a");
16032 let repo_b = temp.path().join("repo-b");
16033 fs::create_dir(&repo_a)?;
16034 fs::create_dir(&repo_b)?;
16035 fs::create_dir(repo_a.join(".projectatlas"))?;
16036 let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
16037 let config_a = repo_a.join(".projectatlas").join("config.toml");
16038 fs::write(
16039 &config_a,
16040 format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
16041 )?;
16042
16043 let db_b = repo_b.join(".projectatlas").join("projectatlas.db");
16044 let server =
16045 ProjectAtlasMcpServer::new(db_b.clone(), Some(config_a), "mcp-test".to_string(), false);
16046 let state = server.active_project_state()?;
16047
16048 require(
16049 state.root == canonical_project_root(&repo_b)?,
16050 "startup state did not fall back to the DB project root",
16051 )?;
16052 require(
16053 state.db_path == db_b,
16054 "startup state changed the selected DB path",
16055 )?;
16056 require(
16057 state.config_path.is_none(),
16058 "startup state retained a config from another project root",
16059 )?;
16060
16061 Ok(())
16062 }
16063
16064 #[test]
16065 fn read_only_store_does_not_create_missing_index() -> Result<(), Box<dyn std::error::Error>> {
16066 let temp = tempfile::tempdir()?;
16067 let repo = temp.path().join("repo-a");
16068 fs::create_dir(&repo)?;
16069 let state = ProjectAtlasMcpServer::project_state_from_root(&repo)?;
16070
16071 let Err(error) = ProjectAtlasMcpServer::open_read_store(&state) else {
16072 return Err(io::Error::other("missing index opened unexpectedly").into());
16073 };
16074 require(
16075 matches!(error, CliError::InitRequired(_)),
16076 "missing index did not return typed init_required state",
16077 )?;
16078 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
16079 require(
16080 payload.contains("kind: init_required")
16081 && payload.contains("init_required:")
16082 && payload.contains("tool: atlas_init")
16083 && payload.contains(&normalize_native_path_display(&repo)),
16084 "missing index payload did not contain the exact atlas_init recovery call",
16085 )?;
16086 require(
16087 !repo.join(".projectatlas").exists(),
16088 "read-only store created .projectatlas",
16089 )?;
16090
16091 Ok(())
16092 }
16093
16094 #[test]
16095 fn atlas_init_explicit_project_path_bootstraps_without_switching_active_project()
16096 -> Result<(), Box<dyn std::error::Error>> {
16097 let temp = tempfile::tempdir()?;
16098 let repo_a = temp.path().join("repo-a");
16099 let repo_b = temp.path().join("repo-b");
16100 fs::create_dir(&repo_a)?;
16101 fs::create_dir(&repo_b)?;
16102 let db_a = repo_a.join(".projectatlas").join("projectatlas.db");
16103 let server = ProjectAtlasMcpServer::new(db_a, None, "mcp-test".to_string(), false);
16104 let active_before = server.active_project_state()?;
16105
16106 let text = server.atlas_init(Parameters(AtlasInitParams {
16107 project_path: Some(repo_b.to_string_lossy().to_string()),
16108 worktree: None,
16109 no_scan: Some(true),
16110 force_rescan: Some(false),
16111 text_index_max_bytes: None,
16112 }));
16113
16114 let expected_b = normalize_native_path_display(canonical_project_root(&repo_b)?);
16115 require(
16116 text.contains("init:"),
16117 "atlas_init did not return named init payload",
16118 )?;
16119 require(
16120 text.contains(&expected_b),
16121 "atlas_init did not report the explicit project path",
16122 )?;
16123 require(
16124 text.contains("status: skipped"),
16125 "atlas_init --no-scan did not report skipped scan",
16126 )?;
16127 require(
16128 text.contains("purpose_handoff:")
16129 && text.contains("execution_owner: agent_host")
16130 && text.contains(&format!(
16131 "recommended_subagent_reasoning: {PURPOSE_CURATOR_RECOMMENDED_REASONING}"
16132 ))
16133 && text.contains("main_agent_fallback: true")
16134 && text.contains("server_started_curator: false")
16135 && text.contains("silent_on_success: true")
16136 && text.contains("curation_scope: low"),
16137 "atlas_init did not expose the host-owned low-scope curator handoff",
16138 )?;
16139 require(
16140 repo_b
16141 .join(".projectatlas")
16142 .join("projectatlas.db")
16143 .is_file(),
16144 "atlas_init did not create the explicit project's DB",
16145 )?;
16146 require(
16147 repo_b
16148 .join(".projectatlas")
16149 .join("projectatlas.mcp.json")
16150 .is_file()
16151 && repo_b
16152 .join(".projectatlas")
16153 .join("projectatlas.claude.mcp.json")
16154 .is_file()
16155 && repo_b
16156 .join(".projectatlas")
16157 .join("projectatlas.opencode.json")
16158 .is_file(),
16159 "atlas_init did not generate host MCP configs",
16160 )?;
16161
16162 let active_after = server.active_project_state()?;
16163 require(
16164 active_after.root == active_before.root,
16165 "atlas_init with explicit project_path changed the active default root",
16166 )?;
16167 require(
16168 !repo_a.join(".projectatlas").exists(),
16169 "explicit atlas_init mutated the active project",
16170 )?;
16171
16172 Ok(())
16173 }
16174
16175 #[test]
16176 fn session_brief_missing_index_stays_read_only() -> Result<(), Box<dyn std::error::Error>> {
16177 let temp = tempfile::tempdir()?;
16178 let repo = temp.path().join("repo-a");
16179 fs::create_dir(&repo)?;
16180 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16181 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16182
16183 let brief = server.build_session_brief(
16184 AtlasSessionBriefParams {
16185 project_path: None,
16186 worktree: None,
16187 query: Some("startup".to_string()),
16188 purpose_task: None,
16189 compact: None,
16190 folder_limit: None,
16191 file_limit: None,
16192 blocker_limit: None,
16193 purpose_limit: None,
16194 },
16195 None,
16196 )?;
16197
16198 require(
16199 brief.project.index_status == McpIndexStatus::Missing,
16200 "missing index was not represented as typed state",
16201 )?;
16202 require(
16203 brief.overview.is_none(),
16204 "missing-index brief unexpectedly included overview",
16205 )?;
16206 require(
16207 brief.recommendations.iter().any(|recommendation| {
16208 matches!(recommendation.kind, McpBriefRecommendationKind::Init)
16209 && recommendation.target == MCP_TOOL_ATLAS_INIT
16210 && recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
16211 == Some(&serde_json::json!(normalize_native_path_display(&repo)))
16212 }),
16213 "missing-index brief did not recommend atlas_init for the exact selected root",
16214 )?;
16215 require(
16216 !repo.join(".projectatlas").exists(),
16217 "session brief created .projectatlas for a missing index",
16218 )?;
16219
16220 Ok(())
16221 }
16222
16223 #[test]
16224 fn session_brief_recommendations_preserve_per_call_project_path()
16225 -> Result<(), Box<dyn std::error::Error>> {
16226 let project_path = "F:/example/repo-b".to_string();
16227 let recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
16228 "startup",
16229 Some(NavigationNextCall {
16230 capability: NavigationNextCapability::Summary,
16231 path: "src/lib.rs".to_string(),
16232 }),
16233 1,
16234 7,
16235 Some(project_path.clone()),
16236 None,
16237 );
16238
16239 require(
16240 recommendations.iter().all(|recommendation| {
16241 recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
16242 == Some(&serde_json::Value::String(project_path.clone()))
16243 }),
16244 "indexed brief recommendations did not preserve project_path",
16245 )?;
16246 require(
16247 recommendations.iter().any(|recommendation| {
16248 matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
16249 && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
16250 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
16251 == Some(&serde_json::Value::String("src/lib.rs".to_string()))
16252 }),
16253 "summary recommendation did not preserve the ranked file selector",
16254 )?;
16255 require(
16256 recommendations.iter().all(|recommendation| {
16257 recommendation.target != MCP_TOOL_ATLAS_FOLDERS
16258 && recommendation.target != MCP_TOOL_ATLAS_FILES
16259 }),
16260 "indexed brief recommended rerunning folder or file ranking",
16261 )?;
16262 require(
16263 recommendations.iter().any(|recommendation| {
16264 matches!(recommendation.kind, McpBriefRecommendationKind::Health)
16265 && recommendation.arguments.get(MCP_BRIEF_ARG_LIMIT)
16266 == Some(&serde_json::json!(7))
16267 }),
16268 "health recommendation did not preserve limit",
16269 )?;
16270
16271 let relation_recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
16272 "startup",
16273 Some(NavigationNextCall {
16274 capability: NavigationNextCapability::Relations,
16275 path: "src/graph.rs".to_string(),
16276 }),
16277 0,
16278 7,
16279 Some(project_path),
16280 None,
16281 );
16282 require(
16283 relation_recommendations.iter().any(|recommendation| {
16284 matches!(recommendation.kind, McpBriefRecommendationKind::Relations)
16285 && recommendation.target == MCP_TOOL_ATLAS_SYMBOL_RELATIONS
16286 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
16287 == Some(&serde_json::Value::String("src/graph.rs".to_string()))
16288 && recommendation.arguments.get(MCP_BRIEF_ARG_VIEW)
16289 == Some(&serde_json::Value::String("detailed".to_string()))
16290 }),
16291 "relation recommendation did not preserve the ranked file and detailed view",
16292 )?;
16293
16294 let worktree_recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
16295 "startup",
16296 None,
16297 1,
16298 7,
16299 None,
16300 Some("issue-430".to_string()),
16301 );
16302 require(
16303 worktree_recommendations.iter().all(|recommendation| {
16304 recommendation.arguments.get(MCP_BRIEF_ARG_WORKTREE)
16305 == Some(&serde_json::Value::String("issue-430".to_string()))
16306 && recommendation
16307 .arguments
16308 .get(MCP_BRIEF_ARG_PROJECT_PATH)
16309 .is_none()
16310 }),
16311 "indexed brief recommendations did not preserve the mutually exclusive worktree alias",
16312 )?;
16313
16314 Ok(())
16315 }
16316
16317 #[test]
16318 fn session_brief_file_candidates_ignore_indexed_text_fallback()
16319 -> Result<(), Box<dyn std::error::Error>> {
16320 let temp = tempfile::tempdir()?;
16321 let repo = temp.path().join("repo-a");
16322 fs::create_dir_all(repo.join("src"))?;
16323 fs::write(
16324 repo.join("src").join("owner.rs"),
16325 "const ROUTE: &str = \"hiddenNeedle\";\n",
16326 )?;
16327 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16328 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16329 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16330 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
16331 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
16332 drop(store);
16333
16334 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16335 let brief = server.build_session_brief(
16336 AtlasSessionBriefParams {
16337 project_path: None,
16338 worktree: None,
16339 query: Some("hiddenNeedle".to_string()),
16340 purpose_task: None,
16341 compact: None,
16342 folder_limit: Some(5),
16343 file_limit: Some(5),
16344 blocker_limit: Some(5),
16345 purpose_limit: Some(5),
16346 },
16347 None,
16348 )?;
16349
16350 require(
16351 brief.files.is_empty(),
16352 "session brief returned a content-only indexed-text hit",
16353 )?;
16354 require(
16355 brief.recommendations.iter().any(|recommendation| {
16356 matches!(recommendation.kind, McpBriefRecommendationKind::Search)
16357 && recommendation.target == MCP_TOOL_ATLAS_SEARCH
16358 && recommendation.arguments.get(MCP_BRIEF_ARG_PATTERN)
16359 == Some(&serde_json::Value::String("hiddenNeedle".to_string()))
16360 }),
16361 "session brief did not route a content-only query directly to indexed search",
16362 )?;
16363
16364 let navigable = server.build_session_brief(
16365 AtlasSessionBriefParams {
16366 project_path: None,
16367 worktree: None,
16368 query: Some("owner".to_string()),
16369 purpose_task: None,
16370 compact: None,
16371 folder_limit: Some(5),
16372 file_limit: Some(5),
16373 blocker_limit: Some(5),
16374 purpose_limit: Some(5),
16375 },
16376 None,
16377 )?;
16378 let candidate = navigable
16379 .files
16380 .first()
16381 .ok_or_else(|| std::io::Error::other("navigable brief file is missing"))?;
16382 require(
16383 candidate.reason_codes.contains(&RankedReasonCode::Path)
16384 && candidate.next_call.capability == NavigationNextCapability::Summary
16385 && !candidate.purpose_agent_reviewed,
16386 "session brief dropped ranked navigation evidence",
16387 )?;
16388 require(
16389 navigable.recommendations.iter().any(|recommendation| {
16390 matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
16391 && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
16392 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
16393 == Some(&serde_json::Value::String(candidate.path.clone()))
16394 }) && navigable.recommendations.iter().all(|recommendation| {
16395 recommendation.target != MCP_TOOL_ATLAS_FOLDERS
16396 && recommendation.target != MCP_TOOL_ATLAS_FILES
16397 }),
16398 "session brief recommendation did not follow its returned ranked file directly",
16399 )?;
16400
16401 Ok(())
16402 }
16403
16404 #[test]
16405 fn mcp_navigation_and_session_brief_propagate_typed_graph_evidence()
16406 -> Result<(), Box<dyn std::error::Error>> {
16407 let temp = tempfile::tempdir()?;
16408 let repo = temp.path().join("repo-a");
16409 fs::create_dir_all(repo.join("src"))?;
16410 fs::create_dir_all(repo.join("tests"))?;
16411 fs::write(
16412 repo.join("Cargo.toml"),
16413 "[package]\nname = \"adapter-navigation\"\nversion = \"0.1.0\"\n",
16414 )?;
16415 for path in [
16416 "src/navigation_owner.rs",
16417 "src/navigation_local.rs",
16418 "src/navigation_unresolved.rs",
16419 "tests/navigation_owner.rs",
16420 ] {
16421 fs::write(repo.join(path), "pub fn navigation_fixture() {}\n")?;
16422 }
16423 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16424 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16425 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16426 run_scan_pipeline(
16427 &mut store,
16428 &plan,
16429 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
16430 )?;
16431 publish_mcp_navigation_graph(&mut store)?;
16432 drop(store);
16433
16434 let server =
16435 ProjectAtlasMcpServer::new(db_path, None, "mcp-navigation-test".to_string(), false);
16436 let folders_text = server.atlas_folders_response(
16437 AtlasQueryParams {
16438 project_path: None,
16439 worktree: None,
16440 query: Some("navigation".to_string()),
16441 limit: Some(10),
16442 },
16443 None,
16444 );
16445 let files_text = server.atlas_files_response(
16446 AtlasFilesParams {
16447 project_path: None,
16448 worktree: None,
16449 query: Some("navigation".to_string()),
16450 folder: None,
16451 nearest_project: Some(false),
16452 file_pattern: None,
16453 include_content: Some(false),
16454 content_selection: None,
16455 limit: Some(10),
16456 },
16457 None,
16458 );
16459 for (surface, text) in [
16460 ("atlas_folders", &folders_text),
16461 ("atlas_files", &files_text),
16462 ] {
16463 require(
16464 text.contains("connection_counts")
16465 && text.contains("connections")
16466 && text.contains("direction:")
16467 && text.contains("target:")
16468 && text.contains("connections_truncated: true"),
16469 &format!("{surface} dropped nonempty typed graph evidence: {text}"),
16470 )?;
16471 }
16472
16473 let brief = server.build_session_brief(
16474 AtlasSessionBriefParams {
16475 project_path: None,
16476 worktree: None,
16477 query: Some("navigation".to_string()),
16478 purpose_task: None,
16479 compact: None,
16480 folder_limit: Some(10),
16481 file_limit: Some(10),
16482 blocker_limit: Some(10),
16483 purpose_limit: Some(10),
16484 },
16485 None,
16486 )?;
16487 let folder = brief
16488 .folders
16489 .iter()
16490 .find(|candidate| candidate.path == "src")
16491 .ok_or_else(|| io::Error::other("graph-enriched MCP folder is missing"))?;
16492 require(
16493 folder.connection_counts.len() == 7
16494 && folder.connections.len() == 3
16495 && folder.connections_truncated,
16496 "MCP folder lost count, sample, or global truncation evidence",
16497 )?;
16498 let owner = brief
16499 .files
16500 .iter()
16501 .find(|candidate| candidate.path == "src/navigation_owner.rs")
16502 .ok_or_else(|| io::Error::other("graph-enriched MCP owner file is missing"))?;
16503 require(
16504 owner.connection_counts.len() == 7
16505 && owner.connections.len() == 3
16506 && owner.connections_truncated
16507 && owner.next_call.capability
16508 == projectatlas_core::NavigationNextCapability::Relations,
16509 "MCP file or session brief lost graph truncation or relations navigation",
16510 )?;
16511 let compact_relations = server.atlas_symbol_relations_response(
16512 &AtlasSymbolRelationsParams {
16513 project_path: None,
16514 file: Some("src/navigation_owner.rs".to_string()),
16515 nearest_project: Some(false),
16516 view: Some("detailed".to_string()),
16517 compact: Some(true),
16518 direction: Some("outbound".to_string()),
16519 include_occurrences: Some(true),
16520 limit: Some(10),
16521 output_bytes: Some(64 * 1_024),
16522 ..AtlasSymbolRelationsParams::default()
16523 },
16524 None,
16525 );
16526 require(
16527 compact_relations.contains("status: resolved")
16528 && compact_relations.contains("status: ambiguous")
16529 && compact_relations.contains("status: external")
16530 && compact_relations.contains("status: unresolved")
16531 && compact_relations.contains("reference: \"navigation-ambiguous\"")
16532 && compact_relations.contains("candidates: 2")
16533 && compact_relations.contains("next_call:"),
16534 &format!(
16535 "compact detailed relations dropped a resolution state or reusable next call: {compact_relations}"
16536 ),
16537 )?;
16538
16539 let compact = server.build_compact_session_brief(
16540 AtlasSessionBriefParams {
16541 project_path: None,
16542 worktree: None,
16543 query: Some("navigation_owner".to_string()),
16544 purpose_task: None,
16545 compact: Some(true),
16546 folder_limit: None,
16547 file_limit: None,
16548 blocker_limit: None,
16549 purpose_limit: None,
16550 },
16551 None,
16552 )?;
16553 let compact_owner = compact
16554 .files
16555 .iter()
16556 .find(|candidate| candidate.path == "src/navigation_owner.rs")
16557 .ok_or_else(|| io::Error::other("compact graph owner file is missing"))?;
16558 require(
16559 compact_owner.connections.len() == 1
16560 && compact_owner.connections.iter().all(|connection| {
16561 connection.kind != RankedConnectionKind::Import
16562 && !matches!(
16563 &connection.target,
16564 RankedConnectionTarget::Unresolved { .. }
16565 )
16566 })
16567 && compact_owner.next_call.capability == NavigationNextCapability::Summary
16568 && compact_owner.purpose_agent_reviewed,
16569 "compact session brief did not retain one crisp edge and summary-first routing",
16570 )?;
16571 let expanded_text =
16572 ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)?;
16573 require(
16574 expanded_text.contains("missing_purposes:")
16575 && expanded_text.contains("stale_purposes:")
16576 && expanded_text.contains("approved_purposes:")
16577 && expanded_text.contains("suggested_purposes:"),
16578 "compatibility session brief lost purpose lifecycle counts",
16579 )?;
16580 let compact_text =
16581 ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &compact)?;
16582 require(
16583 compact.purpose_handoff.as_ref().is_some_and(|handoff| {
16584 handoff.recommended_subagent_reasoning == PURPOSE_CURATOR_RECOMMENDED_REASONING
16585 && handoff.instructions.len() == 1
16586 && handoff.instructions.first()
16587 == brief
16588 .purpose_handoff
16589 .as_ref()
16590 .and_then(|expanded| expanded.instructions.first())
16591 }) && compact_text.contains(&format!(
16592 "recommended_subagent_reasoning: {PURPOSE_CURATOR_RECOMMENDED_REASONING}"
16593 )) && compact_text
16594 .contains("lowest reliable reasoning and cost tier the host supports"),
16595 &format!(
16596 "compact actionable handoff lost its reliable-tier instruction: {compact_text}"
16597 ),
16598 )?;
16599 require(
16600 compact
16601 .blockers
16602 .as_ref()
16603 .is_some_and(|blockers| blockers.total > 0)
16604 && !compact_text.contains("\n db:")
16605 && !compact_text.contains("\n config:")
16606 && !compact_text.contains("\n policy:")
16607 && !compact_text.contains("\n items:")
16608 && !compact_text.contains("reason_codes")
16609 && !compact_text.contains("connection_counts")
16610 && !compact_text.contains("agent_harness_expected")
16611 && !compact_text.contains("server_started_curator")
16612 && !compact_text.contains("missing_purposes")
16613 && compact_text.len() <= 4_096,
16614 &format!("compact session brief retained default-only chatter: {compact_text}"),
16615 )?;
16616
16617 let families = brief
16618 .files
16619 .iter()
16620 .flat_map(|candidate| candidate.connection_counts.iter().map(|count| count.kind))
16621 .collect::<BTreeSet<_>>();
16622 require(
16623 families
16624 == BTreeSet::from([
16625 RankedConnectionKind::Package,
16626 RankedConnectionKind::Import,
16627 RankedConnectionKind::Call,
16628 RankedConnectionKind::Reference,
16629 RankedConnectionKind::Test,
16630 RankedConnectionKind::Route,
16631 RankedConnectionKind::Config,
16632 ]),
16633 &format!("MCP graph families were not propagated: {families:?}"),
16634 )?;
16635 let connections = brief
16636 .files
16637 .iter()
16638 .flat_map(|candidate| candidate.connections.iter())
16639 .collect::<Vec<_>>();
16640 require(
16641 connections
16642 .iter()
16643 .any(|connection| connection.direction == RankedConnectionDirection::Outbound)
16644 && connections
16645 .iter()
16646 .any(|connection| connection.direction == RankedConnectionDirection::Inbound),
16647 "MCP graph samples did not preserve both directions",
16648 )?;
16649 for (name, present) in [
16650 (
16651 "local",
16652 connections.iter().any(|connection| {
16653 matches!(connection.target, RankedConnectionTarget::Local { .. })
16654 }),
16655 ),
16656 (
16657 "package",
16658 connections.iter().any(|connection| {
16659 matches!(connection.target, RankedConnectionTarget::Package { .. })
16660 }),
16661 ),
16662 (
16663 "external",
16664 connections.iter().any(|connection| {
16665 matches!(connection.target, RankedConnectionTarget::External { .. })
16666 }),
16667 ),
16668 (
16669 "unresolved",
16670 connections.iter().any(|connection| {
16671 matches!(connection.target, RankedConnectionTarget::Unresolved { .. })
16672 }),
16673 ),
16674 ] {
16675 require(
16676 present,
16677 &format!("MCP graph samples omitted {name} targets"),
16678 )?;
16679 }
16680 Ok(())
16681 }
16682
16683 fn publish_mcp_navigation_graph(
16684 store: &mut AtlasStore,
16685 ) -> Result<(), Box<dyn std::error::Error>> {
16686 let project = store
16687 .project_instance_id()?
16688 .ok_or_else(|| io::Error::other("MCP navigation project identity is missing"))?;
16689 let current_publication = store
16690 .index_publication()?
16691 .ok_or_else(|| io::Error::other("MCP navigation publication is missing"))?;
16692 let fingerprint = current_publication
16693 .contract_fingerprint
16694 .clone()
16695 .ok_or_else(|| io::Error::other("MCP navigation fingerprint is missing"))?;
16696 let generation = current_publication
16697 .generation
16698 .checked_next()
16699 .ok_or_else(|| io::Error::other("MCP navigation generation overflow"))?;
16700 let file_entity = |path: &str| {
16701 GraphEntity::new(
16702 project,
16703 EntitySelector::File {
16704 path: RepositoryFilePath::new(Path::new(path))?,
16705 },
16706 generation,
16707 )
16708 };
16709 let owner = file_entity("src/navigation_owner.rs")?;
16710 let local = file_entity("src/navigation_local.rs")?;
16711 let unresolved = file_entity("src/navigation_unresolved.rs")?;
16712 let test = file_entity("tests/navigation_owner.rs")?;
16713 let package = GraphEntity::new(
16714 project,
16715 EntitySelector::Package {
16716 package: PackageSelector {
16717 manager: GraphIdentityText::new("cargo")?,
16718 name: GraphIdentityText::new("adapter-navigation")?,
16719 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
16720 },
16721 },
16722 generation,
16723 )?;
16724 let external = GraphEntity::new(
16725 project,
16726 EntitySelector::External {
16727 external: ExternalSelector {
16728 system: GraphIdentityText::new("crates.io")?,
16729 identity: GraphIdentityText::new("serde@1")?,
16730 },
16731 },
16732 generation,
16733 )?;
16734 let resolved = |source: &GraphEntity, kind, target: &GraphEntity| {
16735 Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
16736 source,
16737 kind,
16738 RelationResolution::resolved(target)?,
16739 ConfidenceClass::Exact,
16740 Completeness::Complete,
16741 generation,
16742 )?)
16743 };
16744 let unresolved_relation = |source: &GraphEntity, kind, reference: &str| {
16745 Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
16746 source,
16747 kind,
16748 RelationResolution::Unresolved {
16749 reference: GraphIdentityText::new(reference)?,
16750 },
16751 ConfidenceClass::High,
16752 Completeness::Partial,
16753 generation,
16754 )?)
16755 };
16756 let relations = vec![
16757 resolved(
16758 &package,
16759 GraphRelationKind::Legacy(RelationKind::DependsOn),
16760 &owner,
16761 )?,
16762 LogicalRelation::new(
16763 &owner,
16764 GraphRelationKind::Legacy(RelationKind::Imports),
16765 RelationResolution::external(&external)?,
16766 ConfidenceClass::Exact,
16767 Completeness::Complete,
16768 generation,
16769 )?,
16770 resolved(
16771 &owner,
16772 GraphRelationKind::Legacy(RelationKind::Calls),
16773 &local,
16774 )?,
16775 unresolved_relation(
16776 &unresolved,
16777 GraphRelationKind::Extended(ExtendedRelationKind::References),
16778 "navigation-reference",
16779 )?,
16780 resolved(
16781 &test,
16782 GraphRelationKind::Extended(ExtendedRelationKind::Tests),
16783 &owner,
16784 )?,
16785 resolved(
16786 &owner,
16787 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
16788 &local,
16789 )?,
16790 LogicalRelation::new(
16791 &owner,
16792 GraphRelationKind::Extended(ExtendedRelationKind::References),
16793 RelationResolution::Ambiguous {
16794 reference: GraphIdentityText::new("navigation-ambiguous")?,
16795 candidates: std::num::NonZeroU32::new(2)
16796 .ok_or_else(|| io::Error::other("ambiguous fixture count is zero"))?,
16797 },
16798 ConfidenceClass::High,
16799 Completeness::Partial,
16800 generation,
16801 )?,
16802 unresolved_relation(
16803 &owner,
16804 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
16805 "NAVIGATION_MODE",
16806 )?,
16807 ];
16808 let nodes = store
16809 .load_nodes()?
16810 .into_iter()
16811 .map(|node| node.node)
16812 .collect::<Vec<_>>();
16813 {
16814 let mut publication = store.begin_index_publication(&fingerprint)?;
16815 publication.begin_scan_replacement()?;
16816 publication.upsert_scan_node_batch(&nodes)?;
16817 publication.finish_scan_replacement()?;
16818 publication.replace_repository_graph(
16819 project,
16820 &[owner, local, unresolved, test, package, external],
16821 &relations,
16822 &[],
16823 &[],
16824 )?;
16825 publication.complete()?;
16826 }
16827 store.set_purpose("src", "Navigation graph folder", PurposeSource::Agent)?;
16828 store.set_purpose(
16829 "src/navigation_owner.rs",
16830 "Navigation graph owner",
16831 PurposeSource::Agent,
16832 )?;
16833 store.set_purpose(
16834 "src/navigation_unresolved.rs",
16835 "Navigation unresolved graph owner",
16836 PurposeSource::Agent,
16837 )?;
16838 Ok(())
16839 }
16840
16841 #[test]
16842 fn session_brief_exposes_host_owned_purpose_curator_handoff()
16843 -> Result<(), Box<dyn std::error::Error>> {
16844 let temp = tempfile::tempdir()?;
16845 let repo = temp.path().join("repo-a");
16846 fs::create_dir_all(repo.join("src"))?;
16847 fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
16848 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16849 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16850 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16851 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
16852 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
16853 drop(store);
16854
16855 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16856 let brief = server.build_session_brief(
16857 AtlasSessionBriefParams {
16858 project_path: None,
16859 worktree: None,
16860 query: Some("startup".to_string()),
16861 purpose_task: Some("startup-task".to_string()),
16862 compact: None,
16863 folder_limit: Some(5),
16864 file_limit: Some(5),
16865 blocker_limit: Some(5),
16866 purpose_limit: Some(1),
16867 },
16868 None,
16869 )?;
16870 let handoff = brief
16871 .purpose_handoff
16872 .as_ref()
16873 .ok_or_else(|| std::io::Error::other("actionable purpose handoff missing"))?;
16874 require(
16875 handoff.execution_owner == "agent_host",
16876 "session handoff was not host-owned",
16877 )?;
16878 require(
16879 handoff.recommended_subagent_reasoning == PURPOSE_CURATOR_RECOMMENDED_REASONING,
16880 "session handoff did not request the lowest reliable host-supported reasoning",
16881 )?;
16882 require(
16883 handoff.main_agent_fallback && !handoff.server_started_curator,
16884 "session handoff misrepresented curator execution ownership",
16885 )?;
16886 require(
16887 handoff.silent_on_success,
16888 "session handoff was not quiet on successful maintenance",
16889 )?;
16890 require(
16891 handoff.queue.task == "startup-task"
16892 && handoff.queue.curation_scope == "low"
16893 && handoff.queue.actionable
16894 && handoff.queue.returned == 1
16895 && handoff.queue.limit == 1
16896 && handoff.queue.truncated,
16897 "compatibility session handoff lost its bounded purpose queue metadata",
16898 )?;
16899 require(
16900 handoff.queue.items.iter().all(|item| {
16901 item.work_key.len() == 64
16902 && item.state_token.len() == 64
16903 && !item.purpose_agent_reviewed
16904 }),
16905 "session handoff item tokens or lifecycle state were incomplete",
16906 )?;
16907 let compact = server.build_compact_session_brief(
16908 AtlasSessionBriefParams {
16909 project_path: None,
16910 worktree: None,
16911 query: Some("startup".to_string()),
16912 purpose_task: Some("startup-task".to_string()),
16913 compact: Some(true),
16914 folder_limit: Some(5),
16915 file_limit: Some(5),
16916 blocker_limit: Some(5),
16917 purpose_limit: Some(1),
16918 },
16919 None,
16920 )?;
16921 let compact_handoff = compact
16922 .purpose_handoff
16923 .as_ref()
16924 .ok_or_else(|| std::io::Error::other("compact purpose handoff missing"))?;
16925 require(
16926 matches!(
16927 compact_handoff.next_call.kind,
16928 McpBriefRecommendationKind::PurposeQueue
16929 ) && compact_handoff.next_call.target == MCP_TOOL_ATLAS_PURPOSE_QUEUE
16930 && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_TASK)
16931 == Some(&serde_json::json!("startup-task"))
16932 && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_LIMIT)
16933 == Some(&serde_json::json!(1)),
16934 "compact handoff did not preserve the exact bounded purpose-queue call",
16935 )?;
16936 require(
16937 brief.limits.purpose_limit == 1 && brief.limits.purposes_truncated,
16938 "session brief purpose limits were not reported",
16939 )?;
16940 Ok(())
16941 }
16942
16943 #[test]
16944 fn settings_capabilities_report_nearest_policy() -> Result<(), Box<dyn std::error::Error>> {
16945 let temp = tempfile::tempdir()?;
16946 let repo = temp.path().join("repo-a");
16947 fs::create_dir(&repo)?;
16948 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16949 let disabled =
16950 ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
16951 let enabled = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), true);
16952 let disabled_state = disabled.active_project_state()?;
16953 let enabled_state = enabled.active_project_state()?;
16954
16955 let disabled_text = disabled.render_settings_with_capabilities(&disabled_state)?;
16956 require(
16957 disabled_text.contains("mcp_session:"),
16958 "settings did not include mcp_session capabilities",
16959 )?;
16960 require(
16961 disabled_text.contains("path_scope: selected_project"),
16962 "disabled nearest-project policy was not typed",
16963 )?;
16964 require(
16965 disabled_text.contains("language_registry:")
16966 && disabled_text.contains("accepted_set_digest:")
16967 && disabled_text.contains("semantic_provider_digest:")
16968 && disabled_text.contains("semantic_relation_contract_digest:")
16969 && disabled_text.contains("relation_family_inventory:")
16970 && disabled_text.contains("optional_disabled_families:")
16971 && disabled_text.contains("benchmarked:")
16972 && !disabled_text.contains("accepted_minimum:")
16973 && !disabled_text.contains("provenance_source:")
16974 && disabled_text.contains("optional_catalog:")
16975 && disabled_text.contains("database:")
16976 && disabled_text.contains("compile_options:")
16977 && disabled_text.contains("search:")
16978 && disabled_text.contains("default_mode: lexical")
16979 && disabled_text.contains("optional_parser_pack:"),
16980 "settings did not project compact shared language registry truth",
16981 )?;
16982 require(
16983 disabled_text.len() <= MCP_SETTINGS_RESPONSE_MAX_BYTES,
16984 "settings exceeded its agent-facing output bound",
16985 )?;
16986 let enabled_text = enabled.render_settings_with_capabilities(&enabled_state)?;
16987 require(
16988 enabled_text.contains("path_scope: nearest_indexed_project"),
16989 "enabled nearest-project policy was not typed",
16990 )?;
16991 require(
16992 !enabled_text.contains("GITHUB_TOKEN") && !enabled_text.contains("GH_TOKEN"),
16993 "settings capabilities leaked token environment names",
16994 )?;
16995
16996 Ok(())
16997 }
16998
16999 #[cfg(windows)]
17000 #[test]
17001 fn mcp_settings_reports_native_root_equivalence() -> Result<(), Box<dyn std::error::Error>> {
17002 let temp = tempfile::tempdir()?;
17003 let base = temp
17004 .path()
17005 .to_str()
17006 .ok_or("temporary directory was not UTF-8")?;
17007 let long_component = "a".repeat(220);
17008 let verbatim_root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
17009 fs::create_dir(&verbatim_root)?;
17010 let verbatim_database = verbatim_root.join(".projectatlas/projectatlas.db");
17011 fs::create_dir_all(
17012 verbatim_database
17013 .parent()
17014 .ok_or("verbatim MCP database has no parent")?,
17015 )?;
17016 drop(AtlasStore::open_for_project(
17017 &verbatim_database,
17018 &verbatim_root,
17019 )?);
17020 let verbatim_config = temp.path().join("mcp-verbatim-config.toml");
17021 let verbatim_text = verbatim_root
17022 .to_str()
17023 .ok_or("verbatim MCP root was not UTF-8")?;
17024 fs::write(
17025 &verbatim_config,
17026 format!(
17027 "[project]\nroot = {}\n",
17028 serde_json::to_string(verbatim_text)?
17029 ),
17030 )?;
17031 let verbatim_expected =
17032 CanonicalProjectRoot::from_path(&verbatim_root)?.display_string()?;
17033 let verbatim_server = ProjectAtlasMcpServer::new(
17034 verbatim_database.clone(),
17035 None,
17036 "mcp-verbatim-settings-test".to_string(),
17037 false,
17038 );
17039 let verbatim_state = McpProjectState {
17040 root: verbatim_root,
17041 db_path: verbatim_database,
17042 config_path: Some(verbatim_config),
17043 worktree: None,
17044 };
17045 let verbatim: serde_json::Value = toon_format::decode_default(
17046 &verbatim_server.render_settings_with_capabilities(&verbatim_state)?,
17047 )?;
17048 require(
17049 verbatim.pointer("/settings/repo_root") == Some(&serde_json::json!(verbatim_expected)),
17050 "MCP settings lost the verbatim native root projection",
17051 )?;
17052
17053 let original = temp.path().join("McpCaseRoot");
17054 let staging = temp.path().join("McpCaseRootStaging");
17055 let renamed = temp.path().join("mcpcaseroot");
17056 fs::create_dir(&original)?;
17057 let database = original.join(".projectatlas/projectatlas.db");
17058 fs::create_dir_all(
17059 database
17060 .parent()
17061 .ok_or_else(|| io::Error::other("MCP case-only database has no parent"))?,
17062 )?;
17063 drop(AtlasStore::open_for_project(&database, &original)?);
17064 fs::rename(&original, &staging)?;
17065 fs::rename(&staging, &renamed)?;
17066 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
17067 let positive_config = temp.path().join("mcp-case-only-config.toml");
17068 fs::write(
17069 &positive_config,
17070 format!(
17071 "[project]\nroot = {}\n",
17072 serde_json::to_string(&renamed.to_string_lossy())?
17073 ),
17074 )?;
17075 let positive_server = ProjectAtlasMcpServer::new(
17076 renamed_database.clone(),
17077 None,
17078 "mcp-settings-test".to_string(),
17079 false,
17080 );
17081 let positive_state = McpProjectState {
17082 root: renamed,
17083 db_path: renamed_database,
17084 config_path: Some(positive_config),
17085 worktree: None,
17086 };
17087 let positive: serde_json::Value = toon_format::decode_default(
17088 &positive_server.render_settings_with_capabilities(&positive_state)?,
17089 )?;
17090 require(
17091 positive.pointer("/settings/root_verified") == Some(&serde_json::json!(true)),
17092 "MCP settings rejected case-only root rename",
17093 )?;
17094
17095 let case_sensitive_parent = temp.path().join("mcp-case-sensitive-parent");
17096 fs::create_dir(&case_sensitive_parent)?;
17097 let enabled = StdCommand::new("fsutil")
17098 .args(["file", "SetCaseSensitiveInfo"])
17099 .arg(&case_sensitive_parent)
17100 .arg("enable")
17101 .status()
17102 .is_ok_and(|status| status.success());
17103 if !enabled {
17104 return Ok(());
17105 }
17106 let stored_root = case_sensitive_parent.join("Repo");
17107 let selected_root = case_sensitive_parent.join("repo");
17108 fs::create_dir(&stored_root)?;
17109 fs::create_dir(&selected_root)?;
17110 let database = stored_root.join(".projectatlas/projectatlas.db");
17111 fs::create_dir_all(
17112 database
17113 .parent()
17114 .ok_or_else(|| io::Error::other("MCP case-sensitive database has no parent"))?,
17115 )?;
17116 drop(AtlasStore::open_for_project(&database, &stored_root)?);
17117 let negative_config = temp.path().join("mcp-case-sensitive-config.toml");
17118 fs::write(
17119 &negative_config,
17120 format!(
17121 "[project]\nroot = {}\n",
17122 serde_json::to_string(&selected_root.to_string_lossy())?
17123 ),
17124 )?;
17125 let negative_server = ProjectAtlasMcpServer::new(
17126 database.clone(),
17127 None,
17128 "mcp-settings-test".to_string(),
17129 false,
17130 );
17131 let negative_state = McpProjectState {
17132 root: selected_root,
17133 db_path: database,
17134 config_path: Some(negative_config),
17135 worktree: None,
17136 };
17137 let negative: serde_json::Value = toon_format::decode_default(
17138 &negative_server.render_settings_with_capabilities(&negative_state)?,
17139 )?;
17140 require(
17141 negative.pointer("/settings/root_verified") == Some(&serde_json::json!(false)),
17142 "MCP settings accepted a case-sensitive sibling",
17143 )?;
17144 Ok(())
17145 }
17146
17147 #[test]
17148 fn task_progress_status_and_cancel_are_typed() -> Result<(), Box<dyn std::error::Error>> {
17149 let temp = tempfile::tempdir()?;
17150 let repo = temp.path().join("repo-a");
17151 fs::create_dir(&repo)?;
17152 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17153 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
17154
17155 let status = server.task_status(MCP_TASK_CONTRACT_ID.to_string())?;
17156 require(
17157 status.lookup == McpTaskLookupStatus::Found,
17158 "contract task missing",
17159 )?;
17160 require(
17161 status
17162 .task
17163 .as_ref()
17164 .is_some_and(|task| task.state == McpTaskState::Complete),
17165 "contract task was not complete",
17166 )?;
17167 require(
17168 status.states.contains(&McpTaskState::Pending)
17169 && status.states.contains(&McpTaskState::Canceled),
17170 "status response did not expose task state contract",
17171 )?;
17172 let cancel = server.task_cancel(MCP_TASK_CONTRACT_ID.to_string())?;
17173 require(
17174 cancel.result == McpTaskCancelResult::AlreadyFinished,
17175 "completed contract task did not return already_finished",
17176 )?;
17177 let missing = server.task_status("missing-task".to_string())?;
17178 require(
17179 missing.lookup == McpTaskLookupStatus::NotFound,
17180 "unknown task did not return not_found",
17181 )?;
17182
17183 Ok(())
17184 }
17185
17186 #[test]
17187 fn background_task_envelope_and_cancellation_reach_owned_control()
17188 -> Result<(), Box<dyn std::error::Error>> {
17189 let temp = tempfile::tempdir()?;
17190 let repo = temp.path().join("repo-a");
17191 fs::create_dir(&repo)?;
17192 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17193 let mut server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
17194 let host_envelope = server.background_resources;
17195 let host_workers = thread::available_parallelism().map_or(1, usize::from);
17196 let representative_envelope = McpBackgroundResourceEnvelope::from_available_workers(8);
17197 require(
17198 host_envelope.task_limit <= MCP_BACKGROUND_TASK_SAFE_CEILING
17199 && host_envelope.workers_per_task > 0
17200 && host_envelope.total_worker_limit
17201 <= host_workers.clamp(1, INDEX_WORKER_SAFE_CEILING)
17202 && host_envelope.workers_per_task * host_envelope.task_limit
17203 <= host_envelope.total_worker_limit,
17204 "background resource envelope exceeded its host or process worker budget",
17205 )?;
17206 require(
17207 representative_envelope
17208 == (McpBackgroundResourceEnvelope {
17209 task_limit: 4,
17210 workers_per_task: 2,
17211 total_worker_limit: 8,
17212 })
17213 && McpBackgroundResourceEnvelope::from_available_workers(0).total_worker_limit == 1
17214 && McpBackgroundResourceEnvelope::from_available_workers(usize::MAX)
17215 .total_worker_limit
17216 == INDEX_WORKER_SAFE_CEILING,
17217 "background resource envelope did not partition representative host capacities",
17218 )?;
17219
17220 server.background_resources = McpBackgroundResourceEnvelope::from_available_workers(4);
17221 let envelope = server.background_resources;
17222 let started = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
17223 let databases_ready = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
17224 let release = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
17225 let observed_option_workers = Arc::new(AtomicU64::new(0));
17226 let observed_control_workers = Arc::new(AtomicU64::new(0));
17227 let mut concurrent_tasks = Vec::new();
17228 for task_index in 0..envelope.task_limit {
17229 let isolated_root = temp.path().join(format!("repo-{task_index}"));
17230 let isolated_db = isolated_root.join(".projectatlas").join("projectatlas.db");
17231 fs::create_dir_all(isolated_root.join(".projectatlas"))?;
17232 let worker_started = Arc::clone(&started);
17233 let worker_databases_ready = Arc::clone(&databases_ready);
17234 let worker_release = Arc::clone(&release);
17235 let worker_option_workers = Arc::clone(&observed_option_workers);
17236 let worker_control_workers = Arc::clone(&observed_control_workers);
17237 concurrent_tasks.push(server.start_index_task(
17238 McpTaskOperation::Scan,
17239 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17240 MCP_TOOL_ATLAS_OVERVIEW,
17241 move |control, options| {
17242 worker_option_workers
17243 .fetch_add(options.reported_workers() as u64, Ordering::Relaxed);
17244 worker_control_workers.fetch_add(
17245 control.worker_ceiling().unwrap_or_default() as u64,
17246 Ordering::Relaxed,
17247 );
17248 worker_started.wait();
17249 let store = open_atlas_store_for_project(&isolated_db, &isolated_root);
17250 worker_databases_ready.wait();
17251 worker_release.wait();
17252 let _store = store?;
17253 Ok(())
17254 },
17255 )?);
17256 }
17257 started.wait();
17258 let admitted_workers = envelope.workers_per_task * envelope.task_limit;
17259 require(
17260 observed_option_workers.load(Ordering::Relaxed) == admitted_workers as u64
17261 && observed_control_workers.load(Ordering::Relaxed) == admitted_workers as u64
17262 && admitted_workers <= envelope.total_worker_limit,
17263 "concurrent background tasks did not share the aggregate worker envelope",
17264 )?;
17265 let overflow = server.start_index_task(
17266 McpTaskOperation::Scan,
17267 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17268 MCP_TOOL_ATLAS_OVERVIEW,
17269 |_control, _options| Ok(()),
17270 );
17271 require(
17272 matches!(overflow, Err(CliError::Mcp(message)) if message.starts_with(MCP_INDEX_TASK_LIMIT_PREFIX)),
17273 "background task admission exceeded the server task limit",
17274 )?;
17275 require(
17276 server.task_status(MCP_TASK_CONTRACT_ID.to_string())?.lookup
17277 == McpTaskLookupStatus::Found,
17278 "task status became unresponsive while concurrent work was active",
17279 )?;
17280 databases_ready.wait();
17281 release.wait();
17282 for task in concurrent_tasks {
17283 require(
17284 wait_for_background_task(&server, &task.task_id)?.state == McpTaskState::Complete,
17285 "concurrent background task did not finish successfully",
17286 )?;
17287 }
17288
17289 let cancel_started = Arc::new(std::sync::Barrier::new(2));
17290 let worker_cancel_started = Arc::clone(&cancel_started);
17291 let canceled_task = server.start_index_task(
17292 McpTaskOperation::Scan,
17293 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17294 MCP_TOOL_ATLAS_OVERVIEW,
17295 move |control, options| {
17296 if options.reported_workers() != control.worker_ceiling().unwrap_or_default() {
17297 return Err(CliError::Mcp(
17298 "background parser and operation worker ceilings diverged".to_string(),
17299 ));
17300 }
17301 worker_cancel_started.wait();
17302 loop {
17303 control
17304 .check(projectatlas_core::IndexWorkStage::RepositoryTraversal)
17305 .map_err(|failure| {
17306 CliError::Fs(projectatlas_fs::FsError::IndexWork(failure))
17307 })?;
17308 thread::yield_now();
17309 }
17310 },
17311 )?;
17312 cancel_started.wait();
17313
17314 let running = server.task_status(canceled_task.task_id.clone())?;
17315 require(
17316 running
17317 .task
17318 .as_ref()
17319 .is_some_and(|record| record.state == McpTaskState::Running),
17320 "background task did not become running",
17321 )?;
17322 let cancel = server.task_cancel(canceled_task.task_id.clone())?;
17323 require(
17324 cancel.result == McpTaskCancelResult::CancellationRequested,
17325 "task cancellation did not reach the active work control",
17326 )?;
17327
17328 require(
17329 wait_for_background_task(&server, &canceled_task.task_id)?.state
17330 == McpTaskState::Canceled,
17331 "background task did not finish canceled after consuming the signal",
17332 )?;
17333 require(
17334 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17335 "successful task was not admitted after cancellation",
17336 )?;
17337
17338 let failed_task = server.start_index_task(
17339 McpTaskOperation::Scan,
17340 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17341 MCP_TOOL_ATLAS_OVERVIEW,
17342 |_control, _options| Err(CliError::Mcp("expected task failure".to_string())),
17343 )?;
17344 require(
17345 wait_for_background_task(&server, &failed_task.task_id)?.state == McpTaskState::Failed,
17346 "background task error did not become a terminal failure",
17347 )?;
17348 require(
17349 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17350 "successful task was not admitted after failure",
17351 )?;
17352
17353 let panicked_task = server.start_index_task(
17354 McpTaskOperation::Scan,
17355 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17356 MCP_TOOL_ATLAS_OVERVIEW,
17357 |_control, _options| -> Result<(), CliError> {
17358 std::panic::resume_unwind(Box::new("expected background task panic"));
17359 },
17360 )?;
17361 let panicked = wait_for_background_task(&server, &panicked_task.task_id)?;
17362 require(
17363 panicked.state == McpTaskState::Failed
17364 && panicked.error.as_deref() == Some(MCP_INDEX_WORKER_PANIC_ERROR),
17365 "background task panic did not remain bounded and terminal",
17366 )?;
17367 require(
17368 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17369 "terminal task lifecycle did not release background admission capacity",
17370 )?;
17371
17372 Ok(())
17373 }
17374
17375 #[test]
17376 fn background_scan_defers_config_validation_and_rejects_root_redirection()
17377 -> Result<(), Box<dyn std::error::Error>> {
17378 let temp = tempfile::tempdir()?;
17379 let repo = temp.path().join("repo-a");
17380 let redirected = temp.path().join("repo-b");
17381 let atlas_dir = repo.join(".projectatlas");
17382 fs::create_dir_all(&atlas_dir)?;
17383 fs::create_dir_all(&redirected)?;
17384 let config_path = atlas_dir.join("config.toml");
17385 fs::write(&config_path, "[project]\nroot = \"../../repo-b\"\n")?;
17386 let db_path = atlas_dir.join("projectatlas.db");
17387 let server =
17388 ProjectAtlasMcpServer::new(db_path, Some(config_path), "mcp-test".to_string(), false);
17389
17390 let response = server.atlas_scan(Parameters(AtlasScanParams {
17391 project_path: Some(repo.to_string_lossy().into_owned()),
17392 worktree: None,
17393 path: None,
17394 nearest_project: Some(false),
17395 max_bytes: None,
17396 max_workers: Some(1),
17397 timeout_seconds: None,
17398 text_index_max_bytes: None,
17399 background: Some(true),
17400 }));
17401 require(
17402 response.contains(MCP_PAYLOAD_TASK_START),
17403 "explicit background project validated redirecting config before task admission",
17404 )?;
17405 let admitted_task_id = server
17406 .task_registry
17407 .read()
17408 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
17409 .latest_task_id(&McpTaskOperation::Scan)
17410 .ok_or_else(|| io::Error::other("admitted background scan task missing"))?;
17411 let mut admitted_terminal = None;
17412 for _attempt in 0..1_000 {
17413 let status = server.task_status(admitted_task_id.clone())?;
17414 if status
17415 .task
17416 .as_ref()
17417 .is_some_and(McpTaskRecord::is_terminal_state)
17418 {
17419 admitted_terminal = status.task;
17420 break;
17421 }
17422 thread::sleep(Duration::from_millis(1));
17423 }
17424 require(
17425 admitted_terminal
17426 .as_ref()
17427 .is_some_and(|record| record.state == McpTaskState::Failed),
17428 "redirecting background config did not fail inside the admitted task",
17429 )?;
17430 require(
17431 admitted_terminal
17432 .as_ref()
17433 .and_then(|record| record.error.as_deref())
17434 .is_some_and(|error| error.contains("outside selected project root")),
17435 "controlled plan loading did not preserve root-redirection refusal",
17436 )?;
17437
17438 Ok(())
17439 }
17440
17441 #[test]
17442 fn watch_once_requires_initialized_state_without_creating_a_database()
17443 -> Result<(), Box<dyn std::error::Error>> {
17444 let temp = tempfile::tempdir()?;
17445 let root = temp.path();
17446 fs::write(root.join("lib.rs"), "pub fn source() {}\n")?;
17447 let database = root.join(".projectatlas/projectatlas.db");
17448 let server = ProjectAtlasMcpServer::new(
17449 database.clone(),
17450 None,
17451 "missing-watch-test".to_string(),
17452 false,
17453 );
17454 let response = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17455 project_path: Some(root.to_string_lossy().into_owned()),
17456 worktree: None,
17457 path: None,
17458 nearest_project: Some(false),
17459 max_workers: Some(1),
17460 timeout_seconds: None,
17461 text_index_max_bytes: None,
17462 background: Some(false),
17463 }));
17464 require(
17465 response.contains("init_required"),
17466 "missing index did not return typed initialization guidance",
17467 )?;
17468 require(
17469 !database.exists(),
17470 "watch implicitly created the missing database",
17471 )?;
17472 fs::create_dir(root.join(".projectatlas"))?;
17473 fs::write(&database, [])?;
17474 let response = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17475 project_path: Some(root.to_string_lossy().into_owned()),
17476 worktree: None,
17477 path: None,
17478 nearest_project: Some(false),
17479 max_workers: Some(1),
17480 timeout_seconds: None,
17481 text_index_max_bytes: None,
17482 background: Some(false),
17483 }));
17484 require(
17485 response.contains("init_required"),
17486 "empty database did not return typed initialization guidance",
17487 )?;
17488 require(
17489 fs::read(&database)?.is_empty()
17490 && fs::read_dir(root.join(".projectatlas"))?.count() == 1,
17491 "watch initialized or journaled an empty database",
17492 )?;
17493 Ok(())
17494 }
17495
17496 #[test]
17497 fn watch_adapters_reconcile_noop_and_report_real_changed_work()
17498 -> Result<(), Box<dyn std::error::Error>> {
17499 let temp = tempfile::tempdir()?;
17500 let root = temp.path();
17501 fs::write(root.join("lib.rs"), "pub fn initial() {}\n")?;
17502 let config = root.join(".projectatlas/config.toml");
17503 init_project_with_config(root, Some(&config))?;
17504 let database = root.join(".projectatlas/projectatlas.db");
17505 let plan = ScanRuntimePlan::for_path(None, root, None)?;
17506 let options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None);
17507 let mut writer = open_atlas_store_for_project(&database, root)?;
17508 run_scan_pipeline(&mut writer, &plan, &options)?;
17509 let initial = writer.index_publication()?;
17510 let server = ProjectAtlasMcpServer::new(
17511 database.clone(),
17512 None,
17513 "watch-epoch-test".to_string(),
17514 false,
17515 );
17516 let watch = |background| {
17517 server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17518 project_path: Some(root.to_string_lossy().into_owned()),
17519 worktree: None,
17520 path: None,
17521 nearest_project: Some(false),
17522 max_workers: Some(1),
17523 timeout_seconds: None,
17524 text_index_max_bytes: None,
17525 background: Some(background),
17526 }))
17527 };
17528 for _ in 0..2 {
17529 let result: serde_json::Value = toon_format::decode_default(&watch(false))?;
17530 require(
17531 result
17532 .pointer("/watch/last_symbols/parsed")
17533 .and_then(serde_json::Value::as_u64)
17534 == Some(0),
17535 "unchanged watch reported parser work",
17536 )?;
17537 let epoch = server
17538 .source_observations
17539 .with_verified_current_read(
17540 &database,
17541 root,
17542 Some(&config),
17543 &index_work_control(&options),
17544 |_store, _stamp| Ok(()),
17545 )?
17546 .require_current()?;
17547 require(
17548 epoch.work.exact_verifications == 1
17549 && epoch.work.filesystem_entries > 0
17550 && epoch.work.filesystem_bytes > 0
17551 && epoch.work.decoded_nodes > 0,
17552 "watch adapter trusted an unfenced callback queue",
17553 )?;
17554 }
17555 require(
17556 writer.index_publication()? == initial,
17557 "unchanged watch published a generation",
17558 )?;
17559 require(
17560 watch(true).contains(MCP_PAYLOAD_TASK_START),
17561 "background no-op was not admitted",
17562 )?;
17563 let terminal = wait_for_background_operation(&server, &McpTaskOperation::WatchOnce)?;
17564 require(
17565 terminal.state == McpTaskState::Complete,
17566 "background no-op failed",
17567 )?;
17568 require(
17569 writer.index_publication()? == initial,
17570 "background no-op published",
17571 )?;
17572 fs::write(root.join("lib.rs"), "pub fn changed() {}\n")?;
17573 let changed: serde_json::Value = toon_format::decode_default(&server.atlas_watch_once(
17575 Parameters(AtlasWatchOnceParams {
17576 project_path: Some(root.to_string_lossy().into_owned()),
17577 worktree: None,
17578 path: None,
17579 nearest_project: Some(false),
17580 max_workers: Some(1),
17581 timeout_seconds: None,
17582 text_index_max_bytes: None,
17583 background: Some(false),
17584 }),
17585 ))?;
17586 require(
17587 changed
17588 .pointer("/watch/last_symbols/parsed")
17589 .and_then(serde_json::Value::as_u64)
17590 == Some(1),
17591 "source repair was hidden behind a no-op report",
17592 )?;
17593 require(
17594 writer
17595 .load_file_text("lib.rs")?
17596 .is_some_and(|text| text.content == "pub fn changed() {}\n"),
17597 "watch did not publish changed text",
17598 )?;
17599 let before = initial
17600 .ok_or_else(|| io::Error::other("initial generation missing"))?
17601 .generation;
17602 require(
17603 writer
17604 .index_publication()?
17605 .is_some_and(|publication| Some(publication.generation) == before.checked_next()),
17606 "changed watch did not publish exactly one generation",
17607 )?;
17608 let identity = writer.project_instance_id()?;
17609 drop(writer);
17610 let database = root.join(".projectatlas/projectatlas.db");
17611 let predecessor = rusqlite::Connection::open(&database)?;
17612 drop_native_worktree_identity_schema(&predecessor)?;
17613 predecessor.execute_batch(
17614 "DROP TABLE graph_identity_rejections;
17615 UPDATE metadata SET value = '20' WHERE key = 'schema_version';",
17616 )?;
17617 drop(predecessor);
17618 let cold = ProjectAtlasMcpServer::new(
17619 database.clone(),
17620 None,
17621 "migration-watch-test".to_string(),
17622 false,
17623 );
17624 let migrated: serde_json::Value = toon_format::decode_default(&cold.atlas_watch_once(
17625 Parameters(AtlasWatchOnceParams {
17626 project_path: Some(root.to_string_lossy().into_owned()),
17627 worktree: None,
17628 path: None,
17629 nearest_project: Some(false),
17630 max_workers: Some(1),
17631 timeout_seconds: None,
17632 text_index_max_bytes: None,
17633 background: Some(false),
17634 }),
17635 ))?;
17636 require(
17637 migrated.pointer("/watch/last_symbols").is_some(),
17638 &format!("no-op admission prevented supported migration: {migrated}"),
17639 )?;
17640 require(
17641 open_existing_atlas_store_for_project(&database, root)?.project_instance_id()?
17642 == identity,
17643 "supported migration changed project instance identity",
17644 )?;
17645 Ok(())
17646 }
17647
17648 #[test]
17649 fn index_adapters_publish_scan_symbol_and_watch_effects()
17650 -> Result<(), Box<dyn std::error::Error>> {
17651 let temp = tempfile::tempdir()?;
17652 let repo = temp.path().join("repo");
17653 let source_dir = repo.join("src");
17654 fs::create_dir_all(&source_dir)?;
17655 let source_path = source_dir.join("lib.rs");
17656 fs::write(
17657 &source_path,
17658 "pub fn first() { second(); }\nfn second() {}\n",
17659 )?;
17660 let config_path = repo.join(".projectatlas").join("config.toml");
17661 init_project_with_config(&repo, Some(&config_path))?;
17662 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17663 let server =
17664 ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
17665 let project_path = repo.to_string_lossy().into_owned();
17666
17667 for operation in [
17668 McpTaskOperation::Scan,
17669 McpTaskOperation::SymbolsBuild,
17670 McpTaskOperation::WatchOnce,
17671 ] {
17672 match operation {
17673 McpTaskOperation::SymbolsBuild => {
17674 let store = open_atlas_store_for_project(&db_path, &repo)?;
17675 store.clear_symbol_graph_for_path("src/lib.rs")?;
17676 require(
17677 store.symbol_count_for_path("src/lib.rs")? == 0,
17678 "symbol fixture was not cleared before background rebuild",
17679 )?;
17680 }
17681 McpTaskOperation::WatchOnce => {
17682 fs::write(
17683 &source_path,
17684 "pub fn first() { second(); third(); }\nfn second() {}\nfn third() {}\n",
17685 )?;
17686 }
17687 McpTaskOperation::Scan | McpTaskOperation::Contract | McpTaskOperation::Search => {}
17688 }
17689
17690 let response = match operation {
17691 McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => {
17692 let params = AtlasScanParams {
17693 project_path: Some(project_path.clone()),
17694 worktree: None,
17695 path: None,
17696 nearest_project: Some(false),
17697 max_bytes: None,
17698 max_workers: Some(1),
17699 timeout_seconds: None,
17700 text_index_max_bytes: None,
17701 background: Some(true),
17702 };
17703 if operation == McpTaskOperation::Scan {
17704 server.atlas_scan(Parameters(params))
17705 } else {
17706 server.atlas_symbols_build(Parameters(params))
17707 }
17708 }
17709 McpTaskOperation::WatchOnce => {
17710 server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17711 project_path: Some(project_path.clone()),
17712 worktree: None,
17713 path: None,
17714 nearest_project: Some(false),
17715 max_workers: Some(1),
17716 timeout_seconds: None,
17717 text_index_max_bytes: None,
17718 background: Some(true),
17719 }))
17720 }
17721 McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
17722 };
17723 require(
17724 response.contains(MCP_PAYLOAD_TASK_START),
17725 "production background adapter did not admit its task",
17726 )?;
17727 let terminal = wait_for_background_operation(&server, &operation)?;
17728 require(
17729 terminal.state == McpTaskState::Complete,
17730 terminal
17731 .error
17732 .as_deref()
17733 .unwrap_or("background task failed"),
17734 )?;
17735
17736 let expected_symbol = match operation {
17737 McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => "second",
17738 McpTaskOperation::WatchOnce => "third",
17739 McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
17740 };
17741 let store = open_atlas_store_for_project(&db_path, &repo)?;
17742 store.set_purpose(
17743 "src/lib.rs",
17744 "Own café λ relation navigation",
17745 PurposeSource::Agent,
17746 )?;
17747 drop(store);
17748 require_agent_index_reads(&server, &project_path, expected_symbol)?;
17749 }
17750
17751 let store = open_atlas_store_for_project(&db_path, &repo)?;
17752 store.clear_symbol_graph_for_path("src/lib.rs")?;
17753 drop(store);
17754 let synchronous_symbols = server.atlas_symbols_build(Parameters(AtlasScanParams {
17755 project_path: Some(project_path.clone()),
17756 worktree: None,
17757 path: None,
17758 nearest_project: Some(false),
17759 max_bytes: None,
17760 max_workers: Some(1),
17761 timeout_seconds: None,
17762 text_index_max_bytes: None,
17763 background: Some(false),
17764 }));
17765 require(
17766 synchronous_symbols.contains(MCP_PAYLOAD_SYMBOLS_BUILD),
17767 "synchronous symbol adapter did not return its completed report",
17768 )?;
17769 require_agent_index_reads(&server, &project_path, "second")?;
17770
17771 fs::write(
17772 &source_path,
17773 "pub fn first() { second(); fourth(); }\nfn second() {}\nfn fourth() {}\n",
17774 )?;
17775 let synchronous_watch = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17776 project_path: Some(project_path.clone()),
17777 worktree: None,
17778 path: None,
17779 nearest_project: Some(false),
17780 max_workers: Some(1),
17781 timeout_seconds: None,
17782 text_index_max_bytes: None,
17783 background: Some(false),
17784 }));
17785 require(
17786 synchronous_watch.contains(MCP_PAYLOAD_WATCH),
17787 "synchronous watch adapter did not return its completed report",
17788 )?;
17789 require_agent_index_reads(&server, &project_path, "fourth")?;
17790 Ok(())
17791 }
17792
17793 #[test]
17794 fn long_lived_mcp_query_families_reuse_one_verified_epoch()
17795 -> Result<(), Box<dyn std::error::Error>> {
17796 const LARGE_UNRELATED_SOURCE_FILES: usize = 256;
17797 const MAX_MEASURED_QUERY_OUTPUT_BYTES: usize = 64 * 1_024;
17798 const MAX_MEASURED_QUERY_ELAPSED: Duration = Duration::from_secs(30);
17799
17800 let measure = |unrelated_source_files: usize| {
17801 let temp = tempfile::tempdir()?;
17802 let repo = temp.path().join("repo");
17803 let source_dir = repo.join("src");
17804 fs::create_dir_all(&source_dir)?;
17805 fs::write(
17806 source_dir.join("lib.rs"),
17807 "pub fn first() { second(); }\nfn second() {}\n",
17808 )?;
17809 for index in 0..unrelated_source_files {
17810 fs::write(
17811 source_dir.join(format!("unrelated_{index:03}.rs")),
17812 format!("pub fn unrelated_{index:03}() {{}}\n"),
17813 )?;
17814 }
17815 let config_path = repo.join(".projectatlas").join("config.toml");
17816 init_project_with_config(&repo, Some(&config_path))?;
17817 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17818 let mut writer = open_atlas_store_for_project(&db_path, &repo)?;
17819 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
17820 run_scan_pipeline(
17821 &mut writer,
17822 &plan,
17823 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None),
17824 )?;
17825 drop(writer);
17826
17827 let server = ProjectAtlasMcpServer::new(
17828 db_path.clone(),
17829 None,
17830 "verified-epoch-test".to_string(),
17831 false,
17832 );
17833 let state = McpProjectState {
17834 root: repo,
17835 db_path,
17836 config_path: None,
17837 worktree: None,
17838 };
17839 let first = server.with_fresh_store(&state, |store, _stamp| Ok(store.overview()?))?;
17840 require(
17841 first.work.exact_verifications >= 1,
17842 "first long-lived MCP read did not establish exact source truth",
17843 )?;
17844 require(
17845 first.work.filesystem_entries > u64::try_from(unrelated_source_files)?,
17846 "scale fixture did not exercise its complete repository source set",
17847 )?;
17848 let expected_stamp = first.stamp;
17849
17850 let folder = server.with_fresh_store(&state, |store, _stamp| {
17851 Ok(render_ranked_nodes(
17852 NODE_LABEL_FOLDERS,
17853 &ranked_folder_nodes_with_reasons(store, "", 4)?,
17854 ))
17855 })?;
17856 let folder_output_bytes = folder.value.len();
17857 let folder = folder.with_output_bytes(folder_output_bytes);
17858 let files = server.with_fresh_store(&state, |store, _stamp| {
17859 Ok(render_ranked_nodes(
17860 NODE_LABEL_FILES,
17861 &ranked_file_nodes_with_reasons(store, "", Some("src"), None, 4, false)?,
17862 ))
17863 })?;
17864 let files_output_bytes = files.value.len();
17865 let files = files.with_output_bytes(files_output_bytes);
17866 let summary = server.with_fresh_store(&state, |store, _stamp| {
17867 let content = read_indexed_file_content(store, "src/lib.rs")?;
17868 let report = build_file_summary_from_source(
17869 store,
17870 Path::new("src/lib.rs"),
17871 DEFAULT_FILE_SUMMARY_LIMIT,
17872 &content,
17873 )?;
17874 Ok(render_file_summary(&report))
17875 })?;
17876 let summary_output_bytes = summary.value.len();
17877 let summary = summary.with_output_bytes(summary_output_bytes);
17878 let relations = server.with_fresh_store(&state, |store, _stamp| {
17879 Ok(render_symbol_relations(&store.load_symbol_relations(
17880 Some("src/lib.rs"),
17881 None,
17882 8,
17883 )?))
17884 })?;
17885 let relation_output_bytes = relations.value.len();
17886 let relations = relations.with_output_bytes(relation_output_bytes);
17887
17888 let mut measurements = Vec::new();
17889 for (family, outcome) in [
17890 ("folder", folder),
17891 ("file", files),
17892 ("summary", summary),
17893 ("relation", relations),
17894 ] {
17895 require(
17896 outcome.stamp == expected_stamp,
17897 &format!("{family} call did not remain bound to the verified epoch"),
17898 )?;
17899 require(
17900 outcome.work.exact_verifications == 0
17901 && outcome.work.filesystem_entries == 0
17902 && outcome.work.filesystem_bytes == 0
17903 && outcome.work.decoded_nodes == 0,
17904 &format!("{family} call repeated repository-sized freshness work"),
17905 )?;
17906 require(
17907 outcome.work.sqlite_read_statements == 1,
17908 &format!("{family} freshness check used unexpected SQLite work"),
17909 )?;
17910 require(
17911 outcome.work.output_bytes == u64::try_from(outcome.value.len())?,
17912 &format!("{family} call did not record its accepted rendered bytes"),
17913 )?;
17914 require(
17915 outcome.value.len() <= MAX_MEASURED_QUERY_OUTPUT_BYTES,
17916 &format!("{family} call exceeded the focused output bound"),
17917 )?;
17918 require(
17919 !outcome.work.elapsed.is_zero()
17920 && outcome.work.elapsed <= MAX_MEASURED_QUERY_ELAPSED,
17921 &format!("{family} call did not retain a bounded elapsed measurement"),
17922 )?;
17923 measurements.push((family, outcome.work));
17924 }
17925 Ok::<_, Box<dyn std::error::Error>>(measurements)
17926 };
17927
17928 let small = measure(0)?;
17929 let large = measure(LARGE_UNRELATED_SOURCE_FILES)?;
17930 for ((small_family, small_work), (large_family, large_work)) in small.into_iter().zip(large)
17931 {
17932 require(
17933 small_family == large_family,
17934 "small and large query measurements used different families",
17935 )?;
17936 require(
17937 small_work.exact_verifications == large_work.exact_verifications
17938 && small_work.filesystem_entries == large_work.filesystem_entries
17939 && small_work.filesystem_bytes == large_work.filesystem_bytes
17940 && small_work.sqlite_read_statements == large_work.sqlite_read_statements
17941 && small_work.decoded_nodes == large_work.decoded_nodes,
17942 &format!("{small_family} warm freshness work changed with repository scale"),
17943 )?;
17944 }
17945 Ok(())
17946 }
17947
17948 #[test]
17949 fn selected_root_absolute_path_keys_stay_inside_selected_project()
17950 -> Result<(), Box<dyn std::error::Error>> {
17951 let temp = tempfile::tempdir()?;
17952 let repo = temp.path().join("repo");
17953 let outside = temp.path().join("outside");
17954 fs::create_dir_all(repo.join("src"))?;
17955 fs::create_dir_all(outside.join("src"))?;
17956 let inside_file = repo.join("src").join("lib.rs");
17957 let outside_file = outside.join("src").join("lib.rs");
17958 fs::write(&inside_file, "pub fn inside() {}\n")?;
17959 fs::write(&outside_file, "pub fn outside() {}\n")?;
17960 let state = McpProjectState {
17961 root: canonical_project_root(&repo)?,
17962 db_path: repo.join(".projectatlas").join("projectatlas.db"),
17963 config_path: None,
17964 worktree: None,
17965 };
17966
17967 let inside_key =
17968 ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &inside_file)?
17969 .ok_or_else(|| io::Error::other("inside selected root did not produce key"))?;
17970 require(
17971 inside_key == "src/lib.rs",
17972 "inside selected root produced wrong repo key",
17973 )?;
17974 require(
17975 ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &outside_file)?
17976 .is_none(),
17977 "outside selected root produced a repo key",
17978 )?;
17979
17980 Ok(())
17981 }
17982
17983 #[test]
17984 fn indexed_root_candidate_requires_matching_project_root()
17985 -> Result<(), Box<dyn std::error::Error>> {
17986 let temp = tempfile::tempdir()?;
17987 let repo = temp.path().join("repo");
17988 let other = temp.path().join("other");
17989 fs::create_dir_all(repo.join(".projectatlas"))?;
17990 fs::create_dir_all(&other)?;
17991
17992 require(
17993 ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
17994 "candidate without DB was treated as indexed",
17995 )?;
17996
17997 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17998 {
17999 let _store = open_atlas_store_for_project(&db_path, &other)?;
18000 }
18001 require(
18002 ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
18003 "candidate with mismatched DB root was treated as indexed",
18004 )?;
18005
18006 reset_index_files(&db_path, true, false, false, None)?;
18007 {
18008 let _store = open_atlas_store_for_project(&db_path, &repo)?;
18009 }
18010 let indexed = ProjectAtlasMcpServer::indexed_root_from_candidate(&repo)
18011 .ok_or_else(|| io::Error::other("matching DB root was not accepted"))?;
18012 require(
18013 indexed.root == canonical_project_root(&repo)?,
18014 "indexed root did not preserve canonical candidate root",
18015 )?;
18016 let expected_db_path =
18017 ProjectAtlasMcpServer::projectatlas_db_path(&canonical_project_root(&repo)?);
18018 require(
18019 indexed.db_path == expected_db_path,
18020 "indexed root changed DB path",
18021 )?;
18022
18023 Ok(())
18024 }
18025
18026 #[cfg(windows)]
18027 #[test]
18028 fn indexed_root_predecessor_candidate_supports_nearest_routing()
18029 -> Result<(), Box<dyn std::error::Error>> {
18030 let temp = tempfile::tempdir()?;
18031 let root = temp.path().join("predecessor");
18032 let source = root.join("src").join("lib.rs");
18033 let database = root.join(".projectatlas").join("projectatlas.db");
18034 fs::create_dir_all(
18035 source
18036 .parent()
18037 .ok_or_else(|| io::Error::other("predecessor source path has no parent"))?,
18038 )?;
18039 fs::create_dir_all(
18040 database
18041 .parent()
18042 .ok_or_else(|| io::Error::other("predecessor database path has no parent"))?,
18043 )?;
18044 fs::write(&source, "pub fn predecessor() {}\n")?;
18045 let store = open_atlas_store_for_project(&database, &root)?;
18046 drop(store);
18047 let predecessor = rusqlite::Connection::open(&database)?;
18048 drop_native_worktree_identity_schema(&predecessor)?;
18049 predecessor.execute_batch(
18050 "DROP TABLE project_root_identity;
18051 DROP TABLE IF EXISTS graph_identity_rejections;
18052 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18053 )?;
18054 drop(predecessor);
18055
18056 let expected_root = canonical_project_root(&root)?;
18057 let expected_database = ProjectAtlasMcpServer::projectatlas_db_path(&expected_root);
18058 let canonical = ProjectAtlasMcpServer::project_state_from_nearest_indexed_path(&source)?
18059 .ok_or_else(|| io::Error::other("canonical nearest predecessor was not found"))?;
18060 let lexical =
18061 ProjectAtlasMcpServer::project_state_from_nearest_lexical_indexed_path(&source)?
18062 .ok_or_else(|| io::Error::other("lexical nearest predecessor was not found"))?;
18063 for state in [canonical, lexical] {
18064 require(
18065 state.root == expected_root && state.db_path == expected_database,
18066 "nearest predecessor routing changed the canonical root or database path",
18067 )?;
18068 }
18069 Ok(())
18070 }
18071
18072 #[cfg(windows)]
18073 #[test]
18074 fn nearest_routing_recanonicalizes_case_only_root_rename()
18075 -> Result<(), Box<dyn std::error::Error>> {
18076 let temp = tempfile::tempdir()?;
18077 let original = temp.path().join("NearestCaseRoot");
18078 let staging = temp.path().join("NearestCaseRootStaging");
18079 let renamed = temp.path().join("nearestcaseroot");
18080 let source = original.join("src").join("lib.rs");
18081 fs::create_dir_all(
18082 source
18083 .parent()
18084 .ok_or_else(|| io::Error::other("case-only nearest source has no parent"))?,
18085 )?;
18086 let database = original.join(".projectatlas").join("projectatlas.db");
18087 fs::create_dir_all(
18088 database
18089 .parent()
18090 .ok_or_else(|| io::Error::other("case-only nearest database has no parent"))?,
18091 )?;
18092 fs::write(&source, "pub fn nearest_case_only() {}\n")?;
18093 drop(open_atlas_store_for_project(&database, &original)?);
18094
18095 fs::rename(&original, &staging)?;
18096 fs::rename(&staging, &renamed)?;
18097 let renamed_source = renamed.join("src").join("lib.rs");
18098 let expected_root = canonical_project_root(&renamed)?;
18099 let expected_database = ProjectAtlasMcpServer::projectatlas_db_path(&expected_root);
18100 let canonical =
18101 ProjectAtlasMcpServer::project_state_from_nearest_indexed_path(&renamed_source)?
18102 .ok_or_else(|| {
18103 io::Error::other("canonical nearest case-only root was not found")
18104 })?;
18105 let lexical = ProjectAtlasMcpServer::project_state_from_nearest_lexical_indexed_path(
18106 &renamed_source,
18107 )?
18108 .ok_or_else(|| io::Error::other("lexical nearest case-only root was not found"))?;
18109 for state in [canonical, lexical] {
18110 require(
18111 state.root == expected_root && state.db_path == expected_database,
18112 "nearest routing rejected a case-only root rename",
18113 )?;
18114 }
18115 Ok(())
18116 }
18117
18118 #[cfg(windows)]
18119 #[test]
18120 fn nearest_routing_rejects_case_sensitive_sibling() -> Result<(), Box<dyn std::error::Error>> {
18121 let temp = tempfile::tempdir()?;
18122 let parent = temp.path().join("nearest-case-sensitive-parent");
18123 fs::create_dir(&parent)?;
18124 let enabled = StdCommand::new("fsutil")
18125 .args(["file", "SetCaseSensitiveInfo"])
18126 .arg(&parent)
18127 .arg("enable")
18128 .status()
18129 .is_ok_and(|status| status.success());
18130 if !enabled {
18131 return Ok(());
18132 }
18133
18134 let stored_root = parent.join("Repo");
18135 let selected_root = parent.join("repo");
18136 fs::create_dir(&stored_root)?;
18137 fs::create_dir(&selected_root)?;
18138 let stored_database = stored_root.join(".projectatlas").join("projectatlas.db");
18139 let selected_database = selected_root.join(".projectatlas").join("projectatlas.db");
18140 fs::create_dir_all(
18141 stored_database
18142 .parent()
18143 .ok_or_else(|| io::Error::other("stored nearest database has no parent"))?,
18144 )?;
18145 fs::create_dir_all(
18146 selected_database
18147 .parent()
18148 .ok_or_else(|| io::Error::other("selected nearest database has no parent"))?,
18149 )?;
18150 drop(open_atlas_store_for_project(
18151 &stored_database,
18152 &stored_root,
18153 )?);
18154 fs::copy(&stored_database, &selected_database)?;
18155
18156 require(
18157 ProjectAtlasMcpServer::indexed_root_from_candidate(&selected_root).is_none()
18158 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(&selected_root)
18159 .is_none(),
18160 "nearest routing accepted a distinct case-sensitive sibling",
18161 )?;
18162 Ok(())
18163 }
18164
18165 #[cfg(unix)]
18166 #[test]
18167 fn mcp_init_rejects_ambiguous_predecessor_before_project_writes()
18168 -> Result<(), Box<dyn std::error::Error>> {
18169 use std::ffi::OsString;
18170 use std::os::unix::ffi::OsStringExt;
18171
18172 let temp = tempfile::tempdir()?;
18173 let raw_root = temp
18174 .path()
18175 .join(OsString::from_vec(b"mcp-raw-root-\x80".to_vec()));
18176 let replacement_root = PathBuf::from(raw_root.to_string_lossy().into_owned());
18177 let database = raw_root
18178 .join(PROJECTATLAS_DIR_NAME)
18179 .join(PROJECTATLAS_DB_FILE_NAME);
18180 fs::create_dir_all(&raw_root)?;
18181 fs::create_dir_all(
18182 database
18183 .parent()
18184 .ok_or_else(|| io::Error::other("predecessor database has no parent"))?,
18185 )?;
18186 let store = AtlasStore::open_for_project(&database, &raw_root)?;
18187 drop(store);
18188 let predecessor = rusqlite::Connection::open(&database)?;
18189 drop_native_worktree_identity_schema(&predecessor)?;
18190 predecessor.execute_batch(
18191 "DROP TABLE project_root_identity;
18192 DROP TABLE IF EXISTS graph_identity_rejections;
18193 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18194 )?;
18195 predecessor.execute(
18196 "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
18197 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
18198 [&replacement_root.to_string_lossy().into_owned()],
18199 )?;
18200 drop(predecessor);
18201 fs::create_dir_all(&replacement_root)?;
18202
18203 require(
18208 default_mcp_project_root(&database, None).is_err(),
18209 "ambiguous predecessor was selected during startup discovery",
18210 )?;
18211 let database_before = fs::read(&database)?;
18212 let sidecars_before = ["wal", "shm", "journal"]
18213 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
18214 let raw_project_dir = raw_root.join(PROJECTATLAS_DIR_NAME);
18215 let raw_config = raw_project_dir.join(PROJECTATLAS_CONFIG_FILE_NAME);
18216 let raw_nonsource = raw_project_dir.join(MCP_NONSOURCE_FILE_NAME);
18217 let replacement_project_dir = replacement_root.join(PROJECTATLAS_DIR_NAME);
18218 let replacement_config = replacement_project_dir.join(PROJECTATLAS_CONFIG_FILE_NAME);
18219 let replacement_nonsource = replacement_project_dir.join(MCP_NONSOURCE_FILE_NAME);
18220 let server = ProjectAtlasMcpServer::new(
18221 database.clone(),
18222 None,
18223 "ambiguous-predecessor".to_string(),
18224 false,
18225 );
18226 let startup = ProjectAtlasMcpServer::startup_project_state(database.clone(), None);
18227 require(
18228 startup.root == canonical_project_root(&raw_root)?,
18229 "MCP startup did not select the raw native predecessor root",
18230 )?;
18231 require(
18232 startup.root != canonical_project_root(&replacement_root)?,
18233 "MCP startup selected the replacement-character candidate",
18234 )?;
18235 let result = server.atlas_init(Parameters(AtlasInitParams {
18236 project_path: None,
18237 worktree: None,
18238 no_scan: Some(true),
18239 force_rescan: Some(false),
18240 text_index_max_bytes: None,
18241 }));
18242 require(
18243 result.contains("project-root identity"),
18244 &format!("ambiguous predecessor init returned an unexpected result: {result}"),
18245 )?;
18246 require(
18247 fs::read(&database)? == database_before
18248 && ["wal", "shm", "journal"]
18249 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
18250 == sidecars_before,
18251 "ambiguous predecessor init changed database or sidecar state",
18252 )?;
18253 require(
18254 !replacement_project_dir.exists()
18255 && !replacement_config.exists()
18256 && !replacement_nonsource.exists(),
18257 "ambiguous predecessor init wrote replacement-root project state",
18258 )?;
18259 require(
18260 !raw_config.exists() && !raw_nonsource.exists(),
18261 "ambiguous predecessor init wrote raw-root project state",
18262 )?;
18263 Ok(())
18264 }
18265
18266 #[test]
18267 fn mcp_init_rejects_current_wrong_root_before_project_writes()
18268 -> Result<(), Box<dyn std::error::Error>> {
18269 let temp = tempfile::tempdir()?;
18270 let selected_root = temp.path().join("selected-root");
18271 let bound_root = temp.path().join("bound-root");
18272 let database = temp.path().join("external-projectatlas.db");
18273 let config_path = selected_root.join("external-config/config.toml");
18274 fs::create_dir_all(&selected_root)?;
18275 fs::create_dir_all(&bound_root)?;
18276 let persisted_identity = {
18277 let store = AtlasStore::open_for_project(&database, &bound_root)?;
18278 store.project_root_identity()?
18279 };
18280 drop(AtlasStore::open_read_only_for_project(
18281 &database,
18282 &bound_root,
18283 )?);
18284 let database_before = fs::read(&database)?;
18285 let sidecars_before = ["wal", "shm", "journal"]
18286 .map(|suffix| fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok());
18287 let selected_project_dir = selected_root.join(PROJECTATLAS_DIR_NAME);
18288 let config_parent = config_path
18289 .parent()
18290 .ok_or_else(|| io::Error::other("selected config has no parent"))?;
18291 let server = ProjectAtlasMcpServer::new(
18292 database.clone(),
18293 Some(config_path.clone()),
18294 "current-wrong-root".to_string(),
18295 false,
18296 );
18297 *server
18298 .project_state
18299 .write()
18300 .map_err(|_poisoned| io::Error::other("MCP project state lock poisoned"))? =
18301 McpProjectState {
18302 root: selected_root,
18303 db_path: database.clone(),
18304 config_path: Some(config_path.clone()),
18305 worktree: None,
18306 };
18307
18308 let result = server.atlas_init(Parameters(AtlasInitParams {
18309 project_path: None,
18310 worktree: None,
18311 no_scan: Some(true),
18312 force_rescan: Some(false),
18313 text_index_max_bytes: None,
18314 }));
18315 require(
18316 result.contains("does not match selected root"),
18317 &format!("current wrong-root MCP init returned an unexpected result: {result}"),
18318 )?;
18319 require(
18320 !selected_project_dir.exists() && !config_parent.exists() && !config_path.exists(),
18321 "current wrong-root MCP init created selected project or config state",
18322 )?;
18323 require(
18324 fs::read(&database)? == database_before
18325 && ["wal", "shm", "journal"].map(|suffix| {
18326 fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok()
18327 }) == sidecars_before,
18328 "current wrong-root MCP init changed database or sidecar state",
18329 )?;
18330 let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
18331 require(
18332 reopened.project_root_identity()? == persisted_identity,
18333 "current binding changed after wrong-root MCP init",
18334 )?;
18335 Ok(())
18336 }
18337
18338 #[cfg(windows)]
18339 #[test]
18340 fn mcp_init_rejects_predecessor_wrong_root_before_project_writes()
18341 -> Result<(), Box<dyn std::error::Error>> {
18342 let temp = tempfile::tempdir()?;
18343 let selected_root = temp.path().join("selected-predecessor-root");
18344 let bound_root = temp.path().join("bound-predecessor-root");
18345 let database = temp.path().join("external-predecessor.db");
18346 let config_path = selected_root.join("external-config/config.toml");
18347 fs::create_dir_all(&selected_root)?;
18348 fs::create_dir_all(&bound_root)?;
18349 drop(AtlasStore::open_for_project(&database, &bound_root)?);
18350 {
18351 let connection = rusqlite::Connection::open(&database)?;
18352 drop_native_worktree_identity_schema(&connection)?;
18353 connection.execute_batch(
18354 "DROP TABLE project_root_identity;
18355 DROP TABLE IF EXISTS graph_identity_rejections;
18356 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18357 )?;
18358 }
18359 read_legacy_project_root_candidate_read_only(&database)?;
18360 let database_before = fs::read(&database)?;
18361 let sidecars_before = ["wal", "shm", "journal"]
18362 .map(|suffix| fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok());
18363 let selected_project_dir = selected_root.join(PROJECTATLAS_DIR_NAME);
18364 let config_parent = config_path
18365 .parent()
18366 .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
18367 let server = ProjectAtlasMcpServer::new(
18368 database.clone(),
18369 Some(config_path.clone()),
18370 "predecessor-wrong-root".to_string(),
18371 false,
18372 );
18373 *server
18374 .project_state
18375 .write()
18376 .map_err(|_poisoned| io::Error::other("MCP project state lock poisoned"))? =
18377 McpProjectState {
18378 root: selected_root,
18379 db_path: database.clone(),
18380 config_path: Some(config_path.clone()),
18381 worktree: None,
18382 };
18383
18384 let result = server.atlas_init(Parameters(AtlasInitParams {
18385 project_path: None,
18386 worktree: None,
18387 no_scan: Some(true),
18388 force_rescan: Some(false),
18389 text_index_max_bytes: None,
18390 }));
18391 require(
18392 result.contains("does not match selected root"),
18393 &format!("predecessor wrong-root MCP init returned an unexpected result: {result}"),
18394 )?;
18395 require(
18396 !selected_project_dir.exists() && !config_parent.exists() && !config_path.exists(),
18397 "predecessor wrong-root MCP init created selected project or config state",
18398 )?;
18399 require(
18400 fs::read(&database)? == database_before
18401 && ["wal", "shm", "journal"].map(|suffix| {
18402 fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok()
18403 }) == sidecars_before,
18404 "predecessor wrong-root MCP init changed database or sidecar state",
18405 )?;
18406 Ok(())
18407 }
18408
18409 #[cfg(unix)]
18410 #[test]
18411 fn nearest_root_rejects_ambiguous_predecessor_without_mutation()
18412 -> Result<(), Box<dyn std::error::Error>> {
18413 fn sidecar_bytes(database: &Path) -> [Option<Vec<u8>>; 3] {
18414 ["wal", "shm", "journal"].map(|suffix| fs::read(db_sidecar_path(database, suffix)).ok())
18415 }
18416
18417 fn directory_inventory(path: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
18418 let mut names = fs::read_dir(path)?
18419 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
18420 .collect::<Result<Vec<_>, _>>()?;
18421 names.sort();
18422 Ok(names)
18423 }
18424
18425 fn snapshot(
18426 database: &Path,
18427 ) -> Result<(Vec<u8>, [Option<Vec<u8>>; 3], Vec<String>), Box<dyn std::error::Error>>
18428 {
18429 let parent = database
18430 .parent()
18431 .ok_or_else(|| io::Error::other("predecessor database has no parent"))?;
18432 Ok((
18433 fs::read(database)?,
18434 sidecar_bytes(database),
18435 directory_inventory(parent)?,
18436 ))
18437 }
18438
18439 let temp = tempfile::tempdir()?;
18440 let raw_root = temp
18441 .path()
18442 .join(std::ffi::OsString::from_vec(b"nearest-raw-\x80".to_vec()));
18443 let replacement_root = temp.path().join("nearest-raw-�");
18444 let raw_database = raw_root.join(".projectatlas").join("projectatlas.db");
18445 let replacement_database = replacement_root
18446 .join(".projectatlas")
18447 .join("projectatlas.db");
18448 fs::create_dir_all(
18449 raw_database
18450 .parent()
18451 .ok_or_else(|| io::Error::other("raw predecessor database has no parent"))?,
18452 )?;
18453 fs::create_dir_all(
18454 replacement_database.parent().ok_or_else(|| {
18455 io::Error::other("replacement predecessor database has no parent")
18456 })?,
18457 )?;
18458 let store = open_atlas_store_for_project(&raw_database, &raw_root)?;
18459 drop(store);
18460 let predecessor = rusqlite::Connection::open(&raw_database)?;
18461 drop_native_worktree_identity_schema(&predecessor)?;
18462 predecessor.execute_batch(
18463 "DROP TABLE project_root_identity;
18464 DROP TABLE IF EXISTS graph_identity_rejections;
18465 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18466 )?;
18467 drop(predecessor);
18468 fs::copy(&raw_database, &replacement_database)?;
18469
18470 for database in [&raw_database, &replacement_database] {
18473 require(
18474 matches!(
18475 read_legacy_project_root_candidate_read_only(database),
18476 Err(DbError::ProjectRootIdentityMissing)
18477 ),
18478 "ambiguous predecessor candidate was exposed during warm-up",
18479 )?;
18480 }
18481
18482 for (root, database) in [
18483 (&raw_root, &raw_database),
18484 (&replacement_root, &replacement_database),
18485 ] {
18486 let before = snapshot(database)?;
18487 require(
18488 ProjectAtlasMcpServer::indexed_root_from_candidate(root).is_none()
18489 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(root).is_none(),
18490 "ambiguous predecessor was admitted by nearest routing",
18491 )?;
18492 require(
18493 snapshot(database)? == before,
18494 "ambiguous predecessor detection changed database or sidecar state",
18495 )?;
18496 }
18497
18498 let collision_raw_root = temp
18499 .path()
18500 .join(std::ffi::OsString::from_vec(b"nearest-repo\\name".to_vec()));
18501 let collision_slash_root = temp.path().join("nearest-repo").join("name");
18502 let collision_raw_database = collision_raw_root
18503 .join(PROJECTATLAS_DIR_NAME)
18504 .join(PROJECTATLAS_DB_FILE_NAME);
18505 let collision_slash_database = collision_slash_root
18506 .join(PROJECTATLAS_DIR_NAME)
18507 .join(PROJECTATLAS_DB_FILE_NAME);
18508 fs::create_dir_all(&collision_raw_root)?;
18509 fs::create_dir_all(
18510 collision_slash_database
18511 .parent()
18512 .ok_or_else(|| io::Error::other("slash collision database has no parent"))?,
18513 )?;
18514 drop(open_atlas_store_for_project(
18515 &collision_raw_database,
18516 &collision_raw_root,
18517 )?);
18518 let collision_predecessor = rusqlite::Connection::open(&collision_raw_database)?;
18519 collision_predecessor.execute(
18520 "UPDATE metadata SET value = ?1 WHERE key = 'project_root'",
18521 [collision_slash_root.to_string_lossy().into_owned()],
18522 )?;
18523 drop_native_worktree_identity_schema(&collision_predecessor)?;
18524 collision_predecessor.execute_batch(
18525 "DROP TABLE project_root_identity;
18526 DROP TABLE IF EXISTS graph_identity_rejections;
18527 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18528 )?;
18529 drop(collision_predecessor);
18530 fs::copy(&collision_raw_database, &collision_slash_database)?;
18531 for database in [&collision_raw_database, &collision_slash_database] {
18532 require(
18533 matches!(
18534 read_legacy_project_root_candidate_read_only(database),
18535 Err(DbError::ProjectRootIdentityMissing)
18536 ),
18537 "slash-colliding predecessor candidate was exposed during warm-up",
18538 )?;
18539 }
18540 for (root, database) in [
18541 (&collision_raw_root, &collision_raw_database),
18542 (&collision_slash_root, &collision_slash_database),
18543 ] {
18544 let before = snapshot(database)?;
18545 require(
18546 ProjectAtlasMcpServer::indexed_root_from_candidate(root).is_none()
18547 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(root).is_none(),
18548 "slash-colliding predecessor was admitted by nearest routing",
18549 )?;
18550 require(
18551 snapshot(database)? == before,
18552 "slash-colliding predecessor detection changed database or sidecar state",
18553 )?;
18554 }
18555 Ok(())
18556 }
18557}