1mod task_registry;
5
6use task_registry::{
7 McpTaskOperation, McpTaskProgress, McpTaskRecord, McpTaskRegistry, McpTaskState,
8};
9
10use crate::atlas_map::{
11 AtlasMapConfig, IgnoreEntryKind, LintOptions, add_ignore_entry, effective_config_report,
12 init_gitignore, init_project_with_config, list_ignore_entries, load_atlas_config,
13 load_atlas_config_for_root, remove_ignore_entry, write_map,
14};
15use crate::runtime::{
16 DEFAULT_HEALTH_LIMIT, INDEX_WORKER_SAFE_CEILING, IndexInitRequired, IndexProjectMismatch,
17 IndexRefreshRequired, IndexVerificationIncomplete, InitBootstrapOptions, InitHydrationPhase,
18 InitHydrationStatus, InitPhaseStatus, InitScanPhase, InitSetupReport, MAX_HEALTH_LIMIT,
19 MAX_SYMBOL_FILE_BYTES, ProjectWorktreeRequired, PurposeCuratorHandoff, PurposeLintLevel,
20 PurposeReviewRequest, ResetIndexReport, ScanReport, ScanRuntimePlan,
21 SettingsClassifiedNavigationReport, SourceObservationRegistry, SymbolBuildOptions,
22 UsageRuntimeInstance, VerifiedReadOutcome, VerifiedReadStamp, build_settings_report,
23 byte_count_to_tokens, canonical_project_root, canonical_source_project_root,
24 classified_navigation_capabilities, classified_ranked_file_nodes_with_reasons,
25 config_root_mismatch_error, default_mcp_project_root,
26 estimated_source_tokens_for_indexed_files, estimated_source_tokens_for_paths,
27 federated_worktree_error, index_init_required, index_work_control, init_config_path,
28 init_next_steps, lint_project, load_synchronized_repository_token_report,
29 lossless_native_path_display, lossless_project_root_display, next_step_report_payload,
30 next_step_report_with_selection, normalized_folder_filter, open_atlas_store_for_project,
31 open_atlas_store_read_only_for_project, open_federated_atlas_stores_for_project,
32 preflight_existing_project_binding, purpose_curation_page, purpose_curator_handoff,
33 ranked_file_nodes_with_reasons, ranked_folder_nodes_with_reasons, read_indexed_file_content,
34 reconcile_hydrated_index_controlled, record_directory_walk_usage_estimate,
35 record_usage_estimate, record_usage_text, render_classified_ranked_file_rows,
36 render_classified_symbol_rows, render_health_page, render_purpose_curation_page,
37 render_purpose_review_report, require_current_worktree_usage_snapshot,
38 require_registered_worktree_lifecycle, reset_index_files, reset_index_files_with_revalidation,
39 review_purposes, run_init_bootstrap, run_scan_pipeline_controlled,
40 run_single_watch_refresh_controlled, run_symbol_build_pipeline_controlled,
41 strip_legacy_purpose, telemetry_disabled, validate_purpose_review_admission,
42 validated_indexed_file_key, watcher_status_report,
43};
44#[cfg(all(test, unix))]
45use crate::runtime::{IndexReadStatus, IndexRefreshReason, IndexRefreshScope};
46#[cfg(test)]
47use crate::runtime::{
48 PURPOSE_CURATOR_RECOMMENDED_REASONING, db_sidecar_path, mcp_config_path_for_db,
49 run_scan_pipeline, synchronize_registered_worktree_usage,
50};
51use crate::token_tui::{
52 TokenDashboardTheme, render_token_dashboard_plain_with_theme,
53 render_token_trend_dashboard_plain_with_theme,
54};
55use crate::{
56 AgentErrorKind, CliError, DEFAULT_FILE_SUMMARY_LIMIT, DatabaseFilesystemErrorPayload,
57 HarnessConfig, OutputFormat, RootTransition, RuntimeInfoReport, SchemaMigrationRequiredPayload,
58 SchemaVersionMismatchPayload, SearchRetrievalModeArg, build_harness_mcp_config_report,
59 build_parity_report, build_repository_control_report, build_root_report, build_runtime_info,
60 controlled_named_output, database_filesystem_error_payload, finalize_coverage_output,
61 render_code_slice, render_file_summary, render_parity_report, render_repository_control_report,
62 render_root_report, render_runtime_info, render_search_report, render_watch_status,
63 schema_migration_required_payload, schema_version_mismatch_payload,
64};
65use projectatlas_core::graph::{
66 Completeness, ConfidenceClass, CoverageRecord, DocumentTargetUnresolvedReason, EntitySelector,
67 ExternalSelector, GraphIdentityText, GraphLimitKind, GraphLimits, GraphRelationKind,
68 ProjectInstanceId, RelationOccurrence, RelationResolution, RepositoryFilePath,
69 ReusableTargetSelector, SourceSpan,
70};
71use projectatlas_core::health::Severity;
72use projectatlas_core::language::{ContentClassification, ContentSelection};
73use projectatlas_core::outline::build_outline;
74use projectatlas_core::symbols::ParserKind;
75use projectatlas_core::telemetry::{
76 TOKEN_BASELINE_DIRECTORY_WALK, TOKEN_BASELINE_SELECTED_CANDIDATES,
77 TOKEN_BUCKET_NAVIGATION_AVOIDANCE, TOKEN_CONFIDENCE_INFERRED, TOKEN_CONFIDENCE_POLICY_ESTIMATE,
78 TokenTrendWindow, UsageInstanceOwner, usage_from_estimates_with_context, usage_from_text,
79};
80use projectatlas_core::toon::{
81 encode_agent_payload, render_outline, render_overview, render_ranked_nodes,
82 render_symbol_relations, render_token_overview, render_token_trends,
83};
84use projectatlas_core::{
85 CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
86 MAX_GIT_WORKTREE_REGISTRATIONS, NavigationNextCall, NavigationNextCapability, Overview,
87 PurposeSource, PurposeStatus, RankedConnection, RankedConnectionCount, RankedConnectionKind,
88 RankedConnectionTarget, RankedNode, RankedReasonCode, normalize_native_path_display,
89 normalize_native_path_display_str, normalize_repo_path, normalize_repo_path_prefix,
90 validated_repo_file_key, validated_repo_node_key,
91};
92use projectatlas_db::{
93 ActiveWorktreeRegistrationGuard, AtlasStore, DbError, HealthQuery, HealthResolution,
94 HealthScope, PreparedWorktreeHydrationCandidate, RepositoryCoverageQuery, WorktreeAlias,
95 WorktreeHydrationActivation, WorktreeRegistration, WorktreeRegistrationState,
96 WorktreeUsageSnapshot, WorktreeUsageSyncState, read_legacy_project_root_candidate_read_only,
97 read_project_root_identity_read_only, verify_project_database,
98};
99use projectatlas_fs::worktree::{
100 GitRepositoryStructure, GitWorktreeEntry, GitWorktreeRole, GitWorktreeState,
101 RepositoryStructure, discover_repository_structure, git_administrative_identity,
102};
103#[cfg(test)]
104use projectatlas_service::build_file_summary_from_source;
105use projectatlas_service::{
106 COVERAGE_PAGE_MAX_LIMIT, CodeSliceBudget, CoverageDigest, CoverageTrustState,
107 DetailedRelationBudget, DetailedRelationNode, DetailedRelationQuery, DetailedRelationReport,
108 DetailedRelationRow, DetailedRelationWork, EntrypointProfile, FederatedDetailedRelationReport,
109 FederatedParticipant, FederatedRelationWork, FederatedRendezvous, FederatedStore,
110 FileCallSummary, FileSummaryReport, FileSymbolSummary, GitImpactSelection,
111 RelationAnalysisMode, RelationAnalysisQuery, RelationAnchor, RelationDirection,
112 RelationNextCall, RelationPurpose, RelationTotalState, SearchQuery, ServiceError,
113 SymbolSliceSelector, TokenReport, TokenReportRequest,
114 build_file_summary_from_source_with_selection, load_coverage_discovery_controlled,
115 load_detailed_relation_page, load_federated_detailed_relations,
116 load_federated_relation_analysis, load_relation_analysis, load_token_report,
117 parse_coverage_parser, parse_coverage_relation, parse_coverage_state,
118 parse_relation_confidence, parse_relation_direction, parse_relation_resolution,
119 parse_symbol_kind, read_indexed_code_slice_from_source_bounded_with_selection,
120 read_symbol_slice_from_source_bounded_with_selection, search_indexed_files_with_control,
121 validate_federated_root_count,
122};
123use rmcp::handler::server::{
124 router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters,
125};
126use rmcp::model::{CallToolResponse, Implementation, ServerCapabilities, ServerInfo};
127use rmcp::schemars;
128use rmcp::service::RequestContext;
129use rmcp::{RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
130use serde::{Deserialize, Serialize};
131use std::collections::{HashSet, VecDeque};
132use std::fs;
133use std::path::{Component, Path, PathBuf};
134use std::sync::{
135 Arc, Mutex, RwLock,
136 atomic::{AtomicU64, Ordering},
137};
138use std::thread;
139use std::time::{Duration, SystemTime, UNIX_EPOCH};
140
141#[derive(Debug, PartialEq, Eq)]
143struct McpToolTextResult(Result<String, String>);
144
145impl std::ops::Deref for McpToolTextResult {
146 type Target = str;
147
148 fn deref(&self) -> &Self::Target {
149 match &self.0 {
150 Ok(text) | Err(text) => text,
151 }
152 }
153}
154
155impl std::fmt::Display for McpToolTextResult {
156 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 formatter.write_str(self)
158 }
159}
160
161impl IntoCallToolResult for McpToolTextResult {
162 fn into_call_tool_result(self) -> Result<CallToolResponse, rmcp::ErrorData> {
163 self.0.into_call_tool_result()
164 }
165}
166
167pub(crate) const REQUIRED_MCP_TOOL_NAMES: &[&str] = &[
169 MCP_TOOL_ATLAS_SET_PROJECT_PATH,
170 MCP_TOOL_ATLAS_WORKTREE_LIST,
171 MCP_TOOL_ATLAS_WORKTREE_ADD,
172 MCP_TOOL_ATLAS_WORKTREE_REMOVE,
173 MCP_TOOL_ATLAS_INIT,
174 MCP_TOOL_ATLAS_MAP,
175 MCP_TOOL_ATLAS_ROOT,
176 MCP_TOOL_ATLAS_ROOT_SET,
177 MCP_TOOL_ATLAS_CONFIG,
178 MCP_TOOL_ATLAS_IGNORE_LIST,
179 MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE,
180 MCP_TOOL_ATLAS_IGNORE_ADD,
181 MCP_TOOL_ATLAS_IGNORE_REMOVE,
182 MCP_TOOL_ATLAS_SCAN,
183 MCP_TOOL_ATLAS_OVERVIEW,
184 MCP_TOOL_ATLAS_FOLDERS,
185 MCP_TOOL_ATLAS_FILES,
186 MCP_TOOL_ATLAS_NEXT,
187 MCP_TOOL_ATLAS_OUTLINE,
188 MCP_TOOL_ATLAS_FILE_SUMMARY,
189 MCP_TOOL_ATLAS_SEARCH,
190 MCP_TOOL_ATLAS_SLICE,
191 MCP_TOOL_ATLAS_SYMBOLS_BUILD,
192 MCP_TOOL_ATLAS_SYMBOLS,
193 MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
194 MCP_TOOL_ATLAS_HEALTH,
195 MCP_TOOL_ATLAS_HEALTH_RESOLVE,
196 MCP_TOOL_ATLAS_LINT,
197 MCP_TOOL_ATLAS_TOKEN_REPORT,
198 MCP_TOOL_ATLAS_PARITY_REPORT,
199 MCP_TOOL_ATLAS_SETTINGS,
200 MCP_TOOL_ATLAS_WATCH_STATUS,
201 MCP_TOOL_ATLAS_WATCH_ONCE,
202 MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE,
203 MCP_TOOL_ATLAS_RESET_INDEX,
204 MCP_TOOL_ATLAS_MCP_CONFIG,
205 MCP_TOOL_ATLAS_RUNTIME_INFO,
206 MCP_TOOL_ATLAS_SESSION_BRIEF,
207 MCP_TOOL_ATLAS_TASK_STATUS,
208 MCP_TOOL_ATLAS_TASK_CANCEL,
209 MCP_TOOL_ATLAS_PURPOSE_QUEUE,
210 MCP_TOOL_ATLAS_PURPOSE_SET,
211 MCP_TOOL_ATLAS_PURPOSE_REVIEW,
212];
213
214const MCP_TOOL_ATLAS_SET_PROJECT_PATH: &str = "atlas_set_project_path";
216const MCP_TOOL_ATLAS_WORKTREE_LIST: &str = "atlas_worktree_list";
218const MCP_TOOL_ATLAS_WORKTREE_ADD: &str = "atlas_worktree_add";
220const MCP_TOOL_ATLAS_WORKTREE_REMOVE: &str = "atlas_worktree_remove";
222const MCP_TOOL_ATLAS_INIT: &str = "atlas_init";
224const MCP_TOOL_ATLAS_MAP: &str = "atlas_map";
226const MCP_TOOL_ATLAS_ROOT: &str = "atlas_root";
228const MCP_TOOL_ATLAS_ROOT_SET: &str = "atlas_root_set";
230const MCP_TOOL_ATLAS_CONFIG: &str = "atlas_config";
232const MCP_TOOL_ATLAS_IGNORE_LIST: &str = "atlas_ignore_list";
234const MCP_TOOL_ATLAS_IGNORE_INIT_GITIGNORE: &str = "atlas_ignore_init_gitignore";
236const MCP_TOOL_ATLAS_IGNORE_ADD: &str = "atlas_ignore_add";
238const MCP_TOOL_ATLAS_IGNORE_REMOVE: &str = "atlas_ignore_remove";
240const MCP_TOOL_ATLAS_SCAN: &str = "atlas_scan";
242const MCP_TOOL_ATLAS_OVERVIEW: &str = "atlas_overview";
244const MCP_TOOL_ATLAS_FOLDERS: &str = "atlas_folders";
246const MCP_TOOL_ATLAS_FILES: &str = "atlas_files";
248const MCP_TOOL_ATLAS_NEXT: &str = "atlas_next";
250const MCP_TOOL_ATLAS_OUTLINE: &str = "atlas_outline";
252const MCP_TOOL_ATLAS_FILE_SUMMARY: &str = "atlas_file_summary";
254const MCP_TOOL_ATLAS_SEARCH: &str = "atlas_search";
256const MCP_TOOL_ATLAS_SLICE: &str = "atlas_slice";
258const MCP_TOOL_ATLAS_SYMBOLS_BUILD: &str = "atlas_symbols_build";
260const MCP_TOOL_ATLAS_SYMBOLS: &str = "atlas_symbols";
262const MCP_TOOL_ATLAS_SYMBOL_RELATIONS: &str = "atlas_symbol_relations";
264const MCP_TOOL_ATLAS_HEALTH: &str = "atlas_health";
266const MCP_TOOL_ATLAS_HEALTH_RESOLVE: &str = "atlas_health_resolve";
268const MCP_TOOL_ATLAS_LINT: &str = "atlas_lint";
270const MCP_TOOL_ATLAS_TOKEN_REPORT: &str = "atlas_token_report";
272const MCP_TOOL_ATLAS_PARITY_REPORT: &str = "atlas_parity_report";
274const MCP_TOOL_ATLAS_SETTINGS: &str = "atlas_settings";
276const MCP_TOOL_ATLAS_WATCH_STATUS: &str = "atlas_watch_status";
278const MCP_TOOL_ATLAS_WATCH_ONCE: &str = "atlas_watch_once";
280const MCP_TOOL_ATLAS_STRIP_LEGACY_PURPOSE: &str = "atlas_strip_legacy_purpose";
282const MCP_TOOL_ATLAS_RESET_INDEX: &str = "atlas_reset_index";
284const MCP_TOOL_ATLAS_MCP_CONFIG: &str = "atlas_mcp_config";
286const MCP_TOOL_ATLAS_RUNTIME_INFO: &str = "atlas_runtime_info";
288const MCP_TOOL_ATLAS_SESSION_BRIEF: &str = "atlas_session_brief";
290const MCP_TOOL_ATLAS_TASK_STATUS: &str = "atlas_task_status";
292const MCP_TOOL_ATLAS_TASK_CANCEL: &str = "atlas_task_cancel";
294const MCP_TOOL_ATLAS_PURPOSE_QUEUE: &str = "atlas_purpose_queue";
296const MCP_TOOL_ATLAS_PURPOSE_SET: &str = "atlas_purpose_set";
298const MCP_TOOL_ATLAS_PURPOSE_REVIEW: &str = "atlas_purpose_review";
300const PROJECTATLAS_DIR_NAME: &str = ".projectatlas";
302const PROJECTATLAS_DB_FILE_NAME: &str = "projectatlas.db";
304const PROJECTATLAS_CONFIG_FILE_NAME: &str = "config.toml";
306const PROJECTATLAS_FLAT_CONFIG_FILE_NAME: &str = "projectatlas.toml";
308const MCP_TELEMETRY_PROJECT_BINDING_LIMIT: usize = 64;
310const MCP_SETTINGS_RESPONSE_MAX_BYTES: usize = 64_000;
312const MCP_SETTINGS_RESPONSE_LIMIT_PREFIX: &str = "settings response requires ";
314const MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR: &str = " bytes, exceeding the ";
316const MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX: &str = "-byte diagnostic limit";
318const MCP_SOURCE_TOKEN_BASELINE_LIMIT: usize = 128;
320const MCP_DEFAULT_CONFIG_SERVER_NAME: &str = "projectatlas";
322const 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";
324const 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";
326const CURRENT_DIR_ALIAS: &str = ".";
328const MCP_SERVER_NAME: &str = "ProjectAtlas";
330const MCP_CANCELLATION_MONITOR_THREAD_NAME: &str = "projectatlas-mcp-cancel";
332const MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX: &str =
334 "MCP request cancellation monitor could not start: ";
335const MCP_ERROR_REGISTERED_WORKTREE_ROOT_INVALID_PREFIX: &str =
337 "registered worktree root is invalid: ";
338const MCP_PROJECT_STATE_LOCK_POISONED: &str = "MCP project state lock poisoned";
340const MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX: &str = "error: ";
342const MCP_PAYLOAD_SCAN: &str = "scan";
344const MCP_PAYLOAD_INIT: &str = "init";
346const MCP_PAYLOAD_WORKTREES: &str = "worktrees";
348const MCP_PAYLOAD_WORKTREE: &str = "worktree";
350const MCP_PAYLOAD_MAP: &str = "map";
352const MCP_PAYLOAD_CONFIG: &str = "config";
354const MCP_PAYLOAD_IGNORE: &str = "ignore";
356const MCP_PAYLOAD_GITIGNORE: &str = "gitignore";
358const MCP_PAYLOAD_LINT: &str = "lint";
360const MCP_PAYLOAD_SYMBOLS_BUILD: &str = "symbols_build";
362const MCP_PAYLOAD_SYMBOL_RELATIONS: &str = "symbol_relations";
364const MCP_PAYLOAD_HEALTH_RESOLUTION: &str = "health_resolution";
366const MCP_PAYLOAD_TOKEN_TRENDS: &str = "token_trends";
368const MCP_PAYLOAD_TOKEN_SAVINGS: &str = "token_savings";
370const MCP_PAYLOAD_CHART: &str = "chart";
372const MCP_PAYLOAD_WATCH: &str = "watch";
374const MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION: &str = "legacy_purpose_migration";
376const MCP_PAYLOAD_RESET_INDEX: &str = "reset_index";
378const MCP_PAYLOAD_MCP_CONFIG: &str = "mcp_config";
380const MCP_PAYLOAD_NEXT: &str = "next";
382const MCP_PAYLOAD_SELECTED_PROJECT: &str = "selected_project";
384const MCP_PAYLOAD_SETTINGS: &str = "settings";
386const MCP_PAYLOAD_SESSION_BRIEF: &str = "session_brief";
388const MCP_FILE_SOURCE_STATUS_LIVE: &str = "live-source";
390const MCP_PAYLOAD_TASK_START: &str = "task_start";
392const MCP_PAYLOAD_TASK_STATUS: &str = "task_status";
394const MCP_PAYLOAD_TASK_CANCEL: &str = "task_cancel";
396const MCP_PAYLOAD_SESSION_CAPABILITIES: &str = "mcp_session";
398const MCP_BRIEF_ARG_PROJECT_PATH: &str = "project_path";
400const MCP_BRIEF_ARG_WORKTREE: &str = "worktree";
402const MCP_BRIEF_ARG_FILE: &str = "file";
404const MCP_BRIEF_ARG_PATTERN: &str = "pattern";
406const MCP_BRIEF_ARG_VIEW: &str = "view";
408const MCP_BRIEF_ARG_LIMIT: &str = "limit";
410const MCP_BRIEF_ARG_TASK: &str = "task";
412const MCP_BRIEF_ARG_COMPACT: &str = "compact";
414const MCP_BRIEF_TARGET_FILESYSTEM_TOOLS: &str = "filesystem_tools";
416const MCP_BRIEF_REASON_SELECTED_INDEX_MISSING: &str = "selected_index_missing";
418const MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX: &str =
420 "use_filesystem_until_projectatlas_index_exists";
421const MCP_BRIEF_REASON_RANKED_FILE_SUMMARY: &str = "ranked_file_ready_for_summary";
423const MCP_BRIEF_REASON_RANKED_FILE_RELATIONS: &str = "ranked_file_ready_for_relations";
425const MCP_BRIEF_REASON_SEARCH_FALLBACK: &str = "no_ranked_file_candidate_search_index";
427const MCP_BRIEF_REASON_NO_FILE_CANDIDATE: &str = "no_ranked_file_candidate";
429const MCP_BRIEF_REASON_HEALTH_BLOCKERS: &str = "unresolved_health_blockers_present";
431const MCP_BRIEF_REASON_PURPOSE_QUEUE: &str = "purpose_queue_ready";
433const MCP_TASK_PROGRESS_CONTRACT_MESSAGE: &str = "task progress contract available";
435const MCP_EVENT_ATLAS_OVERVIEW: &str = "mcp.atlas_overview";
437const MCP_EVENT_ATLAS_FOLDERS: &str = "mcp.atlas_folders";
439const MCP_EVENT_ATLAS_FILES: &str = "mcp.atlas_files";
441const MCP_EVENT_ATLAS_NEXT: &str = "mcp.atlas_next";
443const MCP_EVENT_ATLAS_OUTLINE: &str = "mcp.atlas_outline";
445const MCP_EVENT_ATLAS_FILE_SUMMARY: &str = "mcp.atlas_file_summary";
447const MCP_EVENT_ATLAS_SEARCH: &str = "mcp.atlas_search";
449const MCP_EVENT_ATLAS_SLICE: &str = "mcp.atlas_slice";
451const MCP_EVENT_ATLAS_SYMBOLS: &str = "mcp.atlas_symbols";
453const MCP_EVENT_ATLAS_SYMBOL_RELATIONS: &str = "mcp.atlas_symbol_relations";
455const MCP_SYMBOL_RELATION_VIEW_LEGACY: &str = "legacy";
457const MCP_SYMBOL_RELATION_VIEW_DETAILED: &str = "detailed";
459const MCP_SYMBOL_RELATION_VIEW_ANALYSIS: &str = "analysis";
461const MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE: &str = "architecture";
463const MCP_RELATION_ANALYSIS_MODE_IMPACT: &str = "impact";
465const MCP_RELATION_ANALYSIS_MODE_TRACE: &str = "trace";
467const MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT: &str = "entrypoint";
469const MCP_ENTRYPOINT_PROFILE_DEFAULT_NAME: &str = "entrypoint-profile";
471const MCP_ERROR_ENTRYPOINT_FEDERATED: &str = "entrypoint profiles require one project root";
473const MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX: &str =
475 "entrypoints must contain exact RelationAnchor JSON objects: ";
476const MCP_ERROR_ENTRYPOINT_CONTROLS_MODE: &str =
478 "entrypoint profile controls require analysis_mode=entrypoint";
479const MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR: &str =
481 "entrypoint anchors cannot be combined with detailed symbol selectors";
482const MCP_RELATION_ANALYSIS_VCS_WORKING_TREE: &str = "working_tree";
484const MCP_RELATION_ANALYSIS_VCS_INDEX: &str = "index";
486const MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE: &str = "revision_range";
488const MCP_SYMBOL_RELATION_DIRECTION_DEFAULT: &str = "outbound";
490const MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT: &str = "low";
492const MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT: &str = "any";
494const MCP_ERROR_SYMBOL_RELATION_VIEW: &str = "unsupported symbol relation view";
496const MCP_ERROR_DETAILED_RELATION_QUERY: &str =
498 "detailed symbol relations use exact symbol selectors, not query";
499const MCP_ERROR_DETAILED_RELATION_FILE: &str = "detailed symbol relations require file";
501const MCP_ERROR_DETAILED_RELATION_SYMBOL: &str = "detailed relation symbol must not be empty";
503const MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR: &str = "symbol disambiguators require symbol";
505const MCP_ERROR_DETAILED_RELATION_LIMIT: &str = "detailed relation limit exceeds the u32 range";
507const MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW: &str =
509 "compact symbol relations require view=detailed";
510const MCP_ERROR_CONTENT_SELECTION_RELATION_VIEW: &str =
512 "content_selection requires view=detailed or view=analysis";
513const MCP_ERROR_ANALYSIS_VIEW_REQUIRED: &str = "analysis controls require view=analysis";
515const MCP_ERROR_FEDERATED_RELATION_VIEW: &str =
517 "roots or worktrees require the detailed or analysis relation view";
518const MCP_ERROR_FEDERATED_SELECTOR_CONFLICT: &str =
520 "roots and worktrees are mutually exclusive federation selectors";
521const MCP_ERROR_FEDERATED_PROJECT_PATH_CONFLICT: &str =
523 "worktrees federation cannot be combined with project_path";
524const MCP_ERROR_FEDERATED_PRIMARY_CONFLICT: &str =
526 "worktree must match the first ordered worktrees alias";
527const MCP_ERROR_TRACE_TARGET_KIND_REQUIRED: &str = "symbol trace targets require trace_target_kind";
529const MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED: &str =
531 "symbol trace targets require trace_target_signature";
532const MCP_ERROR_TRACE_TARGET_REQUIRED: &str =
534 "trace target symbol disambiguators require trace_target";
535const MCP_ERROR_TRACE_TARGET_FILE_REQUIRED: &str = "trace_target requires trace_target_file";
537const MCP_ERROR_VCS_REVISION_FIELDS: &str = "vcs_base and vcs_head require vcs=revision_range";
539const MCP_ERROR_VCS_BASE_REQUIRED: &str = "vcs=revision_range requires vcs_base";
541const MCP_ERROR_VCS_HEAD_REQUIRED: &str = "vcs=revision_range requires vcs_head";
543const MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS: &str = "unsupported analysis VCS selection";
545const MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE: &str = "unsupported relation analysis mode";
547const MCP_EVENT_ATLAS_HEALTH: &str = "mcp.atlas_health";
549const MCP_EVENT_ATLAS_PURPOSE_QUEUE: &str = "mcp.atlas_purpose_queue";
551const MCP_IGNORE_KIND_DIR_NAME: &str = "dir-name";
553const MCP_IGNORE_KIND_DIR_NAME_ALIAS: &str = "dir_name";
555const MCP_IGNORE_KIND_PATH_PREFIX: &str = "path-prefix";
557const MCP_IGNORE_KIND_PATH_PREFIX_ALIAS: &str = "path_prefix";
559const MCP_PURPOSE_LEVEL_LOW: &str = "low";
561const MCP_PURPOSE_LEVEL_MEDIUM: &str = "medium";
563const MCP_PURPOSE_LEVEL_STRICT: &str = "strict";
565const MCP_PURPOSE_TASK_SESSION_STARTUP: &str = "session-startup";
567const MCP_PURPOSE_TASK_QUEUE: &str = "purpose-curation";
569const MCP_HARNESS_MCP_JSON: &str = "mcp-json";
571const MCP_HARNESS_MCP_JSON_ALIAS: &str = "mcp_json";
573const MCP_HARNESS_CODEX: &str = "codex";
575const MCP_HARNESS_CLAUDE_CODE: &str = "claude-code";
577const MCP_HARNESS_CLAUDE_CODE_ALIAS: &str = "claude_code";
579const MCP_HARNESS_OPENCODE: &str = "opencode";
581const MCP_ERROR_IGNORE_KIND_REQUIRED: &str =
583 "ignore kind is required; expected dir-name or path-prefix";
584const MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD: &str = "ignore kind is required for atlas_ignore_add";
586const MCP_ERROR_ROOT_CONTROL_CONFLICT: &str =
588 "control_root cannot be combined with project_path or verify";
589const MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX: &str = "coverage start index is too large: ";
591const MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX: &str = "coverage limit is too large: ";
593const MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE: &str = "coverage filters require coverage=true";
595const MCP_ENV_CI: &str = "CI";
597const MCP_ENV_GITHUB_ACTIONS: &str = "GITHUB_ACTIONS";
599const MCP_MAP_SKIPPED_IN_CI_REASON: &str =
601 "skipped in CI; pass force=true to write the compatibility map";
602const MCP_NO_ROOT_PLACEHOLDER: &str = "none";
604const MCP_ERROR_INVALID_IGNORE_KIND_PREFIX: &str = "invalid ignore kind '";
606const MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX: &str = "'; expected dir-name or path-prefix";
608const MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX: &str = "invalid purpose_level '";
610const MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX: &str = "'; expected low, medium, or strict";
612const MCP_ERROR_INVALID_HARNESS_PREFIX: &str = "invalid harness '";
614const MCP_ERROR_INVALID_HARNESS_SUFFIX: &str =
616 "'; expected mcp-json, codex, claude-code, or opencode";
617const MCP_ERROR_FOR_PATH_FRAGMENT: &str = " for '";
619const MCP_ERROR_LEXICAL_ROOT_FRAGMENT: &str = "'; lexical root: '";
621const MCP_ERROR_RESOLVED_ROOT_FRAGMENT: &str = "'; resolved root: '";
623const MCP_ERROR_GUIDANCE_FRAGMENT: &str = "'; ";
625const NODE_LABEL_FOLDERS: &str = "folders";
627const NODE_LABEL_FILES: &str = "files";
629const NODE_LABEL_SYMBOLS: &str = "symbols";
631const SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR: &str = "symbol disambiguators require symbol";
633const START_LINE_REQUIRED_ERROR: &str = "start_line is required unless symbol is provided";
635const PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
637 "path is not inside an indexed ProjectAtlas project";
638const FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR: &str =
640 "folder is not inside an indexed ProjectAtlas project";
641const AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR: &str = "absolute MCP path resolves through a symlink or junction with multiple plausible ProjectAtlas roots";
643const SEVERITY_EXPECTED_SEPARATOR: &str = ", ";
645const SEVERITY_EXPECTED_FINAL_SEPARATOR: &str = ", or ";
647const TOKEN_TREND_WINDOW_ERROR_SUFFIX: &str = "expected day, week, month, or year";
649const TOKEN_TREND_BENCHMARK_ERROR: &str =
651 "benchmark_results is only supported for token overview reports";
652const TOKEN_TRENDS_RESULT_VARIANT_MISMATCH: &str = "token trend request returned an overview";
654const TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH: &str = "token overview request returned trends";
656const TOKEN_CHART_THEME_ERROR_PREFIX: &str = "unsupported token chart theme ";
658const TOKEN_CHART_THEME_ERROR_SUFFIX: &str = "; expected dark or light";
660const WATCH_STATUS_SCAN_RECOMMENDATION: &str =
662 " Run `atlas_scan` first when no ProjectAtlas index exists for this project.";
663const SESSION_BRIEF_DEFAULT_LIMIT: usize = 5;
665const COMPACT_SESSION_BRIEF_DEFAULT_LIMIT: usize = 3;
667const SESSION_BRIEF_MAX_LIMIT: usize = 8;
669const MCP_TASK_REGISTRY_CAPACITY: usize = 32;
671const MCP_TASK_ERROR_MAX_CHARS: usize = 512;
673const MCP_TASK_CONTRACT_ID: &str = "task-progress-contract";
675const MCP_TASK_REGISTRY_LOCK_POISONED: &str = "MCP task registry lock is poisoned";
677const MCP_INDEX_TASK_ID_PREFIX: &str = "index-";
679const MCP_INDEX_WORKER_NAME_PREFIX: &str = "projectatlas-";
681const MCP_INDEX_TASK_LIMIT_PREFIX: &str = "background indexing task limit ";
683const MCP_INDEX_TASK_LIMIT_SUFFIX: &str = " is already active";
685const MCP_BACKGROUND_TASK_SAFE_CEILING: usize = 4;
687const MCP_INDEX_WORKER_PANIC_ERROR: &str = "background indexing worker panicked";
689const MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX: &str = "failed to start background indexing: ";
691const MCP_TASK_PROGRESS_ACCEPTED: &str = "accepted";
693const MCP_TASK_PROGRESS_RUNNING: &str = "running";
695const MCP_TASK_PROGRESS_COMPLETE: &str = "complete";
697const MCP_TASK_PROGRESS_FAILED: &str = "failed";
699const MCP_TASK_PROGRESS_CANCELED: &str = "canceled";
701const MCP_TASK_PROGRESS_CANCELLATION_REQUESTED: &str = "cancellation_requested";
703const 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.";
705const MCP_WORKTREE_PROJECT_PATH_CONFLICT: &str =
707 "worktree and project_path are mutually exclusive; choose one target selector";
708const MCP_MAIN_WORKTREE_ALIAS: &str = "main";
710const MCP_WORKTREE_LIST_MAX_ROWS: usize = (MAX_GIT_WORKTREE_REGISTRATIONS * 2) + 1;
712const MCP_WORKTREE_SELECTOR_PREFIX: &str = "wt-";
714const MCP_WORKTREE_SELECTOR_DIGEST_CHARS: usize = 16;
716const MCP_NONSOURCE_FILE_NAME: &str = "projectatlas-nonsource-files.toon";
718const MCP_HYDRATION_NO_SCAN_REASON: &str =
720 "hydration requires source reconciliation; ordinary no-scan init was requested";
721const MCP_ERROR_WORKTREE_CONTROL_REPOSITORY_REQUIRED: &str =
723 "worktree aliases require a structurally valid Git control repository";
724const MCP_ERROR_WORKTREE_PATH_NON_UTF8: &str =
726 "native worktree identity is not available as UTF-8; use its stable alias or selector";
727const MCP_ERROR_WORKTREE_IDENTITY_CONFLICT: &str =
729 "local atlas identity conflicts with its active registration";
730const MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT: &str =
732 "control atlas identity changed after worktree alias selection";
733const 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";
735const 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";
737const MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED: &str =
739 "registered worktree administrative lifecycle changed; unregister and register it again";
740const MCP_ERROR_FEDERATED_ALIAS_MISSING: &str =
742 "federated worktree resolution lost its captured alias";
743const MCP_ERROR_FEDERATED_TARGET_DUPLICATE: &str =
745 "federated worktree aliases and roots must be unique";
746const MCP_ERROR_CONTROL_ALIAS_REQUIRED: &str =
748 "the control checkout is selected through reserved alias main";
749const MCP_ERROR_WORKTREE_SELECTOR_EMPTY: &str = "worktree selector must not be empty";
751const MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE: &str = "selected worktree is no longer active";
753const MCP_WORKTREE_MISSING_RETENTION_REASON: &str =
755 "worktree is structurally missing; the last accepted telemetry total is retained";
756
757#[derive(Debug, Deserialize, schemars::JsonSchema)]
759struct AtlasProjectParams {
760 project_path: Option<String>,
762 worktree: Option<String>,
764}
765
766#[derive(Debug, Deserialize, schemars::JsonSchema)]
768struct AtlasSessionBriefParams {
769 project_path: Option<String>,
771 worktree: Option<String>,
773 query: Option<String>,
775 purpose_task: Option<String>,
777 compact: Option<bool>,
779 folder_limit: Option<usize>,
781 file_limit: Option<usize>,
783 blocker_limit: Option<usize>,
785 purpose_limit: Option<usize>,
787}
788
789#[derive(Debug, Deserialize, schemars::JsonSchema)]
791struct AtlasTaskParams {
792 task_id: String,
794}
795
796#[derive(Debug, Deserialize, schemars::JsonSchema)]
798struct AtlasSetProjectPathParams {
799 project_path: String,
801}
802
803#[derive(Debug, Deserialize, schemars::JsonSchema)]
805struct AtlasWorktreeListParams {
806 include_retired: Option<bool>,
808}
809
810#[derive(Debug, Deserialize, schemars::JsonSchema)]
812struct AtlasWorktreeAddParams {
813 worktree: String,
815 alias: Option<String>,
817}
818
819#[derive(Debug, Deserialize, schemars::JsonSchema)]
821struct AtlasWorktreeRemoveParams {
822 worktree: String,
824}
825
826#[derive(Debug, Deserialize, schemars::JsonSchema)]
828struct AtlasInitParams {
829 project_path: Option<String>,
831 worktree: Option<String>,
833 no_scan: Option<bool>,
835 force_rescan: Option<bool>,
837 text_index_max_bytes: Option<u64>,
839}
840
841#[derive(Debug, Deserialize, schemars::JsonSchema)]
843struct AtlasMapParams {
844 project_path: Option<String>,
846 worktree: Option<String>,
848 json: Option<bool>,
850 force: Option<bool>,
852}
853
854#[derive(Debug, Deserialize, schemars::JsonSchema)]
856struct AtlasRootParams {
857 project_path: Option<String>,
859 worktree: Option<String>,
861 control_root: Option<String>,
863 verify: Option<bool>,
865}
866
867#[derive(Debug, Deserialize, schemars::JsonSchema)]
869struct AtlasRootSetParams {
870 root: String,
872 transition: Option<RootTransition>,
874 nearest_project: Option<bool>,
876}
877
878#[derive(Debug, Deserialize, schemars::JsonSchema)]
880struct AtlasIgnoreMutationParams {
881 project_path: Option<String>,
883 worktree: Option<String>,
885 kind: Option<String>,
887 value: String,
889}
890
891#[derive(Debug, Deserialize, schemars::JsonSchema)]
893struct AtlasLintParams {
894 project_path: Option<String>,
896 worktree: Option<String>,
898 strict_folders: Option<bool>,
900 purpose_level: Option<String>,
902 report_untracked: Option<bool>,
904 strict_untracked: Option<bool>,
906}
907
908#[derive(Debug, Deserialize, schemars::JsonSchema)]
910struct AtlasMcpConfigParams {
911 project_path: Option<String>,
913 worktree: Option<String>,
915 server_name: Option<String>,
917 harness: Option<String>,
919 nearest_project: Option<bool>,
921}
922
923pub(crate) fn run_mcp_server(
925 db_path: PathBuf,
926 config_path: Option<PathBuf>,
927 session: String,
928 allow_nearest_project: bool,
929) -> Result<(), CliError> {
930 let server = ProjectAtlasMcpServer::new(db_path, config_path, session, allow_nearest_project);
931 let shutdown_server = server.clone();
932 let runtime = tokio::runtime::Builder::new_multi_thread()
933 .enable_all()
934 .build()
935 .map_err(|source| CliError::Mcp(source.to_string()))?;
936 let result = runtime.block_on(async move {
937 server
938 .serve(rmcp::transport::stdio())
939 .await
940 .map_err(|source| CliError::Mcp(source.to_string()))?
941 .waiting()
942 .await
943 .map_err(|source| CliError::Mcp(source.to_string()))
944 .map(|_| ())
945 });
946 shutdown_server.seal_usage_instances_for_projects();
947 result
948}
949
950pub(crate) fn required_mcp_surface_present() -> bool {
952 REQUIRED_MCP_TOOL_NAMES
953 .iter()
954 .all(|name| mcp_tool_route_present(name))
955}
956
957pub(crate) fn mcp_tool_route_present(name: &str) -> bool {
959 ProjectAtlasMcpServer::tool_router().has_route(name)
960}
961
962#[derive(Debug, Deserialize, schemars::JsonSchema)]
964struct AtlasScanParams {
965 project_path: Option<String>,
967 worktree: Option<String>,
969 path: Option<String>,
971 nearest_project: Option<bool>,
973 max_bytes: Option<u64>,
975 max_workers: Option<usize>,
977 timeout_seconds: Option<u64>,
979 text_index_max_bytes: Option<u64>,
981 background: Option<bool>,
983}
984
985#[derive(Debug, Deserialize, schemars::JsonSchema)]
987struct AtlasWatchOnceParams {
988 project_path: Option<String>,
990 worktree: Option<String>,
992 path: Option<String>,
994 nearest_project: Option<bool>,
996 max_workers: Option<usize>,
998 timeout_seconds: Option<u64>,
1000 text_index_max_bytes: Option<u64>,
1002 background: Option<bool>,
1004}
1005
1006#[derive(Debug, Deserialize, schemars::JsonSchema)]
1008struct AtlasQueryParams {
1009 project_path: Option<String>,
1011 worktree: Option<String>,
1013 query: Option<String>,
1015 limit: Option<usize>,
1017}
1018
1019#[derive(Debug, Deserialize, schemars::JsonSchema)]
1021struct AtlasNextParams {
1022 project_path: Option<String>,
1024 worktree: Option<String>,
1026 query: Option<String>,
1028 content_selection: Option<String>,
1030 limit: Option<usize>,
1032}
1033
1034#[derive(Debug, Deserialize, schemars::JsonSchema)]
1036struct AtlasFilesParams {
1037 project_path: Option<String>,
1039 worktree: Option<String>,
1041 query: Option<String>,
1043 folder: Option<String>,
1045 nearest_project: Option<bool>,
1047 file_pattern: Option<String>,
1049 include_content: Option<bool>,
1051 content_selection: Option<String>,
1053 limit: Option<usize>,
1055}
1056
1057#[derive(Debug, Deserialize, schemars::JsonSchema)]
1059struct AtlasOutlineParams {
1060 project_path: Option<String>,
1062 worktree: Option<String>,
1064 file: String,
1066 nearest_project: Option<bool>,
1068 lines: Option<usize>,
1070}
1071
1072#[derive(Debug, Deserialize, schemars::JsonSchema)]
1074struct AtlasFileSummaryParams {
1075 project_path: Option<String>,
1077 worktree: Option<String>,
1079 file: String,
1081 nearest_project: Option<bool>,
1083 compact: Option<bool>,
1085 content_selection: Option<String>,
1087 limit: Option<usize>,
1089}
1090
1091#[derive(Debug, Deserialize, schemars::JsonSchema)]
1093struct AtlasSearchParams {
1094 project_path: Option<String>,
1096 worktree: Option<String>,
1098 pattern: String,
1100 retrieval_mode: Option<SearchRetrievalModeArg>,
1102 regex: Option<bool>,
1104 fuzzy: Option<bool>,
1106 case_sensitive: Option<bool>,
1108 file_pattern: Option<String>,
1110 context_lines: Option<usize>,
1112 start_index: Option<usize>,
1114 content_selection: Option<String>,
1116 limit: Option<usize>,
1118}
1119
1120#[derive(Debug, Deserialize, schemars::JsonSchema)]
1122struct AtlasSliceParams {
1123 project_path: Option<String>,
1125 worktree: Option<String>,
1127 file: String,
1129 nearest_project: Option<bool>,
1131 start_line: Option<usize>,
1133 end_line: Option<usize>,
1135 symbol: Option<String>,
1137 symbol_parent: Option<String>,
1139 symbol_kind: Option<String>,
1141 symbol_signature: Option<String>,
1143 symbol_line: Option<usize>,
1145 content_selection: Option<String>,
1147 output_bytes: Option<u32>,
1149}
1150
1151#[derive(Debug, Deserialize, schemars::JsonSchema)]
1153struct AtlasSymbolsParams {
1154 project_path: Option<String>,
1156 worktree: Option<String>,
1158 file: Option<String>,
1160 nearest_project: Option<bool>,
1162 query: Option<String>,
1164 content_selection: Option<String>,
1166 limit: Option<usize>,
1168}
1169
1170#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
1172struct AtlasSymbolRelationsParams {
1173 project_path: Option<String>,
1175 worktree: Option<String>,
1177 file: Option<String>,
1179 nearest_project: Option<bool>,
1181 query: Option<String>,
1183 view: Option<String>,
1185 content_selection: Option<String>,
1187 compact: Option<bool>,
1189 cursor: Option<String>,
1191 roots: Option<Vec<String>>,
1193 worktrees: Option<Vec<String>>,
1195 symbol: Option<String>,
1197 symbol_parent: Option<String>,
1199 symbol_kind: Option<String>,
1201 symbol_signature: Option<String>,
1203 direction: Option<String>,
1205 relation: Option<String>,
1207 minimum_confidence: Option<String>,
1209 resolution: Option<String>,
1211 depth: Option<u32>,
1213 include_occurrences: Option<bool>,
1215 occurrence_limit: Option<u32>,
1217 edge_limit: Option<u32>,
1219 node_limit: Option<u32>,
1221 visited_limit: Option<u32>,
1223 occurrence_total_limit: Option<u32>,
1225 intermediate_bytes: Option<u64>,
1227 deadline_ms: Option<u64>,
1229 output_bytes: Option<u32>,
1231 analysis_mode: Option<String>,
1233 profile_name: Option<String>,
1235 entrypoints: Option<Vec<String>>,
1237 profile_relations: Option<Vec<String>>,
1239 trace_target: Option<String>,
1241 trace_target_file: Option<String>,
1243 trace_target_parent: Option<String>,
1245 trace_target_kind: Option<String>,
1247 trace_target_signature: Option<String>,
1249 vcs: Option<String>,
1251 vcs_base: Option<String>,
1253 vcs_head: Option<String>,
1255 include_communities: Option<bool>,
1257 include_cycles: Option<bool>,
1259 include_dead_code: Option<bool>,
1261 limit: Option<usize>,
1263}
1264
1265fn relation_analysis_controls_present(params: &AtlasSymbolRelationsParams) -> bool {
1267 params.analysis_mode.is_some()
1268 || params.profile_name.is_some()
1269 || params
1270 .entrypoints
1271 .as_ref()
1272 .is_some_and(|items| !items.is_empty())
1273 || params
1274 .profile_relations
1275 .as_ref()
1276 .is_some_and(|items| !items.is_empty())
1277 || params.trace_target.is_some()
1278 || params.trace_target_file.is_some()
1279 || params.trace_target_parent.is_some()
1280 || params.trace_target_kind.is_some()
1281 || params.trace_target_signature.is_some()
1282 || params.vcs.is_some()
1283 || params.vcs_base.is_some()
1284 || params.vcs_head.is_some()
1285 || params.include_communities.is_some()
1286 || params.include_cycles.is_some()
1287 || params.include_dead_code.is_some()
1288}
1289
1290fn relation_analysis_trace_target(
1292 store: &AtlasStore,
1293 params: &AtlasSymbolRelationsParams,
1294) -> Result<Option<RelationAnchor>, CliError> {
1295 match (¶ms.trace_target, ¶ms.trace_target_file) {
1296 (Some(name), Some(file)) => {
1297 let file = validated_indexed_file_key(store, Path::new(file))?;
1298 let kind = params.trace_target_kind.as_deref().ok_or_else(|| {
1299 CliError::Service(ServiceError::InvalidInput(
1300 MCP_ERROR_TRACE_TARGET_KIND_REQUIRED.to_string(),
1301 ))
1302 })?;
1303 let signature = params.trace_target_signature.clone().ok_or_else(|| {
1304 CliError::Service(ServiceError::InvalidInput(
1305 MCP_ERROR_TRACE_TARGET_SIGNATURE_REQUIRED.to_string(),
1306 ))
1307 })?;
1308 Ok(Some(RelationAnchor::Symbol {
1309 file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1310 CliError::Service(ServiceError::InvalidInput(error.to_string()))
1311 })?,
1312 name: name.clone(),
1313 symbol_kind: Some(parse_symbol_kind(kind)?),
1314 parent: params.trace_target_parent.clone(),
1315 signature: Some(signature),
1316 }))
1317 }
1318 (None, Some(file)) => {
1319 if params.trace_target_parent.is_some()
1320 || params.trace_target_kind.is_some()
1321 || params.trace_target_signature.is_some()
1322 {
1323 return Err(CliError::Service(ServiceError::InvalidInput(
1324 MCP_ERROR_TRACE_TARGET_REQUIRED.to_string(),
1325 )));
1326 }
1327 let file = validated_indexed_file_key(store, Path::new(file))?;
1328 Ok(Some(RelationAnchor::File {
1329 file: RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
1330 CliError::Service(ServiceError::InvalidInput(error.to_string()))
1331 })?,
1332 }))
1333 }
1334 (Some(_), None) => Err(CliError::Service(ServiceError::InvalidInput(
1335 MCP_ERROR_TRACE_TARGET_FILE_REQUIRED.to_string(),
1336 ))),
1337 (None, None) => Ok(None),
1338 }
1339}
1340
1341fn relation_analysis_vcs(
1343 params: &AtlasSymbolRelationsParams,
1344) -> Result<GitImpactSelection, CliError> {
1345 match params
1346 .vcs
1347 .as_deref()
1348 .unwrap_or(MCP_RELATION_ANALYSIS_VCS_WORKING_TREE)
1349 {
1350 MCP_RELATION_ANALYSIS_VCS_WORKING_TREE => {
1351 if params.vcs_base.is_some() || params.vcs_head.is_some() {
1352 return Err(CliError::Service(ServiceError::InvalidInput(
1353 MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1354 )));
1355 }
1356 Ok(GitImpactSelection::WorkingTree)
1357 }
1358 MCP_RELATION_ANALYSIS_VCS_INDEX => {
1359 if params.vcs_base.is_some() || params.vcs_head.is_some() {
1360 return Err(CliError::Service(ServiceError::InvalidInput(
1361 MCP_ERROR_VCS_REVISION_FIELDS.to_string(),
1362 )));
1363 }
1364 Ok(GitImpactSelection::Index)
1365 }
1366 MCP_RELATION_ANALYSIS_VCS_REVISION_RANGE => Ok(GitImpactSelection::RevisionRange {
1367 base: params.vcs_base.clone().ok_or_else(|| {
1368 CliError::Service(ServiceError::InvalidInput(
1369 MCP_ERROR_VCS_BASE_REQUIRED.to_string(),
1370 ))
1371 })?,
1372 head: params.vcs_head.clone().ok_or_else(|| {
1373 CliError::Service(ServiceError::InvalidInput(
1374 MCP_ERROR_VCS_HEAD_REQUIRED.to_string(),
1375 ))
1376 })?,
1377 }),
1378 _unsupported => Err(CliError::Service(ServiceError::InvalidInput(
1379 MCP_ERROR_UNSUPPORTED_ANALYSIS_VCS.to_string(),
1380 ))),
1381 }
1382}
1383
1384#[derive(Debug, Deserialize, schemars::JsonSchema)]
1386struct AtlasTokenParams {
1387 project_path: Option<String>,
1389 worktree: Option<String>,
1391 session: Option<String>,
1393 include_chart: Option<bool>,
1395 trend_window: Option<String>,
1397 benchmark_results: Option<String>,
1399 theme: Option<String>,
1401}
1402
1403#[derive(Debug, Deserialize, schemars::JsonSchema)]
1405struct AtlasHealthParams {
1406 project_path: Option<String>,
1408 worktree: Option<String>,
1410 start_index: Option<usize>,
1412 limit: Option<usize>,
1414 category: Option<String>,
1416 severity: Option<String>,
1418 path_prefix: Option<String>,
1420 summary_only: Option<bool>,
1422 source_only: Option<bool>,
1424 include_assets: Option<bool>,
1426 include_low_priority_files: Option<bool>,
1428 coverage: Option<bool>,
1430 parser: Option<String>,
1432 provider: Option<String>,
1434 relation: Option<String>,
1436 coverage_state: Option<String>,
1438 reason: Option<String>,
1440}
1441
1442#[derive(Debug, Deserialize, schemars::JsonSchema)]
1444struct AtlasPurposeQueueParams {
1445 #[serde(flatten)]
1447 health: AtlasHealthParams,
1448 task: Option<String>,
1450}
1451
1452#[derive(Debug, Deserialize, schemars::JsonSchema)]
1454struct AtlasParityParams {
1455 project_path: Option<String>,
1457 worktree: Option<String>,
1459 profile: Option<String>,
1461}
1462
1463#[derive(Debug, Deserialize, schemars::JsonSchema)]
1465struct AtlasStripLegacyParams {
1466 project_path: Option<String>,
1468 worktree: Option<String>,
1470 path: Option<String>,
1472 nearest_project: Option<bool>,
1474 apply: Option<bool>,
1476 dry_run: Option<bool>,
1478 strip_source_headers: Option<bool>,
1480}
1481
1482#[derive(Debug, Deserialize, schemars::JsonSchema)]
1484struct AtlasResetIndexParams {
1485 project_path: Option<String>,
1487 worktree: Option<String>,
1489 apply: Option<bool>,
1491 dry_run: Option<bool>,
1493 include_mcp_config: Option<bool>,
1495}
1496
1497#[derive(Debug, Deserialize, schemars::JsonSchema)]
1499struct AtlasPurposeSetParams {
1500 project_path: Option<String>,
1502 worktree: Option<String>,
1504 path: String,
1506 purpose: String,
1508}
1509
1510#[derive(Debug, Deserialize, schemars::JsonSchema)]
1512#[schemars(inline)]
1513struct AtlasPurposeReviewItem {
1514 path: String,
1516 purpose: Option<String>,
1518 confirm_existing: Option<bool>,
1520 task: Option<String>,
1522 work_key: Option<String>,
1524 state_token: Option<String>,
1526}
1527
1528#[derive(Debug, Deserialize, schemars::JsonSchema)]
1530struct AtlasPurposeReviewParams {
1531 project_path: Option<String>,
1533 worktree: Option<String>,
1535 items: Vec<AtlasPurposeReviewItem>,
1537 apply: Option<bool>,
1539}
1540
1541#[derive(Debug, Deserialize, schemars::JsonSchema)]
1543struct AtlasHealthResolveParams {
1544 project_path: Option<String>,
1546 worktree: Option<String>,
1548 finding_id: String,
1550 category: String,
1552 path: String,
1554 related_path: Option<String>,
1556 rationale: String,
1558}
1559
1560#[derive(Debug, Clone)]
1562struct McpProjectState {
1563 root: PathBuf,
1565 db_path: PathBuf,
1567 config_path: Option<PathBuf>,
1569 worktree: Option<McpWorktreeSelection>,
1571}
1572
1573#[derive(Debug, Clone, Eq, PartialEq)]
1575struct McpWorktreeSelection {
1576 alias: String,
1578 registration_id: Option<i64>,
1580 project_instance_id: Option<ProjectInstanceId>,
1582 control_project_instance_id: Option<ProjectInstanceId>,
1584}
1585
1586enum McpWorktreeHydration {
1588 Activated {
1590 hydration: InitHydrationPhase,
1592 scan: Box<ScanReport>,
1594 },
1595 Fallback(String),
1597}
1598
1599enum SymbolRelationStores<'a> {
1601 Single(&'a AtlasStore),
1603 Federated(Vec<FederatedStore>),
1605}
1606
1607impl SymbolRelationStores<'_> {
1608 fn primary(&self) -> &AtlasStore {
1610 match self {
1611 Self::Single(store) => store,
1612 Self::Federated(stores) => stores[0].store(),
1613 }
1614 }
1615}
1616
1617#[derive(Debug, Clone, Eq, Hash, PartialEq)]
1619struct McpUsageProjectBinding {
1620 root: PathBuf,
1622 db_path: PathBuf,
1624 project_instance_id: ProjectInstanceId,
1626 worktree_registration_id: Option<i64>,
1628}
1629
1630#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1632struct McpSourceTokenBaselineKey {
1633 binding: McpUsageProjectBinding,
1635 generation: projectatlas_core::IndexGeneration,
1637 folder: Option<String>,
1639 file_pattern: Option<String>,
1641}
1642
1643#[derive(Debug)]
1645struct McpUsageIntent {
1646 command: &'static str,
1648 path: Option<String>,
1650 query: Option<String>,
1652 baseline: McpUsageBaseline,
1654}
1655
1656#[derive(Debug)]
1658enum McpUsageBaseline {
1659 Estimate(usize),
1661 DirectoryWalk(usize),
1663 Text(String),
1665}
1666
1667impl McpUsageIntent {
1668 fn estimate(
1670 command: &'static str,
1671 path: Option<String>,
1672 query: Option<String>,
1673 baseline_tokens: usize,
1674 ) -> Self {
1675 Self {
1676 command,
1677 path,
1678 query,
1679 baseline: McpUsageBaseline::Estimate(baseline_tokens),
1680 }
1681 }
1682
1683 fn directory_walk(
1685 command: &'static str,
1686 path: Option<String>,
1687 query: Option<String>,
1688 baseline_tokens: usize,
1689 ) -> Self {
1690 Self {
1691 command,
1692 path,
1693 query,
1694 baseline: McpUsageBaseline::DirectoryWalk(baseline_tokens),
1695 }
1696 }
1697
1698 fn text(command: &'static str, path: Option<String>, baseline_text: String) -> Self {
1700 Self {
1701 command,
1702 path,
1703 query: None,
1704 baseline: McpUsageBaseline::Text(baseline_text),
1705 }
1706 }
1707}
1708
1709impl McpUsageProjectBinding {
1710 #[cfg(test)]
1712 fn capture(state: &McpProjectState, store: &AtlasStore) -> Result<Self, DbError> {
1713 Self::capture_with_origin(state, store, None)
1714 }
1715
1716 fn capture_with_origin(
1718 state: &McpProjectState,
1719 store: &AtlasStore,
1720 worktree_registration_id: Option<i64>,
1721 ) -> Result<Self, DbError> {
1722 let captured = store.captured_project_binding()?;
1723 Ok(Self {
1724 root: state.root.clone(),
1725 db_path: state.db_path.clone(),
1726 project_instance_id: captured.project_instance_id,
1727 worktree_registration_id,
1728 })
1729 }
1730
1731 fn same_project(&self, other: &Self) -> bool {
1733 self.root == other.root
1734 && self.db_path == other.db_path
1735 && self.project_instance_id == other.project_instance_id
1736 }
1737}
1738
1739#[derive(Clone, Debug)]
1741struct McpUsageProjectRuntime {
1742 binding: McpUsageProjectBinding,
1744 instance: Arc<Mutex<UsageRuntimeInstance>>,
1746}
1747
1748#[derive(Debug, Default)]
1750struct McpUsageRuntime {
1751 entries: Vec<McpUsageProjectRuntime>,
1753 source_token_baselines: VecDeque<(McpSourceTokenBaselineKey, usize)>,
1755}
1756
1757impl McpUsageRuntime {
1758 fn instance_for_binding(
1760 &mut self,
1761 binding: McpUsageProjectBinding,
1762 selected_store: &AtlasStore,
1763 ) -> Option<Arc<Mutex<UsageRuntimeInstance>>> {
1764 if let Some(index) = self
1765 .entries
1766 .iter()
1767 .position(|entry| entry.binding == binding)
1768 {
1769 let entry = self.entries.remove(index);
1770 let instance = Arc::clone(&entry.instance);
1771 self.entries.push(entry);
1772 return Some(instance);
1773 }
1774 let instance = Arc::new(Mutex::new(UsageRuntimeInstance::new(
1775 UsageInstanceOwner::McpProcess,
1776 )?));
1777 if self.entries.len() >= MCP_TELEMETRY_PROJECT_BINDING_LIMIT {
1778 let index = (0..self.entries.len()).find(|index| {
1779 Self::seal_inactive_entry(&self.entries[*index], &binding, selected_store)
1780 })?;
1781 self.entries.remove(index);
1782 }
1783 self.entries.push(McpUsageProjectRuntime {
1784 binding,
1785 instance: Arc::clone(&instance),
1786 });
1787 Some(instance)
1788 }
1789
1790 fn seal_inactive_entry(
1792 entry: &McpUsageProjectRuntime,
1793 selected_binding: &McpUsageProjectBinding,
1794 selected_store: &AtlasStore,
1795 ) -> bool {
1796 if Arc::strong_count(&entry.instance) != 1 {
1797 return false;
1798 }
1799 let Ok(instance) = entry.instance.try_lock() else {
1800 return false;
1801 };
1802 let seal = |store: &AtlasStore| {
1803 store.captured_project_binding().is_ok_and(|binding| {
1804 binding.project_instance_id == entry.binding.project_instance_id
1805 }) && matches!(
1806 (*instance).seal(store),
1807 Ok(()) | Err(CliError::Db(DbError::TelemetryInstanceInactive))
1808 )
1809 };
1810 if entry.binding.same_project(selected_binding) {
1811 return seal(selected_store);
1812 }
1813 open_atlas_store_for_project(&entry.binding.db_path, &entry.binding.root)
1814 .is_ok_and(|store| seal(&store))
1815 }
1816
1817 fn snapshot(&self) -> Vec<McpUsageProjectRuntime> {
1819 self.entries.clone()
1820 }
1821
1822 fn source_token_baseline(&self, key: &McpSourceTokenBaselineKey) -> Option<usize> {
1824 self.source_token_baselines
1825 .iter()
1826 .find_map(|(candidate, value)| (candidate == key).then_some(*value))
1827 }
1828
1829 fn insert_source_token_baseline(&mut self, key: McpSourceTokenBaselineKey, value: usize) {
1831 if let Some(index) = self
1832 .source_token_baselines
1833 .iter()
1834 .position(|(candidate, _value)| candidate == &key)
1835 {
1836 let _removed = self.source_token_baselines.remove(index);
1837 }
1838 while self.source_token_baselines.len() >= MCP_SOURCE_TOKEN_BASELINE_LIMIT {
1839 let _removed = self.source_token_baselines.pop_front();
1840 }
1841 self.source_token_baselines.push_back((key, value));
1842 }
1843}
1844
1845#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1847enum McpConfigValidation {
1848 Immediate,
1850 Deferred,
1852}
1853
1854#[derive(Debug, Serialize)]
1856struct McpMapReport {
1857 root: Option<String>,
1859 map_path: Option<String>,
1861 written: bool,
1863 json: bool,
1865 skipped_reason: Option<String>,
1867}
1868
1869#[derive(Debug, Serialize)]
1871struct McpWorktreeListReport {
1872 control_alias: &'static str,
1874 control_root: Option<String>,
1876 common_directory: Option<String>,
1878 worktrees: Vec<McpWorktreeRow>,
1880 retired: Vec<McpRetiredWorktreeRow>,
1882 total_worktrees: usize,
1884 truncated: bool,
1886}
1887
1888#[derive(Debug, Serialize)]
1890struct McpWorktreeRow {
1891 selector: Option<String>,
1893 alias: Option<String>,
1895 role: McpGitWorktreeRole,
1897 path_display: McpWorktreePathDisplayState,
1899 git_state: McpGitWorktreeState,
1901 registration: McpWorktreeRegistrationState,
1903 administrative_directory: Option<String>,
1905 root: Option<String>,
1907 atlas_state: McpWorktreeAtlasState,
1909 telemetry_state: McpWorktreeTelemetryState,
1911 accepted_telemetry_revision: Option<u64>,
1913 local_telemetry_revision: Option<u64>,
1915 project_instance_id: Option<String>,
1917 blocker: Option<String>,
1919}
1920
1921#[derive(Debug, Serialize)]
1923struct McpRetiredWorktreeRow {
1924 alias: String,
1926 last_root: Option<String>,
1928 path_display: McpWorktreePathDisplayState,
1930 project_instance_id: Option<String>,
1932 accepted_telemetry_revision: u64,
1934}
1935
1936#[derive(Debug, Serialize)]
1938struct McpWorktreeCandidate {
1939 selector: String,
1941 root: Option<String>,
1943 path_display: McpWorktreePathDisplayState,
1945 role: McpGitWorktreeRole,
1947}
1948
1949#[derive(Debug, Serialize)]
1951struct McpWorktreeMutationReport {
1952 operation: McpWorktreeMutationOperation,
1954 status: McpWorktreeMutationStatus,
1956 selector: Option<String>,
1958 alias: Option<String>,
1960 root: Option<String>,
1962 path_display: Option<McpWorktreePathDisplayState>,
1964 registration_id: Option<i64>,
1966 telemetry_sync: Option<WorktreeUsageSyncState>,
1968 candidates: Vec<McpWorktreeCandidate>,
1970 blocker: Option<String>,
1972 git_unchanged: bool,
1974 files_unchanged: bool,
1976}
1977
1978#[derive(Clone, Copy, Debug, Serialize)]
1980#[serde(rename_all = "snake_case")]
1981enum McpGitWorktreeRole {
1982 Primary,
1984 Linked,
1986}
1987
1988impl From<GitWorktreeRole> for McpGitWorktreeRole {
1989 fn from(value: GitWorktreeRole) -> Self {
1990 match value {
1991 GitWorktreeRole::Primary => Self::Primary,
1992 GitWorktreeRole::Linked => Self::Linked,
1993 }
1994 }
1995}
1996
1997#[derive(Clone, Copy, Debug, Serialize)]
1999#[serde(rename_all = "snake_case")]
2000enum McpGitWorktreeState {
2001 Active,
2003 Missing,
2005 Invalid,
2007}
2008
2009#[derive(Clone, Copy, Debug, Serialize)]
2011#[serde(rename_all = "snake_case")]
2012enum McpWorktreeRegistrationState {
2013 Control,
2015 Registered,
2017 Unregistered,
2019}
2020
2021#[derive(Clone, Copy, Debug, Serialize)]
2023#[serde(rename_all = "snake_case")]
2024enum McpWorktreePathDisplayState {
2025 Available,
2027 Unavailable,
2029}
2030
2031#[derive(Clone, Copy, Debug, Serialize)]
2033#[serde(rename_all = "snake_case")]
2034enum McpWorktreeAtlasState {
2035 Initialized,
2037 Missing,
2039 Invalid,
2041 Unavailable,
2043}
2044
2045#[derive(Clone, Copy, Debug, Serialize)]
2047#[serde(rename_all = "snake_case")]
2048enum McpWorktreeTelemetryState {
2049 Control,
2051 Current,
2053 Pending,
2055 MissingAtlas,
2057 Unregistered,
2059 Unavailable,
2061}
2062
2063#[derive(Clone, Copy, Debug, Serialize)]
2065#[serde(rename_all = "snake_case")]
2066enum McpWorktreeMutationOperation {
2067 Add,
2069 Remove,
2071}
2072
2073#[derive(Clone, Copy, Debug, Serialize)]
2075#[serde(rename_all = "snake_case")]
2076enum McpWorktreeMutationStatus {
2077 Registered,
2079 Retired,
2081 NotFound,
2083 Ambiguous,
2085}
2086
2087struct LocalWorktreeAtlas {
2089 project_instance_id: ProjectInstanceId,
2091 snapshot: WorktreeUsageSnapshot,
2093}
2094
2095#[derive(Debug, Clone)]
2097struct McpSelectedRoot(PathBuf);
2098
2099impl McpSelectedRoot {
2100 fn from_state(state: &McpProjectState) -> Self {
2102 Self(state.root.clone())
2103 }
2104
2105 fn repo_key_for(&self, path: &McpAbsolutePath) -> Result<Option<McpRepoKey>, CliError> {
2107 if !path.as_path().starts_with(&self.0) {
2108 return Ok(None);
2109 }
2110 normalize_repo_path(&self.0, path.as_path())
2111 .map(McpRepoKey)
2112 .map(Some)
2113 .map_err(ProjectAtlasMcpServer::selected_project_path_error)
2114 }
2115}
2116
2117#[derive(Debug, Clone)]
2119struct McpIndexedRoot {
2120 root: PathBuf,
2122 db_path: PathBuf,
2124}
2125
2126#[derive(Debug, Clone)]
2128struct McpAbsolutePath(PathBuf);
2129
2130impl McpAbsolutePath {
2131 fn canonicalize(path: &Path) -> Result<Self, CliError> {
2137 let mut existing = path;
2138 let mut missing_suffix = Vec::new();
2139 while !existing.exists() {
2140 let file_name = existing
2141 .file_name()
2142 .ok_or_else(|| missing_ancestor_error(path))?;
2143 missing_suffix.push(PathBuf::from(file_name));
2144 existing = existing
2145 .parent()
2146 .ok_or_else(|| missing_ancestor_error(path))?;
2147 }
2148 let mut canonical = canonical_project_root(existing)?;
2149 for component in missing_suffix.into_iter().rev() {
2150 canonical.push(component);
2151 }
2152 Ok(Self(canonical))
2153 }
2154
2155 fn as_path(&self) -> &Path {
2157 &self.0
2158 }
2159
2160 fn nearest_search_start(&self) -> &Path {
2162 if self.0.is_dir() {
2163 &self.0
2164 } else {
2165 self.0.parent().unwrap_or(self.as_path())
2166 }
2167 }
2168}
2169
2170fn missing_ancestor_error(path: &Path) -> CliError {
2172 CliError::InvalidInput(format!(
2173 "absolute path '{}' has no existing ancestor",
2174 path.display()
2175 ))
2176}
2177
2178#[derive(Debug, Clone)]
2180struct McpRepoKey(String);
2181
2182impl McpRepoKey {
2183 fn into_string(self) -> String {
2185 self.0
2186 }
2187}
2188
2189#[derive(Debug, Clone)]
2191struct McpResolvedRepoPath {
2192 state: McpProjectState,
2194 key: String,
2196 routed_project: bool,
2198}
2199
2200#[derive(Debug, Serialize)]
2202struct McpErrorResponse {
2203 error: McpErrorPayload,
2205}
2206
2207#[derive(Debug, Serialize)]
2209struct McpErrorPayload {
2210 kind: AgentErrorKind,
2212 message: String,
2214 #[serde(skip_serializing_if = "Option::is_none")]
2216 refresh_required: Option<IndexRefreshRequired>,
2217 #[serde(skip_serializing_if = "Option::is_none")]
2219 init_required: Option<IndexInitRequired>,
2220 #[serde(skip_serializing_if = "Option::is_none")]
2222 worktree_required: Option<ProjectWorktreeRequired>,
2223 #[serde(skip_serializing_if = "Option::is_none")]
2225 verification_incomplete: Option<IndexVerificationIncomplete>,
2226 #[serde(skip_serializing_if = "Option::is_none")]
2228 project_mismatch: Option<IndexProjectMismatch>,
2229 #[serde(skip_serializing_if = "Option::is_none")]
2231 database_filesystem: Option<DatabaseFilesystemErrorPayload>,
2232 #[serde(skip_serializing_if = "Option::is_none")]
2234 schema_version_mismatch: Option<SchemaVersionMismatchPayload>,
2235 #[serde(skip_serializing_if = "Option::is_none")]
2237 schema_migration_required: Option<SchemaMigrationRequiredPayload>,
2238 #[serde(skip_serializing_if = "Option::is_none")]
2240 search_capability: Option<crate::SearchCapabilityErrorPayload>,
2241 #[serde(skip_serializing_if = "Option::is_none")]
2243 next: Option<McpNextCall>,
2244}
2245
2246#[derive(Debug, Serialize)]
2248struct McpNextCall {
2249 tool: &'static str,
2251 #[serde(skip_serializing_if = "Option::is_none")]
2253 project_path: Option<String>,
2254 #[serde(skip_serializing_if = "Option::is_none")]
2256 worktree: Option<String>,
2257}
2258
2259#[derive(Debug, Serialize)]
2261struct McpProjectStateResponse {
2262 project: McpProjectStatePayload,
2264}
2265
2266#[derive(Debug, Serialize)]
2268struct McpProjectStatePayload {
2269 #[serde(skip_serializing_if = "Option::is_none")]
2271 worktree: Option<String>,
2272 #[serde(skip_serializing_if = "Option::is_none")]
2274 registration_id: Option<i64>,
2275 root: Option<String>,
2277 db: Option<String>,
2279 config: Option<String>,
2281 status: McpProjectStatus,
2283}
2284
2285#[derive(Debug, Serialize)]
2287#[serde(rename_all = "snake_case")]
2288enum McpProjectStatus {
2289 Active,
2291}
2292
2293#[derive(Debug, Serialize)]
2295struct McpPurposeSetResponse {
2296 purpose_set: McpPurposeSetPayload,
2298}
2299
2300#[derive(Debug, Serialize)]
2302struct McpPurposeSetPayload {
2303 path: String,
2305 #[serde(skip_serializing_if = "Option::is_none")]
2307 classification: Option<ContentClassification>,
2308 status: PurposeStatus,
2310 source: PurposeSource,
2312 agent_reviewed: bool,
2314}
2315
2316#[derive(Debug, Serialize)]
2318struct McpSessionCapabilities {
2319 runtime: RuntimeInfoReport,
2321 selected_project: McpSelectedProjectCapability,
2323 startup_policy: McpStartupPolicy,
2325 path_scope: McpPathScope,
2327 scan_policy: McpScanPolicy,
2329 classified_navigation: SettingsClassifiedNavigationReport,
2331 telemetry: McpTelemetryPolicy,
2333 privacy: McpPrivacyPolicy,
2335}
2336
2337#[derive(Debug, Serialize)]
2339struct McpSelectedProjectCapability {
2340 #[serde(skip_serializing_if = "Option::is_none")]
2342 worktree: Option<String>,
2343 #[serde(skip_serializing_if = "Option::is_none")]
2345 registration_id: Option<i64>,
2346 root: Option<String>,
2348 db: Option<String>,
2350 config: Option<String>,
2352 index_status: McpIndexStatus,
2354}
2355
2356#[derive(Debug, Serialize)]
2358struct McpStartupPolicy {
2359 nearest_project: McpPolicyState,
2361}
2362
2363#[derive(Debug, Serialize)]
2365struct McpScanPolicy {
2366 implicit_scan: McpPolicyState,
2368 text_index_max_bytes: u64,
2370}
2371
2372#[derive(Debug, Serialize)]
2374struct McpTelemetryPolicy {
2375 mode: McpPolicyState,
2377}
2378
2379#[derive(Debug, Serialize)]
2381struct McpPrivacyPolicy {
2382 environment_dump: bool,
2384 secret_values: bool,
2386 projectatlas_paths_only: bool,
2388}
2389
2390#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2392#[serde(rename_all = "snake_case")]
2393enum McpPolicyState {
2394 Enabled,
2396 Disabled,
2398}
2399
2400#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2402#[serde(rename_all = "snake_case")]
2403enum McpIndexStatus {
2404 Available,
2406 Missing,
2408}
2409
2410#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2412#[serde(rename_all = "snake_case")]
2413enum McpPathScope {
2414 SelectedProject,
2416 NearestIndexedProject,
2418}
2419
2420#[derive(Debug, Serialize)]
2422struct McpFileSummaryPayload<'a> {
2423 file_summary: McpFileSummary<'a>,
2425}
2426
2427#[derive(Debug, Serialize)]
2429struct McpFileSummary<'a> {
2430 file_path: &'a str,
2432 classification: projectatlas_core::language::ContentClassification,
2434 language: &'a str,
2436 line_count: usize,
2438 #[serde(skip_serializing_if = "Option::is_none")]
2440 source_status: Option<&'a str>,
2441 #[serde(skip_serializing_if = "Option::is_none")]
2443 source_error: Option<&'a str>,
2444 parser_kind: &'a str,
2446 summary_status: &'a str,
2448 #[serde(skip_serializing_if = "Option::is_none")]
2450 file_purpose: Option<&'a str>,
2451 #[serde(skip_serializing_if = "Option::is_none")]
2453 file_purpose_status: Option<&'a str>,
2454 #[serde(skip_serializing_if = "Option::is_none")]
2456 file_purpose_source: Option<&'a str>,
2457 #[serde(skip_serializing_if = "is_false")]
2459 file_purpose_agent_reviewed: bool,
2460 content_summary: &'a str,
2462 #[serde(skip_serializing_if = "Option::is_none")]
2464 package: Option<&'a str>,
2465 #[serde(skip_serializing_if = "Option::is_none")]
2467 docstring: Option<&'a str>,
2468 #[serde(skip_serializing_if = "is_false")]
2470 truncated: bool,
2471 #[serde(skip_serializing_if = "Option::is_none")]
2473 functions: Option<Vec<McpFileSymbolSummary<'a>>>,
2474 #[serde(skip_serializing_if = "Option::is_none")]
2476 methods: Option<Vec<McpFileSymbolSummary<'a>>>,
2477 #[serde(skip_serializing_if = "Option::is_none")]
2479 classes: Option<Vec<McpFileSymbolSummary<'a>>>,
2480 #[serde(skip_serializing_if = "Option::is_none")]
2482 types: Option<Vec<McpFileSymbolSummary<'a>>>,
2483 #[serde(skip_serializing_if = "Option::is_none")]
2485 imports: Option<&'a [String]>,
2486 #[serde(skip_serializing_if = "Option::is_none")]
2488 dependencies: Option<&'a [String]>,
2489 #[serde(skip_serializing_if = "Option::is_none")]
2491 exports: Option<&'a [String]>,
2492 #[serde(skip_serializing_if = "Option::is_none")]
2494 calls: Option<&'a [FileCallSummary]>,
2495 #[serde(skip_serializing_if = "Option::is_none")]
2497 coverage: Option<McpCompactCoverageDigest<'a>>,
2498}
2499
2500#[derive(Debug, Serialize)]
2502struct McpFileSymbolSummary<'a> {
2503 name: &'a str,
2505 kind: &'a str,
2507 line: usize,
2509 end_line: usize,
2511 signature: &'a str,
2513 exported: bool,
2515 #[serde(skip_serializing_if = "Option::is_none")]
2517 documentation: Option<&'a str>,
2518 #[serde(skip_serializing_if = "Option::is_none")]
2520 parent: Option<&'a str>,
2521 #[serde(skip_serializing_if = "Option::is_none")]
2523 called_by: Option<&'a [String]>,
2524}
2525
2526impl<'a> From<&'a FileSymbolSummary> for McpFileSymbolSummary<'a> {
2527 fn from(symbol: &'a FileSymbolSummary) -> Self {
2528 Self {
2529 name: &symbol.name,
2530 kind: &symbol.kind,
2531 line: symbol.line,
2532 end_line: symbol.end_line,
2533 signature: &symbol.signature,
2534 exported: symbol.exported,
2535 documentation: nonempty_str(&symbol.documentation),
2536 parent: nonempty_str(&symbol.parent),
2537 called_by: nonempty_slice(&symbol.called_by),
2538 }
2539 }
2540}
2541
2542fn compact_file_symbols(symbols: &[FileSymbolSummary]) -> Option<Vec<McpFileSymbolSummary<'_>>> {
2544 (!symbols.is_empty()).then(|| symbols.iter().map(McpFileSymbolSummary::from).collect())
2545}
2546
2547impl<'a> From<&'a FileSummaryReport> for McpFileSummary<'a> {
2548 fn from(report: &'a FileSummaryReport) -> Self {
2549 let reviewed_purpose = report.file_purpose_agent_reviewed;
2550 let coverage_requires_attention = !report.coverage.available
2551 || report.coverage.trust != CoverageTrustState::Trusted
2552 || report.coverage.omitted > 0
2553 || report.coverage.truncated;
2554 Self {
2555 file_path: &report.file_path,
2556 classification: report.classification,
2557 language: &report.language,
2558 line_count: report.line_count,
2559 source_status: (report.source_status != MCP_FILE_SOURCE_STATUS_LIVE)
2560 .then_some(report.source_status.as_str()),
2561 source_error: nonempty_str(&report.source_error),
2562 parser_kind: &report.parser_kind,
2563 summary_status: &report.summary_status,
2564 file_purpose: nonempty_str(&report.file_purpose),
2565 file_purpose_status: (!reviewed_purpose).then_some(report.file_purpose_status.as_str()),
2566 file_purpose_source: (!reviewed_purpose).then_some(report.file_purpose_source.as_str()),
2567 file_purpose_agent_reviewed: reviewed_purpose,
2568 content_summary: &report.content_summary,
2569 package: nonempty_str(&report.package),
2570 docstring: nonempty_str(&report.docstring),
2571 truncated: report.truncated,
2572 functions: compact_file_symbols(&report.functions),
2573 methods: compact_file_symbols(&report.methods),
2574 classes: compact_file_symbols(&report.classes),
2575 types: compact_file_symbols(&report.types),
2576 imports: nonempty_slice(&report.imports),
2577 dependencies: nonempty_slice(&report.dependencies),
2578 exports: nonempty_slice(&report.exports),
2579 calls: nonempty_slice(&report.calls),
2580 coverage: coverage_requires_attention
2581 .then(|| McpCompactCoverageDigest::from(&report.coverage)),
2582 }
2583 }
2584}
2585
2586#[derive(Debug, Serialize)]
2588struct McpCompactCoverageStateCounts {
2589 #[serde(skip_serializing_if = "is_zero_u32")]
2591 complete: u32,
2592 #[serde(skip_serializing_if = "is_zero_u32")]
2594 no_candidates: u32,
2595 #[serde(skip_serializing_if = "is_zero_u32")]
2597 partial: u32,
2598 #[serde(skip_serializing_if = "is_zero_u32")]
2600 failed: u32,
2601 #[serde(skip_serializing_if = "is_zero_u32")]
2603 ignored: u32,
2604 #[serde(skip_serializing_if = "is_zero_u32")]
2606 oversized: u32,
2607 #[serde(skip_serializing_if = "is_zero_u32")]
2609 quarantined: u32,
2610 #[serde(skip_serializing_if = "is_zero_u32")]
2612 stale: u32,
2613}
2614
2615#[derive(Debug, Serialize)]
2617struct McpCompactCoverageDigest<'a> {
2618 #[serde(skip_serializing_if = "is_true")]
2620 available: bool,
2621 active_generation: &'a IndexGeneration,
2623 #[serde(skip_serializing_if = "Option::is_none")]
2625 parser: Option<&'a ParserKind>,
2626 #[serde(skip_serializing_if = "Option::is_none")]
2628 provider: Option<&'a ParserKind>,
2629 states: McpCompactCoverageStateCounts,
2631 total: u64,
2633 covered: u64,
2635 #[serde(skip_serializing_if = "is_zero_u64")]
2637 omitted: u64,
2638 #[serde(skip_serializing_if = "is_zero_u32")]
2640 relation_rows: u32,
2641 #[serde(skip_serializing_if = "is_false")]
2643 truncated: bool,
2644 trust: &'a CoverageTrustState,
2646 next_call: &'a NavigationNextCall,
2648}
2649
2650impl<'a> From<&'a CoverageDigest> for McpCompactCoverageDigest<'a> {
2651 fn from(coverage: &'a CoverageDigest) -> Self {
2652 Self {
2653 available: coverage.available,
2654 active_generation: &coverage.active_generation,
2655 parser: coverage.parser.as_ref(),
2656 provider: coverage.provider.as_ref(),
2657 states: McpCompactCoverageStateCounts {
2658 complete: coverage.states.complete,
2659 no_candidates: coverage.states.no_candidates,
2660 partial: coverage.states.partial,
2661 failed: coverage.states.failed,
2662 ignored: coverage.states.ignored,
2663 oversized: coverage.states.oversized,
2664 quarantined: coverage.states.quarantined,
2665 stale: coverage.states.stale,
2666 },
2667 total: coverage.total,
2668 covered: coverage.covered,
2669 omitted: coverage.omitted,
2670 relation_rows: coverage.relation_rows,
2671 truncated: coverage.truncated,
2672 trust: &coverage.trust,
2673 next_call: &coverage.next_call,
2674 }
2675 }
2676}
2677
2678#[derive(Debug, Serialize)]
2680struct McpCompactDetailedRelationNode<'a> {
2681 selector: &'a EntitySelector,
2683 purpose: &'a RelationPurpose,
2685 #[serde(skip_serializing_if = "Option::is_none")]
2687 coverage: Option<&'a [CoverageRecord]>,
2688}
2689
2690impl<'a> From<&'a DetailedRelationNode> for McpCompactDetailedRelationNode<'a> {
2691 fn from(node: &'a DetailedRelationNode) -> Self {
2692 Self {
2693 selector: node.entity.selector(),
2694 purpose: &node.purpose,
2695 coverage: nonempty_slice(&node.coverage),
2696 }
2697 }
2698}
2699
2700#[derive(Debug, Serialize)]
2702#[serde(tag = "status", rename_all = "snake_case")]
2703enum McpCompactRelationResolution<'a> {
2704 Resolved {
2706 selector: &'a ReusableTargetSelector,
2708 generation: IndexGeneration,
2710 },
2711 Ambiguous {
2713 reference: &'a GraphIdentityText,
2715 candidates: u32,
2717 },
2718 Unresolved {
2720 reference: &'a GraphIdentityText,
2722 },
2723 External {
2725 external: &'a ExternalSelector,
2727 generation: IndexGeneration,
2729 },
2730}
2731
2732impl<'a> From<&'a RelationResolution> for McpCompactRelationResolution<'a> {
2733 fn from(resolution: &'a RelationResolution) -> Self {
2734 match resolution {
2735 RelationResolution::Resolved {
2736 selector,
2737 generation,
2738 ..
2739 } => Self::Resolved {
2740 selector,
2741 generation: *generation,
2742 },
2743 RelationResolution::Ambiguous {
2744 reference,
2745 candidates,
2746 } => Self::Ambiguous {
2747 reference,
2748 candidates: candidates.get(),
2749 },
2750 RelationResolution::Unresolved { reference } => Self::Unresolved { reference },
2751 RelationResolution::External {
2752 external,
2753 generation,
2754 ..
2755 } => Self::External {
2756 external,
2757 generation: *generation,
2758 },
2759 }
2760 }
2761}
2762
2763#[derive(Debug, Serialize)]
2765struct McpCompactLogicalRelation<'a> {
2766 kind: GraphRelationKind,
2768 resolution: McpCompactRelationResolution<'a>,
2770 confidence: ConfidenceClass,
2772 completeness: Completeness,
2774 generation: IndexGeneration,
2776}
2777
2778#[derive(Debug, Serialize)]
2780struct McpCompactDetailedRelationRow<'a> {
2781 depth: u32,
2783 direction: RelationDirection,
2785 relation: McpCompactLogicalRelation<'a>,
2787 #[serde(skip_serializing_if = "Option::is_none")]
2789 document_unresolved_reason: Option<DocumentTargetUnresolvedReason>,
2790 source: McpCompactDetailedRelationNode<'a>,
2792 #[serde(skip_serializing_if = "Option::is_none")]
2794 target: Option<McpCompactDetailedRelationNode<'a>>,
2795 #[serde(skip_serializing_if = "Option::is_none")]
2797 target_purpose: Option<&'a RelationPurpose>,
2798 #[serde(skip_serializing_if = "Option::is_none")]
2800 path: Option<Vec<&'a EntitySelector>>,
2801 #[serde(skip_serializing_if = "Option::is_none")]
2803 occurrences: Option<Vec<McpCompactRelationOccurrence<'a>>>,
2804 #[serde(skip_serializing_if = "is_false")]
2806 occurrences_truncated: bool,
2807 #[serde(skip_serializing_if = "Option::is_none")]
2809 next_call: Option<&'a RelationNextCall>,
2810}
2811
2812#[derive(Debug, Serialize)]
2814struct McpCompactRelationOccurrence<'a> {
2815 file: &'a RepositoryFilePath,
2817 span: SourceSpan,
2819 generation: IndexGeneration,
2821}
2822
2823impl<'a> From<&'a RelationOccurrence> for McpCompactRelationOccurrence<'a> {
2824 fn from(occurrence: &'a RelationOccurrence) -> Self {
2825 Self {
2826 file: occurrence.file(),
2827 span: occurrence.span(),
2828 generation: occurrence.generation(),
2829 }
2830 }
2831}
2832
2833impl<'a> From<&'a DetailedRelationRow> for McpCompactDetailedRelationRow<'a> {
2834 fn from(row: &'a DetailedRelationRow) -> Self {
2835 Self {
2836 depth: row.depth,
2837 direction: row.direction,
2838 relation: McpCompactLogicalRelation {
2839 kind: row.relation.kind(),
2840 resolution: McpCompactRelationResolution::from(row.relation.resolution()),
2841 confidence: row.relation.confidence(),
2842 completeness: row.relation.completeness(),
2843 generation: row.relation.generation(),
2844 },
2845 document_unresolved_reason: row.document_unresolved_reason,
2846 source: McpCompactDetailedRelationNode::from(&row.source),
2847 target: row
2848 .target
2849 .as_ref()
2850 .map(McpCompactDetailedRelationNode::from),
2851 target_purpose: row.target.is_none().then_some(&row.target_purpose),
2852 path: (row.path.len() > 2)
2853 .then(|| row.path.iter().map(|node| node.entity.selector()).collect()),
2854 occurrences: (!row.occurrences.is_empty()).then(|| {
2855 row.occurrences
2856 .iter()
2857 .map(McpCompactRelationOccurrence::from)
2858 .collect()
2859 }),
2860 occurrences_truncated: row.occurrences_truncated,
2861 next_call: row.next_call.as_ref(),
2862 }
2863 }
2864}
2865
2866#[derive(Debug, Serialize)]
2868struct McpCompactDetailedRelationReport<'a> {
2869 anchor: McpCompactDetailedRelationNode<'a>,
2871 generation: IndexGeneration,
2873 authored_purpose_revision: u64,
2875 direction: RelationDirection,
2877 returned: u32,
2879 #[serde(skip_serializing_if = "is_zero_u64")]
2881 pruned_paths: u64,
2882 #[serde(skip_serializing_if = "is_false")]
2884 truncated: bool,
2885 #[serde(skip_serializing_if = "Option::is_none")]
2887 next_call: Option<McpCompactRelationContinuationCall<'a>>,
2888 total: &'a RelationTotalState,
2890 #[serde(skip_serializing_if = "Option::is_none")]
2892 reached_limits: Option<&'a [GraphLimitKind]>,
2893 work: &'a DetailedRelationWork,
2895 rows: Vec<McpCompactDetailedRelationRow<'a>>,
2897}
2898
2899#[derive(Debug, Serialize)]
2901struct McpCompactRelationContinuationCall<'a> {
2902 tool: &'static str,
2904 arguments: McpCompactRelationContinuationArguments<'a>,
2906}
2907
2908#[derive(Debug, Serialize)]
2910struct McpCompactRelationContinuationArguments<'a> {
2911 #[serde(skip_serializing_if = "Option::is_none")]
2913 project_path: Option<&'a str>,
2914 #[serde(skip_serializing_if = "Option::is_none")]
2916 worktree: Option<&'a str>,
2917 file: &'a str,
2919 #[serde(skip_serializing_if = "Option::is_none")]
2921 nearest_project: Option<bool>,
2922 #[serde(skip_serializing_if = "Option::is_none")]
2924 roots: Option<&'a [String]>,
2925 #[serde(skip_serializing_if = "Option::is_none")]
2927 worktrees: Option<&'a [String]>,
2928 view: &'static str,
2930 compact: bool,
2932 cursor: &'a str,
2934 #[serde(skip_serializing_if = "Option::is_none")]
2936 symbol: Option<&'a str>,
2937 #[serde(skip_serializing_if = "Option::is_none")]
2939 symbol_parent: Option<&'a str>,
2940 #[serde(skip_serializing_if = "Option::is_none")]
2942 symbol_kind: Option<&'a str>,
2943 #[serde(skip_serializing_if = "Option::is_none")]
2945 symbol_signature: Option<&'a str>,
2946 #[serde(skip_serializing_if = "Option::is_none")]
2948 direction: Option<&'a str>,
2949 #[serde(skip_serializing_if = "Option::is_none")]
2951 relation: Option<&'a str>,
2952 #[serde(skip_serializing_if = "Option::is_none")]
2954 minimum_confidence: Option<&'a str>,
2955 #[serde(skip_serializing_if = "Option::is_none")]
2957 resolution: Option<&'a str>,
2958 #[serde(skip_serializing_if = "Option::is_none")]
2960 depth: Option<u32>,
2961 #[serde(skip_serializing_if = "Option::is_none")]
2963 include_occurrences: Option<bool>,
2964 #[serde(skip_serializing_if = "Option::is_none")]
2966 limit: Option<usize>,
2967 #[serde(skip_serializing_if = "Option::is_none")]
2969 occurrence_limit: Option<u32>,
2970 #[serde(skip_serializing_if = "Option::is_none")]
2972 edge_limit: Option<u32>,
2973 #[serde(skip_serializing_if = "Option::is_none")]
2975 node_limit: Option<u32>,
2976 #[serde(skip_serializing_if = "Option::is_none")]
2978 visited_limit: Option<u32>,
2979 #[serde(skip_serializing_if = "Option::is_none")]
2981 occurrence_total_limit: Option<u32>,
2982 #[serde(skip_serializing_if = "Option::is_none")]
2984 intermediate_bytes: Option<u64>,
2985 #[serde(skip_serializing_if = "Option::is_none")]
2987 deadline_ms: Option<u64>,
2988 #[serde(skip_serializing_if = "Option::is_none")]
2990 output_bytes: Option<u32>,
2991}
2992
2993impl<'a> McpCompactDetailedRelationReport<'a> {
2994 fn new(
2996 report: &'a DetailedRelationReport,
2997 file: &'a str,
2998 params: &'a AtlasSymbolRelationsParams,
2999 ) -> Self {
3000 Self {
3001 anchor: McpCompactDetailedRelationNode::from(&report.anchor),
3002 generation: report.generation,
3003 authored_purpose_revision: report.authored_purpose_revision,
3004 direction: report.direction,
3005 returned: report.returned,
3006 pruned_paths: report.pruned_paths,
3007 truncated: report.truncated,
3008 next_call: report.continuation.as_deref().map(|cursor| {
3009 McpCompactRelationContinuationCall {
3010 tool: MCP_TOOL_ATLAS_SYMBOL_RELATIONS,
3011 arguments: McpCompactRelationContinuationArguments {
3012 project_path: params.project_path.as_deref(),
3013 worktree: params.worktree.as_deref(),
3014 file,
3015 nearest_project: params.nearest_project,
3016 roots: params.roots.as_deref(),
3017 worktrees: params.worktrees.as_deref(),
3018 view: MCP_SYMBOL_RELATION_VIEW_DETAILED,
3019 compact: true,
3020 cursor,
3021 symbol: params.symbol.as_deref(),
3022 symbol_parent: params.symbol_parent.as_deref().and_then(nonempty_str),
3023 symbol_kind: params.symbol_kind.as_deref().and_then(nonempty_str),
3024 symbol_signature: params.symbol_signature.as_deref().and_then(nonempty_str),
3025 direction: params.direction.as_deref(),
3026 relation: params.relation.as_deref(),
3027 minimum_confidence: params.minimum_confidence.as_deref(),
3028 resolution: params.resolution.as_deref(),
3029 depth: params.depth,
3030 include_occurrences: params.include_occurrences,
3031 limit: params.limit,
3032 occurrence_limit: params.occurrence_limit,
3033 edge_limit: params.edge_limit,
3034 node_limit: params.node_limit,
3035 visited_limit: params.visited_limit,
3036 occurrence_total_limit: params.occurrence_total_limit,
3037 intermediate_bytes: params.intermediate_bytes,
3038 deadline_ms: params.deadline_ms,
3039 output_bytes: params.output_bytes,
3040 },
3041 }
3042 }),
3043 total: &report.total,
3044 reached_limits: nonempty_slice(&report.reached_limits),
3045 work: &report.work,
3046 rows: report
3047 .rows
3048 .iter()
3049 .map(McpCompactDetailedRelationRow::from)
3050 .collect(),
3051 }
3052 }
3053}
3054
3055#[derive(Debug, Serialize)]
3057struct McpCompactFederatedDetailedRelationReport<'a> {
3058 participants: &'a [FederatedParticipant],
3060 #[serde(skip_serializing_if = "Option::is_none")]
3062 primary_worktree: Option<&'a str>,
3063 primary: McpCompactDetailedRelationReport<'a>,
3065 #[serde(skip_serializing_if = "Option::is_none")]
3067 rendezvous: Option<&'a [FederatedRendezvous]>,
3068 #[serde(skip_serializing_if = "is_false")]
3070 truncated: bool,
3071 #[serde(skip_serializing_if = "Option::is_none")]
3073 reached_limits: Option<&'a [GraphLimitKind]>,
3074 work: &'a FederatedRelationWork,
3076}
3077
3078impl<'a> McpCompactFederatedDetailedRelationReport<'a> {
3079 fn new(
3081 report: &'a FederatedDetailedRelationReport,
3082 file: &'a str,
3083 params: &'a AtlasSymbolRelationsParams,
3084 ) -> Self {
3085 Self {
3086 participants: &report.participants,
3087 primary_worktree: report.primary_worktree.as_deref(),
3088 primary: McpCompactDetailedRelationReport::new(&report.primary, file, params),
3089 rendezvous: nonempty_slice(&report.rendezvous),
3090 truncated: report.truncated,
3091 reached_limits: nonempty_slice(&report.reached_limits),
3092 work: &report.work,
3093 }
3094 }
3095}
3096
3097fn nonempty_str(value: &str) -> Option<&str> {
3099 (!value.is_empty()).then_some(value)
3100}
3101
3102fn parse_content_selection(value: Option<&str>) -> Result<ContentSelection, CliError> {
3104 value
3105 .map(str::parse::<ContentSelection>)
3106 .transpose()
3107 .map(Option::unwrap_or_default)
3108 .map_err(|error| CliError::InvalidInput(error.to_string()))
3109}
3110
3111fn nonempty_slice<T>(value: &[T]) -> Option<&[T]> {
3113 (!value.is_empty()).then_some(value)
3114}
3115
3116#[allow(clippy::trivially_copy_pass_by_ref)]
3118const fn is_zero_u32(value: &u32) -> bool {
3119 *value == 0
3120}
3121
3122#[allow(clippy::trivially_copy_pass_by_ref)]
3124const fn is_zero_u64(value: &u64) -> bool {
3125 *value == 0
3126}
3127
3128#[derive(Debug, Serialize)]
3130struct McpSessionBrief {
3131 project: McpSelectedProjectCapability,
3133 policy: McpBriefPolicy,
3135 overview: Option<Overview>,
3137 folders: Vec<McpBriefCandidate>,
3139 files: Vec<McpBriefCandidate>,
3141 blockers: McpBriefBlockers,
3143 purpose_handoff: Option<PurposeCuratorHandoff>,
3145 recommendations: Vec<McpBriefRecommendation>,
3147 limits: McpBriefLimits,
3149}
3150
3151#[derive(Debug, Serialize)]
3153struct McpCompactSessionBrief {
3154 project: McpCompactBriefProject,
3156 #[serde(skip_serializing_if = "Option::is_none")]
3158 policy: Option<McpBriefPolicy>,
3159 #[serde(skip_serializing_if = "Option::is_none")]
3161 overview: Option<McpCompactBriefOverview>,
3162 #[serde(skip_serializing_if = "Vec::is_empty")]
3164 folders: Vec<McpCompactBriefCandidate>,
3165 #[serde(skip_serializing_if = "Vec::is_empty")]
3167 files: Vec<McpCompactBriefCandidate>,
3168 #[serde(skip_serializing_if = "Option::is_none")]
3170 blockers: Option<McpCompactBriefBlockers>,
3171 #[serde(skip_serializing_if = "Option::is_none")]
3173 purpose_handoff: Option<McpCompactBriefPurposeHandoff>,
3174 recommendations: Vec<McpCompactBriefRecommendation>,
3176 #[serde(skip_serializing_if = "Option::is_none")]
3178 limits: Option<McpCompactBriefLimits>,
3179}
3180
3181#[derive(Debug, Serialize)]
3183struct McpCompactBriefProject {
3184 #[serde(skip_serializing_if = "Option::is_none")]
3186 worktree: Option<String>,
3187 #[serde(skip_serializing_if = "Option::is_none")]
3189 registration_id: Option<i64>,
3190 root: Option<String>,
3192 index_status: McpIndexStatus,
3194}
3195
3196#[derive(Debug, Serialize)]
3198struct McpCompactBriefOverview {
3199 files: usize,
3201 folders: usize,
3203}
3204
3205#[derive(Clone, Copy, Debug, Serialize)]
3207struct McpBriefPolicy {
3208 nearest_project: McpPolicyState,
3210 path_scope: McpPathScope,
3212}
3213
3214impl McpBriefPolicy {
3215 fn is_default(self) -> bool {
3217 self.nearest_project == McpPolicyState::Disabled
3218 && self.path_scope == McpPathScope::SelectedProject
3219 }
3220}
3221
3222#[derive(Debug, Serialize)]
3224struct McpBriefCandidate {
3225 path: String,
3227 kind: String,
3229 purpose_status: PurposeStatus,
3231 purpose_source: PurposeSource,
3233 purpose_agent_reviewed: bool,
3235 purpose: Option<String>,
3237 summary: Option<String>,
3239 reasons: Vec<String>,
3241 reason_codes: Vec<RankedReasonCode>,
3243 connection_counts: Vec<RankedConnectionCount>,
3245 connections: Vec<RankedConnection>,
3247 connections_truncated: bool,
3249 next_call: NavigationNextCall,
3251}
3252
3253#[derive(Debug, Serialize)]
3255struct McpCompactBriefCandidate {
3256 path: String,
3258 #[serde(skip_serializing_if = "Option::is_none")]
3260 purpose_status: Option<PurposeStatus>,
3261 #[serde(skip_serializing_if = "Option::is_none")]
3263 purpose_source: Option<PurposeSource>,
3264 #[serde(skip_serializing_if = "is_false")]
3266 purpose_agent_reviewed: bool,
3267 #[serde(skip_serializing_if = "Option::is_none")]
3269 purpose: Option<String>,
3270 #[serde(skip_serializing_if = "Vec::is_empty")]
3272 connections: Vec<RankedConnection>,
3273 #[serde(skip_serializing_if = "is_false")]
3275 connections_truncated: bool,
3276 next_call: NavigationNextCall,
3278}
3279
3280#[derive(Debug, Serialize)]
3282#[allow(clippy::struct_excessive_bools)]
3283struct McpCompactBriefPurposeHandoff {
3284 #[serde(skip_serializing_if = "is_true")]
3286 agent_harness_expected: bool,
3287 recommended_subagent_reasoning: &'static str,
3289 instructions: Vec<String>,
3291 #[serde(skip_serializing_if = "is_true")]
3293 main_agent_fallback: bool,
3294 #[serde(skip_serializing_if = "is_false")]
3296 server_started_curator: bool,
3297 #[serde(skip_serializing_if = "is_true")]
3299 silent_on_success: bool,
3300 #[serde(skip_serializing_if = "is_false")]
3302 truncated: bool,
3303 next_call: McpCompactBriefRecommendation,
3305}
3306
3307#[derive(Clone, Debug, Serialize)]
3309struct McpBriefBlockers {
3310 total: usize,
3312 returned: usize,
3314 truncated: bool,
3316 items: Vec<McpBriefBlocker>,
3318}
3319
3320#[derive(Debug, Serialize)]
3322struct McpCompactBriefBlockers {
3323 total: usize,
3325}
3326
3327#[allow(clippy::trivially_copy_pass_by_ref)]
3329const fn is_false(value: &bool) -> bool {
3330 !*value
3331}
3332
3333#[allow(clippy::trivially_copy_pass_by_ref)]
3335const fn is_true(value: &bool) -> bool {
3336 *value
3337}
3338
3339#[derive(Clone, Debug, Serialize)]
3341struct McpBriefBlocker {
3342 id: String,
3344 severity: Severity,
3346 category: String,
3348 path: String,
3350 related_path: Option<String>,
3352 message: String,
3354 recommendation: String,
3356}
3357
3358#[derive(Debug, Serialize)]
3360struct McpBriefRecommendation {
3361 kind: McpBriefRecommendationKind,
3363 target: String,
3365 reason: String,
3367 arguments: serde_json::Value,
3369}
3370
3371#[derive(Debug, Serialize)]
3373struct McpCompactBriefRecommendation {
3374 kind: McpBriefRecommendationKind,
3376 target: String,
3378 reason: String,
3380 arguments: serde_json::Value,
3382}
3383
3384#[derive(Clone, Copy, Debug, Serialize)]
3386#[serde(rename_all = "snake_case")]
3387enum McpBriefRecommendationKind {
3388 Init,
3390 Summary,
3392 Search,
3394 Relations,
3396 Health,
3398 PurposeQueue,
3400 FilesystemTools,
3402}
3403
3404#[derive(Debug, Serialize)]
3406struct McpBriefLimits {
3407 folder_limit: usize,
3409 file_limit: usize,
3411 blocker_limit: usize,
3413 purpose_limit: usize,
3415 folders_truncated: bool,
3417 files_truncated: bool,
3419 purposes_truncated: bool,
3421}
3422
3423#[derive(Debug, Serialize)]
3425struct McpCompactBriefLimits {
3426 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3428 folder_limit: usize,
3429 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3431 file_limit: usize,
3432 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3434 blocker_limit: usize,
3435 #[serde(skip_serializing_if = "is_compact_brief_default_limit")]
3437 purpose_limit: usize,
3438 #[serde(skip_serializing_if = "is_false")]
3440 folders_truncated: bool,
3441 #[serde(skip_serializing_if = "is_false")]
3443 files_truncated: bool,
3444 #[serde(skip_serializing_if = "is_false")]
3446 purposes_truncated: bool,
3447}
3448
3449#[allow(clippy::trivially_copy_pass_by_ref)]
3451const fn is_compact_brief_default_limit(value: &usize) -> bool {
3452 *value == COMPACT_SESSION_BRIEF_DEFAULT_LIMIT
3453}
3454
3455#[derive(Debug, Serialize)]
3457struct McpTaskStatusResponse {
3458 task_id: String,
3460 lookup: McpTaskLookupStatus,
3462 states: Vec<McpTaskState>,
3464 operations: Vec<McpTaskOperation>,
3466 registry_capacity: usize,
3468 task: Option<McpTaskRecord>,
3470}
3471
3472#[derive(Debug, Eq, PartialEq, Serialize)]
3474#[serde(rename_all = "snake_case")]
3475enum McpTaskLookupStatus {
3476 Found,
3478 NotFound,
3480}
3481
3482#[derive(Debug, Serialize)]
3484struct McpTaskCancelResponse {
3485 task_id: String,
3487 result: McpTaskCancelResult,
3489 registry_capacity: usize,
3491 task: Option<McpTaskRecord>,
3493}
3494
3495#[derive(Debug, Serialize)]
3497struct McpTaskStartResponse {
3498 task_id: String,
3500 operation: McpTaskOperation,
3502 state: McpTaskState,
3504 status_tool: &'static str,
3506 cancel_tool: &'static str,
3508}
3509
3510#[derive(Debug, Eq, PartialEq, Serialize)]
3512#[serde(rename_all = "snake_case")]
3513enum McpTaskCancelResult {
3514 CancellationRequested,
3516 NotFound,
3518 AlreadyFinished,
3520 NotCancelable,
3522}
3523
3524#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3526struct McpBackgroundResourceEnvelope {
3527 task_limit: usize,
3529 workers_per_task: usize,
3531 total_worker_limit: usize,
3533}
3534
3535impl McpBackgroundResourceEnvelope {
3536 fn for_host() -> Self {
3538 let available_workers = thread::available_parallelism().map_or(1, usize::from);
3539 Self::from_available_workers(available_workers)
3540 }
3541
3542 fn from_available_workers(available_workers: usize) -> Self {
3544 let total_worker_limit = available_workers.clamp(1, INDEX_WORKER_SAFE_CEILING);
3545 let task_limit = total_worker_limit.min(MCP_BACKGROUND_TASK_SAFE_CEILING);
3546 let workers_per_task = (total_worker_limit / task_limit).max(1);
3547 Self {
3548 task_limit,
3549 workers_per_task,
3550 total_worker_limit,
3551 }
3552 }
3553}
3554
3555#[derive(Debug, Clone)]
3557pub(crate) struct ProjectAtlasMcpServer {
3558 control_state: McpProjectState,
3560 project_state: Arc<RwLock<McpProjectState>>,
3562 session: String,
3564 usage_runtime: Arc<Mutex<McpUsageRuntime>>,
3566 allow_nearest_project: bool,
3568 task_registry: Arc<RwLock<McpTaskRegistry>>,
3570 background_resources: McpBackgroundResourceEnvelope,
3572 next_task_sequence: Arc<AtomicU64>,
3574 source_observations: Arc<SourceObservationRegistry>,
3576 tool_router: ToolRouter<Self>,
3578}
3579
3580struct McpRequestCancellationBridge {
3582 stop: Arc<std::sync::atomic::AtomicBool>,
3584 monitor: Option<thread::JoinHandle<()>>,
3586 probe: Arc<dyn Fn() -> bool + Send + Sync>,
3588}
3589
3590impl McpRequestCancellationBridge {
3591 fn start(
3593 context: &RequestContext<RoleServer>,
3594 control: &IndexWorkControl,
3595 ) -> Result<Self, CliError> {
3596 let token = context.ct.clone();
3597 Self::start_with_probe(move || token.is_cancelled(), control)
3598 }
3599
3600 fn start_with_probe<P>(probe: P, control: &IndexWorkControl) -> Result<Self, CliError>
3602 where
3603 P: Fn() -> bool + Send + Sync + 'static,
3604 {
3605 let probe: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(probe);
3606 if probe() {
3607 control.cancel();
3608 }
3609 let observed_control = control.clone();
3610 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
3611 let monitor_stop = Arc::clone(&stop);
3612 let monitor_probe = Arc::clone(&probe);
3613 let monitor = thread::Builder::new()
3614 .name(MCP_CANCELLATION_MONITOR_THREAD_NAME.to_string())
3615 .spawn(move || {
3616 while !monitor_stop.load(Ordering::Acquire) {
3617 if monitor_probe() {
3618 observed_control.cancel();
3619 break;
3620 }
3621 thread::sleep(std::time::Duration::from_millis(5));
3622 }
3623 })
3624 .map_err(|source| {
3625 let mut message = MCP_CANCELLATION_MONITOR_START_ERROR_PREFIX.to_string();
3626 message.push_str(&source.to_string());
3627 CliError::InvalidInput(message)
3628 })?;
3629 Ok(Self {
3630 stop,
3631 monitor: Some(monitor),
3632 probe,
3633 })
3634 }
3635
3636 fn synchronize(&self, control: &IndexWorkControl) {
3638 if (self.probe)() {
3639 control.cancel();
3640 }
3641 }
3642}
3643
3644impl Drop for McpRequestCancellationBridge {
3645 fn drop(&mut self) {
3646 self.stop.store(true, Ordering::Release);
3647 if let Some(monitor) = self.monitor.take() {
3648 drop(monitor.join());
3649 }
3650 }
3651}
3652
3653impl ProjectAtlasMcpServer {
3654 pub(crate) fn new(
3656 db_path: PathBuf,
3657 config_path: Option<PathBuf>,
3658 session: String,
3659 allow_nearest_project: bool,
3660 ) -> Self {
3661 let startup_state = Self::startup_project_state(db_path, config_path);
3662 Self {
3663 control_state: startup_state.clone(),
3664 project_state: Arc::new(RwLock::new(startup_state)),
3665 session,
3666 usage_runtime: Arc::new(Mutex::new(McpUsageRuntime::default())),
3667 allow_nearest_project,
3668 task_registry: Arc::new(RwLock::new(McpTaskRegistry::new())),
3669 background_resources: McpBackgroundResourceEnvelope::for_host(),
3670 next_task_sequence: Arc::new(AtomicU64::new(1)),
3671 source_observations: Arc::new(SourceObservationRegistry::default()),
3672 tool_router: Self::tool_router(),
3673 }
3674 }
3675
3676 fn open_read_store(state: &McpProjectState) -> Result<AtlasStore, CliError> {
3678 if !state.db_path.exists() {
3679 return Err(Self::with_target_error_context(
3680 index_init_required(&state.root, &state.db_path),
3681 state,
3682 ));
3683 }
3684 let store = open_atlas_store_read_only_for_project(&state.db_path, &state.root)
3685 .map_err(|error| Self::with_target_error_context(error, state))?;
3686 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)
3687 .map_err(|error| Self::with_target_error_context(error, state))?;
3688 Ok(store)
3689 }
3690
3691 fn require_initialized_worktree_target(state: &McpProjectState) -> Result<(), CliError> {
3693 if state.worktree.is_some() && !state.db_path.is_file() {
3694 return Err(Self::with_target_error_context(
3695 index_init_required(&state.root, &state.db_path),
3696 state,
3697 ));
3698 }
3699 Ok(())
3700 }
3701
3702 fn with_target_error_context(mut error: CliError, state: &McpProjectState) -> CliError {
3704 let Some(selection) = state.worktree.as_ref() else {
3705 return error;
3706 };
3707 match &mut error {
3708 CliError::InitRequired(report) => report.worktree = Some(selection.alias.clone()),
3709 CliError::RefreshRequired(report) => report.worktree = Some(selection.alias.clone()),
3710 CliError::VerificationIncomplete(report) => {
3711 report.worktree = Some(selection.alias.clone());
3712 }
3713 CliError::ProjectMismatch(report) => {
3714 report.worktree = Some(selection.alias.clone());
3715 }
3716 _ => {}
3717 }
3718 error
3719 }
3720
3721 #[cfg(test)]
3723 fn with_fresh_store<T, F>(
3724 &self,
3725 state: &McpProjectState,
3726 query: F,
3727 ) -> Result<VerifiedReadOutcome<T>, CliError>
3728 where
3729 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3730 {
3731 self.with_fresh_store_for_request(state, None, query)
3732 }
3733
3734 fn with_fresh_store_for_request<T, F>(
3736 &self,
3737 state: &McpProjectState,
3738 context: Option<RequestContext<RoleServer>>,
3739 mut query: F,
3740 ) -> Result<VerifiedReadOutcome<T>, CliError>
3741 where
3742 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<T, CliError>,
3743 {
3744 self.with_fresh_store_controlled_for_request(state, context, |store, stamp, _control| {
3745 query(store, stamp)
3746 })
3747 }
3748
3749 fn with_fresh_store_controlled_for_request<T, F>(
3751 &self,
3752 state: &McpProjectState,
3753 context: Option<RequestContext<RoleServer>>,
3754 mut query: F,
3755 ) -> Result<VerifiedReadOutcome<T>, CliError>
3756 where
3757 F: FnMut(&AtlasStore, VerifiedReadStamp, &IndexWorkControl) -> Result<T, CliError>,
3758 {
3759 if !state.db_path.is_file() {
3760 return Err(Self::with_target_error_context(
3761 index_init_required(&state.root, &state.db_path),
3762 state,
3763 ));
3764 }
3765 let control =
3766 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
3767 let bridge = context
3768 .map(|context| McpRequestCancellationBridge::start(&context, &control))
3769 .transpose()?;
3770 let result = self
3771 .source_observations
3772 .with_verified_read(
3773 &state.db_path,
3774 &state.root,
3775 state.config_path.as_deref(),
3776 &control,
3777 |store, stamp| {
3778 Self::require_captured_worktree_identity(state.worktree.as_ref(), store)?;
3779 query(store, stamp, &control)
3780 },
3781 )
3782 .map_err(|error| Self::with_target_error_context(error, state));
3783 if let Some(bridge) = bridge.as_ref() {
3784 bridge.synchronize(&control);
3785 }
3786 drop(bridge);
3787 result
3788 }
3789
3790 fn require_captured_worktree_identity(
3792 selection: Option<&McpWorktreeSelection>,
3793 store: &AtlasStore,
3794 ) -> Result<(), CliError> {
3795 let Some(expected) = selection.and_then(|selection| selection.project_instance_id) else {
3796 return Ok(());
3797 };
3798 if store.project_instance_id()? != Some(expected) {
3799 return Err(CliError::InvalidInput(
3800 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
3801 ));
3802 }
3803 Ok(())
3804 }
3805
3806 fn require_captured_control_identity(
3808 selection: Option<&McpWorktreeSelection>,
3809 control: &AtlasStore,
3810 ) -> Result<(), CliError> {
3811 let Some(expected) = selection.and_then(|selection| selection.control_project_instance_id)
3812 else {
3813 return Ok(());
3814 };
3815 if control.project_instance_id()? != Some(expected) {
3816 return Err(CliError::InvalidInput(
3817 MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT.to_string(),
3818 ));
3819 }
3820 Ok(())
3821 }
3822
3823 fn require_federated_worktree_identities(
3825 stores: Vec<FederatedStore>,
3826 selections: &[McpWorktreeSelection],
3827 ) -> Result<Vec<FederatedStore>, CliError> {
3828 if stores.len() != selections.len() {
3829 for store in stores {
3830 drop(store.finish());
3831 }
3832 return Err(CliError::InvalidInput(
3833 MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string(),
3834 ));
3835 }
3836 for (store, selection) in stores.iter().zip(selections) {
3837 if let Err(error) =
3838 Self::require_captured_worktree_identity(Some(selection), store.store())
3839 {
3840 let error = federated_worktree_error(error, &selection.alias);
3841 for store in stores {
3842 drop(store.finish());
3843 }
3844 return Err(error);
3845 }
3846 }
3847 Ok(stores)
3848 }
3849
3850 fn with_fresh_string_for_request<F>(
3852 &self,
3853 state: &McpProjectState,
3854 context: Option<RequestContext<RoleServer>>,
3855 mut query: F,
3856 ) -> Result<String, CliError>
3857 where
3858 F: FnMut(&AtlasStore, VerifiedReadStamp) -> Result<String, CliError>,
3859 {
3860 self.with_fresh_string_and_usage_for_request(state, context, |store, stamp| {
3861 Ok((query(store, stamp)?, None))
3862 })
3863 }
3864
3865 fn with_fresh_string_and_usage_for_request<F>(
3867 &self,
3868 state: &McpProjectState,
3869 context: Option<RequestContext<RoleServer>>,
3870 mut query: F,
3871 ) -> Result<String, CliError>
3872 where
3873 F: FnMut(
3874 &AtlasStore,
3875 VerifiedReadStamp,
3876 ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3877 {
3878 self.with_fresh_string_and_usage_controlled_for_request(
3879 state,
3880 context,
3881 |store, stamp, _control| query(store, stamp),
3882 )
3883 }
3884
3885 fn with_fresh_string_and_usage_controlled_for_request<F>(
3887 &self,
3888 state: &McpProjectState,
3889 context: Option<RequestContext<RoleServer>>,
3890 query: F,
3891 ) -> Result<String, CliError>
3892 where
3893 F: FnMut(
3894 &AtlasStore,
3895 VerifiedReadStamp,
3896 &IndexWorkControl,
3897 ) -> Result<(String, Option<McpUsageIntent>), CliError>,
3898 {
3899 let outcome = self.with_fresh_store_controlled_for_request(state, context, query)?;
3900 let stamp = outcome.stamp.clone();
3901 let (value, usage) = outcome.value;
3902 let output_bytes = value.len();
3903 let outcome = VerifiedReadOutcome {
3904 value,
3905 stamp,
3906 work: outcome.work,
3907 }
3908 .with_output_bytes(output_bytes);
3909 if let Some(usage) = usage {
3910 self.record_accepted_usage(state, &outcome.stamp, &usage, &outcome.value);
3911 }
3912 Ok(outcome.value)
3913 }
3914
3915 fn open_mut_store(
3917 state: &McpProjectState,
3918 control_state: &McpProjectState,
3919 ) -> Result<AtlasStore, CliError> {
3920 let Some(selection) = state
3921 .worktree
3922 .as_ref()
3923 .filter(|selection| selection.registration_id.is_some())
3924 else {
3925 let store = open_atlas_store_for_project(&state.db_path, &state.root)?;
3926 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)?;
3927 return Ok(store);
3928 };
3929 Self::open_registered_worktree_mut_store(state, control_state, selection)
3930 }
3931
3932 fn open_registered_worktree_mut_store(
3934 state: &McpProjectState,
3935 control_state: &McpProjectState,
3936 selection: &McpWorktreeSelection,
3937 ) -> Result<AtlasStore, CliError> {
3938 let alias = WorktreeAlias::parse(&selection.alias)?;
3939 let registration_id =
3940 selection
3941 .registration_id
3942 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
3943 alias: selection.alias.clone(),
3944 })?;
3945 let control = open_atlas_store_for_project(&control_state.db_path, &control_state.root)?;
3946 Self::require_captured_control_identity(Some(selection), &control)?;
3947 control.with_active_worktree_registration(registration_id, &alias, |guard| {
3948 if let Err(error) =
3949 require_registered_worktree_lifecycle(guard.registration(), &state.root)
3950 {
3951 return Ok(Err(error));
3952 }
3953 if !state.db_path.is_file() {
3954 return Ok(Err(Self::with_target_error_context(
3955 index_init_required(&state.root, &state.db_path),
3956 state,
3957 )));
3958 }
3959 let target = match open_atlas_store_for_project(&state.db_path, &state.root) {
3960 Ok(store) => store,
3961 Err(error) => return Ok(Err(error)),
3962 };
3963 let project = match target.captured_project_binding() {
3964 Ok(binding) => binding.project_instance_id,
3965 Err(error) => return Ok(Err(error.into())),
3966 };
3967 if selection
3968 .project_instance_id
3969 .is_some_and(|expected| expected != project)
3970 {
3971 return Ok(Err(CliError::InvalidInput(
3972 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
3973 )));
3974 }
3975 match guard.registration().project_instance_id {
3976 Some(bound) if bound == project => {}
3977 Some(_) => {
3978 return Ok(Err(CliError::InvalidInput(
3979 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
3980 )));
3981 }
3982 None => {
3983 let snapshot = match target.export_worktree_usage_snapshot() {
3984 Ok(snapshot) => snapshot,
3985 Err(error) => return Ok(Err(error.into())),
3986 };
3987 if let Err(error) =
3988 require_registered_worktree_lifecycle(guard.registration(), &state.root)
3989 {
3990 return Ok(Err(error));
3991 }
3992 if let Err(error) = require_current_worktree_usage_snapshot(
3993 &state.db_path,
3994 &state.root,
3995 &snapshot,
3996 ) {
3997 return Ok(Err(error));
3998 }
3999 guard.bind_project_with_usage_snapshot(&state.root, project, &snapshot)?;
4000 }
4001 }
4002 Ok(Ok(target))
4003 })?
4004 }
4005
4006 fn open_existing_mut_store(
4008 state: &McpProjectState,
4009 control_state: &McpProjectState,
4010 ) -> Result<AtlasStore, CliError> {
4011 if !state.db_path.is_file() {
4012 return Err(Self::with_target_error_context(
4013 index_init_required(&state.root, &state.db_path),
4014 state,
4015 ));
4016 }
4017 Self::open_mut_store(state, control_state)
4018 }
4019
4020 fn with_admitted_purpose_mutation<T>(
4022 &self,
4023 state: &McpProjectState,
4024 context: Option<RequestContext<RoleServer>>,
4025 mutation: impl FnOnce(&AtlasStore) -> Result<T, CliError>,
4026 ) -> Result<T, CliError> {
4027 if !state.db_path.is_file() {
4028 return Err(Self::with_target_error_context(
4029 index_init_required(&state.root, &state.db_path),
4030 state,
4031 ));
4032 }
4033 let control =
4034 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
4035 let bridge = context
4036 .map(|context| McpRequestCancellationBridge::start(&context, &control))
4037 .transpose()?;
4038 let result = self
4039 .with_admitted_purpose_mutation_controlled(state, &control, bridge.as_ref(), mutation)
4040 .map_err(|error| Self::with_target_error_context(error, state));
4041 drop(bridge);
4042 result
4043 }
4044
4045 fn with_admitted_purpose_mutation_controlled<T>(
4047 &self,
4048 state: &McpProjectState,
4049 control: &IndexWorkControl,
4050 bridge: Option<&McpRequestCancellationBridge>,
4051 mutation: impl FnOnce(&AtlasStore) -> Result<T, CliError>,
4052 ) -> Result<T, CliError> {
4053 if let Some(bridge) = bridge {
4054 bridge.synchronize(control);
4055 }
4056 let admission = self.source_observations.admit_mutation(
4057 &state.db_path,
4058 &state.root,
4059 state.config_path.as_deref(),
4060 control,
4061 )?;
4062 let store = Self::open_existing_mut_store(state, &self.control_state)?;
4063 Self::require_captured_worktree_identity(state.worktree.as_ref(), &store)?;
4064 let transaction = store.begin_purpose_mutation()?;
4065 let operation = (|| {
4066 let value = mutation(&store)?;
4067 if let Some(bridge) = bridge {
4068 bridge.synchronize(control);
4069 }
4070 admission.verify()?;
4071 if let Some(bridge) = bridge {
4072 bridge.synchronize(control);
4073 }
4074 control.check(IndexWorkStage::Publication)?;
4075 Ok(value)
4076 })();
4077 match operation {
4078 Ok(value) => {
4079 transaction.commit()?;
4080 Ok(value)
4081 }
4082 Err(operation) => Err(crate::rollback_rejected_purpose_mutation(
4083 transaction,
4084 operation,
4085 )),
4086 }
4087 }
4088
4089 fn telemetry_enabled() -> bool {
4091 !telemetry_disabled()
4092 }
4093
4094 fn record_usage_for_state<F>(&self, state: &McpProjectState, store: &AtlasStore, record: F)
4096 where
4097 F: FnMut(UsageRuntimeInstance) -> Result<(), CliError>,
4098 {
4099 self.record_usage_for_origin(state, store, None, record);
4100 }
4101
4102 fn record_usage_for_origin<F>(
4104 &self,
4105 state: &McpProjectState,
4106 store: &AtlasStore,
4107 worktree_registration_id: Option<i64>,
4108 mut record: F,
4109 ) where
4110 F: FnMut(UsageRuntimeInstance) -> Result<(), CliError>,
4111 {
4112 if telemetry_disabled() {
4113 return;
4114 }
4115 let Ok(binding) =
4116 McpUsageProjectBinding::capture_with_origin(state, store, worktree_registration_id)
4117 else {
4118 return;
4119 };
4120 let Some(project_instance) = self
4121 .usage_runtime
4122 .lock()
4123 .ok()
4124 .and_then(|mut runtime| runtime.instance_for_binding(binding, store))
4125 else {
4126 return;
4127 };
4128 let Ok(mut usage_instance) = project_instance.lock() else {
4129 return;
4130 };
4131 if !matches!(
4132 record(*usage_instance),
4133 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
4134 ) {
4135 return;
4136 }
4137
4138 let Some(next_instance) = UsageRuntimeInstance::new(UsageInstanceOwner::McpProcess) else {
4139 return;
4140 };
4141 if usage_instance.seal(store).is_err() {
4144 return;
4145 }
4146 *usage_instance = next_instance;
4147 drop(record(next_instance));
4148 }
4149
4150 fn record_accepted_usage(
4152 &self,
4153 state: &McpProjectState,
4154 stamp: &VerifiedReadStamp,
4155 intent: &McpUsageIntent,
4156 output: &str,
4157 ) {
4158 if !Self::telemetry_enabled() {
4159 return;
4160 }
4161 let Ok(store) = Self::open_read_store(state) else {
4162 return;
4163 };
4164 let Ok(binding) = store.captured_project_binding() else {
4165 return;
4166 };
4167 if binding.project_instance_id != stamp.project_instance_id {
4168 return;
4169 }
4170 if let Some(selection) = state
4171 .worktree
4172 .as_ref()
4173 .filter(|selection| selection.registration_id.is_some())
4174 {
4175 let Some(registration_id) = selection.registration_id else {
4176 return;
4177 };
4178 let event = match &intent.baseline {
4179 McpUsageBaseline::Estimate(baseline_tokens) => usage_from_estimates_with_context(
4180 &self.session,
4181 intent.command,
4182 intent.path.clone(),
4183 intent.query.clone(),
4184 *baseline_tokens,
4185 projectatlas_core::outline::estimate_tokens(output),
4186 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4187 TOKEN_BASELINE_SELECTED_CANDIDATES,
4188 TOKEN_CONFIDENCE_INFERRED,
4189 ),
4190 McpUsageBaseline::DirectoryWalk(baseline_tokens) => {
4191 usage_from_estimates_with_context(
4192 &self.session,
4193 intent.command,
4194 intent.path.clone(),
4195 intent.query.clone(),
4196 *baseline_tokens,
4197 projectatlas_core::outline::estimate_tokens(output),
4198 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
4199 TOKEN_BASELINE_DIRECTORY_WALK,
4200 TOKEN_CONFIDENCE_POLICY_ESTIMATE,
4201 )
4202 }
4203 McpUsageBaseline::Text(baseline_text) => usage_from_text(
4204 &self.session,
4205 intent.command,
4206 intent.path.clone(),
4207 intent.query.clone(),
4208 baseline_text,
4209 output,
4210 ),
4211 };
4212 let Ok(control) = Self::open_read_store(&self.control_state) else {
4213 return;
4214 };
4215 if Self::require_captured_control_identity(Some(selection), &control).is_err() {
4216 return;
4217 }
4218 if control.finish_index_read_snapshot().is_ok() {
4219 self.record_usage_for_origin(
4220 &self.control_state,
4221 &control,
4222 Some(registration_id),
4223 |usage_instance| {
4224 usage_instance.record_for_worktree(&control, registration_id, &event)
4225 },
4226 );
4227 }
4228 return;
4229 }
4230 self.record_usage_for_state(state, &store, |usage_instance| match &intent.baseline {
4231 McpUsageBaseline::Estimate(baseline_tokens) => record_usage_estimate(
4232 &store,
4233 Some(usage_instance),
4234 &self.session,
4235 intent.command,
4236 intent.path.clone(),
4237 intent.query.clone(),
4238 *baseline_tokens,
4239 output,
4240 ),
4241 McpUsageBaseline::DirectoryWalk(baseline_tokens) => {
4242 record_directory_walk_usage_estimate(
4243 &store,
4244 Some(usage_instance),
4245 &self.session,
4246 intent.command,
4247 intent.path.clone(),
4248 intent.query.clone(),
4249 *baseline_tokens,
4250 output,
4251 )
4252 }
4253 McpUsageBaseline::Text(baseline_text) => record_usage_text(
4254 &store,
4255 Some(usage_instance),
4256 &self.session,
4257 intent.command,
4258 intent.path.clone(),
4259 intent.query.clone(),
4260 baseline_text,
4261 output,
4262 ),
4263 });
4264 }
4265
4266 fn estimated_source_tokens_cached(
4268 &self,
4269 state: &McpProjectState,
4270 store: &AtlasStore,
4271 stamp: &VerifiedReadStamp,
4272 folder: Option<&str>,
4273 file_pattern: Option<&str>,
4274 ) -> Result<usize, CliError> {
4275 let key = McpSourceTokenBaselineKey {
4276 binding: McpUsageProjectBinding {
4277 root: state.root.clone(),
4278 db_path: state.db_path.clone(),
4279 project_instance_id: stamp.project_instance_id,
4280 worktree_registration_id: None,
4281 },
4282 generation: stamp.generation,
4283 folder: folder.map(ToOwned::to_owned),
4284 file_pattern: file_pattern.map(ToOwned::to_owned),
4285 };
4286 if let Some(value) = self
4287 .usage_runtime
4288 .lock()
4289 .ok()
4290 .and_then(|runtime| runtime.source_token_baseline(&key))
4291 {
4292 return Ok(value);
4293 }
4294 let value = estimated_source_tokens_for_indexed_files(store, folder, file_pattern)?;
4295 if let Ok(mut runtime) = self.usage_runtime.lock() {
4296 runtime.insert_source_token_baseline(key, value);
4297 }
4298 Ok(value)
4299 }
4300
4301 fn seal_usage_instances_for_projects(&self) {
4303 let Some(projects) = self
4304 .usage_runtime
4305 .lock()
4306 .ok()
4307 .map(|runtime| runtime.snapshot())
4308 else {
4309 return;
4310 };
4311 for project in projects {
4312 let Ok(instance) = project.instance.lock() else {
4313 continue;
4314 };
4315 if let Ok(store) =
4316 open_atlas_store_for_project(&project.binding.db_path, &project.binding.root)
4317 {
4318 drop(instance.seal(&store));
4319 }
4320 }
4321 }
4322
4323 fn load_config_for_state(state: &McpProjectState) -> Result<AtlasMapConfig, CliError> {
4325 state
4326 .config_path
4327 .as_deref()
4328 .map_or_else(
4329 || load_atlas_config_for_root(&state.root).map_err(CliError::from),
4330 |config_path| load_atlas_config(Some(config_path)).map_err(CliError::from),
4331 )
4332 .map(|config| config.with_database_path(&state.db_path))
4333 }
4334
4335 fn init_project_root(
4337 &self,
4338 project_path: Option<String>,
4339 worktree: Option<String>,
4340 ) -> Result<McpProjectState, CliError> {
4341 self.state_for_target_with_config_validation(
4342 project_path,
4343 worktree,
4344 McpConfigValidation::Immediate,
4345 )
4346 }
4347
4348 fn run_registered_worktree_init(
4350 &self,
4351 state: &McpProjectState,
4352 config_path: &Path,
4353 options: &InitBootstrapOptions,
4354 ) -> Result<InitSetupReport, CliError> {
4355 let Some(selection) = state
4356 .worktree
4357 .as_ref()
4358 .filter(|selection| selection.registration_id.is_some())
4359 else {
4360 let report =
4361 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4362 if report.ok {
4363 self.bind_initialized_registration_for_root(state)?;
4364 }
4365 return Ok(report);
4366 };
4367 if state.db_path.is_file() {
4368 let mut report =
4369 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4370 report.hydration = Some(InitHydrationPhase {
4371 status: InitHydrationStatus::Existing,
4372 source_root: None,
4373 source_project_instance_id: None,
4374 target_project_instance_id: None,
4375 baseline_generation: None,
4376 reconciled_generation: None,
4377 fallback_reason: None,
4378 });
4379 if report.ok {
4380 self.bind_initialized_worktree(selection, state)?;
4381 }
4382 return Ok(report);
4383 }
4384
4385 preflight_existing_project_binding(&state.db_path, &state.root)?;
4386 let project_dir = state.root.join(PROJECTATLAS_DIR_NAME);
4387 let nonsource_file = project_dir.join(MCP_NONSOURCE_FILE_NAME);
4388 let project_dir_existed = project_dir.exists();
4389 let config_existed = config_path.exists();
4390 let nonsource_existed = nonsource_file.exists();
4391 init_project_with_config(&state.root, Some(config_path))?;
4392
4393 let hydration = if options.no_scan {
4394 McpWorktreeHydration::Fallback(MCP_HYDRATION_NO_SCAN_REASON.to_string())
4395 } else {
4396 self.attempt_worktree_hydration(state, config_path, options)?
4397 };
4398 let mut report = match hydration {
4399 McpWorktreeHydration::Activated { hydration, scan } => {
4400 let mut report = run_init_bootstrap(
4401 &state.root,
4402 &state.db_path,
4403 Some(config_path),
4404 &InitBootstrapOptions {
4405 no_scan: true,
4406 force_rescan: options.force_rescan,
4407 text_index_max_bytes: options.text_index_max_bytes,
4408 },
4409 )?;
4410 report.scan = InitScanPhase {
4411 status: InitPhaseStatus::Verified,
4412 requested: true,
4413 force_rescan: options.force_rescan,
4414 report: Some(*scan),
4415 error: None,
4416 };
4417 report.next_steps =
4418 init_next_steps(false, false, report.purpose_handoff.queue.total);
4419 report.hydration = Some(hydration);
4420 report
4421 }
4422 McpWorktreeHydration::Fallback(reason) => {
4423 let mut report =
4424 run_init_bootstrap(&state.root, &state.db_path, Some(config_path), options)?;
4425 report.hydration = Some(InitHydrationPhase {
4426 status: InitHydrationStatus::Fallback,
4427 source_root: lossless_project_root_display(&self.control_state.root),
4428 source_project_instance_id: None,
4429 target_project_instance_id: None,
4430 baseline_generation: None,
4431 reconciled_generation: None,
4432 fallback_reason: Some(reason),
4433 });
4434 report
4435 }
4436 };
4437 report.project_dir.status = if project_dir_existed {
4438 InitPhaseStatus::Exists
4439 } else {
4440 InitPhaseStatus::Created
4441 };
4442 report.config.status = if config_existed {
4443 InitPhaseStatus::Exists
4444 } else {
4445 InitPhaseStatus::Created
4446 };
4447 report.nonsource_files.status = if nonsource_existed {
4448 InitPhaseStatus::Exists
4449 } else {
4450 InitPhaseStatus::Created
4451 };
4452 report.db.status = InitPhaseStatus::Created;
4453 if report.ok {
4454 self.bind_initialized_worktree(selection, state)?;
4455 }
4456 Ok(report)
4457 }
4458
4459 fn attempt_worktree_hydration(
4461 &self,
4462 state: &McpProjectState,
4463 config_path: &Path,
4464 options: &InitBootstrapOptions,
4465 ) -> Result<McpWorktreeHydration, CliError> {
4466 let selection = state
4467 .worktree
4468 .as_ref()
4469 .filter(|selection| selection.registration_id.is_some())
4470 .ok_or_else(|| CliError::InvalidInput(MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string()))?;
4471 let source = match Self::open_read_store(&self.control_state) {
4472 Ok(source) => source,
4473 Err(error) if Self::hydration_can_fallback(&error) => {
4474 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4475 }
4476 Err(error) => return Err(error),
4477 };
4478 Self::require_captured_control_identity(state.worktree.as_ref(), &source)?;
4479 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
4480 let control = index_work_control(&symbol_options);
4481 let mut candidate =
4482 match source.prepare_worktree_hydration(&state.root, &state.db_path, &control) {
4483 Ok(candidate) => candidate,
4484 Err(error) => {
4485 let error = Self::hydration_db_error(error);
4486 if Self::hydration_can_fallback(&error) {
4487 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4488 }
4489 return Err(error);
4490 }
4491 };
4492 let candidate_path = candidate
4493 .path()
4494 .map_err(Self::hydration_db_error)?
4495 .to_path_buf();
4496 let mut target = open_atlas_store_for_project(&candidate_path, &state.root)?;
4497 let plan = ScanRuntimePlan::for_path_controlled(
4498 Some(config_path),
4499 &state.root,
4500 options.text_index_max_bytes,
4501 &control,
4502 )?;
4503 let (scan, source_unchanged) =
4504 reconcile_hydrated_index_controlled(&mut target, &plan, &symbol_options, &control)?;
4505 drop(target);
4506 if source_unchanged {
4507 candidate
4508 .accept_verified_source_state(&control)
4509 .map_err(Self::hydration_db_error)?;
4510 }
4511 drop(source);
4512 let candidate = candidate
4513 .prepare_activation(&control)
4514 .map_err(Self::hydration_db_error)?;
4515 let activation = match self
4516 .activate_registered_worktree_hydration(state, selection, candidate, &control)
4517 {
4518 Ok(activation) => activation,
4519 Err(CliError::Db(error @ DbError::WorktreeHydrationDestinationExists { .. })) => {
4520 return Ok(McpWorktreeHydration::Fallback(error.to_string()));
4521 }
4522 Err(error) => return Err(error),
4523 };
4524 Ok(McpWorktreeHydration::Activated {
4525 hydration: InitHydrationPhase {
4526 status: InitHydrationStatus::Hydrated,
4527 source_root: lossless_project_root_display(&self.control_state.root),
4528 source_project_instance_id: Some(activation.source_project_instance_id.to_string()),
4529 target_project_instance_id: Some(activation.target_project_instance_id.to_string()),
4530 baseline_generation: Some(activation.baseline_generation.get()),
4531 reconciled_generation: Some(activation.reconciled_generation.get()),
4532 fallback_reason: None,
4533 },
4534 scan: Box::new(scan),
4535 })
4536 }
4537
4538 fn activate_registered_worktree_hydration(
4540 &self,
4541 state: &McpProjectState,
4542 selection: &McpWorktreeSelection,
4543 candidate: PreparedWorktreeHydrationCandidate,
4544 work_control: &IndexWorkControl,
4545 ) -> Result<WorktreeHydrationActivation, CliError> {
4546 self.activate_registered_worktree_hydration_with_post_publication(
4547 state,
4548 selection,
4549 candidate,
4550 work_control,
4551 || Ok(()),
4552 )
4553 }
4554
4555 fn activate_registered_worktree_hydration_with_post_publication<F>(
4557 &self,
4558 state: &McpProjectState,
4559 selection: &McpWorktreeSelection,
4560 candidate: PreparedWorktreeHydrationCandidate,
4561 work_control: &IndexWorkControl,
4562 post_publication: F,
4563 ) -> Result<WorktreeHydrationActivation, CliError>
4564 where
4565 F: FnOnce() -> Result<(), CliError>,
4566 {
4567 let alias = WorktreeAlias::parse(&selection.alias)?;
4568 let registration_id =
4569 selection
4570 .registration_id
4571 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
4572 alias: selection.alias.clone(),
4573 })?;
4574 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
4575 Self::require_captured_control_identity(Some(selection), &control)?;
4576 control.with_active_worktree_registration(registration_id, &alias, |guard| {
4577 if let Err(error) =
4578 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4579 {
4580 return Ok(Err(error));
4581 }
4582 let activation = candidate.activate(work_control)?;
4583 if let Err(error) = post_publication() {
4584 return Ok(Err(error));
4585 }
4586 let target =
4587 match open_atlas_store_read_only_for_project(&activation.database, &state.root) {
4588 Ok(target) => target,
4589 Err(error) => return Ok(Err(error)),
4590 };
4591 let snapshot = match target.export_worktree_usage_snapshot() {
4592 Ok(snapshot) => snapshot,
4593 Err(error) => return Ok(Err(error.into())),
4594 };
4595 if let Err(error) =
4596 require_registered_worktree_lifecycle(guard.registration(), &state.root)
4597 {
4598 return Ok(Err(error));
4599 }
4600 if let Err(error) = require_current_worktree_usage_snapshot(
4601 &activation.database,
4602 &state.root,
4603 &snapshot,
4604 ) {
4605 return Ok(Err(error));
4606 }
4607 guard.bind_project_with_usage_snapshot(
4608 &state.root,
4609 activation.target_project_instance_id,
4610 &snapshot,
4611 )?;
4612 Ok(Ok(activation))
4613 })?
4614 }
4615
4616 fn bind_initialized_worktree(
4618 &self,
4619 selection: &McpWorktreeSelection,
4620 state: &McpProjectState,
4621 ) -> Result<(), CliError> {
4622 drop(Self::open_registered_worktree_mut_store(
4623 state,
4624 &self.control_state,
4625 selection,
4626 )?);
4627 Ok(())
4628 }
4629
4630 fn bind_initialized_registration_for_root(
4632 &self,
4633 state: &McpProjectState,
4634 ) -> Result<(), CliError> {
4635 let Some(repository) = self.control_git_repository_if_present()? else {
4636 return Ok(());
4637 };
4638 let Some(entry) = repository
4639 .worktrees
4640 .iter()
4641 .find(|entry| Self::active_worktree_root(entry).is_some_and(|root| root == state.root))
4642 else {
4643 return Ok(());
4644 };
4645 let administrative_identity = git_administrative_identity(&entry.administrative_directory)?;
4646 let administrative_directory =
4647 CanonicalProjectRoot::from_path(&entry.administrative_directory)
4648 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
4649 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
4650 let control_project_instance_id = control.captured_project_binding()?.project_instance_id;
4651 let Some(registration) =
4652 control
4653 .worktree_registrations(false)?
4654 .into_iter()
4655 .find(|registration| {
4656 registration.git_administrative_directory_identity == administrative_directory
4657 && registration.git_administrative_identity == administrative_identity
4658 })
4659 else {
4660 return Ok(());
4661 };
4662 self.bind_initialized_worktree(
4663 &McpWorktreeSelection {
4664 alias: registration.alias.to_string(),
4665 registration_id: Some(registration.registration_id),
4666 project_instance_id: registration.project_instance_id,
4667 control_project_instance_id: Some(control_project_instance_id),
4668 },
4669 state,
4670 )
4671 }
4672
4673 fn hydration_can_fallback(error: &CliError) -> bool {
4675 matches!(
4676 error,
4677 CliError::Db(
4678 DbError::WorktreeHydrationInvalid { .. }
4679 | DbError::WorktreeHydrationDestinationExists { .. }
4680 | DbError::WorktreeHydrationBackupBusy { .. }
4681 | DbError::GraphPublicationUnavailable
4682 | DbError::GraphProjectIdentityMismatch { .. }
4683 | DbError::DerivedSnapshotInvalid { .. }
4684 | DbError::DerivedSnapshotLimit { .. }
4685 | DbError::SchemaVersion { .. }
4686 | DbError::SchemaVersionMissing
4687 | DbError::SchemaShape { .. }
4688 | DbError::IntegrityCheck { .. }
4689 | DbError::ProjectRootMissing
4690 | DbError::ProjectInstanceIdentityMissing
4691 )
4692 )
4693 }
4694
4695 fn hydration_db_error(error: DbError) -> CliError {
4697 match error {
4698 DbError::IndexWork(failure) => failure.into(),
4699 error => error.into(),
4700 }
4701 }
4702
4703 fn admin_project_root(
4705 &self,
4706 project_path: Option<String>,
4707 worktree: Option<String>,
4708 ) -> Result<McpProjectState, CliError> {
4709 self.state_for_target(project_path, worktree)
4710 }
4711
4712 fn parse_ignore_kind(
4714 kind: Option<&str>,
4715 required: bool,
4716 ) -> Result<Option<IgnoreEntryKind>, CliError> {
4717 let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) else {
4718 return if required {
4719 Err(CliError::InvalidInput(
4720 MCP_ERROR_IGNORE_KIND_REQUIRED.to_string(),
4721 ))
4722 } else {
4723 Ok(None)
4724 };
4725 };
4726 match kind {
4727 MCP_IGNORE_KIND_DIR_NAME | MCP_IGNORE_KIND_DIR_NAME_ALIAS => {
4728 Ok(Some(IgnoreEntryKind::DirName))
4729 }
4730 MCP_IGNORE_KIND_PATH_PREFIX | MCP_IGNORE_KIND_PATH_PREFIX_ALIAS => {
4731 Ok(Some(IgnoreEntryKind::PathPrefix))
4732 }
4733 other => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4734 MCP_ERROR_INVALID_IGNORE_KIND_PREFIX,
4735 other,
4736 MCP_ERROR_INVALID_IGNORE_KIND_SUFFIX,
4737 ))),
4738 }
4739 }
4740
4741 fn parse_purpose_lint_level(value: Option<&str>) -> Result<PurposeLintLevel, CliError> {
4743 match value.map(str::trim).filter(|value| !value.is_empty()) {
4744 None | Some(MCP_PURPOSE_LEVEL_LOW) => Ok(PurposeLintLevel::Low),
4745 Some(MCP_PURPOSE_LEVEL_MEDIUM) => Ok(PurposeLintLevel::Medium),
4746 Some(MCP_PURPOSE_LEVEL_STRICT) => Ok(PurposeLintLevel::Strict),
4747 Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4748 MCP_ERROR_INVALID_PURPOSE_LEVEL_PREFIX,
4749 other,
4750 MCP_ERROR_INVALID_PURPOSE_LEVEL_SUFFIX,
4751 ))),
4752 }
4753 }
4754
4755 fn parse_harness_config(value: Option<&str>) -> Result<HarnessConfig, CliError> {
4757 match value.map(str::trim).filter(|value| !value.is_empty()) {
4758 None | Some(MCP_HARNESS_MCP_JSON | MCP_HARNESS_MCP_JSON_ALIAS) => {
4759 Ok(HarnessConfig::McpJson)
4760 }
4761 Some(MCP_HARNESS_CODEX) => Ok(HarnessConfig::Codex),
4762 Some(MCP_HARNESS_CLAUDE_CODE | MCP_HARNESS_CLAUDE_CODE_ALIAS) => {
4763 Ok(HarnessConfig::ClaudeCode)
4764 }
4765 Some(MCP_HARNESS_OPENCODE) => Ok(HarnessConfig::OpenCode),
4766 Some(other) => Err(CliError::InvalidInput(Self::invalid_parameter_message(
4767 MCP_ERROR_INVALID_HARNESS_PREFIX,
4768 other,
4769 MCP_ERROR_INVALID_HARNESS_SUFFIX,
4770 ))),
4771 }
4772 }
4773
4774 fn parse_token_chart_theme(value: Option<&str>) -> Result<TokenDashboardTheme, CliError> {
4776 match value.map(str::trim).filter(|value| !value.is_empty()) {
4777 None => Ok(TokenDashboardTheme::Dark),
4778 Some(theme) => TokenDashboardTheme::parse(theme).ok_or_else(|| {
4779 CliError::InvalidInput(Self::invalid_parameter_message(
4780 TOKEN_CHART_THEME_ERROR_PREFIX,
4781 theme,
4782 TOKEN_CHART_THEME_ERROR_SUFFIX,
4783 ))
4784 }),
4785 }
4786 }
4787
4788 fn invalid_parameter_message(prefix: &str, value: &str, suffix: &str) -> String {
4790 let mut message = String::with_capacity(prefix.len() + value.len() + suffix.len());
4791 message.push_str(prefix);
4792 message.push_str(value);
4793 message.push_str(suffix);
4794 message
4795 }
4796
4797 fn build_map_report(
4799 state: &McpProjectState,
4800 json: bool,
4801 force: bool,
4802 ) -> Result<McpMapReport, CliError> {
4803 let config = Self::load_config_for_state(state)?;
4804 let skipped_reason = if !force
4805 && (crate::truthy_env(MCP_ENV_CI) || crate::truthy_env(MCP_ENV_GITHUB_ACTIONS))
4806 {
4807 Some(MCP_MAP_SKIPPED_IN_CI_REASON.to_string())
4808 } else {
4809 write_map(&config, json)?;
4810 None
4811 };
4812 Ok(McpMapReport {
4813 root: lossless_project_root_display(&config.root),
4814 map_path: lossless_native_path_display(&config.map_path),
4815 written: skipped_reason.is_none(),
4816 json,
4817 skipped_reason,
4818 })
4819 }
4820
4821 fn session_capabilities(
4823 &self,
4824 state: &McpProjectState,
4825 text_index_max_bytes: u64,
4826 ) -> McpSessionCapabilities {
4827 McpSessionCapabilities {
4828 runtime: build_runtime_info(),
4829 selected_project: Self::selected_project_capability(state),
4830 startup_policy: McpStartupPolicy {
4831 nearest_project: Self::policy_state(self.allow_nearest_project),
4832 },
4833 path_scope: self.path_scope(),
4834 scan_policy: McpScanPolicy {
4835 implicit_scan: McpPolicyState::Disabled,
4836 text_index_max_bytes,
4837 },
4838 classified_navigation: classified_navigation_capabilities(),
4839 telemetry: McpTelemetryPolicy {
4840 mode: Self::policy_state(!telemetry_disabled()),
4841 },
4842 privacy: McpPrivacyPolicy {
4843 environment_dump: false,
4844 secret_values: false,
4845 projectatlas_paths_only: true,
4846 },
4847 }
4848 }
4849
4850 fn render_settings_with_capabilities(
4852 &self,
4853 state: &McpProjectState,
4854 ) -> Result<String, CliError> {
4855 let report = build_settings_report(
4856 &state.db_path,
4857 state.config_path.as_deref(),
4858 OutputFormat::Toon,
4859 )?;
4860 let capabilities = self.session_capabilities(state, report.text_index_max_bytes);
4861 let rendered = Self::encode_two_named_payloads(
4862 MCP_PAYLOAD_SETTINGS,
4863 &report,
4864 MCP_PAYLOAD_SESSION_CAPABILITIES,
4865 &capabilities,
4866 )?;
4867 if rendered.len() > MCP_SETTINGS_RESPONSE_MAX_BYTES {
4868 let mut message = MCP_SETTINGS_RESPONSE_LIMIT_PREFIX.to_string();
4869 message.push_str(&rendered.len().to_string());
4870 message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SEPARATOR);
4871 message.push_str(&MCP_SETTINGS_RESPONSE_MAX_BYTES.to_string());
4872 message.push_str(MCP_SETTINGS_RESPONSE_LIMIT_SUFFIX);
4873 return Err(CliError::InvalidInput(message));
4874 }
4875 Ok(rendered)
4876 }
4877
4878 fn selected_project_capability(state: &McpProjectState) -> McpSelectedProjectCapability {
4880 McpSelectedProjectCapability {
4881 worktree: state
4882 .worktree
4883 .as_ref()
4884 .map(|selection| selection.alias.clone()),
4885 registration_id: state
4886 .worktree
4887 .as_ref()
4888 .and_then(|selection| selection.registration_id),
4889 root: lossless_project_root_display(&state.root),
4890 db: lossless_native_path_display(&state.db_path),
4891 config: state
4892 .config_path
4893 .as_ref()
4894 .and_then(|path| lossless_native_path_display(path)),
4895 index_status: if state.db_path.exists() {
4896 McpIndexStatus::Available
4897 } else {
4898 McpIndexStatus::Missing
4899 },
4900 }
4901 }
4902
4903 fn path_scope(&self) -> McpPathScope {
4905 if self.allow_nearest_project {
4906 McpPathScope::NearestIndexedProject
4907 } else {
4908 McpPathScope::SelectedProject
4909 }
4910 }
4911
4912 fn policy_state(enabled: bool) -> McpPolicyState {
4914 if enabled {
4915 McpPolicyState::Enabled
4916 } else {
4917 McpPolicyState::Disabled
4918 }
4919 }
4920
4921 fn brief_limit(value: Option<usize>) -> usize {
4923 value
4924 .unwrap_or(SESSION_BRIEF_DEFAULT_LIMIT)
4925 .clamp(1, SESSION_BRIEF_MAX_LIMIT)
4926 }
4927
4928 fn build_session_brief(
4930 &self,
4931 params: AtlasSessionBriefParams,
4932 context: Option<RequestContext<RoleServer>>,
4933 ) -> Result<McpSessionBrief, CliError> {
4934 let selected_project_path = params.project_path.clone();
4935 let selected_worktree = params.worktree.clone();
4936 let state =
4937 self.state_for_target(selected_project_path.clone(), selected_worktree.clone())?;
4938 let query = Self::query_or_empty(params.query);
4939 let purpose_task = params
4940 .purpose_task
4941 .unwrap_or_else(|| MCP_PURPOSE_TASK_SESSION_STARTUP.to_string());
4942 let folder_limit = Self::brief_limit(params.folder_limit);
4943 let file_limit = Self::brief_limit(params.file_limit);
4944 let blocker_limit = Self::brief_limit(params.blocker_limit);
4945 let purpose_limit = Self::brief_limit(params.purpose_limit);
4946 let project = Self::selected_project_capability(&state);
4947 if !state.db_path.exists() {
4948 let init_project_path = selected_worktree
4949 .is_none()
4950 .then(|| lossless_project_root_display(&state.root))
4951 .flatten();
4952 return Ok(McpSessionBrief {
4953 project,
4954 policy: self.brief_policy(),
4955 overview: None,
4956 folders: Vec::new(),
4957 files: Vec::new(),
4958 blockers: McpBriefBlockers {
4959 total: 0,
4960 returned: 0,
4961 truncated: false,
4962 items: Vec::new(),
4963 },
4964 purpose_handoff: None,
4965 recommendations: Self::missing_index_recommendations(
4966 init_project_path,
4967 selected_worktree,
4968 ),
4969 limits: McpBriefLimits {
4970 folder_limit,
4971 file_limit,
4972 blocker_limit,
4973 purpose_limit,
4974 folders_truncated: false,
4975 files_truncated: false,
4976 purposes_truncated: false,
4977 },
4978 });
4979 }
4980 let outcome = self.with_fresh_store_for_request(&state, context, |store, _stamp| {
4981 let overview = store.overview()?;
4982 let folder_rows =
4983 ranked_folder_nodes_with_reasons(store, &query, folder_limit.saturating_add(1))?;
4984 let file_rows = ranked_file_nodes_with_reasons(
4985 store,
4986 &query,
4987 None,
4988 None,
4989 file_limit.saturating_add(1),
4990 false,
4991 )?;
4992 let blockers = Self::brief_blockers(store, blocker_limit)?;
4993 let purpose_query = HealthQuery {
4994 start_index: 0,
4995 limit: purpose_limit,
4996 category: None,
4997 severity: None,
4998 path_prefix: None,
4999 summary_only: false,
5000 scope: HealthScope::purpose_default(),
5001 };
5002 let purpose_queue = purpose_curation_page(store, &purpose_query, &purpose_task)?;
5003 let purposes_truncated = purpose_queue.truncated;
5004 let folders_truncated = folder_rows.len() > folder_limit;
5005 let files_truncated = file_rows.len() > file_limit;
5006 let next_navigation_call = file_rows.first().map(|row| row.next_call.clone());
5007 Ok(McpSessionBrief {
5008 project: Self::selected_project_capability(&state),
5009 policy: self.brief_policy(),
5010 overview: Some(overview),
5011 folders: folder_rows
5012 .into_iter()
5013 .take(folder_limit)
5014 .map(Self::brief_candidate)
5015 .collect(),
5016 files: file_rows
5017 .into_iter()
5018 .take(file_limit)
5019 .map(Self::brief_candidate)
5020 .collect(),
5021 recommendations: Self::indexed_project_recommendations(
5022 &query,
5023 next_navigation_call,
5024 blockers.total,
5025 blocker_limit,
5026 selected_project_path.clone(),
5027 selected_worktree.clone(),
5028 ),
5029 blockers,
5030 purpose_handoff: purpose_queue
5031 .actionable
5032 .then(|| purpose_curator_handoff(purpose_queue)),
5033 limits: McpBriefLimits {
5034 folder_limit,
5035 file_limit,
5036 blocker_limit,
5037 purpose_limit,
5038 folders_truncated,
5039 files_truncated,
5040 purposes_truncated,
5041 },
5042 })
5043 })?;
5044 Ok(outcome.value)
5045 }
5046
5047 fn brief_policy(&self) -> McpBriefPolicy {
5049 McpBriefPolicy {
5050 nearest_project: Self::policy_state(self.allow_nearest_project),
5051 path_scope: self.path_scope(),
5052 }
5053 }
5054
5055 fn build_compact_session_brief(
5057 &self,
5058 mut params: AtlasSessionBriefParams,
5059 context: Option<RequestContext<RoleServer>>,
5060 ) -> Result<McpCompactSessionBrief, CliError> {
5061 let project_path = params.project_path.clone();
5062 let worktree = params.worktree.clone();
5063 params.compact = None;
5064 params
5065 .folder_limit
5066 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5067 params
5068 .file_limit
5069 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5070 params
5071 .blocker_limit
5072 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5073 params
5074 .purpose_limit
5075 .get_or_insert(COMPACT_SESSION_BRIEF_DEFAULT_LIMIT);
5076 let brief = self.build_session_brief(params, context)?;
5077 Ok(Self::compact_session_brief(
5078 &brief,
5079 project_path.as_deref(),
5080 worktree.as_deref(),
5081 ))
5082 }
5083
5084 fn compact_session_brief(
5086 brief: &McpSessionBrief,
5087 project_path: Option<&str>,
5088 worktree: Option<&str>,
5089 ) -> McpCompactSessionBrief {
5090 let omit_folders = !brief.files.is_empty();
5091 let folders_truncated =
5092 brief.limits.folders_truncated || (omit_folders && !brief.folders.is_empty());
5093 let compact_limits = McpCompactBriefLimits {
5094 folder_limit: brief.limits.folder_limit,
5095 file_limit: brief.limits.file_limit,
5096 blocker_limit: brief.limits.blocker_limit,
5097 purpose_limit: brief.limits.purpose_limit,
5098 folders_truncated,
5099 files_truncated: brief.limits.files_truncated,
5100 purposes_truncated: brief.limits.purposes_truncated,
5101 };
5102 let limits_are_default = is_compact_brief_default_limit(&compact_limits.folder_limit)
5103 && is_compact_brief_default_limit(&compact_limits.file_limit)
5104 && is_compact_brief_default_limit(&compact_limits.blocker_limit)
5105 && is_compact_brief_default_limit(&compact_limits.purpose_limit)
5106 && !compact_limits.folders_truncated
5107 && !compact_limits.files_truncated
5108 && !compact_limits.purposes_truncated;
5109 McpCompactSessionBrief {
5110 project: McpCompactBriefProject {
5111 worktree: brief.project.worktree.clone(),
5112 registration_id: brief.project.registration_id,
5113 root: brief.project.root.clone(),
5114 index_status: brief.project.index_status,
5115 },
5116 policy: (!brief.policy.is_default()).then_some(brief.policy),
5117 overview: brief
5118 .overview
5119 .as_ref()
5120 .map(|overview| McpCompactBriefOverview {
5121 files: overview.files,
5122 folders: overview.folders,
5123 }),
5124 folders: if omit_folders {
5125 Vec::new()
5126 } else {
5127 brief
5128 .folders
5129 .iter()
5130 .map(Self::compact_brief_candidate)
5131 .collect()
5132 },
5133 files: brief
5134 .files
5135 .iter()
5136 .map(Self::compact_brief_candidate)
5137 .collect(),
5138 blockers: (brief.blockers.total > 0).then_some(McpCompactBriefBlockers {
5139 total: brief.blockers.total,
5140 }),
5141 purpose_handoff: brief.purpose_handoff.as_ref().map(|handoff| {
5142 Self::compact_brief_purpose_handoff(
5143 handoff,
5144 project_path.map(ToString::to_string),
5145 worktree.map(ToString::to_string),
5146 )
5147 }),
5148 recommendations: brief
5149 .recommendations
5150 .iter()
5151 .map(Self::compact_brief_recommendation)
5152 .collect(),
5153 limits: (!limits_are_default).then_some(compact_limits),
5154 }
5155 }
5156
5157 fn brief_candidate(row: RankedNode) -> McpBriefCandidate {
5159 let purpose_agent_reviewed = row.node.purpose.agent_reviewed();
5160 McpBriefCandidate {
5161 path: row.node.node.path,
5162 kind: row.node.node.kind.to_string(),
5163 purpose_status: row.node.purpose.status,
5164 purpose_source: row.node.purpose.source,
5165 purpose_agent_reviewed,
5166 purpose: row.node.purpose.purpose,
5167 summary: row.node.summary,
5168 reasons: row.reasons,
5169 reason_codes: row.reason_codes,
5170 connection_counts: row.connection_counts,
5171 connections: row.connections,
5172 connections_truncated: row.connections_truncated,
5173 next_call: row.next_call,
5174 }
5175 }
5176
5177 fn compact_brief_candidate(row: &McpBriefCandidate) -> McpCompactBriefCandidate {
5179 let connections = row
5180 .connections
5181 .iter()
5182 .find(|connection| Self::brief_connection_is_crisp(connection))
5183 .cloned()
5184 .into_iter()
5185 .collect::<Vec<_>>();
5186 let next_call = if row.next_call.capability == NavigationNextCapability::Relations {
5187 NavigationNextCall {
5188 capability: NavigationNextCapability::Summary,
5189 path: row.path.clone(),
5190 }
5191 } else {
5192 row.next_call.clone()
5193 };
5194 McpCompactBriefCandidate {
5195 path: row.path.clone(),
5196 purpose_status: (!row.purpose_agent_reviewed).then_some(row.purpose_status),
5197 purpose_source: (!row.purpose_agent_reviewed).then_some(row.purpose_source),
5198 purpose_agent_reviewed: row.purpose_agent_reviewed,
5199 purpose: row.purpose.clone(),
5200 connections_truncated: row.connections_truncated
5201 || connections.len() < row.connections.len(),
5202 connections,
5203 next_call,
5204 }
5205 }
5206
5207 fn brief_connection_is_crisp(connection: &RankedConnection) -> bool {
5209 connection.kind != RankedConnectionKind::Import
5210 && !matches!(
5211 &connection.target,
5212 RankedConnectionTarget::Unresolved { .. }
5213 )
5214 }
5215
5216 fn compact_brief_purpose_handoff(
5218 handoff: &PurposeCuratorHandoff,
5219 project_path: Option<String>,
5220 worktree: Option<String>,
5221 ) -> McpCompactBriefPurposeHandoff {
5222 McpCompactBriefPurposeHandoff {
5223 agent_harness_expected: handoff.agent_harness_expected,
5224 recommended_subagent_reasoning: handoff.recommended_subagent_reasoning,
5225 instructions: handoff.instructions.first().cloned().into_iter().collect(),
5226 main_agent_fallback: handoff.main_agent_fallback,
5227 server_started_curator: handoff.server_started_curator,
5228 silent_on_success: handoff.silent_on_success,
5229 truncated: handoff.queue.truncated,
5230 next_call: McpCompactBriefRecommendation {
5231 kind: McpBriefRecommendationKind::PurposeQueue,
5232 target: MCP_TOOL_ATLAS_PURPOSE_QUEUE.to_string(),
5233 reason: MCP_BRIEF_REASON_PURPOSE_QUEUE.to_string(),
5234 arguments: Self::brief_call_arguments(
5235 project_path,
5236 worktree,
5237 &[(MCP_BRIEF_ARG_TASK, &handoff.queue.task)],
5238 Some((MCP_BRIEF_ARG_LIMIT, handoff.queue.limit)),
5239 ),
5240 },
5241 }
5242 }
5243
5244 fn compact_brief_recommendation(
5246 recommendation: &McpBriefRecommendation,
5247 ) -> McpCompactBriefRecommendation {
5248 let relation_to_summary =
5249 matches!(recommendation.kind, McpBriefRecommendationKind::Relations);
5250 let mut arguments = recommendation.arguments.clone();
5251 if let Some(object) = arguments.as_object_mut() {
5252 if relation_to_summary {
5253 object.remove(MCP_BRIEF_ARG_VIEW);
5254 }
5255 if relation_to_summary
5256 || matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
5257 {
5258 object.insert(
5259 MCP_BRIEF_ARG_COMPACT.to_string(),
5260 serde_json::Value::Bool(true),
5261 );
5262 }
5263 }
5264 McpCompactBriefRecommendation {
5265 kind: if relation_to_summary {
5266 McpBriefRecommendationKind::Summary
5267 } else {
5268 recommendation.kind
5269 },
5270 target: if relation_to_summary {
5271 MCP_TOOL_ATLAS_FILE_SUMMARY.to_string()
5272 } else {
5273 recommendation.target.clone()
5274 },
5275 reason: if relation_to_summary {
5276 MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string()
5277 } else {
5278 recommendation.reason.clone()
5279 },
5280 arguments,
5281 }
5282 }
5283
5284 fn brief_blockers(
5286 store: &AtlasStore,
5287 blocker_limit: usize,
5288 ) -> Result<McpBriefBlockers, CliError> {
5289 let query = HealthQuery {
5290 start_index: 0,
5291 limit: blocker_limit,
5292 category: None,
5293 severity: None,
5294 path_prefix: None,
5295 summary_only: false,
5296 scope: HealthScope::all(),
5297 };
5298 let page = store.unresolved_health_findings_page_current(&query)?;
5299 let total = page.total;
5300 let returned = page.returned;
5301 Ok(McpBriefBlockers {
5302 total,
5303 returned,
5304 truncated: returned < total,
5305 items: page
5306 .findings
5307 .into_iter()
5308 .map(|finding| McpBriefBlocker {
5309 id: finding.id,
5310 severity: finding.severity,
5311 category: finding.category,
5312 path: finding.path,
5313 related_path: finding.related_path,
5314 message: finding.message,
5315 recommendation: finding.recommendation,
5316 })
5317 .collect(),
5318 })
5319 }
5320
5321 fn missing_index_recommendations(
5323 project_path: Option<String>,
5324 worktree: Option<String>,
5325 ) -> Vec<McpBriefRecommendation> {
5326 vec![
5327 McpBriefRecommendation {
5328 kind: McpBriefRecommendationKind::Init,
5329 target: MCP_TOOL_ATLAS_INIT.to_string(),
5330 reason: MCP_BRIEF_REASON_SELECTED_INDEX_MISSING.to_string(),
5331 arguments: Self::target_arguments(project_path.clone(), worktree.clone()),
5332 },
5333 McpBriefRecommendation {
5334 kind: McpBriefRecommendationKind::FilesystemTools,
5335 target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
5336 reason: MCP_BRIEF_REASON_FILESYSTEM_UNTIL_INDEX.to_string(),
5337 arguments: Self::target_arguments(project_path, worktree),
5338 },
5339 ]
5340 }
5341
5342 fn indexed_project_recommendations(
5344 query: &str,
5345 next_navigation_call: Option<NavigationNextCall>,
5346 blocker_total: usize,
5347 blocker_limit: usize,
5348 project_path: Option<String>,
5349 worktree: Option<String>,
5350 ) -> Vec<McpBriefRecommendation> {
5351 let mut recommendations = match next_navigation_call {
5352 Some(next_call) if next_call.capability == NavigationNextCapability::Summary => {
5353 vec![McpBriefRecommendation {
5354 kind: McpBriefRecommendationKind::Summary,
5355 target: MCP_TOOL_ATLAS_FILE_SUMMARY.to_string(),
5356 reason: MCP_BRIEF_REASON_RANKED_FILE_SUMMARY.to_string(),
5357 arguments: Self::brief_call_arguments(
5358 project_path.clone(),
5359 worktree.clone(),
5360 &[(MCP_BRIEF_ARG_FILE, &next_call.path)],
5361 None,
5362 ),
5363 }]
5364 }
5365 Some(next_call) if next_call.capability == NavigationNextCapability::Relations => {
5366 vec![McpBriefRecommendation {
5367 kind: McpBriefRecommendationKind::Relations,
5368 target: MCP_TOOL_ATLAS_SYMBOL_RELATIONS.to_string(),
5369 reason: MCP_BRIEF_REASON_RANKED_FILE_RELATIONS.to_string(),
5370 arguments: Self::brief_call_arguments(
5371 project_path.clone(),
5372 worktree.clone(),
5373 &[
5374 (MCP_BRIEF_ARG_FILE, &next_call.path),
5375 (MCP_BRIEF_ARG_VIEW, MCP_SYMBOL_RELATION_VIEW_DETAILED),
5376 ],
5377 None,
5378 ),
5379 }]
5380 }
5381 _ if !query.trim().is_empty() => vec![McpBriefRecommendation {
5382 kind: McpBriefRecommendationKind::Search,
5383 target: MCP_TOOL_ATLAS_SEARCH.to_string(),
5384 reason: MCP_BRIEF_REASON_SEARCH_FALLBACK.to_string(),
5385 arguments: Self::brief_call_arguments(
5386 project_path.clone(),
5387 worktree.clone(),
5388 &[(MCP_BRIEF_ARG_PATTERN, query)],
5389 None,
5390 ),
5391 }],
5392 _ => vec![McpBriefRecommendation {
5393 kind: McpBriefRecommendationKind::FilesystemTools,
5394 target: MCP_BRIEF_TARGET_FILESYSTEM_TOOLS.to_string(),
5395 reason: MCP_BRIEF_REASON_NO_FILE_CANDIDATE.to_string(),
5396 arguments: Self::target_arguments(project_path.clone(), worktree.clone()),
5397 }],
5398 };
5399 if blocker_total > 0 {
5400 recommendations.push(McpBriefRecommendation {
5401 kind: McpBriefRecommendationKind::Health,
5402 target: MCP_TOOL_ATLAS_HEALTH.to_string(),
5403 reason: MCP_BRIEF_REASON_HEALTH_BLOCKERS.to_string(),
5404 arguments: Self::brief_call_arguments(
5405 project_path,
5406 worktree,
5407 &[],
5408 Some((MCP_BRIEF_ARG_LIMIT, blocker_limit)),
5409 ),
5410 });
5411 }
5412 recommendations
5413 }
5414
5415 fn target_arguments(
5417 project_path: Option<String>,
5418 worktree: Option<String>,
5419 ) -> serde_json::Value {
5420 let mut arguments = serde_json::Map::new();
5421 if let Some(path) = project_path {
5422 arguments.insert(
5423 MCP_BRIEF_ARG_PROJECT_PATH.to_string(),
5424 serde_json::Value::String(path),
5425 );
5426 } else if let Some(alias) = worktree {
5427 arguments.insert(
5428 MCP_BRIEF_ARG_WORKTREE.to_string(),
5429 serde_json::Value::String(alias),
5430 );
5431 }
5432 serde_json::Value::Object(arguments)
5433 }
5434
5435 fn brief_call_arguments(
5437 project_path: Option<String>,
5438 worktree: Option<String>,
5439 string_args: &[(&'static str, &str)],
5440 usize_arg: Option<(&'static str, usize)>,
5441 ) -> serde_json::Value {
5442 let mut arguments = serde_json::Map::new();
5443 if let Some(path) = project_path {
5444 arguments.insert(
5445 MCP_BRIEF_ARG_PROJECT_PATH.to_string(),
5446 serde_json::Value::String(path),
5447 );
5448 } else if let Some(alias) = worktree {
5449 arguments.insert(
5450 MCP_BRIEF_ARG_WORKTREE.to_string(),
5451 serde_json::Value::String(alias),
5452 );
5453 }
5454 for (key, value) in string_args {
5455 arguments.insert(
5456 (*key).to_string(),
5457 serde_json::Value::String((*value).to_string()),
5458 );
5459 }
5460 if let Some((key, value)) = usize_arg {
5461 arguments.insert(key.to_string(), serde_json::json!(value));
5462 }
5463 serde_json::Value::Object(arguments)
5464 }
5465
5466 fn task_state_values() -> Vec<McpTaskState> {
5468 vec![
5469 McpTaskState::Pending,
5470 McpTaskState::Running,
5471 McpTaskState::Complete,
5472 McpTaskState::Failed,
5473 McpTaskState::Canceled,
5474 ]
5475 }
5476
5477 fn task_operation_values() -> Vec<McpTaskOperation> {
5479 vec![
5480 McpTaskOperation::Contract,
5481 McpTaskOperation::Scan,
5482 McpTaskOperation::WatchOnce,
5483 McpTaskOperation::SymbolsBuild,
5484 McpTaskOperation::Search,
5485 ]
5486 }
5487
5488 fn start_index_task<F>(
5490 &self,
5491 operation: McpTaskOperation,
5492 options: SymbolBuildOptions,
5493 result_ref: &'static str,
5494 work: F,
5495 ) -> Result<McpTaskStartResponse, CliError>
5496 where
5497 F: FnOnce(&IndexWorkControl, SymbolBuildOptions) -> Result<(), CliError> + Send + 'static,
5498 {
5499 let options = options.with_worker_ceiling(self.background_resources.workers_per_task);
5500 let control = index_work_control(&options);
5501 let mut task_id = MCP_INDEX_TASK_ID_PREFIX.to_string();
5502 task_id.push_str(
5503 &self
5504 .next_task_sequence
5505 .fetch_add(1, Ordering::Relaxed)
5506 .to_string(),
5507 );
5508 let now = mcp_unix_time_ms();
5509 {
5510 let mut registry = self
5511 .task_registry
5512 .write()
5513 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
5514 let active = registry.active_count();
5515 if active >= self.background_resources.task_limit {
5516 let mut message = MCP_INDEX_TASK_LIMIT_PREFIX.to_string();
5517 message.push_str(&self.background_resources.task_limit.to_string());
5518 message.push_str(MCP_INDEX_TASK_LIMIT_SUFFIX);
5519 return Err(CliError::Mcp(message));
5520 }
5521 registry.insert(McpTaskRecord {
5522 task_id: task_id.clone(),
5523 operation: operation.clone(),
5524 state: McpTaskState::Pending,
5525 created_at_ms: now,
5526 updated_at_ms: now,
5527 progress: Some(McpTaskProgress {
5528 current: None,
5529 total: None,
5530 message: Some(MCP_TASK_PROGRESS_ACCEPTED.to_string()),
5531 }),
5532 error: None,
5533 result_ref: None,
5534 cancelable: true,
5535 control: Some(control.clone()),
5536 });
5537 }
5538
5539 let registry = Arc::clone(&self.task_registry);
5540 let worker_task_id = task_id.clone();
5541 let mut worker_name = MCP_INDEX_WORKER_NAME_PREFIX.to_string();
5542 worker_name.push_str(&task_id);
5543 let spawn_result = thread::Builder::new().name(worker_name).spawn(move || {
5544 if let Ok(mut registry) = registry.write() {
5545 registry.update(&worker_task_id, |record| {
5546 record.state = McpTaskState::Running;
5547 record.updated_at_ms = mcp_unix_time_ms();
5548 record.progress = Some(McpTaskProgress {
5549 current: None,
5550 total: None,
5551 message: Some(MCP_TASK_PROGRESS_RUNNING.to_string()),
5552 });
5553 });
5554 }
5555 let outcome =
5556 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&control, options)));
5557 let (state, progress, error, completed_result_ref) = match outcome {
5558 Ok(Ok(())) => (
5559 McpTaskState::Complete,
5560 MCP_TASK_PROGRESS_COMPLETE,
5561 None,
5562 Some(result_ref.to_string()),
5563 ),
5564 Ok(Err(error)) => {
5565 let state = if task_error_is_canceled(&error) {
5566 McpTaskState::Canceled
5567 } else {
5568 McpTaskState::Failed
5569 };
5570 (
5571 state,
5572 if state == McpTaskState::Canceled {
5573 MCP_TASK_PROGRESS_CANCELED
5574 } else {
5575 MCP_TASK_PROGRESS_FAILED
5576 },
5577 Some(bounded_task_error(&error)),
5578 None,
5579 )
5580 }
5581 Err(_panic) => (
5582 McpTaskState::Failed,
5583 MCP_TASK_PROGRESS_FAILED,
5584 Some(MCP_INDEX_WORKER_PANIC_ERROR.to_string()),
5585 None,
5586 ),
5587 };
5588 if let Ok(mut registry) = registry.write() {
5589 registry.update(&worker_task_id, |record| {
5590 record.state = state;
5591 record.updated_at_ms = mcp_unix_time_ms();
5592 record.progress = Some(McpTaskProgress {
5593 current: None,
5594 total: None,
5595 message: Some(progress.to_string()),
5596 });
5597 record.error = error;
5598 record.result_ref = completed_result_ref;
5599 record.cancelable = false;
5600 record.control = None;
5601 });
5602 }
5603 });
5604 if let Err(source) = spawn_result {
5605 let mut spawn_error = MCP_INDEX_WORKER_SPAWN_ERROR_PREFIX.to_string();
5606 spawn_error.push_str(&source.to_string());
5607 if let Ok(mut registry) = self.task_registry.write() {
5608 registry.update(&task_id, |record| {
5609 record.state = McpTaskState::Failed;
5610 record.updated_at_ms = mcp_unix_time_ms();
5611 record.progress = Some(McpTaskProgress {
5612 current: None,
5613 total: None,
5614 message: Some(MCP_TASK_PROGRESS_FAILED.to_string()),
5615 });
5616 record.error = Some(spawn_error.clone());
5617 record.cancelable = false;
5618 record.control = None;
5619 });
5620 }
5621 return Err(CliError::Mcp(spawn_error));
5622 }
5623
5624 Ok(McpTaskStartResponse {
5625 task_id,
5626 operation,
5627 state: McpTaskState::Pending,
5628 status_tool: MCP_TOOL_ATLAS_TASK_STATUS,
5629 cancel_tool: MCP_TOOL_ATLAS_TASK_CANCEL,
5630 })
5631 }
5632
5633 fn task_status(&self, task_id: String) -> Result<McpTaskStatusResponse, CliError> {
5635 let registry = self
5636 .task_registry
5637 .read()
5638 .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
5639 let task = registry.get(&task_id);
5640 Ok(McpTaskStatusResponse {
5641 task_id,
5642 lookup: if task.is_some() {
5643 McpTaskLookupStatus::Found
5644 } else {
5645 McpTaskLookupStatus::NotFound
5646 },
5647 states: Self::task_state_values(),
5648 operations: Self::task_operation_values(),
5649 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5650 task,
5651 })
5652 }
5653
5654 fn task_cancel(&self, task_id: String) -> Result<McpTaskCancelResponse, CliError> {
5656 let mut registry = self
5657 .task_registry
5658 .write()
5659 .map_err(|_poisoned| CliError::Mcp(MCP_TASK_REGISTRY_LOCK_POISONED.to_string()))?;
5660 let Some(record) = registry.get(&task_id) else {
5661 return Ok(McpTaskCancelResponse {
5662 task_id,
5663 result: McpTaskCancelResult::NotFound,
5664 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5665 task: None,
5666 });
5667 };
5668 if matches!(
5669 record.state,
5670 McpTaskState::Complete | McpTaskState::Failed | McpTaskState::Canceled
5671 ) {
5672 return Ok(McpTaskCancelResponse {
5673 task_id,
5674 result: McpTaskCancelResult::AlreadyFinished,
5675 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5676 task: Some(record),
5677 });
5678 }
5679 if !record.cancelable {
5680 return Ok(McpTaskCancelResponse {
5681 task_id,
5682 result: McpTaskCancelResult::NotCancelable,
5683 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5684 task: Some(record),
5685 });
5686 }
5687 let task = registry.update(&task_id, |record| {
5688 if let Some(control) = &record.control {
5689 control.cancel();
5690 }
5691 record.updated_at_ms = mcp_unix_time_ms();
5692 record.progress = Some(McpTaskProgress {
5693 current: None,
5694 total: None,
5695 message: Some(MCP_TASK_PROGRESS_CANCELLATION_REQUESTED.to_string()),
5696 });
5697 record.cancelable = false;
5698 });
5699 Ok(McpTaskCancelResponse {
5700 task_id,
5701 result: McpTaskCancelResult::CancellationRequested,
5702 registry_capacity: MCP_TASK_REGISTRY_CAPACITY,
5703 task,
5704 })
5705 }
5706
5707 fn lint_report_for_state(
5709 state: &McpProjectState,
5710 params: &AtlasLintParams,
5711 ) -> Result<crate::runtime::LintReport, CliError> {
5712 let config = Self::load_config_for_state(state)?;
5713 let purpose_level = Self::parse_purpose_lint_level(params.purpose_level.as_deref())?;
5714 lint_project(
5715 &config,
5716 &state.db_path,
5717 state.config_path.as_deref(),
5718 LintOptions {
5719 strict_folders: params.strict_folders.unwrap_or(false),
5720 report_untracked: params.report_untracked.unwrap_or(false),
5721 strict_untracked: params.strict_untracked.unwrap_or(false),
5722 },
5723 purpose_level,
5724 )
5725 }
5726
5727 fn startup_project_state(db_path: PathBuf, config_path: Option<PathBuf>) -> McpProjectState {
5729 let root = Self::startup_project_root(&db_path, config_path.as_deref());
5730 let config_path = config_path.filter(|path| Self::config_matches_project_root(&root, path));
5731 McpProjectState {
5732 root,
5733 db_path,
5734 config_path,
5735 worktree: None,
5736 }
5737 }
5738
5739 fn startup_project_root(db_path: &Path, config_path: Option<&Path>) -> PathBuf {
5741 if let Ok(root) = default_mcp_project_root(db_path, config_path) {
5742 return root;
5743 }
5744 if let Some(root) = Self::project_root_from_default_db_path(db_path) {
5745 return root;
5746 }
5747 std::env::current_dir()
5748 .ok()
5749 .and_then(|root| canonical_project_root(&root).ok())
5750 .unwrap_or_else(|| PathBuf::from(CURRENT_DIR_ALIAS))
5751 }
5752
5753 fn project_root_from_default_db_path(db_path: &Path) -> Option<PathBuf> {
5755 let atlas_dir = db_path.parent()?;
5756 if atlas_dir.file_name()? != PROJECTATLAS_DIR_NAME {
5757 return None;
5758 }
5759 let root = atlas_dir.parent()?;
5760 canonical_project_root(root)
5761 .ok()
5762 .or_else(|| Some(root.to_path_buf()))
5763 }
5764
5765 fn active_project_state(&self) -> Result<McpProjectState, CliError> {
5767 let state = self
5768 .project_state
5769 .read()
5770 .map(|state| state.clone())
5771 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))?;
5772 canonical_source_project_root(&state.root)?;
5773 Ok(state)
5774 }
5775
5776 fn set_active_project_state(&self, state: McpProjectState) -> Result<(), CliError> {
5778 *self
5779 .project_state
5780 .write()
5781 .map_err(|_poisoned| CliError::Mcp(MCP_PROJECT_STATE_LOCK_POISONED.to_string()))? =
5782 state;
5783 Ok(())
5784 }
5785
5786 fn control_git_repository(&self) -> Result<GitRepositoryStructure, CliError> {
5788 self.control_git_repository_if_present()?.ok_or_else(|| {
5789 CliError::InvalidInput(MCP_ERROR_WORKTREE_CONTROL_REPOSITORY_REQUIRED.to_string())
5790 })
5791 }
5792
5793 fn control_git_repository_if_present(
5795 &self,
5796 ) -> Result<Option<GitRepositoryStructure>, CliError> {
5797 let repository = match discover_repository_structure(&self.control_state.root)? {
5798 RepositoryStructure::Git(repository) => repository,
5799 RepositoryStructure::NonGit { .. } => return Ok(None),
5800 RepositoryStructure::InvalidGit { issue, .. } => {
5801 return Err(CliError::InvalidInput(format!(
5802 "invalid control-repository Git evidence at '{}': {:?}",
5803 normalize_native_path_display(issue.path),
5804 issue.kind
5805 )));
5806 }
5807 };
5808 let control_present = repository.worktrees.iter().any(|entry| {
5809 matches!(
5810 &entry.state,
5811 GitWorktreeState::Active { root, .. } if root == &self.control_state.root
5812 )
5813 });
5814 if !control_present {
5815 return Err(CliError::InvalidInput(format!(
5816 "selected control root '{}' is not one active worktree in its reciprocal Git inventory",
5817 normalize_native_path_display(&self.control_state.root)
5818 )));
5819 }
5820 Ok(Some(repository))
5821 }
5822
5823 fn worktree_candidate_selector(entry: &GitWorktreeEntry) -> String {
5825 Self::worktree_candidate_selector_from_path(&entry.administrative_directory)
5826 }
5827
5828 fn worktree_candidate_selector_from_path(identity: &Path) -> String {
5830 if let Some(identity) = identity.to_str() {
5831 return Self::worktree_candidate_selector_from_identity(
5832 &normalize_native_path_display_str(identity),
5833 );
5834 }
5835 Self::worktree_candidate_selector_from_native_identity(
5836 identity.as_os_str().as_encoded_bytes(),
5837 )
5838 }
5839
5840 fn worktree_candidate_selector_from_identity(identity: &str) -> String {
5842 Self::worktree_candidate_selector_from_native_identity(identity.as_bytes())
5843 }
5844
5845 fn worktree_candidate_selector_from_native_identity(identity: &[u8]) -> String {
5847 let digest = blake3::hash(identity).to_hex();
5848 let mut selector = String::with_capacity(
5849 MCP_WORKTREE_SELECTOR_PREFIX.len() + MCP_WORKTREE_SELECTOR_DIGEST_CHARS,
5850 );
5851 selector.push_str(MCP_WORKTREE_SELECTOR_PREFIX);
5852 selector.push_str(&digest.as_str()[..MCP_WORKTREE_SELECTOR_DIGEST_CHARS]);
5853 selector
5854 }
5855
5856 fn worktree_candidate_selector_from_canonical_identity(
5860 identity: &CanonicalProjectRoot,
5861 ) -> String {
5862 Self::worktree_candidate_selector_from_path(identity.as_path())
5863 }
5864
5865 fn active_worktree_root(entry: &GitWorktreeEntry) -> Option<&Path> {
5867 match &entry.state {
5868 GitWorktreeState::Active { root, .. } => Some(root),
5869 GitWorktreeState::Missing { .. } | GitWorktreeState::Invalid { .. } => None,
5870 }
5871 }
5872
5873 fn worktree_candidate_matches(entry: &GitWorktreeEntry, selector: &str) -> bool {
5875 if Self::worktree_candidate_selector(entry) == selector {
5876 return true;
5877 }
5878 let root_name = Self::active_worktree_root(entry)
5879 .and_then(Path::file_name)
5880 .and_then(std::ffi::OsStr::to_str);
5881 let administrative_name = entry
5882 .administrative_directory
5883 .file_name()
5884 .and_then(std::ffi::OsStr::to_str);
5885 root_name.is_some_and(|name| name.eq_ignore_ascii_case(selector))
5886 || administrative_name.is_some_and(|name| name.eq_ignore_ascii_case(selector))
5887 }
5888
5889 fn matching_worktree_candidates<'a>(
5891 &'a self,
5892 repository: &'a GitRepositoryStructure,
5893 selector: &str,
5894 ) -> Vec<&'a GitWorktreeEntry> {
5895 repository
5896 .worktrees
5897 .iter()
5898 .filter(|entry| {
5899 Self::active_worktree_root(entry)
5900 .is_some_and(|root| root != self.control_state.root)
5901 })
5902 .filter(|entry| Self::worktree_candidate_matches(entry, selector))
5903 .collect()
5904 }
5905
5906 fn revalidate_worktree_candidate(
5908 &self,
5909 expected_repository: &GitRepositoryStructure,
5910 expected_entry: &GitWorktreeEntry,
5911 expected_identity: &str,
5912 ) -> Result<(GitRepositoryStructure, GitWorktreeEntry), CliError> {
5913 let expected_root = Self::active_worktree_root(expected_entry).ok_or_else(|| {
5914 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
5915 })?;
5916 let repository = self.control_git_repository()?;
5917 let entry = repository
5918 .worktrees
5919 .iter()
5920 .find(|entry| entry.administrative_directory == expected_entry.administrative_directory)
5921 .cloned()
5922 .ok_or_else(|| {
5923 CliError::InvalidInput(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
5924 })?;
5925 let root = Self::active_worktree_root(&entry).ok_or_else(|| {
5926 CliError::InvalidInput(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
5927 })?;
5928 let identity = git_administrative_identity(&entry.administrative_directory)?;
5929 if repository.common_directory != expected_repository.common_directory
5930 || entry.role != expected_entry.role
5931 || root != expected_root
5932 || identity != expected_identity
5933 {
5934 return Err(CliError::InvalidInput(
5935 MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string(),
5936 ));
5937 }
5938 Ok((repository, entry))
5939 }
5940
5941 fn worktree_candidate(
5943 common_directory: &Path,
5944 entry: &GitWorktreeEntry,
5945 ) -> Option<McpWorktreeCandidate> {
5946 let root = Self::active_worktree_root(entry)?;
5947 Some(McpWorktreeCandidate {
5948 selector: Self::worktree_candidate_selector(entry),
5949 path_display: if common_directory.to_str().is_some()
5950 && entry.administrative_directory.to_str().is_some()
5951 && root.to_str().is_some()
5952 {
5953 McpWorktreePathDisplayState::Available
5954 } else {
5955 McpWorktreePathDisplayState::Unavailable
5956 },
5957 root: lossless_project_root_display(root),
5958 role: entry.role.into(),
5959 })
5960 }
5961
5962 fn default_worktree_alias(root: &Path) -> Result<WorktreeAlias, CliError> {
5964 let name = root
5965 .file_name()
5966 .and_then(std::ffi::OsStr::to_str)
5967 .ok_or_else(|| CliError::InvalidInput(MCP_ERROR_WORKTREE_PATH_NON_UTF8.to_string()))?;
5968 WorktreeAlias::parse(&name.to_ascii_lowercase()).map_err(|source| {
5969 CliError::InvalidInput(format!(
5970 "selected worktree directory cannot be used as an alias; provide alias explicitly: {source}"
5971 ))
5972 })
5973 }
5974
5975 fn open_local_worktree_atlas(root: &Path) -> Result<Option<AtlasStore>, CliError> {
5977 let db_path = Self::projectatlas_db_path(root);
5978 if !db_path.exists() {
5979 return Ok(None);
5980 }
5981 Ok(Some(open_atlas_store_read_only_for_project(
5982 &db_path, root,
5983 )?))
5984 }
5985
5986 fn local_worktree_project_instance_id(
5988 store: &AtlasStore,
5989 db_path: &Path,
5990 ) -> Result<ProjectInstanceId, CliError> {
5991 let project_instance_id = store.project_instance_id()?.ok_or_else(|| {
5992 CliError::InvalidInput(format!(
5993 "worktree atlas '{}' has no exact project identity",
5994 normalize_native_path_display(db_path)
5995 ))
5996 })?;
5997 Ok(project_instance_id)
5998 }
5999
6000 fn local_worktree_usage_snapshot(
6002 store: &AtlasStore,
6003 db_path: &Path,
6004 project_instance_id: ProjectInstanceId,
6005 ) -> Result<WorktreeUsageSnapshot, CliError> {
6006 let snapshot = store.export_worktree_usage_snapshot()?;
6007 if snapshot.project_instance_id() != project_instance_id {
6008 return Err(CliError::InvalidInput(format!(
6009 "worktree atlas '{}' telemetry identity does not match its project identity",
6010 normalize_native_path_display(db_path)
6011 )));
6012 }
6013 Ok(snapshot)
6014 }
6015
6016 fn local_worktree_atlas(root: &Path) -> Result<Option<LocalWorktreeAtlas>, CliError> {
6018 let db_path = Self::projectatlas_db_path(root);
6019 let Some(store) = Self::open_local_worktree_atlas(root)? else {
6020 return Ok(None);
6021 };
6022 let project_instance_id = Self::local_worktree_project_instance_id(&store, &db_path)?;
6023 let snapshot = Self::local_worktree_usage_snapshot(&store, &db_path, project_instance_id)?;
6024 Ok(Some(LocalWorktreeAtlas {
6025 project_instance_id,
6026 snapshot,
6027 }))
6028 }
6029
6030 fn revalidate_local_worktree_atlas_identity(
6032 root: &Path,
6033 expected: Option<ProjectInstanceId>,
6034 ) -> Result<(), CliError> {
6035 let Some(expected) = expected else {
6036 return Ok(());
6037 };
6038 let db_path = Self::projectatlas_db_path(root);
6039 let Some(store) = Self::open_local_worktree_atlas(root)? else {
6040 return Err(CliError::InvalidInput(
6041 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6042 ));
6043 };
6044 if Self::local_worktree_project_instance_id(&store, &db_path)? != expected {
6045 return Err(CliError::InvalidInput(
6046 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6047 ));
6048 }
6049 Ok(())
6050 }
6051
6052 fn retire_registered_worktree(
6054 control: &AtlasStore,
6055 registration: &WorktreeRegistration,
6056 root: Option<&Path>,
6057 retired_at_epoch: u64,
6058 initial_blocker: Option<String>,
6059 ) -> Result<
6060 (
6061 WorktreeRegistration,
6062 Option<WorktreeUsageSyncState>,
6063 Option<String>,
6064 ),
6065 CliError,
6066 > {
6067 Self::retire_registered_worktree_with_pre_open(
6068 control,
6069 registration,
6070 root,
6071 retired_at_epoch,
6072 initial_blocker,
6073 || Ok(()),
6074 )
6075 }
6076
6077 fn retire_registered_worktree_with_pre_open<F>(
6079 control: &AtlasStore,
6080 registration: &WorktreeRegistration,
6081 root: Option<&Path>,
6082 retired_at_epoch: u64,
6083 initial_blocker: Option<String>,
6084 pre_open: F,
6085 ) -> Result<
6086 (
6087 WorktreeRegistration,
6088 Option<WorktreeUsageSyncState>,
6089 Option<String>,
6090 ),
6091 CliError,
6092 >
6093 where
6094 F: FnOnce() -> Result<(), CliError>,
6095 {
6096 control.with_active_worktree_registration(
6097 registration.registration_id,
6098 ®istration.alias,
6099 |guard| {
6100 let Some(root) = root else {
6101 let retired = guard.retire(retired_at_epoch)?;
6102 return Ok(Ok((retired, None, initial_blocker)));
6103 };
6104 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6105 return Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch)
6106 .map(Ok);
6107 }
6108 if let Err(error) = pre_open() {
6109 return Ok(Err(error));
6110 }
6111 let local = match Self::open_local_worktree_atlas(root) {
6112 Ok(local) => local,
6113 Err(error) => {
6114 return Self::classify_retirement_failure(
6115 guard,
6116 root,
6117 retired_at_epoch,
6118 error,
6119 );
6120 }
6121 };
6122 let Some(local) = local else {
6123 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6124 return Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch)
6125 .map(Ok);
6126 }
6127 if guard.registration().project_instance_id.is_some() {
6128 return Ok(Err(CliError::InvalidInput(
6129 MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING.to_string(),
6130 )));
6131 }
6132 let retired = guard.retire(retired_at_epoch)?;
6133 return Ok(Ok((retired, None, initial_blocker)));
6134 };
6135 let db_path = Self::projectatlas_db_path(root);
6136 let project_instance_id =
6137 match Self::local_worktree_project_instance_id(&local, &db_path) {
6138 Ok(project_instance_id) => project_instance_id,
6139 Err(error) => {
6140 return Self::classify_retirement_failure(
6141 guard,
6142 root,
6143 retired_at_epoch,
6144 error,
6145 );
6146 }
6147 };
6148 let finalization = match local.with_exclusive_worktree_usage_snapshot(|snapshot| {
6149 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6150 return Ok(None);
6151 }
6152 guard
6153 .retire_with_usage_snapshot(
6154 root,
6155 project_instance_id,
6156 snapshot,
6157 retired_at_epoch,
6158 )
6159 .map(Some)
6160 }) {
6161 Ok(finalization) => finalization,
6162 Err(error) => {
6163 return Self::classify_retirement_failure(
6164 guard,
6165 root,
6166 retired_at_epoch,
6167 error.into(),
6168 );
6169 }
6170 };
6171 if let Some((retired, synchronized)) = finalization {
6172 return Ok(Ok((retired, Some(synchronized), initial_blocker)));
6173 }
6174 Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch).map(Ok)
6175 },
6176 )?
6177 }
6178
6179 fn classify_retirement_failure(
6181 guard: &mut ActiveWorktreeRegistrationGuard<'_>,
6182 root: &Path,
6183 retired_at_epoch: u64,
6184 error: CliError,
6185 ) -> Result<
6186 Result<
6187 (
6188 WorktreeRegistration,
6189 Option<WorktreeUsageSyncState>,
6190 Option<String>,
6191 ),
6192 CliError,
6193 >,
6194 DbError,
6195 > {
6196 if require_registered_worktree_lifecycle(guard.registration(), root).is_err() {
6197 Self::retire_changed_worktree_lifecycle(guard, retired_at_epoch).map(Ok)
6198 } else {
6199 Ok(Err(error))
6200 }
6201 }
6202
6203 fn retire_changed_worktree_lifecycle(
6205 guard: &mut ActiveWorktreeRegistrationGuard<'_>,
6206 retired_at_epoch: u64,
6207 ) -> Result<
6208 (
6209 WorktreeRegistration,
6210 Option<WorktreeUsageSyncState>,
6211 Option<String>,
6212 ),
6213 DbError,
6214 > {
6215 let retired = guard.retire(retired_at_epoch)?;
6216 Ok((
6217 retired,
6218 None,
6219 Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string()),
6220 ))
6221 }
6222
6223 fn reset_registered_worktree_index(
6225 &self,
6226 state: &McpProjectState,
6227 selection: &McpWorktreeSelection,
6228 include_mcp_config: bool,
6229 ) -> Result<ResetIndexReport, CliError> {
6230 self.reset_registered_worktree_index_with_post_validation(
6231 state,
6232 selection,
6233 include_mcp_config,
6234 || Ok(()),
6235 )
6236 }
6237
6238 fn reset_registered_worktree_index_with_post_validation<F>(
6240 &self,
6241 state: &McpProjectState,
6242 selection: &McpWorktreeSelection,
6243 include_mcp_config: bool,
6244 post_validation: F,
6245 ) -> Result<ResetIndexReport, CliError>
6246 where
6247 F: FnOnce() -> Result<(), CliError>,
6248 {
6249 let alias = WorktreeAlias::parse(&selection.alias)?;
6250 let registration_id =
6251 selection
6252 .registration_id
6253 .ok_or_else(|| DbError::WorktreeRegistrationNotFound {
6254 alias: selection.alias.clone(),
6255 })?;
6256 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
6257 Self::require_captured_control_identity(Some(selection), &control)?;
6258 match control.with_unbound_worktree_registration(registration_id, &alias, |registration| {
6259 require_registered_worktree_lifecycle(registration, &state.root)?;
6260 post_validation()?;
6261 reset_index_files_with_revalidation(&state.db_path, include_mcp_config, || {
6262 require_registered_worktree_lifecycle(registration, &state.root)
6263 })
6264 }) {
6265 Ok(result) => result,
6266 Err(DbError::WorktreeRegistrationConflict { .. }) => Err(CliError::InvalidInput(
6267 MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED.to_string(),
6268 )),
6269 Err(error) => Err(error.into()),
6270 }
6271 }
6272
6273 fn worktree_administrative_path_identity(
6275 entry: &GitWorktreeEntry,
6276 ) -> Option<CanonicalProjectRoot> {
6277 match &entry.state {
6278 GitWorktreeState::Invalid { .. } => {
6279 CanonicalProjectRoot::from_persisted_path(entry.administrative_directory.clone())
6280 .ok()
6281 }
6282 _ => CanonicalProjectRoot::from_path(&entry.administrative_directory).ok(),
6283 }
6284 }
6285
6286 fn worktree_list_row(
6288 &self,
6289 common_directory: &Path,
6290 entry: &GitWorktreeEntry,
6291 registrations: &[WorktreeRegistration],
6292 ) -> McpWorktreeRow {
6293 let administrative_directory =
6294 lossless_native_path_display(&entry.administrative_directory);
6295 let common_identity = CanonicalProjectRoot::from_path(common_directory).ok();
6296 let administrative_identity = Self::worktree_administrative_path_identity(entry);
6297 let registration = registrations.iter().find(|registration| {
6298 registration.state == WorktreeRegistrationState::Active
6299 && common_identity
6300 .as_ref()
6301 .is_some_and(|identity| *identity == registration.git_common_directory_identity)
6302 && administrative_identity.as_ref().is_some_and(|identity| {
6303 *identity == registration.git_administrative_directory_identity
6304 })
6305 });
6306 let administrative_identity = git_administrative_identity(&entry.administrative_directory);
6307 let lifecycle_matches = registration.is_none_or(|registration| {
6308 administrative_identity
6309 .as_ref()
6310 .is_ok_and(|identity| identity == ®istration.git_administrative_identity)
6311 });
6312 let root = Self::active_worktree_root(entry);
6313 let display_root = match &entry.state {
6314 GitWorktreeState::Missing { .. } => {
6315 registration.map(|registration| registration.last_root_identity.as_path())
6316 }
6317 _ => root,
6318 };
6319 let path_display = if common_directory.to_str().is_some()
6320 && entry.administrative_directory.to_str().is_some()
6321 && display_root.is_none_or(|root| root.to_str().is_some())
6322 {
6323 McpWorktreePathDisplayState::Available
6324 } else {
6325 McpWorktreePathDisplayState::Unavailable
6326 };
6327 let control = root.is_some_and(|root| root == self.control_state.root);
6328 let alias = if control {
6329 Some(MCP_MAIN_WORKTREE_ALIAS.to_string())
6330 } else {
6331 registration.map(|registration| registration.alias.to_string())
6332 };
6333 let registration_state = if control {
6334 McpWorktreeRegistrationState::Control
6335 } else if registration.is_some() {
6336 McpWorktreeRegistrationState::Registered
6337 } else {
6338 McpWorktreeRegistrationState::Unregistered
6339 };
6340 let mut atlas_state = McpWorktreeAtlasState::Unavailable;
6341 let mut telemetry_state = McpWorktreeTelemetryState::Unavailable;
6342 let mut local_telemetry_revision = None;
6343 let mut project_instance_id = None;
6344 let mut blocker = (!lifecycle_matches)
6345 .then(|| MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string())
6346 .or_else(|| administrative_identity.err().map(|error| error.to_string()));
6347 if matches!(path_display, McpWorktreePathDisplayState::Unavailable) && blocker.is_none() {
6348 blocker = Some(MCP_ERROR_WORKTREE_PATH_NON_UTF8.to_string());
6349 }
6350 let git_state = match &entry.state {
6351 GitWorktreeState::Active { root, .. } => {
6352 if lifecycle_matches {
6353 match Self::local_worktree_atlas(root) {
6354 Ok(Some(local)) => {
6355 let identity_matches = registration
6356 .and_then(|registration| registration.project_instance_id)
6357 .is_none_or(|expected| expected == local.project_instance_id);
6358 if identity_matches {
6359 atlas_state = McpWorktreeAtlasState::Initialized;
6360 local_telemetry_revision = Some(local.snapshot.revision());
6361 project_instance_id = Some(local.project_instance_id.to_string());
6362 telemetry_state = if control {
6363 McpWorktreeTelemetryState::Control
6364 } else if let Some(registration) = registration {
6365 if local.snapshot.revision()
6366 > registration.accepted_telemetry_revision
6367 {
6368 McpWorktreeTelemetryState::Pending
6369 } else {
6370 McpWorktreeTelemetryState::Current
6371 }
6372 } else {
6373 McpWorktreeTelemetryState::Unregistered
6374 };
6375 } else {
6376 atlas_state = McpWorktreeAtlasState::Invalid;
6377 blocker = Some(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string());
6378 }
6379 }
6380 Ok(None) => {
6381 atlas_state = McpWorktreeAtlasState::Missing;
6382 telemetry_state = if registration.is_some() {
6383 McpWorktreeTelemetryState::MissingAtlas
6384 } else {
6385 McpWorktreeTelemetryState::Unregistered
6386 };
6387 }
6388 Err(error) => {
6389 atlas_state = McpWorktreeAtlasState::Invalid;
6390 blocker = Some(error.to_string());
6391 }
6392 }
6393 } else {
6394 atlas_state = McpWorktreeAtlasState::Invalid;
6395 telemetry_state = McpWorktreeTelemetryState::Unavailable;
6396 }
6397 McpGitWorktreeState::Active
6398 }
6399 GitWorktreeState::Missing { git_control_path } => {
6400 blocker = Some(format!(
6401 "Git registration target is missing at '{}'",
6402 normalize_native_path_display(git_control_path)
6403 ));
6404 McpGitWorktreeState::Missing
6405 }
6406 GitWorktreeState::Invalid { issue } => {
6407 blocker = Some(format!(
6408 "invalid Git evidence at '{}': {:?}",
6409 normalize_native_path_display(&issue.path),
6410 issue.kind
6411 ));
6412 McpGitWorktreeState::Invalid
6413 }
6414 };
6415 McpWorktreeRow {
6416 selector: Some(Self::worktree_candidate_selector(entry)),
6417 alias,
6418 role: entry.role.into(),
6419 path_display,
6420 git_state,
6421 registration: registration_state,
6422 administrative_directory,
6423 root: display_root.and_then(lossless_project_root_display),
6424 atlas_state,
6425 telemetry_state,
6426 accepted_telemetry_revision: registration
6427 .map(|registration| registration.accepted_telemetry_revision),
6428 local_telemetry_revision,
6429 project_instance_id,
6430 blocker,
6431 }
6432 }
6433
6434 fn missing_registered_worktree_row(registration: &WorktreeRegistration) -> McpWorktreeRow {
6436 let path_display = if registration
6437 .git_common_directory_identity
6438 .display_string()
6439 .is_ok()
6440 && registration.last_root_identity.display_string().is_ok()
6441 && registration
6442 .git_administrative_directory_identity
6443 .display_string()
6444 .is_ok()
6445 {
6446 McpWorktreePathDisplayState::Available
6447 } else {
6448 McpWorktreePathDisplayState::Unavailable
6449 };
6450 McpWorktreeRow {
6451 selector: Some(Self::worktree_candidate_selector_from_canonical_identity(
6452 ®istration.git_administrative_directory_identity,
6453 )),
6454 alias: Some(registration.alias.to_string()),
6455 role: McpGitWorktreeRole::Linked,
6456 path_display,
6457 git_state: McpGitWorktreeState::Missing,
6458 registration: McpWorktreeRegistrationState::Registered,
6459 administrative_directory: registration
6460 .git_administrative_directory_identity
6461 .display_string()
6462 .ok(),
6463 root: registration.last_root_identity.display_string().ok(),
6464 atlas_state: McpWorktreeAtlasState::Unavailable,
6465 telemetry_state: McpWorktreeTelemetryState::Unavailable,
6466 accepted_telemetry_revision: Some(registration.accepted_telemetry_revision),
6467 local_telemetry_revision: None,
6468 project_instance_id: registration
6469 .project_instance_id
6470 .map(|identity| identity.to_string()),
6471 blocker: Some(MCP_WORKTREE_MISSING_RETENTION_REASON.to_string()),
6472 }
6473 }
6474
6475 fn current_epoch_seconds() -> Result<u64, CliError> {
6477 u64::try_from(mcp_unix_time_ms().saturating_div(1_000)).map_err(|source| {
6478 CliError::InvalidInput(format!(
6479 "current Unix epoch exceeds the supported worktree registry range: {source}"
6480 ))
6481 })
6482 }
6483
6484 fn state_for_target(
6486 &self,
6487 project_path: Option<String>,
6488 worktree: Option<String>,
6489 ) -> Result<McpProjectState, CliError> {
6490 let state = self.state_for_target_with_config_validation(
6491 project_path,
6492 worktree,
6493 McpConfigValidation::Immediate,
6494 )?;
6495 Self::require_initialized_worktree_target(&state)?;
6496 Ok(state)
6497 }
6498
6499 fn federated_worktree_roots(
6501 &self,
6502 worktrees: &[String],
6503 ) -> Result<(Vec<PathBuf>, Vec<McpWorktreeSelection>), CliError> {
6504 validate_federated_root_count(worktrees.len()).map_err(CliError::Service)?;
6505 let mut roots = Vec::with_capacity(worktrees.len());
6506 let mut selections = Vec::with_capacity(worktrees.len());
6507 for worktree in worktrees {
6508 let state = self.state_for_target(None, Some(worktree.clone()))?;
6509 let selection = state.worktree.ok_or_else(|| {
6510 CliError::InvalidInput(MCP_ERROR_FEDERATED_ALIAS_MISSING.to_string())
6511 })?;
6512 if selections
6513 .iter()
6514 .any(|captured: &McpWorktreeSelection| captured.alias == selection.alias)
6515 || roots.contains(&state.root)
6516 {
6517 return Err(CliError::Service(ServiceError::InvalidInput(
6518 MCP_ERROR_FEDERATED_TARGET_DUPLICATE.to_string(),
6519 )));
6520 }
6521 selections.push(selection);
6522 roots.push(state.root);
6523 }
6524 Ok((roots, selections))
6525 }
6526
6527 fn state_for_target_with_config_validation(
6529 &self,
6530 project_path: Option<String>,
6531 worktree: Option<String>,
6532 validation: McpConfigValidation,
6533 ) -> Result<McpProjectState, CliError> {
6534 let project_path = Self::normalized_optional_path(project_path);
6535 let worktree = worktree.map(|alias| alias.trim().to_string());
6536 if project_path.is_some() && worktree.is_some() {
6537 return Err(CliError::InvalidInput(
6538 MCP_WORKTREE_PROJECT_PATH_CONFLICT.to_string(),
6539 ));
6540 }
6541 let Some(alias) = worktree else {
6542 return self.state_for_project_path_with_config_validation(project_path, validation);
6543 };
6544 if alias.is_empty() {
6545 return Err(CliError::InvalidInput(
6546 MCP_ERROR_WORKTREE_SELECTOR_EMPTY.to_string(),
6547 ));
6548 }
6549 if alias == MCP_MAIN_WORKTREE_ALIAS {
6550 let mut state = self.control_state.clone();
6551 let project_instance_id = if state.db_path.is_file() {
6552 Self::open_read_store(&state)?.project_instance_id()?
6553 } else {
6554 None
6555 };
6556 state.worktree = Some(McpWorktreeSelection {
6557 alias,
6558 registration_id: None,
6559 project_instance_id,
6560 control_project_instance_id: project_instance_id,
6561 });
6562 return Ok(state);
6563 }
6564 let alias = WorktreeAlias::parse(&alias)?;
6565 self.resolve_registered_worktree(&alias, validation)
6566 }
6567
6568 fn resolve_registered_worktree(
6570 &self,
6571 alias: &WorktreeAlias,
6572 validation: McpConfigValidation,
6573 ) -> Result<McpProjectState, CliError> {
6574 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
6575 let control_project_instance_id = control.captured_project_binding()?.project_instance_id;
6576 let registration = control.worktree_registration(alias)?;
6577 let repository = self.control_git_repository()?;
6578 let common_identity = CanonicalProjectRoot::from_path(&repository.common_directory)
6579 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6580 if common_identity != registration.git_common_directory_identity {
6581 return Err(CliError::InvalidInput(format!(
6582 "registered worktree '{}' belongs to a different Git common directory",
6583 alias.as_str()
6584 )));
6585 }
6586 let entry = repository
6587 .worktrees
6588 .iter()
6589 .find(|entry| {
6590 Self::worktree_administrative_path_identity(entry).is_some_and(|identity| {
6591 identity == registration.git_administrative_directory_identity
6592 })
6593 })
6594 .ok_or_else(|| {
6595 CliError::InvalidInput(format!(
6596 "registered worktree '{}' is no longer present in the bounded Git inventory",
6597 alias.as_str()
6598 ))
6599 })?;
6600 let administrative_identity = git_administrative_identity(&entry.administrative_directory)?;
6601 if administrative_identity != registration.git_administrative_identity {
6602 return Err(CliError::InvalidInput(
6603 MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string(),
6604 ));
6605 }
6606 let root = match &entry.state {
6607 GitWorktreeState::Active { root, .. } => root.clone(),
6608 GitWorktreeState::Missing { .. } => {
6609 return Err(CliError::InvalidInput(format!(
6610 "registered worktree '{}' is missing; restore it through Git or unregister it",
6611 alias.as_str()
6612 )));
6613 }
6614 GitWorktreeState::Invalid { issue } => {
6615 return Err(CliError::InvalidInput(format!(
6616 "registered worktree '{}' has invalid Git evidence at '{}': {:?}",
6617 alias.as_str(),
6618 normalize_native_path_display(&issue.path),
6619 issue.kind
6620 )));
6621 }
6622 };
6623 if root == self.control_state.root {
6624 return Err(CliError::InvalidInput(
6625 MCP_ERROR_CONTROL_ALIAS_REQUIRED.to_string(),
6626 ));
6627 }
6628 CanonicalProjectRoot::from_path(&root).map_err(|source| {
6629 let mut message = MCP_ERROR_REGISTERED_WORKTREE_ROOT_INVALID_PREFIX.to_string();
6630 message.push_str(&source.to_string());
6631 CliError::InvalidInput(message)
6632 })?;
6633 let current_registry_root = CanonicalProjectRoot::from_path(&root)
6634 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6635 if let Some(expected) = registration.project_instance_id {
6640 let store = Self::open_local_worktree_atlas(&root)?.ok_or_else(|| {
6641 CliError::InvalidInput(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING.to_string())
6642 })?;
6643 let db_path = Self::projectatlas_db_path(&root);
6644 let observed = Self::local_worktree_project_instance_id(&store, &db_path)?;
6645 if observed != expected {
6646 return Err(CliError::InvalidInput(
6647 MCP_ERROR_WORKTREE_IDENTITY_CONFLICT.to_string(),
6648 ));
6649 }
6650 }
6651 let registration_root_matches = current_registry_root == registration.last_root_identity;
6652 let registration = if registration_root_matches {
6653 registration
6654 } else {
6655 control.refresh_worktree_root(®istration, &root)?
6656 };
6657 let mut state = Self::project_state_from_root_with_config_validation(&root, validation)?;
6658 state.worktree = Some(McpWorktreeSelection {
6659 alias: alias.to_string(),
6660 registration_id: Some(registration.registration_id),
6661 project_instance_id: registration.project_instance_id,
6662 control_project_instance_id: Some(control_project_instance_id),
6663 });
6664 Ok(state)
6665 }
6666
6667 fn state_for_project_path_with_config_validation(
6669 &self,
6670 project_path: Option<String>,
6671 validation: McpConfigValidation,
6672 ) -> Result<McpProjectState, CliError> {
6673 let project_path = Self::normalized_optional_path(project_path);
6674 project_path.map_or_else(
6675 || self.active_project_state(),
6676 |path| {
6677 Self::project_state_from_root_with_config_validation(Path::new(&path), validation)
6678 },
6679 )
6680 }
6681
6682 fn nearest_project_enabled(&self, override_value: Option<bool>) -> bool {
6684 override_value.unwrap_or(self.allow_nearest_project)
6685 }
6686
6687 fn state_and_root_path(
6689 &self,
6690 project_path: Option<String>,
6691 worktree: Option<String>,
6692 path: Option<String>,
6693 nearest_project: bool,
6694 ) -> Result<(McpProjectState, PathBuf), CliError> {
6695 self.state_and_root_path_with_config_validation(
6696 project_path,
6697 worktree,
6698 path,
6699 nearest_project,
6700 McpConfigValidation::Immediate,
6701 )
6702 }
6703
6704 fn background_state_and_root_path(
6706 &self,
6707 project_path: Option<String>,
6708 worktree: Option<String>,
6709 path: Option<String>,
6710 nearest_project: bool,
6711 ) -> Result<(McpProjectState, PathBuf), CliError> {
6712 self.state_and_root_path_with_config_validation(
6713 project_path,
6714 worktree,
6715 path,
6716 nearest_project,
6717 McpConfigValidation::Deferred,
6718 )
6719 }
6720
6721 fn state_and_root_path_with_config_validation(
6723 &self,
6724 project_path: Option<String>,
6725 worktree: Option<String>,
6726 path: Option<String>,
6727 nearest_project: bool,
6728 validation: McpConfigValidation,
6729 ) -> Result<(McpProjectState, PathBuf), CliError> {
6730 let explicit_target = project_path.is_some() || worktree.is_some();
6731 let state = self.state_for_target_with_config_validation(
6732 project_path.clone(),
6733 worktree,
6734 validation,
6735 )?;
6736 Self::require_initialized_worktree_target(&state)?;
6737 let root = match (
6738 Self::normalized_optional_path(project_path),
6739 Self::normalized_optional_path(path),
6740 ) {
6741 (None, Some(path)) if !explicit_target => {
6742 match Self::path_or_project_root(&state, Some(path.clone())) {
6743 Ok(root) => root,
6744 Err(active_error) => {
6745 if !nearest_project {
6746 return Err(active_error);
6747 }
6748 if Self::absolute_path_inside_selected_root(&state, &path)? {
6749 return Err(active_error);
6750 }
6751 let Some(indexed_state) =
6752 Self::nearest_root_state_for_root_argument_with_config_validation(
6753 Path::new(&path),
6754 validation,
6755 )?
6756 else {
6757 return Err(active_error);
6758 };
6759 let root = indexed_state.root.clone();
6760 return Ok((indexed_state, root));
6761 }
6762 }
6763 }
6764 (_, path) => Self::path_or_project_root(&state, path)?,
6765 };
6766 Ok((state, root))
6767 }
6768
6769 fn absolute_path_inside_selected_root(
6771 state: &McpProjectState,
6772 path: &str,
6773 ) -> Result<bool, CliError> {
6774 let candidate = PathBuf::from(path);
6775 if !candidate.is_absolute() {
6776 return Ok(false);
6777 }
6778 let resolved = canonical_project_root(&candidate)?;
6779 Ok(resolved != state.root && resolved.starts_with(&state.root))
6780 }
6781
6782 fn nearest_root_state_for_root_argument_with_config_validation(
6784 path: &Path,
6785 validation: McpConfigValidation,
6786 ) -> Result<Option<McpProjectState>, CliError> {
6787 let Ok(addressed_root) = canonical_project_root(path) else {
6788 return Ok(None);
6789 };
6790 let Some(indexed_state) =
6791 Self::project_state_from_nearest_indexed_path_with_config_validation(path, validation)?
6792 else {
6793 return Ok(None);
6794 };
6795 if addressed_root == indexed_state.root {
6796 Ok(Some(indexed_state))
6797 } else {
6798 Ok(None)
6799 }
6800 }
6801
6802 fn state_and_file_key(
6804 &self,
6805 project_path: Option<&str>,
6806 worktree: Option<&str>,
6807 file: &str,
6808 nearest_project: bool,
6809 ) -> Result<McpResolvedRepoPath, CliError> {
6810 let state = self.state_for_target(
6811 project_path.map(ToString::to_string),
6812 worktree.map(ToString::to_string),
6813 )?;
6814 let file_path = PathBuf::from(&file);
6815 if !file_path.is_absolute() {
6816 let file_key = validated_repo_file_key(&file_path)
6817 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6818 return Ok(McpResolvedRepoPath {
6819 state,
6820 key: file_key,
6821 routed_project: false,
6822 });
6823 }
6824 if nearest_project && project_path.is_none() && worktree.is_none() {
6825 let resolved = Self::nearest_state_and_repo_key(&state, file)?.ok_or_else(|| {
6826 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
6827 })?;
6828 let file_key = validated_repo_file_key(Path::new(&resolved.key))
6829 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6830 return Ok(McpResolvedRepoPath {
6831 key: file_key,
6832 ..resolved
6833 });
6834 }
6835 if let Some(file_key) = Self::absolute_path_key_in_selected_project(&state, &file_path)? {
6836 let file_key = validated_repo_file_key(Path::new(&file_key))
6837 .map_err(|source| CliError::InvalidInput(source.to_string()))?;
6838 return Ok(McpResolvedRepoPath {
6839 state,
6840 key: file_key,
6841 routed_project: false,
6842 });
6843 }
6844 if project_path.is_some() || worktree.is_some() {
6845 return Err(Self::selected_project_path_error(
6846 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6847 ));
6848 }
6849 if !nearest_project {
6850 return Err(Self::selected_project_path_error(
6851 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6852 ));
6853 }
6854 Err(Self::selected_project_path_error(
6855 PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6856 ))
6857 }
6858
6859 fn state_and_optional_file_key(
6861 &self,
6862 project_path: Option<&str>,
6863 worktree: Option<&str>,
6864 file: Option<&str>,
6865 nearest_project: bool,
6866 ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
6867 let Some(file) = file else {
6868 return self
6869 .state_for_target(
6870 project_path.map(ToString::to_string),
6871 worktree.map(ToString::to_string),
6872 )
6873 .map(|state| (state, None, false));
6874 };
6875 let resolved = self.state_and_file_key(project_path, worktree, file, nearest_project)?;
6876 Ok((resolved.state, Some(resolved.key), resolved.routed_project))
6877 }
6878
6879 fn state_and_optional_folder_filter(
6881 &self,
6882 project_path: Option<&str>,
6883 worktree: Option<&str>,
6884 folder: Option<&str>,
6885 nearest_project: bool,
6886 ) -> Result<(McpProjectState, Option<String>, bool), CliError> {
6887 let state = self.state_for_target(
6888 project_path.map(ToString::to_string),
6889 worktree.map(ToString::to_string),
6890 )?;
6891 let Some(folder) = folder.map(str::trim).filter(|folder| !folder.is_empty()) else {
6892 return Ok((state, None, false));
6893 };
6894 let folder_path = PathBuf::from(&folder);
6895 if !folder_path.is_absolute() {
6896 let folder_filter = normalized_folder_filter(folder)?;
6897 return Ok((state, Some(folder_filter), false));
6898 }
6899 if nearest_project && project_path.is_none() && worktree.is_none() {
6900 let resolved = Self::nearest_state_and_repo_key(&state, folder)?.ok_or_else(|| {
6901 Self::selected_project_path_error(FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR)
6902 })?;
6903 let folder_filter = normalized_folder_filter(&resolved.key)?;
6904 return Ok((resolved.state, Some(folder_filter), resolved.routed_project));
6905 }
6906 if let Some(folder_filter) =
6907 Self::absolute_path_key_in_selected_project(&state, &folder_path)?
6908 {
6909 let folder_filter = normalized_folder_filter(&folder_filter)?;
6910 return Ok((state, Some(folder_filter), false));
6911 }
6912 if project_path.is_some() || worktree.is_some() {
6913 return Err(Self::selected_project_path_error(
6914 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6915 ));
6916 }
6917 if !nearest_project {
6918 return Err(Self::selected_project_path_error(
6919 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6920 ));
6921 }
6922 Err(Self::selected_project_path_error(
6923 FOLDER_NOT_INSIDE_INDEXED_PROJECT_ERROR,
6924 ))
6925 }
6926
6927 fn nearest_state_and_repo_key(
6929 active_state: &McpProjectState,
6930 path: &str,
6931 ) -> Result<Option<McpResolvedRepoPath>, CliError> {
6932 let path = Path::new(path);
6933 let absolute_path = McpAbsolutePath::canonicalize(path)?;
6934 let lexical_state = Self::project_state_from_nearest_lexical_indexed_path(path)?;
6935 let canonical_state = Self::project_state_from_nearest_indexed_path(path)?;
6936 Self::reject_ambiguous_nearest_project_path(
6937 path,
6938 lexical_state.as_ref(),
6939 canonical_state.as_ref(),
6940 &absolute_path,
6941 )?;
6942 let Some(state) = canonical_state else {
6943 return Ok(None);
6944 };
6945 if state.root == active_state.root {
6946 let key = McpSelectedRoot::from_state(active_state)
6947 .repo_key_for(&absolute_path)?
6948 .ok_or_else(|| {
6949 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
6950 })?;
6951 return Ok(Some(McpResolvedRepoPath {
6952 state: active_state.clone(),
6953 key: key.into_string(),
6954 routed_project: false,
6955 }));
6956 }
6957 let key = McpSelectedRoot::from_state(&state)
6958 .repo_key_for(&absolute_path)?
6959 .ok_or_else(|| {
6960 Self::selected_project_path_error(PATH_NOT_INSIDE_INDEXED_PROJECT_ERROR)
6961 })?;
6962 Ok(Some(McpResolvedRepoPath {
6963 state,
6964 key: key.into_string(),
6965 routed_project: true,
6966 }))
6967 }
6968
6969 fn absolute_path_key_in_selected_project(
6971 state: &McpProjectState,
6972 path: &Path,
6973 ) -> Result<Option<String>, CliError> {
6974 let absolute_path = McpAbsolutePath::canonicalize(path)?;
6975 McpSelectedRoot::from_state(state)
6976 .repo_key_for(&absolute_path)
6977 .map(|key| key.map(McpRepoKey::into_string))
6978 }
6979
6980 fn normalized_optional_path(path: Option<String>) -> Option<String> {
6982 path.map(|path| path.trim().to_string())
6983 .filter(|path| !path.is_empty())
6984 }
6985
6986 fn project_state_from_root(root: &Path) -> Result<McpProjectState, CliError> {
6988 Self::project_state_from_root_with_config_validation(root, McpConfigValidation::Immediate)
6989 }
6990
6991 fn project_state_from_root_with_config_validation(
6993 root: &Path,
6994 validation: McpConfigValidation,
6995 ) -> Result<McpProjectState, CliError> {
6996 let root = canonical_source_project_root(root)?;
6997 if !root.is_dir() {
6998 return Err(CliError::InvalidInput(format!(
6999 "project path '{}' is not a directory",
7000 root.display()
7001 )));
7002 }
7003 let db_path = Self::projectatlas_db_path(&root);
7004 let config_path = Self::config_path_for_project_root(&root, validation)?;
7005 Ok(McpProjectState {
7006 root,
7007 db_path,
7008 config_path,
7009 worktree: None,
7010 })
7011 }
7012
7013 fn project_state_from_nearest_indexed_path(
7015 path: &Path,
7016 ) -> Result<Option<McpProjectState>, CliError> {
7017 Self::project_state_from_nearest_indexed_path_with_config_validation(
7018 path,
7019 McpConfigValidation::Immediate,
7020 )
7021 }
7022
7023 fn project_state_from_nearest_indexed_path_with_config_validation(
7025 path: &Path,
7026 validation: McpConfigValidation,
7027 ) -> Result<Option<McpProjectState>, CliError> {
7028 let Ok(absolute_path) = McpAbsolutePath::canonicalize(path) else {
7029 return Ok(None);
7030 };
7031 let mut candidate = absolute_path.nearest_search_start();
7032 loop {
7033 if let Some(indexed_root) = Self::indexed_root_from_candidate(candidate) {
7034 let config_path =
7035 Self::config_path_for_project_root(&indexed_root.root, validation)?;
7036 return Ok(Some(McpProjectState {
7037 root: indexed_root.root,
7038 db_path: indexed_root.db_path,
7039 config_path,
7040 worktree: None,
7041 }));
7042 }
7043 let Some(parent) = candidate.parent() else {
7044 return Ok(None);
7045 };
7046 candidate = parent;
7047 }
7048 }
7049
7050 fn project_state_from_nearest_lexical_indexed_path(
7052 path: &Path,
7053 ) -> Result<Option<McpProjectState>, CliError> {
7054 Self::project_state_from_nearest_lexical_indexed_path_with_config_validation(
7055 path,
7056 McpConfigValidation::Immediate,
7057 )
7058 }
7059
7060 fn project_state_from_nearest_lexical_indexed_path_with_config_validation(
7062 path: &Path,
7063 validation: McpConfigValidation,
7064 ) -> Result<Option<McpProjectState>, CliError> {
7065 if !path.is_absolute() {
7066 return Ok(None);
7067 }
7068 let lexical_path = Self::lexically_normalized_absolute_path(path);
7069 let mut candidate = if lexical_path.is_dir() {
7070 lexical_path
7071 } else {
7072 lexical_path
7073 .parent()
7074 .unwrap_or(lexical_path.as_path())
7075 .to_path_buf()
7076 };
7077 loop {
7078 if let Some(indexed_root) = Self::indexed_root_from_lexical_candidate(&candidate) {
7079 let config_path =
7080 Self::config_path_for_project_root(&indexed_root.root, validation)?;
7081 return Ok(Some(McpProjectState {
7082 root: indexed_root.root,
7083 db_path: indexed_root.db_path,
7084 config_path,
7085 worktree: None,
7086 }));
7087 }
7088 let Some(parent) = candidate.parent() else {
7089 return Ok(None);
7090 };
7091 candidate = parent.to_path_buf();
7092 }
7093 }
7094
7095 fn indexed_root_from_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
7097 let Ok(root) = canonical_project_root(candidate) else {
7098 return None;
7099 };
7100 let Ok(root_identity) = CanonicalProjectRoot::from_path(&root) else {
7101 return None;
7102 };
7103 let db_path = Self::projectatlas_db_path(&root);
7104 if !db_path.is_file() || !Self::nearest_indexed_db_matches_root(&db_path, &root_identity) {
7105 return None;
7106 }
7107 Some(McpIndexedRoot { root, db_path })
7108 }
7109
7110 fn indexed_root_from_lexical_candidate(candidate: &Path) -> Option<McpIndexedRoot> {
7112 if Self::path_has_symlink_component(candidate) {
7113 return None;
7114 }
7115 let Ok(root) = canonical_project_root(candidate) else {
7116 return None;
7117 };
7118 let Ok(candidate_identity) = CanonicalProjectRoot::from_path(candidate) else {
7119 return None;
7120 };
7121 let Ok(root_identity) = CanonicalProjectRoot::from_path(&root) else {
7122 return None;
7123 };
7124 if candidate_identity != root_identity {
7125 return None;
7126 }
7127 let db_path = Self::projectatlas_db_path(&root);
7128 if !db_path.is_file() || !Self::nearest_indexed_db_matches_root(&db_path, &root_identity) {
7129 return None;
7130 }
7131 Some(McpIndexedRoot { root, db_path })
7132 }
7133
7134 fn lexically_normalized_absolute_path(path: &Path) -> PathBuf {
7136 let mut normalized = PathBuf::new();
7137 for component in path.components() {
7138 match component {
7139 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
7140 Component::RootDir => normalized.push(component.as_os_str()),
7141 Component::CurDir => {}
7142 Component::ParentDir => {
7143 normalized.pop();
7144 }
7145 Component::Normal(segment) => normalized.push(segment),
7146 }
7147 }
7148 normalized
7149 }
7150
7151 fn path_has_symlink_component(path: &Path) -> bool {
7153 let mut current = PathBuf::new();
7154 for component in path.components() {
7155 match component {
7156 Component::Prefix(prefix) => current.push(prefix.as_os_str()),
7157 Component::RootDir => current.push(component.as_os_str()),
7158 Component::CurDir => {}
7159 Component::ParentDir => {
7160 current.pop();
7161 }
7162 Component::Normal(segment) => {
7163 current.push(segment);
7164 if fs::symlink_metadata(¤t)
7165 .is_ok_and(|metadata| Self::metadata_is_symlink_or_reparse_point(&metadata))
7166 {
7167 return true;
7168 }
7169 }
7170 }
7171 }
7172 false
7173 }
7174
7175 fn metadata_is_symlink_or_reparse_point(metadata: &fs::Metadata) -> bool {
7177 if metadata.file_type().is_symlink() {
7178 return true;
7179 }
7180 #[cfg(windows)]
7181 {
7182 use std::os::windows::fs::MetadataExt;
7183 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
7184 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
7185 }
7186 #[cfg(not(windows))]
7187 {
7188 false
7189 }
7190 }
7191
7192 fn reject_ambiguous_nearest_project_path(
7194 path: &Path,
7195 lexical_state: Option<&McpProjectState>,
7196 canonical_state: Option<&McpProjectState>,
7197 canonical_path: &McpAbsolutePath,
7198 ) -> Result<(), CliError> {
7199 if let (Some(lexical_state), Some(canonical_state)) = (lexical_state, canonical_state) {
7200 if lexical_state.root != canonical_state.root {
7201 return Err(Self::ambiguous_nearest_project_path_error(
7202 path,
7203 Some(lexical_state),
7204 Some(canonical_state),
7205 ));
7206 }
7207 return Ok(());
7208 }
7209 let lexical_path = Self::lexically_normalized_absolute_path(path);
7210 if Self::path_has_symlink_component(&lexical_path)
7211 || lexical_state.is_some_and(|state| !canonical_path.as_path().starts_with(&state.root))
7212 {
7213 return Err(Self::ambiguous_nearest_project_path_error(
7214 path,
7215 lexical_state,
7216 canonical_state,
7217 ));
7218 }
7219 Ok(())
7220 }
7221
7222 fn ambiguous_nearest_project_path_error(
7224 path: &Path,
7225 lexical_state: Option<&McpProjectState>,
7226 canonical_state: Option<&McpProjectState>,
7227 ) -> CliError {
7228 let lexical_root = lexical_state.map_or_else(
7229 || MCP_NO_ROOT_PLACEHOLDER.to_string(),
7230 |state| normalize_native_path_display(&state.root),
7231 );
7232 let resolved_root = canonical_state.map_or_else(
7233 || MCP_NO_ROOT_PLACEHOLDER.to_string(),
7234 |state| normalize_native_path_display(&state.root),
7235 );
7236 let path_display = normalize_native_path_display(path);
7237 let mut message = String::new();
7238 message.push_str(AMBIGUOUS_NEAREST_PROJECT_PATH_ERROR);
7239 message.push_str(MCP_ERROR_FOR_PATH_FRAGMENT);
7240 message.push_str(&path_display);
7241 message.push_str(MCP_ERROR_LEXICAL_ROOT_FRAGMENT);
7242 message.push_str(&lexical_root);
7243 message.push_str(MCP_ERROR_RESOLVED_ROOT_FRAGMENT);
7244 message.push_str(&resolved_root);
7245 message.push_str(MCP_ERROR_GUIDANCE_FRAGMENT);
7246 message.push_str(OUTSIDE_SELECTED_PROJECT_GUIDANCE);
7247 CliError::InvalidInput(message)
7248 }
7249
7250 fn nearest_indexed_db_matches_root(db_path: &Path, root: &CanonicalProjectRoot) -> bool {
7252 match read_project_root_identity_read_only(db_path) {
7253 Ok(Some(stored_root)) => Self::nearest_existing_roots_match(&stored_root, root),
7254 Ok(None) => {
7255 let Ok(Some(legacy_root)) = read_legacy_project_root_candidate_read_only(db_path)
7256 else {
7257 return false;
7258 };
7259 let Ok(legacy_root) = CanonicalProjectRoot::from_path(Path::new(&legacy_root))
7260 else {
7261 return false;
7262 };
7263 Self::nearest_existing_roots_match(&legacy_root, root)
7264 }
7265 Err(_) => false,
7266 }
7267 }
7268
7269 fn nearest_existing_roots_match(
7271 persisted_root: &CanonicalProjectRoot,
7272 selected_root: &CanonicalProjectRoot,
7273 ) -> bool {
7274 let Ok(persisted_root) = CanonicalProjectRoot::from_path(persisted_root.as_path()) else {
7275 return false;
7276 };
7277 let Ok(selected_root) = CanonicalProjectRoot::from_path(selected_root.as_path()) else {
7278 return false;
7279 };
7280 persisted_root == selected_root
7281 }
7282
7283 fn projectatlas_db_path(root: &Path) -> PathBuf {
7285 root.join(PROJECTATLAS_DIR_NAME)
7286 .join(PROJECTATLAS_DB_FILE_NAME)
7287 }
7288
7289 fn projectatlas_nested_config_path(root: &Path) -> PathBuf {
7291 root.join(PROJECTATLAS_DIR_NAME)
7292 .join(PROJECTATLAS_CONFIG_FILE_NAME)
7293 }
7294
7295 fn projectatlas_flat_config_path(root: &Path) -> PathBuf {
7297 root.join(PROJECTATLAS_FLAT_CONFIG_FILE_NAME)
7298 }
7299
7300 fn config_path_for_project_root(
7302 root: &Path,
7303 validation: McpConfigValidation,
7304 ) -> Result<Option<PathBuf>, CliError> {
7305 for config_path in [
7306 Self::projectatlas_nested_config_path(root),
7307 Self::projectatlas_flat_config_path(root),
7308 ] {
7309 if config_path.exists() {
7310 if validation == McpConfigValidation::Immediate {
7311 Self::validate_project_config_root(root, &config_path)?;
7312 }
7313 return Ok(Some(config_path));
7314 }
7315 }
7316 Ok(None)
7317 }
7318
7319 fn validate_project_config_root(root: &Path, config_path: &Path) -> Result<(), CliError> {
7321 let config = load_atlas_config(Some(config_path))?;
7322 let config_root = canonical_project_root(&config.root)?;
7323 if config_root != root {
7324 return Err(config_root_mismatch_error(config_path, &config_root, root));
7325 }
7326 Ok(())
7327 }
7328
7329 fn config_matches_project_root(root: &Path, config_path: &Path) -> bool {
7331 Self::validate_project_config_root(root, config_path).is_ok()
7332 }
7333
7334 fn render_project_state(state: &McpProjectState) -> Result<String, CliError> {
7336 let payload = McpProjectStateResponse {
7337 project: Self::project_state_payload(state),
7338 };
7339 Self::encode_serialized_payload(payload)
7340 }
7341
7342 fn project_state_payload(state: &McpProjectState) -> McpProjectStatePayload {
7344 McpProjectStatePayload {
7345 worktree: state
7346 .worktree
7347 .as_ref()
7348 .map(|selection| selection.alias.clone()),
7349 registration_id: state
7350 .worktree
7351 .as_ref()
7352 .and_then(|selection| selection.registration_id),
7353 root: lossless_project_root_display(&state.root),
7354 db: lossless_native_path_display(&state.db_path),
7355 config: state
7356 .config_path
7357 .as_ref()
7358 .and_then(|path| lossless_native_path_display(path)),
7359 status: McpProjectStatus::Active,
7360 }
7361 }
7362
7363 fn with_selected_project_audit(
7365 state: &McpProjectState,
7366 routed_project: bool,
7367 toon: String,
7368 ) -> Result<String, CliError> {
7369 if !routed_project {
7370 return Ok(toon);
7371 }
7372 let prefix = Self::encode_named_payload(
7373 MCP_PAYLOAD_SELECTED_PROJECT,
7374 &Self::project_state_payload(state),
7375 )?;
7376 let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
7377 audited.push_str(&prefix);
7378 audited.push('\n');
7379 audited.push_str(&toon);
7380 Ok(audited)
7381 }
7382
7383 fn with_selected_project_audit_controlled(
7385 state: &McpProjectState,
7386 routed_project: bool,
7387 toon: String,
7388 control: &IndexWorkControl,
7389 ) -> Result<String, CliError> {
7390 control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
7391 if !routed_project {
7392 return Ok(toon);
7393 }
7394 let prefix = controlled_named_output(
7395 OutputFormat::Toon,
7396 MCP_PAYLOAD_SELECTED_PROJECT,
7397 &Self::project_state_payload(state),
7398 control,
7399 )?;
7400 let mut audited = String::with_capacity(prefix.len() + 1 + toon.len());
7401 audited.push_str(&prefix);
7402 audited.push('\n');
7403 audited.push_str(&toon);
7404 control.check(projectatlas_core::IndexWorkStage::RepositoryTraversal)?;
7405 Ok(audited)
7406 }
7407
7408 fn path_or_project_root(
7410 state: &McpProjectState,
7411 path: Option<String>,
7412 ) -> Result<PathBuf, CliError> {
7413 let Some(value) = path else {
7414 return Ok(state.root.clone());
7415 };
7416 if value.is_empty() {
7417 return Ok(state.root.clone());
7418 }
7419 let original = value.clone();
7420 let candidate = PathBuf::from(value);
7421 let resolved = if candidate.is_absolute() {
7422 candidate
7423 } else {
7424 state.root.join(candidate)
7425 };
7426 let resolved = canonical_project_root(&resolved)?;
7427 if resolved == state.root {
7428 Ok(resolved)
7429 } else if resolved.starts_with(&state.root) {
7430 let resolved_display = resolved.display();
7431 let project_root_display = state.root.display();
7432 Err(CliError::InvalidInput(format!(
7433 "MCP path '{original}' resolves to '{resolved_display}', not the selected project root '{project_root_display}'; {SELECTED_ROOT_ASSERTION_GUIDANCE}"
7434 )))
7435 } else {
7436 let resolved_display = resolved.display();
7437 let project_root_display = state.root.display();
7438 Err(CliError::InvalidInput(format!(
7439 "MCP path '{original}' resolves to '{resolved_display}', outside the selected project root '{project_root_display}'; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"
7440 )))
7441 }
7442 }
7443
7444 fn preflight_purpose_path(state: &McpProjectState, path: &str) -> Result<String, CliError> {
7446 let node_key = validated_repo_node_key(std::path::Path::new(path))
7447 .map_err(Self::selected_project_path_error)?;
7448 let store = Self::open_read_store(state)?;
7449 let indexed = store.load_node_by_path(&node_key)?.is_some();
7450 store.finish_index_read_snapshot()?;
7451 let source = state.root.join(&node_key);
7452 if !indexed
7453 && !source.try_exists().map_err(|source_error| CliError::Io {
7454 path: source,
7455 source: source_error,
7456 })?
7457 {
7458 return Err(CliError::InvalidInput(format!(
7459 "path {node_key:?} is not indexed in the MCP-bound project"
7460 )));
7461 }
7462 Ok(node_key)
7463 }
7464
7465 fn require_indexed_purpose_path(store: &AtlasStore, node_key: &str) -> Result<(), CliError> {
7467 if store.load_node_by_path(node_key)?.is_none() {
7468 return Err(CliError::InvalidInput(format!(
7469 "path {node_key:?} is not indexed in the MCP-bound project"
7470 )));
7471 }
7472 Ok(())
7473 }
7474
7475 fn selected_project_path_error(message: impl std::fmt::Display) -> CliError {
7477 CliError::InvalidInput(format!("{message}; {OUTSIDE_SELECTED_PROJECT_GUIDANCE}"))
7478 }
7479
7480 fn query_or_empty(query: Option<String>) -> String {
7482 query.unwrap_or_default()
7483 }
7484
7485 fn encode_serialized_payload<T>(payload: T) -> Result<String, CliError>
7487 where
7488 T: Serialize,
7489 {
7490 Ok(encode_agent_payload(&serde_json::to_value(payload)?))
7491 }
7492
7493 fn encode_named_payload<T>(key: &str, payload: &T) -> Result<String, CliError>
7495 where
7496 T: Serialize,
7497 {
7498 let mut object = serde_json::Map::new();
7499 object.insert(key.to_string(), serde_json::to_value(payload)?);
7500 Ok(encode_agent_payload(&serde_json::Value::Object(object)))
7501 }
7502
7503 fn encode_two_named_payloads<T, U>(
7505 first_key: &str,
7506 first_payload: &T,
7507 second_key: &str,
7508 second_payload: &U,
7509 ) -> Result<String, CliError>
7510 where
7511 T: Serialize,
7512 U: Serialize,
7513 {
7514 let mut object = serde_json::Map::new();
7515 object.insert(first_key.to_string(), serde_json::to_value(first_payload)?);
7516 object.insert(
7517 second_key.to_string(),
7518 serde_json::to_value(second_payload)?,
7519 );
7520 Ok(encode_agent_payload(&serde_json::Value::Object(object)))
7521 }
7522
7523 fn encode_error_payload(error: &CliError) -> String {
7525 let schema_version_mismatch = schema_version_mismatch_payload(error);
7526 let schema_migration_required = schema_migration_required_payload(error);
7527 let message = schema_migration_required.as_ref().map_or_else(
7528 || error.to_string(),
7529 SchemaMigrationRequiredPayload::message,
7530 );
7531 let (
7532 kind,
7533 refresh_required,
7534 init_required,
7535 worktree_required,
7536 verification_incomplete,
7537 project_mismatch,
7538 database_filesystem,
7539 search_capability,
7540 next,
7541 ) = match error {
7542 CliError::InitRequired(report) => (
7543 AgentErrorKind::InitRequired,
7544 None,
7545 Some(report.as_ref().clone()),
7546 None,
7547 None,
7548 None,
7549 None,
7550 None,
7551 Some(McpNextCall {
7552 tool: MCP_TOOL_ATLAS_INIT,
7553 project_path: report
7554 .worktree
7555 .is_none()
7556 .then(|| report.project_root.clone())
7557 .flatten(),
7558 worktree: report.worktree.clone(),
7559 }),
7560 ),
7561 CliError::WorktreeRequired(report) => (
7562 AgentErrorKind::WorktreeRequired,
7563 None,
7564 None,
7565 Some(report.as_ref().clone()),
7566 None,
7567 None,
7568 None,
7569 None,
7570 None,
7571 ),
7572 CliError::RefreshRequired(report) => (
7573 AgentErrorKind::RefreshRequired,
7574 Some(report.as_ref().clone()),
7575 None,
7576 None,
7577 None,
7578 None,
7579 None,
7580 None,
7581 Some(McpNextCall {
7582 tool: MCP_TOOL_ATLAS_WATCH_ONCE,
7583 project_path: report
7584 .worktree
7585 .is_none()
7586 .then(|| report.project_root.clone())
7587 .flatten(),
7588 worktree: report.worktree.clone(),
7589 }),
7590 ),
7591 CliError::VerificationIncomplete(report) => (
7592 AgentErrorKind::VerificationIncomplete,
7593 None,
7594 None,
7595 None,
7596 Some(report.as_ref().clone()),
7597 None,
7598 None,
7599 None,
7600 None,
7601 ),
7602 CliError::ProjectMismatch(report) => (
7603 AgentErrorKind::ProjectMismatch,
7604 None,
7605 None,
7606 None,
7607 None,
7608 Some(report.as_ref().clone()),
7609 None,
7610 None,
7611 None,
7612 ),
7613 CliError::Service(ServiceError::SearchCapabilityUnavailable {
7614 requested_mode,
7615 state,
7616 guidance,
7617 }) => (
7618 AgentErrorKind::SearchCapabilityUnavailable,
7619 None,
7620 None,
7621 None,
7622 None,
7623 None,
7624 None,
7625 Some(crate::SearchCapabilityErrorPayload {
7626 requested_mode: *requested_mode,
7627 state,
7628 recovery: guidance,
7629 }),
7630 None,
7631 ),
7632 _ if schema_version_mismatch.is_some() => (
7633 AgentErrorKind::SchemaVersionMismatch,
7634 None,
7635 None,
7636 None,
7637 None,
7638 None,
7639 None,
7640 None,
7641 None,
7642 ),
7643 _ if schema_migration_required.is_some() => (
7644 AgentErrorKind::SchemaMigrationRequired,
7645 None,
7646 None,
7647 None,
7648 None,
7649 None,
7650 None,
7651 None,
7652 None,
7653 ),
7654 _ => database_filesystem_error_payload(error).map_or(
7655 (
7656 AgentErrorKind::Error,
7657 None,
7658 None,
7659 None,
7660 None,
7661 None,
7662 None,
7663 None,
7664 None,
7665 ),
7666 |(kind, database_filesystem)| {
7667 (
7668 kind,
7669 None,
7670 None,
7671 None,
7672 None,
7673 None,
7674 Some(database_filesystem),
7675 None,
7676 None,
7677 )
7678 },
7679 ),
7680 };
7681 let payload = McpErrorResponse {
7682 error: McpErrorPayload {
7683 kind,
7684 message,
7685 refresh_required,
7686 init_required,
7687 worktree_required,
7688 verification_incomplete,
7689 project_mismatch,
7690 database_filesystem,
7691 schema_version_mismatch,
7692 schema_migration_required,
7693 search_capability,
7694 next,
7695 },
7696 };
7697 serde_json::to_value(payload).map_or_else(
7698 |source| {
7699 let mut message = MCP_ERROR_SERIALIZATION_FALLBACK_PREFIX.to_string();
7700 message.push_str(&source.to_string());
7701 message
7702 },
7703 |value| encode_agent_payload(&value),
7704 )
7705 }
7706
7707 fn as_mcp_text(result: Result<String, CliError>) -> McpToolTextResult {
7709 match result {
7710 Ok(text) => McpToolTextResult(Ok(text)),
7711 Err(error) => {
7712 let payload = Self::encode_error_payload(&error);
7713 if schema_version_mismatch_payload(&error).is_some() {
7714 McpToolTextResult(Err(payload))
7715 } else {
7716 McpToolTextResult(Ok(payload))
7717 }
7718 }
7719 }
7720 }
7721}
7722
7723fn health_query_from_params(
7725 params: &AtlasHealthParams,
7726 scope: HealthScope,
7727) -> Result<HealthQuery, CliError> {
7728 Ok(HealthQuery {
7729 start_index: params.start_index.unwrap_or(0),
7730 limit: params
7731 .limit
7732 .filter(|value| *value > 0)
7733 .unwrap_or(DEFAULT_HEALTH_LIMIT)
7734 .min(MAX_HEALTH_LIMIT),
7735 category: trimmed_filter(params.category.as_deref()),
7736 severity: trimmed_filter(params.severity.as_deref())
7737 .as_deref()
7738 .map(parse_health_severity)
7739 .transpose()?,
7740 path_prefix: trimmed_filter(params.path_prefix.as_deref())
7741 .map(|value| normalize_repo_path_prefix(&value)),
7742 summary_only: params.summary_only.unwrap_or(false),
7743 scope,
7744 })
7745}
7746
7747fn has_coverage_filters(params: &AtlasHealthParams) -> bool {
7749 params.parser.is_some()
7750 || params.provider.is_some()
7751 || params.relation.is_some()
7752 || params.coverage_state.is_some()
7753 || params.reason.is_some()
7754}
7755
7756fn coverage_query_from_params(
7758 params: &AtlasHealthParams,
7759) -> Result<RepositoryCoverageQuery, CliError> {
7760 let limit = params
7761 .limit
7762 .filter(|value| *value > 0)
7763 .unwrap_or(DEFAULT_HEALTH_LIMIT)
7764 .min(COVERAGE_PAGE_MAX_LIMIT as usize);
7765 Ok(RepositoryCoverageQuery {
7766 start_index: u32::try_from(params.start_index.unwrap_or(0)).map_err(|error| {
7767 let mut message = String::from(MCP_ERROR_COVERAGE_START_INDEX_TOO_LARGE_PREFIX);
7768 message.push_str(&error.to_string());
7769 CliError::InvalidInput(message)
7770 })?,
7771 limit: u32::try_from(limit).map_err(|error| {
7772 let mut message = String::from(MCP_ERROR_COVERAGE_LIMIT_TOO_LARGE_PREFIX);
7773 message.push_str(&error.to_string());
7774 CliError::InvalidInput(message)
7775 })?,
7776 path_prefix: trimmed_filter(params.path_prefix.as_deref())
7777 .map(|value| normalize_repo_path_prefix(&value)),
7778 parser: trimmed_filter(params.parser.as_deref())
7779 .as_deref()
7780 .map(parse_coverage_parser)
7781 .transpose()?,
7782 provider: trimmed_filter(params.provider.as_deref())
7783 .as_deref()
7784 .map(parse_coverage_parser)
7785 .transpose()?,
7786 relation: trimmed_filter(params.relation.as_deref())
7787 .as_deref()
7788 .map(parse_coverage_relation)
7789 .transpose()?,
7790 state: trimmed_filter(params.coverage_state.as_deref())
7791 .as_deref()
7792 .map(parse_coverage_state)
7793 .transpose()?,
7794 reason: trimmed_filter(params.reason.as_deref()),
7795 })
7796}
7797
7798fn purpose_queue_scope(params: &AtlasHealthParams) -> HealthScope {
7800 match (
7801 params.include_assets.unwrap_or(false),
7802 params.include_low_priority_files.unwrap_or(false),
7803 ) {
7804 (false, false) => HealthScope::purpose_default(),
7805 (true, false) => HealthScope::purpose_with_assets(),
7806 (false, true) => HealthScope::purpose_with_source_files(),
7807 (true, true) => HealthScope::all(),
7808 }
7809}
7810
7811fn trimmed_filter(value: Option<&str>) -> Option<String> {
7813 value
7814 .map(str::trim)
7815 .filter(|value| !value.is_empty())
7816 .map(ToString::to_string)
7817}
7818
7819fn parse_health_severity(value: &str) -> Result<Severity, CliError> {
7821 let trimmed = value.trim();
7822 trimmed.parse::<Severity>().map_err(|_source| {
7823 let expected = expected_health_severity_names();
7824 CliError::InvalidInput(format!(
7825 "invalid health severity '{trimmed}'; expected {expected}"
7826 ))
7827 })
7828}
7829
7830fn expected_health_severity_names() -> String {
7832 let mut expected = Severity::Info.as_str().to_string();
7833 expected.push_str(SEVERITY_EXPECTED_SEPARATOR);
7834 expected.push_str(Severity::Warning.as_str());
7835 expected.push_str(SEVERITY_EXPECTED_FINAL_SEPARATOR);
7836 expected.push_str(Severity::Error.as_str());
7837 expected
7838}
7839
7840#[tool_router(router = tool_router)]
7841impl ProjectAtlasMcpServer {
7842 #[tool(
7844 name = "atlas_set_project_path",
7845 description = "Select the active ProjectAtlas project root for later MCP calls that omit project_path."
7846 )]
7847 fn atlas_set_project_path(
7848 &self,
7849 Parameters(params): Parameters<AtlasSetProjectPathParams>,
7850 ) -> McpToolTextResult {
7851 Self::as_mcp_text((|| {
7852 let state = Self::project_state_from_root(Path::new(¶ms.project_path))?;
7853 self.set_active_project_state(state.clone())?;
7854 Self::render_project_state(&state)
7855 })())
7856 }
7857
7858 #[tool(
7860 name = "atlas_worktree_list",
7861 description = "List structurally discovered Git worktrees, short ProjectAtlas aliases, atlas availability, and telemetry synchronization state without changing Git or files."
7862 )]
7863 fn atlas_worktree_list(
7864 &self,
7865 Parameters(params): Parameters<AtlasWorktreeListParams>,
7866 ) -> McpToolTextResult {
7867 Self::as_mcp_text((|| {
7868 let repository = self.control_git_repository()?;
7869 let store = open_atlas_store_read_only_for_project(
7870 &self.control_state.db_path,
7871 &self.control_state.root,
7872 )?;
7873 let include_retired = params.include_retired.unwrap_or(false);
7874 let registrations = store.worktree_registrations(include_retired)?;
7875 let structural_identities = repository
7876 .worktrees
7877 .iter()
7878 .filter_map(Self::worktree_administrative_path_identity)
7879 .collect::<HashSet<_>>();
7880 let (mut worktrees, unregistered): (Vec<_>, Vec<_>) = repository
7881 .worktrees
7882 .iter()
7883 .map(|entry| {
7884 self.worktree_list_row(&repository.common_directory, entry, ®istrations)
7885 })
7886 .partition(|row| {
7887 !matches!(row.registration, McpWorktreeRegistrationState::Unregistered)
7888 });
7889 worktrees.extend(
7890 registrations
7891 .iter()
7892 .filter(|registration| {
7893 registration.state == WorktreeRegistrationState::Active
7894 && !structural_identities
7895 .contains(®istration.git_administrative_directory_identity)
7896 })
7897 .map(Self::missing_registered_worktree_row),
7898 );
7899 let total_worktrees = worktrees.len() + unregistered.len();
7900 worktrees.extend(
7901 unregistered
7902 .into_iter()
7903 .take(MCP_WORKTREE_LIST_MAX_ROWS.saturating_sub(worktrees.len())),
7904 );
7905 let truncated = total_worktrees > worktrees.len();
7906 let retired = registrations
7907 .iter()
7908 .filter(|registration| registration.state == WorktreeRegistrationState::Retired)
7909 .map(|registration| McpRetiredWorktreeRow {
7910 alias: registration.alias.to_string(),
7911 path_display: if registration.last_root_identity.display_string().is_ok() {
7912 McpWorktreePathDisplayState::Available
7913 } else {
7914 McpWorktreePathDisplayState::Unavailable
7915 },
7916 last_root: registration.last_root_identity.display_string().ok(),
7917 project_instance_id: registration
7918 .project_instance_id
7919 .map(|identity| identity.to_string()),
7920 accepted_telemetry_revision: registration.accepted_telemetry_revision,
7921 })
7922 .collect();
7923 Self::encode_named_payload(
7924 MCP_PAYLOAD_WORKTREES,
7925 &McpWorktreeListReport {
7926 control_alias: MCP_MAIN_WORKTREE_ALIAS,
7927 control_root: lossless_project_root_display(&self.control_state.root),
7928 common_directory: lossless_native_path_display(&repository.common_directory),
7929 worktrees,
7930 retired,
7931 total_worktrees,
7932 truncated,
7933 },
7934 )
7935 })())
7936 }
7937
7938 #[tool(
7940 name = "atlas_worktree_add",
7941 description = "Register one structurally discovered Git worktree under a short ProjectAtlas alias without creating, moving, or switching Git worktrees."
7942 )]
7943 fn atlas_worktree_add(
7944 &self,
7945 Parameters(params): Parameters<AtlasWorktreeAddParams>,
7946 ) -> McpToolTextResult {
7947 Self::as_mcp_text((|| {
7948 let requested = params.worktree.trim();
7949 if requested.is_empty() {
7950 return Err(CliError::InvalidInput(
7951 MCP_ERROR_WORKTREE_SELECTOR_EMPTY.to_string(),
7952 ));
7953 }
7954 let repository = self.control_git_repository()?;
7955 let candidates = self.matching_worktree_candidates(&repository, requested);
7956 if candidates.len() != 1 {
7957 let ambiguous = !candidates.is_empty();
7958 let candidate_rows: Vec<McpWorktreeCandidate> = if candidates.is_empty() {
7959 repository
7960 .worktrees
7961 .iter()
7962 .filter(|entry| {
7963 Self::active_worktree_root(entry)
7964 .is_some_and(|root| root != self.control_state.root)
7965 })
7966 .filter_map(|entry| {
7967 Self::worktree_candidate(&repository.common_directory, entry)
7968 })
7969 .take(MCP_WORKTREE_LIST_MAX_ROWS)
7970 .collect()
7971 } else {
7972 candidates
7973 .into_iter()
7974 .filter_map(|entry| {
7975 Self::worktree_candidate(&repository.common_directory, entry)
7976 })
7977 .take(MCP_WORKTREE_LIST_MAX_ROWS)
7978 .collect()
7979 };
7980 return Self::encode_named_payload(
7981 MCP_PAYLOAD_WORKTREE,
7982 &McpWorktreeMutationReport {
7983 operation: McpWorktreeMutationOperation::Add,
7984 status: if ambiguous {
7985 McpWorktreeMutationStatus::Ambiguous
7986 } else {
7987 McpWorktreeMutationStatus::NotFound
7988 },
7989 selector: None,
7990 alias: None,
7991 root: None,
7992 path_display: None,
7993 registration_id: None,
7994 telemetry_sync: None,
7995 candidates: candidate_rows,
7996 blocker: Some(format!(
7997 "selector {requested:?} did not identify exactly one active non-control worktree"
7998 )),
7999 git_unchanged: true,
8000 files_unchanged: true,
8001 },
8002 );
8003 }
8004 let entry = candidates[0];
8005 let root = Self::active_worktree_root(entry).ok_or_else(|| {
8006 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
8007 })?;
8008 let administrative_identity =
8009 git_administrative_identity(&entry.administrative_directory)?;
8010 let alias = match params.alias.as_deref() {
8011 Some(alias) => WorktreeAlias::parse(alias.trim())?,
8012 None => Self::default_worktree_alias(root)?,
8013 };
8014 let db_path = Self::projectatlas_db_path(root);
8015 let mut project_instance_id = None;
8016 let local = (|| {
8017 let Some(store) = Self::open_local_worktree_atlas(root)? else {
8018 return Ok(None);
8019 };
8020 let identity = Self::local_worktree_project_instance_id(&store, &db_path)?;
8021 project_instance_id = Some(identity);
8022 let snapshot = Self::local_worktree_usage_snapshot(&store, &db_path, identity)?;
8023 Ok::<_, CliError>(Some(LocalWorktreeAtlas {
8024 project_instance_id: identity,
8025 snapshot,
8026 }))
8027 })();
8028 let (local, blocker) = match local {
8029 Ok(local) => (local, None),
8030 Err(error) if project_instance_id.is_some() => return Err(error),
8031 Err(error) => (
8032 None,
8033 Some(format!(
8034 "registration committed without local telemetry import: {error}"
8035 )),
8036 ),
8037 };
8038 let (repository, entry) =
8039 self.revalidate_worktree_candidate(&repository, entry, &administrative_identity)?;
8040 let root = Self::active_worktree_root(&entry).ok_or_else(|| {
8041 CliError::InvalidInput(MCP_ERROR_WORKTREE_NO_LONGER_ACTIVE.to_string())
8042 })?;
8043 Self::revalidate_local_worktree_atlas_identity(root, project_instance_id)?;
8044 if let Some(local) = local.as_ref() {
8045 require_current_worktree_usage_snapshot(&db_path, root, &local.snapshot)?;
8046 }
8047 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
8048 let created_at_epoch = Self::current_epoch_seconds()?;
8049 let (registration, telemetry_sync) = if let Some(local) = local.as_ref() {
8050 match control.register_worktree_with_usage_snapshot(
8051 &alias,
8052 &repository.common_directory,
8053 &entry.administrative_directory,
8054 &administrative_identity,
8055 root,
8056 local.project_instance_id,
8057 &local.snapshot,
8058 created_at_epoch,
8059 ) {
8060 Ok((registration, state)) => (registration, Some(state)),
8061 Err(error) => return Err(error.into()),
8062 }
8063 } else {
8064 (
8065 control.register_worktree(
8066 &alias,
8067 &repository.common_directory,
8068 &entry.administrative_directory,
8069 &administrative_identity,
8070 root,
8071 project_instance_id,
8072 created_at_epoch,
8073 )?,
8074 None,
8075 )
8076 };
8077 Self::encode_named_payload(
8078 MCP_PAYLOAD_WORKTREE,
8079 &McpWorktreeMutationReport {
8080 operation: McpWorktreeMutationOperation::Add,
8081 status: McpWorktreeMutationStatus::Registered,
8082 selector: Some(Self::worktree_candidate_selector(&entry)),
8083 alias: Some(alias.to_string()),
8084 root: lossless_project_root_display(root),
8085 path_display: Some(
8086 if root.to_str().is_some()
8087 && repository.common_directory.to_str().is_some()
8088 && entry.administrative_directory.to_str().is_some()
8089 {
8090 McpWorktreePathDisplayState::Available
8091 } else {
8092 McpWorktreePathDisplayState::Unavailable
8093 },
8094 ),
8095 registration_id: Some(registration.registration_id),
8096 telemetry_sync,
8097 candidates: Vec::new(),
8098 blocker,
8099 git_unchanged: true,
8100 files_unchanged: true,
8101 },
8102 )
8103 })())
8104 }
8105
8106 #[tool(
8108 name = "atlas_worktree_remove",
8109 description = "Final-sync and retire one ProjectAtlas worktree alias while preserving retained token totals and leaving Git, source files, .projectatlas, and SQLite files untouched."
8110 )]
8111 fn atlas_worktree_remove(
8112 &self,
8113 Parameters(params): Parameters<AtlasWorktreeRemoveParams>,
8114 ) -> McpToolTextResult {
8115 Self::as_mcp_text((|| {
8116 let alias = WorktreeAlias::parse(params.worktree.trim())?;
8117 let repository = self.control_git_repository()?;
8118 let control = Self::open_existing_mut_store(&self.control_state, &self.control_state)?;
8119 let registration = control.worktree_registration(&alias)?;
8120 let mut blocker = None;
8121 let entry = repository.worktrees.iter().find(|entry| {
8122 Self::worktree_administrative_path_identity(entry).is_some_and(|identity| {
8123 identity == registration.git_administrative_directory_identity
8124 })
8125 });
8126 let entry = match entry {
8127 Some(entry) if matches!(entry.state, GitWorktreeState::Invalid { .. }) => {
8128 Some(entry)
8129 }
8130 Some(entry) => match git_administrative_identity(&entry.administrative_directory) {
8131 Ok(identity) if identity == registration.git_administrative_identity => {
8132 Some(entry)
8133 }
8134 Ok(_) => {
8135 blocker = Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED.to_string());
8136 None
8137 }
8138 Err(error) => {
8139 blocker = Some(error.to_string());
8140 None
8141 }
8142 },
8143 None => None,
8144 };
8145 let retired_at_epoch = Self::current_epoch_seconds()?;
8146 let retained_path_display =
8147 Self::missing_registered_worktree_row(®istration).path_display;
8148 let (root_display, active_root) = match entry.map(|entry| &entry.state) {
8149 Some(GitWorktreeState::Active { root, .. }) => {
8150 (lossless_project_root_display(root), Some(root.as_path()))
8151 }
8152 Some(GitWorktreeState::Invalid { issue }) => {
8153 return Err(CliError::InvalidInput(format!(
8154 "cannot retire worktree '{}' while its Git evidence is invalid at '{}': {:?}",
8155 alias,
8156 normalize_native_path_display(&issue.path),
8157 issue.kind
8158 )));
8159 }
8160 Some(GitWorktreeState::Missing { .. }) | None => {
8161 blocker
8162 .get_or_insert_with(|| MCP_WORKTREE_MISSING_RETENTION_REASON.to_string());
8163 (registration.last_root_identity.display_string().ok(), None)
8164 }
8165 };
8166 let (retired, telemetry_sync, final_blocker) = Self::retire_registered_worktree(
8167 &control,
8168 ®istration,
8169 active_root,
8170 retired_at_epoch,
8171 blocker,
8172 )?;
8173 Self::encode_named_payload(
8174 MCP_PAYLOAD_WORKTREE,
8175 &McpWorktreeMutationReport {
8176 operation: McpWorktreeMutationOperation::Remove,
8177 status: McpWorktreeMutationStatus::Retired,
8178 selector: entry.map(Self::worktree_candidate_selector),
8179 alias: Some(alias.to_string()),
8180 root: root_display,
8181 path_display: Some(entry.map_or_else(
8182 || retained_path_display,
8183 |entry| match &entry.state {
8184 GitWorktreeState::Active { root, .. }
8185 if root.to_str().is_some()
8186 && repository.common_directory.to_str().is_some()
8187 && entry.administrative_directory.to_str().is_some() =>
8188 {
8189 McpWorktreePathDisplayState::Available
8190 }
8191 GitWorktreeState::Missing { .. } => retained_path_display,
8192 GitWorktreeState::Active { .. } | GitWorktreeState::Invalid { .. } => {
8193 McpWorktreePathDisplayState::Unavailable
8194 }
8195 },
8196 )),
8197 registration_id: Some(retired.registration_id),
8198 telemetry_sync,
8199 candidates: Vec::new(),
8200 blocker: final_blocker,
8201 git_unchanged: true,
8202 files_unchanged: true,
8203 },
8204 )
8205 })())
8206 }
8207
8208 #[tool(
8210 name = "atlas_init",
8211 description = "Initialize ProjectAtlas project-local config, database, host MCP configs, scan/index, and purpose handoff."
8212 )]
8213 fn atlas_init(&self, Parameters(params): Parameters<AtlasInitParams>) -> McpToolTextResult {
8214 Self::as_mcp_text((|| {
8215 let state = self.init_project_root(params.project_path, params.worktree)?;
8216 let config_path = init_config_path(&state.root, state.config_path.as_deref());
8217 let mut report = self.run_registered_worktree_init(
8218 &state,
8219 &config_path,
8220 &InitBootstrapOptions {
8221 no_scan: params.no_scan.unwrap_or(false),
8222 force_rescan: params.force_rescan.unwrap_or(false),
8223 text_index_max_bytes: params.text_index_max_bytes,
8224 },
8225 )?;
8226 crate::write_init_mcp_config_files(
8227 &mut report,
8228 &state.root.join(PROJECTATLAS_DIR_NAME),
8229 &state.db_path,
8230 &config_path,
8231 false,
8232 );
8233 Self::encode_named_payload(MCP_PAYLOAD_INIT, &report)
8234 })())
8235 }
8236
8237 #[tool(
8239 name = "atlas_map",
8240 description = "Write the explicit compatibility ProjectAtlas map export for older workflows."
8241 )]
8242 fn atlas_map(&self, Parameters(params): Parameters<AtlasMapParams>) -> McpToolTextResult {
8243 Self::as_mcp_text((|| {
8244 let state = self.admin_project_root(params.project_path, params.worktree)?;
8245 let report = Self::build_map_report(
8246 &state,
8247 params.json.unwrap_or(false),
8248 params.force.unwrap_or(false),
8249 )?;
8250 Self::encode_named_payload(MCP_PAYLOAD_MAP, &report)
8251 })())
8252 }
8253
8254 #[tool(
8256 name = "atlas_root",
8257 description = "Show or verify ProjectAtlas root, DB, config, and runtime identity."
8258 )]
8259 fn atlas_root(&self, Parameters(params): Parameters<AtlasRootParams>) -> McpToolTextResult {
8260 Self::as_mcp_text((|| {
8261 if let Some(control_root) = params.control_root.as_deref() {
8262 if params.project_path.is_some()
8263 || params.worktree.is_some()
8264 || params.verify.unwrap_or(false)
8265 {
8266 return Err(CliError::InvalidInput(
8267 MCP_ERROR_ROOT_CONTROL_CONFLICT.to_owned(),
8268 ));
8269 }
8270 let report = build_repository_control_report(Path::new(control_root))?;
8271 return Ok(render_repository_control_report(&report));
8272 }
8273 let state = self.admin_project_root(params.project_path, params.worktree)?;
8274 let report = build_root_report(&state.db_path, state.config_path.as_deref())?;
8275 if params.verify.unwrap_or(false) && report.verified {
8276 verify_project_database(&state.db_path, &state.root)?;
8277 }
8278 Self::with_selected_project_audit(
8279 &state,
8280 state.worktree.is_some(),
8281 render_root_report(&report),
8282 )
8283 })())
8284 }
8285
8286 #[tool(
8288 name = "atlas_root_set",
8289 description = "Bind a repository root, generate project-local MCP configs, and make it active for later MCP calls."
8290 )]
8291 fn atlas_root_set(
8292 &self,
8293 Parameters(params): Parameters<AtlasRootSetParams>,
8294 ) -> McpToolTextResult {
8295 Self::as_mcp_text((|| {
8296 let root = canonical_project_root(Path::new(¶ms.root))?;
8297 let report = crate::bind_project_root(
8298 &root,
8299 params.transition.unwrap_or(RootTransition::Bind),
8300 params.nearest_project.unwrap_or(false),
8301 )?;
8302 let state = Self::project_state_from_root(&root)?;
8303 self.set_active_project_state(state)?;
8304 Ok(render_root_report(&report))
8305 })())
8306 }
8307
8308 #[tool(
8310 name = "atlas_config",
8311 description = "Return the effective ProjectAtlas scan, purpose, and output configuration."
8312 )]
8313 fn atlas_config(
8314 &self,
8315 Parameters(params): Parameters<AtlasProjectParams>,
8316 ) -> McpToolTextResult {
8317 Self::as_mcp_text((|| {
8318 let state = self.admin_project_root(params.project_path, params.worktree)?;
8319 let report = effective_config_report(&Self::load_config_for_state(&state)?);
8320 Self::encode_named_payload(MCP_PAYLOAD_CONFIG, &report)
8321 })())
8322 }
8323
8324 #[tool(
8326 name = "atlas_ignore_list",
8327 description = "List effective ProjectAtlas manual ignore policy and inherited .gitignore status."
8328 )]
8329 fn atlas_ignore_list(
8330 &self,
8331 Parameters(params): Parameters<AtlasProjectParams>,
8332 ) -> McpToolTextResult {
8333 Self::as_mcp_text((|| {
8334 let state = self.admin_project_root(params.project_path, params.worktree)?;
8335 let report = list_ignore_entries(state.config_path.as_deref(), &state.root)?;
8336 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8337 })())
8338 }
8339
8340 #[tool(
8342 name = "atlas_ignore_init_gitignore",
8343 description = "Create a project-root .gitignore when it is missing."
8344 )]
8345 fn atlas_ignore_init_gitignore(
8346 &self,
8347 Parameters(params): Parameters<AtlasProjectParams>,
8348 ) -> McpToolTextResult {
8349 Self::as_mcp_text((|| {
8350 let state = self.admin_project_root(params.project_path, params.worktree)?;
8351 let report = init_gitignore(state.config_path.as_deref(), &state.root)?;
8352 Self::encode_named_payload(MCP_PAYLOAD_GITIGNORE, &report)
8353 })())
8354 }
8355
8356 #[tool(
8358 name = "atlas_ignore_add",
8359 description = "Add one manual ProjectAtlas ignore entry to the selected project's config."
8360 )]
8361 fn atlas_ignore_add(
8362 &self,
8363 Parameters(params): Parameters<AtlasIgnoreMutationParams>,
8364 ) -> McpToolTextResult {
8365 Self::as_mcp_text((|| {
8366 let state = self.admin_project_root(params.project_path, params.worktree)?;
8367 let kind = Self::parse_ignore_kind(params.kind.as_deref(), true)?.ok_or_else(|| {
8368 CliError::InvalidInput(MCP_ERROR_IGNORE_KIND_REQUIRED_FOR_ADD.to_owned())
8369 })?;
8370 let report = add_ignore_entry(
8371 state.config_path.as_deref(),
8372 &state.root,
8373 kind,
8374 ¶ms.value,
8375 )?;
8376 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8377 })())
8378 }
8379
8380 #[tool(
8382 name = "atlas_ignore_remove",
8383 description = "Remove one manual ProjectAtlas ignore entry from the selected project's config."
8384 )]
8385 fn atlas_ignore_remove(
8386 &self,
8387 Parameters(params): Parameters<AtlasIgnoreMutationParams>,
8388 ) -> McpToolTextResult {
8389 Self::as_mcp_text((|| {
8390 let state = self.admin_project_root(params.project_path, params.worktree)?;
8391 let kind = Self::parse_ignore_kind(params.kind.as_deref(), false)?;
8392 let report = remove_ignore_entry(
8393 state.config_path.as_deref(),
8394 &state.root,
8395 kind,
8396 ¶ms.value,
8397 )?;
8398 Self::encode_named_payload(MCP_PAYLOAD_IGNORE, &report)
8399 })())
8400 }
8401
8402 #[tool(
8404 name = "atlas_scan",
8405 description = "Scan repository structure, import ProjectAtlas purpose metadata, rebuild symbols, and return a TOON overview."
8406 )]
8407 fn atlas_scan(&self, Parameters(params): Parameters<AtlasScanParams>) -> McpToolTextResult {
8408 Self::as_mcp_text((|| {
8409 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8410 let background = params.background.unwrap_or(false);
8411 let (state, path) = if background {
8412 self.background_state_and_root_path(
8413 params.project_path,
8414 params.worktree,
8415 params.path,
8416 nearest_project,
8417 )?
8418 } else {
8419 self.state_and_root_path(
8420 params.project_path,
8421 params.worktree,
8422 params.path,
8423 nearest_project,
8424 )?
8425 };
8426 let symbol_options = SymbolBuildOptions::new(
8427 params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
8428 params.max_workers,
8429 params.timeout_seconds,
8430 );
8431 let text_index_max_bytes = params.text_index_max_bytes;
8432 if background {
8433 let control_state = self.control_state.clone();
8434 let task = self.start_index_task(
8435 McpTaskOperation::Scan,
8436 symbol_options,
8437 MCP_TOOL_ATLAS_OVERVIEW,
8438 move |control, symbol_options| {
8439 let plan = ScanRuntimePlan::for_path_controlled(
8440 state.config_path.as_deref(),
8441 &path,
8442 text_index_max_bytes,
8443 control,
8444 )?;
8445 let mut store = Self::open_mut_store(&state, &control_state)?;
8446 run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, control)?;
8447 Ok(())
8448 },
8449 )?;
8450 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
8451 }
8452 let control = index_work_control(&symbol_options);
8453 let plan = ScanRuntimePlan::for_path_controlled(
8454 state.config_path.as_deref(),
8455 &path,
8456 text_index_max_bytes,
8457 &control,
8458 )?;
8459 let mut store = Self::open_mut_store(&state, &self.control_state)?;
8460 let report =
8461 run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control)?;
8462 Self::encode_named_payload(MCP_PAYLOAD_SCAN, &report)
8463 })())
8464 }
8465
8466 fn atlas_overview_response(
8468 &self,
8469 params: AtlasProjectParams,
8470 context: Option<RequestContext<RoleServer>>,
8471 ) -> McpToolTextResult {
8472 Self::as_mcp_text((|| {
8473 let state = self.state_for_target(params.project_path, params.worktree)?;
8474 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8475 let overview = store.overview()?;
8476 let toon = render_overview(&overview);
8477 let usage = Self::telemetry_enabled()
8478 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8479 .and_then(Result::ok)
8480 .map(|baseline_tokens| {
8481 McpUsageIntent::directory_walk(
8482 MCP_EVENT_ATLAS_OVERVIEW,
8483 None,
8484 None,
8485 baseline_tokens,
8486 )
8487 });
8488 Ok((toon, usage))
8489 })
8490 })())
8491 }
8492
8493 #[tool(
8495 name = "atlas_overview",
8496 description = "Return a compact TOON overview of indexed files, folders, and purpose coverage."
8497 )]
8498 fn atlas_overview(
8499 &self,
8500 Parameters(params): Parameters<AtlasProjectParams>,
8501 context: RequestContext<RoleServer>,
8502 ) -> McpToolTextResult {
8503 self.atlas_overview_response(params, Some(context))
8504 }
8505
8506 #[tool(
8508 name = "atlas_folders",
8509 description = "Rank repository folders by query and purpose so agents choose a work area before opening files."
8510 )]
8511 fn atlas_folders(
8512 &self,
8513 Parameters(params): Parameters<AtlasQueryParams>,
8514 context: RequestContext<RoleServer>,
8515 ) -> McpToolTextResult {
8516 self.atlas_folders_response(params, Some(context))
8517 }
8518
8519 fn atlas_folders_response(
8521 &self,
8522 params: AtlasQueryParams,
8523 context: Option<RequestContext<RoleServer>>,
8524 ) -> McpToolTextResult {
8525 Self::as_mcp_text((|| {
8526 let state = self.state_for_target(params.project_path, params.worktree)?;
8527 let query = Self::query_or_empty(params.query);
8528 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8529 let selected =
8530 ranked_folder_nodes_with_reasons(store, &query, params.limit.unwrap_or(10))?;
8531 let toon = render_ranked_nodes(NODE_LABEL_FOLDERS, &selected);
8532 let usage = Self::telemetry_enabled()
8533 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8534 .and_then(Result::ok)
8535 .map(|baseline_tokens| {
8536 McpUsageIntent::directory_walk(
8537 MCP_EVENT_ATLAS_FOLDERS,
8538 None,
8539 Some(query.clone()),
8540 baseline_tokens,
8541 )
8542 });
8543 Ok((toon, usage))
8544 })
8545 })())
8546 }
8547
8548 #[tool(
8550 name = "atlas_files",
8551 description = "Rank repository files by query, purpose, optional folder, and optional indexed text fallback before an agent opens source."
8552 )]
8553 fn atlas_files(
8554 &self,
8555 Parameters(params): Parameters<AtlasFilesParams>,
8556 context: RequestContext<RoleServer>,
8557 ) -> McpToolTextResult {
8558 self.atlas_files_response(params, Some(context))
8559 }
8560
8561 fn atlas_files_response(
8563 &self,
8564 params: AtlasFilesParams,
8565 context: Option<RequestContext<RoleServer>>,
8566 ) -> McpToolTextResult {
8567 Self::as_mcp_text((|| {
8568 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8569 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8570 let (state, folder_filter, routed_project) = self.state_and_optional_folder_filter(
8571 params.project_path.as_deref(),
8572 params.worktree.as_deref(),
8573 params.folder.as_deref(),
8574 nearest_project,
8575 )?;
8576 let query = Self::query_or_empty(params.query);
8577 self.with_fresh_string_and_usage_for_request(&state, context, |store, stamp| {
8578 let selected = classified_ranked_file_nodes_with_reasons(
8579 store,
8580 &query,
8581 folder_filter.as_deref(),
8582 params.file_pattern.as_deref(),
8583 params.limit.unwrap_or(10),
8584 params.include_content.unwrap_or(false),
8585 content_selection,
8586 )?;
8587 let toon = Self::with_selected_project_audit(
8588 &state,
8589 routed_project,
8590 encode_agent_payload(&serde_json::json!({
8591 NODE_LABEL_FILES: render_classified_ranked_file_rows(&selected),
8592 })),
8593 )?;
8594 let usage = Self::telemetry_enabled()
8595 .then(|| {
8596 self.estimated_source_tokens_cached(
8597 &state,
8598 store,
8599 &stamp,
8600 folder_filter.as_deref(),
8601 params.file_pattern.as_deref(),
8602 )
8603 })
8604 .and_then(Result::ok)
8605 .map(|baseline_tokens| {
8606 McpUsageIntent::estimate(
8607 MCP_EVENT_ATLAS_FILES,
8608 params
8609 .file_pattern
8610 .clone()
8611 .or_else(|| folder_filter.clone()),
8612 Some(query.clone()),
8613 baseline_tokens,
8614 )
8615 });
8616 Ok((toon, usage))
8617 })
8618 })())
8619 }
8620
8621 #[tool(
8623 name = "atlas_next",
8624 description = "Recommend top indexed folders/files with reasons and deterministic follow-up commands for a task query."
8625 )]
8626 fn atlas_next(
8627 &self,
8628 Parameters(params): Parameters<AtlasNextParams>,
8629 context: RequestContext<RoleServer>,
8630 ) -> McpToolTextResult {
8631 Self::as_mcp_text((|| {
8632 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8633 let state = self.state_for_target(params.project_path, params.worktree)?;
8634 let query = Self::query_or_empty(params.query);
8635 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
8636 let report = next_step_report_with_selection(
8637 store,
8638 &query,
8639 params.limit,
8640 content_selection,
8641 )?;
8642 let payload = next_step_report_payload(&report);
8643 let toon = Self::encode_named_payload(MCP_PAYLOAD_NEXT, &payload)?;
8644 let usage = Self::telemetry_enabled()
8645 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
8646 .and_then(Result::ok)
8647 .map(|baseline_tokens| {
8648 McpUsageIntent::directory_walk(
8649 MCP_EVENT_ATLAS_NEXT,
8650 None,
8651 Some(query.clone()),
8652 baseline_tokens,
8653 )
8654 });
8655 Ok((toon, usage))
8656 })
8657 })())
8658 }
8659
8660 #[tool(
8662 name = "atlas_outline",
8663 description = "Return compact TOON outline and preview context for a selected file."
8664 )]
8665 fn atlas_outline(
8666 &self,
8667 Parameters(params): Parameters<AtlasOutlineParams>,
8668 context: RequestContext<RoleServer>,
8669 ) -> McpToolTextResult {
8670 Self::as_mcp_text((|| {
8671 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8672 let resolved = self.state_and_file_key(
8673 params.project_path.as_deref(),
8674 params.worktree.as_deref(),
8675 ¶ms.file,
8676 nearest_project,
8677 )?;
8678 let state = resolved.state;
8679 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
8680 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8681 let content = read_indexed_file_content(store, &file_key)?;
8682 let language = store
8683 .load_node_by_path(&file_key)?
8684 .and_then(|node| node.node.language);
8685 let outline =
8686 build_outline(&file_key, language, &content, params.lines.unwrap_or(12));
8687 let toon = Self::with_selected_project_audit(
8688 &state,
8689 resolved.routed_project,
8690 render_outline(&outline),
8691 )?;
8692 let usage = Some(McpUsageIntent::text(
8693 MCP_EVENT_ATLAS_OUTLINE,
8694 Some(file_key),
8695 content,
8696 ));
8697 Ok((toon, usage))
8698 })
8699 })())
8700 }
8701
8702 fn atlas_file_summary_response(
8704 &self,
8705 params: &AtlasFileSummaryParams,
8706 context: Option<RequestContext<RoleServer>>,
8707 ) -> McpToolTextResult {
8708 Self::as_mcp_text((|| {
8709 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8710 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8711 let resolved = self.state_and_file_key(
8712 params.project_path.as_deref(),
8713 params.worktree.as_deref(),
8714 ¶ms.file,
8715 nearest_project,
8716 )?;
8717 let state = resolved.state;
8718 self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
8719 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8720 let content = read_indexed_file_content(store, &file_key)?;
8721 let report = build_file_summary_from_source_with_selection(
8722 store,
8723 Path::new(&file_key),
8724 params.limit.unwrap_or(DEFAULT_FILE_SUMMARY_LIMIT),
8725 &content,
8726 content_selection,
8727 )?;
8728 let rendered = if params.compact.unwrap_or(false) {
8729 encode_agent_payload(&McpFileSummaryPayload {
8730 file_summary: McpFileSummary::from(&report),
8731 })
8732 } else {
8733 render_file_summary(&report)
8734 };
8735 let toon =
8736 Self::with_selected_project_audit(&state, resolved.routed_project, rendered)?;
8737 let usage = Some(McpUsageIntent::text(
8738 MCP_EVENT_ATLAS_FILE_SUMMARY,
8739 Some(report.file_path),
8740 content,
8741 ));
8742 Ok((toon, usage))
8743 })
8744 })())
8745 }
8746
8747 #[tool(
8749 name = "atlas_file_summary",
8750 description = "Return structured TOON file intelligence: file purpose, content summary, imports, symbols, line ranges, and calls."
8751 )]
8752 fn atlas_file_summary(
8753 &self,
8754 Parameters(params): Parameters<AtlasFileSummaryParams>,
8755 context: RequestContext<RoleServer>,
8756 ) -> McpToolTextResult {
8757 self.atlas_file_summary_response(¶ms, Some(context))
8758 }
8759
8760 #[tool(
8762 name = "atlas_search",
8763 description = "Search indexed files with literal, regex, or fuzzy matching, file filters, pagination, and TOON results."
8764 )]
8765 fn atlas_search(
8766 &self,
8767 Parameters(params): Parameters<AtlasSearchParams>,
8768 context: RequestContext<RoleServer>,
8769 ) -> McpToolTextResult {
8770 Self::as_mcp_text((|| {
8771 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8772 let state =
8773 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
8774 self.with_fresh_string_and_usage_controlled_for_request(
8775 &state,
8776 Some(context),
8777 |store, _stamp, control| {
8778 let report = search_indexed_files_with_control(
8779 store,
8780 &SearchQuery {
8781 pattern: ¶ms.pattern,
8782 regex: params.regex.unwrap_or(false),
8783 fuzzy: params.fuzzy.unwrap_or(false),
8784 case_sensitive: params.case_sensitive.unwrap_or(false),
8785 file_pattern: params.file_pattern.as_deref(),
8786 context_lines: params.context_lines.unwrap_or(0),
8787 start_index: params.start_index.unwrap_or(0),
8788 limit: params.limit.unwrap_or(20),
8789 content_selection,
8790 retrieval_mode: params.retrieval_mode.unwrap_or_default().into(),
8791 },
8792 Some(control),
8793 )?;
8794 let toon = render_search_report(&report);
8795 let usage = Some(McpUsageIntent::estimate(
8796 MCP_EVENT_ATLAS_SEARCH,
8797 params.file_pattern.clone(),
8798 Some(params.pattern.clone()),
8799 byte_count_to_tokens(report.searched_bytes),
8800 ));
8801 Ok((toon, usage))
8802 },
8803 )
8804 })())
8805 }
8806
8807 #[tool(
8809 name = "atlas_slice",
8810 description = "Return exact source for a selected line range or indexed symbol, after folder/file orientation."
8811 )]
8812 fn atlas_slice(
8813 &self,
8814 Parameters(params): Parameters<AtlasSliceParams>,
8815 context: RequestContext<RoleServer>,
8816 ) -> McpToolTextResult {
8817 Self::as_mcp_text((|| {
8818 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8819 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8820 let resolved = self.state_and_file_key(
8821 params.project_path.as_deref(),
8822 params.worktree.as_deref(),
8823 ¶ms.file,
8824 nearest_project,
8825 )?;
8826 let state = resolved.state;
8827 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, _stamp| {
8828 let file_key = validated_indexed_file_key(store, Path::new(&resolved.key))?;
8829 let file = PathBuf::from(&file_key);
8830 let content = read_indexed_file_content(store, &file_key)?;
8831 let output_budget = CodeSliceBudget::new(
8832 params
8833 .output_bytes
8834 .unwrap_or(CodeSliceBudget::DEFAULT_OUTPUT_BYTES),
8835 )?;
8836 let report = if let Some(symbol) = params.symbol.as_ref() {
8837 read_symbol_slice_from_source_bounded_with_selection(
8838 store,
8839 &file,
8840 &SymbolSliceSelector {
8841 name: symbol,
8842 parent: params.symbol_parent.as_deref().and_then(nonempty_str),
8843 kind: params.symbol_kind.as_deref().and_then(nonempty_str),
8844 signature: params.symbol_signature.as_deref().and_then(nonempty_str),
8845 line: params.symbol_line,
8846 },
8847 &content,
8848 output_budget,
8849 content_selection,
8850 )?
8851 } else {
8852 if params
8853 .symbol_parent
8854 .as_deref()
8855 .and_then(nonempty_str)
8856 .is_some()
8857 || params
8858 .symbol_kind
8859 .as_deref()
8860 .and_then(nonempty_str)
8861 .is_some()
8862 || params
8863 .symbol_signature
8864 .as_deref()
8865 .and_then(nonempty_str)
8866 .is_some()
8867 || params.symbol_line.is_some()
8868 {
8869 return Err(CliError::InvalidInput(
8870 SYMBOL_DISAMBIGUATOR_WITHOUT_SYMBOL_ERROR.to_string(),
8871 ));
8872 }
8873 let start_line = params.start_line.ok_or_else(|| {
8874 CliError::InvalidInput(START_LINE_REQUIRED_ERROR.to_string())
8875 })?;
8876 read_indexed_code_slice_from_source_bounded_with_selection(
8877 store,
8878 &file,
8879 start_line,
8880 params.end_line,
8881 &content,
8882 output_budget,
8883 content_selection,
8884 )?
8885 };
8886 let toon = report.fit_output(|report| {
8887 Self::with_selected_project_audit(
8888 &state,
8889 resolved.routed_project,
8890 render_code_slice(report),
8891 )
8892 })?;
8893 let usage = Some(McpUsageIntent::text(
8894 MCP_EVENT_ATLAS_SLICE,
8895 Some(report.slice().path.clone()),
8896 content,
8897 ));
8898 Ok((toon, usage))
8899 })
8900 })())
8901 }
8902
8903 #[tool(
8905 name = "atlas_symbols_build",
8906 description = "Rebuild ProjectAtlas symbol graphs for indexed files and return a TOON build report."
8907 )]
8908 fn atlas_symbols_build(
8909 &self,
8910 Parameters(params): Parameters<AtlasScanParams>,
8911 ) -> McpToolTextResult {
8912 Self::as_mcp_text((|| {
8913 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8914 let background = params.background.unwrap_or(false);
8915 let (state, path) = if background {
8916 self.background_state_and_root_path(
8917 params.project_path,
8918 params.worktree,
8919 params.path,
8920 nearest_project,
8921 )?
8922 } else {
8923 self.state_and_root_path(
8924 params.project_path,
8925 params.worktree,
8926 params.path,
8927 nearest_project,
8928 )?
8929 };
8930 let options = SymbolBuildOptions::new(
8931 params.max_bytes.unwrap_or(MAX_SYMBOL_FILE_BYTES),
8932 params.max_workers,
8933 params.timeout_seconds,
8934 );
8935 let text_index_max_bytes = params.text_index_max_bytes;
8936 if background {
8937 let control_state = self.control_state.clone();
8938 let task = self.start_index_task(
8939 McpTaskOperation::SymbolsBuild,
8940 options,
8941 MCP_TOOL_ATLAS_SYMBOLS,
8942 move |control, options| {
8943 let plan = ScanRuntimePlan::for_path_controlled(
8944 state.config_path.as_deref(),
8945 &path,
8946 text_index_max_bytes,
8947 control,
8948 )?;
8949 let mut store = Self::open_mut_store(&state, &control_state)?;
8950 run_symbol_build_pipeline_controlled(
8951 &mut store, &plan, &options, None, control,
8952 )?;
8953 Ok(())
8954 },
8955 )?;
8956 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
8957 }
8958 let control = index_work_control(&options);
8959 let plan = ScanRuntimePlan::for_path_controlled(
8960 state.config_path.as_deref(),
8961 &path,
8962 text_index_max_bytes,
8963 &control,
8964 )?;
8965 let mut store = Self::open_mut_store(&state, &self.control_state)?;
8966 let report =
8967 run_symbol_build_pipeline_controlled(&mut store, &plan, &options, None, &control)?;
8968 Self::encode_named_payload(MCP_PAYLOAD_SYMBOLS_BUILD, &report)
8969 })())
8970 }
8971
8972 fn atlas_symbols_response(
8974 &self,
8975 params: &AtlasSymbolsParams,
8976 context: Option<RequestContext<RoleServer>>,
8977 ) -> McpToolTextResult {
8978 Self::as_mcp_text((|| {
8979 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
8980 let nearest_project = self.nearest_project_enabled(params.nearest_project);
8981 let (state, file, routed_project) = self.state_and_optional_file_key(
8982 params.project_path.as_deref(),
8983 params.worktree.as_deref(),
8984 params.file.as_deref(),
8985 nearest_project,
8986 )?;
8987 self.with_fresh_string_and_usage_for_request(&state, context, |store, _stamp| {
8988 let file = file
8989 .as_deref()
8990 .map(|path| validated_indexed_file_key(store, Path::new(path)))
8991 .transpose()?;
8992 let symbols = store.load_classified_symbols(
8993 file.as_deref(),
8994 params.query.as_deref(),
8995 content_selection,
8996 params.limit.unwrap_or(50),
8997 )?;
8998 let toon = Self::with_selected_project_audit(
8999 &state,
9000 routed_project,
9001 encode_agent_payload(&serde_json::json!({
9002 NODE_LABEL_SYMBOLS: render_classified_symbol_rows(&symbols),
9003 })),
9004 )?;
9005 let usage = Self::telemetry_enabled()
9006 .then(|| {
9007 estimated_source_tokens_for_paths(
9008 store,
9009 symbols
9010 .iter()
9011 .map(|classified| classified.symbol.path.as_str()),
9012 )
9013 })
9014 .and_then(Result::ok)
9015 .map(|baseline_tokens| {
9016 McpUsageIntent::estimate(
9017 MCP_EVENT_ATLAS_SYMBOLS,
9018 file.clone(),
9019 params.query.clone(),
9020 baseline_tokens,
9021 )
9022 });
9023 Ok((toon, usage))
9024 })
9025 })())
9026 }
9027
9028 #[tool(
9030 name = "atlas_symbols",
9031 description = "List indexed symbols by optional file and query as compact TOON."
9032 )]
9033 fn atlas_symbols(
9034 &self,
9035 Parameters(params): Parameters<AtlasSymbolsParams>,
9036 context: RequestContext<RoleServer>,
9037 ) -> McpToolTextResult {
9038 self.atlas_symbols_response(¶ms, Some(context))
9039 }
9040
9041 fn detailed_symbol_relations_response(
9043 state: &McpProjectState,
9044 routed_project: bool,
9045 file: &str,
9046 params: &AtlasSymbolRelationsParams,
9047 content_selection: ContentSelection,
9048 analysis: bool,
9049 stores: SymbolRelationStores<'_>,
9050 control: &IndexWorkControl,
9051 ) -> Result<(String, Option<McpUsageIntent>), CliError> {
9052 if params.query.is_some() {
9053 return Err(CliError::Service(ServiceError::InvalidInput(
9054 MCP_ERROR_DETAILED_RELATION_QUERY.to_string(),
9055 )));
9056 }
9057 let primary = stores.primary();
9058 let file = validated_indexed_file_key(primary, Path::new(file))?;
9059 let graph_file = RepositoryFilePath::new(Path::new(&file))
9060 .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
9061 let anchor = if let Some(symbol) = params.symbol.as_ref() {
9062 if symbol.is_empty() {
9063 return Err(CliError::Service(ServiceError::InvalidInput(
9064 MCP_ERROR_DETAILED_RELATION_SYMBOL.to_string(),
9065 )));
9066 }
9067 RelationAnchor::Symbol {
9068 file: graph_file,
9069 name: symbol.clone(),
9070 symbol_kind: params
9071 .symbol_kind
9072 .as_deref()
9073 .and_then(nonempty_str)
9074 .map(parse_symbol_kind)
9075 .transpose()?,
9076 parent: params
9077 .symbol_parent
9078 .as_deref()
9079 .and_then(nonempty_str)
9080 .map(ToString::to_string),
9081 signature: params
9082 .symbol_signature
9083 .as_deref()
9084 .and_then(nonempty_str)
9085 .map(ToString::to_string),
9086 }
9087 } else {
9088 if params
9089 .symbol_parent
9090 .as_deref()
9091 .and_then(nonempty_str)
9092 .is_some()
9093 || params
9094 .symbol_kind
9095 .as_deref()
9096 .and_then(nonempty_str)
9097 .is_some()
9098 || params
9099 .symbol_signature
9100 .as_deref()
9101 .and_then(nonempty_str)
9102 .is_some()
9103 {
9104 return Err(CliError::Service(ServiceError::InvalidInput(
9105 MCP_ERROR_DETAILED_RELATION_DISAMBIGUATOR.to_string(),
9106 )));
9107 }
9108 RelationAnchor::File { file: graph_file }
9109 };
9110 let rows = u32::try_from(params.limit.unwrap_or(50)).map_err(|_overflow| {
9111 CliError::Service(ServiceError::InvalidInput(
9112 MCP_ERROR_DETAILED_RELATION_LIMIT.to_string(),
9113 ))
9114 })?;
9115 let limits = GraphLimits::new(
9116 rows,
9117 params.occurrence_limit.unwrap_or(25),
9118 params.depth.unwrap_or(1),
9119 params.output_bytes.unwrap_or(256 * 1024),
9120 )
9121 .map_err(|error| CliError::Service(ServiceError::InvalidInput(error.to_string())))?;
9122 let relations = DetailedRelationQuery {
9123 anchor,
9124 direction: parse_relation_direction(
9125 params
9126 .direction
9127 .as_deref()
9128 .unwrap_or(MCP_SYMBOL_RELATION_DIRECTION_DEFAULT),
9129 )?,
9130 relation: params
9131 .relation
9132 .as_deref()
9133 .map(parse_coverage_relation)
9134 .transpose()?,
9135 minimum_confidence: parse_relation_confidence(
9136 params
9137 .minimum_confidence
9138 .as_deref()
9139 .unwrap_or(MCP_SYMBOL_RELATION_CONFIDENCE_DEFAULT),
9140 )?,
9141 resolution: parse_relation_resolution(
9142 params
9143 .resolution
9144 .as_deref()
9145 .unwrap_or(MCP_SYMBOL_RELATION_RESOLUTION_DEFAULT),
9146 )?,
9147 content_selection,
9148 include_occurrences: params.include_occurrences.unwrap_or(false),
9149 budget: DetailedRelationBudget::from_graph_limits(limits).with_aggregate_limits(
9150 params.edge_limit,
9151 params.node_limit,
9152 params.visited_limit,
9153 params.occurrence_total_limit,
9154 params.intermediate_bytes,
9155 params.deadline_ms,
9156 )?,
9157 cursor: params.cursor.clone(),
9158 };
9159 let usage = matches!(&stores, SymbolRelationStores::Single(_))
9160 .then(|| {
9161 Self::telemetry_enabled()
9162 .then(|| {
9163 estimated_source_tokens_for_paths(primary, std::iter::once(file.as_str()))
9164 })
9165 .and_then(Result::ok)
9166 .map(|baseline_tokens| {
9167 McpUsageIntent::estimate(
9168 MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
9169 Some(file.clone()),
9170 params.symbol.clone(),
9171 baseline_tokens,
9172 )
9173 })
9174 })
9175 .flatten();
9176
9177 if analysis {
9178 let mode = match params
9179 .analysis_mode
9180 .as_deref()
9181 .unwrap_or(MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE)
9182 {
9183 MCP_RELATION_ANALYSIS_MODE_ARCHITECTURE => RelationAnalysisMode::Architecture,
9184 MCP_RELATION_ANALYSIS_MODE_IMPACT => RelationAnalysisMode::Impact,
9185 MCP_RELATION_ANALYSIS_MODE_TRACE => RelationAnalysisMode::Trace,
9186 MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT => RelationAnalysisMode::Entrypoint,
9187 _unsupported => {
9188 return Err(CliError::Service(ServiceError::InvalidInput(
9189 MCP_ERROR_UNSUPPORTED_ANALYSIS_MODE.to_string(),
9190 )));
9191 }
9192 };
9193 let trace_target = relation_analysis_trace_target(primary, params)?;
9194 let vcs_explicit =
9195 params.vcs.is_some() || params.vcs_base.is_some() || params.vcs_head.is_some();
9196 let vcs = relation_analysis_vcs(params)?;
9197 if mode == RelationAnalysisMode::Entrypoint
9198 && matches!(&stores, SymbolRelationStores::Federated(_))
9199 {
9200 return Err(CliError::Service(ServiceError::InvalidInput(
9201 MCP_ERROR_ENTRYPOINT_FEDERATED.to_string(),
9202 )));
9203 }
9204 if mode == RelationAnalysisMode::Entrypoint
9205 && params
9206 .entrypoints
9207 .as_ref()
9208 .is_some_and(|items| !items.is_empty())
9209 && (params.symbol.is_some()
9210 || params.symbol_parent.is_some()
9211 || params.symbol_kind.is_some()
9212 || params.symbol_signature.is_some())
9213 {
9214 return Err(CliError::Service(ServiceError::InvalidInput(
9215 MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR.to_string(),
9216 )));
9217 }
9218 let entrypoint_profile = if mode == RelationAnalysisMode::Entrypoint {
9219 let anchors = match params.entrypoints.as_ref() {
9220 Some(values) if !values.is_empty() => values
9221 .iter()
9222 .map(|value| serde_json::from_str::<RelationAnchor>(value))
9223 .collect::<Result<Vec<_>, _>>()
9224 .map_err(|error| {
9225 let mut message = MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX.to_string();
9226 message.push_str(&error.to_string());
9227 CliError::Service(ServiceError::InvalidInput(message))
9228 })?,
9229 _ => vec![relations.anchor.clone()],
9230 };
9231 let relation_families = match params.profile_relations.as_ref() {
9232 Some(values) if !values.is_empty() => values
9233 .iter()
9234 .map(|value| parse_coverage_relation(value))
9235 .collect::<Result<Vec<_>, _>>()?,
9236 _ => GraphRelationKind::ALL.to_vec(),
9237 };
9238 Some(EntrypointProfile {
9239 name: params
9240 .profile_name
9241 .clone()
9242 .unwrap_or_else(|| MCP_ENTRYPOINT_PROFILE_DEFAULT_NAME.to_string()),
9243 anchors,
9244 relations: relation_families,
9245 })
9246 } else {
9247 if params.profile_name.is_some()
9248 || params
9249 .entrypoints
9250 .as_ref()
9251 .is_some_and(|items| !items.is_empty())
9252 || params
9253 .profile_relations
9254 .as_ref()
9255 .is_some_and(|items| !items.is_empty())
9256 {
9257 return Err(CliError::Service(ServiceError::InvalidInput(
9258 MCP_ERROR_ENTRYPOINT_CONTROLS_MODE.to_string(),
9259 )));
9260 }
9261 None
9262 };
9263 let query = RelationAnalysisQuery {
9264 relations,
9265 mode,
9266 trace_target,
9267 vcs: (mode == RelationAnalysisMode::Impact || vcs_explicit).then_some(vcs),
9268 include_communities: params.include_communities.unwrap_or(false),
9269 include_cycles: params.include_cycles.unwrap_or(false),
9270 include_dead_code: params.include_dead_code.unwrap_or(false),
9271 entrypoint_profile,
9272 };
9273 let toon = match stores {
9274 SymbolRelationStores::Single(store) => {
9275 let draft = load_relation_analysis(store, &query, Some(control))?;
9276 draft
9277 .fit_output(|report, control| {
9278 Self::with_selected_project_audit_controlled(
9279 state,
9280 routed_project,
9281 controlled_named_output(
9282 OutputFormat::Toon,
9283 MCP_PAYLOAD_SYMBOL_RELATIONS,
9284 report,
9285 control,
9286 )?,
9287 control,
9288 )
9289 })?
9290 .1
9291 }
9292 SymbolRelationStores::Federated(stores) => {
9293 let draft = load_federated_relation_analysis(stores, &query, Some(control))?;
9294 draft
9295 .fit_output(|report, control| {
9296 Self::with_selected_project_audit_controlled(
9297 state,
9298 routed_project,
9299 controlled_named_output(
9300 OutputFormat::Toon,
9301 MCP_PAYLOAD_SYMBOL_RELATIONS,
9302 report,
9303 control,
9304 )?,
9305 control,
9306 )
9307 })?
9308 .1
9309 }
9310 };
9311 return Ok((toon, usage));
9312 }
9313
9314 let toon = match stores {
9315 SymbolRelationStores::Single(store) => {
9316 let draft = load_detailed_relation_page(store, &relations, Some(control))?;
9317 draft
9318 .fit_output(Some(control), |report| {
9319 let payload = if params.compact.unwrap_or(false) {
9320 Self::encode_named_payload(
9321 MCP_PAYLOAD_SYMBOL_RELATIONS,
9322 &McpCompactDetailedRelationReport::new(report, &file, params),
9323 )?
9324 } else {
9325 Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
9326 };
9327 Self::with_selected_project_audit(state, routed_project, payload)
9328 })?
9329 .1
9330 }
9331 SymbolRelationStores::Federated(stores) => {
9332 let draft = load_federated_detailed_relations(stores, &relations, Some(control))?;
9333 draft
9334 .fit_output(Some(control), |report| {
9335 let payload = if params.compact.unwrap_or(false) {
9336 Self::encode_named_payload(
9337 MCP_PAYLOAD_SYMBOL_RELATIONS,
9338 &McpCompactFederatedDetailedRelationReport::new(
9339 report, &file, params,
9340 ),
9341 )?
9342 } else {
9343 Self::encode_named_payload(MCP_PAYLOAD_SYMBOL_RELATIONS, report)?
9344 };
9345 Self::with_selected_project_audit(state, routed_project, payload)
9346 })?
9347 .1
9348 }
9349 };
9350 Ok((toon, usage))
9351 }
9352
9353 fn atlas_symbol_relations_response(
9355 &self,
9356 params: &AtlasSymbolRelationsParams,
9357 context: Option<RequestContext<RoleServer>>,
9358 ) -> McpToolTextResult {
9359 Self::as_mcp_text((|| {
9360 let (detailed, analysis) = match params
9361 .view
9362 .as_deref()
9363 .unwrap_or(MCP_SYMBOL_RELATION_VIEW_LEGACY)
9364 {
9365 MCP_SYMBOL_RELATION_VIEW_LEGACY => (false, false),
9366 MCP_SYMBOL_RELATION_VIEW_DETAILED => (true, false),
9367 MCP_SYMBOL_RELATION_VIEW_ANALYSIS => (true, true),
9368 _unsupported => {
9369 return Err(CliError::Service(ServiceError::InvalidInput(
9370 MCP_ERROR_SYMBOL_RELATION_VIEW.to_string(),
9371 )));
9372 }
9373 };
9374 if params.compact.unwrap_or(false) && (!detailed || analysis) {
9375 return Err(CliError::Service(ServiceError::InvalidInput(
9376 MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW.to_string(),
9377 )));
9378 }
9379 if !analysis && relation_analysis_controls_present(params) {
9380 return Err(CliError::Service(ServiceError::InvalidInput(
9381 MCP_ERROR_ANALYSIS_VIEW_REQUIRED.to_string(),
9382 )));
9383 }
9384 let content_selection = parse_content_selection(params.content_selection.as_deref())?;
9385 if !detailed && params.content_selection.is_some() {
9386 return Err(CliError::Service(ServiceError::InvalidInput(
9387 MCP_ERROR_CONTENT_SELECTION_RELATION_VIEW.to_string(),
9388 )));
9389 }
9390 if params.roots.is_some() && params.worktrees.is_some() {
9391 return Err(CliError::Service(ServiceError::InvalidInput(
9392 MCP_ERROR_FEDERATED_SELECTOR_CONFLICT.to_string(),
9393 )));
9394 }
9395 let federated_worktrees = params.worktrees.as_deref();
9396 if let Some(worktrees) = federated_worktrees {
9397 validate_federated_root_count(worktrees.len()).map_err(CliError::Service)?;
9398 if params.project_path.is_some() {
9399 return Err(CliError::Service(ServiceError::InvalidInput(
9400 MCP_ERROR_FEDERATED_PROJECT_PATH_CONFLICT.to_string(),
9401 )));
9402 }
9403 if params.worktree.as_deref().is_some_and(|primary| {
9404 worktrees
9405 .first()
9406 .is_none_or(|first| primary.trim() != first.trim())
9407 }) {
9408 return Err(CliError::Service(ServiceError::InvalidInput(
9409 MCP_ERROR_FEDERATED_PRIMARY_CONFLICT.to_string(),
9410 )));
9411 }
9412 }
9413 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9414 let selected_worktree = federated_worktrees
9415 .and_then(|worktrees| worktrees.first())
9416 .map(String::as_str)
9417 .or(params.worktree.as_deref());
9418 let profile_file = if analysis
9419 && params.analysis_mode.as_deref() == Some(MCP_RELATION_ANALYSIS_MODE_ENTRYPOINT)
9420 {
9421 params
9422 .entrypoints
9423 .as_ref()
9424 .and_then(|values| values.first())
9425 .map(|value| serde_json::from_str::<RelationAnchor>(value))
9426 .transpose()
9427 .map_err(|error| {
9428 let mut message = MCP_ERROR_ENTRYPOINT_ANCHORS_PREFIX.to_string();
9429 message.push_str(&error.to_string());
9430 CliError::Service(ServiceError::InvalidInput(message))
9431 })?
9432 .map(|anchor| match anchor {
9433 RelationAnchor::File { file } | RelationAnchor::Symbol { file, .. } => {
9434 file.as_str().to_string()
9435 }
9436 })
9437 } else {
9438 None
9439 };
9440 let (state, file, routed_project) = self.state_and_optional_file_key(
9441 params.project_path.as_deref(),
9442 selected_worktree,
9443 params.file.as_deref().or(profile_file.as_deref()),
9444 nearest_project,
9445 )?;
9446 if params.roots.is_some() || federated_worktrees.is_some() {
9447 if !detailed {
9448 return Err(CliError::Service(ServiceError::InvalidInput(
9449 MCP_ERROR_FEDERATED_RELATION_VIEW.to_string(),
9450 )));
9451 }
9452 let file = file.as_deref().ok_or_else(|| {
9453 CliError::Service(ServiceError::InvalidInput(
9454 MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
9455 ))
9456 })?;
9457 let control =
9458 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None))
9459 .with_timeout_ceiling(Duration::from_millis(
9460 params.deadline_ms.unwrap_or(10_000).clamp(1, 60_000),
9461 ));
9462 let bridge = context
9463 .map(|context| McpRequestCancellationBridge::start(&context, &control))
9464 .transpose()?;
9465 let (roots, worktree_selections) = if let Some(worktrees) = federated_worktrees {
9466 let (roots, selections) = self.federated_worktree_roots(worktrees)?;
9467 (roots, Some(selections))
9468 } else {
9469 (
9470 params
9471 .roots
9472 .as_deref()
9473 .unwrap_or_default()
9474 .iter()
9475 .map(PathBuf::from)
9476 .collect::<Vec<_>>(),
9477 None,
9478 )
9479 };
9480 let worktree_labels = worktree_selections.as_ref().map(|selections| {
9481 selections
9482 .iter()
9483 .map(|selection| selection.alias.clone())
9484 .collect::<Vec<_>>()
9485 });
9486 let stores = open_federated_atlas_stores_for_project(
9487 &state.db_path,
9488 &state.root,
9489 state.config_path.as_deref(),
9490 &roots,
9491 worktree_labels.as_deref(),
9492 &control,
9493 )?;
9494 let stores = if let Some(selections) = worktree_selections.as_deref() {
9495 Self::require_federated_worktree_identities(stores, selections)?
9496 } else {
9497 stores
9498 };
9499 let result = Self::detailed_symbol_relations_response(
9500 &state,
9501 routed_project,
9502 file,
9503 params,
9504 content_selection,
9505 analysis,
9506 SymbolRelationStores::Federated(stores),
9507 &control,
9508 )
9509 .map(|(toon, _usage)| toon);
9510 if let Some(bridge) = bridge.as_ref() {
9511 bridge.synchronize(&control);
9512 }
9513 drop(bridge);
9514 return result;
9515 }
9516 self.with_fresh_string_and_usage_controlled_for_request(
9517 &state,
9518 context,
9519 |store, _stamp, control| {
9520 let file = file
9521 .as_deref()
9522 .map(|path| validated_indexed_file_key(store, Path::new(path)))
9523 .transpose()?;
9524 if detailed {
9525 let file = file.as_deref().ok_or_else(|| {
9526 CliError::Service(ServiceError::InvalidInput(
9527 MCP_ERROR_DETAILED_RELATION_FILE.to_string(),
9528 ))
9529 })?;
9530 return Self::detailed_symbol_relations_response(
9531 &state,
9532 routed_project,
9533 file,
9534 params,
9535 content_selection,
9536 analysis,
9537 SymbolRelationStores::Single(store),
9538 control,
9539 );
9540 }
9541
9542 let relations = store.load_symbol_relations(
9543 file.as_deref(),
9544 params.query.as_deref(),
9545 params.limit.unwrap_or(50),
9546 )?;
9547 let toon = Self::with_selected_project_audit(
9548 &state,
9549 routed_project,
9550 render_symbol_relations(&relations),
9551 )?;
9552 let usage = Self::telemetry_enabled()
9553 .then(|| {
9554 estimated_source_tokens_for_paths(
9555 store,
9556 relations.iter().map(|relation| relation.path.as_str()),
9557 )
9558 })
9559 .and_then(Result::ok)
9560 .map(|baseline_tokens| {
9561 McpUsageIntent::estimate(
9562 MCP_EVENT_ATLAS_SYMBOL_RELATIONS,
9563 file.clone(),
9564 params.query.clone(),
9565 baseline_tokens,
9566 )
9567 });
9568 Ok((toon, usage))
9569 },
9570 )
9571 })())
9572 }
9573
9574 #[tool(
9576 name = "atlas_symbol_relations",
9577 description = "List imports, calls, dependencies, and containment edges as compact TOON."
9578 )]
9579 fn atlas_symbol_relations(
9580 &self,
9581 Parameters(params): Parameters<AtlasSymbolRelationsParams>,
9582 context: RequestContext<RoleServer>,
9583 ) -> McpToolTextResult {
9584 self.atlas_symbol_relations_response(¶ms, Some(context))
9585 }
9586
9587 #[tool(
9589 name = "atlas_health",
9590 description = "Return a bounded ProjectAtlas structural health page with optional category, severity, and path-prefix filters."
9591 )]
9592 fn atlas_health(
9593 &self,
9594 Parameters(params): Parameters<AtlasHealthParams>,
9595 context: RequestContext<RoleServer>,
9596 ) -> McpToolTextResult {
9597 Self::as_mcp_text((|| {
9598 let state =
9599 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
9600 if params.coverage.unwrap_or(false) {
9601 let query = coverage_query_from_params(¶ms)?;
9602 return self.with_fresh_string_and_usage_controlled_for_request(
9603 &state,
9604 Some(context),
9605 |store, stamp, control| {
9606 let mut report =
9607 load_coverage_discovery_controlled(store, query.clone(), control)?;
9608 let toon = finalize_coverage_output(OutputFormat::Toon, &mut report)?;
9609 let usage = Self::telemetry_enabled()
9610 .then(|| {
9611 self.estimated_source_tokens_cached(
9612 &state, store, &stamp, None, None,
9613 )
9614 })
9615 .and_then(Result::ok)
9616 .map(|baseline_tokens| {
9617 McpUsageIntent::directory_walk(
9618 MCP_EVENT_ATLAS_HEALTH,
9619 None,
9620 None,
9621 baseline_tokens,
9622 )
9623 });
9624 Ok((toon, usage))
9625 },
9626 );
9627 }
9628 if has_coverage_filters(¶ms) {
9629 return Err(CliError::InvalidInput(
9630 MCP_ERROR_COVERAGE_FILTERS_REQUIRE_COVERAGE.to_string(),
9631 ));
9632 }
9633 let scope = if params.source_only.unwrap_or(false) {
9634 HealthScope::source_only()
9635 } else {
9636 HealthScope::all()
9637 };
9638 let query = health_query_from_params(¶ms, scope)?;
9639 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
9640 let page = store.unresolved_health_findings_page_current(&query)?;
9641 let toon = render_health_page(&page, &query);
9642 let usage = Self::telemetry_enabled()
9643 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
9644 .and_then(Result::ok)
9645 .map(|baseline_tokens| {
9646 McpUsageIntent::directory_walk(
9647 MCP_EVENT_ATLAS_HEALTH,
9648 None,
9649 None,
9650 baseline_tokens,
9651 )
9652 });
9653 Ok((toon, usage))
9654 })
9655 })())
9656 }
9657
9658 #[tool(
9660 name = "atlas_health_resolve",
9661 description = "Mark a deterministic ProjectAtlas health finding as agent-resolved with rationale."
9662 )]
9663 fn atlas_health_resolve(
9664 &self,
9665 Parameters(params): Parameters<AtlasHealthResolveParams>,
9666 ) -> McpToolTextResult {
9667 Self::as_mcp_text((|| {
9668 let state = self.state_for_target(params.project_path, params.worktree)?;
9669 let store = Self::open_existing_mut_store(&state, &self.control_state)?;
9670 let resolution = HealthResolution {
9671 finding_id: params.finding_id,
9672 category: params.category,
9673 path: params.path,
9674 related_path: params.related_path,
9675 rationale: params.rationale,
9676 };
9677 store.resolve_health_finding(&resolution)?;
9678 Self::encode_named_payload(MCP_PAYLOAD_HEALTH_RESOLUTION, &resolution)
9679 })())
9680 }
9681
9682 #[tool(
9684 name = "atlas_lint",
9685 description = "Run ProjectAtlas lint checks and return an ok flag, CLI-compatible exit code, and report text."
9686 )]
9687 fn atlas_lint(&self, Parameters(params): Parameters<AtlasLintParams>) -> McpToolTextResult {
9688 Self::as_mcp_text((|| {
9689 let state =
9690 self.admin_project_root(params.project_path.clone(), params.worktree.clone())?;
9691 let report = Self::lint_report_for_state(&state, ¶ms)?;
9692 Self::encode_named_payload(MCP_PAYLOAD_LINT, &report)
9693 })())
9694 }
9695
9696 #[tool(
9698 name = "atlas_token_report",
9699 description = "Return ProjectAtlas token-savings telemetry for the whole index or one session."
9700 )]
9701 fn atlas_token_report(
9702 &self,
9703 Parameters(params): Parameters<AtlasTokenParams>,
9704 ) -> McpToolTextResult {
9705 Self::as_mcp_text((|| {
9706 let state =
9707 self.state_for_target(params.project_path.clone(), params.worktree.clone())?;
9708 let repository_scope = params.session.is_none()
9709 && state.root == self.control_state.root
9710 && state.db_path == self.control_state.db_path;
9711 let load_report = |request: TokenReportRequest<'_>| {
9712 if repository_scope {
9713 load_synchronized_repository_token_report(
9714 &state.db_path,
9715 &state.root,
9716 state
9717 .worktree
9718 .as_ref()
9719 .and_then(|selection| selection.control_project_instance_id),
9720 request,
9721 )
9722 } else {
9723 let store = Self::open_read_store(&state)?;
9724 load_token_report(&store, request).map_err(CliError::from)
9725 }
9726 };
9727 let include_chart = params.include_chart.unwrap_or(false);
9728 let chart_theme = Self::parse_token_chart_theme(params.theme.as_deref())?;
9729 if let Some(window) = params.trend_window.as_deref() {
9730 if params.benchmark_results.is_some() {
9731 return Err(CliError::InvalidInput(
9732 TOKEN_TREND_BENCHMARK_ERROR.to_string(),
9733 ));
9734 }
9735 let window = TokenTrendWindow::parse(window).ok_or_else(|| {
9736 CliError::InvalidInput(format!(
9737 "unsupported token trend window {window:?}; {TOKEN_TREND_WINDOW_ERROR_SUFFIX}"
9738 ))
9739 })?;
9740 let request = if repository_scope {
9741 TokenReportRequest::RepositoryTrends { window }
9742 } else {
9743 TokenReportRequest::Trends {
9744 caller_label: params.session.as_deref(),
9745 window,
9746 }
9747 };
9748 let report = match load_report(request)? {
9749 TokenReport::Trends(report) => report,
9750 TokenReport::Overview(_) => {
9751 return Err(CliError::InvalidInput(
9752 TOKEN_TRENDS_RESULT_VARIANT_MISMATCH.to_string(),
9753 ));
9754 }
9755 };
9756 if include_chart {
9757 let chart = render_token_trend_dashboard_plain_with_theme(&report, chart_theme);
9758 let output = Self::encode_two_named_payloads(
9759 MCP_PAYLOAD_TOKEN_TRENDS,
9760 &report,
9761 MCP_PAYLOAD_CHART,
9762 &chart,
9763 )?;
9764 return Self::with_selected_project_audit(
9765 &state,
9766 state.worktree.is_some(),
9767 output,
9768 );
9769 }
9770 return Self::with_selected_project_audit(
9771 &state,
9772 state.worktree.is_some(),
9773 render_token_trends(&report),
9774 );
9775 }
9776 let request = if repository_scope {
9777 TokenReportRequest::RepositoryOverview {
9778 benchmark_results: params.benchmark_results.as_deref().map(Path::new),
9779 }
9780 } else {
9781 TokenReportRequest::Overview {
9782 caller_label: params.session.as_deref(),
9783 benchmark_results: params.benchmark_results.as_deref().map(Path::new),
9784 }
9785 };
9786 let overview = match load_report(request)? {
9787 TokenReport::Overview(overview) => overview,
9788 TokenReport::Trends(_) => {
9789 return Err(CliError::InvalidInput(
9790 TOKEN_OVERVIEW_RESULT_VARIANT_MISMATCH.to_string(),
9791 ));
9792 }
9793 };
9794 if include_chart {
9795 let chart = render_token_dashboard_plain_with_theme(
9796 &overview,
9797 params.session.as_deref(),
9798 chart_theme,
9799 );
9800 let output = Self::encode_two_named_payloads(
9801 MCP_PAYLOAD_TOKEN_SAVINGS,
9802 &overview,
9803 MCP_PAYLOAD_CHART,
9804 &chart,
9805 )?;
9806 return Self::with_selected_project_audit(&state, state.worktree.is_some(), output);
9807 }
9808 Self::with_selected_project_audit(
9809 &state,
9810 state.worktree.is_some(),
9811 render_token_overview(&overview),
9812 )
9813 })())
9814 }
9815
9816 #[tool(
9818 name = "atlas_parity_report",
9819 description = "Return a ProjectAtlas repository-intelligence parity gate report for release and agent-runtime readiness."
9820 )]
9821 fn atlas_parity_report(
9822 &self,
9823 Parameters(params): Parameters<AtlasParityParams>,
9824 context: RequestContext<RoleServer>,
9825 ) -> McpToolTextResult {
9826 Self::as_mcp_text((|| {
9827 let state = self.state_for_target(params.project_path, params.worktree)?;
9828 let profile = params
9829 .profile
9830 .unwrap_or_else(|| crate::REPOSITORY_INTELLIGENCE_PROFILE.to_string());
9831 self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
9832 Ok(render_parity_report(&build_parity_report(store, &profile)?))
9833 })
9834 })())
9835 }
9836
9837 #[tool(
9839 name = "atlas_settings",
9840 description = "Return ProjectAtlas local settings, config, and durable index paths."
9841 )]
9842 fn atlas_settings(
9843 &self,
9844 Parameters(params): Parameters<AtlasProjectParams>,
9845 ) -> McpToolTextResult {
9846 Self::as_mcp_text((|| {
9847 let state = self.state_for_target(params.project_path, params.worktree)?;
9848 self.render_settings_with_capabilities(&state)
9849 })())
9850 }
9851
9852 #[tool(
9854 name = "atlas_watch_status",
9855 description = "Return ProjectAtlas watcher availability and current operating mode."
9856 )]
9857 fn atlas_watch_status(
9858 &self,
9859 Parameters(params): Parameters<AtlasProjectParams>,
9860 ) -> McpToolTextResult {
9861 let state = match self.state_for_target(params.project_path, params.worktree) {
9862 Ok(state) => state,
9863 Err(error) => return Self::as_mcp_text(Err(error)),
9864 };
9865 let mut report = watcher_status_report(false);
9866 if !state.db_path.exists() {
9867 report
9868 .recommendation
9869 .push_str(WATCH_STATUS_SCAN_RECOMMENDATION);
9870 }
9871 Self::as_mcp_text(Ok(render_watch_status(&report)))
9872 }
9873
9874 #[tool(
9876 name = "atlas_watch_once",
9877 description = "Run one MCP-safe watcher refresh pass over the repository and rebuild changed symbols, with optional worker, timeout, and text-index size controls."
9878 )]
9879 fn atlas_watch_once(
9880 &self,
9881 Parameters(params): Parameters<AtlasWatchOnceParams>,
9882 ) -> McpToolTextResult {
9883 Self::as_mcp_text((|| {
9884 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9885 let background = params.background.unwrap_or(false);
9886 let (state, path) = if background {
9887 self.background_state_and_root_path(
9888 params.project_path,
9889 params.worktree,
9890 params.path,
9891 nearest_project,
9892 )?
9893 } else {
9894 self.state_and_root_path(
9895 params.project_path,
9896 params.worktree,
9897 params.path,
9898 nearest_project,
9899 )?
9900 };
9901 let symbol_options = SymbolBuildOptions::new(
9902 MAX_SYMBOL_FILE_BYTES,
9903 params.max_workers,
9904 params.timeout_seconds,
9905 );
9906 let text_index_max_bytes = params.text_index_max_bytes;
9907 if background {
9908 let control_state = self.control_state.clone();
9909 let task = self.start_index_task(
9910 McpTaskOperation::WatchOnce,
9911 symbol_options,
9912 MCP_TOOL_ATLAS_OVERVIEW,
9913 move |control, symbol_options| {
9914 let plan = ScanRuntimePlan::for_path_controlled(
9915 state.config_path.as_deref(),
9916 &path,
9917 text_index_max_bytes,
9918 control,
9919 )?;
9920 let mut store = Self::open_mut_store(&state, &control_state)?;
9921 run_single_watch_refresh_controlled(
9922 &mut store,
9923 &plan,
9924 &symbol_options,
9925 control,
9926 )?;
9927 Ok(())
9928 },
9929 )?;
9930 return Self::encode_named_payload(MCP_PAYLOAD_TASK_START, &task);
9931 }
9932 let control = index_work_control(&symbol_options);
9933 let plan = ScanRuntimePlan::for_path_controlled(
9934 state.config_path.as_deref(),
9935 &path,
9936 text_index_max_bytes,
9937 &control,
9938 )?;
9939 let mut store = Self::open_mut_store(&state, &self.control_state)?;
9940 let report =
9941 run_single_watch_refresh_controlled(&mut store, &plan, &symbol_options, &control)?;
9942 Self::encode_named_payload(MCP_PAYLOAD_WATCH, &report)
9943 })())
9944 }
9945
9946 #[tool(
9948 name = "atlas_strip_legacy_purpose",
9949 description = "Preview or remove legacy .purpose files after their metadata has been imported to SQLite."
9950 )]
9951 fn atlas_strip_legacy_purpose(
9952 &self,
9953 Parameters(params): Parameters<AtlasStripLegacyParams>,
9954 ) -> McpToolTextResult {
9955 Self::as_mcp_text((|| {
9956 let nearest_project = self.nearest_project_enabled(params.nearest_project);
9957 let (state, path) = self.state_and_root_path(
9958 params.project_path,
9959 params.worktree,
9960 params.path,
9961 nearest_project,
9962 )?;
9963 let report = strip_legacy_purpose(
9964 &path,
9965 state.config_path.as_deref(),
9966 params.apply.unwrap_or(false),
9967 params.dry_run.unwrap_or(false),
9968 params
9969 .strip_source_headers
9970 .unwrap_or_else(|| state.config_path.is_some()),
9971 )?;
9972 Self::encode_named_payload(MCP_PAYLOAD_LEGACY_PURPOSE_MIGRATION, &report)
9973 })())
9974 }
9975
9976 #[tool(
9978 name = "atlas_reset_index",
9979 description = "Preview or clear ProjectAtlas local SQLite index/cache files for recovery."
9980 )]
9981 fn atlas_reset_index(
9982 &self,
9983 Parameters(params): Parameters<AtlasResetIndexParams>,
9984 ) -> McpToolTextResult {
9985 Self::as_mcp_text((|| {
9986 let state = self.state_for_target(params.project_path, params.worktree)?;
9987 let apply = params.apply.unwrap_or(false);
9988 let dry_run = params.dry_run.unwrap_or(false);
9989 let include_mcp_config = params.include_mcp_config.unwrap_or(false);
9990 let report = if apply && !dry_run {
9991 if let Some(selection) = state
9992 .worktree
9993 .as_ref()
9994 .filter(|selection| selection.registration_id.is_some())
9995 {
9996 self.reset_registered_worktree_index(&state, selection, include_mcp_config)?
9997 } else {
9998 reset_index_files(&state.db_path, true, false, include_mcp_config)?
9999 }
10000 } else {
10001 reset_index_files(&state.db_path, apply, dry_run, include_mcp_config)?
10002 };
10003 Self::encode_named_payload(MCP_PAYLOAD_RESET_INDEX, &report)
10004 })())
10005 }
10006
10007 #[tool(
10009 name = "atlas_mcp_config",
10010 description = "Return a generated ProjectAtlas MCP config document for mcp-json, codex, claude-code, or opencode hosts."
10011 )]
10012 fn atlas_mcp_config(
10013 &self,
10014 Parameters(params): Parameters<AtlasMcpConfigParams>,
10015 ) -> McpToolTextResult {
10016 Self::as_mcp_text((|| {
10017 let state = self.admin_project_root(params.project_path, params.worktree)?;
10018 let harness = Self::parse_harness_config(params.harness.as_deref())?;
10019 let server_name = params
10020 .server_name
10021 .unwrap_or_else(|| MCP_DEFAULT_CONFIG_SERVER_NAME.to_string());
10022 let report = build_harness_mcp_config_report(
10023 harness,
10024 &server_name,
10025 &state.db_path,
10026 state.config_path.as_deref(),
10027 params.nearest_project.unwrap_or(false),
10028 )?;
10029 Self::encode_named_payload(MCP_PAYLOAD_MCP_CONFIG, &report)
10030 })())
10031 }
10032
10033 #[tool(
10035 name = "atlas_runtime_info",
10036 description = "Return ProjectAtlas runtime identity, version, capabilities, and compiled MCP tool names."
10037 )]
10038 fn atlas_runtime_info(
10039 &self,
10040 Parameters(_params): Parameters<AtlasProjectParams>,
10041 ) -> McpToolTextResult {
10042 let _session_scope = self.session.as_str();
10043 Self::as_mcp_text(Ok(render_runtime_info(&build_runtime_info())))
10044 }
10045
10046 #[tool(
10048 name = "atlas_session_brief",
10049 description = "Return selected project identity, index state, ranked candidates, blockers, and typed next-call recommendations for agent startup."
10050 )]
10051 fn atlas_session_brief(
10052 &self,
10053 Parameters(params): Parameters<AtlasSessionBriefParams>,
10054 context: RequestContext<RoleServer>,
10055 ) -> McpToolTextResult {
10056 Self::as_mcp_text((|| {
10057 if params.compact.unwrap_or(false) {
10058 let brief = self.build_compact_session_brief(params, Some(context))?;
10059 Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
10060 } else {
10061 let brief = self.build_session_brief(params, Some(context))?;
10062 Self::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)
10063 }
10064 })())
10065 }
10066
10067 #[tool(
10069 name = "atlas_task_status",
10070 description = "Return typed status for a bounded MCP task-progress record."
10071 )]
10072 fn atlas_task_status(
10073 &self,
10074 Parameters(params): Parameters<AtlasTaskParams>,
10075 ) -> McpToolTextResult {
10076 Self::as_mcp_text((|| {
10077 let status = self.task_status(params.task_id)?;
10078 Self::encode_named_payload(MCP_PAYLOAD_TASK_STATUS, &status)
10079 })())
10080 }
10081
10082 #[tool(
10084 name = "atlas_task_cancel",
10085 description = "Request cancellation for a bounded MCP task-progress record."
10086 )]
10087 fn atlas_task_cancel(
10088 &self,
10089 Parameters(params): Parameters<AtlasTaskParams>,
10090 ) -> McpToolTextResult {
10091 Self::as_mcp_text((|| {
10092 let cancel = self.task_cancel(params.task_id)?;
10093 Self::encode_named_payload(MCP_PAYLOAD_TASK_CANCEL, &cancel)
10094 })())
10095 }
10096
10097 #[tool(
10099 name = "atlas_purpose_queue",
10100 description = "Return a bounded folder-first queue of ProjectAtlas paths that need agent purpose curation."
10101 )]
10102 fn atlas_purpose_queue(
10103 &self,
10104 Parameters(params): Parameters<AtlasPurposeQueueParams>,
10105 context: RequestContext<RoleServer>,
10106 ) -> McpToolTextResult {
10107 Self::as_mcp_text((|| {
10108 let state = self.state_for_target(
10109 params.health.project_path.clone(),
10110 params.health.worktree.clone(),
10111 )?;
10112 let query =
10113 health_query_from_params(¶ms.health, purpose_queue_scope(¶ms.health))?;
10114 let task = params
10115 .task
10116 .as_deref()
10117 .unwrap_or(MCP_PURPOSE_TASK_QUEUE)
10118 .to_string();
10119 self.with_fresh_string_and_usage_for_request(&state, Some(context), |store, stamp| {
10120 let page = purpose_curation_page(store, &query, &task)?;
10121 let toon = render_purpose_curation_page(&page);
10122 let usage = Self::telemetry_enabled()
10123 .then(|| self.estimated_source_tokens_cached(&state, store, &stamp, None, None))
10124 .and_then(Result::ok)
10125 .map(|baseline_tokens| {
10126 McpUsageIntent::directory_walk(
10127 MCP_EVENT_ATLAS_PURPOSE_QUEUE,
10128 None,
10129 None,
10130 baseline_tokens,
10131 )
10132 });
10133 Ok((toon, usage))
10134 })
10135 })())
10136 }
10137
10138 #[tool(
10140 name = "atlas_purpose_set",
10141 description = "Set agent-approved ProjectAtlas purpose metadata for one indexed path."
10142 )]
10143 fn atlas_purpose_set(
10144 &self,
10145 Parameters(params): Parameters<AtlasPurposeSetParams>,
10146 context: RequestContext<RoleServer>,
10147 ) -> McpToolTextResult {
10148 Self::as_mcp_text((|| {
10149 let state = self.state_for_target(params.project_path, params.worktree)?;
10150 let node_key = Self::preflight_purpose_path(&state, ¶ms.path)?;
10151 self.with_admitted_purpose_mutation(&state, Some(context), |store| {
10152 Self::require_indexed_purpose_path(store, &node_key)?;
10153 store.set_purpose(&node_key, ¶ms.purpose, PurposeSource::Agent)?;
10154 let classification = if store
10155 .load_node_by_path(&node_key)?
10156 .is_some_and(|node| node.node.kind == projectatlas_core::NodeKind::File)
10157 {
10158 store
10159 .file_content_classifications_for_paths(std::slice::from_ref(&node_key))?
10160 .first()
10161 .map(|row| row.classification)
10162 } else {
10163 None
10164 };
10165 Self::encode_serialized_payload(McpPurposeSetResponse {
10166 purpose_set: McpPurposeSetPayload {
10167 path: node_key,
10168 classification,
10169 status: PurposeStatus::Approved,
10170 source: PurposeSource::Agent,
10171 agent_reviewed: true,
10172 },
10173 })
10174 })
10175 })())
10176 }
10177
10178 #[tool(
10180 name = "atlas_purpose_review",
10181 description = "Preview or apply agent-reviewed ProjectAtlas purpose metadata for multiple indexed paths."
10182 )]
10183 fn atlas_purpose_review(
10184 &self,
10185 Parameters(params): Parameters<AtlasPurposeReviewParams>,
10186 context: RequestContext<RoleServer>,
10187 ) -> McpToolTextResult {
10188 Self::as_mcp_text((|| {
10189 let apply = params.apply.unwrap_or(false);
10190 let requests = params
10191 .items
10192 .into_iter()
10193 .map(|item| PurposeReviewRequest {
10194 path: item.path,
10195 purpose: item.purpose,
10196 confirm_existing: item.confirm_existing.unwrap_or(false),
10197 task: item.task,
10198 work_key: item.work_key,
10199 state_token: item.state_token,
10200 })
10201 .collect::<Vec<_>>();
10202 validate_purpose_review_admission(&requests)?;
10203 let state = self.state_for_target(params.project_path, params.worktree)?;
10204 if apply {
10205 return self.with_admitted_purpose_mutation(&state, Some(context), |store| {
10206 let report = review_purposes(store, &requests, true)?;
10207 Ok(render_purpose_review_report(&report))
10208 });
10209 }
10210 self.with_fresh_string_for_request(&state, Some(context), |store, _stamp| {
10211 let report = review_purposes(store, &requests, false)?;
10212 Ok(render_purpose_review_report(&report))
10213 })
10214 })())
10215 }
10216}
10217
10218#[allow(clippy::unused_async_trait_impl)]
10219#[tool_handler(router = self.tool_router)]
10220impl ServerHandler for ProjectAtlasMcpServer {
10221 fn get_info(&self) -> ServerInfo {
10222 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
10223 .with_server_info(Implementation::new(
10224 MCP_SERVER_NAME,
10225 env!("CARGO_PKG_VERSION"),
10226 ))
10227 .with_instructions(MCP_SERVER_INSTRUCTIONS)
10228 }
10229}
10230
10231fn mcp_unix_time_ms() -> u128 {
10233 SystemTime::now()
10234 .duration_since(UNIX_EPOCH)
10235 .map_or(0, |duration| duration.as_millis())
10236}
10237
10238fn task_error_is_canceled(error: &CliError) -> bool {
10240 std::iter::successors(
10241 Some(error as &(dyn std::error::Error + 'static)),
10242 |source| source.source(),
10243 )
10244 .any(|source| {
10245 matches!(
10246 source.downcast_ref::<IndexWorkFailure>(),
10247 Some(IndexWorkFailure::Cancelled { .. })
10248 )
10249 })
10250}
10251
10252fn bounded_task_error(error: &CliError) -> String {
10254 error
10255 .to_string()
10256 .chars()
10257 .take(MCP_TASK_ERROR_MAX_CHARS)
10258 .collect()
10259}
10260
10261#[cfg(test)]
10262mod tests {
10263 use super::*;
10264 use crate::atlas_map::init_project_with_config;
10265 use notify::{Event, EventKind, event::ModifyKind};
10266 use projectatlas_core::graph::{
10267 Completeness, ConfidenceClass, EntitySelector, ExtendedRelationKind, ExternalSelector,
10268 GraphEntity, GraphIdentityText, GraphRelationKind, LogicalRelation, PackageSelector,
10269 RelationResolution, RepositoryFilePath,
10270 };
10271 use projectatlas_core::symbols::RelationKind;
10272 use projectatlas_core::{
10273 CanonicalProjectRoot, IndexCancellation, IndexWorkStage, RankedConnectionDirection,
10274 RankedConnectionKind, RankedConnectionTarget,
10275 };
10276 use projectatlas_db::ProjectRootTransition;
10277 use std::collections::BTreeSet;
10278 use std::fs;
10279 use std::io;
10280 #[cfg(unix)]
10281 use std::os::unix::ffi::OsStringExt;
10282 use std::process::Command as StdCommand;
10283 use std::time::{Duration, Instant};
10284
10285 fn require(condition: bool, message: &str) -> Result<(), Box<dyn std::error::Error>> {
10286 if condition {
10287 Ok(())
10288 } else {
10289 Err(io::Error::other(message.to_string()).into())
10290 }
10291 }
10292
10293 fn drop_native_worktree_identity_schema(
10295 connection: &rusqlite::Connection,
10296 ) -> rusqlite::Result<()> {
10297 connection.execute_batch(
10298 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
10299 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
10300 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
10301 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
10302 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;",
10303 )
10304 }
10305
10306 fn run_fixture_command(command: &mut StdCommand) -> Result<String, Box<dyn std::error::Error>> {
10308 let output = command.output()?;
10309 if !output.status.success() {
10310 return Err(io::Error::other(format!(
10311 "fixture command failed: {}{}",
10312 String::from_utf8_lossy(&output.stdout),
10313 String::from_utf8_lossy(&output.stderr)
10314 ))
10315 .into());
10316 }
10317 Ok(String::from_utf8(output.stdout)?)
10318 }
10319
10320 struct RegisteredWorktreeRaceFixture {
10321 _temp: tempfile::TempDir,
10322 primary: PathBuf,
10323 control_root: PathBuf,
10324 linked: PathBuf,
10325 control_db: PathBuf,
10326 target_db: PathBuf,
10327 server: ProjectAtlasMcpServer,
10328 alias: WorktreeAlias,
10329 registration: WorktreeRegistration,
10330 selection: McpWorktreeSelection,
10331 state: McpProjectState,
10332 administrative_directory: PathBuf,
10333 administrative_identity: String,
10334 }
10335
10336 fn registered_worktree_race_fixture(
10337 alias: &str,
10338 ) -> Result<RegisteredWorktreeRaceFixture, Box<dyn std::error::Error>> {
10339 let temp = tempfile::tempdir()?;
10340 let primary = temp.path().join("control");
10341 let linked = temp.path().join("linked");
10342 fs::create_dir_all(primary.join("src"))?;
10343 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
10344 for (key, value) in [
10345 ("user.name", "ProjectAtlas Test"),
10346 ("user.email", "projectatlas@example.invalid"),
10347 ("commit.gpgsign", "false"),
10348 ("core.autocrlf", "false"),
10349 ] {
10350 run_fixture_command(
10351 StdCommand::new("git")
10352 .current_dir(&primary)
10353 .args(["config", key, value]),
10354 )?;
10355 }
10356 fs::write(primary.join("src/lib.rs"), "pub fn control() {}\n")?;
10357 run_fixture_command(
10358 StdCommand::new("git")
10359 .current_dir(&primary)
10360 .args(["add", "."]),
10361 )?;
10362 run_fixture_command(
10363 StdCommand::new("git")
10364 .current_dir(&primary)
10365 .args(["commit", "-m", "fixture"]),
10366 )?;
10367 run_fixture_command(
10368 StdCommand::new("git")
10369 .current_dir(&primary)
10370 .args(["worktree", "add", "-b", "captured"])
10371 .arg(&linked),
10372 )?;
10373
10374 let control_root = primary.canonicalize()?;
10375 let control_config = control_root.join(PROJECTATLAS_DIR_NAME).join("config.toml");
10376 let control_db = control_root
10377 .join(PROJECTATLAS_DIR_NAME)
10378 .join(PROJECTATLAS_DB_FILE_NAME);
10379 init_project_with_config(&control_root, Some(&control_config))?;
10380 let mut control = AtlasStore::open_for_project(&control_db, &control_root)?;
10381 let plan = ScanRuntimePlan::for_path(Some(&control_config), &control_root, None)?;
10382 run_scan_pipeline(
10383 &mut control,
10384 &plan,
10385 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
10386 )?;
10387 let control_project_instance_id = control
10388 .project_instance_id()?
10389 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
10390 let server = ProjectAtlasMcpServer::new(
10391 control_db.clone(),
10392 Some(control_config),
10393 "worktree-race".to_string(),
10394 false,
10395 );
10396 let repository = server.control_git_repository()?;
10397 let canonical_linked = linked.canonicalize()?;
10398 let entry = repository
10399 .worktrees
10400 .iter()
10401 .find(|entry| {
10402 ProjectAtlasMcpServer::active_worktree_root(entry)
10403 == Some(canonical_linked.as_path())
10404 })
10405 .ok_or_else(|| io::Error::other("linked worktree was not discovered"))?;
10406 let administrative_directory = entry.administrative_directory.clone();
10407 let administrative_identity = git_administrative_identity(&administrative_directory)?;
10408 let alias = WorktreeAlias::parse(alias)?;
10409 let registration = control.register_worktree(
10410 &alias,
10411 &repository.common_directory,
10412 &administrative_directory,
10413 &administrative_identity,
10414 &canonical_linked,
10415 None,
10416 1,
10417 )?;
10418 drop(control);
10419 let target_db = canonical_linked
10420 .join(PROJECTATLAS_DIR_NAME)
10421 .join(PROJECTATLAS_DB_FILE_NAME);
10422 let selection = McpWorktreeSelection {
10423 alias: alias.to_string(),
10424 registration_id: Some(registration.registration_id),
10425 project_instance_id: None,
10426 control_project_instance_id: Some(control_project_instance_id),
10427 };
10428 let state = McpProjectState {
10429 root: canonical_linked,
10430 db_path: target_db.clone(),
10431 config_path: Some(linked.join(PROJECTATLAS_DIR_NAME).join("config.toml")),
10432 worktree: Some(selection.clone()),
10433 };
10434 Ok(RegisteredWorktreeRaceFixture {
10435 _temp: temp,
10436 primary: control_root.clone(),
10437 control_root,
10438 linked,
10439 control_db,
10440 target_db,
10441 server,
10442 alias,
10443 registration,
10444 selection,
10445 state,
10446 administrative_directory,
10447 administrative_identity,
10448 })
10449 }
10450
10451 fn replace_registered_worktree(
10452 fixture: &RegisteredWorktreeRaceFixture,
10453 branch: &str,
10454 ) -> Result<GitWorktreeEntry, Box<dyn std::error::Error>> {
10455 run_fixture_command(
10456 StdCommand::new("git")
10457 .current_dir(&fixture.primary)
10458 .args(["worktree", "remove", "--force"])
10459 .arg(&fixture.linked),
10460 )?;
10461 run_fixture_command(
10462 StdCommand::new("git")
10463 .current_dir(&fixture.primary)
10464 .args(["worktree", "add", "-b", branch])
10465 .arg(&fixture.linked),
10466 )?;
10467 let repository = fixture.server.control_git_repository()?;
10468 repository
10469 .worktrees
10470 .into_iter()
10471 .find(|entry| {
10472 ProjectAtlasMcpServer::active_worktree_root(entry)
10473 == Some(fixture.state.root.as_path())
10474 })
10475 .ok_or_else(|| io::Error::other("replacement worktree was not discovered").into())
10476 }
10477
10478 fn prepared_hydration_candidate(
10480 fixture: &RegisteredWorktreeRaceFixture,
10481 ) -> Result<
10482 (
10483 PreparedWorktreeHydrationCandidate,
10484 PathBuf,
10485 IndexWorkControl,
10486 ),
10487 Box<dyn std::error::Error>,
10488 > {
10489 fs::create_dir_all(
10490 fixture
10491 .target_db
10492 .parent()
10493 .ok_or_else(|| io::Error::other("target database has no parent"))?,
10494 )?;
10495 let source =
10496 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
10497 let work_control =
10498 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
10499 let mut candidate = source.prepare_worktree_hydration(
10500 &fixture.state.root,
10501 &fixture.target_db,
10502 &work_control,
10503 )?;
10504 let candidate_path = candidate.path()?.to_path_buf();
10505 candidate.accept_verified_source_state(&work_control)?;
10506 let candidate = candidate.prepare_activation(&work_control)?;
10507 Ok((candidate, candidate_path, work_control))
10508 }
10509
10510 #[test]
10511 fn task_errors_classify_only_typed_cancellation_as_canceled() {
10512 let stage = IndexWorkStage::RepositoryTraversal;
10513 assert!(task_error_is_canceled(&CliError::IndexWork(
10514 IndexWorkFailure::Cancelled { stage },
10515 )));
10516 assert!(task_error_is_canceled(&CliError::Fs(
10517 projectatlas_fs::FsError::IndexWork(IndexWorkFailure::Cancelled { stage }),
10518 )));
10519 assert!(task_error_is_canceled(&CliError::Db(DbError::IndexWork(
10520 IndexWorkFailure::Cancelled { stage },
10521 ))));
10522 assert!(task_error_is_canceled(&CliError::Service(
10523 ServiceError::Db(DbError::IndexWork(IndexWorkFailure::Cancelled { stage })),
10524 )));
10525 assert!(!task_error_is_canceled(&CliError::IndexWork(
10526 IndexWorkFailure::DeadlineExceeded { stage },
10527 )));
10528 assert!(!task_error_is_canceled(&CliError::Db(DbError::IndexWork(
10529 IndexWorkFailure::DeadlineExceeded { stage },
10530 ))));
10531 assert!(!task_error_is_canceled(&CliError::Fs(
10532 projectatlas_fs::FsError::IndexWork(IndexWorkFailure::ResourceLimitExceeded {
10533 stage,
10534 resource: projectatlas_core::IndexWorkResource::Entries,
10535 limit: 1,
10536 observed: 2,
10537 }),
10538 )));
10539 }
10540
10541 fn usage_test_project(
10542 parent: &Path,
10543 name: &str,
10544 ) -> Result<(McpProjectState, AtlasStore), Box<dyn std::error::Error>> {
10545 let root = parent.join(name);
10546 fs::create_dir_all(root.join(".projectatlas"))?;
10547 let db_path = root.join(".projectatlas").join("projectatlas.db");
10548 let store = AtlasStore::open_for_project(&db_path, &root)?;
10549 Ok((
10550 McpProjectState {
10551 root,
10552 db_path,
10553 config_path: None,
10554 worktree: None,
10555 },
10556 store,
10557 ))
10558 }
10559
10560 fn usage_runtime_identity(
10561 server: &ProjectAtlasMcpServer,
10562 state: &McpProjectState,
10563 store: &AtlasStore,
10564 ) -> Result<UsageRuntimeInstance, Box<dyn std::error::Error>> {
10565 let binding = McpUsageProjectBinding::capture(state, store)?;
10566 let project_instance = server
10567 .usage_runtime
10568 .lock()
10569 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
10570 .entries
10571 .iter()
10572 .find(|entry| entry.binding == binding)
10573 .map(|entry| Arc::clone(&entry.instance))
10574 .ok_or_else(|| io::Error::other("operating-system entropy was unavailable"))?;
10575 let identity = *project_instance
10576 .lock()
10577 .map_err(|_poisoned| io::Error::other("project usage lock poisoned"))?;
10578 Ok(identity)
10579 }
10580
10581 #[test]
10582 fn mcp_server_clones_share_one_runtime_identity() -> Result<(), Box<dyn std::error::Error>> {
10583 let temp = tempfile::tempdir()?;
10584 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
10585 let first = ProjectAtlasMcpServer::new(
10586 state.db_path.clone(),
10587 None,
10588 "shared-label".to_string(),
10589 false,
10590 );
10591 first.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
10592 let first_identity = usage_runtime_identity(&first, &state, &store)?;
10593 let cloned = first.clone();
10594 require(
10595 usage_runtime_identity(&first, &state, &store)? == first_identity,
10596 "cloning an MCP server changed the original telemetry identity",
10597 )?;
10598 let restarted = ProjectAtlasMcpServer::new(
10599 state.db_path.clone(),
10600 None,
10601 "shared-label".to_string(),
10602 false,
10603 );
10604 restarted.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
10605 let restarted_identity = usage_runtime_identity(&restarted, &state, &store)?;
10606
10607 require(
10608 usage_runtime_identity(&cloned, &state, &store)? == first_identity,
10609 "cloning an MCP server changed its process-scoped telemetry identity",
10610 )?;
10611 require(
10612 restarted_identity != first_identity,
10613 "a separately constructed MCP server reused the prior runtime identity",
10614 )
10615 }
10616
10617 #[test]
10618 fn mcp_request_cancellation_bridge_reaches_index_work() -> Result<(), Box<dyn std::error::Error>>
10619 {
10620 let cancellation = Arc::new(std::sync::atomic::AtomicBool::new(false));
10621 let observed = Arc::clone(&cancellation);
10622 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10623 let bridge = McpRequestCancellationBridge::start_with_probe(
10624 move || observed.load(Ordering::Acquire),
10625 &control,
10626 )?;
10627
10628 cancellation.store(true, Ordering::Release);
10629 let mut canceled = false;
10630 for _attempt in 0..100 {
10631 if matches!(
10632 control.check(IndexWorkStage::Publication),
10633 Err(IndexWorkFailure::Cancelled { .. })
10634 ) {
10635 canceled = true;
10636 break;
10637 }
10638 thread::sleep(Duration::from_millis(2));
10639 }
10640 drop(bridge);
10641
10642 require(
10643 canceled,
10644 "RMCP cancellation probe did not reach the shared index work control",
10645 )
10646 }
10647
10648 #[test]
10649 fn purpose_preflight_preserves_admitted_mutations_and_saved_source_repair()
10650 -> Result<(), Box<dyn std::error::Error>> {
10651 let temp = tempfile::tempdir()?;
10652 let root = temp.path();
10653 fs::create_dir(root.join(".projectatlas"))?;
10654 fs::write(root.join("source.rs"), "fn current() {}\n")?;
10655 let db_path = root.join(".projectatlas/projectatlas.db");
10656 let plan = ScanRuntimePlan::for_path(None, root, None)?;
10657 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
10658 run_scan_pipeline(
10659 &mut store,
10660 &plan,
10661 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
10662 )?;
10663 drop(store);
10664 let server =
10665 ProjectAtlasMcpServer::new(db_path, None, "purpose-preflight".to_owned(), false);
10666 let state = server.state_for_target(Some(normalize_native_path_display(root)), None)?;
10667 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10668 let admission = server.source_observations.admit_mutation(
10669 &state.db_path,
10670 &state.root,
10671 state.config_path.as_deref(),
10672 &control,
10673 )?;
10674 let store = ProjectAtlasMcpServer::open_existing_mut_store(&state, &server.control_state)?;
10675 let transaction = store.begin_purpose_mutation()?;
10676 store.set_purpose(".", "Accepted repository purpose", PurposeSource::Agent)?;
10677 let absolute = normalize_native_path_display(root.join("source.rs"));
10678 for path in [absolute.as_str(), "", "missing.rs"] {
10679 require(
10680 matches!(
10681 ProjectAtlasMcpServer::preflight_purpose_path(&state, path),
10682 Err(CliError::InvalidInput(_))
10683 ),
10684 "invalid purpose path passed admission preflight",
10685 )?;
10686 admission.verify()?;
10687 }
10688 transaction.commit()?;
10689 require(
10690 store.load_node_by_path(".")?.is_some_and(|node| {
10691 node.purpose.purpose.as_deref() == Some("Accepted repository purpose")
10692 }),
10693 "rejected purpose requests prevented the admitted mutation from committing",
10694 )?;
10695 let identity = store.captured_project_binding()?.project_instance_id;
10696 drop(store);
10697 let saved_path = root.join("added.rs");
10698 let saved_source = "fn added() {}\n";
10699 fs::write(&saved_path, saved_source)?;
10700 let node_key = ProjectAtlasMcpServer::preflight_purpose_path(&state, "added.rs")?;
10701 server.with_admitted_purpose_mutation_controlled(&state, &control, None, |store| {
10702 ProjectAtlasMcpServer::require_indexed_purpose_path(store, &node_key)?;
10703 store.set_purpose(&node_key, "New saved source", PurposeSource::Agent)?;
10704 Ok(())
10705 })?;
10706 let store = ProjectAtlasMcpServer::open_read_store(&state)?;
10707 require(
10708 store.captured_project_binding()?.project_instance_id == identity
10709 && store.load_node_by_path("added.rs")?.is_some_and(|node| {
10710 node.purpose.purpose.as_deref() == Some("New saved source")
10711 })
10712 && fs::read_to_string(saved_path)? == saved_source,
10713 "purpose preflight prevented exact saved-source repair or changed source identity",
10714 )?;
10715 store.finish_index_read_snapshot()?;
10716 Ok(())
10717 }
10718
10719 #[test]
10720 fn purpose_preflight_preserves_selected_worktree_error_context()
10721 -> Result<(), Box<dyn std::error::Error>> {
10722 let fixture = registered_worktree_race_fixture("purpose-target")?;
10723 fs::create_dir_all(fixture.state.root.join(PROJECTATLAS_DIR_NAME))?;
10724 drop(AtlasStore::open_for_project(
10726 &fixture.target_db,
10727 &fixture.control_root,
10728 )?);
10729 let before = fs::read(&fixture.target_db)?;
10730 let error = ProjectAtlasMcpServer::preflight_purpose_path(&fixture.state, "src/lib.rs")
10731 .err()
10732 .ok_or_else(|| io::Error::other("mismatched preflight was accepted"))?;
10733 require(
10734 matches!(&error, CliError::ProjectMismatch(report)
10735 if report.worktree.as_deref() == Some("purpose-target")),
10736 "purpose preflight lost the selected worktree context",
10737 )?;
10738 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
10739 let value: serde_json::Value = toon_format::decode_default(&payload)?;
10740 require(
10741 value
10742 .pointer("/error/kind")
10743 .and_then(serde_json::Value::as_str)
10744 == Some("project_mismatch")
10745 && value
10746 .pointer("/error/project_mismatch/worktree")
10747 .and_then(serde_json::Value::as_str)
10748 == Some("purpose-target"),
10749 "MCP mismatch payload lost the selected worktree alias",
10750 )?;
10751 let mut explicit = fixture.state.clone();
10752 explicit.worktree = None;
10753 require(
10754 matches!(
10755 ProjectAtlasMcpServer::preflight_purpose_path(&explicit, "src/lib.rs"),
10756 Err(CliError::ProjectMismatch(report)) if report.worktree.is_none()
10757 ),
10758 "explicit-project preflight acquired an unrelated worktree alias",
10759 )?;
10760 require(
10761 fs::read(&fixture.target_db)? == before,
10762 "rejected purpose preflight changed the mismatched database",
10763 )?;
10764 Ok(())
10765 }
10766
10767 #[test]
10768 fn purpose_mutation_synchronously_rolls_back_request_cancellation()
10769 -> Result<(), Box<dyn std::error::Error>> {
10770 let temp = tempfile::tempdir()?;
10771 let root = temp.path().join("purpose-cancellation");
10772 fs::create_dir_all(root.join(".projectatlas"))?;
10773 fs::write(root.join("source.rs"), "fn current() {}\n")?;
10774 let db_path = root.join(".projectatlas/projectatlas.db");
10775 let plan = ScanRuntimePlan::for_path(None, &root, None)?;
10776 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
10777 run_scan_pipeline(
10778 &mut store,
10779 &plan,
10780 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
10781 )?;
10782 let before_revision = store.authored_purpose_revision()?;
10783 let before_purpose = store
10784 .load_node_by_path("source.rs")?
10785 .ok_or_else(|| io::Error::other("indexed cancellation source missing"))?
10786 .purpose;
10787 drop(store);
10788
10789 let server = ProjectAtlasMcpServer::new(
10790 db_path.clone(),
10791 None,
10792 "purpose-cancellation".to_string(),
10793 false,
10794 );
10795 let state = server.state_for_target(Some(normalize_native_path_display(&root)), None)?;
10796 let control = IndexWorkControl::new(IndexCancellation::new(), None);
10797 let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
10798 let cancellation_probe = Arc::clone(&cancelled);
10799 let bridge = McpRequestCancellationBridge::start_with_probe(
10800 move || cancellation_probe.load(Ordering::Acquire),
10801 &control,
10802 )?;
10803 let result = server.with_admitted_purpose_mutation_controlled(
10804 &state,
10805 &control,
10806 Some(&bridge),
10807 |store| {
10808 store.set_purpose("source.rs", "Canceled purpose", PurposeSource::Agent)?;
10809 cancelled.store(true, Ordering::Release);
10810 Ok(())
10811 },
10812 );
10813 drop(bridge);
10814
10815 require(
10816 matches!(result, Err(CliError::IndexWork(_))),
10817 "request cancellation did not reject the purpose transaction",
10818 )?;
10819 let store = open_atlas_store_for_project(&db_path, &state.root)?;
10820 require(
10821 store.authored_purpose_revision()? == before_revision,
10822 "request cancellation advanced the authored-purpose revision",
10823 )?;
10824 require(
10825 store
10826 .load_node_by_path("source.rs")?
10827 .is_some_and(|node| node.purpose == before_purpose),
10828 "request cancellation persisted the rejected purpose",
10829 )
10830 }
10831
10832 #[test]
10833 fn mcp_same_path_project_identity_rotation_starts_a_distinct_runtime_entry()
10834 -> Result<(), Box<dyn std::error::Error>> {
10835 if telemetry_disabled() {
10836 return Ok(());
10837 }
10838 let temp = tempfile::tempdir()?;
10839 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
10840 let server = ProjectAtlasMcpServer::new(
10841 state.db_path.clone(),
10842 None,
10843 "shared-label".to_string(),
10844 false,
10845 );
10846 let old_project_identity = store.captured_project_binding()?.project_instance_id;
10847 server.record_usage_for_state(&state, &store, |_usage_instance| Ok(()));
10848 let old_runtime_identity = usage_runtime_identity(&server, &state, &store)?;
10849 drop(store);
10850
10851 AtlasStore::transition_project_root(
10852 &state.db_path,
10853 &state.root,
10854 projectatlas_db::ProjectRootTransition::Detach,
10855 )?;
10856 let detached_store = AtlasStore::open_for_project(&state.db_path, &state.root)?;
10857 let detached_project_identity = detached_store
10858 .captured_project_binding()?
10859 .project_instance_id;
10860 server.record_usage_for_state(&state, &detached_store, |_usage_instance| Ok(()));
10861 let detached_runtime_identity = usage_runtime_identity(&server, &state, &detached_store)?;
10862 let tracked = server
10863 .usage_runtime
10864 .lock()
10865 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
10866 .entries
10867 .len();
10868
10869 require(
10870 detached_project_identity != old_project_identity,
10871 "detach did not rotate the captured project identity",
10872 )?;
10873 require(
10874 detached_runtime_identity != old_runtime_identity,
10875 "same-path detach reused the previous project's telemetry identity",
10876 )?;
10877 require(
10878 tracked == 2,
10879 "same-path project identities did not retain distinct bounded runtime entries",
10880 )
10881 }
10882
10883 #[test]
10884 fn mcp_telemetry_rotates_inactive_bindings_without_dropping_later_worktrees()
10885 -> Result<(), Box<dyn std::error::Error>> {
10886 if telemetry_disabled() {
10887 return Ok(());
10888 }
10889 let temp = tempfile::tempdir()?;
10890 let (state, control) = usage_test_project(temp.path(), "control")?;
10891 let server =
10892 ProjectAtlasMcpServer::new(state.db_path, None, "worktree-capacity".to_string(), false);
10893 let common = temp.path().join("common.git");
10894 let event = usage_from_estimates_with_context(
10895 "worktree-capacity",
10896 "atlas_overview",
10897 None,
10898 None,
10899 100,
10900 10,
10901 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10902 TOKEN_BASELINE_SELECTED_CANDIDATES,
10903 TOKEN_CONFIDENCE_INFERRED,
10904 );
10905 for index in 0..=MCP_TELEMETRY_PROJECT_BINDING_LIMIT {
10906 let registration = control.register_worktree(
10907 &WorktreeAlias::parse(&format!("worktree-{index:03}"))?,
10908 &common,
10909 &common.join(format!("worktrees/{index:03}")),
10910 &format!("{:064x}", index + 1),
10911 &temp.path().join(format!("worktree-{index:03}")),
10912 Some(ProjectInstanceId::from_bytes(
10913 [u8::try_from(index + 1)?; 16],
10914 )?),
10915 u64::try_from(index + 1)?,
10916 )?;
10917 server.record_usage_for_origin(
10918 &server.control_state,
10919 &control,
10920 Some(registration.registration_id),
10921 |usage_instance| {
10922 usage_instance.record_for_worktree(
10923 &control,
10924 registration.registration_id,
10925 &event,
10926 )
10927 },
10928 );
10929 }
10930
10931 require(
10932 control.repository_token_overview()?.calls == MCP_TELEMETRY_PROJECT_BINDING_LIMIT + 1,
10933 "a worktree beyond the in-memory telemetry bound lost its accepted event",
10934 )?;
10935 let runtime = server
10936 .usage_runtime
10937 .lock()
10938 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?;
10939 require(
10940 runtime.entries.len() == MCP_TELEMETRY_PROJECT_BINDING_LIMIT,
10941 "telemetry project binding registry exceeded its hard bound",
10942 )?;
10943 drop(runtime);
10944 require(
10945 control.telemetry_retention_state()?.active_instance_rows
10946 == MCP_TELEMETRY_PROJECT_BINDING_LIMIT,
10947 "inactive binding rotation did not seal before replacement",
10948 )?;
10949 server.seal_usage_instances_for_projects();
10950 require(
10951 control.telemetry_retention_state()?.active_instance_rows == 0,
10952 "rotated telemetry bindings were not sealed at MCP shutdown",
10953 )
10954 }
10955
10956 #[test]
10957 fn routed_worktree_telemetry_preserves_session_baseline_identity()
10958 -> Result<(), Box<dyn std::error::Error>> {
10959 if telemetry_disabled() {
10960 return Ok(());
10961 }
10962 let temp = tempfile::tempdir()?;
10963 let (state, control) = usage_test_project(temp.path(), "control")?;
10964 let server =
10965 ProjectAtlasMcpServer::new(state.db_path, None, "worktree-scale".to_string(), false);
10966 let common = temp.path().join("common.git");
10967 let event = usage_from_estimates_with_context(
10968 "worktree-scale",
10969 "atlas_overview",
10970 None,
10971 None,
10972 100,
10973 10,
10974 TOKEN_BUCKET_NAVIGATION_AVOIDANCE,
10975 TOKEN_BASELINE_SELECTED_CANDIDATES,
10976 TOKEN_CONFIDENCE_INFERRED,
10977 );
10978 let registration = control.register_worktree(
10979 &WorktreeAlias::parse("worktree-001")?,
10980 &common,
10981 &common.join("worktrees/001"),
10982 &format!("{:064x}", 1),
10983 &temp.path().join("worktree-001"),
10984 Some(ProjectInstanceId::from_bytes([1; 16])?),
10985 1,
10986 )?;
10987 for _ in 0..2 {
10988 server.record_usage_for_origin(
10989 &server.control_state,
10990 &control,
10991 Some(registration.registration_id),
10992 |usage_instance| {
10993 usage_instance.record_for_worktree(
10994 &control,
10995 registration.registration_id,
10996 &event,
10997 )
10998 },
10999 );
11000 }
11001
11002 let overview = control.repository_token_overview()?;
11003
11004 require(
11005 overview.calls == 2,
11006 "alias-routed modeled usage did not retain both accepted calls",
11007 )?;
11008 require(
11009 overview.deduped_modeled_tokens_avoided == 80,
11010 "alias-routed modeled usage did not reuse the session baseline witness",
11011 )?;
11012 require(
11013 overview.repeated_baselines_deduped == 1,
11014 "alias-routed modeled usage did not classify the repeated baseline",
11015 )?;
11016 require(
11017 control.telemetry_retention_state()?.active_instance_rows == 1,
11018 "alias-routed usage did not retain one bounded session identity",
11019 )?;
11020 server.seal_usage_instances_for_projects();
11021 require(
11022 control.telemetry_retention_state()?.active_instance_rows == 0,
11023 "alias-routed session identity was not sealed at MCP shutdown",
11024 )
11025 }
11026
11027 #[test]
11028 fn mcp_telemetry_busy_project_does_not_block_another_project()
11029 -> Result<(), Box<dyn std::error::Error>> {
11030 if telemetry_disabled() {
11031 return Ok(());
11032 }
11033 let server = ProjectAtlasMcpServer::new(
11034 PathBuf::from("startup.db"),
11035 None,
11036 "shared-label".to_string(),
11037 false,
11038 );
11039 let temp = tempfile::tempdir()?;
11040 let (state_a, store_a) = usage_test_project(temp.path(), "project-a")?;
11041 let (state_b, store_b) = usage_test_project(temp.path(), "project-b")?;
11042 let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1);
11043 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
11044 let server_a = server.clone();
11045 let a_handle = std::thread::spawn(move || -> Result<(), String> {
11046 server_a.record_usage_for_state(&state_a, &store_a, |_usage_instance| {
11047 entered_tx.send(()).map_err(|error| {
11048 CliError::InvalidInput(format!("test coordination failed: {error}"))
11049 })?;
11050 release_rx
11051 .recv_timeout(Duration::from_secs(5))
11052 .map_err(|error| {
11053 CliError::InvalidInput(format!("test coordination failed: {error}"))
11054 })?;
11055 Ok(())
11056 });
11057 Ok(())
11058 });
11059 entered_rx.recv_timeout(Duration::from_secs(2))?;
11060
11061 let (done_tx, done_rx) = std::sync::mpsc::sync_channel(1);
11062 let server_b = server;
11063 let b_handle = std::thread::spawn(move || -> Result<(), String> {
11064 server_b.record_usage_for_state(&state_b, &store_b, |_usage_instance| {
11065 done_tx.send(()).map_err(|error| {
11066 CliError::InvalidInput(format!("test coordination failed: {error}"))
11067 })?;
11068 Ok(())
11069 });
11070 Ok(())
11071 });
11072 let project_b_completed = done_rx.recv_timeout(Duration::from_secs(1)).is_ok();
11073 release_tx.send(())?;
11074 a_handle
11075 .join()
11076 .map_err(|_panic| io::Error::other("project A telemetry thread panicked"))?
11077 .map_err(io::Error::other)?;
11078 b_handle
11079 .join()
11080 .map_err(|_panic| io::Error::other("project B telemetry thread panicked"))?
11081 .map_err(io::Error::other)?;
11082
11083 require(
11084 project_b_completed,
11085 "one project's blocked telemetry delayed another project",
11086 )
11087 }
11088
11089 #[test]
11090 fn mcp_telemetry_keeps_identity_when_capacity_seal_fails()
11091 -> Result<(), Box<dyn std::error::Error>> {
11092 if telemetry_disabled() {
11093 return Ok(());
11094 }
11095 let temp = tempfile::tempdir()?;
11096 let (state, store) = usage_test_project(temp.path(), "selected-project")?;
11097 let server = ProjectAtlasMcpServer::new(
11098 state.db_path.clone(),
11099 None,
11100 "shared-label".to_string(),
11101 false,
11102 );
11103 server.record_usage_for_state(&state, &store, |usage_instance| {
11104 record_usage_estimate(
11105 &store,
11106 Some(usage_instance),
11107 "seal-failure-test",
11108 MCP_EVENT_ATLAS_OVERVIEW,
11109 None,
11110 None,
11111 8,
11112 "overview:\n files: 1\n",
11113 )
11114 });
11115 let initial_identity = usage_runtime_identity(&server, &state, &store)?;
11116 let busy_connection = rusqlite::Connection::open(&state.db_path)?;
11117 busy_connection.execute_batch("BEGIN IMMEDIATE")?;
11118 let mut calls = 0usize;
11119
11120 server.record_usage_for_state(&state, &store, |_usage_instance| {
11121 calls += 1;
11122 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
11123 });
11124 busy_connection.execute_batch("ROLLBACK")?;
11125
11126 require(calls == 1, "failed sealing unexpectedly retried the event")?;
11127 require(
11128 usage_runtime_identity(&server, &state, &store)? == initial_identity,
11129 "failed sealing replaced the still-active project identity",
11130 )
11131 }
11132
11133 #[test]
11134 fn mcp_telemetry_rotates_and_retries_once_when_baselines_reach_capacity()
11135 -> Result<(), Box<dyn std::error::Error>> {
11136 if telemetry_disabled() {
11137 return Ok(());
11138 }
11139 let temp = tempfile::tempdir()?;
11140 let root = temp.path().join("selected-project");
11141 fs::create_dir_all(root.join(".projectatlas"))?;
11142 let db_path = root.join(".projectatlas").join("projectatlas.db");
11143 let store = AtlasStore::open_for_project(&db_path, &root)?;
11144 let state = McpProjectState {
11145 root,
11146 db_path: db_path.clone(),
11147 config_path: None,
11148 worktree: None,
11149 };
11150 let other_root = temp.path().join("other-project");
11151 fs::create_dir_all(other_root.join(".projectatlas"))?;
11152 let other_db_path = other_root.join(".projectatlas").join("projectatlas.db");
11153 let other_store = AtlasStore::open_for_project(&other_db_path, &other_root)?;
11154 let other_state = McpProjectState {
11155 root: other_root,
11156 db_path: other_db_path,
11157 config_path: None,
11158 worktree: None,
11159 };
11160 let server = ProjectAtlasMcpServer::new(db_path, None, "shared-label".to_string(), false);
11161 server.record_usage_for_state(&state, &store, |usage_instance| {
11162 record_usage_estimate(
11163 &store,
11164 Some(usage_instance),
11165 "rotation-test",
11166 MCP_EVENT_ATLAS_OVERVIEW,
11167 None,
11168 None,
11169 8,
11170 "overview:\n files: 1\n",
11171 )
11172 });
11173 server.record_usage_for_state(&other_state, &other_store, |_usage_instance| Ok(()));
11174 let initial_identity = usage_runtime_identity(&server, &state, &store)?;
11175 let other_identity = usage_runtime_identity(&server, &other_state, &other_store)?;
11176 let mut calls = 0usize;
11177
11178 server.record_usage_for_state(&state, &store, |_usage_instance| {
11179 calls += 1;
11180 if calls == 1 {
11181 Err(CliError::Db(DbError::TelemetryBaselineCapacity))
11182 } else {
11183 Ok(())
11184 }
11185 });
11186
11187 let tracked = server
11188 .usage_runtime
11189 .lock()
11190 .map_err(|_poisoned| io::Error::other("usage runtime lock poisoned"))?
11191 .entries
11192 .len();
11193 let rotated_identity = usage_runtime_identity(&server, &state, &store)?;
11194 require(calls == 2, "capacity handling did not retry exactly once")?;
11195 require(
11196 rotated_identity != initial_identity,
11197 "capacity handling did not rotate the bounded runtime identity",
11198 )?;
11199 require(
11200 tracked == 2,
11201 "rotation changed the bounded project binding inventory",
11202 )?;
11203 require(
11204 usage_runtime_identity(&server, &other_state, &other_store)? == other_identity,
11205 "one project's capacity rotation changed another project's identity",
11206 )
11207 }
11208
11209 #[test]
11210 fn navigation_result_survives_telemetry_write_failure() -> Result<(), Box<dyn std::error::Error>>
11211 {
11212 if telemetry_disabled() {
11213 return Ok(());
11214 }
11215 let temp = tempfile::tempdir()?;
11216 let repo = temp.path().join("repo");
11217 fs::create_dir_all(repo.join("src"))?;
11218 fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
11219 let config_path = repo.join(".projectatlas").join("config.toml");
11220 init_project_with_config(&repo, Some(&config_path))?;
11221 let db_path = repo.join(".projectatlas").join("projectatlas.db");
11222 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
11223 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
11224 run_scan_pipeline(
11225 &mut store,
11226 &plan,
11227 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
11228 )?;
11229 drop(store);
11230
11231 let connection = rusqlite::Connection::open(&db_path)?;
11232 connection.execute_batch("BEGIN IMMEDIATE")?;
11233
11234 let server = ProjectAtlasMcpServer::new(
11235 db_path,
11236 Some(config_path),
11237 "shared-label".to_string(),
11238 false,
11239 );
11240 let result = server.atlas_overview_response(
11241 AtlasProjectParams {
11242 project_path: None,
11243 worktree: None,
11244 },
11245 None,
11246 );
11247 connection.execute_batch("ROLLBACK")?;
11248
11249 if result.contains("overview:") && result.contains("files:") {
11250 Ok(())
11251 } else {
11252 Err(io::Error::other(format!(
11253 "telemetry failure replaced an already-built navigation result: {result}"
11254 ))
11255 .into())
11256 }
11257 }
11258
11259 #[test]
11260 fn mcp_calls_share_server_identity_and_new_server_uses_another()
11261 -> Result<(), Box<dyn std::error::Error>> {
11262 if telemetry_disabled() {
11263 return Ok(());
11264 }
11265 let temp = tempfile::tempdir()?;
11266 let repo = temp.path().join("repo");
11267 fs::create_dir_all(repo.join("src"))?;
11268 fs::write(repo.join("src").join("lib.rs"), "pub fn owner() {}\n")?;
11269 let config_path = repo.join(".projectatlas").join("config.toml");
11270 init_project_with_config(&repo, Some(&config_path))?;
11271 let db_path = repo.join(".projectatlas").join("projectatlas.db");
11272 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
11273 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
11274 run_scan_pipeline(
11275 &mut store,
11276 &plan,
11277 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
11278 )?;
11279 drop(store);
11280
11281 let call_overview = |server: &ProjectAtlasMcpServer| {
11282 server.atlas_overview_response(
11283 AtlasProjectParams {
11284 project_path: None,
11285 worktree: None,
11286 },
11287 None,
11288 )
11289 };
11290 let first = ProjectAtlasMcpServer::new(
11291 db_path.clone(),
11292 Some(config_path.clone()),
11293 "shared-label".to_string(),
11294 false,
11295 );
11296 require(
11297 call_overview(&first).contains("overview:"),
11298 "first MCP call failed",
11299 )?;
11300 require(
11301 call_overview(&first).contains("overview:"),
11302 "second MCP call failed",
11303 )?;
11304 let restarted = ProjectAtlasMcpServer::new(
11305 db_path.clone(),
11306 Some(config_path),
11307 "shared-label".to_string(),
11308 false,
11309 );
11310 require(
11311 call_overview(&restarted).contains("overview:"),
11312 "restarted MCP call failed",
11313 )?;
11314
11315 let connection = rusqlite::Connection::open(db_path)?;
11316 let instances: i64 = connection.query_row(
11317 "SELECT COUNT(*) FROM usage_instances WHERE owner = 'mcp_process' AND caller_label = 'shared-label'",
11318 [],
11319 |row| row.get(0),
11320 )?;
11321 let events: i64 =
11322 connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
11323 require(
11324 instances == 2 && events == 3,
11325 "MCP calls did not reuse one identity per server construction",
11326 )?;
11327 drop(connection);
11328 drop(restarted);
11329 drop(first);
11330 fs::remove_dir_all(&repo)?;
11331 require(!repo.exists(), "MCP teardown left its repository in use")
11332 }
11333
11334 #[test]
11335 fn mcp_database_filesystem_failures_are_typed_and_actionable()
11336 -> Result<(), Box<dyn std::error::Error>> {
11337 let error = CliError::Db(projectatlas_db::DbError::DatabaseFilesystemUnsupported {
11338 path: PathBuf::from("project")
11339 .join(".projectatlas")
11340 .join("projectatlas.db"),
11341 mount_point: Some(PathBuf::from("project")),
11342 filesystem_type: Some("nfs".to_string()),
11343 });
11344 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11345 require(
11346 payload.contains("kind: database_filesystem_unsupported")
11347 && payload.contains("filesystem_type: nfs")
11348 && payload.contains("supported local filesystem"),
11349 "MCP TOON lost typed filesystem details or recovery guidance",
11350 )
11351 }
11352
11353 #[test]
11354 fn mcp_project_mismatch_preserves_lossless_store_roots()
11355 -> Result<(), Box<dyn std::error::Error>> {
11356 let temp = tempfile::tempdir()?;
11357 let selected_root = temp.path().join("selected-root");
11358 let indexed_root = temp.path().join("indexed-root");
11359 fs::create_dir_all(&selected_root)?;
11360 fs::create_dir_all(&indexed_root)?;
11361 let database = indexed_root.join(".projectatlas").join("projectatlas.db");
11362 fs::create_dir_all(
11363 database
11364 .parent()
11365 .ok_or_else(|| io::Error::other("indexed database path has no parent"))?,
11366 )?;
11367 drop(AtlasStore::open_for_project(&database, &indexed_root)?);
11368
11369 let Err(error) = open_atlas_store_for_project(&database, &selected_root) else {
11370 return Err(io::Error::other("wrong-root store open unexpectedly succeeded").into());
11371 };
11372 let selected_display = CanonicalProjectRoot::from_path(&selected_root)?.display_string()?;
11373 let indexed_display = CanonicalProjectRoot::from_path(&indexed_root)?.display_string()?;
11374 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11375 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11376 require(
11377 value.pointer("/error/project_mismatch/selected_project_root")
11378 == Some(&serde_json::Value::String(selected_display.clone()))
11379 && value.pointer("/error/project_mismatch/indexed_project_root")
11380 == Some(&serde_json::Value::String(indexed_display.clone())),
11381 "MCP omitted lossless roots from a store mismatch",
11382 )?;
11383 require(
11384 payload.contains(&selected_display) && payload.contains(&indexed_display),
11385 "MCP TOON did not retain lossless store roots",
11386 )
11387 }
11388
11389 #[cfg(unix)]
11390 #[test]
11391 fn mcp_project_mismatch_keeps_native_display_unavailable_typed()
11392 -> Result<(), Box<dyn std::error::Error>> {
11393 let temp = tempfile::tempdir()?;
11394 let raw_root = temp
11395 .path()
11396 .join(std::ffi::OsString::from_vec(b"raw-root-\x80".to_vec()));
11397 let replacement_root = temp.path().join("raw-root-�");
11398 fs::create_dir(&raw_root)?;
11399 fs::create_dir(&replacement_root)?;
11400 let raw_identity = CanonicalProjectRoot::from_path(&raw_root)?;
11401 let replacement_identity = CanonicalProjectRoot::from_path(&replacement_root)?;
11402 let error = CliError::ProjectMismatch(Box::new(IndexProjectMismatch::from_native_roots(
11403 &raw_identity,
11404 &replacement_identity,
11405 )));
11406 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11407 require(
11408 payload.contains("selected_project_root: null")
11409 && payload.contains("indexed_project_root:")
11410 && payload.contains("raw-root-�"),
11411 "MCP promoted a lossy native root into an ambiguous structured value",
11412 )?;
11413
11414 let mapped = crate::runtime::project_store_error(DbError::ProjectRootMismatch {
11415 expected: raw_root.to_string_lossy().into_owned(),
11416 found: replacement_root.to_string_lossy().into_owned(),
11417 identities: None,
11418 });
11419 let mapped_payload = ProjectAtlasMcpServer::encode_error_payload(&mapped);
11420 require(
11421 mapped_payload.contains("selected_project_root: null")
11422 && mapped_payload.contains("indexed_project_root: null")
11423 && mapped_payload.contains("does not match"),
11424 "MCP promoted lossy store mismatch text into structured roots",
11425 )
11426 }
11427
11428 #[cfg(unix)]
11429 #[test]
11430 fn mcp_recovery_and_session_reports_omit_unavailable_native_root_selectors()
11431 -> Result<(), Box<dyn std::error::Error>> {
11432 let temp = tempfile::tempdir()?;
11433 let raw_root = temp
11434 .path()
11435 .join(std::ffi::OsString::from_vec(b"repo-\x80".to_vec()));
11436 let replacement_root = temp.path().join("repo-�");
11437 fs::create_dir(&raw_root)?;
11438 fs::create_dir(&replacement_root)?;
11439 let raw_db = raw_root.join(".projectatlas").join("projectatlas.db");
11440 let replacement_db = replacement_root
11441 .join(".projectatlas")
11442 .join("projectatlas.db");
11443 let replacement_display = lossless_project_root_display(&replacement_root)
11444 .ok_or_else(|| io::Error::other("replacement root lost its UTF-8 display"))?;
11445
11446 let init_errors = [
11447 (
11448 crate::runtime::index_init_required(&raw_root, &raw_db),
11449 false,
11450 ),
11451 (
11452 crate::runtime::index_init_required(&replacement_root, &replacement_db),
11453 true,
11454 ),
11455 ];
11456 for (error, displayable) in init_errors {
11457 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11458 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11459 if displayable {
11460 let expected = serde_json::Value::String(replacement_display.clone());
11461 require(
11462 value.pointer("/error/init_required/project_root") == Some(&expected)
11463 && value.pointer("/error/next/project_path") == Some(&expected),
11464 "MCP init recovery lost a displayable root selector",
11465 )?;
11466 } else {
11467 require(
11468 value
11469 .pointer("/error/init_required/project_root")
11470 .is_some_and(serde_json::Value::is_null)
11471 && value.pointer("/error/next/project_path").is_none()
11472 && !payload.contains("repo-�"),
11473 "MCP init recovery exposed a lossy raw-root selector",
11474 )?;
11475 }
11476 }
11477
11478 let refresh_errors = [
11479 (
11480 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
11481 project_root: lossless_project_root_display(&raw_root),
11482 worktree: None,
11483 status: IndexReadStatus::RefreshRequired,
11484 reason: IndexRefreshReason::SourceChanged,
11485 scope: IndexRefreshScope::Incremental,
11486 changed: 1,
11487 added: 0,
11488 removed: 0,
11489 modified: 1,
11490 sample_paths: vec!["src/lib.rs".to_string()],
11491 })),
11492 false,
11493 ),
11494 (
11495 CliError::RefreshRequired(Box::new(IndexRefreshRequired {
11496 project_root: lossless_project_root_display(&replacement_root),
11497 worktree: None,
11498 status: IndexReadStatus::RefreshRequired,
11499 reason: IndexRefreshReason::SourceChanged,
11500 scope: IndexRefreshScope::Incremental,
11501 changed: 1,
11502 added: 0,
11503 removed: 0,
11504 modified: 1,
11505 sample_paths: vec!["src/lib.rs".to_string()],
11506 })),
11507 true,
11508 ),
11509 ];
11510 for (error, displayable) in refresh_errors {
11511 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11512 let value: serde_json::Value = toon_format::decode_default(&payload)?;
11513 if displayable {
11514 let expected = serde_json::Value::String(replacement_display.clone());
11515 require(
11516 value.pointer("/error/refresh_required/project_root") == Some(&expected)
11517 && value.pointer("/error/next/project_path") == Some(&expected),
11518 "MCP refresh recovery lost a displayable root selector",
11519 )?;
11520 } else {
11521 require(
11522 value
11523 .pointer("/error/refresh_required/project_root")
11524 .is_some_and(serde_json::Value::is_null)
11525 && value.pointer("/error/next/project_path").is_none()
11526 && !payload.contains("repo-�"),
11527 "MCP refresh recovery exposed a lossy raw-root selector",
11528 )?;
11529 }
11530 }
11531
11532 let raw_server = ProjectAtlasMcpServer::new(raw_db, None, "raw-session".to_string(), false);
11533 let raw_project = serde_json::to_value(ProjectAtlasMcpServer::project_state_payload(
11534 &raw_server.control_state,
11535 ))?;
11536 let raw_capability = serde_json::to_value(
11537 ProjectAtlasMcpServer::selected_project_capability(&raw_server.control_state),
11538 )?;
11539 require(
11540 raw_project.get("root") == Some(&serde_json::Value::Null)
11541 && raw_project.get("db") == Some(&serde_json::Value::Null)
11542 && raw_capability.get("root") == Some(&serde_json::Value::Null)
11543 && raw_capability.get("db") == Some(&serde_json::Value::Null)
11544 && !raw_project.to_string().contains("repo-�")
11545 && !raw_capability.to_string().contains("repo-�"),
11546 "MCP selected-project reports exposed a lossy raw-root projection",
11547 )?;
11548 let raw_brief = raw_server.build_session_brief(
11549 AtlasSessionBriefParams {
11550 project_path: None,
11551 worktree: None,
11552 query: None,
11553 purpose_task: None,
11554 compact: None,
11555 folder_limit: None,
11556 file_limit: None,
11557 blocker_limit: None,
11558 purpose_limit: None,
11559 },
11560 None,
11561 )?;
11562 let raw_value = serde_json::to_value(&raw_brief)?;
11563 require(
11564 raw_value.pointer("/project/root") == Some(&serde_json::Value::Null)
11565 && raw_value
11566 .pointer("/recommendations/0/arguments/project_path")
11567 .is_none()
11568 && raw_value
11569 .pointer("/recommendations/0/arguments/worktree")
11570 .is_none()
11571 && !raw_value.to_string().contains("repo-�"),
11572 "MCP session brief offered a lossy sibling selector for a raw root",
11573 )?;
11574
11575 let replacement_server = ProjectAtlasMcpServer::new(
11576 replacement_db,
11577 None,
11578 "replacement-session".to_string(),
11579 false,
11580 );
11581 let replacement_project = serde_json::to_value(
11582 ProjectAtlasMcpServer::project_state_payload(&replacement_server.control_state),
11583 )?;
11584 let replacement_capability = serde_json::to_value(
11585 ProjectAtlasMcpServer::selected_project_capability(&replacement_server.control_state),
11586 )?;
11587 require(
11588 replacement_project
11589 .get("root")
11590 .and_then(|value| value.as_str())
11591 == Some(replacement_display.as_str())
11592 && replacement_capability
11593 .get("root")
11594 .and_then(|value| value.as_str())
11595 == Some(replacement_display.as_str()),
11596 "MCP selected-project reports lost the displayable root",
11597 )?;
11598 let replacement_brief = replacement_server.build_session_brief(
11599 AtlasSessionBriefParams {
11600 project_path: None,
11601 worktree: None,
11602 query: None,
11603 purpose_task: None,
11604 compact: None,
11605 folder_limit: None,
11606 file_limit: None,
11607 blocker_limit: None,
11608 purpose_limit: None,
11609 },
11610 None,
11611 )?;
11612 let replacement_value = serde_json::to_value(&replacement_brief)?;
11613 require(
11614 replacement_value
11615 .pointer("/project/root")
11616 .and_then(|value| value.as_str())
11617 == Some(replacement_display.as_str())
11618 && replacement_value
11619 .pointer("/recommendations/0/arguments/project_path")
11620 .and_then(|value| value.as_str())
11621 == Some(replacement_display.as_str()),
11622 "MCP session brief lost the displayable root recovery selector",
11623 )?;
11624 require(
11625 !raw_root.join(".projectatlas").exists()
11626 && !replacement_root.join(".projectatlas").exists(),
11627 "MCP missing-index recovery mutated a project root",
11628 )
11629 }
11630
11631 #[test]
11632 fn mcp_schema_version_mismatches_are_typed_and_content_free()
11633 -> Result<(), Box<dyn std::error::Error>> {
11634 let supported = projectatlas_db::CURRENT_SCHEMA_VERSION;
11635 let future = supported + 1;
11636 let error = CliError::Db(DbError::SchemaVersion {
11637 found: future,
11638 expected: supported,
11639 });
11640 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11641 require(
11642 payload.contains("kind: schema_version_mismatch")
11643 && payload.contains(&format!("found_schema_version: {future}"))
11644 && payload.contains(&format!("supported_schema_version: {supported}"))
11645 && payload.contains(env!("CARGO_PKG_VERSION"))
11646 && payload.contains("do not reset")
11647 && !payload.contains(".projectatlas")
11648 && !payload.contains("session_id")
11649 && !payload.contains("project_root"),
11650 "MCP TOON lost typed schema-version details or exposed database context",
11651 )?;
11652
11653 let predecessor = CliError::Service(ServiceError::Db(DbError::SchemaVersion {
11654 found: 8,
11655 expected: supported,
11656 }));
11657 let McpToolTextResult(predecessor_result) =
11658 ProjectAtlasMcpServer::as_mcp_text(Err(predecessor));
11659 let predecessor_payload = predecessor_result.map_err(std::io::Error::other)?;
11660 require(
11661 predecessor_payload.contains("kind: schema_migration_required")
11662 && predecessor_payload.contains("found_schema_version: 8")
11663 && predecessor_payload.contains(&format!("supported_schema_version: {supported}"))
11664 && predecessor_payload
11665 .contains(&format!("migration_steps_remaining: {}", supported - 8))
11666 && predecessor_payload.contains("projectatlas init")
11667 && predecessor_payload.contains("atlas_init")
11668 && predecessor_payload.contains("same global `--db`/`--config` selection")
11669 && predecessor_payload.contains("same MCP server/database binding")
11670 && !predecessor_payload.contains("schema_version_mismatch")
11671 && !predecessor_payload.contains(crate::SCHEMA_VERSION_MISMATCH_RECOVERY),
11672 "MCP omitted the supported-predecessor migration handoff",
11673 )
11674 }
11675
11676 #[test]
11677 fn mcp_search_capability_failures_are_typed_and_actionable()
11678 -> Result<(), Box<dyn std::error::Error>> {
11679 let error = CliError::Service(ServiceError::SearchCapabilityUnavailable {
11680 requested_mode: projectatlas_service::SearchRetrievalMode::Hybrid,
11681 state: "not-installed",
11682 guidance: "install and build a compatible semantic generation",
11683 });
11684 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
11685 require(
11686 payload.contains("kind: search_capability_unavailable")
11687 && payload.contains("requested_mode")
11688 && payload.contains("hybrid")
11689 && payload.contains("state")
11690 && payload.contains("not-installed")
11691 && payload.contains("compatible semantic generation"),
11692 "MCP TOON lost typed search-capability state or recovery guidance",
11693 )
11694 }
11695
11696 #[test]
11697 fn mcp_records_usage_only_for_the_accepted_verified_attempt()
11698 -> Result<(), Box<dyn std::error::Error>> {
11699 if telemetry_disabled() {
11700 return Ok(());
11701 }
11702 let temp = tempfile::tempdir()?;
11703 let repo = temp.path().join("repo");
11704 let source = repo.join("src").join("lib.rs");
11705 fs::create_dir_all(
11706 source
11707 .parent()
11708 .ok_or_else(|| io::Error::other("missing parent"))?,
11709 )?;
11710 let original = "pub fn original() {}\n";
11711 let revised = "pub fn revised() {}\n";
11712 fs::write(&source, original)?;
11713 let config_path = repo.join(".projectatlas").join("config.toml");
11714 init_project_with_config(&repo, Some(&config_path))?;
11715 let db_path = repo.join(".projectatlas").join("projectatlas.db");
11716 let plan = ScanRuntimePlan::for_path(Some(&config_path), &repo, None)?;
11717 let mut store = open_atlas_store_for_project(&db_path, &repo)?;
11718 run_scan_pipeline(
11719 &mut store,
11720 &plan,
11721 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
11722 )?;
11723 drop(store);
11724 let state = McpProjectState {
11725 root: repo.clone(),
11726 db_path: db_path.clone(),
11727 config_path: Some(config_path.clone()),
11728 worktree: None,
11729 };
11730 let server = ProjectAtlasMcpServer::new(
11731 db_path.clone(),
11732 Some(config_path.clone()),
11733 "accepted-attempt".to_string(),
11734 false,
11735 );
11736 let mut attempts = 0_u64;
11737
11738 let response =
11739 server.with_fresh_string_and_usage_for_request(&state, None, |store, _stamp| {
11740 attempts = attempts.saturating_add(1);
11741 let hash = store
11742 .load_node_by_path("src/lib.rs")?
11743 .and_then(|node| node.node.content_hash)
11744 .ok_or_else(|| CliError::InvalidInput("source hash missing".to_string()))?;
11745 if attempts == 1 {
11746 fs::write(&source, revised).map_err(|source_error| CliError::Io {
11747 path: source.clone(),
11748 source: source_error,
11749 })?;
11750 server.source_observations.inject_test_event(
11751 &db_path,
11752 &repo,
11753 Some(&config_path),
11754 Event::new(EventKind::Modify(ModifyKind::Any)).add_path(source.clone()),
11755 )?;
11756 }
11757 Ok((
11758 hash,
11759 Some(McpUsageIntent::estimate(
11760 MCP_EVENT_ATLAS_OVERVIEW,
11761 None,
11762 None,
11763 1,
11764 )),
11765 ))
11766 })?;
11767
11768 require(
11769 attempts >= 2,
11770 "mid-query source edit did not retry the query",
11771 )?;
11772 require(
11773 response == blake3::hash(revised.as_bytes()).to_hex().to_string(),
11774 "MCP returned the provisional pre-edit result",
11775 )?;
11776 let connection = rusqlite::Connection::open(db_path)?;
11777 let events: i64 =
11778 connection.query_row("SELECT COUNT(*) FROM usage_events", [], |row| row.get(0))?;
11779 require(
11780 events == 1,
11781 "MCP telemetry recorded a discarded provisional attempt",
11782 )
11783 }
11784
11785 fn wait_for_background_task(
11787 server: &ProjectAtlasMcpServer,
11788 task_id: &str,
11789 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
11790 for _attempt in 0..5_000 {
11791 let status = server.task_status(task_id.to_string())?;
11792 if let Some(record) = status.task.filter(McpTaskRecord::is_terminal_state) {
11793 return Ok(record);
11794 }
11795 thread::sleep(Duration::from_millis(1));
11796 }
11797 Err(io::Error::other("background task did not reach a terminal state").into())
11798 }
11799
11800 fn run_successful_background_task(
11802 server: &ProjectAtlasMcpServer,
11803 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
11804 let task = server.start_index_task(
11805 McpTaskOperation::Scan,
11806 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
11807 MCP_TOOL_ATLAS_OVERVIEW,
11808 |_control, _options| Ok(()),
11809 )?;
11810 wait_for_background_task(server, &task.task_id)
11811 }
11812
11813 fn wait_for_background_operation(
11815 server: &ProjectAtlasMcpServer,
11816 operation: &McpTaskOperation,
11817 ) -> Result<McpTaskRecord, Box<dyn std::error::Error>> {
11818 let task_id = server
11819 .task_registry
11820 .read()
11821 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
11822 .latest_task_id(operation)
11823 .ok_or_else(|| io::Error::other("background task was not admitted"))?;
11824 wait_for_background_task(server, &task_id)
11825 }
11826
11827 fn require_agent_index_reads(
11829 server: &ProjectAtlasMcpServer,
11830 project_path: &str,
11831 expected_symbol: &str,
11832 ) -> Result<(), Box<dyn std::error::Error>> {
11833 let overview = server.atlas_overview_response(
11834 AtlasProjectParams {
11835 project_path: Some(project_path.to_string()),
11836 worktree: None,
11837 },
11838 None,
11839 );
11840 require(
11841 overview.contains("overview:"),
11842 "agent overview did not read the published index",
11843 )?;
11844
11845 let summary = server.atlas_file_summary_response(
11846 &AtlasFileSummaryParams {
11847 project_path: Some(project_path.to_string()),
11848 worktree: None,
11849 file: "src/lib.rs".to_string(),
11850 nearest_project: Some(false),
11851 compact: None,
11852 content_selection: None,
11853 limit: Some(25),
11854 },
11855 None,
11856 );
11857 require(
11858 summary.contains("file_summary:")
11859 && summary.contains("src/lib.rs")
11860 && summary.contains(expected_symbol),
11861 "agent file summary omitted published source facts",
11862 )?;
11863
11864 let symbols = server.atlas_symbols_response(
11865 &AtlasSymbolsParams {
11866 project_path: Some(project_path.to_string()),
11867 worktree: None,
11868 file: Some("src/lib.rs".to_string()),
11869 nearest_project: Some(false),
11870 query: None,
11871 content_selection: None,
11872 limit: Some(50),
11873 },
11874 None,
11875 );
11876 require(
11877 symbols.contains("symbols[") && symbols.contains(expected_symbol),
11878 "agent symbol read omitted published parser output",
11879 )?;
11880
11881 let relations = server.atlas_symbol_relations_response(
11882 &AtlasSymbolRelationsParams {
11883 project_path: Some(project_path.to_string()),
11884 file: Some("src/lib.rs".to_string()),
11885 nearest_project: Some(false),
11886 query: None,
11887 limit: Some(50),
11888 ..AtlasSymbolRelationsParams::default()
11889 },
11890 None,
11891 );
11892 require(
11893 relations.contains("relations[") && relations.contains(expected_symbol),
11894 "agent relation read omitted published graph output",
11895 )?;
11896 let explicit_legacy = server.atlas_symbol_relations_response(
11897 &AtlasSymbolRelationsParams {
11898 project_path: Some(project_path.to_string()),
11899 file: Some("src/lib.rs".to_string()),
11900 nearest_project: Some(false),
11901 view: Some("legacy".to_string()),
11902 query: None,
11903 limit: Some(50),
11904 ..AtlasSymbolRelationsParams::default()
11905 },
11906 None,
11907 );
11908 require(
11909 relations == explicit_legacy,
11910 "explicit MCP legacy relation view changed default response bytes or ordering",
11911 )?;
11912 let compact_legacy = server.atlas_symbol_relations_response(
11913 &AtlasSymbolRelationsParams {
11914 project_path: Some(project_path.to_string()),
11915 file: Some("src/lib.rs".to_string()),
11916 nearest_project: Some(false),
11917 compact: Some(true),
11918 limit: Some(50),
11919 ..AtlasSymbolRelationsParams::default()
11920 },
11921 None,
11922 );
11923 require(
11924 compact_legacy.contains(MCP_ERROR_COMPACT_DETAILED_RELATION_VIEW),
11925 "compact relation projection did not reject the legacy view",
11926 )?;
11927 let zero_limit_legacy = server.atlas_symbol_relations_response(
11928 &AtlasSymbolRelationsParams {
11929 project_path: Some(project_path.to_string()),
11930 file: Some("src/lib.rs".to_string()),
11931 nearest_project: Some(false),
11932 limit: Some(0),
11933 ..AtlasSymbolRelationsParams::default()
11934 },
11935 None,
11936 );
11937 require(
11938 zero_limit_legacy.contains("relations[1]"),
11939 "MCP legacy zero limit no longer preserves its one-row compatibility behavior",
11940 )?;
11941 let detailed = server.atlas_symbol_relations_response(
11942 &AtlasSymbolRelationsParams {
11943 project_path: Some(project_path.to_string()),
11944 file: Some("src/lib.rs".to_string()),
11945 nearest_project: Some(false),
11946 view: Some("detailed".to_string()),
11947 direction: Some("outbound".to_string()),
11948 limit: Some(50),
11949 ..AtlasSymbolRelationsParams::default()
11950 },
11951 None,
11952 );
11953 require(
11954 detailed.contains("symbol_relations:") && detailed.contains("anchor:"),
11955 "detailed MCP relation route did not return the bounded graph envelope",
11956 )?;
11957 if expected_symbol == "third" {
11958 let compact_detailed = server.atlas_symbol_relations_response(
11959 &AtlasSymbolRelationsParams {
11960 project_path: Some(project_path.to_string()),
11961 file: Some("src/lib.rs".to_string()),
11962 nearest_project: Some(false),
11963 view: Some("detailed".to_string()),
11964 compact: Some(true),
11965 symbol: Some("first".to_string()),
11966 direction: Some("outbound".to_string()),
11967 include_occurrences: Some(true),
11968 limit: Some(1),
11969 output_bytes: Some(8 * 1_024),
11970 ..AtlasSymbolRelationsParams::default()
11971 },
11972 None,
11973 );
11974 require(
11975 compact_detailed.len() <= 8 * 1_024
11976 && compact_detailed.contains("returned: 1")
11977 && compact_detailed.contains("status: resolved")
11978 && compact_detailed.contains("confidence: exact")
11979 && compact_detailed.contains("completeness: complete")
11980 && compact_detailed.contains("Own café λ relation navigation")
11981 && compact_detailed.contains("next_call:")
11982 && compact_detailed.contains("occurrences[1]:")
11983 && !compact_detailed.contains("occurrences[1]:\n - relation:"),
11984 "compact detailed relation omitted trust, purpose, occurrence, next-call, or bounded-output behavior",
11985 )?;
11986 let first_detailed_page = server.atlas_symbol_relations_response(
11987 &AtlasSymbolRelationsParams {
11988 project_path: Some(project_path.to_string()),
11989 file: Some("src/lib.rs".to_string()),
11990 nearest_project: Some(false),
11991 view: Some("detailed".to_string()),
11992 symbol: Some("first".to_string()),
11993 direction: Some("outbound".to_string()),
11994 depth: Some(2),
11995 limit: Some(1),
11996 output_bytes: Some(64 * 1024),
11997 ..AtlasSymbolRelationsParams::default()
11998 },
11999 None,
12000 );
12001 let first_detailed_value: serde_json::Value =
12002 toon_format::decode_default(&first_detailed_page)?;
12003 let first_detailed_report = first_detailed_value
12004 .get("symbol_relations")
12005 .ok_or_else(|| io::Error::other("first detailed MCP page omitted its envelope"))?;
12006 let continuation = first_detailed_report
12007 .get("continuation")
12008 .and_then(serde_json::Value::as_str)
12009 .ok_or_else(|| io::Error::other("first detailed MCP page omitted its cursor"))?;
12010 require(
12011 first_detailed_report
12012 .get("returned")
12013 .and_then(serde_json::Value::as_u64)
12014 == Some(1)
12015 && first_detailed_report
12016 .get("rows")
12017 .and_then(serde_json::Value::as_array)
12018 .is_some_and(|rows| rows.len() == 1),
12019 "first detailed MCP page was not a nonempty bounded symbol result",
12020 )?;
12021 let second_detailed_page = server.atlas_symbol_relations_response(
12022 &AtlasSymbolRelationsParams {
12023 project_path: Some(project_path.to_string()),
12024 file: Some("src/lib.rs".to_string()),
12025 nearest_project: Some(false),
12026 view: Some("detailed".to_string()),
12027 cursor: Some(continuation.to_string()),
12028 symbol: Some("first".to_string()),
12029 direction: Some("outbound".to_string()),
12030 depth: Some(2),
12031 limit: Some(1),
12032 output_bytes: Some(64 * 1024),
12033 ..AtlasSymbolRelationsParams::default()
12034 },
12035 None,
12036 );
12037 let second_detailed_value: serde_json::Value =
12038 toon_format::decode_default(&second_detailed_page)?;
12039 let second_detailed_report = second_detailed_value
12040 .get("symbol_relations")
12041 .ok_or_else(|| io::Error::other("second detailed MCP page omitted its envelope"))?;
12042 require(
12043 second_detailed_report
12044 .get("returned")
12045 .and_then(serde_json::Value::as_u64)
12046 == Some(1)
12047 && second_detailed_report.get("rows") != first_detailed_report.get("rows")
12048 && second_detailed_page.contains("Own café λ relation navigation"),
12049 "detailed MCP cursor did not resume a distinct Unicode-safe symbol row",
12050 )?;
12051 let compact_continuation_page = server.atlas_symbol_relations_response(
12052 &AtlasSymbolRelationsParams {
12053 project_path: Some(project_path.to_string()),
12054 file: Some("src/lib.rs".to_string()),
12055 nearest_project: Some(false),
12056 view: Some("detailed".to_string()),
12057 compact: Some(true),
12058 symbol: Some("first".to_string()),
12059 symbol_parent: Some(String::new()),
12060 direction: Some("outbound".to_string()),
12061 depth: Some(2),
12062 limit: Some(1),
12063 output_bytes: Some(64 * 1024),
12064 ..AtlasSymbolRelationsParams::default()
12065 },
12066 None,
12067 );
12068 let compact_continuation_value: serde_json::Value =
12069 toon_format::decode_default(&compact_continuation_page)?;
12070 let compact_continuation_report = compact_continuation_value
12071 .get("symbol_relations")
12072 .ok_or_else(|| io::Error::other("compact relation page omitted its envelope"))?;
12073 let compact_next_call = compact_continuation_report
12074 .get("next_call")
12075 .ok_or_else(|| io::Error::other("compact relation page omitted its next call"))?;
12076 require(
12077 compact_next_call
12078 .get("tool")
12079 .and_then(serde_json::Value::as_str)
12080 == Some(MCP_TOOL_ATLAS_SYMBOL_RELATIONS),
12081 "compact relation continuation did not name its owning MCP tool",
12082 )?;
12083 let compact_next_arguments = compact_next_call
12084 .get("arguments")
12085 .cloned()
12086 .ok_or_else(|| io::Error::other("compact next call omitted its arguments"))?;
12087 require(
12088 compact_next_arguments.get("cursor").is_some()
12089 && compact_next_arguments.get("symbol_parent").is_none(),
12090 "compact next call did not preserve its cursor or normalize an empty parent",
12091 )?;
12092 let compact_next_params: AtlasSymbolRelationsParams =
12093 serde_json::from_value(compact_next_arguments)?;
12094 let compact_resumed_page =
12095 server.atlas_symbol_relations_response(&compact_next_params, None);
12096 require(
12097 compact_resumed_page.contains("symbol_relations:")
12098 && !compact_resumed_page.contains("cursor does not match query")
12099 && !compact_resumed_page.contains("graph symbol anchor is not available"),
12100 "compact relation next call was not directly reusable",
12101 )?;
12102 }
12103 let bounded_output_bytes = 4 * 1024_u32;
12104 let bounded = server.atlas_symbol_relations_response(
12105 &AtlasSymbolRelationsParams {
12106 project_path: Some(project_path.to_string()),
12107 file: Some("src/lib.rs".to_string()),
12108 nearest_project: Some(false),
12109 view: Some("detailed".to_string()),
12110 direction: Some("outbound".to_string()),
12111 limit: Some(50),
12112 edge_limit: Some(50),
12113 node_limit: Some(50),
12114 visited_limit: Some(50),
12115 occurrence_total_limit: Some(50),
12116 intermediate_bytes: Some(128 * 1024),
12117 deadline_ms: Some(2_000),
12118 output_bytes: Some(bounded_output_bytes),
12119 ..AtlasSymbolRelationsParams::default()
12120 },
12121 None,
12122 );
12123 require(
12124 bounded.contains("symbol_relations:")
12125 && bounded.len() <= bounded_output_bytes as usize
12126 && bounded.contains("Own café λ relation navigation")
12127 && bounded.contains(&format!("rendered_output_bytes: {}", bounded.len())),
12128 "detailed MCP relation output did not enforce or report the exact routed envelope bytes",
12129 )?;
12130
12131 let analysis = server.atlas_symbol_relations_response(
12132 &AtlasSymbolRelationsParams {
12133 project_path: Some(project_path.to_string()),
12134 file: Some("src/lib.rs".to_string()),
12135 nearest_project: Some(false),
12136 view: Some("analysis".to_string()),
12137 symbol: Some("first".to_string()),
12138 direction: Some("outbound".to_string()),
12139 depth: Some(2),
12140 limit: Some(50),
12141 output_bytes: Some(64 * 1024),
12142 include_communities: Some(true),
12143 include_cycles: Some(true),
12144 ..AtlasSymbolRelationsParams::default()
12145 },
12146 None,
12147 );
12148 require(
12149 analysis.contains("symbol_relations:")
12150 && analysis.contains("mode: architecture")
12151 && analysis.contains("findings[")
12152 && analysis.contains("next_call:")
12153 && analysis.contains("work:"),
12154 "MCP relation analysis omitted its closed mode, findings, work, or reusable next call",
12155 )?;
12156 let entrypoint_anchor = serde_json::to_string(&RelationAnchor::File {
12157 file: RepositoryFilePath::new(Path::new("src/lib.rs"))?,
12158 })?;
12159 let entrypoint = server.atlas_symbol_relations_response(
12160 &AtlasSymbolRelationsParams {
12161 project_path: Some(project_path.to_string()),
12162 file: None,
12163 nearest_project: Some(false),
12164 view: Some("analysis".to_string()),
12165 direction: Some("outbound".to_string()),
12166 resolution: Some("any".to_string()),
12167 depth: Some(2),
12168 limit: Some(50),
12169 output_bytes: Some(64 * 1024),
12170 analysis_mode: Some("entrypoint".to_string()),
12171 profile_name: Some("mcp-entrypoint".to_string()),
12172 entrypoints: Some(vec![entrypoint_anchor.clone()]),
12173 profile_relations: Some(vec!["calls".to_string()]),
12174 ..AtlasSymbolRelationsParams::default()
12175 },
12176 None,
12177 );
12178 require(
12179 entrypoint.contains("symbol_relations:")
12180 && entrypoint.contains("mode: entrypoint")
12181 && entrypoint.contains("entrypoint_profile:")
12182 && entrypoint.contains("coverage:"),
12183 "MCP entrypoint analysis did not serialize the shared profile contract",
12184 )?;
12185 let entrypoint_symbol_conflict = server.atlas_symbol_relations_response(
12186 &AtlasSymbolRelationsParams {
12187 project_path: Some(project_path.to_string()),
12188 file: None,
12189 nearest_project: Some(false),
12190 view: Some("analysis".to_string()),
12191 direction: Some("outbound".to_string()),
12192 resolution: Some("any".to_string()),
12193 depth: Some(2),
12194 limit: Some(50),
12195 output_bytes: Some(64 * 1024),
12196 analysis_mode: Some("entrypoint".to_string()),
12197 symbol: Some("first".to_string()),
12198 entrypoints: Some(vec![entrypoint_anchor]),
12199 profile_relations: Some(vec!["calls".to_string()]),
12200 ..AtlasSymbolRelationsParams::default()
12201 },
12202 None,
12203 );
12204 require(
12205 entrypoint_symbol_conflict.contains(MCP_ERROR_ENTRYPOINT_SYMBOL_SELECTOR),
12206 "MCP silently ignored a symbol selector alongside explicit entrypoint anchors",
12207 )?;
12208
12209 let state = ProjectAtlasMcpServer::project_state_from_root(Path::new(project_path))?;
12210 let publication_before = ProjectAtlasMcpServer::open_read_store(&state)?
12211 .index_publication()?
12212 .ok_or_else(|| io::Error::other("MCP impact fixture publication missing"))?;
12213 let task_records_before = server
12214 .task_registry
12215 .read()
12216 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
12217 .len();
12218 let impact_started = Instant::now();
12219 let impact = server.atlas_symbol_relations_response(
12220 &AtlasSymbolRelationsParams {
12221 project_path: Some(project_path.to_string()),
12222 file: Some("src/lib.rs".to_string()),
12223 nearest_project: Some(false),
12224 view: Some("analysis".to_string()),
12225 symbol: Some("first".to_string()),
12226 direction: Some("outbound".to_string()),
12227 depth: Some(2),
12228 limit: Some(8),
12229 edge_limit: Some(8),
12230 node_limit: Some(16),
12231 visited_limit: Some(16),
12232 occurrence_total_limit: Some(16),
12233 intermediate_bytes: Some(128 * 1_024),
12234 deadline_ms: Some(1_000),
12235 output_bytes: Some(64 * 1_024),
12236 analysis_mode: Some("impact".to_string()),
12237 vcs: Some("working_tree".to_string()),
12238 include_dead_code: Some(true),
12239 ..AtlasSymbolRelationsParams::default()
12240 },
12241 None,
12242 );
12243 let impact_elapsed = impact_started.elapsed();
12244 require(
12245 impact_elapsed <= Duration::from_secs(5)
12246 && impact.contains("mode: impact")
12247 && (impact.contains("state: available") || impact.contains("state: unavailable")),
12248 "MCP impact analysis exceeded its elapsed tolerance or omitted typed mode/VCS state",
12249 )?;
12250 let impact_value: serde_json::Value = toon_format::decode_default(&impact)?;
12251 let impact_report = impact_value
12252 .get(MCP_PAYLOAD_SYMBOL_RELATIONS)
12253 .ok_or_else(|| io::Error::other("MCP impact response omitted its envelope"))?;
12254 let bounded_work = [
12255 ("/returned", 8_u64),
12256 ("/work/relations/inspected_edges", 8),
12257 ("/work/relations/active_nodes", 16),
12258 ("/work/relations/visited_nodes", 16),
12259 ("/work/analyzed_nodes", 16),
12260 ("/work/analyzed_edges", 8),
12261 ("/work/peak_intermediate_bytes", 128 * 1_024),
12262 ("/work/rendered_output_bytes", 64 * 1_024),
12263 ];
12264 require(
12265 impact.len() <= 64 * 1_024
12266 && bounded_work.iter().all(|(path, limit)| {
12267 impact_report
12268 .pointer(path)
12269 .and_then(serde_json::Value::as_u64)
12270 .is_some_and(|observed| observed <= *limit)
12271 }),
12272 "MCP impact analysis crossed or omitted a declared row/node/edge/visited/intermediate/output budget",
12273 )?;
12274 require(
12275 ProjectAtlasMcpServer::open_read_store(&state)?
12276 .index_publication()?
12277 .as_ref()
12278 == Some(&publication_before)
12279 && server
12280 .task_registry
12281 .read()
12282 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
12283 .len()
12284 == task_records_before,
12285 "read-only MCP impact analysis changed publication or retained a task record",
12286 )?;
12287 let follow_up_started = Instant::now();
12288 let follow_up = server.atlas_overview_response(
12289 AtlasProjectParams {
12290 project_path: Some(project_path.to_string()),
12291 worktree: None,
12292 },
12293 None,
12294 );
12295 require(
12296 follow_up_started.elapsed() <= Duration::from_secs(2)
12297 && follow_up.contains("overview:"),
12298 "immediate MCP follow-up read was not responsive after bounded impact analysis",
12299 )?;
12300
12301 let trace = server.atlas_symbol_relations_response(
12302 &AtlasSymbolRelationsParams {
12303 project_path: Some(project_path.to_string()),
12304 file: Some("src/lib.rs".to_string()),
12305 nearest_project: Some(false),
12306 view: Some("analysis".to_string()),
12307 symbol: Some("first".to_string()),
12308 direction: Some("outbound".to_string()),
12309 depth: Some(2),
12310 limit: Some(50),
12311 analysis_mode: Some("trace".to_string()),
12312 trace_target: Some("second".to_string()),
12313 trace_target_file: Some("src/lib.rs".to_string()),
12314 trace_target_kind: Some("function".to_string()),
12315 trace_target_signature: Some("fn second ( )".to_string()),
12316 ..AtlasSymbolRelationsParams::default()
12317 },
12318 None,
12319 );
12320 require(
12321 trace.contains("mode: trace")
12322 && trace.contains("kind: static_trace")
12323 && trace.contains("status: confirmed")
12324 && trace.contains("name: second")
12325 && trace.contains("capability: symbol_slice"),
12326 "MCP trace analysis omitted its confirmed path or reusable exact selector",
12327 )?;
12328
12329 let misplaced = server.atlas_symbol_relations_response(
12330 &AtlasSymbolRelationsParams {
12331 project_path: Some(project_path.to_string()),
12332 file: Some("src/lib.rs".to_string()),
12333 nearest_project: Some(false),
12334 view: Some("detailed".to_string()),
12335 analysis_mode: Some("impact".to_string()),
12336 ..AtlasSymbolRelationsParams::default()
12337 },
12338 None,
12339 );
12340 require(
12341 misplaced.contains("analysis controls require view=analysis"),
12342 "MCP detailed relation view accepted analysis-only controls",
12343 )?;
12344
12345 let missing_trace_target = server.atlas_symbol_relations_response(
12346 &AtlasSymbolRelationsParams {
12347 project_path: Some(project_path.to_string()),
12348 file: Some("src/lib.rs".to_string()),
12349 nearest_project: Some(false),
12350 view: Some("analysis".to_string()),
12351 analysis_mode: Some("trace".to_string()),
12352 ..AtlasSymbolRelationsParams::default()
12353 },
12354 None,
12355 );
12356 require(
12357 missing_trace_target.contains("analysis trace requires an exact file or symbol target"),
12358 "MCP trace analysis accepted a missing exact target",
12359 )?;
12360
12361 let misplaced_vcs = server.atlas_symbol_relations_response(
12362 &AtlasSymbolRelationsParams {
12363 project_path: Some(project_path.to_string()),
12364 file: Some("src/lib.rs".to_string()),
12365 nearest_project: Some(false),
12366 view: Some("analysis".to_string()),
12367 analysis_mode: Some("architecture".to_string()),
12368 vcs: Some("working_tree".to_string()),
12369 ..AtlasSymbolRelationsParams::default()
12370 },
12371 None,
12372 );
12373 require(
12374 misplaced_vcs.contains("VCS selection is valid only for impact analysis"),
12375 "MCP silently dropped an explicit VCS selector outside impact mode",
12376 )
12377 }
12378
12379 #[test]
12380 fn current_dir_alias_paths_use_active_mcp_project() -> Result<(), Box<dyn std::error::Error>> {
12381 let temp = tempfile::tempdir()?;
12382 let repo = temp.path().join("repo-a");
12383 fs::create_dir(&repo)?;
12384 let db_path = repo.join(".projectatlas").join("projectatlas.db");
12385 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
12386 let expected_root = canonical_project_root(&repo)?;
12387
12388 let (_state, root) =
12389 server.state_and_root_path(None, None, Some("./".to_string()), false)?;
12390 require(
12391 root == expected_root,
12392 "current-dir alias did not use active root",
12393 )?;
12394
12395 #[cfg(windows)]
12396 {
12397 let (_state, root) =
12398 server.state_and_root_path(None, None, Some(".\\".to_string()), false)?;
12399 require(
12400 root == expected_root,
12401 "windows current-dir alias did not use active root",
12402 )?;
12403 }
12404
12405 Ok(())
12406 }
12407
12408 #[test]
12409 fn worktree_list_retains_retired_rows_at_structural_capacity()
12410 -> Result<(), Box<dyn std::error::Error>> {
12411 let temp = tempfile::tempdir()?;
12412 let primary = temp.path().join("control");
12413 fs::create_dir(&primary)?;
12414 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
12415 let git_directory = primary.join(".git");
12416 let structural_registrations = git_directory.join("worktrees");
12417 fs::create_dir(&structural_registrations)?;
12418 for index in 0..MAX_GIT_WORKTREE_REGISTRATIONS {
12419 let administrative_directory =
12420 structural_registrations.join(format!("missing-{index:04}"));
12421 fs::create_dir(&administrative_directory)?;
12422 fs::write(
12423 administrative_directory.join("gitdir"),
12424 temp.path()
12425 .join(format!("missing-{index:04}"))
12426 .join(".git")
12427 .to_string_lossy()
12428 .as_bytes(),
12429 )?;
12430 }
12431
12432 let database = primary.join(".projectatlas").join("projectatlas.db");
12433 fs::create_dir_all(
12434 database
12435 .parent()
12436 .ok_or_else(|| io::Error::other("control database has no parent"))?,
12437 )?;
12438 let store = AtlasStore::open_for_project(&database, &primary)?;
12439 let retired_alias = WorktreeAlias::parse("retired-at-capacity")?;
12440 let retired_registration = store.register_worktree(
12441 &retired_alias,
12442 &git_directory,
12443 &structural_registrations.join("retired"),
12444 &"ab".repeat(32),
12445 &temp.path().join("retired"),
12446 None,
12447 1,
12448 )?;
12449 drop(store);
12450
12451 let server =
12452 ProjectAtlasMcpServer::new(database.clone(), None, "worktree-list".to_string(), false);
12453 let active = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12454 include_retired: Some(false),
12455 }));
12456 require(
12457 active.contains("total_worktrees: 1026")
12458 && active.contains("truncated: false")
12459 && active.contains("retired-at-capacity")
12460 && active.contains("missing-1023"),
12461 "combined capacity starved a structural or active missing registration",
12462 )?;
12463
12464 let store = AtlasStore::open_for_project(&database, &primary)?;
12465 store.retire_worktree(retired_registration.registration_id, &retired_alias, 2)?;
12466 drop(store);
12467
12468 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12469 include_retired: Some(true),
12470 }));
12471 require(
12472 listed.contains("total_worktrees: 1025") && listed.contains("retired-at-capacity"),
12473 "full structural inventory starved the requested retired registration",
12474 )
12475 }
12476
12477 #[test]
12478 fn worktree_remove_retains_display_for_git_known_missing_registration()
12479 -> Result<(), Box<dyn std::error::Error>> {
12480 let fixture = registered_worktree_race_fixture("missing-remove")?;
12481 fs::remove_file(fixture.linked.join(".git"))?;
12482 let repository = fixture.server.control_git_repository()?;
12483 let missing_entry = repository
12484 .worktrees
12485 .iter()
12486 .find(|entry| entry.administrative_directory == fixture.administrative_directory)
12487 .ok_or_else(|| io::Error::other("missing Git worktree entry was not discovered"))?;
12488 require(
12489 matches!(missing_entry.state, GitWorktreeState::Missing { .. }),
12490 "Git-known missing worktree did not retain its structural entry",
12491 )?;
12492 let root_display = normalize_native_path_display(&fixture.linked.canonicalize()?);
12493 let registrations =
12494 AtlasStore::open_for_project(&fixture.control_db, &fixture.server.control_state.root)?
12495 .worktree_registrations(false)?;
12496 let listed = fixture.server.worktree_list_row(
12497 &repository.common_directory,
12498 missing_entry,
12499 ®istrations,
12500 );
12501 require(
12502 listed.root.as_deref() == Some(root_display.as_str())
12503 && matches!(listed.path_display, McpWorktreePathDisplayState::Available)
12504 && matches!(listed.git_state, McpGitWorktreeState::Missing)
12505 && matches!(
12506 listed.registration,
12507 McpWorktreeRegistrationState::Registered
12508 ),
12509 "Git-known missing worktree listing lost its retained root",
12510 )?;
12511 let removed = fixture
12512 .server
12513 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
12514 worktree: fixture.alias.to_string(),
12515 }));
12516 require(
12517 removed.contains("status: retired")
12518 && removed.contains("path_display: available")
12519 && removed.contains(&root_display)
12520 && removed.contains(MCP_WORKTREE_MISSING_RETENTION_REASON),
12521 &format!(
12522 "Git-known missing worktree retirement lost its retained display identity: {removed}"
12523 ),
12524 )?;
12525 Ok(())
12526 }
12527
12528 #[test]
12529 fn worktree_list_retains_one_invalid_registration_after_administrative_replacement()
12530 -> Result<(), Box<dyn std::error::Error>> {
12531 for replacement in ["file", "link"] {
12532 let fixture = registered_worktree_race_fixture("invalid-registration")?;
12533 let preserved = fixture.primary.join("preserved-administrative");
12534 let target = fixture.primary.join("unrelated-target");
12535 fs::create_dir(&target)?;
12536 fs::write(target.join("canary"), "unrelated")?;
12537 fs::rename(&fixture.administrative_directory, &preserved)?;
12538 if replacement == "file" {
12539 fs::write(&fixture.administrative_directory, "replacement")?;
12540 } else {
12541 #[cfg(unix)]
12542 std::os::unix::fs::symlink(&target, &fixture.administrative_directory)?;
12543 #[cfg(windows)]
12544 {
12545 let parent = fixture
12546 .administrative_directory
12547 .parent()
12548 .ok_or_else(|| io::Error::other("administrative path has no parent"))?;
12549 let name = fixture
12550 .administrative_directory
12551 .file_name()
12552 .ok_or_else(|| io::Error::other("administrative path has no name"))?;
12553 run_fixture_command(
12554 StdCommand::new("cmd")
12555 .args(["/D", "/C", "mklink", "/J"])
12556 .arg(parent.canonicalize()?.join(name))
12557 .arg(target.canonicalize()?),
12558 )?;
12559 }
12560 }
12561 let listed = fixture
12562 .server
12563 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12564 include_retired: Some(false),
12565 }));
12566 let value: serde_json::Value = toon_format::decode_default(&listed)?;
12567 let rows = value
12568 .pointer("/worktrees/worktrees")
12569 .and_then(serde_json::Value::as_array)
12570 .ok_or_else(|| io::Error::other(format!("missing worktree rows: {listed}")))?;
12571 require(
12572 rows.len() == 2
12573 && rows
12574 .iter()
12575 .filter(|row| {
12576 row.get("alias").and_then(serde_json::Value::as_str)
12577 == Some(fixture.alias.as_str())
12578 && row.get("git_state").and_then(serde_json::Value::as_str)
12579 == Some("invalid")
12580 && row.get("registration").and_then(serde_json::Value::as_str)
12581 == Some("registered")
12582 && row.get("atlas_state").and_then(serde_json::Value::as_str)
12583 == Some("unavailable")
12584 })
12585 .count()
12586 == 1,
12587 &format!("{replacement} replacement split its registered invalid row: {listed}"),
12588 )?;
12589 let removed =
12590 fixture
12591 .server
12592 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
12593 worktree: fixture.alias.to_string(),
12594 }));
12595 let control =
12596 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
12597 require(
12598 removed.contains("cannot retire worktree")
12599 && removed.contains("Git evidence is invalid")
12600 && control.worktree_registration(&fixture.alias)?.state
12601 == WorktreeRegistrationState::Active,
12602 &format!(
12603 "{replacement} invalid evidence allowed registration retirement: {removed}"
12604 ),
12605 )?;
12606 require(
12607 fixture
12608 .server
12609 .state_for_target(None, Some(fixture.alias.to_string()))
12610 .is_err()
12611 && fs::read_to_string(target.join("canary"))? == "unrelated"
12612 && !target.join(PROJECTATLAS_DIR_NAME).exists(),
12613 "invalid administrative replacement became an actionable target",
12614 )?;
12615 }
12616 Ok(())
12617 }
12618
12619 #[cfg(windows)]
12620 #[test]
12621 fn windows_worktree_selector_survives_live_to_retained_transition()
12622 -> Result<(), Box<dyn std::error::Error>> {
12623 let common = PathBuf::from(r"C:\repo\.git");
12624 let root = PathBuf::from(r"C:\repo\linked");
12625 for (index, administrative_directory) in [
12626 PathBuf::from(r"C:\repo\.git\worktrees\linked"),
12627 PathBuf::from(r"\\?\C:\repo\.git\worktrees\CON"),
12628 ]
12629 .into_iter()
12630 .enumerate()
12631 {
12632 let entry = GitWorktreeEntry {
12633 role: GitWorktreeRole::Linked,
12634 administrative_directory: administrative_directory.clone(),
12635 state: GitWorktreeState::Active {
12636 git_control_path: root.join(".git"),
12637 root: root.clone(),
12638 },
12639 };
12640 let selector = ProjectAtlasMcpServer::worktree_candidate_selector(&entry);
12641 let alias = WorktreeAlias::parse(&format!("retained-{index}"))?;
12642 let registration = WorktreeRegistration {
12643 registration_id: i64::try_from(index + 1)?,
12644 alias: alias.clone(),
12645 state: WorktreeRegistrationState::Active,
12646 git_common_directory: normalize_native_path_display(&common),
12647 git_common_directory_identity: CanonicalProjectRoot::from_persisted_path(
12648 common.clone(),
12649 )?,
12650 git_administrative_directory: normalize_native_path_display(
12651 &administrative_directory,
12652 ),
12653 git_administrative_directory_identity: CanonicalProjectRoot::from_persisted_path(
12654 administrative_directory,
12655 )?,
12656 git_administrative_identity: "ab".repeat(32),
12657 last_root: normalize_native_path_display(&root),
12658 last_root_identity: CanonicalProjectRoot::from_persisted_path(root.clone())?,
12659 project_instance_id: None,
12660 accepted_telemetry_revision: 0,
12661 created_at_epoch: 1,
12662 retired_at_epoch: None,
12663 };
12664 let retained = ProjectAtlasMcpServer::missing_registered_worktree_row(®istration);
12665 require(
12666 retained.selector.as_deref() == Some(selector.as_str())
12667 && retained.alias.as_deref() == Some(alias.as_str()),
12668 "live-to-retained transition changed the selector or lost the removal alias",
12669 )?;
12670 }
12671 Ok(())
12672 }
12673
12674 #[cfg(unix)]
12675 #[test]
12676 fn non_utf8_worktree_identities_retain_native_join_and_display_state()
12677 -> Result<(), Box<dyn std::error::Error>> {
12678 let temp = tempfile::tempdir()?;
12679 let control_root = temp.path().join("control");
12680 let common_directory = control_root.join(".git");
12681 fs::create_dir_all(&common_directory)?;
12682 let database = control_root.join(".projectatlas").join("projectatlas.db");
12683 fs::create_dir_all(
12684 database
12685 .parent()
12686 .ok_or_else(|| io::Error::other("control database has no parent"))?,
12687 )?;
12688 let store = AtlasStore::open_for_project(&database, &control_root)?;
12689 let server =
12690 ProjectAtlasMcpServer::new(database, None, "non-utf8-worktrees".to_string(), false);
12691 let mut entries = Vec::new();
12692
12693 for (index, terminal_byte) in [0xff, 0xfe].into_iter().enumerate() {
12694 let name = std::ffi::OsString::from_vec(vec![b'w', b't', b'-', terminal_byte]);
12695 let administrative_directory = common_directory.join("worktrees").join(&name);
12696 let root = temp.path().join(name);
12697 fs::create_dir_all(&administrative_directory)?;
12698 fs::create_dir_all(&root)?;
12699 let entry = GitWorktreeEntry {
12700 role: GitWorktreeRole::Linked,
12701 administrative_directory: administrative_directory.clone(),
12702 state: GitWorktreeState::Active {
12703 git_control_path: root.join(".git"),
12704 root: root.clone(),
12705 },
12706 };
12707 let shadow = WorktreeRegistration {
12708 registration_id: i64::try_from(index + 1)?,
12709 alias: WorktreeAlias::parse(&format!("shadow-{index}"))?,
12710 state: WorktreeRegistrationState::Active,
12711 git_common_directory: normalize_native_path_display(&common_directory),
12712 git_common_directory_identity: CanonicalProjectRoot::from_path(&common_directory)?,
12713 git_administrative_directory: normalize_native_path_display(
12714 &administrative_directory,
12715 ),
12716 git_administrative_directory_identity: CanonicalProjectRoot::from_path(
12717 &administrative_directory,
12718 )?,
12719 git_administrative_identity: "ab".repeat(32),
12720 last_root: normalize_native_path_display(&root),
12721 last_root_identity: CanonicalProjectRoot::from_path(&root)?,
12722 project_instance_id: None,
12723 accepted_telemetry_revision: 0,
12724 created_at_epoch: 1,
12725 retired_at_epoch: None,
12726 };
12727 let row = server.worktree_list_row(&common_directory, &entry, &[shadow]);
12728 require(
12729 row.selector.is_some()
12730 && row
12731 .alias
12732 .as_deref()
12733 .is_some_and(|alias| alias == format!("shadow-{index}"))
12734 && matches!(row.registration, McpWorktreeRegistrationState::Registered)
12735 && matches!(row.atlas_state, McpWorktreeAtlasState::Invalid)
12736 && row.root.is_none()
12737 && matches!(row.path_display, McpWorktreePathDisplayState::Unavailable),
12738 "non-UTF-8 structural row lost its native selector or typed display state",
12739 )?;
12740 entries.push(entry);
12741 }
12742
12743 let first_selector = ProjectAtlasMcpServer::worktree_candidate_selector(&entries[0]);
12744 require(
12745 first_selector != ProjectAtlasMcpServer::worktree_candidate_selector(&entries[1]),
12746 "native administrative identities collapsed into one selector",
12747 )?;
12748 let repository = GitRepositoryStructure {
12749 common_directory,
12750 selection: projectatlas_fs::worktree::GitRepositorySelection::CommonManager {
12751 source_selection: projectatlas_fs::worktree::GitManagerSourceSelection::Ambiguous {
12752 worktree_count: entries.len(),
12753 },
12754 },
12755 worktrees: entries,
12756 };
12757 let candidates = server.matching_worktree_candidates(&repository, &first_selector);
12758 require(
12759 candidates.len() == 1
12760 && ProjectAtlasMcpServer::worktree_candidate(
12761 &repository.common_directory,
12762 candidates[0],
12763 )
12764 .is_some_and(|candidate| {
12765 candidate.selector == first_selector
12766 && candidate.root.is_none()
12767 && matches!(
12768 candidate.path_display,
12769 McpWorktreePathDisplayState::Unavailable
12770 )
12771 }),
12772 "non-UTF-8 structural identity was not selectable with typed display state",
12773 )?;
12774 require(
12775 store.worktree_registrations(true)?.is_empty(),
12776 "non-UTF-8 structural identity reached the registration store",
12777 )?;
12778
12779 let non_utf8_common = temp
12780 .path()
12781 .join(std::ffi::OsString::from_vec(b"common-\xff".to_vec()));
12782 let non_utf8_administrative = non_utf8_common.join("worktrees/utf8");
12783 let non_utf8_root = temp.path().join("utf8");
12784 fs::create_dir_all(&non_utf8_administrative)?;
12785 fs::create_dir(&non_utf8_root)?;
12786 let utf8_entry = GitWorktreeEntry {
12787 role: GitWorktreeRole::Linked,
12788 administrative_directory: non_utf8_administrative,
12789 state: GitWorktreeState::Active {
12790 git_control_path: non_utf8_root.join(".git"),
12791 root: non_utf8_root,
12792 },
12793 };
12794 let row = server.worktree_list_row(&non_utf8_common, &utf8_entry, &[]);
12795 require(
12796 row.selector.is_some()
12797 && row.root.is_some()
12798 && matches!(row.path_display, McpWorktreePathDisplayState::Unavailable)
12799 && row.blocker.as_deref() == Some(MCP_ERROR_WORKTREE_PATH_NON_UTF8)
12800 && ProjectAtlasMcpServer::worktree_candidate(&non_utf8_common, &utf8_entry)
12801 .is_some_and(|candidate| {
12802 candidate.root.is_some()
12803 && matches!(
12804 candidate.path_display,
12805 McpWorktreePathDisplayState::Unavailable
12806 )
12807 }),
12808 "non-UTF-8 common-directory identity lost its selector or display state",
12809 )
12810 }
12811
12812 #[cfg(unix)]
12813 fn relocate_fixture_administrative_directory(
12814 linked: &Path,
12815 current: &Path,
12816 name: std::ffi::OsString,
12817 ) -> Result<PathBuf, Box<dyn std::error::Error>> {
12818 let relocated = current
12819 .parent()
12820 .ok_or_else(|| io::Error::other("worktree administrative directory has no parent"))?
12821 .join(name);
12822 fs::rename(current, &relocated)?;
12823 let relocated = relocated.canonicalize()?;
12824 let mut pointer = b"gitdir: ".to_vec();
12825 pointer.extend_from_slice(relocated.as_os_str().as_encoded_bytes());
12826 pointer.push(b'\n');
12827 fs::write(linked.join(".git"), pointer)?;
12828 Ok(relocated)
12829 }
12830
12831 #[cfg(unix)]
12832 fn require_real_native_worktree_lifecycle(
12833 case: &str,
12834 primary_name: std::ffi::OsString,
12835 common_name: Option<std::ffi::OsString>,
12836 linked_name: std::ffi::OsString,
12837 administrative_name: Option<std::ffi::OsString>,
12838 expected_utf8: (bool, bool, bool),
12839 ) -> Result<(), Box<dyn std::error::Error>> {
12840 let temp = tempfile::tempdir()?;
12841 let primary = temp.path().join(primary_name);
12842 let linked = temp.path().join(linked_name);
12843 fs::create_dir_all(primary.join("src"))?;
12844 if let Some(common_name) = common_name {
12845 run_fixture_command(
12846 StdCommand::new("git")
12847 .current_dir(temp.path())
12848 .args(["init", "--quiet", "--separate-git-dir"])
12849 .arg(temp.path().join(common_name))
12850 .arg(&primary),
12851 )?;
12852 } else {
12853 run_fixture_command(
12854 StdCommand::new("git")
12855 .current_dir(&primary)
12856 .args(["init", "--quiet"]),
12857 )?;
12858 }
12859 for (key, value) in [
12860 ("user.name", "ProjectAtlas Test"),
12861 ("user.email", "projectatlas@example.invalid"),
12862 ("commit.gpgsign", "false"),
12863 ("core.autocrlf", "false"),
12864 ] {
12865 run_fixture_command(
12866 StdCommand::new("git")
12867 .current_dir(&primary)
12868 .args(["config", key, value]),
12869 )?;
12870 }
12871 fs::write(primary.join("src/lib.rs"), "pub fn baseline() {}\n")?;
12872 run_fixture_command(
12873 StdCommand::new("git")
12874 .current_dir(&primary)
12875 .args(["add", "."]),
12876 )?;
12877 run_fixture_command(
12878 StdCommand::new("git")
12879 .current_dir(&primary)
12880 .args(["commit", "--quiet", "-m", "fixture"]),
12881 )?;
12882 run_fixture_command(
12883 StdCommand::new("git")
12884 .current_dir(&primary)
12885 .args(["worktree", "add", "--quiet", "-b", case])
12886 .arg(&linked),
12887 )?;
12888
12889 let linked = linked.canonicalize()?;
12890 if let Some(name) = administrative_name {
12891 let RepositoryStructure::Git(initial) = discover_repository_structure(&linked)? else {
12892 return Err(io::Error::other("fixture was not discovered as Git").into());
12893 };
12894 let current = initial
12895 .worktrees
12896 .iter()
12897 .find(|entry| {
12898 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(linked.as_path())
12899 })
12900 .ok_or_else(|| io::Error::other("linked fixture was not discovered"))?
12901 .administrative_directory
12902 .clone();
12903 relocate_fixture_administrative_directory(&linked, ¤t, name)?;
12904 }
12905 require(
12906 run_fixture_command(
12907 StdCommand::new("git")
12908 .current_dir(&linked)
12909 .args(["status", "--short"]),
12910 )?
12911 .is_empty(),
12912 "Git could not use the native fixture paths",
12913 )?;
12914
12915 let primary = primary.canonicalize()?;
12916 let control_config = primary.join(PROJECTATLAS_DIR_NAME).join("config.toml");
12917 let control_db = primary
12918 .join(PROJECTATLAS_DIR_NAME)
12919 .join(PROJECTATLAS_DB_FILE_NAME);
12920 init_project_with_config(&primary, Some(&control_config))?;
12921 let mut control = AtlasStore::open_for_project(&control_db, &primary)?;
12922 let plan = ScanRuntimePlan::for_path(Some(&control_config), &primary, None)?;
12923 run_scan_pipeline(
12924 &mut control,
12925 &plan,
12926 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None),
12927 )?;
12928 drop(control);
12929 let server = ProjectAtlasMcpServer::new(
12930 control_db.clone(),
12931 Some(control_config),
12932 format!("native-{case}"),
12933 false,
12934 );
12935 let repository = server.control_git_repository()?;
12936 let entry = repository
12937 .worktrees
12938 .iter()
12939 .find(|entry| {
12940 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(linked.as_path())
12941 })
12942 .ok_or_else(|| io::Error::other("native linked worktree was not discovered"))?;
12943 let common_identity = CanonicalProjectRoot::from_path(&repository.common_directory)?;
12944 let administrative_identity =
12945 CanonicalProjectRoot::from_path(&entry.administrative_directory)?;
12946 let root_identity = CanonicalProjectRoot::from_path(&linked)?;
12947 require(
12948 (
12949 linked.to_str().is_some(),
12950 repository.common_directory.to_str().is_some(),
12951 entry.administrative_directory.to_str().is_some(),
12952 ) == expected_utf8,
12953 "fixture did not isolate the expected native path component",
12954 )?;
12955 let selector = ProjectAtlasMcpServer::worktree_candidate_selector(entry);
12956 let display_available = expected_utf8 == (true, true, true);
12957 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
12958 include_retired: Some(false),
12959 }));
12960 let listed_value: serde_json::Value = toon_format::decode_default(&listed)?;
12961 let expected_display = if display_available {
12962 "available"
12963 } else {
12964 "unavailable"
12965 };
12966 require(
12967 listed_value
12968 .pointer("/worktrees/worktrees")
12969 .and_then(serde_json::Value::as_array)
12970 .is_some_and(|rows| {
12971 rows.iter().any(|row| {
12972 row.get("selector").and_then(serde_json::Value::as_str)
12973 == Some(selector.as_str())
12974 && row.get("path_display").and_then(serde_json::Value::as_str)
12975 == Some(expected_display)
12976 })
12977 }),
12978 &format!("native worktree discovery was not publicly selectable: {listed}"),
12979 )?;
12980 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
12981 worktree: selector,
12982 alias: Some(case.to_string()),
12983 }));
12984 require(
12985 added.contains("status: registered")
12986 && added.contains(&format!("alias: \"{case}\""))
12987 && added.contains(if display_available {
12988 "path_display: available"
12989 } else {
12990 "path_display: unavailable"
12991 }),
12992 &format!("native worktree registration failed: {added}"),
12993 )?;
12994 let alias = WorktreeAlias::parse(case)?;
12995 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
12996 let registration = control.worktree_registration(&alias)?;
12997 require(
12998 registration.git_common_directory_identity == common_identity
12999 && registration.git_administrative_directory_identity == administrative_identity
13000 && registration.last_root_identity == root_identity,
13001 "registration did not retain the exact native identities",
13002 )?;
13003 drop(control);
13004
13005 let initialized = server.atlas_init(Parameters(AtlasInitParams {
13006 project_path: None,
13007 worktree: Some(case.to_string()),
13008 no_scan: Some(false),
13009 force_rescan: Some(false),
13010 text_index_max_bytes: None,
13011 }));
13012 require(
13013 initialized.contains("status: hydrated"),
13014 &format!("alias-routed native initialization failed: {initialized}"),
13015 )?;
13016 fs::write(linked.join("src/native.rs"), "pub fn native_refresh() {}\n")?;
13017 let watched = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
13018 project_path: None,
13019 worktree: Some(case.to_string()),
13020 path: None,
13021 nearest_project: Some(false),
13022 max_workers: Some(1),
13023 timeout_seconds: None,
13024 text_index_max_bytes: None,
13025 background: Some(false),
13026 }));
13027 require(
13028 watched.contains(MCP_PAYLOAD_WATCH),
13029 &format!("alias-routed native watcher failed: {watched}"),
13030 )?;
13031 let target_db = linked
13032 .join(PROJECTATLAS_DIR_NAME)
13033 .join(PROJECTATLAS_DB_FILE_NAME);
13034 let target = open_atlas_store_read_only_for_project(&target_db, &linked)?;
13035 require(
13036 target.load_node_by_path("src/native.rs")?.is_some(),
13037 "alias-routed watcher did not publish the changed native worktree",
13038 )?;
13039 drop(target);
13040 require(
13041 run_fixture_command(StdCommand::new("git").current_dir(&linked).args([
13042 "status",
13043 "--short",
13044 "--untracked-files=all",
13045 ]))?
13046 .contains("src/native.rs"),
13047 "Git command invocation lost the native worktree path",
13048 )?;
13049
13050 let git_pointer = fs::read(linked.join(".git"))?;
13051 fs::remove_file(linked.join(".git"))?;
13052 let missing_repository = server.control_git_repository()?;
13053 let missing_entry = missing_repository
13054 .worktrees
13055 .iter()
13056 .find(|entry| entry.administrative_directory == administrative_identity.as_path())
13057 .ok_or_else(|| io::Error::other("native missing worktree entry was not discovered"))?;
13058 let missing_row = server.worktree_list_row(
13059 &missing_repository.common_directory,
13060 missing_entry,
13061 std::slice::from_ref(®istration),
13062 );
13063 require(
13064 matches!(missing_row.git_state, McpGitWorktreeState::Missing)
13065 && missing_row.alias.as_deref() == Some(case)
13066 && missing_row.root == root_identity.display_string().ok()
13067 && matches!(
13068 missing_row.path_display,
13069 McpWorktreePathDisplayState::Available
13070 ) == display_available,
13071 "Git-known missing native worktree lost its retained root or display state",
13072 )?;
13073 fs::write(linked.join(".git"), git_pointer)?;
13074
13075 let preserved_administrative = temp.path().join("preserved-administrative");
13076 fs::rename(administrative_identity.as_path(), &preserved_administrative)?;
13077 std::os::unix::fs::symlink(&preserved_administrative, administrative_identity.as_path())?;
13078 let invalid_listing = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
13079 include_retired: Some(false),
13080 }));
13081 let invalid_value: serde_json::Value = toon_format::decode_default(&invalid_listing)?;
13082 let invalid_rows = invalid_value
13083 .pointer("/worktrees/worktrees")
13084 .and_then(serde_json::Value::as_array)
13085 .ok_or_else(|| io::Error::other("native invalid worktree rows are missing"))?;
13086 require(
13087 invalid_rows.len() == 2
13088 && invalid_rows.iter().any(|row| {
13089 row.get("alias").and_then(serde_json::Value::as_str) == Some(case)
13090 && row.get("git_state").and_then(serde_json::Value::as_str)
13091 == Some("invalid")
13092 && row.get("registration").and_then(serde_json::Value::as_str)
13093 == Some("registered")
13094 })
13095 && server
13096 .state_for_target(None, Some(case.to_string()))
13097 .is_err(),
13098 &format!("native invalid registration was split or admitted: {invalid_listing}"),
13099 )?;
13100 let invalid_removal = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13101 worktree: case.to_string(),
13102 }));
13103 require(
13104 invalid_removal.contains("cannot retire worktree")
13105 && invalid_removal.contains("Git evidence is invalid"),
13106 &format!("native invalid evidence allowed retirement: {invalid_removal}"),
13107 )?;
13108 fs::remove_file(administrative_identity.as_path())?;
13109 fs::rename(&preserved_administrative, administrative_identity.as_path())?;
13110
13111 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13112 worktree: case.to_string(),
13113 }));
13114 require(
13115 removed.contains("status: retired")
13116 && removed.contains(if display_available {
13117 "path_display: available"
13118 } else {
13119 "path_display: unavailable"
13120 }),
13121 &format!("native worktree retirement failed: {removed}"),
13122 )?;
13123 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
13124 let retired = control
13125 .worktree_registrations(true)?
13126 .into_iter()
13127 .find(|registration| registration.alias == alias)
13128 .ok_or_else(|| io::Error::other("retired native registration is missing"))?;
13129 require(
13130 retired.state == WorktreeRegistrationState::Retired
13131 && retired.git_common_directory_identity == common_identity
13132 && retired.git_administrative_directory_identity == administrative_identity
13133 && retired.last_root_identity == root_identity,
13134 "retirement changed the persisted native identities",
13135 )?;
13136 require(
13137 server
13138 .state_for_target(None, Some(case.to_string()))
13139 .is_err(),
13140 "retired native alias remained routable",
13141 )
13142 }
13143
13144 #[cfg(all(unix, not(target_os = "macos")))]
13146 #[test]
13147 fn real_non_utf8_worktree_lifecycle_routes_alias_and_preserves_native_identities()
13148 -> Result<(), Box<dyn std::error::Error>> {
13149 for (case, primary, common, linked, administrative, expected_utf8) in [
13150 (
13151 "invalid-root",
13152 std::ffi::OsString::from("root-control"),
13153 None,
13154 std::ffi::OsString::from_vec(b"root-\xff".to_vec()),
13155 Some(std::ffi::OsString::from("root-administrative")),
13156 (false, true, true),
13157 ),
13158 (
13159 "invalid-common",
13160 std::ffi::OsString::from("common-control"),
13161 Some(std::ffi::OsString::from_vec(b"common-\xfe ".to_vec())),
13162 std::ffi::OsString::from("common-linked"),
13163 None,
13164 (true, false, false),
13165 ),
13166 (
13167 "invalid-administrative",
13168 std::ffi::OsString::from("administrative-control"),
13169 None,
13170 std::ffi::OsString::from("administrative-linked"),
13171 Some(std::ffi::OsString::from_vec(
13172 b"administrative-\xfd\t".to_vec(),
13173 )),
13174 (true, true, false),
13175 ),
13176 ] {
13177 require_real_native_worktree_lifecycle(
13178 case,
13179 primary,
13180 common,
13181 linked,
13182 administrative,
13183 expected_utf8,
13184 )?;
13185 }
13186 Ok(())
13187 }
13188
13189 #[cfg(unix)]
13190 #[test]
13191 fn real_unicode_worktree_lifecycle_routes_alias_and_preserves_native_identities()
13192 -> Result<(), Box<dyn std::error::Error>> {
13193 require_real_native_worktree_lifecycle(
13194 "valid-unicode",
13195 std::ffi::OsString::from("unicode-control-λ"),
13196 Some(std::ffi::OsString::from("unicode-common-共同 ")),
13197 std::ffi::OsString::from("unicode-linked-工作树"),
13198 Some(std::ffi::OsString::from("unicode-administrative-管理\t")),
13199 (true, true, true),
13200 )
13201 }
13202
13203 #[test]
13204 fn worktree_registration_revalidates_captured_local_atlas_identity()
13205 -> Result<(), Box<dyn std::error::Error>> {
13206 let temp = tempfile::tempdir()?;
13207 let root = temp.path().join("worktree");
13208 fs::create_dir(&root)?;
13209 let state = root.join(PROJECTATLAS_DIR_NAME);
13210 let db_path = state.join(PROJECTATLAS_DB_FILE_NAME);
13211 let config_path = state.join("config.toml");
13212 init_project_with_config(&root, Some(&config_path))?;
13213 drop(AtlasStore::open_for_project(&db_path, &root)?);
13214 let captured = ProjectAtlasMcpServer::local_worktree_atlas(&root)?
13215 .ok_or_else(|| io::Error::other("captured worktree atlas is missing"))?;
13216 ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13217 &root,
13218 Some(captured.project_instance_id),
13219 )?;
13220
13221 let preserved = root.join(".projectatlas-captured-registration");
13222 fs::rename(&state, &preserved)?;
13223 fs::create_dir(&state)?;
13224 let replacement = AtlasStore::open_for_project(&db_path, &root)?;
13225 require(
13226 replacement.project_instance_id()? != Some(captured.project_instance_id),
13227 "replacement atlas reused the captured registration identity",
13228 )?;
13229 drop(replacement);
13230 let rejected = ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13231 &root,
13232 Some(captured.project_instance_id),
13233 );
13234 require(
13235 rejected.as_ref().is_err_and(|error| {
13236 error
13237 .to_string()
13238 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
13239 }),
13240 "registration guard accepted a replacement local atlas",
13241 )?;
13242 fs::remove_dir_all(&state)?;
13243 let missing = ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(
13244 &root,
13245 Some(captured.project_instance_id),
13246 );
13247 require(
13248 missing.as_ref().is_err_and(|error| {
13249 error
13250 .to_string()
13251 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
13252 }),
13253 "registration guard accepted a missing captured local atlas",
13254 )?;
13255
13256 let uninitialized = temp.path().join("uninitialized");
13257 fs::create_dir(&uninitialized)?;
13258 ProjectAtlasMcpServer::revalidate_local_worktree_atlas_identity(&uninitialized, None)
13259 .map_err(Into::into)
13260 }
13261
13262 #[test]
13263 fn registered_init_rejects_replaced_git_lifecycle_before_or_during_activation_and_binding()
13264 -> Result<(), Box<dyn std::error::Error>> {
13265 let fixture = registered_worktree_race_fixture("init-race")?;
13266 let (candidate, candidate_path, work_control) = prepared_hydration_candidate(&fixture)?;
13267
13268 let replacement_entry = replace_registered_worktree(&fixture, "replacement-init")?;
13269 require(
13270 replacement_entry.administrative_directory == fixture.administrative_directory
13271 && git_administrative_identity(&replacement_entry.administrative_directory)?
13272 != fixture.administrative_identity,
13273 "replacement fixture did not reuse the administrative path with a new lifecycle",
13274 )?;
13275 fs::create_dir_all(
13276 fixture
13277 .target_db
13278 .parent()
13279 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13280 )?;
13281 let replacement = AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13282 let replacement_project = replacement
13283 .project_instance_id()?
13284 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13285 drop(replacement);
13286
13287 let activation = fixture.server.activate_registered_worktree_hydration(
13288 &fixture.state,
13289 &fixture.selection,
13290 candidate,
13291 &work_control,
13292 );
13293 require(
13294 activation.as_ref().is_err_and(|error| {
13295 error
13296 .to_string()
13297 .contains("administrative lifecycle changed")
13298 }),
13299 "hydration candidate activated into a replacement Git lifecycle",
13300 )?;
13301 require(
13302 !candidate_path.exists()
13303 && open_atlas_store_read_only_for_project(&fixture.target_db, &fixture.state.root)?
13304 .project_instance_id()?
13305 == Some(replacement_project),
13306 "rejected hydration changed the replacement atlas or retained its candidate",
13307 )?;
13308
13309 let binding = fixture
13310 .server
13311 .bind_initialized_worktree(&fixture.selection, &fixture.state);
13312 require(
13313 binding.as_ref().is_err_and(|error| {
13314 error
13315 .to_string()
13316 .contains("administrative lifecycle changed")
13317 }),
13318 "final init binding accepted a replacement Git lifecycle",
13319 )?;
13320 let control =
13321 open_atlas_store_read_only_for_project(&fixture.control_db, &fixture.control_root)?;
13322 require(
13323 control
13324 .worktree_registration(&fixture.alias)?
13325 .project_instance_id
13326 .is_none(),
13327 "failed activation or binding attached the replacement atlas",
13328 )?;
13329
13330 let during = registered_worktree_race_fixture("init-publish-race")?;
13331 let (candidate, _candidate_path, work_control) = prepared_hydration_candidate(&during)?;
13332 let replacement_project = std::cell::Cell::new(None);
13333 let activation = during
13334 .server
13335 .activate_registered_worktree_hydration_with_post_publication(
13336 &during.state,
13337 &during.selection,
13338 candidate,
13339 &work_control,
13340 || {
13341 replace_registered_worktree(&during, "replacement-during-publish")
13342 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13343 fs::create_dir_all(during.target_db.parent().ok_or_else(|| {
13344 CliError::InvalidInput("replacement database has no parent".to_string())
13345 })?)
13346 .map_err(|source| CliError::Io {
13347 path: during.state.root.clone(),
13348 source,
13349 })?;
13350 let replacement =
13351 AtlasStore::open_for_project(&during.target_db, &during.state.root)?;
13352 replacement_project.set(Some(
13353 replacement
13354 .project_instance_id()?
13355 .ok_or(DbError::ProjectInstanceIdentityMissing)?,
13356 ));
13357 Ok(())
13358 },
13359 );
13360 let activation_error = activation.as_ref().err().map(ToString::to_string);
13361 require(
13362 activation_error
13363 .as_deref()
13364 .is_some_and(|error| error.contains("administrative lifecycle changed")),
13365 &format!(
13366 "hydration bound a lifecycle replaced after candidate publication: {activation_error:?}"
13367 ),
13368 )?;
13369 let replacement =
13370 open_atlas_store_read_only_for_project(&during.target_db, &during.state.root)?;
13371 let control =
13372 open_atlas_store_read_only_for_project(&during.control_db, &during.control_root)?;
13373 require(
13374 replacement.project_instance_id()? == replacement_project.get()
13375 && control
13376 .worktree_registration(&during.alias)?
13377 .project_instance_id
13378 .is_none(),
13379 "post-publication lifecycle rejection changed or bound the replacement atlas",
13380 )
13381 }
13382
13383 #[test]
13384 fn final_retirement_does_not_import_replacement_lifecycle_telemetry()
13385 -> Result<(), Box<dyn std::error::Error>> {
13386 let fixture = registered_worktree_race_fixture("retire-race")?;
13387 let replacement_entry = replace_registered_worktree(&fixture, "replacement-retire")?;
13388 fs::create_dir_all(
13389 fixture
13390 .target_db
13391 .parent()
13392 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13393 )?;
13394 let replacement = AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13395 replacement.record_usage(&usage_from_text(
13396 "replacement",
13397 "atlas_overview",
13398 None,
13399 None,
13400 "pub fn replacement() {}",
13401 "repository overview",
13402 ))?;
13403 let replacement_project = replacement
13404 .project_instance_id()?
13405 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13406 drop(replacement);
13407 let control = open_atlas_store_for_project(&fixture.control_db, &fixture.control_root)?;
13408 let (retired, synchronized, blocker) = ProjectAtlasMcpServer::retire_registered_worktree(
13409 &control,
13410 &fixture.registration,
13411 Some(&fixture.state.root),
13412 2,
13413 None,
13414 )?;
13415 require(
13416 retired.state == WorktreeRegistrationState::Retired
13417 && retired.project_instance_id.is_none()
13418 && retired.accepted_telemetry_revision == 0
13419 && synchronized.is_none()
13420 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
13421 "replacement lifecycle telemetry was bound, imported, or hidden during retirement",
13422 )?;
13423
13424 let repository = fixture.server.control_git_repository()?;
13425 let replacement_alias = WorktreeAlias::parse("replacement-owner")?;
13426 let replacement_registration = control.register_worktree(
13427 &replacement_alias,
13428 &repository.common_directory,
13429 &replacement_entry.administrative_directory,
13430 &git_administrative_identity(&replacement_entry.administrative_directory)?,
13431 &fixture.state.root,
13432 Some(replacement_project),
13433 3,
13434 )?;
13435 require(
13436 replacement_registration.project_instance_id == Some(replacement_project),
13437 "retired origin stranded the replacement atlas identity",
13438 )?;
13439
13440 let open_race = registered_worktree_race_fixture("retire-open-race")?;
13441 let control = open_atlas_store_for_project(&open_race.control_db, &open_race.primary)?;
13442 let sentinel = b"replacement atlas must remain untouched";
13443 let (retired, synchronized, blocker) =
13444 ProjectAtlasMcpServer::retire_registered_worktree_with_pre_open(
13445 &control,
13446 &open_race.registration,
13447 Some(&open_race.state.root),
13448 2,
13449 None,
13450 || {
13451 replace_registered_worktree(&open_race, "replacement-before-local-open")
13452 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13453 fs::create_dir_all(open_race.target_db.parent().ok_or_else(|| {
13454 CliError::InvalidInput("replacement database has no parent".to_string())
13455 })?)
13456 .map_err(|source| CliError::Io {
13457 path: open_race.state.root.clone(),
13458 source,
13459 })?;
13460 fs::write(&open_race.target_db, sentinel).map_err(|source| CliError::Io {
13461 path: open_race.target_db.clone(),
13462 source,
13463 })
13464 },
13465 )?;
13466 require(
13467 retired.state == WorktreeRegistrationState::Retired
13468 && retired.project_instance_id.is_none()
13469 && synchronized.is_none()
13470 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
13471 && fs::read(&open_race.target_db)? == sentinel,
13472 "replacement database failure blocked stale retirement or changed replacement bytes",
13473 )
13474 }
13475
13476 #[test]
13477 fn retirement_reclassifies_identity_and_snapshot_failures_after_lifecycle_replacement()
13478 -> Result<(), Box<dyn std::error::Error>> {
13479 for (suffix, failure) in [
13480 ("retire-identity-failure", "project identity read failed"),
13481 ("retire-snapshot-failure", "usage snapshot export failed"),
13482 ] {
13483 let fixture = registered_worktree_race_fixture(suffix)?;
13484 let replacement_entry = replace_registered_worktree(&fixture, suffix)?;
13485 fs::create_dir_all(
13486 fixture
13487 .target_db
13488 .parent()
13489 .ok_or_else(|| io::Error::other("replacement database has no parent"))?,
13490 )?;
13491 let replacement =
13492 AtlasStore::open_for_project(&fixture.target_db, &fixture.state.root)?;
13493 replacement.record_usage(&usage_from_text(
13494 suffix,
13495 "atlas_token_report",
13496 None,
13497 None,
13498 "pub fn replacement() {}",
13499 "replacement telemetry",
13500 ))?;
13501 let replacement_project = replacement
13502 .project_instance_id()?
13503 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13504 drop(replacement);
13505
13506 let control = open_atlas_store_for_project(&fixture.control_db, &fixture.control_root)?;
13507 let classified = control.with_active_worktree_registration(
13508 fixture.registration.registration_id,
13509 &fixture.alias,
13510 |guard| {
13511 ProjectAtlasMcpServer::classify_retirement_failure(
13512 guard,
13513 &fixture.state.root,
13514 2,
13515 CliError::InvalidInput(failure.to_string()),
13516 )
13517 },
13518 )?;
13519 let (retired, synchronized, blocker) = classified?;
13520 let replacement =
13521 open_atlas_store_read_only_for_project(&fixture.target_db, &fixture.state.root)?;
13522 require(
13523 retired.state == WorktreeRegistrationState::Retired
13524 && retired.project_instance_id.is_none()
13525 && synchronized.is_none()
13526 && blocker.as_deref() == Some(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
13527 && replacement.project_instance_id()? == Some(replacement_project)
13528 && replacement.token_overview(Some(suffix))?.calls == 1,
13529 "retirement failure classification changed or imported replacement state",
13530 )?;
13531
13532 let repository = fixture.server.control_git_repository()?;
13533 let replacement_alias = WorktreeAlias::parse(&format!("{suffix}-owner"))?;
13534 let replacement_registration = control.register_worktree(
13535 &replacement_alias,
13536 &repository.common_directory,
13537 &replacement_entry.administrative_directory,
13538 &git_administrative_identity(&replacement_entry.administrative_directory)?,
13539 &fixture.state.root,
13540 Some(replacement_project),
13541 3,
13542 )?;
13543 require(
13544 replacement_registration.project_instance_id == Some(replacement_project),
13545 "retirement failure classification stranded replacement ownership",
13546 )?;
13547 }
13548 Ok(())
13549 }
13550
13551 #[test]
13552 fn registered_reset_and_binding_linearize_without_recreating_a_reset_winner()
13553 -> Result<(), Box<dyn std::error::Error>> {
13554 let bind_wins = registered_worktree_race_fixture("reset-bind-wins")?;
13555 fs::create_dir_all(
13556 bind_wins
13557 .target_db
13558 .parent()
13559 .ok_or_else(|| io::Error::other("bind-wins database has no parent"))?,
13560 )?;
13561 let target = AtlasStore::open_for_project(&bind_wins.target_db, &bind_wins.state.root)?;
13562 let project = target
13563 .project_instance_id()?
13564 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13565 drop(target);
13566 let before = fs::read(&bind_wins.target_db)?;
13567 let control = open_atlas_store_for_project(&bind_wins.control_db, &bind_wins.primary)?;
13568 control.bind_worktree_project(
13569 bind_wins.registration.registration_id,
13570 &bind_wins.alias,
13571 &bind_wins.state.root,
13572 project,
13573 )?;
13574 let rejected = bind_wins.server.reset_registered_worktree_index(
13575 &bind_wins.state,
13576 &bind_wins.selection,
13577 false,
13578 );
13579 require(
13580 rejected.as_ref().is_err_and(|error| {
13581 error
13582 .to_string()
13583 .contains(MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED)
13584 }) && fs::read(&bind_wins.target_db)? == before,
13585 "reset deleted a target after a concurrent binding won",
13586 )?;
13587
13588 let reset_wins = registered_worktree_race_fixture("reset-delete-wins")?;
13589 fs::create_dir_all(
13590 reset_wins
13591 .target_db
13592 .parent()
13593 .ok_or_else(|| io::Error::other("reset-wins database has no parent"))?,
13594 )?;
13595 drop(AtlasStore::open_for_project(
13596 &reset_wins.target_db,
13597 &reset_wins.state.root,
13598 )?);
13599 reset_wins.server.reset_registered_worktree_index(
13600 &reset_wins.state,
13601 &reset_wins.selection,
13602 false,
13603 )?;
13604 require(
13605 !reset_wins.target_db.exists(),
13606 "winning reset did not delete the unbound target atlas",
13607 )?;
13608 let late_bind = ProjectAtlasMcpServer::open_registered_worktree_mut_store(
13609 &reset_wins.state,
13610 &reset_wins.server.control_state,
13611 &reset_wins.selection,
13612 );
13613 let control =
13614 open_atlas_store_read_only_for_project(&reset_wins.control_db, &reset_wins.primary)?;
13615 require(
13616 late_bind.is_err()
13617 && !reset_wins.target_db.exists()
13618 && control
13619 .worktree_registration(&reset_wins.alias)?
13620 .project_instance_id
13621 .is_none(),
13622 "late binding recreated or attached the atlas deleted by reset",
13623 )?;
13624
13625 let replaced = registered_worktree_race_fixture("reset-replaced-lifecycle")?;
13626 let replacement_files = [
13627 replaced.target_db.clone(),
13628 db_sidecar_path(&replaced.target_db, "wal"),
13629 db_sidecar_path(&replaced.target_db, "shm"),
13630 db_sidecar_path(&replaced.target_db, "journal"),
13631 mcp_config_path_for_db(&replaced.target_db),
13632 ];
13633 let replacement_bytes = replacement_files
13634 .iter()
13635 .enumerate()
13636 .map(|(index, _)| format!("replacement-owned-{index}").into_bytes())
13637 .collect::<Vec<_>>();
13638 let rejected = replaced
13639 .server
13640 .reset_registered_worktree_index_with_post_validation(
13641 &replaced.state,
13642 &replaced.selection,
13643 true,
13644 || {
13645 replace_registered_worktree(&replaced, "replacement-during-reset")
13646 .map_err(|error| CliError::InvalidInput(error.to_string()))?;
13647 fs::create_dir_all(replaced.target_db.parent().ok_or_else(|| {
13648 CliError::InvalidInput("replacement database has no parent".to_string())
13649 })?)
13650 .map_err(|source| CliError::Io {
13651 path: replaced.state.root.clone(),
13652 source,
13653 })?;
13654 for (path, bytes) in replacement_files.iter().zip(&replacement_bytes) {
13655 fs::write(path, bytes).map_err(|source| CliError::Io {
13656 path: path.clone(),
13657 source,
13658 })?;
13659 }
13660 Ok(())
13661 },
13662 );
13663 let control =
13664 open_atlas_store_read_only_for_project(&replaced.control_db, &replaced.primary)?;
13665 require(
13666 rejected.as_ref().is_err_and(|error| {
13667 error
13668 .to_string()
13669 .contains("administrative lifecycle changed")
13670 }) && replacement_files
13671 .iter()
13672 .zip(&replacement_bytes)
13673 .all(|(path, bytes)| {
13674 fs::read(path).is_ok_and(|found| found.as_slice() == bytes.as_slice())
13675 })
13676 && control
13677 .worktree_registration(&replaced.alias)?
13678 .project_instance_id
13679 .is_none(),
13680 "guarded reset deleted replacement lifecycle database, sidecars, or MCP config",
13681 )
13682 }
13683
13684 #[test]
13685 fn registered_worktree_missing_atlas_does_not_refresh_control_root()
13686 -> Result<(), Box<dyn std::error::Error>> {
13687 let fixture = registered_worktree_race_fixture("missing-atlas")?;
13688 let expected_project = ProjectInstanceId::from_bytes([0xA; 16])?;
13689 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
13690 control.bind_worktree_project(
13691 fixture.registration.registration_id,
13692 &fixture.alias,
13693 &fixture.state.root,
13694 expected_project,
13695 )?;
13696 drop(control);
13697
13698 let sidecars = ["-wal", "-shm", "-journal"].map(|suffix| {
13699 let mut path = fixture.control_db.as_os_str().to_os_string();
13700 path.push(suffix);
13701 PathBuf::from(path)
13702 });
13703 let before = std::iter::once(&fixture.control_db)
13704 .chain(sidecars.iter())
13705 .map(|path| fs::read(path).ok())
13706 .collect::<Vec<_>>();
13707 let result = fixture
13708 .server
13709 .state_for_target(None, Some(fixture.alias.to_string()));
13710 require(
13711 result.is_err(),
13712 "missing bound atlas was accepted during registered resolution",
13713 )?;
13714 let after = std::iter::once(&fixture.control_db)
13715 .chain(sidecars.iter())
13716 .map(|path| fs::read(path).ok())
13717 .collect::<Vec<_>>();
13718 require(
13719 before == after,
13720 "failed atlas validation refreshed the control registration",
13721 )?;
13722 Ok(())
13723 }
13724
13725 #[test]
13726 fn unbound_registered_worktree_move_refreshes_root_before_missing_retirement()
13727 -> Result<(), Box<dyn std::error::Error>> {
13728 let fixture = registered_worktree_race_fixture("unbound-move")?;
13729 let original_root_display = normalize_native_path_display(&fixture.state.root);
13730 let moved = fixture
13731 .primary
13732 .parent()
13733 .ok_or_else(|| io::Error::other("moved worktree has no parent"))?
13734 .join("moved-unbound-worktree");
13735 run_fixture_command(
13736 StdCommand::new("git")
13737 .current_dir(&fixture.primary)
13738 .args(["worktree", "move"])
13739 .arg(&fixture.linked)
13740 .arg(&moved),
13741 )?;
13742 let moved_root = moved.canonicalize()?;
13743 let unresolved = fixture
13744 .server
13745 .state_for_target(None, Some(fixture.alias.to_string()));
13746 require(
13747 unresolved.is_err(),
13748 "unbound moved worktree without an atlas was accepted as initialized",
13749 )?;
13750
13751 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
13752 let refreshed = control.worktree_registration(&fixture.alias)?;
13753 require(
13754 refreshed.project_instance_id.is_none()
13755 && refreshed.last_root == normalize_native_path_display(&moved_root),
13756 "unbound Git move did not refresh its registered root",
13757 )?;
13758 drop(control);
13759
13760 run_fixture_command(
13761 StdCommand::new("git")
13762 .current_dir(&fixture.primary)
13763 .args(["worktree", "remove", "--force"])
13764 .arg(&moved),
13765 )?;
13766 let missing = fixture
13767 .server
13768 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
13769 include_retired: Some(false),
13770 }));
13771 require(
13772 missing.contains(&normalize_native_path_display(&moved_root))
13773 && !missing.contains(&original_root_display)
13774 && missing.contains("\"unbound-move\",linked,available,missing,registered"),
13775 &format!("missing worktree reporting retained the pre-move root: {missing}"),
13776 )?;
13777 let retired = fixture
13778 .server
13779 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13780 worktree: fixture.alias.to_string(),
13781 }));
13782 require(
13783 retired.contains("status: retired")
13784 && retired.contains(&normalize_native_path_display(&moved_root))
13785 && !retired.contains(&original_root_display),
13786 &format!("retirement reporting retained the pre-move root: {retired}"),
13787 )
13788 }
13789
13790 #[test]
13791 fn registered_worktree_move_refreshes_registry_root_after_local_rebind()
13792 -> Result<(), Box<dyn std::error::Error>> {
13793 let fixture = registered_worktree_race_fixture("moved-root")?;
13794 let original_root = fixture.state.root.clone();
13795 let original_root_display = normalize_native_path_display(&original_root);
13796 let target_config = fixture
13797 .linked
13798 .join(PROJECTATLAS_DIR_NAME)
13799 .join(PROJECTATLAS_CONFIG_FILE_NAME);
13800 init_project_with_config(&fixture.linked, Some(&target_config))?;
13801 let target_store = AtlasStore::open_for_project(&fixture.target_db, &fixture.linked)?;
13802 let target_project = target_store
13803 .project_instance_id()?
13804 .ok_or(DbError::ProjectInstanceIdentityMissing)?;
13805 drop(target_store);
13806
13807 let registered = {
13808 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
13809 control.register_worktree(
13810 &fixture.alias,
13811 &fixture.control_root.join(".git"),
13812 &fixture.administrative_directory,
13813 &fixture.registration.git_administrative_identity,
13814 &original_root,
13815 Some(target_project),
13816 1,
13817 )?
13818 };
13819 require(
13820 registered.project_instance_id == Some(target_project),
13821 "moved worktree fixture remained unbound",
13822 )?;
13823 let original_registration = registered;
13824
13825 let moved = fixture
13826 .primary
13827 .parent()
13828 .ok_or_else(|| io::Error::other("moved worktree has no parent"))?
13829 .join("moved-worktree");
13830 run_fixture_command(
13831 StdCommand::new("git")
13832 .current_dir(&fixture.primary)
13833 .args(["worktree", "move"])
13834 .arg(&fixture.linked)
13835 .arg(&moved),
13836 )?;
13837 let moved_root = moved.canonicalize()?;
13838 let moved_db = moved_root
13839 .join(PROJECTATLAS_DIR_NAME)
13840 .join(PROJECTATLAS_DB_FILE_NAME);
13841 let moved_binding = AtlasStore::transition_project_root(
13842 &moved_db,
13843 &moved_root,
13844 ProjectRootTransition::Move,
13845 )?;
13846 require(
13847 moved_binding.project_instance_id == target_project,
13848 "local root rebind changed the registered project identity",
13849 )?;
13850 require(
13851 read_project_root_identity_read_only(&moved_db)?
13852 == Some(CanonicalProjectRoot::from_path(&moved_root)?),
13853 "local root rebind did not establish the moved native identity",
13854 )?;
13855
13856 let resolved = fixture
13857 .server
13858 .state_for_target(None, Some(fixture.alias.to_string()))?;
13859 require(
13860 resolved.root == moved_root,
13861 "alias resolution did not use the moved Git root",
13862 )?;
13863 let control = AtlasStore::open_for_project(&fixture.control_db, &fixture.control_root)?;
13864 let refreshed = control.worktree_registration(&fixture.alias)?;
13865 require(
13866 refreshed.last_root == normalize_native_path_display(&moved_root)
13867 && refreshed.last_root != original_root_display,
13868 "alias resolution retained the stale registry root",
13869 )?;
13870 require(
13871 refreshed.registration_id == original_registration.registration_id
13872 && refreshed.alias == original_registration.alias
13873 && refreshed.git_common_directory == original_registration.git_common_directory
13874 && refreshed.git_administrative_directory
13875 == original_registration.git_administrative_directory
13876 && refreshed.git_administrative_identity
13877 == original_registration.git_administrative_identity
13878 && refreshed.project_instance_id == original_registration.project_instance_id
13879 && refreshed.accepted_telemetry_revision
13880 == original_registration.accepted_telemetry_revision,
13881 "moved root refresh changed administrative identity or telemetry state",
13882 )?;
13883 drop(control);
13884
13885 run_fixture_command(
13886 StdCommand::new("git")
13887 .current_dir(&fixture.primary)
13888 .args(["worktree", "remove", "--force"])
13889 .arg(&moved),
13890 )?;
13891 let missing = fixture
13892 .server
13893 .atlas_worktree_list(Parameters(AtlasWorktreeListParams {
13894 include_retired: Some(false),
13895 }));
13896 require(
13897 missing.contains(&normalize_native_path_display(&moved_root))
13898 && !missing.contains(&original_root_display)
13899 && missing.contains("\"moved-root\",linked,available,missing,registered"),
13900 &format!("missing worktree reporting regressed to the pre-move root: {missing}"),
13901 )?;
13902 let retired = fixture
13903 .server
13904 .atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
13905 worktree: fixture.alias.to_string(),
13906 }));
13907 require(
13908 retired.contains("status: retired")
13909 && retired.contains(&normalize_native_path_display(&moved_root))
13910 && !retired.contains(&original_root_display),
13911 &format!("retirement did not preserve the moved root: {retired}"),
13912 )
13913 }
13914
13915 #[test]
13916 fn worktree_tools_register_route_and_retire_without_git_or_file_lifecycle_mutation()
13917 -> Result<(), Box<dyn std::error::Error>> {
13918 let temp = tempfile::tempdir()?;
13919 let primary = temp.path().join("selected control");
13920 let worktree_a = temp.path().join("unrelated one").join("checkout");
13921 let worktree_b = temp.path().join("unrelated two").join("checkout");
13922 fs::create_dir_all(&primary)?;
13923 fs::create_dir_all(
13924 worktree_a
13925 .parent()
13926 .ok_or_else(|| io::Error::other("worktree A has no parent"))?,
13927 )?;
13928 fs::create_dir_all(
13929 worktree_b
13930 .parent()
13931 .ok_or_else(|| io::Error::other("worktree B has no parent"))?,
13932 )?;
13933 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
13934 for (key, value) in [
13935 ("user.name", "ProjectAtlas Test"),
13936 ("user.email", "projectatlas@example.invalid"),
13937 ("commit.gpgsign", "false"),
13938 ("core.autocrlf", "false"),
13939 ] {
13940 run_fixture_command(
13941 StdCommand::new("git")
13942 .current_dir(&primary)
13943 .args(["config", key, value]),
13944 )?;
13945 }
13946 fs::create_dir(primary.join("src"))?;
13947 fs::write(
13948 primary.join("src").join("lib.rs"),
13949 "mod child;\npub fn main_only() { child::helper(); }\n",
13950 )?;
13951 fs::write(primary.join("src").join("child.rs"), "pub fn helper() {}\n")?;
13952 run_fixture_command(
13953 StdCommand::new("git")
13954 .current_dir(&primary)
13955 .args(["add", "."]),
13956 )?;
13957 run_fixture_command(
13958 StdCommand::new("git")
13959 .current_dir(&primary)
13960 .args(["commit", "-m", "fixture"]),
13961 )?;
13962 run_fixture_command(
13963 StdCommand::new("git")
13964 .current_dir(&primary)
13965 .args(["worktree", "add", "-b", "issue-430-a"])
13966 .arg(&worktree_a),
13967 )?;
13968 run_fixture_command(
13969 StdCommand::new("git")
13970 .current_dir(&primary)
13971 .args(["worktree", "add", "-b", "issue-430-b"])
13972 .arg(&worktree_b),
13973 )?;
13974 fs::write(
13975 worktree_a.join("src").join("branch.rs"),
13976 "pub fn worktree_only() {}\n",
13977 )?;
13978 let git_before = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
13979 "worktree",
13980 "list",
13981 "--porcelain",
13982 ]))?;
13983
13984 let control_db = primary.join(".projectatlas").join("projectatlas.db");
13985 let control_config = primary.join(".projectatlas").join("config.toml");
13986 init_project_with_config(&primary, Some(&control_config))?;
13987 let mut control_store = AtlasStore::open_for_project(&control_db, &primary)?;
13988 let control_plan = ScanRuntimePlan::for_path(Some(&control_config), &primary, None)?;
13989 run_scan_pipeline(
13990 &mut control_store,
13991 &control_plan,
13992 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
13993 )?;
13994 control_store.set_purpose(
13995 "src/lib.rs",
13996 "Own the shared library contract.",
13997 PurposeSource::Agent,
13998 )?;
13999 drop(control_store);
14000 let server = ProjectAtlasMcpServer::new(
14001 control_db.clone(),
14002 Some(control_config),
14003 "worktree-tools".to_string(),
14004 false,
14005 );
14006 let control_before_blank_selector = fs::read(&control_db)?;
14007 let blank_selector = server.atlas_reset_index(Parameters(AtlasResetIndexParams {
14008 project_path: None,
14009 worktree: Some(" ".to_string()),
14010 apply: Some(true),
14011 dry_run: Some(false),
14012 include_mcp_config: Some(true),
14013 }));
14014 require(
14015 blank_selector.contains(MCP_ERROR_WORKTREE_SELECTOR_EMPTY),
14016 "blank worktree selector fell back to the control atlas",
14017 )?;
14018 require(
14019 fs::read(&control_db)? == control_before_blank_selector,
14020 "blank worktree selector changed the control database",
14021 )?;
14022 let repository = server.control_git_repository()?;
14023 let canonical_a = worktree_a.canonicalize()?;
14024 let entry_a = repository
14025 .worktrees
14026 .iter()
14027 .find(|entry| {
14028 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(canonical_a.as_path())
14029 })
14030 .ok_or_else(|| io::Error::other("worktree A was not structurally discovered"))?;
14031 let selector_a = ProjectAtlasMcpServer::worktree_candidate_selector(entry_a);
14032 let canonical_b = worktree_b.canonicalize()?;
14033 let entry_b = repository
14034 .worktrees
14035 .iter()
14036 .find(|entry| {
14037 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(canonical_b.as_path())
14038 })
14039 .ok_or_else(|| io::Error::other("worktree B was not structurally discovered"))?;
14040 let selector_b = ProjectAtlasMcpServer::worktree_candidate_selector(entry_b);
14041
14042 let listed = server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
14043 include_retired: Some(false),
14044 }));
14045 require(
14046 listed.contains("control_alias: main")
14047 && listed.contains(&selector_a)
14048 && listed.contains(&normalize_native_path_display(&canonical_a)),
14049 "worktree list omitted control, stable selector, or arbitrary exact root",
14050 )?;
14051 let ambiguous = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14052 worktree: "checkout".to_string(),
14053 alias: Some("issue-430".to_string()),
14054 }));
14055 require(
14056 ambiguous.contains("status: ambiguous")
14057 && ambiguous.matches(MCP_WORKTREE_SELECTOR_PREFIX).count() >= 2,
14058 "ambiguous human selector guessed or omitted bounded stable candidates",
14059 )?;
14060 let control_before_blank_alias = fs::read(&control_db)?;
14061 let blank_alias = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14062 worktree: selector_a.clone(),
14063 alias: Some(" ".to_string()),
14064 }));
14065 require(
14066 blank_alias.contains("alias is empty"),
14067 "blank explicit alias fell back to the selected directory name",
14068 )?;
14069 require(
14070 fs::read(&control_db)? == control_before_blank_alias,
14071 "blank explicit alias changed the control database",
14072 )?;
14073
14074 let target_b_config = worktree_b.join(PROJECTATLAS_DIR_NAME).join("config.toml");
14075 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14076 let target_b_db = worktree_b
14077 .join(PROJECTATLAS_DIR_NAME)
14078 .join(PROJECTATLAS_DB_FILE_NAME);
14079 let target_b_store = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14080 target_b_store.record_usage(&usage_from_text(
14081 "snapshot-blocked",
14082 "atlas_overview",
14083 None,
14084 None,
14085 "pub fn main_only() {}",
14086 "repository overview",
14087 ))?;
14088 let target_b_project = target_b_store
14089 .project_instance_id()?
14090 .ok_or_else(|| io::Error::other("worktree B project identity is missing"))?;
14091 drop(target_b_store);
14092 let corrupt = rusqlite::Connection::open(&target_b_db)?;
14093 corrupt.execute_batch("PRAGMA ignore_check_constraints = ON;")?;
14094 let corrupted = corrupt.execute("UPDATE usage_global_aggregates SET calls = -1", [])?;
14095 require(
14096 corrupted > 0,
14097 "worktree B fixture did not invalidate an aggregate row",
14098 )?;
14099 drop(corrupt);
14100 let rejected_snapshot = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14101 worktree: selector_b.clone(),
14102 alias: Some("snapshot-blocked".to_string()),
14103 }));
14104 require(
14105 rejected_snapshot.contains("telemetry integer overflow")
14106 && !rejected_snapshot.contains("status: registered"),
14107 &format!(
14108 "worktree registration survived a failed local usage snapshot: {rejected_snapshot}"
14109 ),
14110 )?;
14111 let control_after_rejection =
14112 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14113 require(
14114 matches!(
14115 control_after_rejection
14116 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?),
14117 Err(DbError::WorktreeRegistrationNotFound { .. })
14118 ),
14119 "telemetry export failure committed a partial worktree registration",
14120 )?;
14121 drop(control_after_rejection);
14122 let repaired = rusqlite::Connection::open(&target_b_db)?;
14123 repaired.execute("UPDATE usage_global_aggregates SET calls = 1", [])?;
14124 drop(repaired);
14125 let registered_snapshot = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14126 worktree: selector_b.clone(),
14127 alias: Some("snapshot-blocked".to_string()),
14128 }));
14129 require(
14130 registered_snapshot.contains("status: registered"),
14131 &format!(
14132 "repaired local usage snapshot did not register atomically: {registered_snapshot}"
14133 ),
14134 )?;
14135 let control_after_snapshot = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14136 require(
14137 control_after_snapshot
14138 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?)?
14139 .project_instance_id
14140 == Some(target_b_project)
14141 && control_after_snapshot
14142 .registered_worktree_token_overview(&WorktreeAlias::parse("snapshot-blocked")?)?
14143 .calls
14144 == 1,
14145 "successful registration did not bind identity and import telemetry atomically",
14146 )?;
14147 drop(control_after_snapshot);
14148 let target_b_state = worktree_b.join(PROJECTATLAS_DIR_NAME);
14149 let preserved_target_b_state = worktree_b.join(".projectatlas-snapshot-blocked");
14150 fs::rename(&target_b_state, &preserved_target_b_state)?;
14151 fs::create_dir(&target_b_state)?;
14152 let replacement_b = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14153 require(
14154 replacement_b.project_instance_id()? != Some(target_b_project),
14155 "worktree B replacement reused the registered project identity",
14156 )?;
14157 drop(replacement_b);
14158 let replacement_b_error =
14159 server.state_for_target(None, Some("snapshot-blocked".to_string()));
14160 require(
14161 replacement_b_error.as_ref().is_err_and(|error| {
14162 error
14163 .to_string()
14164 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14165 }),
14166 "snapshot-backed registration routed to a replacement atlas",
14167 )?;
14168 fs::remove_dir_all(&target_b_state)?;
14169 let refused_snapshot =
14170 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14171 worktree: "snapshot-blocked".to_string(),
14172 }));
14173 require(
14174 refused_snapshot.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
14175 &format!(
14176 "bound registration retired without its required final snapshot: {refused_snapshot}"
14177 ),
14178 )?;
14179 let control_after_refusal = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14180 require(
14181 control_after_refusal
14182 .worktree_registration(&WorktreeAlias::parse("snapshot-blocked")?)?
14183 .state
14184 == WorktreeRegistrationState::Active,
14185 "missing bound atlas retirement changed the active registration",
14186 )?;
14187 drop(control_after_refusal);
14188 fs::rename(&preserved_target_b_state, &target_b_state)?;
14189 let retired_snapshot =
14190 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14191 worktree: "snapshot-blocked".to_string(),
14192 }));
14193 require(
14194 retired_snapshot.contains("status: retired"),
14195 &format!(
14196 "restored bound atlas did not permit final-sync retirement: {retired_snapshot}"
14197 ),
14198 )?;
14199 fs::remove_dir_all(&target_b_state)?;
14200
14201 let legacy_added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14202 worktree: selector_b.clone(),
14203 alias: Some("legacy-init".to_string()),
14204 }));
14205 require(
14206 legacy_added.contains("status: registered"),
14207 &format!("legacy-init fixture registration failed: {legacy_added}"),
14208 )?;
14209 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14210 let control_before_legacy_sync =
14211 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14212 require(
14213 control_before_legacy_sync
14214 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14215 .project_instance_id
14216 .is_none(),
14217 "independent exact-path init unexpectedly mutated the control registration",
14218 )?;
14219 drop(control_before_legacy_sync);
14220 let legacy_target = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14221 legacy_target.record_usage(&usage_from_text(
14222 "legacy-init",
14223 "atlas_overview",
14224 None,
14225 None,
14226 "pub fn independent() {}",
14227 "repository overview",
14228 ))?;
14229 let legacy_project = legacy_target
14230 .project_instance_id()?
14231 .ok_or_else(|| io::Error::other("legacy-init target identity is missing"))?;
14232 drop(legacy_target);
14233 let legacy_state = server.state_for_target(None, Some("legacy-init".to_string()))?;
14234 let legacy_selection = legacy_state
14235 .worktree
14236 .as_ref()
14237 .ok_or_else(|| io::Error::other("legacy-init selection is missing"))?;
14238 let corrupt = rusqlite::Connection::open(&target_b_db)?;
14239 corrupt.execute_batch("PRAGMA ignore_check_constraints = ON;")?;
14240 corrupt.execute("UPDATE usage_global_aggregates SET calls = -1", [])?;
14241 drop(corrupt);
14242 let rejected_bind = ProjectAtlasMcpServer::open_registered_worktree_mut_store(
14243 &legacy_state,
14244 &server.control_state,
14245 legacy_selection,
14246 );
14247 let control_after_rejected_bind =
14248 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14249 require(
14250 rejected_bind
14251 .as_ref()
14252 .is_err_and(|error| error.to_string().contains("telemetry integer overflow"))
14253 && control_after_rejected_bind
14254 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14255 .project_instance_id
14256 .is_none()
14257 && control_after_rejected_bind
14258 .registered_worktree_token_overview(&WorktreeAlias::parse("legacy-init")?)?
14259 .calls
14260 == 0,
14261 "failed deferred snapshot synchronization committed its project binding or aggregate",
14262 )?;
14263 drop(control_after_rejected_bind);
14264 let repaired = rusqlite::Connection::open(&target_b_db)?;
14265 repaired.execute("UPDATE usage_global_aggregates SET calls = 1", [])?;
14266 drop(repaired);
14267 drop(ProjectAtlasMcpServer::open_registered_worktree_mut_store(
14268 &legacy_state,
14269 &server.control_state,
14270 legacy_selection,
14271 )?);
14272 let control_after_legacy_init =
14273 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14274 require(
14275 control_after_legacy_init
14276 .worktree_registration(&WorktreeAlias::parse("legacy-init")?)?
14277 .project_instance_id
14278 == Some(legacy_project),
14279 "first alias mutation did not bind the independently initialized alias",
14280 )?;
14281 require(
14282 control_after_legacy_init
14283 .registered_worktree_token_overview(&WorktreeAlias::parse("legacy-init")?)?
14284 .calls
14285 == 1,
14286 "first alias mutation omitted independently initialized worktree usage",
14287 )?;
14288 drop(control_after_legacy_init);
14289 let preserved_legacy_state = worktree_b.join(".projectatlas-legacy-init");
14290 fs::rename(&target_b_state, &preserved_legacy_state)?;
14291 fs::create_dir(&target_b_state)?;
14292 let legacy_replacement = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14293 require(
14294 legacy_replacement.project_instance_id()? != Some(legacy_project),
14295 "legacy-init replacement reused the registered project identity",
14296 )?;
14297 drop(legacy_replacement);
14298 let legacy_replacement_error =
14299 server.state_for_target(None, Some("legacy-init".to_string()));
14300 require(
14301 legacy_replacement_error.as_ref().is_err_and(|error| {
14302 error
14303 .to_string()
14304 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14305 }),
14306 "legacy exact-path init left its alias unbound to a replacement atlas",
14307 )?;
14308 fs::remove_dir_all(&target_b_state)?;
14309 let refused_legacy = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14310 worktree: "legacy-init".to_string(),
14311 }));
14312 require(
14313 refused_legacy.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
14314 &format!("legacy-init registration retired without its atlas: {refused_legacy}"),
14315 )?;
14316 fs::rename(&preserved_legacy_state, &target_b_state)?;
14317 let retired_legacy = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14318 worktree: "legacy-init".to_string(),
14319 }));
14320 require(
14321 retired_legacy.contains("status: retired"),
14322 &format!("restored legacy-init registration could not be retired: {retired_legacy}"),
14323 )?;
14324 fs::remove_dir_all(&target_b_state)?;
14325
14326 init_project_with_config(&worktree_b, Some(&target_b_config))?;
14327 let migratable_store = AtlasStore::open_for_project(&target_b_db, &worktree_b)?;
14328 let migratable_project = migratable_store
14329 .project_instance_id()?
14330 .ok_or_else(|| io::Error::other("migratable target identity is missing"))?;
14331 drop(migratable_store);
14332 let incomplete_current = rusqlite::Connection::open(&target_b_db)?;
14333 incomplete_current.execute(
14334 "UPDATE metadata SET value = ?1 WHERE key = 'project_root'",
14335 [worktree_b.join(".").to_string_lossy().into_owned()],
14336 )?;
14337 incomplete_current.execute_batch(
14343 "DROP INDEX IF EXISTS idx_worktree_registrations_active_native_administrative_directory;
14344 DROP INDEX IF EXISTS idx_worktree_registrations_active_native_root;
14345 ALTER TABLE worktree_registrations DROP COLUMN git_common_directory_identity;
14346 ALTER TABLE worktree_registrations DROP COLUMN git_administrative_directory_identity;
14347 ALTER TABLE worktree_registrations DROP COLUMN last_root_identity;
14348 DROP TABLE graph_identity_rejections;
14349 UPDATE metadata SET value = '20' WHERE key = 'schema_version';",
14350 )?;
14351 drop(incomplete_current);
14352 let migratable_added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14353 worktree: selector_b,
14354 alias: Some("migratable-atlas".to_string()),
14355 }));
14356 require(
14357 migratable_added.contains("status: registered")
14358 && migratable_added
14359 .contains("registration committed without local telemetry import"),
14360 &format!(
14361 "current repair atlas did not retain its explicit unbound registration: {migratable_added}"
14362 ),
14363 )?;
14364 let control_before_migration =
14365 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14366 require(
14367 control_before_migration
14368 .worktree_registration(&WorktreeAlias::parse("migratable-atlas")?)?
14369 .project_instance_id
14370 .is_none(),
14371 "current repair fixture unexpectedly bound before scan",
14372 )?;
14373 drop(control_before_migration);
14374 let migrated = server.atlas_scan(Parameters(AtlasScanParams {
14375 project_path: None,
14376 worktree: Some("migratable-atlas".to_string()),
14377 path: None,
14378 nearest_project: Some(false),
14379 max_bytes: None,
14380 max_workers: Some(1),
14381 timeout_seconds: None,
14382 text_index_max_bytes: None,
14383 background: Some(false),
14384 }));
14385 require(
14386 migrated.contains("scan:"),
14387 &format!("alias-routed current repair did not complete its scan: {migrated}"),
14388 )?;
14389 let migrated_target = open_atlas_store_read_only_for_project(&target_b_db, &worktree_b)?;
14390 require(
14391 migrated_target.project_instance_id()? == Some(migratable_project),
14392 "current repair changed the worktree project identity",
14393 )?;
14394 drop(migrated_target);
14395 let control_after_migration =
14396 open_atlas_store_read_only_for_project(&control_db, &primary)?;
14397 require(
14398 control_after_migration
14399 .worktree_registration(&WorktreeAlias::parse("migratable-atlas")?)?
14400 .project_instance_id
14401 == Some(migratable_project),
14402 "alias-routed current repair left its registration unbound",
14403 )?;
14404 drop(control_after_migration);
14405 let retired_migratable =
14406 server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14407 worktree: "migratable-atlas".to_string(),
14408 }));
14409 require(
14410 retired_migratable.contains("status: retired"),
14411 &format!("current-repair registration could not be retired: {retired_migratable}"),
14412 )?;
14413
14414 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
14415 worktree: selector_a,
14416 alias: Some("issue-430".to_string()),
14417 }));
14418 require(
14419 added.contains("status: registered")
14420 && added.contains("alias: \"issue-430\"")
14421 && added.contains("git_unchanged: true")
14422 && added.contains("files_unchanged: true"),
14423 &format!("stable selector did not create one lifecycle-neutral registration: {added}"),
14424 )?;
14425 require(
14426 !worktree_a.join(".projectatlas").exists(),
14427 "registration created a target atlas before explicit init",
14428 )?;
14429
14430 let missing = server.atlas_overview_response(
14431 AtlasProjectParams {
14432 project_path: None,
14433 worktree: Some("issue-430".to_string()),
14434 },
14435 None,
14436 );
14437 require(
14438 missing.contains("init_required")
14439 && missing.contains("tool: atlas_init")
14440 && missing.contains("worktree: \"issue-430\"")
14441 && !missing.contains("project_path:"),
14442 "missing alias target did not preserve its short selector in typed init guidance",
14443 )?;
14444 let conflict = server.atlas_overview_response(
14445 AtlasProjectParams {
14446 project_path: Some(primary.to_string_lossy().to_string()),
14447 worktree: Some("issue-430".to_string()),
14448 },
14449 None,
14450 );
14451 require(
14452 conflict.contains(MCP_WORKTREE_PROJECT_PATH_CONFLICT),
14453 "worktree/project_path conflict was not rejected by the shared resolver",
14454 )?;
14455
14456 let initialized = server.atlas_init(Parameters(AtlasInitParams {
14457 project_path: None,
14458 worktree: Some("issue-430".to_string()),
14459 no_scan: Some(false),
14460 force_rescan: Some(false),
14461 text_index_max_bytes: None,
14462 }));
14463 require(
14464 initialized.contains("status: hydrated")
14465 && initialized.contains("source_project_instance_id:")
14466 && initialized.contains("target_project_instance_id:")
14467 && initialized.contains("reconciled_generation:")
14468 && initialized.contains("parsed: 1"),
14469 &format!("registered alias init did not expose one completed hydration: {initialized}"),
14470 )?;
14471 let target_db = worktree_a.join(".projectatlas").join("projectatlas.db");
14472 let target_store = open_atlas_store_read_only_for_project(&target_db, &worktree_a)?;
14473 require(
14474 target_store.load_node_by_path("src/branch.rs")?.is_some(),
14475 "hydration reconciliation omitted a target-only dirty source file",
14476 )?;
14477 require(
14478 target_store
14479 .load_node_by_path("src/lib.rs")?
14480 .is_some_and(|node| {
14481 node.purpose.purpose.as_deref() == Some("Own the shared library contract.")
14482 && node.purpose.source == PurposeSource::Agent
14483 }),
14484 "hydration did not preserve an applicable approved main purpose",
14485 )?;
14486 let target_project = target_store
14487 .project_instance_id()?
14488 .ok_or_else(|| io::Error::other("hydrated target identity is missing"))?;
14489 drop(target_store);
14490 let captured_alias_state = server.state_for_target(None, Some("issue-430".to_string()))?;
14491 let captured_federated_aliases = ["main".to_string(), "issue-430".to_string()];
14492 let (captured_federated_roots, captured_federated_selections) =
14493 server.federated_worktree_roots(&captured_federated_aliases)?;
14494 let captured_main_state = server.state_for_target(None, Some("main".to_string()))?;
14495 let control_after_init = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14496 let control_project = control_after_init
14497 .project_instance_id()?
14498 .ok_or_else(|| io::Error::other("control project identity is missing"))?;
14499 require(
14500 captured_main_state
14501 .worktree
14502 .as_ref()
14503 .and_then(|selection| selection.project_instance_id)
14504 == Some(control_project),
14505 "main alias did not capture the current control atlas identity",
14506 )?;
14507 require(
14508 [&captured_main_state, &captured_alias_state]
14509 .iter()
14510 .all(|state| {
14511 state
14512 .worktree
14513 .as_ref()
14514 .and_then(|selection| selection.control_project_instance_id)
14515 == Some(control_project)
14516 }),
14517 "alias selection did not capture its control atlas identity",
14518 )?;
14519 require(
14520 control_after_init
14521 .load_node_by_path("src/branch.rs")?
14522 .is_none(),
14523 "target-only source state bled into the control graph",
14524 )?;
14525 require(
14526 control_after_init
14527 .worktree_registration(&WorktreeAlias::parse("issue-430")?)?
14528 .project_instance_id
14529 == Some(target_project),
14530 "successful alias init did not bind the exact target atlas identity",
14531 )?;
14532 drop(control_after_init);
14533 let target_before_reset = fs::read(&target_db)?;
14534 let bound_reset = server.atlas_reset_index(Parameters(AtlasResetIndexParams {
14535 project_path: None,
14536 worktree: Some("issue-430".to_string()),
14537 apply: Some(true),
14538 dry_run: Some(false),
14539 include_mcp_config: Some(true),
14540 }));
14541 require(
14542 bound_reset.contains(MCP_ERROR_BOUND_WORKTREE_RESET_UNSUPPORTED),
14543 "applied reset did not reject a bound worktree alias",
14544 )?;
14545 require(
14546 fs::read(&target_db)? == target_before_reset,
14547 "rejected bound worktree reset changed its database",
14548 )?;
14549 let control_state = primary.join(PROJECTATLAS_DIR_NAME);
14550 let preserved_control_state = primary.join(".projectatlas-captured-main");
14551 fs::rename(&control_state, &preserved_control_state)?;
14552 fs::create_dir(&control_state)?;
14553 init_project_with_config(&primary, captured_main_state.config_path.as_deref())?;
14554 let mut replacement_control = AtlasStore::open_for_project(&control_db, &primary)?;
14555 let replacement_control_plan =
14556 ScanRuntimePlan::for_path(captured_main_state.config_path.as_deref(), &primary, None)?;
14557 run_scan_pipeline(
14558 &mut replacement_control,
14559 &replacement_control_plan,
14560 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
14561 )?;
14562 require(
14563 replacement_control.project_instance_id()? != Some(control_project),
14564 "replacement control atlas reused the captured main identity",
14565 )?;
14566 let captured_registration_id = captured_alias_state
14567 .worktree
14568 .as_ref()
14569 .and_then(|selection| selection.registration_id)
14570 .ok_or_else(|| io::Error::other("captured alias registration identity is missing"))?;
14571 let common = primary.join(".git");
14572 let mut replacement_alias = None;
14573 for index in 1..=captured_registration_id {
14574 let alias = WorktreeAlias::parse(&format!("replacement-{index}"))?;
14575 let registration = replacement_control.register_worktree(
14576 &alias,
14577 &common,
14578 &common
14579 .join("worktrees")
14580 .join(format!("replacement-{index}")),
14581 &format!("{index:064x}"),
14582 &temp.path().join(format!("replacement-worktree-{index}")),
14583 Some(ProjectInstanceId::from_bytes([u8::try_from(index)?; 16])?),
14584 u64::try_from(index)?,
14585 )?;
14586 require(
14587 registration.registration_id == index,
14588 "replacement control did not reuse the expected registration identity",
14589 )?;
14590 if index == captured_registration_id {
14591 replacement_alias = Some(alias);
14592 }
14593 }
14594 let replacement_alias = replacement_alias
14595 .ok_or_else(|| io::Error::other("replacement alias was not created"))?;
14596 let replacement_calls_before = replacement_control
14597 .registered_worktree_token_overview(&replacement_alias)?
14598 .calls;
14599 require(
14600 ProjectAtlasMcpServer::require_captured_control_identity(
14601 captured_alias_state.worktree.as_ref(),
14602 &replacement_control,
14603 )
14604 .as_ref()
14605 .is_err_and(|error| {
14606 error
14607 .to_string()
14608 .contains(MCP_ERROR_WORKTREE_CONTROL_IDENTITY_CONFLICT)
14609 }),
14610 "captured alias accepted a replacement control atlas",
14611 )?;
14612 drop(replacement_control);
14613 let accepted = server.with_fresh_string_and_usage_for_request(
14614 &captured_alias_state,
14615 None,
14616 |_store, _stamp| {
14617 Ok((
14618 "accepted result".to_string(),
14619 Some(McpUsageIntent::estimate(
14620 MCP_EVENT_ATLAS_OVERVIEW,
14621 None,
14622 None,
14623 1,
14624 )),
14625 ))
14626 },
14627 )?;
14628 require(
14629 accepted == "accepted result",
14630 "accepted target read was lost",
14631 )?;
14632 let replacement_control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14633 require(
14634 replacement_control
14635 .registered_worktree_token_overview(&replacement_alias)?
14636 .calls
14637 == replacement_calls_before,
14638 "deferred telemetry was attributed through a replacement control catalog",
14639 )?;
14640 drop(replacement_control);
14641 require(
14642 matches!(
14643 synchronize_registered_worktree_usage(
14644 &captured_main_state.db_path,
14645 &captured_main_state.root,
14646 Some(control_project),
14647 ),
14648 Err(CliError::InvalidInput(message))
14649 if message.contains("control atlas identity changed")
14650 ),
14651 "main token synchronization did not revalidate its captured project identity",
14652 )?;
14653 let captured_main_direct_read =
14654 ProjectAtlasMcpServer::open_read_store(&captured_main_state);
14655 require(
14656 captured_main_direct_read.as_ref().is_err_and(|error| {
14657 error
14658 .to_string()
14659 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14660 }),
14661 "main direct token read did not revalidate its captured project identity",
14662 )?;
14663 let captured_main_read =
14664 server.with_fresh_store(&captured_main_state, |_store, _stamp| Ok(()));
14665 require(
14666 captured_main_read.as_ref().is_err_and(|error| {
14667 error
14668 .to_string()
14669 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14670 }),
14671 "main read snapshot did not revalidate its captured project identity",
14672 )?;
14673 let captured_main_write = ProjectAtlasMcpServer::open_existing_mut_store(
14674 &captured_main_state,
14675 &server.control_state,
14676 );
14677 require(
14678 captured_main_write.as_ref().is_err_and(|error| {
14679 error
14680 .to_string()
14681 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14682 }),
14683 "main mutation did not revalidate its captured project identity",
14684 )?;
14685 let captured_federated_labels = captured_federated_selections
14686 .iter()
14687 .map(|selection| selection.alias.clone())
14688 .collect::<Vec<_>>();
14689 let federated_control =
14690 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
14691 let captured_federated_stores = open_federated_atlas_stores_for_project(
14692 &captured_main_state.db_path,
14693 &captured_main_state.root,
14694 captured_main_state.config_path.as_deref(),
14695 &captured_federated_roots,
14696 Some(&captured_federated_labels),
14697 &federated_control,
14698 )?;
14699 let captured_main_federation = ProjectAtlasMcpServer::require_federated_worktree_identities(
14700 captured_federated_stores,
14701 &captured_federated_selections,
14702 );
14703 require(
14704 captured_main_federation.as_ref().is_err_and(|error| {
14705 error
14706 .to_string()
14707 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14708 && error.to_string().contains(MCP_MAIN_WORKTREE_ALIAS)
14709 }),
14710 "federation did not revalidate the captured main identity",
14711 )?;
14712 fs::remove_dir_all(&control_state)?;
14713 fs::rename(&preserved_control_state, &control_state)?;
14714 let target_state = worktree_a.join(PROJECTATLAS_DIR_NAME);
14715 let preserved_target_state = worktree_a.join(".projectatlas-registered");
14716 fs::rename(&target_state, &preserved_target_state)?;
14717 fs::create_dir(&target_state)?;
14718 init_project_with_config(&worktree_a, captured_alias_state.config_path.as_deref())?;
14719 let mut replacement_store = AtlasStore::open_for_project(&target_db, &worktree_a)?;
14720 let replacement_plan = ScanRuntimePlan::for_path(
14721 captured_alias_state.config_path.as_deref(),
14722 &worktree_a,
14723 None,
14724 )?;
14725 run_scan_pipeline(
14726 &mut replacement_store,
14727 &replacement_plan,
14728 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
14729 )?;
14730 let replacement_project = replacement_store
14731 .project_instance_id()?
14732 .ok_or_else(|| io::Error::other("replacement target identity is missing"))?;
14733 require(
14734 replacement_project != target_project,
14735 "replacement atlas reused the registered project identity",
14736 )?;
14737 drop(replacement_store);
14738 let captured_read = server.with_fresh_store(&captured_alias_state, |_store, _stamp| Ok(()));
14739 require(
14740 captured_read.as_ref().is_err_and(|error| {
14741 error
14742 .to_string()
14743 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14744 }),
14745 "alias read snapshot did not revalidate its captured project identity",
14746 )?;
14747 let captured_federated_labels = captured_federated_selections
14748 .iter()
14749 .map(|selection| selection.alias.clone())
14750 .collect::<Vec<_>>();
14751 let federated_control =
14752 index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
14753 let captured_federated_stores = open_federated_atlas_stores_for_project(
14754 &captured_main_state.db_path,
14755 &captured_main_state.root,
14756 captured_main_state.config_path.as_deref(),
14757 &captured_federated_roots,
14758 Some(&captured_federated_labels),
14759 &federated_control,
14760 )?;
14761 let captured_federation = ProjectAtlasMcpServer::require_federated_worktree_identities(
14762 captured_federated_stores,
14763 &captured_federated_selections,
14764 );
14765 require(
14766 captured_federation.as_ref().is_err_and(|error| {
14767 error
14768 .to_string()
14769 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14770 && error.to_string().contains("issue-430")
14771 }),
14772 "federated snapshots did not retain their captured alias identities",
14773 )?;
14774 let replacement_error = server.state_for_target(None, Some("issue-430".to_string()));
14775 require(
14776 replacement_error.as_ref().is_err_and(|error| {
14777 error
14778 .to_string()
14779 .contains(MCP_ERROR_WORKTREE_IDENTITY_CONFLICT)
14780 }),
14781 "alias routing accepted a replacement atlas at the registered root",
14782 )?;
14783 fs::remove_dir_all(&target_state)?;
14784 fs::rename(&preserved_target_state, &target_state)?;
14785 let selected = server.state_for_target(None, Some("issue-430".to_string()))?;
14786 require(
14787 selected.root == canonical_a
14788 && selected
14789 .worktree
14790 .as_ref()
14791 .is_some_and(|selection| selection.alias == "issue-430"),
14792 "short alias did not capture the exact target root and identity",
14793 )?;
14794 let selected_diagnostics = ProjectAtlasMcpServer::render_project_state(&selected)?;
14795 require(
14796 selected_diagnostics.contains("worktree: \"issue-430\"")
14797 && selected_diagnostics.contains("registration_id:"),
14798 &format!(
14799 "selected-project diagnostics omitted the captured alias or registration identity: {selected_diagnostics}"
14800 ),
14801 )?;
14802 let settings = server.atlas_settings(Parameters(AtlasProjectParams {
14803 project_path: None,
14804 worktree: Some("issue-430".to_string()),
14805 }));
14806 require(
14807 settings.contains("worktree: \"issue-430\"") && settings.contains("registration_id:"),
14808 &format!(
14809 "alias-routed settings omitted the captured alias or registration identity: {settings}"
14810 ),
14811 )?;
14812 let root_report = server.atlas_root(Parameters(AtlasRootParams {
14813 project_path: None,
14814 worktree: Some("issue-430".to_string()),
14815 verify: Some(false),
14816 control_root: None,
14817 }));
14818 require(
14819 root_report.contains("worktree: \"issue-430\"")
14820 && root_report.contains("registration_id:"),
14821 &format!(
14822 "alias-routed root diagnostics omitted the captured alias or registration identity: {root_report}"
14823 ),
14824 )?;
14825 let routed_overview = server.atlas_overview_response(
14826 AtlasProjectParams {
14827 project_path: None,
14828 worktree: Some("issue-430".to_string()),
14829 },
14830 None,
14831 );
14832 require(
14833 routed_overview.contains("overview:") && routed_overview.contains("files:"),
14834 &format!("alias-routed overview failed: {routed_overview}"),
14835 )?;
14836 let alias = WorktreeAlias::parse("issue-430")?;
14837 let control_after_routed = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14838 require(
14839 control_after_routed
14840 .registered_worktree_token_overview(&alias)?
14841 .calls
14842 == 1
14843 && control_after_routed.repository_token_overview()?.calls == 3,
14844 "alias-routed MCP usage or retained independently initialized usage was miscounted",
14845 )?;
14846 drop(control_after_routed);
14847 let local_event = usage_from_text(
14848 "worktree-local",
14849 "atlas_summary",
14850 Some("src/lib.rs".to_string()),
14851 None,
14852 "pub fn main_only() { child::helper(); }",
14853 "Own the shared library contract.",
14854 );
14855 let local_target = AtlasStore::open_for_project(&target_db, &worktree_a)?;
14856 local_target.record_usage(&local_event)?;
14857 require(
14858 local_target.token_overview(None)?.calls == 1,
14859 "independent worktree usage was not retained in its exact local atlas",
14860 )?;
14861 drop(local_target);
14862 let repository_tokens = server.atlas_token_report(Parameters(AtlasTokenParams {
14863 project_path: None,
14864 worktree: Some("main".to_string()),
14865 session: None,
14866 include_chart: Some(false),
14867 trend_window: None,
14868 benchmark_results: None,
14869 theme: None,
14870 }));
14871 require(
14872 repository_tokens.contains("worktree: main") && repository_tokens.contains("calls: 4"),
14873 &format!(
14874 "control token report did not combine routed and synchronized worktree usage: {repository_tokens}"
14875 ),
14876 )?;
14877 let worktree_tokens = server.atlas_token_report(Parameters(AtlasTokenParams {
14878 project_path: None,
14879 worktree: Some("issue-430".to_string()),
14880 session: None,
14881 include_chart: Some(false),
14882 trend_window: None,
14883 benchmark_results: None,
14884 theme: None,
14885 }));
14886 require(
14887 worktree_tokens.contains("worktree: \"issue-430\"")
14888 && worktree_tokens.contains("calls: 1"),
14889 &format!(
14890 "exact worktree token report included routed or sibling usage: {worktree_tokens}"
14891 ),
14892 )?;
14893 let federated = server.atlas_symbol_relations_response(
14894 &AtlasSymbolRelationsParams {
14895 file: Some("src/lib.rs".to_string()),
14896 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
14897 compact: Some(true),
14898 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
14899 limit: Some(1),
14900 ..AtlasSymbolRelationsParams::default()
14901 },
14902 None,
14903 );
14904 require(
14905 federated.contains("primary_worktree: main")
14906 && federated.contains("participants[2]{order,worktree")
14907 && federated.contains("0,main,")
14908 && federated.contains("1,\"issue-430\","),
14909 &format!(
14910 "alias federation did not label the primary and every participant: {federated}"
14911 ),
14912 )?;
14913 let federation_conflict = server.atlas_symbol_relations_response(
14914 &AtlasSymbolRelationsParams {
14915 file: Some("src/lib.rs".to_string()),
14916 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
14917 roots: Some(vec![
14918 normalize_native_path_display(&primary),
14919 normalize_native_path_display(&worktree_a),
14920 ]),
14921 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
14922 ..AtlasSymbolRelationsParams::default()
14923 },
14924 None,
14925 );
14926 require(
14927 federation_conflict.contains(MCP_ERROR_FEDERATED_SELECTOR_CONFLICT),
14928 "alias federation did not reject legacy roots before opening participants",
14929 )?;
14930 let target_database_before_blocker = fs::read(&target_db)?;
14931 fs::write(
14932 worktree_a.join("src").join("branch.rs"),
14933 "pub fn worktree_only_changed() {}\n",
14934 )?;
14935 let blocked_federation = server.atlas_symbol_relations_response(
14936 &AtlasSymbolRelationsParams {
14937 file: Some("src/lib.rs".to_string()),
14938 view: Some(MCP_SYMBOL_RELATION_VIEW_DETAILED.to_string()),
14939 worktrees: Some(vec!["main".to_string(), "issue-430".to_string()]),
14940 ..AtlasSymbolRelationsParams::default()
14941 },
14942 None,
14943 );
14944 require(
14945 blocked_federation.contains("refresh_required")
14946 && blocked_federation.contains("worktree: \"issue-430\""),
14947 &format!(
14948 "stale federated participant blocker omitted its exact worktree alias: {blocked_federation}"
14949 ),
14950 )?;
14951 require(
14952 fs::read(&target_db)? == target_database_before_blocker,
14953 "read-only alias federation repaired or changed a sibling database",
14954 )?;
14955 let main = server.state_for_target(None, Some("main".to_string()))?;
14956 require(
14957 main.root == primary.canonicalize()? && selected.root != main.root,
14958 "interleaved main and worktree selections bled into one target",
14959 )?;
14960 let source_before = fs::read(worktree_a.join("src").join("lib.rs"))?;
14961 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
14962 worktree: "issue-430".to_string(),
14963 }));
14964 require(
14965 removed.contains("status: retired")
14966 && removed.contains("git_unchanged: true")
14967 && removed.contains("files_unchanged: true"),
14968 "unregister did not retire the alias with lifecycle-neutral status",
14969 )?;
14970 require(
14971 target_db.is_file()
14972 && worktree_a.join(".git").is_file()
14973 && fs::read(worktree_a.join("src").join("lib.rs"))? == source_before,
14974 "unregister deleted or modified target-owned Git, source, or atlas state",
14975 )?;
14976 let git_after = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
14977 "worktree",
14978 "list",
14979 "--porcelain",
14980 ]))?;
14981 require(
14982 git_after == git_before,
14983 "ProjectAtlas worktree operations changed Git lifecycle state",
14984 )?;
14985 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
14986 let registrations = control.worktree_registrations(true)?;
14987 require(
14988 registrations.iter().any(|registration| {
14989 registration.alias.as_str() == "issue-430"
14990 && registration.state == WorktreeRegistrationState::Retired
14991 }),
14992 "retired registration and retained telemetry identity were not durable",
14993 )?;
14994 require(
14995 server
14996 .state_for_target(None, Some("issue-430".to_string()))
14997 .is_err(),
14998 "retired alias remained selectable for source operations",
14999 )
15000 }
15001
15002 #[test]
15003 fn uninitialized_alias_rejects_a_reused_git_administrative_path()
15004 -> Result<(), Box<dyn std::error::Error>> {
15005 let temp = tempfile::tempdir()?;
15006 let primary = temp.path().join("control");
15007 let original = temp.path().join("first location").join("checkout");
15008 let replacement = temp.path().join("second location").join("checkout");
15009 fs::create_dir_all(&primary)?;
15010 fs::create_dir_all(
15011 original
15012 .parent()
15013 .ok_or_else(|| io::Error::other("original worktree has no parent"))?,
15014 )?;
15015 fs::create_dir_all(
15016 replacement
15017 .parent()
15018 .ok_or_else(|| io::Error::other("replacement worktree has no parent"))?,
15019 )?;
15020 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15021 for (key, value) in [
15022 ("user.name", "ProjectAtlas Test"),
15023 ("user.email", "projectatlas@example.invalid"),
15024 ("commit.gpgsign", "false"),
15025 ] {
15026 run_fixture_command(
15027 StdCommand::new("git")
15028 .current_dir(&primary)
15029 .args(["config", key, value]),
15030 )?;
15031 }
15032 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15033 run_fixture_command(
15034 StdCommand::new("git")
15035 .current_dir(&primary)
15036 .args(["add", "."]),
15037 )?;
15038 run_fixture_command(
15039 StdCommand::new("git")
15040 .current_dir(&primary)
15041 .args(["commit", "-m", "fixture"]),
15042 )?;
15043 run_fixture_command(
15044 StdCommand::new("git")
15045 .current_dir(&primary)
15046 .args(["worktree", "add", "-b", "original"])
15047 .arg(&original),
15048 )?;
15049
15050 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15051 let control_config = primary.join(".projectatlas").join("config.toml");
15052 init_project_with_config(&primary, Some(&control_config))?;
15053 drop(AtlasStore::open_for_project(&control_db, &primary)?);
15054 let server = ProjectAtlasMcpServer::new(
15055 control_db,
15056 Some(control_config),
15057 "worktree-lifecycle".to_string(),
15058 false,
15059 );
15060 let repository = server.control_git_repository()?;
15061 let original_root = original.canonicalize()?;
15062 let original_entry = repository
15063 .worktrees
15064 .iter()
15065 .find(|entry| {
15066 ProjectAtlasMcpServer::active_worktree_root(entry) == Some(original_root.as_path())
15067 })
15068 .ok_or_else(|| io::Error::other("original worktree was not discovered"))?;
15069 let administrative_directory = original_entry.administrative_directory.clone();
15070 let administrative_identity = git_administrative_identity(&administrative_directory)?;
15071 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
15072 worktree: ProjectAtlasMcpServer::worktree_candidate_selector(original_entry),
15073 alias: Some("replacement-check".to_string()),
15074 }));
15075 require(
15076 added.contains("status: registered"),
15077 &format!("original uninitialized worktree was not registered: {added}"),
15078 )?;
15079
15080 run_fixture_command(
15081 StdCommand::new("git")
15082 .current_dir(&primary)
15083 .args(["worktree", "remove", "--force"])
15084 .arg(&original),
15085 )?;
15086 let missing_registration =
15087 server.atlas_worktree_list(Parameters(AtlasWorktreeListParams {
15088 include_retired: Some(false),
15089 }));
15090 require(
15091 missing_registration
15092 .contains("\"replacement-check\",linked,available,missing,registered")
15093 && missing_registration.contains(&normalize_native_path_display(&original))
15094 && missing_registration.contains(",unavailable,unavailable,0,")
15095 && missing_registration.contains(MCP_WORKTREE_MISSING_RETENTION_REASON),
15096 &format!(
15097 "active alias disappeared after external Git worktree removal: {missing_registration}"
15098 ),
15099 )?;
15100 run_fixture_command(
15101 StdCommand::new("git")
15102 .current_dir(&primary)
15103 .args(["worktree", "add", "-b", "replacement"])
15104 .arg(&replacement),
15105 )?;
15106 let replacement_repository = server.control_git_repository()?;
15107 let replacement_root = replacement.canonicalize()?;
15108 let replacement_entry = replacement_repository
15109 .worktrees
15110 .iter()
15111 .find(|entry| {
15112 ProjectAtlasMcpServer::active_worktree_root(entry)
15113 == Some(replacement_root.as_path())
15114 })
15115 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
15116 require(
15117 replacement_entry.administrative_directory == administrative_directory,
15118 "Git fixture did not reuse the administrative path",
15119 )?;
15120 require(
15121 git_administrative_identity(&replacement_entry.administrative_directory)?
15122 != administrative_identity,
15123 "replacement Git worktree reused the prior lifecycle identity",
15124 )?;
15125 let add_revalidation = server.revalidate_worktree_candidate(
15126 &repository,
15127 original_entry,
15128 &administrative_identity,
15129 );
15130 require(
15131 add_revalidation.as_ref().is_err_and(|error| {
15132 error
15133 .to_string()
15134 .contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED)
15135 }),
15136 "add revalidation combined the old root with a replacement lifecycle",
15137 )?;
15138
15139 let resolution_error = server
15140 .state_for_target(None, Some("replacement-check".to_string()))
15141 .err()
15142 .ok_or_else(|| io::Error::other("reused administrative path was accepted"))?;
15143 require(
15144 resolution_error
15145 .to_string()
15146 .contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
15147 "reused administrative path did not return the lifecycle blocker",
15148 )?;
15149 require(
15150 !replacement.join(".projectatlas").exists(),
15151 "failed lifecycle validation initialized the replacement worktree",
15152 )?;
15153 let before_remove =
15154 run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15155 "worktree",
15156 "list",
15157 "--porcelain",
15158 ]))?;
15159 let removed = server.atlas_worktree_remove(Parameters(AtlasWorktreeRemoveParams {
15160 worktree: "replacement-check".to_string(),
15161 }));
15162 require(
15163 removed.contains("status: retired")
15164 && removed.contains(MCP_ERROR_WORKTREE_LIFECYCLE_CHANGED),
15165 &format!("stale registration could not be retired safely: {removed}"),
15166 )?;
15167 let after_remove =
15168 run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15169 "worktree",
15170 "list",
15171 "--porcelain",
15172 ]))?;
15173 require(
15174 after_remove == before_remove,
15175 "retiring a stale registration changed Git lifecycle state",
15176 )
15177 }
15178
15179 #[test]
15180 fn reused_git_administrative_path_cannot_synchronize_replacement_telemetry()
15181 -> Result<(), Box<dyn std::error::Error>> {
15182 let temp = tempfile::tempdir()?;
15183 let primary = temp.path().join("control");
15184 let worktree = temp.path().join("checkout");
15185 fs::create_dir_all(&primary)?;
15186 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15187 for (key, value) in [
15188 ("user.name", "ProjectAtlas Test"),
15189 ("user.email", "projectatlas@example.invalid"),
15190 ("commit.gpgsign", "false"),
15191 ] {
15192 run_fixture_command(
15193 StdCommand::new("git")
15194 .current_dir(&primary)
15195 .args(["config", key, value]),
15196 )?;
15197 }
15198 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15199 run_fixture_command(
15200 StdCommand::new("git")
15201 .current_dir(&primary)
15202 .args(["add", "."]),
15203 )?;
15204 run_fixture_command(
15205 StdCommand::new("git")
15206 .current_dir(&primary)
15207 .args(["commit", "-m", "fixture"]),
15208 )?;
15209 run_fixture_command(
15210 StdCommand::new("git")
15211 .current_dir(&primary)
15212 .args(["worktree", "add", "-b", "original-telemetry"])
15213 .arg(&worktree),
15214 )?;
15215
15216 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15217 let target_db = worktree.join(".projectatlas").join("projectatlas.db");
15218 fs::create_dir_all(
15219 control_db
15220 .parent()
15221 .ok_or_else(|| io::Error::other("control database has no parent"))?,
15222 )?;
15223 fs::create_dir_all(
15224 target_db
15225 .parent()
15226 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15227 )?;
15228 let event = usage_from_text(
15229 "worktree-lifecycle",
15230 "atlas_overview",
15231 None,
15232 None,
15233 "pub fn source() {}",
15234 "repository overview",
15235 );
15236 let target = AtlasStore::open_for_project(&target_db, &worktree)?;
15237 target.record_usage(&event)?;
15238 let target_project = target
15239 .project_instance_id()?
15240 .ok_or_else(|| io::Error::other("target project identity is missing"))?;
15241 drop(target);
15242
15243 let RepositoryStructure::Git(repository) = discover_repository_structure(&primary)? else {
15244 return Err(io::Error::other("Git repository was not discovered").into());
15245 };
15246 let canonical_worktree = worktree.canonicalize()?;
15247 let entry = repository
15248 .worktrees
15249 .iter()
15250 .find(|entry| {
15251 ProjectAtlasMcpServer::active_worktree_root(entry)
15252 == Some(canonical_worktree.as_path())
15253 })
15254 .ok_or_else(|| io::Error::other("worktree was not discovered"))?;
15255 let administrative_directory = entry.administrative_directory.clone();
15256 let administrative_identity = git_administrative_identity(&administrative_directory)?;
15257 let alias = WorktreeAlias::parse("lifecycle-telemetry")?;
15258 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15259 control.register_worktree(
15260 &alias,
15261 &repository.common_directory,
15262 &administrative_directory,
15263 &administrative_identity,
15264 &canonical_worktree,
15265 Some(target_project),
15266 1,
15267 )?;
15268 drop(control);
15269 synchronize_registered_worktree_usage(&control_db, &primary, None)?;
15270 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15271 require(
15272 control.registered_worktree_token_overview(&alias)?.calls == 1,
15273 "initial worktree telemetry was not synchronized",
15274 )?;
15275 drop(control);
15276
15277 let saved_db = temp.path().join("saved-projectatlas.db");
15278 fs::copy(&target_db, &saved_db)?;
15279 run_fixture_command(
15280 StdCommand::new("git")
15281 .current_dir(&primary)
15282 .args(["worktree", "remove", "--force"])
15283 .arg(&worktree),
15284 )?;
15285 require(
15286 matches!(
15287 synchronize_registered_worktree_usage(&control_db, &primary, None),
15288 Err(CliError::InvalidInput(message))
15289 if message.contains("aggregate token totals cannot be synchronized")
15290 ),
15291 "externally removed bound worktree reported stale aggregate success",
15292 )?;
15293 run_fixture_command(
15294 StdCommand::new("git")
15295 .current_dir(&primary)
15296 .args(["worktree", "add", "-b", "replacement-telemetry"])
15297 .arg(&worktree),
15298 )?;
15299 fs::create_dir_all(
15300 target_db
15301 .parent()
15302 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15303 )?;
15304 fs::copy(&saved_db, &target_db)?;
15305 let replacement = AtlasStore::open_for_project(&target_db, &worktree)?;
15306 replacement.record_usage(&event)?;
15307 drop(replacement);
15308
15309 let RepositoryStructure::Git(replacement_repository) =
15310 discover_repository_structure(&primary)?
15311 else {
15312 return Err(io::Error::other("replacement Git repository was not discovered").into());
15313 };
15314 let replacement_entry = replacement_repository
15315 .worktrees
15316 .iter()
15317 .find(|entry| {
15318 ProjectAtlasMcpServer::active_worktree_root(entry)
15319 == Some(canonical_worktree.as_path())
15320 })
15321 .ok_or_else(|| io::Error::other("replacement worktree was not discovered"))?;
15322 require(
15323 replacement_entry.administrative_directory == administrative_directory,
15324 "Git fixture did not reuse the administrative path",
15325 )?;
15326 require(
15327 git_administrative_identity(&replacement_entry.administrative_directory)?
15328 != administrative_identity,
15329 "replacement Git worktree reused the prior lifecycle identity",
15330 )?;
15331
15332 require(
15333 matches!(
15334 synchronize_registered_worktree_usage(&control_db, &primary, None),
15335 Err(CliError::InvalidInput(message))
15336 if message.contains("aggregate token totals cannot be synchronized")
15337 ),
15338 "replacement lifecycle reported stale aggregate success",
15339 )?;
15340 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15341 require(
15342 control.registered_worktree_token_overview(&alias)?.calls == 1,
15343 "replacement lifecycle telemetry was imported into the retired origin",
15344 )
15345 }
15346
15347 #[test]
15348 fn aggregate_synchronization_propagates_local_atlas_and_identity_failures()
15349 -> Result<(), Box<dyn std::error::Error>> {
15350 let temp = tempfile::tempdir()?;
15351 let primary = temp.path().join("control");
15352 let worktree = temp.path().join("external").join("worktree");
15353 fs::create_dir_all(&primary)?;
15354 fs::create_dir_all(
15355 worktree
15356 .parent()
15357 .ok_or_else(|| io::Error::other("worktree has no parent"))?,
15358 )?;
15359 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15360 for (key, value) in [
15361 ("user.name", "ProjectAtlas Test"),
15362 ("user.email", "projectatlas@example.invalid"),
15363 ("commit.gpgsign", "false"),
15364 ] {
15365 run_fixture_command(
15366 StdCommand::new("git")
15367 .current_dir(&primary)
15368 .args(["config", key, value]),
15369 )?;
15370 }
15371 fs::write(primary.join("README.md"), "# fixture\n")?;
15372 run_fixture_command(
15373 StdCommand::new("git")
15374 .current_dir(&primary)
15375 .args(["add", "."]),
15376 )?;
15377 run_fixture_command(
15378 StdCommand::new("git")
15379 .current_dir(&primary)
15380 .args(["commit", "-m", "fixture"]),
15381 )?;
15382 run_fixture_command(
15383 StdCommand::new("git")
15384 .current_dir(&primary)
15385 .args(["worktree", "add", "-b", "sync-failure"])
15386 .arg(&worktree),
15387 )?;
15388
15389 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15390 let target_db = worktree.join(".projectatlas").join("projectatlas.db");
15391 fs::create_dir_all(
15392 control_db
15393 .parent()
15394 .ok_or_else(|| io::Error::other("control database has no parent"))?,
15395 )?;
15396 fs::create_dir_all(
15397 target_db
15398 .parent()
15399 .ok_or_else(|| io::Error::other("target database has no parent"))?,
15400 )?;
15401 let target = AtlasStore::open_for_project(&target_db, &worktree)?;
15402 target.record_usage(&usage_from_text(
15403 "worktree-sync",
15404 "atlas_overview",
15405 None,
15406 None,
15407 "pub fn source() {}",
15408 "repository overview",
15409 ))?;
15410 let target_project = target
15411 .project_instance_id()?
15412 .ok_or_else(|| io::Error::other("target project identity is missing"))?;
15413 drop(target);
15414
15415 let RepositoryStructure::Git(repository) = discover_repository_structure(&primary)? else {
15416 return Err(io::Error::other("Git repository was not discovered").into());
15417 };
15418 let canonical_worktree = worktree.canonicalize()?;
15419 let entry = repository
15420 .worktrees
15421 .iter()
15422 .find(|entry| {
15423 ProjectAtlasMcpServer::active_worktree_root(entry)
15424 == Some(canonical_worktree.as_path())
15425 })
15426 .ok_or_else(|| io::Error::other("worktree was not discovered"))?;
15427 let alias = WorktreeAlias::parse("sync-failure")?;
15428 let foreign_project = if target_project == ProjectInstanceId::from_bytes([0x7f; 16])? {
15429 ProjectInstanceId::from_bytes([0x7e; 16])?
15430 } else {
15431 ProjectInstanceId::from_bytes([0x7f; 16])?
15432 };
15433 let control = AtlasStore::open_for_project(&control_db, &primary)?;
15434 control.register_worktree(
15435 &alias,
15436 &repository.common_directory,
15437 &entry.administrative_directory,
15438 &git_administrative_identity(&entry.administrative_directory)?,
15439 &canonical_worktree,
15440 Some(foreign_project),
15441 1,
15442 )?;
15443 drop(control);
15444
15445 require(
15446 matches!(
15447 synchronize_registered_worktree_usage(&control_db, &primary, None),
15448 Err(CliError::Db(
15449 DbError::WorktreeTelemetryProjectMismatch { .. }
15450 ))
15451 ),
15452 "aggregate synchronization hid the project identity failure behind stale success",
15453 )?;
15454
15455 let git_directory = primary.join(".git");
15456 let unavailable_git_directory = temp.path().join("unavailable-control-git");
15457 fs::rename(&git_directory, &unavailable_git_directory)?;
15458 require(
15459 matches!(
15460 synchronize_registered_worktree_usage(&control_db, &primary, None),
15461 Err(CliError::InvalidInput(message))
15462 if message.contains("requires Git control evidence")
15463 ),
15464 "aggregate synchronization treated missing control Git evidence as success",
15465 )?;
15466 fs::rename(&unavailable_git_directory, &git_directory)?;
15467
15468 let control_head = git_directory.join("HEAD");
15469 let valid_control_head = temp.path().join("valid-control-head");
15470 fs::rename(&control_head, &valid_control_head)?;
15471 fs::create_dir(&control_head)?;
15472 require(
15473 matches!(
15474 synchronize_registered_worktree_usage(&control_db, &primary, None),
15475 Err(CliError::InvalidInput(message))
15476 if message.contains("invalid Git evidence")
15477 ),
15478 "aggregate synchronization treated invalid control Git evidence as success",
15479 )?;
15480 fs::remove_dir(&control_head)?;
15481 fs::rename(&valid_control_head, &control_head)?;
15482
15483 let corrupt = rusqlite::Connection::open(&target_db)?;
15484 corrupt.pragma_update(None, "ignore_check_constraints", true)?;
15485 corrupt.execute("UPDATE usage_aggregate_revisions SET revision = -1", [])?;
15486 drop(corrupt);
15487 require(
15488 matches!(
15489 synchronize_registered_worktree_usage(&control_db, &primary, None),
15490 Err(CliError::Db(DbError::TelemetryIntegerOverflow {
15491 field: "usage_aggregate_revisions.revision"
15492 }))
15493 ),
15494 "aggregate synchronization hid the local snapshot export failure",
15495 )?;
15496
15497 fs::remove_file(&target_db)?;
15498 require(
15499 matches!(
15500 synchronize_registered_worktree_usage(&control_db, &primary, None),
15501 Err(CliError::InvalidInput(message))
15502 if message.contains("aggregate token totals cannot be synchronized")
15503 ),
15504 "aggregate synchronization reported stale success for a bound missing atlas",
15505 )?;
15506 fs::create_dir(&target_db)?;
15507 require(
15508 synchronize_registered_worktree_usage(&control_db, &primary, None).is_err(),
15509 "aggregate synchronization hid the existing local atlas open failure",
15510 )
15511 }
15512
15513 #[test]
15514 fn registered_worktree_init_falls_back_from_incomplete_control_and_preserves_existing_atlas()
15515 -> Result<(), Box<dyn std::error::Error>> {
15516 let temp = tempfile::tempdir()?;
15517 let primary = temp.path().join("control");
15518 let linked = temp.path().join("external").join("linked");
15519 fs::create_dir_all(&primary)?;
15520 fs::create_dir_all(
15521 linked
15522 .parent()
15523 .ok_or_else(|| io::Error::other("linked worktree has no parent"))?,
15524 )?;
15525 run_fixture_command(StdCommand::new("git").current_dir(&primary).arg("init"))?;
15526 for (key, value) in [
15527 ("user.name", "ProjectAtlas Test"),
15528 ("user.email", "projectatlas@example.invalid"),
15529 ("commit.gpgsign", "false"),
15530 ] {
15531 run_fixture_command(
15532 StdCommand::new("git")
15533 .current_dir(&primary)
15534 .args(["config", key, value]),
15535 )?;
15536 }
15537 fs::write(primary.join("lib.rs"), "pub fn control() {}\n")?;
15538 run_fixture_command(
15539 StdCommand::new("git")
15540 .current_dir(&primary)
15541 .args(["add", "."]),
15542 )?;
15543 run_fixture_command(
15544 StdCommand::new("git")
15545 .current_dir(&primary)
15546 .args(["commit", "-m", "fixture"]),
15547 )?;
15548 run_fixture_command(
15549 StdCommand::new("git")
15550 .current_dir(&primary)
15551 .args(["worktree", "add", "-b", "fallback"])
15552 .arg(&linked),
15553 )?;
15554 let git_before = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15555 "worktree",
15556 "list",
15557 "--porcelain",
15558 ]))?;
15559
15560 let control_db = primary.join(".projectatlas").join("projectatlas.db");
15561 fs::create_dir_all(
15562 control_db
15563 .parent()
15564 .ok_or_else(|| io::Error::other("control DB has no parent"))?,
15565 )?;
15566 drop(AtlasStore::open_for_project(&control_db, &primary)?);
15567 let server = ProjectAtlasMcpServer::new(
15568 control_db.clone(),
15569 None,
15570 "worktree-fallback".to_string(),
15571 false,
15572 );
15573 let repository = server.control_git_repository()?;
15574 let canonical_linked = linked.canonicalize()?;
15575 let entry = repository
15576 .worktrees
15577 .iter()
15578 .find(|entry| {
15579 ProjectAtlasMcpServer::active_worktree_root(entry)
15580 == Some(canonical_linked.as_path())
15581 })
15582 .ok_or_else(|| io::Error::other("linked worktree was not discovered"))?;
15583 let added = server.atlas_worktree_add(Parameters(AtlasWorktreeAddParams {
15584 worktree: ProjectAtlasMcpServer::worktree_candidate_selector(entry),
15585 alias: Some("fallback".to_string()),
15586 }));
15587 require(
15588 added.contains("status: registered"),
15589 "fallback fixture registration failed",
15590 )?;
15591
15592 let initialized = server.atlas_init(Parameters(AtlasInitParams {
15593 project_path: None,
15594 worktree: Some("fallback".to_string()),
15595 no_scan: Some(false),
15596 force_rescan: Some(false),
15597 text_index_max_bytes: None,
15598 }));
15599 require(
15600 initialized.contains("status: fallback")
15601 && initialized.contains("fallback_reason:")
15602 && initialized.contains("repository graph is unavailable"),
15603 &format!(
15604 "incomplete control atlas did not produce visible ordinary fallback: {initialized}"
15605 ),
15606 )?;
15607 let target_db = linked.join(".projectatlas").join("projectatlas.db");
15608 let target = open_atlas_store_read_only_for_project(&target_db, &linked)?;
15609 let identity = target
15610 .project_instance_id()?
15611 .ok_or_else(|| io::Error::other("fallback target identity is missing"))?;
15612 require(
15613 target.index_publication()?.is_some_and(|publication| {
15614 publication.state == projectatlas_db::IndexPublicationState::Complete
15615 }),
15616 "ordinary fallback did not publish a complete target index",
15617 )?;
15618 drop(target);
15619
15620 let repeated = server.atlas_init(Parameters(AtlasInitParams {
15621 project_path: None,
15622 worktree: Some("fallback".to_string()),
15623 no_scan: Some(true),
15624 force_rescan: Some(false),
15625 text_index_max_bytes: None,
15626 }));
15627 require(
15628 repeated.contains("status: existing"),
15629 &format!("repeat init did not preserve the valid target atlas: {repeated}"),
15630 )?;
15631 let preserved = open_atlas_store_read_only_for_project(&target_db, &linked)?;
15632 require(
15633 preserved.project_instance_id()? == Some(identity),
15634 "repeat init replaced the valid target atlas identity",
15635 )?;
15636 let control = open_atlas_store_read_only_for_project(&control_db, &primary)?;
15637 require(
15638 control
15639 .worktree_registration(&WorktreeAlias::parse("fallback")?)?
15640 .project_instance_id
15641 == Some(identity),
15642 "fallback init did not bind the exact target identity",
15643 )?;
15644 let git_after = run_fixture_command(StdCommand::new("git").current_dir(&primary).args([
15645 "worktree",
15646 "list",
15647 "--porcelain",
15648 ]))?;
15649 require(
15650 git_after == git_before,
15651 "fallback or repeat init changed Git lifecycle state",
15652 )?;
15653
15654 let config_path = init_config_path(&linked, None);
15655 let config_before = fs::read(&config_path)?;
15656 drop(preserved);
15657 drop(control);
15658 fs::remove_file(&target_db)?;
15659 let refused = server.atlas_init(Parameters(AtlasInitParams {
15660 project_path: None,
15661 worktree: Some("fallback".to_string()),
15662 no_scan: Some(false),
15663 force_rescan: Some(false),
15664 text_index_max_bytes: None,
15665 }));
15666 require(
15667 refused.contains(MCP_ERROR_BOUND_WORKTREE_ATLAS_MISSING),
15668 &format!("bound missing atlas did not fail before initialization: {refused}"),
15669 )?;
15670 require(
15671 !target_db.exists() && fs::read(&config_path)? == config_before,
15672 "bound missing atlas refusal changed target ProjectAtlas state",
15673 )
15674 }
15675
15676 #[test]
15677 fn bare_startup_and_root_set_preserve_worktree_required_without_state()
15678 -> Result<(), Box<dyn std::error::Error>> {
15679 let temp = tempfile::tempdir()?;
15680 let bare = temp.path().join("repository.git");
15681 let output = StdCommand::new("git")
15682 .args(["init", "--bare"])
15683 .arg(&bare)
15684 .output()?;
15685 require(output.status.success(), "git init --bare failed")?;
15686 let db_path = bare.join(".projectatlas").join("projectatlas.db");
15687 let server =
15688 ProjectAtlasMcpServer::new(db_path, None, "mcp-bare-root-test".to_string(), false);
15689
15690 let Err(error) = server.active_project_state() else {
15691 return Err(io::Error::other("bare MCP startup state was exposed as active").into());
15692 };
15693 if !matches!(error, CliError::WorktreeRequired(_)) {
15694 return Err(io::Error::other(format!(
15695 "bare MCP startup did not preserve typed worktree_required state: {error:?}"
15696 ))
15697 .into());
15698 }
15699
15700 let response = server.atlas_root_set(Parameters(AtlasRootSetParams {
15701 root: bare.to_string_lossy().to_string(),
15702 transition: None,
15703 nearest_project: None,
15704 }));
15705 require(
15706 response.contains("worktree_required"),
15707 "atlas_root_set did not reject a bare Git control root",
15708 )?;
15709 require(
15710 !bare.join(".projectatlas").exists(),
15711 "bare MCP startup or root-set refusal created project state",
15712 )
15713 }
15714
15715 #[test]
15716 fn selected_project_config_cannot_redirect_root() -> Result<(), Box<dyn std::error::Error>> {
15717 let temp = tempfile::tempdir()?;
15718 let repo_a = temp.path().join("repo-a");
15719 let repo_b = temp.path().join("repo-b");
15720 fs::create_dir(&repo_a)?;
15721 fs::create_dir(&repo_b)?;
15722 fs::create_dir(repo_b.join(".projectatlas"))?;
15723 let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
15724 fs::write(
15725 repo_b.join(".projectatlas").join("config.toml"),
15726 format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
15727 )?;
15728
15729 let Err(error) = ProjectAtlasMcpServer::project_state_from_root(&repo_b) else {
15730 return Err(io::Error::other("stale selected-project config was accepted").into());
15731 };
15732 require(
15733 error.to_string().contains("outside selected project root"),
15734 "stale selected-project config error was not root-scoped",
15735 )?;
15736
15737 Ok(())
15738 }
15739
15740 #[test]
15741 fn startup_config_mismatch_cannot_bind_one_root_to_another_db()
15742 -> Result<(), Box<dyn std::error::Error>> {
15743 let temp = tempfile::tempdir()?;
15744 let repo_a = temp.path().join("repo-a");
15745 let repo_b = temp.path().join("repo-b");
15746 fs::create_dir(&repo_a)?;
15747 fs::create_dir(&repo_b)?;
15748 fs::create_dir(repo_a.join(".projectatlas"))?;
15749 let escaped_repo_a = repo_a.to_string_lossy().replace('\\', "/");
15750 let config_a = repo_a.join(".projectatlas").join("config.toml");
15751 fs::write(
15752 &config_a,
15753 format!("[project]\nroot = \"{escaped_repo_a}\"\n"),
15754 )?;
15755
15756 let db_b = repo_b.join(".projectatlas").join("projectatlas.db");
15757 let server =
15758 ProjectAtlasMcpServer::new(db_b.clone(), Some(config_a), "mcp-test".to_string(), false);
15759 let state = server.active_project_state()?;
15760
15761 require(
15762 state.root == canonical_project_root(&repo_b)?,
15763 "startup state did not fall back to the DB project root",
15764 )?;
15765 require(
15766 state.db_path == db_b,
15767 "startup state changed the selected DB path",
15768 )?;
15769 require(
15770 state.config_path.is_none(),
15771 "startup state retained a config from another project root",
15772 )?;
15773
15774 Ok(())
15775 }
15776
15777 #[test]
15778 fn read_only_store_does_not_create_missing_index() -> Result<(), Box<dyn std::error::Error>> {
15779 let temp = tempfile::tempdir()?;
15780 let repo = temp.path().join("repo-a");
15781 fs::create_dir(&repo)?;
15782 let state = ProjectAtlasMcpServer::project_state_from_root(&repo)?;
15783
15784 let Err(error) = ProjectAtlasMcpServer::open_read_store(&state) else {
15785 return Err(io::Error::other("missing index opened unexpectedly").into());
15786 };
15787 require(
15788 matches!(error, CliError::InitRequired(_)),
15789 "missing index did not return typed init_required state",
15790 )?;
15791 let payload = ProjectAtlasMcpServer::encode_error_payload(&error);
15792 require(
15793 payload.contains("kind: init_required")
15794 && payload.contains("init_required:")
15795 && payload.contains("tool: atlas_init")
15796 && payload.contains(&normalize_native_path_display(&repo)),
15797 "missing index payload did not contain the exact atlas_init recovery call",
15798 )?;
15799 require(
15800 !repo.join(".projectatlas").exists(),
15801 "read-only store created .projectatlas",
15802 )?;
15803
15804 Ok(())
15805 }
15806
15807 #[test]
15808 fn atlas_init_explicit_project_path_bootstraps_without_switching_active_project()
15809 -> Result<(), Box<dyn std::error::Error>> {
15810 let temp = tempfile::tempdir()?;
15811 let repo_a = temp.path().join("repo-a");
15812 let repo_b = temp.path().join("repo-b");
15813 fs::create_dir(&repo_a)?;
15814 fs::create_dir(&repo_b)?;
15815 let db_a = repo_a.join(".projectatlas").join("projectatlas.db");
15816 let server = ProjectAtlasMcpServer::new(db_a, None, "mcp-test".to_string(), false);
15817 let active_before = server.active_project_state()?;
15818
15819 let text = server.atlas_init(Parameters(AtlasInitParams {
15820 project_path: Some(repo_b.to_string_lossy().to_string()),
15821 worktree: None,
15822 no_scan: Some(true),
15823 force_rescan: Some(false),
15824 text_index_max_bytes: None,
15825 }));
15826
15827 let expected_b = normalize_native_path_display(canonical_project_root(&repo_b)?);
15828 require(
15829 text.contains("init:"),
15830 "atlas_init did not return named init payload",
15831 )?;
15832 require(
15833 text.contains(&expected_b),
15834 "atlas_init did not report the explicit project path",
15835 )?;
15836 require(
15837 text.contains("status: skipped"),
15838 "atlas_init --no-scan did not report skipped scan",
15839 )?;
15840 require(
15841 text.contains("purpose_handoff:")
15842 && text.contains("execution_owner: agent_host")
15843 && text.contains(&format!(
15844 "recommended_subagent_reasoning: {PURPOSE_CURATOR_RECOMMENDED_REASONING}"
15845 ))
15846 && text.contains("main_agent_fallback: true")
15847 && text.contains("server_started_curator: false")
15848 && text.contains("silent_on_success: true")
15849 && text.contains("curation_scope: low"),
15850 "atlas_init did not expose the host-owned low-scope curator handoff",
15851 )?;
15852 require(
15853 repo_b
15854 .join(".projectatlas")
15855 .join("projectatlas.db")
15856 .is_file(),
15857 "atlas_init did not create the explicit project's DB",
15858 )?;
15859 require(
15860 repo_b
15861 .join(".projectatlas")
15862 .join("projectatlas.mcp.json")
15863 .is_file()
15864 && repo_b
15865 .join(".projectatlas")
15866 .join("projectatlas.claude.mcp.json")
15867 .is_file()
15868 && repo_b
15869 .join(".projectatlas")
15870 .join("projectatlas.opencode.json")
15871 .is_file(),
15872 "atlas_init did not generate host MCP configs",
15873 )?;
15874
15875 let active_after = server.active_project_state()?;
15876 require(
15877 active_after.root == active_before.root,
15878 "atlas_init with explicit project_path changed the active default root",
15879 )?;
15880 require(
15881 !repo_a.join(".projectatlas").exists(),
15882 "explicit atlas_init mutated the active project",
15883 )?;
15884
15885 Ok(())
15886 }
15887
15888 #[test]
15889 fn session_brief_missing_index_stays_read_only() -> Result<(), Box<dyn std::error::Error>> {
15890 let temp = tempfile::tempdir()?;
15891 let repo = temp.path().join("repo-a");
15892 fs::create_dir(&repo)?;
15893 let db_path = repo.join(".projectatlas").join("projectatlas.db");
15894 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
15895
15896 let brief = server.build_session_brief(
15897 AtlasSessionBriefParams {
15898 project_path: None,
15899 worktree: None,
15900 query: Some("startup".to_string()),
15901 purpose_task: None,
15902 compact: None,
15903 folder_limit: None,
15904 file_limit: None,
15905 blocker_limit: None,
15906 purpose_limit: None,
15907 },
15908 None,
15909 )?;
15910
15911 require(
15912 brief.project.index_status == McpIndexStatus::Missing,
15913 "missing index was not represented as typed state",
15914 )?;
15915 require(
15916 brief.overview.is_none(),
15917 "missing-index brief unexpectedly included overview",
15918 )?;
15919 require(
15920 brief.recommendations.iter().any(|recommendation| {
15921 matches!(recommendation.kind, McpBriefRecommendationKind::Init)
15922 && recommendation.target == MCP_TOOL_ATLAS_INIT
15923 && recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
15924 == Some(&serde_json::json!(normalize_native_path_display(&repo)))
15925 }),
15926 "missing-index brief did not recommend atlas_init for the exact selected root",
15927 )?;
15928 require(
15929 !repo.join(".projectatlas").exists(),
15930 "session brief created .projectatlas for a missing index",
15931 )?;
15932
15933 Ok(())
15934 }
15935
15936 #[test]
15937 fn session_brief_recommendations_preserve_per_call_project_path()
15938 -> Result<(), Box<dyn std::error::Error>> {
15939 let project_path = "F:/example/repo-b".to_string();
15940 let recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
15941 "startup",
15942 Some(NavigationNextCall {
15943 capability: NavigationNextCapability::Summary,
15944 path: "src/lib.rs".to_string(),
15945 }),
15946 1,
15947 7,
15948 Some(project_path.clone()),
15949 None,
15950 );
15951
15952 require(
15953 recommendations.iter().all(|recommendation| {
15954 recommendation.arguments.get(MCP_BRIEF_ARG_PROJECT_PATH)
15955 == Some(&serde_json::Value::String(project_path.clone()))
15956 }),
15957 "indexed brief recommendations did not preserve project_path",
15958 )?;
15959 require(
15960 recommendations.iter().any(|recommendation| {
15961 matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
15962 && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
15963 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
15964 == Some(&serde_json::Value::String("src/lib.rs".to_string()))
15965 }),
15966 "summary recommendation did not preserve the ranked file selector",
15967 )?;
15968 require(
15969 recommendations.iter().all(|recommendation| {
15970 recommendation.target != MCP_TOOL_ATLAS_FOLDERS
15971 && recommendation.target != MCP_TOOL_ATLAS_FILES
15972 }),
15973 "indexed brief recommended rerunning folder or file ranking",
15974 )?;
15975 require(
15976 recommendations.iter().any(|recommendation| {
15977 matches!(recommendation.kind, McpBriefRecommendationKind::Health)
15978 && recommendation.arguments.get(MCP_BRIEF_ARG_LIMIT)
15979 == Some(&serde_json::json!(7))
15980 }),
15981 "health recommendation did not preserve limit",
15982 )?;
15983
15984 let relation_recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
15985 "startup",
15986 Some(NavigationNextCall {
15987 capability: NavigationNextCapability::Relations,
15988 path: "src/graph.rs".to_string(),
15989 }),
15990 0,
15991 7,
15992 Some(project_path),
15993 None,
15994 );
15995 require(
15996 relation_recommendations.iter().any(|recommendation| {
15997 matches!(recommendation.kind, McpBriefRecommendationKind::Relations)
15998 && recommendation.target == MCP_TOOL_ATLAS_SYMBOL_RELATIONS
15999 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
16000 == Some(&serde_json::Value::String("src/graph.rs".to_string()))
16001 && recommendation.arguments.get(MCP_BRIEF_ARG_VIEW)
16002 == Some(&serde_json::Value::String("detailed".to_string()))
16003 }),
16004 "relation recommendation did not preserve the ranked file and detailed view",
16005 )?;
16006
16007 let worktree_recommendations = ProjectAtlasMcpServer::indexed_project_recommendations(
16008 "startup",
16009 None,
16010 1,
16011 7,
16012 None,
16013 Some("issue-430".to_string()),
16014 );
16015 require(
16016 worktree_recommendations.iter().all(|recommendation| {
16017 recommendation.arguments.get(MCP_BRIEF_ARG_WORKTREE)
16018 == Some(&serde_json::Value::String("issue-430".to_string()))
16019 && recommendation
16020 .arguments
16021 .get(MCP_BRIEF_ARG_PROJECT_PATH)
16022 .is_none()
16023 }),
16024 "indexed brief recommendations did not preserve the mutually exclusive worktree alias",
16025 )?;
16026
16027 Ok(())
16028 }
16029
16030 #[test]
16031 fn session_brief_file_candidates_ignore_indexed_text_fallback()
16032 -> Result<(), Box<dyn std::error::Error>> {
16033 let temp = tempfile::tempdir()?;
16034 let repo = temp.path().join("repo-a");
16035 fs::create_dir_all(repo.join("src"))?;
16036 fs::write(
16037 repo.join("src").join("owner.rs"),
16038 "const ROUTE: &str = \"hiddenNeedle\";\n",
16039 )?;
16040 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16041 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16042 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16043 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
16044 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
16045 drop(store);
16046
16047 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16048 let brief = server.build_session_brief(
16049 AtlasSessionBriefParams {
16050 project_path: None,
16051 worktree: None,
16052 query: Some("hiddenNeedle".to_string()),
16053 purpose_task: None,
16054 compact: None,
16055 folder_limit: Some(5),
16056 file_limit: Some(5),
16057 blocker_limit: Some(5),
16058 purpose_limit: Some(5),
16059 },
16060 None,
16061 )?;
16062
16063 require(
16064 brief.files.is_empty(),
16065 "session brief returned a content-only indexed-text hit",
16066 )?;
16067 require(
16068 brief.recommendations.iter().any(|recommendation| {
16069 matches!(recommendation.kind, McpBriefRecommendationKind::Search)
16070 && recommendation.target == MCP_TOOL_ATLAS_SEARCH
16071 && recommendation.arguments.get(MCP_BRIEF_ARG_PATTERN)
16072 == Some(&serde_json::Value::String("hiddenNeedle".to_string()))
16073 }),
16074 "session brief did not route a content-only query directly to indexed search",
16075 )?;
16076
16077 let navigable = server.build_session_brief(
16078 AtlasSessionBriefParams {
16079 project_path: None,
16080 worktree: None,
16081 query: Some("owner".to_string()),
16082 purpose_task: None,
16083 compact: None,
16084 folder_limit: Some(5),
16085 file_limit: Some(5),
16086 blocker_limit: Some(5),
16087 purpose_limit: Some(5),
16088 },
16089 None,
16090 )?;
16091 let candidate = navigable
16092 .files
16093 .first()
16094 .ok_or_else(|| std::io::Error::other("navigable brief file is missing"))?;
16095 require(
16096 candidate.reason_codes.contains(&RankedReasonCode::Path)
16097 && candidate.next_call.capability == NavigationNextCapability::Summary
16098 && !candidate.purpose_agent_reviewed,
16099 "session brief dropped ranked navigation evidence",
16100 )?;
16101 require(
16102 navigable.recommendations.iter().any(|recommendation| {
16103 matches!(recommendation.kind, McpBriefRecommendationKind::Summary)
16104 && recommendation.target == MCP_TOOL_ATLAS_FILE_SUMMARY
16105 && recommendation.arguments.get(MCP_BRIEF_ARG_FILE)
16106 == Some(&serde_json::Value::String(candidate.path.clone()))
16107 }) && navigable.recommendations.iter().all(|recommendation| {
16108 recommendation.target != MCP_TOOL_ATLAS_FOLDERS
16109 && recommendation.target != MCP_TOOL_ATLAS_FILES
16110 }),
16111 "session brief recommendation did not follow its returned ranked file directly",
16112 )?;
16113
16114 Ok(())
16115 }
16116
16117 #[test]
16118 fn mcp_navigation_and_session_brief_propagate_typed_graph_evidence()
16119 -> Result<(), Box<dyn std::error::Error>> {
16120 let temp = tempfile::tempdir()?;
16121 let repo = temp.path().join("repo-a");
16122 fs::create_dir_all(repo.join("src"))?;
16123 fs::create_dir_all(repo.join("tests"))?;
16124 fs::write(
16125 repo.join("Cargo.toml"),
16126 "[package]\nname = \"adapter-navigation\"\nversion = \"0.1.0\"\n",
16127 )?;
16128 for path in [
16129 "src/navigation_owner.rs",
16130 "src/navigation_local.rs",
16131 "src/navigation_unresolved.rs",
16132 "tests/navigation_owner.rs",
16133 ] {
16134 fs::write(repo.join(path), "pub fn navigation_fixture() {}\n")?;
16135 }
16136 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16137 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16138 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16139 run_scan_pipeline(
16140 &mut store,
16141 &plan,
16142 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30)),
16143 )?;
16144 publish_mcp_navigation_graph(&mut store)?;
16145 drop(store);
16146
16147 let server =
16148 ProjectAtlasMcpServer::new(db_path, None, "mcp-navigation-test".to_string(), false);
16149 let folders_text = server.atlas_folders_response(
16150 AtlasQueryParams {
16151 project_path: None,
16152 worktree: None,
16153 query: Some("navigation".to_string()),
16154 limit: Some(10),
16155 },
16156 None,
16157 );
16158 let files_text = server.atlas_files_response(
16159 AtlasFilesParams {
16160 project_path: None,
16161 worktree: None,
16162 query: Some("navigation".to_string()),
16163 folder: None,
16164 nearest_project: Some(false),
16165 file_pattern: None,
16166 include_content: Some(false),
16167 content_selection: None,
16168 limit: Some(10),
16169 },
16170 None,
16171 );
16172 for (surface, text) in [
16173 ("atlas_folders", &folders_text),
16174 ("atlas_files", &files_text),
16175 ] {
16176 require(
16177 text.contains("connection_counts")
16178 && text.contains("connections")
16179 && text.contains("direction:")
16180 && text.contains("target:")
16181 && text.contains("connections_truncated: true"),
16182 &format!("{surface} dropped nonempty typed graph evidence: {text}"),
16183 )?;
16184 }
16185
16186 let brief = server.build_session_brief(
16187 AtlasSessionBriefParams {
16188 project_path: None,
16189 worktree: None,
16190 query: Some("navigation".to_string()),
16191 purpose_task: None,
16192 compact: None,
16193 folder_limit: Some(10),
16194 file_limit: Some(10),
16195 blocker_limit: Some(10),
16196 purpose_limit: Some(10),
16197 },
16198 None,
16199 )?;
16200 let folder = brief
16201 .folders
16202 .iter()
16203 .find(|candidate| candidate.path == "src")
16204 .ok_or_else(|| io::Error::other("graph-enriched MCP folder is missing"))?;
16205 require(
16206 folder.connection_counts.len() == 7
16207 && folder.connections.len() == 3
16208 && folder.connections_truncated,
16209 "MCP folder lost count, sample, or global truncation evidence",
16210 )?;
16211 let owner = brief
16212 .files
16213 .iter()
16214 .find(|candidate| candidate.path == "src/navigation_owner.rs")
16215 .ok_or_else(|| io::Error::other("graph-enriched MCP owner file is missing"))?;
16216 require(
16217 owner.connection_counts.len() == 7
16218 && owner.connections.len() == 3
16219 && owner.connections_truncated
16220 && owner.next_call.capability
16221 == projectatlas_core::NavigationNextCapability::Relations,
16222 "MCP file or session brief lost graph truncation or relations navigation",
16223 )?;
16224 let compact_relations = server.atlas_symbol_relations_response(
16225 &AtlasSymbolRelationsParams {
16226 project_path: None,
16227 file: Some("src/navigation_owner.rs".to_string()),
16228 nearest_project: Some(false),
16229 view: Some("detailed".to_string()),
16230 compact: Some(true),
16231 direction: Some("outbound".to_string()),
16232 include_occurrences: Some(true),
16233 limit: Some(10),
16234 output_bytes: Some(64 * 1_024),
16235 ..AtlasSymbolRelationsParams::default()
16236 },
16237 None,
16238 );
16239 require(
16240 compact_relations.contains("status: resolved")
16241 && compact_relations.contains("status: ambiguous")
16242 && compact_relations.contains("status: external")
16243 && compact_relations.contains("status: unresolved")
16244 && compact_relations.contains("reference: \"navigation-ambiguous\"")
16245 && compact_relations.contains("candidates: 2")
16246 && compact_relations.contains("next_call:"),
16247 &format!(
16248 "compact detailed relations dropped a resolution state or reusable next call: {compact_relations}"
16249 ),
16250 )?;
16251
16252 let compact = server.build_compact_session_brief(
16253 AtlasSessionBriefParams {
16254 project_path: None,
16255 worktree: None,
16256 query: Some("navigation_owner".to_string()),
16257 purpose_task: None,
16258 compact: Some(true),
16259 folder_limit: None,
16260 file_limit: None,
16261 blocker_limit: None,
16262 purpose_limit: None,
16263 },
16264 None,
16265 )?;
16266 let compact_owner = compact
16267 .files
16268 .iter()
16269 .find(|candidate| candidate.path == "src/navigation_owner.rs")
16270 .ok_or_else(|| io::Error::other("compact graph owner file is missing"))?;
16271 require(
16272 compact_owner.connections.len() == 1
16273 && compact_owner.connections.iter().all(|connection| {
16274 connection.kind != RankedConnectionKind::Import
16275 && !matches!(
16276 &connection.target,
16277 RankedConnectionTarget::Unresolved { .. }
16278 )
16279 })
16280 && compact_owner.next_call.capability == NavigationNextCapability::Summary
16281 && compact_owner.purpose_agent_reviewed,
16282 "compact session brief did not retain one crisp edge and summary-first routing",
16283 )?;
16284 let expanded_text =
16285 ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &brief)?;
16286 require(
16287 expanded_text.contains("missing_purposes:")
16288 && expanded_text.contains("stale_purposes:")
16289 && expanded_text.contains("approved_purposes:")
16290 && expanded_text.contains("suggested_purposes:"),
16291 "compatibility session brief lost purpose lifecycle counts",
16292 )?;
16293 let compact_text =
16294 ProjectAtlasMcpServer::encode_named_payload(MCP_PAYLOAD_SESSION_BRIEF, &compact)?;
16295 require(
16296 compact.purpose_handoff.as_ref().is_some_and(|handoff| {
16297 handoff.recommended_subagent_reasoning == PURPOSE_CURATOR_RECOMMENDED_REASONING
16298 && handoff.instructions.len() == 1
16299 && handoff.instructions.first()
16300 == brief
16301 .purpose_handoff
16302 .as_ref()
16303 .and_then(|expanded| expanded.instructions.first())
16304 }) && compact_text.contains(&format!(
16305 "recommended_subagent_reasoning: {PURPOSE_CURATOR_RECOMMENDED_REASONING}"
16306 )) && compact_text
16307 .contains("lowest reliable reasoning and cost tier the host supports"),
16308 &format!(
16309 "compact actionable handoff lost its reliable-tier instruction: {compact_text}"
16310 ),
16311 )?;
16312 require(
16313 compact
16314 .blockers
16315 .as_ref()
16316 .is_some_and(|blockers| blockers.total > 0)
16317 && !compact_text.contains("\n db:")
16318 && !compact_text.contains("\n config:")
16319 && !compact_text.contains("\n policy:")
16320 && !compact_text.contains("\n items:")
16321 && !compact_text.contains("reason_codes")
16322 && !compact_text.contains("connection_counts")
16323 && !compact_text.contains("agent_harness_expected")
16324 && !compact_text.contains("server_started_curator")
16325 && !compact_text.contains("missing_purposes")
16326 && compact_text.len() <= 4_096,
16327 &format!("compact session brief retained default-only chatter: {compact_text}"),
16328 )?;
16329
16330 let families = brief
16331 .files
16332 .iter()
16333 .flat_map(|candidate| candidate.connection_counts.iter().map(|count| count.kind))
16334 .collect::<BTreeSet<_>>();
16335 require(
16336 families
16337 == BTreeSet::from([
16338 RankedConnectionKind::Package,
16339 RankedConnectionKind::Import,
16340 RankedConnectionKind::Call,
16341 RankedConnectionKind::Reference,
16342 RankedConnectionKind::Test,
16343 RankedConnectionKind::Route,
16344 RankedConnectionKind::Config,
16345 ]),
16346 &format!("MCP graph families were not propagated: {families:?}"),
16347 )?;
16348 let connections = brief
16349 .files
16350 .iter()
16351 .flat_map(|candidate| candidate.connections.iter())
16352 .collect::<Vec<_>>();
16353 require(
16354 connections
16355 .iter()
16356 .any(|connection| connection.direction == RankedConnectionDirection::Outbound)
16357 && connections
16358 .iter()
16359 .any(|connection| connection.direction == RankedConnectionDirection::Inbound),
16360 "MCP graph samples did not preserve both directions",
16361 )?;
16362 for (name, present) in [
16363 (
16364 "local",
16365 connections.iter().any(|connection| {
16366 matches!(connection.target, RankedConnectionTarget::Local { .. })
16367 }),
16368 ),
16369 (
16370 "package",
16371 connections.iter().any(|connection| {
16372 matches!(connection.target, RankedConnectionTarget::Package { .. })
16373 }),
16374 ),
16375 (
16376 "external",
16377 connections.iter().any(|connection| {
16378 matches!(connection.target, RankedConnectionTarget::External { .. })
16379 }),
16380 ),
16381 (
16382 "unresolved",
16383 connections.iter().any(|connection| {
16384 matches!(connection.target, RankedConnectionTarget::Unresolved { .. })
16385 }),
16386 ),
16387 ] {
16388 require(
16389 present,
16390 &format!("MCP graph samples omitted {name} targets"),
16391 )?;
16392 }
16393 Ok(())
16394 }
16395
16396 fn publish_mcp_navigation_graph(
16397 store: &mut AtlasStore,
16398 ) -> Result<(), Box<dyn std::error::Error>> {
16399 let project = store
16400 .project_instance_id()?
16401 .ok_or_else(|| io::Error::other("MCP navigation project identity is missing"))?;
16402 let current_publication = store
16403 .index_publication()?
16404 .ok_or_else(|| io::Error::other("MCP navigation publication is missing"))?;
16405 let fingerprint = current_publication
16406 .contract_fingerprint
16407 .clone()
16408 .ok_or_else(|| io::Error::other("MCP navigation fingerprint is missing"))?;
16409 let generation = current_publication
16410 .generation
16411 .checked_next()
16412 .ok_or_else(|| io::Error::other("MCP navigation generation overflow"))?;
16413 let file_entity = |path: &str| {
16414 GraphEntity::new(
16415 project,
16416 EntitySelector::File {
16417 path: RepositoryFilePath::new(Path::new(path))?,
16418 },
16419 generation,
16420 )
16421 };
16422 let owner = file_entity("src/navigation_owner.rs")?;
16423 let local = file_entity("src/navigation_local.rs")?;
16424 let unresolved = file_entity("src/navigation_unresolved.rs")?;
16425 let test = file_entity("tests/navigation_owner.rs")?;
16426 let package = GraphEntity::new(
16427 project,
16428 EntitySelector::Package {
16429 package: PackageSelector {
16430 manager: GraphIdentityText::new("cargo")?,
16431 name: GraphIdentityText::new("adapter-navigation")?,
16432 manifest: RepositoryFilePath::new(Path::new("Cargo.toml"))?,
16433 },
16434 },
16435 generation,
16436 )?;
16437 let external = GraphEntity::new(
16438 project,
16439 EntitySelector::External {
16440 external: ExternalSelector {
16441 system: GraphIdentityText::new("crates.io")?,
16442 identity: GraphIdentityText::new("serde@1")?,
16443 },
16444 },
16445 generation,
16446 )?;
16447 let resolved = |source: &GraphEntity, kind, target: &GraphEntity| {
16448 Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
16449 source,
16450 kind,
16451 RelationResolution::resolved(target)?,
16452 ConfidenceClass::Exact,
16453 Completeness::Complete,
16454 generation,
16455 )?)
16456 };
16457 let unresolved_relation = |source: &GraphEntity, kind, reference: &str| {
16458 Ok::<_, Box<dyn std::error::Error>>(LogicalRelation::new(
16459 source,
16460 kind,
16461 RelationResolution::Unresolved {
16462 reference: GraphIdentityText::new(reference)?,
16463 },
16464 ConfidenceClass::High,
16465 Completeness::Partial,
16466 generation,
16467 )?)
16468 };
16469 let relations = vec![
16470 resolved(
16471 &package,
16472 GraphRelationKind::Legacy(RelationKind::DependsOn),
16473 &owner,
16474 )?,
16475 LogicalRelation::new(
16476 &owner,
16477 GraphRelationKind::Legacy(RelationKind::Imports),
16478 RelationResolution::external(&external)?,
16479 ConfidenceClass::Exact,
16480 Completeness::Complete,
16481 generation,
16482 )?,
16483 resolved(
16484 &owner,
16485 GraphRelationKind::Legacy(RelationKind::Calls),
16486 &local,
16487 )?,
16488 unresolved_relation(
16489 &unresolved,
16490 GraphRelationKind::Extended(ExtendedRelationKind::References),
16491 "navigation-reference",
16492 )?,
16493 resolved(
16494 &test,
16495 GraphRelationKind::Extended(ExtendedRelationKind::Tests),
16496 &owner,
16497 )?,
16498 resolved(
16499 &owner,
16500 GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
16501 &local,
16502 )?,
16503 LogicalRelation::new(
16504 &owner,
16505 GraphRelationKind::Extended(ExtendedRelationKind::References),
16506 RelationResolution::Ambiguous {
16507 reference: GraphIdentityText::new("navigation-ambiguous")?,
16508 candidates: std::num::NonZeroU32::new(2)
16509 .ok_or_else(|| io::Error::other("ambiguous fixture count is zero"))?,
16510 },
16511 ConfidenceClass::High,
16512 Completeness::Partial,
16513 generation,
16514 )?,
16515 unresolved_relation(
16516 &owner,
16517 GraphRelationKind::Extended(ExtendedRelationKind::Configures),
16518 "NAVIGATION_MODE",
16519 )?,
16520 ];
16521 let nodes = store
16522 .load_nodes()?
16523 .into_iter()
16524 .map(|node| node.node)
16525 .collect::<Vec<_>>();
16526 {
16527 let mut publication = store.begin_index_publication(&fingerprint)?;
16528 publication.begin_scan_replacement()?;
16529 publication.upsert_scan_node_batch(&nodes)?;
16530 publication.finish_scan_replacement()?;
16531 publication.replace_repository_graph(
16532 project,
16533 &[owner, local, unresolved, test, package, external],
16534 &relations,
16535 &[],
16536 &[],
16537 )?;
16538 publication.complete()?;
16539 }
16540 store.set_purpose("src", "Navigation graph folder", PurposeSource::Agent)?;
16541 store.set_purpose(
16542 "src/navigation_owner.rs",
16543 "Navigation graph owner",
16544 PurposeSource::Agent,
16545 )?;
16546 store.set_purpose(
16547 "src/navigation_unresolved.rs",
16548 "Navigation unresolved graph owner",
16549 PurposeSource::Agent,
16550 )?;
16551 Ok(())
16552 }
16553
16554 #[test]
16555 fn session_brief_exposes_host_owned_purpose_curator_handoff()
16556 -> Result<(), Box<dyn std::error::Error>> {
16557 let temp = tempfile::tempdir()?;
16558 let repo = temp.path().join("repo-a");
16559 fs::create_dir_all(repo.join("src"))?;
16560 fs::write(repo.join("src").join("main.rs"), "fn main() {}\n")?;
16561 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16562 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
16563 let mut store = open_atlas_store_for_project(&db_path, &plan.root)?;
16564 let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), Some(30));
16565 run_scan_pipeline(&mut store, &plan, &symbol_options)?;
16566 drop(store);
16567
16568 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16569 let brief = server.build_session_brief(
16570 AtlasSessionBriefParams {
16571 project_path: None,
16572 worktree: None,
16573 query: Some("startup".to_string()),
16574 purpose_task: Some("startup-task".to_string()),
16575 compact: None,
16576 folder_limit: Some(5),
16577 file_limit: Some(5),
16578 blocker_limit: Some(5),
16579 purpose_limit: Some(1),
16580 },
16581 None,
16582 )?;
16583 let handoff = brief
16584 .purpose_handoff
16585 .as_ref()
16586 .ok_or_else(|| std::io::Error::other("actionable purpose handoff missing"))?;
16587 require(
16588 handoff.execution_owner == "agent_host",
16589 "session handoff was not host-owned",
16590 )?;
16591 require(
16592 handoff.recommended_subagent_reasoning == PURPOSE_CURATOR_RECOMMENDED_REASONING,
16593 "session handoff did not request the lowest reliable host-supported reasoning",
16594 )?;
16595 require(
16596 handoff.main_agent_fallback && !handoff.server_started_curator,
16597 "session handoff misrepresented curator execution ownership",
16598 )?;
16599 require(
16600 handoff.silent_on_success,
16601 "session handoff was not quiet on successful maintenance",
16602 )?;
16603 require(
16604 handoff.queue.task == "startup-task"
16605 && handoff.queue.curation_scope == "low"
16606 && handoff.queue.actionable
16607 && handoff.queue.returned == 1
16608 && handoff.queue.limit == 1
16609 && handoff.queue.truncated,
16610 "compatibility session handoff lost its bounded purpose queue metadata",
16611 )?;
16612 require(
16613 handoff.queue.items.iter().all(|item| {
16614 item.work_key.len() == 64
16615 && item.state_token.len() == 64
16616 && !item.purpose_agent_reviewed
16617 }),
16618 "session handoff item tokens or lifecycle state were incomplete",
16619 )?;
16620 let compact = server.build_compact_session_brief(
16621 AtlasSessionBriefParams {
16622 project_path: None,
16623 worktree: None,
16624 query: Some("startup".to_string()),
16625 purpose_task: Some("startup-task".to_string()),
16626 compact: Some(true),
16627 folder_limit: Some(5),
16628 file_limit: Some(5),
16629 blocker_limit: Some(5),
16630 purpose_limit: Some(1),
16631 },
16632 None,
16633 )?;
16634 let compact_handoff = compact
16635 .purpose_handoff
16636 .as_ref()
16637 .ok_or_else(|| std::io::Error::other("compact purpose handoff missing"))?;
16638 require(
16639 matches!(
16640 compact_handoff.next_call.kind,
16641 McpBriefRecommendationKind::PurposeQueue
16642 ) && compact_handoff.next_call.target == MCP_TOOL_ATLAS_PURPOSE_QUEUE
16643 && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_TASK)
16644 == Some(&serde_json::json!("startup-task"))
16645 && compact_handoff.next_call.arguments.get(MCP_BRIEF_ARG_LIMIT)
16646 == Some(&serde_json::json!(1)),
16647 "compact handoff did not preserve the exact bounded purpose-queue call",
16648 )?;
16649 require(
16650 brief.limits.purpose_limit == 1 && brief.limits.purposes_truncated,
16651 "session brief purpose limits were not reported",
16652 )?;
16653 Ok(())
16654 }
16655
16656 #[test]
16657 fn settings_capabilities_report_nearest_policy() -> Result<(), Box<dyn std::error::Error>> {
16658 let temp = tempfile::tempdir()?;
16659 let repo = temp.path().join("repo-a");
16660 fs::create_dir(&repo)?;
16661 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16662 let disabled =
16663 ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
16664 let enabled = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), true);
16665 let disabled_state = disabled.active_project_state()?;
16666 let enabled_state = enabled.active_project_state()?;
16667
16668 let disabled_text = disabled.render_settings_with_capabilities(&disabled_state)?;
16669 require(
16670 disabled_text.contains("mcp_session:"),
16671 "settings did not include mcp_session capabilities",
16672 )?;
16673 require(
16674 disabled_text.contains("path_scope: selected_project"),
16675 "disabled nearest-project policy was not typed",
16676 )?;
16677 require(
16678 disabled_text.contains("language_registry:")
16679 && disabled_text.contains("accepted_set_digest:")
16680 && disabled_text.contains("semantic_provider_digest:")
16681 && disabled_text.contains("semantic_relation_contract_digest:")
16682 && disabled_text.contains("relation_family_inventory:")
16683 && disabled_text.contains("optional_disabled_families:")
16684 && disabled_text.contains("benchmarked:")
16685 && !disabled_text.contains("accepted_minimum:")
16686 && !disabled_text.contains("provenance_source:")
16687 && disabled_text.contains("optional_catalog:")
16688 && disabled_text.contains("database:")
16689 && disabled_text.contains("compile_options:")
16690 && disabled_text.contains("search:")
16691 && disabled_text.contains("default_mode: lexical")
16692 && disabled_text.contains("optional_parser_pack:"),
16693 "settings did not project compact shared language registry truth",
16694 )?;
16695 require(
16696 disabled_text.len() <= MCP_SETTINGS_RESPONSE_MAX_BYTES,
16697 "settings exceeded its agent-facing output bound",
16698 )?;
16699 let enabled_text = enabled.render_settings_with_capabilities(&enabled_state)?;
16700 require(
16701 enabled_text.contains("path_scope: nearest_indexed_project"),
16702 "enabled nearest-project policy was not typed",
16703 )?;
16704 require(
16705 !enabled_text.contains("GITHUB_TOKEN") && !enabled_text.contains("GH_TOKEN"),
16706 "settings capabilities leaked token environment names",
16707 )?;
16708
16709 Ok(())
16710 }
16711
16712 #[cfg(windows)]
16713 #[test]
16714 fn mcp_settings_reports_native_root_equivalence() -> Result<(), Box<dyn std::error::Error>> {
16715 let temp = tempfile::tempdir()?;
16716 let base = temp
16717 .path()
16718 .to_str()
16719 .ok_or("temporary directory was not UTF-8")?;
16720 let long_component = "a".repeat(220);
16721 let verbatim_root = PathBuf::from(format!(r"\\?\{base}\{long_component}"));
16722 fs::create_dir(&verbatim_root)?;
16723 let verbatim_database = verbatim_root.join(".projectatlas/projectatlas.db");
16724 fs::create_dir_all(
16725 verbatim_database
16726 .parent()
16727 .ok_or("verbatim MCP database has no parent")?,
16728 )?;
16729 drop(AtlasStore::open_for_project(
16730 &verbatim_database,
16731 &verbatim_root,
16732 )?);
16733 let verbatim_config = temp.path().join("mcp-verbatim-config.toml");
16734 let verbatim_text = verbatim_root
16735 .to_str()
16736 .ok_or("verbatim MCP root was not UTF-8")?;
16737 fs::write(
16738 &verbatim_config,
16739 format!(
16740 "[project]\nroot = {}\n",
16741 serde_json::to_string(verbatim_text)?
16742 ),
16743 )?;
16744 let verbatim_expected =
16745 CanonicalProjectRoot::from_path(&verbatim_root)?.display_string()?;
16746 let verbatim_server = ProjectAtlasMcpServer::new(
16747 verbatim_database.clone(),
16748 None,
16749 "mcp-verbatim-settings-test".to_string(),
16750 false,
16751 );
16752 let verbatim_state = McpProjectState {
16753 root: verbatim_root,
16754 db_path: verbatim_database,
16755 config_path: Some(verbatim_config),
16756 worktree: None,
16757 };
16758 let verbatim: serde_json::Value = toon_format::decode_default(
16759 &verbatim_server.render_settings_with_capabilities(&verbatim_state)?,
16760 )?;
16761 require(
16762 verbatim.pointer("/settings/repo_root") == Some(&serde_json::json!(verbatim_expected)),
16763 "MCP settings lost the verbatim native root projection",
16764 )?;
16765
16766 let original = temp.path().join("McpCaseRoot");
16767 let staging = temp.path().join("McpCaseRootStaging");
16768 let renamed = temp.path().join("mcpcaseroot");
16769 fs::create_dir(&original)?;
16770 let database = original.join(".projectatlas/projectatlas.db");
16771 fs::create_dir_all(
16772 database
16773 .parent()
16774 .ok_or_else(|| io::Error::other("MCP case-only database has no parent"))?,
16775 )?;
16776 drop(AtlasStore::open_for_project(&database, &original)?);
16777 fs::rename(&original, &staging)?;
16778 fs::rename(&staging, &renamed)?;
16779 let renamed_database = renamed.join(".projectatlas/projectatlas.db");
16780 let positive_config = temp.path().join("mcp-case-only-config.toml");
16781 fs::write(
16782 &positive_config,
16783 format!(
16784 "[project]\nroot = {}\n",
16785 serde_json::to_string(&renamed.to_string_lossy())?
16786 ),
16787 )?;
16788 let positive_server = ProjectAtlasMcpServer::new(
16789 renamed_database.clone(),
16790 None,
16791 "mcp-settings-test".to_string(),
16792 false,
16793 );
16794 let positive_state = McpProjectState {
16795 root: renamed,
16796 db_path: renamed_database,
16797 config_path: Some(positive_config),
16798 worktree: None,
16799 };
16800 let positive: serde_json::Value = toon_format::decode_default(
16801 &positive_server.render_settings_with_capabilities(&positive_state)?,
16802 )?;
16803 require(
16804 positive.pointer("/settings/root_verified") == Some(&serde_json::json!(true)),
16805 "MCP settings rejected case-only root rename",
16806 )?;
16807
16808 let case_sensitive_parent = temp.path().join("mcp-case-sensitive-parent");
16809 fs::create_dir(&case_sensitive_parent)?;
16810 let enabled = StdCommand::new("fsutil")
16811 .args(["file", "SetCaseSensitiveInfo"])
16812 .arg(&case_sensitive_parent)
16813 .arg("enable")
16814 .status()
16815 .is_ok_and(|status| status.success());
16816 if !enabled {
16817 return Ok(());
16818 }
16819 let stored_root = case_sensitive_parent.join("Repo");
16820 let selected_root = case_sensitive_parent.join("repo");
16821 fs::create_dir(&stored_root)?;
16822 fs::create_dir(&selected_root)?;
16823 let database = stored_root.join(".projectatlas/projectatlas.db");
16824 fs::create_dir_all(
16825 database
16826 .parent()
16827 .ok_or_else(|| io::Error::other("MCP case-sensitive database has no parent"))?,
16828 )?;
16829 drop(AtlasStore::open_for_project(&database, &stored_root)?);
16830 let negative_config = temp.path().join("mcp-case-sensitive-config.toml");
16831 fs::write(
16832 &negative_config,
16833 format!(
16834 "[project]\nroot = {}\n",
16835 serde_json::to_string(&selected_root.to_string_lossy())?
16836 ),
16837 )?;
16838 let negative_server = ProjectAtlasMcpServer::new(
16839 database.clone(),
16840 None,
16841 "mcp-settings-test".to_string(),
16842 false,
16843 );
16844 let negative_state = McpProjectState {
16845 root: selected_root,
16846 db_path: database,
16847 config_path: Some(negative_config),
16848 worktree: None,
16849 };
16850 let negative: serde_json::Value = toon_format::decode_default(
16851 &negative_server.render_settings_with_capabilities(&negative_state)?,
16852 )?;
16853 require(
16854 negative.pointer("/settings/root_verified") == Some(&serde_json::json!(false)),
16855 "MCP settings accepted a case-sensitive sibling",
16856 )?;
16857 Ok(())
16858 }
16859
16860 #[test]
16861 fn task_progress_status_and_cancel_are_typed() -> Result<(), Box<dyn std::error::Error>> {
16862 let temp = tempfile::tempdir()?;
16863 let repo = temp.path().join("repo-a");
16864 fs::create_dir(&repo)?;
16865 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16866 let server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16867
16868 let status = server.task_status(MCP_TASK_CONTRACT_ID.to_string())?;
16869 require(
16870 status.lookup == McpTaskLookupStatus::Found,
16871 "contract task missing",
16872 )?;
16873 require(
16874 status
16875 .task
16876 .as_ref()
16877 .is_some_and(|task| task.state == McpTaskState::Complete),
16878 "contract task was not complete",
16879 )?;
16880 require(
16881 status.states.contains(&McpTaskState::Pending)
16882 && status.states.contains(&McpTaskState::Canceled),
16883 "status response did not expose task state contract",
16884 )?;
16885 let cancel = server.task_cancel(MCP_TASK_CONTRACT_ID.to_string())?;
16886 require(
16887 cancel.result == McpTaskCancelResult::AlreadyFinished,
16888 "completed contract task did not return already_finished",
16889 )?;
16890 let missing = server.task_status("missing-task".to_string())?;
16891 require(
16892 missing.lookup == McpTaskLookupStatus::NotFound,
16893 "unknown task did not return not_found",
16894 )?;
16895
16896 Ok(())
16897 }
16898
16899 #[test]
16900 fn background_task_envelope_and_cancellation_reach_owned_control()
16901 -> Result<(), Box<dyn std::error::Error>> {
16902 let temp = tempfile::tempdir()?;
16903 let repo = temp.path().join("repo-a");
16904 fs::create_dir(&repo)?;
16905 let db_path = repo.join(".projectatlas").join("projectatlas.db");
16906 let mut server = ProjectAtlasMcpServer::new(db_path, None, "mcp-test".to_string(), false);
16907 let host_envelope = server.background_resources;
16908 let host_workers = thread::available_parallelism().map_or(1, usize::from);
16909 let representative_envelope = McpBackgroundResourceEnvelope::from_available_workers(8);
16910 require(
16911 host_envelope.task_limit <= MCP_BACKGROUND_TASK_SAFE_CEILING
16912 && host_envelope.workers_per_task > 0
16913 && host_envelope.total_worker_limit
16914 <= host_workers.clamp(1, INDEX_WORKER_SAFE_CEILING)
16915 && host_envelope.workers_per_task * host_envelope.task_limit
16916 <= host_envelope.total_worker_limit,
16917 "background resource envelope exceeded its host or process worker budget",
16918 )?;
16919 require(
16920 representative_envelope
16921 == (McpBackgroundResourceEnvelope {
16922 task_limit: 4,
16923 workers_per_task: 2,
16924 total_worker_limit: 8,
16925 })
16926 && McpBackgroundResourceEnvelope::from_available_workers(0).total_worker_limit == 1
16927 && McpBackgroundResourceEnvelope::from_available_workers(usize::MAX)
16928 .total_worker_limit
16929 == INDEX_WORKER_SAFE_CEILING,
16930 "background resource envelope did not partition representative host capacities",
16931 )?;
16932
16933 server.background_resources = McpBackgroundResourceEnvelope::from_available_workers(4);
16934 let envelope = server.background_resources;
16935 let started = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
16936 let databases_ready = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
16937 let release = Arc::new(std::sync::Barrier::new(envelope.task_limit + 1));
16938 let observed_option_workers = Arc::new(AtomicU64::new(0));
16939 let observed_control_workers = Arc::new(AtomicU64::new(0));
16940 let mut concurrent_tasks = Vec::new();
16941 for task_index in 0..envelope.task_limit {
16942 let isolated_root = temp.path().join(format!("repo-{task_index}"));
16943 let isolated_db = isolated_root.join(".projectatlas").join("projectatlas.db");
16944 fs::create_dir_all(isolated_root.join(".projectatlas"))?;
16945 let worker_started = Arc::clone(&started);
16946 let worker_databases_ready = Arc::clone(&databases_ready);
16947 let worker_release = Arc::clone(&release);
16948 let worker_option_workers = Arc::clone(&observed_option_workers);
16949 let worker_control_workers = Arc::clone(&observed_control_workers);
16950 concurrent_tasks.push(server.start_index_task(
16951 McpTaskOperation::Scan,
16952 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
16953 MCP_TOOL_ATLAS_OVERVIEW,
16954 move |control, options| {
16955 worker_option_workers
16956 .fetch_add(options.reported_workers() as u64, Ordering::Relaxed);
16957 worker_control_workers.fetch_add(
16958 control.worker_ceiling().unwrap_or_default() as u64,
16959 Ordering::Relaxed,
16960 );
16961 worker_started.wait();
16962 let store = open_atlas_store_for_project(&isolated_db, &isolated_root);
16963 worker_databases_ready.wait();
16964 worker_release.wait();
16965 let _store = store?;
16966 Ok(())
16967 },
16968 )?);
16969 }
16970 started.wait();
16971 let admitted_workers = envelope.workers_per_task * envelope.task_limit;
16972 require(
16973 observed_option_workers.load(Ordering::Relaxed) == admitted_workers as u64
16974 && observed_control_workers.load(Ordering::Relaxed) == admitted_workers as u64
16975 && admitted_workers <= envelope.total_worker_limit,
16976 "concurrent background tasks did not share the aggregate worker envelope",
16977 )?;
16978 let overflow = server.start_index_task(
16979 McpTaskOperation::Scan,
16980 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
16981 MCP_TOOL_ATLAS_OVERVIEW,
16982 |_control, _options| Ok(()),
16983 );
16984 require(
16985 matches!(overflow, Err(CliError::Mcp(message)) if message.starts_with(MCP_INDEX_TASK_LIMIT_PREFIX)),
16986 "background task admission exceeded the server task limit",
16987 )?;
16988 require(
16989 server.task_status(MCP_TASK_CONTRACT_ID.to_string())?.lookup
16990 == McpTaskLookupStatus::Found,
16991 "task status became unresponsive while concurrent work was active",
16992 )?;
16993 databases_ready.wait();
16994 release.wait();
16995 for task in concurrent_tasks {
16996 require(
16997 wait_for_background_task(&server, &task.task_id)?.state == McpTaskState::Complete,
16998 "concurrent background task did not finish successfully",
16999 )?;
17000 }
17001
17002 let cancel_started = Arc::new(std::sync::Barrier::new(2));
17003 let worker_cancel_started = Arc::clone(&cancel_started);
17004 let canceled_task = server.start_index_task(
17005 McpTaskOperation::Scan,
17006 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17007 MCP_TOOL_ATLAS_OVERVIEW,
17008 move |control, options| {
17009 if options.reported_workers() != control.worker_ceiling().unwrap_or_default() {
17010 return Err(CliError::Mcp(
17011 "background parser and operation worker ceilings diverged".to_string(),
17012 ));
17013 }
17014 worker_cancel_started.wait();
17015 loop {
17016 control
17017 .check(projectatlas_core::IndexWorkStage::RepositoryTraversal)
17018 .map_err(|failure| {
17019 CliError::Fs(projectatlas_fs::FsError::IndexWork(failure))
17020 })?;
17021 thread::yield_now();
17022 }
17023 },
17024 )?;
17025 cancel_started.wait();
17026
17027 let running = server.task_status(canceled_task.task_id.clone())?;
17028 require(
17029 running
17030 .task
17031 .as_ref()
17032 .is_some_and(|record| record.state == McpTaskState::Running),
17033 "background task did not become running",
17034 )?;
17035 let cancel = server.task_cancel(canceled_task.task_id.clone())?;
17036 require(
17037 cancel.result == McpTaskCancelResult::CancellationRequested,
17038 "task cancellation did not reach the active work control",
17039 )?;
17040
17041 require(
17042 wait_for_background_task(&server, &canceled_task.task_id)?.state
17043 == McpTaskState::Canceled,
17044 "background task did not finish canceled after consuming the signal",
17045 )?;
17046 require(
17047 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17048 "successful task was not admitted after cancellation",
17049 )?;
17050
17051 let failed_task = server.start_index_task(
17052 McpTaskOperation::Scan,
17053 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17054 MCP_TOOL_ATLAS_OVERVIEW,
17055 |_control, _options| Err(CliError::Mcp("expected task failure".to_string())),
17056 )?;
17057 require(
17058 wait_for_background_task(&server, &failed_task.task_id)?.state == McpTaskState::Failed,
17059 "background task error did not become a terminal failure",
17060 )?;
17061 require(
17062 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17063 "successful task was not admitted after failure",
17064 )?;
17065
17066 let panicked_task = server.start_index_task(
17067 McpTaskOperation::Scan,
17068 SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None),
17069 MCP_TOOL_ATLAS_OVERVIEW,
17070 |_control, _options| -> Result<(), CliError> {
17071 std::panic::resume_unwind(Box::new("expected background task panic"));
17072 },
17073 )?;
17074 let panicked = wait_for_background_task(&server, &panicked_task.task_id)?;
17075 require(
17076 panicked.state == McpTaskState::Failed
17077 && panicked.error.as_deref() == Some(MCP_INDEX_WORKER_PANIC_ERROR),
17078 "background task panic did not remain bounded and terminal",
17079 )?;
17080 require(
17081 run_successful_background_task(&server)?.state == McpTaskState::Complete,
17082 "terminal task lifecycle did not release background admission capacity",
17083 )?;
17084
17085 Ok(())
17086 }
17087
17088 #[test]
17089 fn background_scan_defers_config_validation_and_rejects_root_redirection()
17090 -> Result<(), Box<dyn std::error::Error>> {
17091 let temp = tempfile::tempdir()?;
17092 let repo = temp.path().join("repo-a");
17093 let redirected = temp.path().join("repo-b");
17094 let atlas_dir = repo.join(".projectatlas");
17095 fs::create_dir_all(&atlas_dir)?;
17096 fs::create_dir_all(&redirected)?;
17097 let config_path = atlas_dir.join("config.toml");
17098 fs::write(&config_path, "[project]\nroot = \"../../repo-b\"\n")?;
17099 let db_path = atlas_dir.join("projectatlas.db");
17100 let server =
17101 ProjectAtlasMcpServer::new(db_path, Some(config_path), "mcp-test".to_string(), false);
17102
17103 let response = server.atlas_scan(Parameters(AtlasScanParams {
17104 project_path: Some(repo.to_string_lossy().into_owned()),
17105 worktree: None,
17106 path: None,
17107 nearest_project: Some(false),
17108 max_bytes: None,
17109 max_workers: Some(1),
17110 timeout_seconds: None,
17111 text_index_max_bytes: None,
17112 background: Some(true),
17113 }));
17114 require(
17115 response.contains(MCP_PAYLOAD_TASK_START),
17116 "explicit background project validated redirecting config before task admission",
17117 )?;
17118 let admitted_task_id = server
17119 .task_registry
17120 .read()
17121 .map_err(|_poisoned| io::Error::other("task registry lock poisoned"))?
17122 .latest_task_id(&McpTaskOperation::Scan)
17123 .ok_or_else(|| io::Error::other("admitted background scan task missing"))?;
17124 let mut admitted_terminal = None;
17125 for _attempt in 0..1_000 {
17126 let status = server.task_status(admitted_task_id.clone())?;
17127 if status
17128 .task
17129 .as_ref()
17130 .is_some_and(McpTaskRecord::is_terminal_state)
17131 {
17132 admitted_terminal = status.task;
17133 break;
17134 }
17135 thread::sleep(Duration::from_millis(1));
17136 }
17137 require(
17138 admitted_terminal
17139 .as_ref()
17140 .is_some_and(|record| record.state == McpTaskState::Failed),
17141 "redirecting background config did not fail inside the admitted task",
17142 )?;
17143 require(
17144 admitted_terminal
17145 .as_ref()
17146 .and_then(|record| record.error.as_deref())
17147 .is_some_and(|error| error.contains("outside selected project root")),
17148 "controlled plan loading did not preserve root-redirection refusal",
17149 )?;
17150
17151 Ok(())
17152 }
17153
17154 #[test]
17155 fn index_adapters_publish_scan_symbol_and_watch_effects()
17156 -> Result<(), Box<dyn std::error::Error>> {
17157 let temp = tempfile::tempdir()?;
17158 let repo = temp.path().join("repo");
17159 let source_dir = repo.join("src");
17160 fs::create_dir_all(&source_dir)?;
17161 let source_path = source_dir.join("lib.rs");
17162 fs::write(
17163 &source_path,
17164 "pub fn first() { second(); }\nfn second() {}\n",
17165 )?;
17166 let config_path = repo.join(".projectatlas").join("config.toml");
17167 init_project_with_config(&repo, Some(&config_path))?;
17168 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17169 let server =
17170 ProjectAtlasMcpServer::new(db_path.clone(), None, "mcp-test".to_string(), false);
17171 let project_path = repo.to_string_lossy().into_owned();
17172
17173 for operation in [
17174 McpTaskOperation::Scan,
17175 McpTaskOperation::SymbolsBuild,
17176 McpTaskOperation::WatchOnce,
17177 ] {
17178 match operation {
17179 McpTaskOperation::SymbolsBuild => {
17180 let store = open_atlas_store_for_project(&db_path, &repo)?;
17181 store.clear_symbol_graph_for_path("src/lib.rs")?;
17182 require(
17183 store.symbol_count_for_path("src/lib.rs")? == 0,
17184 "symbol fixture was not cleared before background rebuild",
17185 )?;
17186 }
17187 McpTaskOperation::WatchOnce => {
17188 fs::write(
17189 &source_path,
17190 "pub fn first() { second(); third(); }\nfn second() {}\nfn third() {}\n",
17191 )?;
17192 }
17193 McpTaskOperation::Scan | McpTaskOperation::Contract | McpTaskOperation::Search => {}
17194 }
17195
17196 let response = match operation {
17197 McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => {
17198 let params = AtlasScanParams {
17199 project_path: Some(project_path.clone()),
17200 worktree: None,
17201 path: None,
17202 nearest_project: Some(false),
17203 max_bytes: None,
17204 max_workers: Some(1),
17205 timeout_seconds: None,
17206 text_index_max_bytes: None,
17207 background: Some(true),
17208 };
17209 if operation == McpTaskOperation::Scan {
17210 server.atlas_scan(Parameters(params))
17211 } else {
17212 server.atlas_symbols_build(Parameters(params))
17213 }
17214 }
17215 McpTaskOperation::WatchOnce => {
17216 server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17217 project_path: Some(project_path.clone()),
17218 worktree: None,
17219 path: None,
17220 nearest_project: Some(false),
17221 max_workers: Some(1),
17222 timeout_seconds: None,
17223 text_index_max_bytes: None,
17224 background: Some(true),
17225 }))
17226 }
17227 McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
17228 };
17229 require(
17230 response.contains(MCP_PAYLOAD_TASK_START),
17231 "production background adapter did not admit its task",
17232 )?;
17233 let terminal = wait_for_background_operation(&server, &operation)?;
17234 require(
17235 terminal.state == McpTaskState::Complete,
17236 terminal
17237 .error
17238 .as_deref()
17239 .unwrap_or("background task failed"),
17240 )?;
17241
17242 let expected_symbol = match operation {
17243 McpTaskOperation::Scan | McpTaskOperation::SymbolsBuild => "second",
17244 McpTaskOperation::WatchOnce => "third",
17245 McpTaskOperation::Contract | McpTaskOperation::Search => unreachable!(),
17246 };
17247 let store = open_atlas_store_for_project(&db_path, &repo)?;
17248 store.set_purpose(
17249 "src/lib.rs",
17250 "Own café λ relation navigation",
17251 PurposeSource::Agent,
17252 )?;
17253 drop(store);
17254 require_agent_index_reads(&server, &project_path, expected_symbol)?;
17255 }
17256
17257 let store = open_atlas_store_for_project(&db_path, &repo)?;
17258 store.clear_symbol_graph_for_path("src/lib.rs")?;
17259 drop(store);
17260 let synchronous_symbols = server.atlas_symbols_build(Parameters(AtlasScanParams {
17261 project_path: Some(project_path.clone()),
17262 worktree: None,
17263 path: None,
17264 nearest_project: Some(false),
17265 max_bytes: None,
17266 max_workers: Some(1),
17267 timeout_seconds: None,
17268 text_index_max_bytes: None,
17269 background: Some(false),
17270 }));
17271 require(
17272 synchronous_symbols.contains(MCP_PAYLOAD_SYMBOLS_BUILD),
17273 "synchronous symbol adapter did not return its completed report",
17274 )?;
17275 require_agent_index_reads(&server, &project_path, "second")?;
17276
17277 fs::write(
17278 &source_path,
17279 "pub fn first() { second(); fourth(); }\nfn second() {}\nfn fourth() {}\n",
17280 )?;
17281 let synchronous_watch = server.atlas_watch_once(Parameters(AtlasWatchOnceParams {
17282 project_path: Some(project_path.clone()),
17283 worktree: None,
17284 path: None,
17285 nearest_project: Some(false),
17286 max_workers: Some(1),
17287 timeout_seconds: None,
17288 text_index_max_bytes: None,
17289 background: Some(false),
17290 }));
17291 require(
17292 synchronous_watch.contains(MCP_PAYLOAD_WATCH),
17293 "synchronous watch adapter did not return its completed report",
17294 )?;
17295 require_agent_index_reads(&server, &project_path, "fourth")?;
17296 Ok(())
17297 }
17298
17299 #[test]
17300 fn long_lived_mcp_query_families_reuse_one_verified_epoch()
17301 -> Result<(), Box<dyn std::error::Error>> {
17302 const LARGE_UNRELATED_SOURCE_FILES: usize = 256;
17303 const MAX_MEASURED_QUERY_OUTPUT_BYTES: usize = 64 * 1_024;
17304 const MAX_MEASURED_QUERY_ELAPSED: Duration = Duration::from_secs(30);
17305
17306 let measure = |unrelated_source_files: usize| {
17307 let temp = tempfile::tempdir()?;
17308 let repo = temp.path().join("repo");
17309 let source_dir = repo.join("src");
17310 fs::create_dir_all(&source_dir)?;
17311 fs::write(
17312 source_dir.join("lib.rs"),
17313 "pub fn first() { second(); }\nfn second() {}\n",
17314 )?;
17315 for index in 0..unrelated_source_files {
17316 fs::write(
17317 source_dir.join(format!("unrelated_{index:03}.rs")),
17318 format!("pub fn unrelated_{index:03}() {{}}\n"),
17319 )?;
17320 }
17321 let config_path = repo.join(".projectatlas").join("config.toml");
17322 init_project_with_config(&repo, Some(&config_path))?;
17323 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17324 let mut writer = open_atlas_store_for_project(&db_path, &repo)?;
17325 let plan = ScanRuntimePlan::for_path(None, &repo, None)?;
17326 run_scan_pipeline(
17327 &mut writer,
17328 &plan,
17329 &SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, Some(1), None),
17330 )?;
17331 drop(writer);
17332
17333 let server = ProjectAtlasMcpServer::new(
17334 db_path.clone(),
17335 None,
17336 "verified-epoch-test".to_string(),
17337 false,
17338 );
17339 let state = McpProjectState {
17340 root: repo,
17341 db_path,
17342 config_path: None,
17343 worktree: None,
17344 };
17345 let first = server.with_fresh_store(&state, |store, _stamp| Ok(store.overview()?))?;
17346 require(
17347 first.work.exact_verifications >= 1,
17348 "first long-lived MCP read did not establish exact source truth",
17349 )?;
17350 require(
17351 first.work.filesystem_entries > u64::try_from(unrelated_source_files)?,
17352 "scale fixture did not exercise its complete repository source set",
17353 )?;
17354 let expected_stamp = first.stamp;
17355
17356 let folder = server.with_fresh_store(&state, |store, _stamp| {
17357 Ok(render_ranked_nodes(
17358 NODE_LABEL_FOLDERS,
17359 &ranked_folder_nodes_with_reasons(store, "", 4)?,
17360 ))
17361 })?;
17362 let folder_output_bytes = folder.value.len();
17363 let folder = folder.with_output_bytes(folder_output_bytes);
17364 let files = server.with_fresh_store(&state, |store, _stamp| {
17365 Ok(render_ranked_nodes(
17366 NODE_LABEL_FILES,
17367 &ranked_file_nodes_with_reasons(store, "", Some("src"), None, 4, false)?,
17368 ))
17369 })?;
17370 let files_output_bytes = files.value.len();
17371 let files = files.with_output_bytes(files_output_bytes);
17372 let summary = server.with_fresh_store(&state, |store, _stamp| {
17373 let content = read_indexed_file_content(store, "src/lib.rs")?;
17374 let report = build_file_summary_from_source(
17375 store,
17376 Path::new("src/lib.rs"),
17377 DEFAULT_FILE_SUMMARY_LIMIT,
17378 &content,
17379 )?;
17380 Ok(render_file_summary(&report))
17381 })?;
17382 let summary_output_bytes = summary.value.len();
17383 let summary = summary.with_output_bytes(summary_output_bytes);
17384 let relations = server.with_fresh_store(&state, |store, _stamp| {
17385 Ok(render_symbol_relations(&store.load_symbol_relations(
17386 Some("src/lib.rs"),
17387 None,
17388 8,
17389 )?))
17390 })?;
17391 let relation_output_bytes = relations.value.len();
17392 let relations = relations.with_output_bytes(relation_output_bytes);
17393
17394 let mut measurements = Vec::new();
17395 for (family, outcome) in [
17396 ("folder", folder),
17397 ("file", files),
17398 ("summary", summary),
17399 ("relation", relations),
17400 ] {
17401 require(
17402 outcome.stamp == expected_stamp,
17403 &format!("{family} call did not remain bound to the verified epoch"),
17404 )?;
17405 require(
17406 outcome.work.exact_verifications == 0
17407 && outcome.work.filesystem_entries == 0
17408 && outcome.work.filesystem_bytes == 0
17409 && outcome.work.decoded_nodes == 0,
17410 &format!("{family} call repeated repository-sized freshness work"),
17411 )?;
17412 require(
17413 outcome.work.sqlite_read_statements == 1,
17414 &format!("{family} freshness check used unexpected SQLite work"),
17415 )?;
17416 require(
17417 outcome.work.output_bytes == u64::try_from(outcome.value.len())?,
17418 &format!("{family} call did not record its accepted rendered bytes"),
17419 )?;
17420 require(
17421 outcome.value.len() <= MAX_MEASURED_QUERY_OUTPUT_BYTES,
17422 &format!("{family} call exceeded the focused output bound"),
17423 )?;
17424 require(
17425 !outcome.work.elapsed.is_zero()
17426 && outcome.work.elapsed <= MAX_MEASURED_QUERY_ELAPSED,
17427 &format!("{family} call did not retain a bounded elapsed measurement"),
17428 )?;
17429 measurements.push((family, outcome.work));
17430 }
17431 Ok::<_, Box<dyn std::error::Error>>(measurements)
17432 };
17433
17434 let small = measure(0)?;
17435 let large = measure(LARGE_UNRELATED_SOURCE_FILES)?;
17436 for ((small_family, small_work), (large_family, large_work)) in small.into_iter().zip(large)
17437 {
17438 require(
17439 small_family == large_family,
17440 "small and large query measurements used different families",
17441 )?;
17442 require(
17443 small_work.exact_verifications == large_work.exact_verifications
17444 && small_work.filesystem_entries == large_work.filesystem_entries
17445 && small_work.filesystem_bytes == large_work.filesystem_bytes
17446 && small_work.sqlite_read_statements == large_work.sqlite_read_statements
17447 && small_work.decoded_nodes == large_work.decoded_nodes,
17448 &format!("{small_family} warm freshness work changed with repository scale"),
17449 )?;
17450 }
17451 Ok(())
17452 }
17453
17454 #[test]
17455 fn selected_root_absolute_path_keys_stay_inside_selected_project()
17456 -> Result<(), Box<dyn std::error::Error>> {
17457 let temp = tempfile::tempdir()?;
17458 let repo = temp.path().join("repo");
17459 let outside = temp.path().join("outside");
17460 fs::create_dir_all(repo.join("src"))?;
17461 fs::create_dir_all(outside.join("src"))?;
17462 let inside_file = repo.join("src").join("lib.rs");
17463 let outside_file = outside.join("src").join("lib.rs");
17464 fs::write(&inside_file, "pub fn inside() {}\n")?;
17465 fs::write(&outside_file, "pub fn outside() {}\n")?;
17466 let state = McpProjectState {
17467 root: canonical_project_root(&repo)?,
17468 db_path: repo.join(".projectatlas").join("projectatlas.db"),
17469 config_path: None,
17470 worktree: None,
17471 };
17472
17473 let inside_key =
17474 ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &inside_file)?
17475 .ok_or_else(|| io::Error::other("inside selected root did not produce key"))?;
17476 require(
17477 inside_key == "src/lib.rs",
17478 "inside selected root produced wrong repo key",
17479 )?;
17480 require(
17481 ProjectAtlasMcpServer::absolute_path_key_in_selected_project(&state, &outside_file)?
17482 .is_none(),
17483 "outside selected root produced a repo key",
17484 )?;
17485
17486 Ok(())
17487 }
17488
17489 #[test]
17490 fn indexed_root_candidate_requires_matching_project_root()
17491 -> Result<(), Box<dyn std::error::Error>> {
17492 let temp = tempfile::tempdir()?;
17493 let repo = temp.path().join("repo");
17494 let other = temp.path().join("other");
17495 fs::create_dir_all(repo.join(".projectatlas"))?;
17496 fs::create_dir_all(&other)?;
17497
17498 require(
17499 ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
17500 "candidate without DB was treated as indexed",
17501 )?;
17502
17503 let db_path = repo.join(".projectatlas").join("projectatlas.db");
17504 {
17505 let _store = open_atlas_store_for_project(&db_path, &other)?;
17506 }
17507 require(
17508 ProjectAtlasMcpServer::indexed_root_from_candidate(&repo).is_none(),
17509 "candidate with mismatched DB root was treated as indexed",
17510 )?;
17511
17512 reset_index_files(&db_path, true, false, false)?;
17513 {
17514 let _store = open_atlas_store_for_project(&db_path, &repo)?;
17515 }
17516 let indexed = ProjectAtlasMcpServer::indexed_root_from_candidate(&repo)
17517 .ok_or_else(|| io::Error::other("matching DB root was not accepted"))?;
17518 require(
17519 indexed.root == canonical_project_root(&repo)?,
17520 "indexed root did not preserve canonical candidate root",
17521 )?;
17522 let expected_db_path =
17523 ProjectAtlasMcpServer::projectatlas_db_path(&canonical_project_root(&repo)?);
17524 require(
17525 indexed.db_path == expected_db_path,
17526 "indexed root changed DB path",
17527 )?;
17528
17529 Ok(())
17530 }
17531
17532 #[cfg(windows)]
17533 #[test]
17534 fn indexed_root_predecessor_candidate_supports_nearest_routing()
17535 -> Result<(), Box<dyn std::error::Error>> {
17536 let temp = tempfile::tempdir()?;
17537 let root = temp.path().join("predecessor");
17538 let source = root.join("src").join("lib.rs");
17539 let database = root.join(".projectatlas").join("projectatlas.db");
17540 fs::create_dir_all(
17541 source
17542 .parent()
17543 .ok_or_else(|| io::Error::other("predecessor source path has no parent"))?,
17544 )?;
17545 fs::create_dir_all(
17546 database
17547 .parent()
17548 .ok_or_else(|| io::Error::other("predecessor database path has no parent"))?,
17549 )?;
17550 fs::write(&source, "pub fn predecessor() {}\n")?;
17551 let store = open_atlas_store_for_project(&database, &root)?;
17552 drop(store);
17553 let predecessor = rusqlite::Connection::open(&database)?;
17554 drop_native_worktree_identity_schema(&predecessor)?;
17555 predecessor.execute_batch(
17556 "DROP TABLE project_root_identity;
17557 DROP TABLE IF EXISTS graph_identity_rejections;
17558 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
17559 )?;
17560 drop(predecessor);
17561
17562 let expected_root = canonical_project_root(&root)?;
17563 let expected_database = ProjectAtlasMcpServer::projectatlas_db_path(&expected_root);
17564 let canonical = ProjectAtlasMcpServer::project_state_from_nearest_indexed_path(&source)?
17565 .ok_or_else(|| io::Error::other("canonical nearest predecessor was not found"))?;
17566 let lexical =
17567 ProjectAtlasMcpServer::project_state_from_nearest_lexical_indexed_path(&source)?
17568 .ok_or_else(|| io::Error::other("lexical nearest predecessor was not found"))?;
17569 for state in [canonical, lexical] {
17570 require(
17571 state.root == expected_root && state.db_path == expected_database,
17572 "nearest predecessor routing changed the canonical root or database path",
17573 )?;
17574 }
17575 Ok(())
17576 }
17577
17578 #[cfg(windows)]
17579 #[test]
17580 fn nearest_routing_recanonicalizes_case_only_root_rename()
17581 -> Result<(), Box<dyn std::error::Error>> {
17582 let temp = tempfile::tempdir()?;
17583 let original = temp.path().join("NearestCaseRoot");
17584 let staging = temp.path().join("NearestCaseRootStaging");
17585 let renamed = temp.path().join("nearestcaseroot");
17586 let source = original.join("src").join("lib.rs");
17587 fs::create_dir_all(
17588 source
17589 .parent()
17590 .ok_or_else(|| io::Error::other("case-only nearest source has no parent"))?,
17591 )?;
17592 let database = original.join(".projectatlas").join("projectatlas.db");
17593 fs::create_dir_all(
17594 database
17595 .parent()
17596 .ok_or_else(|| io::Error::other("case-only nearest database has no parent"))?,
17597 )?;
17598 fs::write(&source, "pub fn nearest_case_only() {}\n")?;
17599 drop(open_atlas_store_for_project(&database, &original)?);
17600
17601 fs::rename(&original, &staging)?;
17602 fs::rename(&staging, &renamed)?;
17603 let renamed_source = renamed.join("src").join("lib.rs");
17604 let expected_root = canonical_project_root(&renamed)?;
17605 let expected_database = ProjectAtlasMcpServer::projectatlas_db_path(&expected_root);
17606 let canonical =
17607 ProjectAtlasMcpServer::project_state_from_nearest_indexed_path(&renamed_source)?
17608 .ok_or_else(|| {
17609 io::Error::other("canonical nearest case-only root was not found")
17610 })?;
17611 let lexical = ProjectAtlasMcpServer::project_state_from_nearest_lexical_indexed_path(
17612 &renamed_source,
17613 )?
17614 .ok_or_else(|| io::Error::other("lexical nearest case-only root was not found"))?;
17615 for state in [canonical, lexical] {
17616 require(
17617 state.root == expected_root && state.db_path == expected_database,
17618 "nearest routing rejected a case-only root rename",
17619 )?;
17620 }
17621 Ok(())
17622 }
17623
17624 #[cfg(windows)]
17625 #[test]
17626 fn nearest_routing_rejects_case_sensitive_sibling() -> Result<(), Box<dyn std::error::Error>> {
17627 let temp = tempfile::tempdir()?;
17628 let parent = temp.path().join("nearest-case-sensitive-parent");
17629 fs::create_dir(&parent)?;
17630 let enabled = StdCommand::new("fsutil")
17631 .args(["file", "SetCaseSensitiveInfo"])
17632 .arg(&parent)
17633 .arg("enable")
17634 .status()
17635 .is_ok_and(|status| status.success());
17636 if !enabled {
17637 return Ok(());
17638 }
17639
17640 let stored_root = parent.join("Repo");
17641 let selected_root = parent.join("repo");
17642 fs::create_dir(&stored_root)?;
17643 fs::create_dir(&selected_root)?;
17644 let stored_database = stored_root.join(".projectatlas").join("projectatlas.db");
17645 let selected_database = selected_root.join(".projectatlas").join("projectatlas.db");
17646 fs::create_dir_all(
17647 stored_database
17648 .parent()
17649 .ok_or_else(|| io::Error::other("stored nearest database has no parent"))?,
17650 )?;
17651 fs::create_dir_all(
17652 selected_database
17653 .parent()
17654 .ok_or_else(|| io::Error::other("selected nearest database has no parent"))?,
17655 )?;
17656 drop(open_atlas_store_for_project(
17657 &stored_database,
17658 &stored_root,
17659 )?);
17660 fs::copy(&stored_database, &selected_database)?;
17661
17662 require(
17663 ProjectAtlasMcpServer::indexed_root_from_candidate(&selected_root).is_none()
17664 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(&selected_root)
17665 .is_none(),
17666 "nearest routing accepted a distinct case-sensitive sibling",
17667 )?;
17668 Ok(())
17669 }
17670
17671 #[cfg(unix)]
17672 #[test]
17673 fn mcp_init_rejects_ambiguous_predecessor_before_project_writes()
17674 -> Result<(), Box<dyn std::error::Error>> {
17675 use std::ffi::OsString;
17676 use std::os::unix::ffi::OsStringExt;
17677
17678 let temp = tempfile::tempdir()?;
17679 let raw_root = temp
17680 .path()
17681 .join(OsString::from_vec(b"mcp-raw-root-\x80".to_vec()));
17682 let replacement_root = PathBuf::from(raw_root.to_string_lossy().into_owned());
17683 let database = raw_root
17684 .join(PROJECTATLAS_DIR_NAME)
17685 .join(PROJECTATLAS_DB_FILE_NAME);
17686 fs::create_dir_all(&raw_root)?;
17687 fs::create_dir_all(
17688 database
17689 .parent()
17690 .ok_or_else(|| io::Error::other("predecessor database has no parent"))?,
17691 )?;
17692 let store = AtlasStore::open_for_project(&database, &raw_root)?;
17693 drop(store);
17694 let predecessor = rusqlite::Connection::open(&database)?;
17695 drop_native_worktree_identity_schema(&predecessor)?;
17696 predecessor.execute_batch(
17697 "DROP TABLE project_root_identity;
17698 DROP TABLE IF EXISTS graph_identity_rejections;
17699 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
17700 )?;
17701 predecessor.execute(
17702 "INSERT INTO metadata(key, value) VALUES('project_root', ?1)
17703 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
17704 [&replacement_root.to_string_lossy().into_owned()],
17705 )?;
17706 drop(predecessor);
17707 fs::create_dir_all(&replacement_root)?;
17708
17709 require(
17714 default_mcp_project_root(&database, None).is_err(),
17715 "ambiguous predecessor was selected during startup discovery",
17716 )?;
17717 let database_before = fs::read(&database)?;
17718 let sidecars_before = ["wal", "shm", "journal"]
17719 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok());
17720 let raw_project_dir = raw_root.join(PROJECTATLAS_DIR_NAME);
17721 let raw_config = raw_project_dir.join(PROJECTATLAS_CONFIG_FILE_NAME);
17722 let raw_nonsource = raw_project_dir.join(MCP_NONSOURCE_FILE_NAME);
17723 let replacement_project_dir = replacement_root.join(PROJECTATLAS_DIR_NAME);
17724 let replacement_config = replacement_project_dir.join(PROJECTATLAS_CONFIG_FILE_NAME);
17725 let replacement_nonsource = replacement_project_dir.join(MCP_NONSOURCE_FILE_NAME);
17726 let server = ProjectAtlasMcpServer::new(
17727 database.clone(),
17728 None,
17729 "ambiguous-predecessor".to_string(),
17730 false,
17731 );
17732 let startup = ProjectAtlasMcpServer::startup_project_state(database.clone(), None);
17733 require(
17734 startup.root == canonical_project_root(&raw_root)?,
17735 "MCP startup did not select the raw native predecessor root",
17736 )?;
17737 require(
17738 startup.root != canonical_project_root(&replacement_root)?,
17739 "MCP startup selected the replacement-character candidate",
17740 )?;
17741 let result = server.atlas_init(Parameters(AtlasInitParams {
17742 project_path: None,
17743 worktree: None,
17744 no_scan: Some(true),
17745 force_rescan: Some(false),
17746 text_index_max_bytes: None,
17747 }));
17748 require(
17749 result.contains("project-root identity"),
17750 &format!("ambiguous predecessor init returned an unexpected result: {result}"),
17751 )?;
17752 require(
17753 fs::read(&database)? == database_before
17754 && ["wal", "shm", "journal"]
17755 .map(|suffix| fs::read(db_sidecar_path(&database, suffix)).ok())
17756 == sidecars_before,
17757 "ambiguous predecessor init changed database or sidecar state",
17758 )?;
17759 require(
17760 !replacement_project_dir.exists()
17761 && !replacement_config.exists()
17762 && !replacement_nonsource.exists(),
17763 "ambiguous predecessor init wrote replacement-root project state",
17764 )?;
17765 require(
17766 !raw_config.exists() && !raw_nonsource.exists(),
17767 "ambiguous predecessor init wrote raw-root project state",
17768 )?;
17769 Ok(())
17770 }
17771
17772 #[test]
17773 fn mcp_init_rejects_current_wrong_root_before_project_writes()
17774 -> Result<(), Box<dyn std::error::Error>> {
17775 let temp = tempfile::tempdir()?;
17776 let selected_root = temp.path().join("selected-root");
17777 let bound_root = temp.path().join("bound-root");
17778 let database = temp.path().join("external-projectatlas.db");
17779 let config_path = selected_root.join("external-config/config.toml");
17780 fs::create_dir_all(&selected_root)?;
17781 fs::create_dir_all(&bound_root)?;
17782 let persisted_identity = {
17783 let store = AtlasStore::open_for_project(&database, &bound_root)?;
17784 store.project_root_identity()?
17785 };
17786 drop(AtlasStore::open_read_only_for_project(
17787 &database,
17788 &bound_root,
17789 )?);
17790 let database_before = fs::read(&database)?;
17791 let sidecars_before = ["wal", "shm", "journal"]
17792 .map(|suffix| fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok());
17793 let selected_project_dir = selected_root.join(PROJECTATLAS_DIR_NAME);
17794 let config_parent = config_path
17795 .parent()
17796 .ok_or_else(|| io::Error::other("selected config has no parent"))?;
17797 let server = ProjectAtlasMcpServer::new(
17798 database.clone(),
17799 Some(config_path.clone()),
17800 "current-wrong-root".to_string(),
17801 false,
17802 );
17803 *server
17804 .project_state
17805 .write()
17806 .map_err(|_poisoned| io::Error::other("MCP project state lock poisoned"))? =
17807 McpProjectState {
17808 root: selected_root,
17809 db_path: database.clone(),
17810 config_path: Some(config_path.clone()),
17811 worktree: None,
17812 };
17813
17814 let result = server.atlas_init(Parameters(AtlasInitParams {
17815 project_path: None,
17816 worktree: None,
17817 no_scan: Some(true),
17818 force_rescan: Some(false),
17819 text_index_max_bytes: None,
17820 }));
17821 require(
17822 result.contains("does not match selected root"),
17823 &format!("current wrong-root MCP init returned an unexpected result: {result}"),
17824 )?;
17825 require(
17826 !selected_project_dir.exists() && !config_parent.exists() && !config_path.exists(),
17827 "current wrong-root MCP init created selected project or config state",
17828 )?;
17829 require(
17830 fs::read(&database)? == database_before
17831 && ["wal", "shm", "journal"].map(|suffix| {
17832 fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok()
17833 }) == sidecars_before,
17834 "current wrong-root MCP init changed database or sidecar state",
17835 )?;
17836 let reopened = AtlasStore::open_read_only_for_project(&database, &bound_root)?;
17837 require(
17838 reopened.project_root_identity()? == persisted_identity,
17839 "current binding changed after wrong-root MCP init",
17840 )?;
17841 Ok(())
17842 }
17843
17844 #[cfg(windows)]
17845 #[test]
17846 fn mcp_init_rejects_predecessor_wrong_root_before_project_writes()
17847 -> Result<(), Box<dyn std::error::Error>> {
17848 let temp = tempfile::tempdir()?;
17849 let selected_root = temp.path().join("selected-predecessor-root");
17850 let bound_root = temp.path().join("bound-predecessor-root");
17851 let database = temp.path().join("external-predecessor.db");
17852 let config_path = selected_root.join("external-config/config.toml");
17853 fs::create_dir_all(&selected_root)?;
17854 fs::create_dir_all(&bound_root)?;
17855 drop(AtlasStore::open_for_project(&database, &bound_root)?);
17856 {
17857 let connection = rusqlite::Connection::open(&database)?;
17858 drop_native_worktree_identity_schema(&connection)?;
17859 connection.execute_batch(
17860 "DROP TABLE project_root_identity;
17861 DROP TABLE IF EXISTS graph_identity_rejections;
17862 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
17863 )?;
17864 }
17865 read_legacy_project_root_candidate_read_only(&database)?;
17866 let database_before = fs::read(&database)?;
17867 let sidecars_before = ["wal", "shm", "journal"]
17868 .map(|suffix| fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok());
17869 let selected_project_dir = selected_root.join(PROJECTATLAS_DIR_NAME);
17870 let config_parent = config_path
17871 .parent()
17872 .ok_or_else(|| io::Error::other("selected predecessor config has no parent"))?;
17873 let server = ProjectAtlasMcpServer::new(
17874 database.clone(),
17875 Some(config_path.clone()),
17876 "predecessor-wrong-root".to_string(),
17877 false,
17878 );
17879 *server
17880 .project_state
17881 .write()
17882 .map_err(|_poisoned| io::Error::other("MCP project state lock poisoned"))? =
17883 McpProjectState {
17884 root: selected_root,
17885 db_path: database.clone(),
17886 config_path: Some(config_path.clone()),
17887 worktree: None,
17888 };
17889
17890 let result = server.atlas_init(Parameters(AtlasInitParams {
17891 project_path: None,
17892 worktree: None,
17893 no_scan: Some(true),
17894 force_rescan: Some(false),
17895 text_index_max_bytes: None,
17896 }));
17897 require(
17898 result.contains("does not match selected root"),
17899 &format!("predecessor wrong-root MCP init returned an unexpected result: {result}"),
17900 )?;
17901 require(
17902 !selected_project_dir.exists() && !config_parent.exists() && !config_path.exists(),
17903 "predecessor wrong-root MCP init created selected project or config state",
17904 )?;
17905 require(
17906 fs::read(&database)? == database_before
17907 && ["wal", "shm", "journal"].map(|suffix| {
17908 fs::read(crate::runtime::db_sidecar_path(&database, suffix)).ok()
17909 }) == sidecars_before,
17910 "predecessor wrong-root MCP init changed database or sidecar state",
17911 )?;
17912 Ok(())
17913 }
17914
17915 #[cfg(unix)]
17916 #[test]
17917 fn nearest_root_rejects_ambiguous_predecessor_without_mutation()
17918 -> Result<(), Box<dyn std::error::Error>> {
17919 fn sidecar_bytes(database: &Path) -> [Option<Vec<u8>>; 3] {
17920 ["wal", "shm", "journal"].map(|suffix| fs::read(db_sidecar_path(database, suffix)).ok())
17921 }
17922
17923 fn directory_inventory(path: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
17924 let mut names = fs::read_dir(path)?
17925 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
17926 .collect::<Result<Vec<_>, _>>()?;
17927 names.sort();
17928 Ok(names)
17929 }
17930
17931 fn snapshot(
17932 database: &Path,
17933 ) -> Result<(Vec<u8>, [Option<Vec<u8>>; 3], Vec<String>), Box<dyn std::error::Error>>
17934 {
17935 let parent = database
17936 .parent()
17937 .ok_or_else(|| io::Error::other("predecessor database has no parent"))?;
17938 Ok((
17939 fs::read(database)?,
17940 sidecar_bytes(database),
17941 directory_inventory(parent)?,
17942 ))
17943 }
17944
17945 let temp = tempfile::tempdir()?;
17946 let raw_root = temp
17947 .path()
17948 .join(std::ffi::OsString::from_vec(b"nearest-raw-\x80".to_vec()));
17949 let replacement_root = temp.path().join("nearest-raw-�");
17950 let raw_database = raw_root.join(".projectatlas").join("projectatlas.db");
17951 let replacement_database = replacement_root
17952 .join(".projectatlas")
17953 .join("projectatlas.db");
17954 fs::create_dir_all(
17955 raw_database
17956 .parent()
17957 .ok_or_else(|| io::Error::other("raw predecessor database has no parent"))?,
17958 )?;
17959 fs::create_dir_all(
17960 replacement_database.parent().ok_or_else(|| {
17961 io::Error::other("replacement predecessor database has no parent")
17962 })?,
17963 )?;
17964 let store = open_atlas_store_for_project(&raw_database, &raw_root)?;
17965 drop(store);
17966 let predecessor = rusqlite::Connection::open(&raw_database)?;
17967 drop_native_worktree_identity_schema(&predecessor)?;
17968 predecessor.execute_batch(
17969 "DROP TABLE project_root_identity;
17970 DROP TABLE IF EXISTS graph_identity_rejections;
17971 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
17972 )?;
17973 drop(predecessor);
17974 fs::copy(&raw_database, &replacement_database)?;
17975
17976 for database in [&raw_database, &replacement_database] {
17979 require(
17980 matches!(
17981 read_legacy_project_root_candidate_read_only(database),
17982 Err(DbError::ProjectRootIdentityMissing)
17983 ),
17984 "ambiguous predecessor candidate was exposed during warm-up",
17985 )?;
17986 }
17987
17988 for (root, database) in [
17989 (&raw_root, &raw_database),
17990 (&replacement_root, &replacement_database),
17991 ] {
17992 let before = snapshot(database)?;
17993 require(
17994 ProjectAtlasMcpServer::indexed_root_from_candidate(root).is_none()
17995 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(root).is_none(),
17996 "ambiguous predecessor was admitted by nearest routing",
17997 )?;
17998 require(
17999 snapshot(database)? == before,
18000 "ambiguous predecessor detection changed database or sidecar state",
18001 )?;
18002 }
18003
18004 let collision_raw_root = temp
18005 .path()
18006 .join(std::ffi::OsString::from_vec(b"nearest-repo\\name".to_vec()));
18007 let collision_slash_root = temp.path().join("nearest-repo").join("name");
18008 let collision_raw_database = collision_raw_root
18009 .join(PROJECTATLAS_DIR_NAME)
18010 .join(PROJECTATLAS_DB_FILE_NAME);
18011 let collision_slash_database = collision_slash_root
18012 .join(PROJECTATLAS_DIR_NAME)
18013 .join(PROJECTATLAS_DB_FILE_NAME);
18014 fs::create_dir_all(&collision_raw_root)?;
18015 fs::create_dir_all(
18016 collision_slash_database
18017 .parent()
18018 .ok_or_else(|| io::Error::other("slash collision database has no parent"))?,
18019 )?;
18020 drop(open_atlas_store_for_project(
18021 &collision_raw_database,
18022 &collision_raw_root,
18023 )?);
18024 let collision_predecessor = rusqlite::Connection::open(&collision_raw_database)?;
18025 collision_predecessor.execute(
18026 "UPDATE metadata SET value = ?1 WHERE key = 'project_root'",
18027 [collision_slash_root.to_string_lossy().into_owned()],
18028 )?;
18029 drop_native_worktree_identity_schema(&collision_predecessor)?;
18030 collision_predecessor.execute_batch(
18031 "DROP TABLE project_root_identity;
18032 DROP TABLE IF EXISTS graph_identity_rejections;
18033 UPDATE metadata SET value = '19' WHERE key = 'schema_version';",
18034 )?;
18035 drop(collision_predecessor);
18036 fs::copy(&collision_raw_database, &collision_slash_database)?;
18037 for database in [&collision_raw_database, &collision_slash_database] {
18038 require(
18039 matches!(
18040 read_legacy_project_root_candidate_read_only(database),
18041 Err(DbError::ProjectRootIdentityMissing)
18042 ),
18043 "slash-colliding predecessor candidate was exposed during warm-up",
18044 )?;
18045 }
18046 for (root, database) in [
18047 (&collision_raw_root, &collision_raw_database),
18048 (&collision_slash_root, &collision_slash_database),
18049 ] {
18050 let before = snapshot(database)?;
18051 require(
18052 ProjectAtlasMcpServer::indexed_root_from_candidate(root).is_none()
18053 && ProjectAtlasMcpServer::indexed_root_from_lexical_candidate(root).is_none(),
18054 "slash-colliding predecessor was admitted by nearest routing",
18055 )?;
18056 require(
18057 snapshot(database)? == before,
18058 "slash-colliding predecessor detection changed database or sidecar state",
18059 )?;
18060 }
18061 Ok(())
18062 }
18063}