Skip to main content

projectatlas/
main.rs

1//! Purpose: Provide the `ProjectAtlas` 3 command-line adapter.
2
3mod atlas_map;
4#[cfg(feature = "derived-snapshot")]
5mod derived_snapshot_archive;
6mod mcp;
7mod project_state;
8mod runtime;
9mod structural;
10mod token_tui;
11
12use atlas_map::{
13    AtlasMapConfig, IgnoreEntryKind, LintOptions, add_ignore_entry, effective_config_report,
14    init_gitignore, init_project_with_config, list_ignore_entries, load_atlas_config,
15    remove_ignore_entry,
16};
17use clap::parser::ValueSource;
18use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
19#[cfg(feature = "optional-parser-supervisor")]
20use projectatlas_cli::optional_parser_lifecycle::{
21    OptionalParserPackLifecycle, OptionalParserPackLifecycleError,
22};
23use projectatlas_core::graph::{
24    ConfidenceClass, GraphLimits, GraphRelationKind, LogicalRelation, RepositoryFilePath,
25};
26use projectatlas_core::health::Severity;
27use projectatlas_core::language::{ContentClassification, ContentSelection};
28use projectatlas_core::outline::build_outline;
29use projectatlas_core::telemetry::{
30    TokenCalibrationOverview, TokenTrendWindow as CoreTokenTrendWindow, UsageInstanceOwner,
31};
32use projectatlas_core::toon::{
33    encode_agent_payload, encode_error_text, render_outline, render_overview,
34    render_ranked_node_rows, render_ranked_nodes, render_symbol_relations, render_token_overview,
35    render_token_trends,
36};
37use projectatlas_core::{
38    IndexWorkControl, IndexWorkStage, PurposeSource, PurposeStatus, normalize_native_path_display,
39    normalize_repo_path_prefix,
40};
41use projectatlas_db::{
42    AtlasStore, DbError, HealthQuery, HealthResolution, HealthScope, ProjectRootTransition,
43    ProjectRootTransitionResult, PurposeMutationTransaction, RepositoryCoverageQuery,
44    RepositoryGraphDirection, verify_project_database,
45};
46use projectatlas_fs::worktree::{
47    GitManagerSourceSelection, GitRepositorySelection, GitWorktreeRole, GitWorktreeState,
48    RepositoryStructure, discover_repository_structure,
49};
50use projectatlas_service::{
51    COVERAGE_PAGE_MAX_LIMIT, CodeSlice, CodeSliceBudget, CodeSliceDraft, CoverageDiscoveryReport,
52    DetailedRelationBudget, DetailedRelationQuery, EntrypointProfile, FederatedStore,
53    FileSummaryReport, GitImpactSelection, RelationAnalysisMode, RelationAnalysisQuery,
54    RelationAnchor, RelationDirection, RelationResolutionFilter, SearchQuery, SearchReport,
55    SearchRetrievalMode, ServiceError, SymbolSliceSelector, TokenReport, TokenReportRequest,
56    build_file_summary_from_source_with_selection, load_coverage_discovery,
57    load_detailed_relation_page, load_federated_detailed_relations,
58    load_federated_relation_analysis, load_relation_analysis, load_token_report,
59    parse_coverage_parser, parse_coverage_relation, parse_coverage_state, parse_symbol_kind,
60    read_indexed_code_slice_from_source_bounded_with_selection,
61    read_symbol_slice_from_source_bounded_with_selection, search_indexed_files_with_control,
62};
63use rmcp::schemars;
64use runtime::{
65    DEFAULT_HEALTH_LIMIT, InitBootstrapOptions, InitHostConfigStatus, InitSetupReport,
66    MAX_HEALTH_LIMIT, MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES, MAX_SYMBOL_FILE_BYTES, PurposeLintLevel,
67    PurposeReviewRequest, ScanRuntimePlan, SettingsReport, SourceObservationRegistry,
68    SymbolBuildOptions, UsageRuntimeInstance, WatchStatusReport, absolute_path,
69    build_settings_report, byte_count_to_tokens, canonical_project_root,
70    canonical_source_project_root, classified_ranked_file_nodes_with_reasons,
71    config_root_mismatch_error, default_cli_project_root, default_mcp_project_root,
72    defaultable_cli_project_root, estimated_source_tokens_for_indexed_files,
73    estimated_source_tokens_for_paths, index_work_control, init_config_path, init_path_status,
74    lint_project, load_synchronized_repository_token_report, lossless_native_path_display,
75    lossless_project_root_display, next_step_report_payload, next_step_report_with_selection,
76    normalized_folder_filter, open_atlas_store_for_project, open_atlas_store_read_only_for_project,
77    open_federated_atlas_stores_for_project, open_fresh_atlas_store_for_project,
78    preflight_existing_project_binding, purpose_curation_page, ranked_folder_nodes_with_reasons,
79    read_indexed_file_content, record_directory_walk_usage_estimate, record_usage_estimate,
80    record_usage_text, render_classified_ranked_file_rows, render_classified_symbol_rows,
81    render_coverage_report, render_health_page, render_purpose_curation_page,
82    render_purpose_review_report, reset_index_files, resolved_mcp_config_path, review_purposes,
83    run_init_bootstrap, run_scan_pipeline_controlled, run_single_watch_refresh_controlled,
84    run_symbol_build_pipeline_controlled, run_watch_loop, standalone_index_work_control,
85    strip_legacy_purpose, validate_purpose_review_admission, validated_indexed_file_key,
86    watcher_status_report,
87};
88use serde::{Deserialize, Serialize};
89use serde_json::json;
90use std::collections::{BTreeMap, BTreeSet};
91use std::fs;
92use std::io::{self, Read, Write};
93#[cfg(unix)]
94use std::os::fd::AsFd;
95#[cfg(unix)]
96use std::os::unix::fs::MetadataExt;
97use std::path::{Path, PathBuf};
98use std::time::Duration;
99#[cfg(unix)]
100use std::time::Instant;
101use thiserror::Error;
102use token_tui::{
103    TokenAtlasPreview, TokenDashboardTheme, capture_token_dashboard_viewport,
104    render_token_dashboard_with_atlas, render_token_trend_dashboard_with_theme,
105    token_atlas_network_relation, token_dashboard_wants_atlas,
106};
107#[cfg(test)]
108use token_tui::{render_token_dashboard, render_token_dashboard_with_atlas_at_width};
109
110/// Default relative path for the `SQLite` index.
111const DEFAULT_DB_PATH: &str = ".projectatlas/projectatlas.db";
112/// Whole-operation deadline for the optional live token-dashboard graph read.
113const TOKEN_ATLAS_READ_TIMEOUT: Duration = Duration::from_secs(15);
114/// Maximum time the installer waits for another updater.
115#[cfg(unix)]
116const INSTALLER_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
117/// Poll cadence for the standard-library nonblocking file lock.
118#[cfg(unix)]
119const INSTALLER_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(25);
120/// `ProjectAtlas` major architecture version.
121const PROJECTATLAS_MAJOR_VERSION: u8 = 3;
122/// Default caller-visible compatibility label for token telemetry.
123const DEFAULT_CALLER_LABEL: &str = "default";
124/// Default maximum rows returned per structured file-summary section.
125const DEFAULT_FILE_SUMMARY_LIMIT: usize = 25;
126/// CLI top-level field for detailed and analysis relation responses.
127const CLI_PAYLOAD_SYMBOL_RELATIONS: &str = "symbol_relations";
128/// Federated analysis does not support single-root entrypoint profiling.
129const CLI_ERROR_ENTRYPOINT_FEDERATED: &str = "entrypoint profiles require one project root";
130/// One-shot watcher refresh mode.
131const WATCH_MODE_ONCE: &str = "single-refresh";
132/// Event-backed watcher mode.
133const WATCH_MODE_NOTIFY: &str = "notify";
134/// Recovery guidance for a database whose local WAL-safe placement was rejected.
135const DATABASE_FILESYSTEM_RECOVERY: &str = "Place the selected local source tree and its .projectatlas database on a supported local filesystem, resolve any mount or permission uncertainty, and retry; ProjectAtlas will not weaken the WAL durability profile.";
136/// Recovery guidance for a database owned by another schema version.
137const SCHEMA_VERSION_MISMATCH_RECOVERY: &str = "Use a ProjectAtlas runtime that supports this database schema; do not reset, downgrade, or repair the database with this runtime.";
138/// Recovery guidance for an admitted predecessor database.
139const SCHEMA_MIGRATION_REQUIRED_RECOVERY: &str = "Apply the supported database-owned migration without changing the selected database: for CLI, use the `projectatlas init` action from the selected project root while preserving the same global `--db`/`--config` selection; for MCP, call `atlas_init` through the same MCP server/database binding. Do not reset or replace the database.";
140/// Existing CLI command that performs one bounded refresh pass.
141const CLI_REFRESH_COMMAND: &str = "watch";
142/// Existing CLI command that initializes one selected project root.
143const CLI_INIT_COMMAND: &str = "init";
144/// Portable fallback watcher mode.
145const WATCH_MODE_POLLING: &str = "portable-polling";
146/// Default parity profile for repository-intelligence checks.
147pub(crate) const REPOSITORY_INTELLIGENCE_PROFILE: &str = "repository-intelligence";
148/// CLI command families required for the agent-first repository-intelligence surface.
149const REQUIRED_CLI_COMMANDS: &[RequiredCliCommand] = &[
150    RequiredCliCommand::Init,
151    RequiredCliCommand::Map,
152    RequiredCliCommand::Scan,
153    RequiredCliCommand::Overview,
154    RequiredCliCommand::Folders,
155    RequiredCliCommand::Files,
156    RequiredCliCommand::Next,
157    RequiredCliCommand::Outline,
158    RequiredCliCommand::Summary,
159    RequiredCliCommand::Search,
160    RequiredCliCommand::Slice,
161    RequiredCliCommand::Symbols,
162    RequiredCliCommand::Settings,
163    #[cfg(feature = "derived-snapshot")]
164    RequiredCliCommand::Snapshot,
165    #[cfg(feature = "optional-parser-supervisor")]
166    RequiredCliCommand::ParserPack,
167    RequiredCliCommand::Root,
168    RequiredCliCommand::Config,
169    RequiredCliCommand::Ignore,
170    RequiredCliCommand::WatchStatus,
171    RequiredCliCommand::Watch,
172    RequiredCliCommand::HealthCheck,
173    RequiredCliCommand::Health,
174    RequiredCliCommand::Lint,
175    RequiredCliCommand::Token,
176    RequiredCliCommand::Parity,
177    RequiredCliCommand::StripLegacyPurpose,
178    RequiredCliCommand::ResetIndex,
179    RequiredCliCommand::Mcp,
180    RequiredCliCommand::McpConfig,
181    RequiredCliCommand::RuntimeInfo,
182    RequiredCliCommand::Purpose,
183];
184
185/// Error type for CLI boundary failures.
186#[derive(Debug, Error)]
187enum CliError {
188    /// Cooperative index work was canceled or exceeded a declared bound.
189    #[error("{0}")]
190    IndexWork(#[from] projectatlas_core::IndexWorkFailure),
191    /// Database operation failed.
192    #[error("{0}")]
193    Db(#[from] DbError),
194    /// Shared service operation failed.
195    #[error("{0}")]
196    Service(#[from] projectatlas_service::ServiceError),
197    /// Filesystem scanner operation failed.
198    #[error("{0}")]
199    Fs(#[from] projectatlas_fs::FsError),
200    /// File or directory operation failed.
201    #[error("io error for {path:?}: {source}")]
202    Io {
203        /// Path involved in the IO failure.
204        path: PathBuf,
205        /// Source IO error.
206        source: std::io::Error,
207    },
208    /// Output stream write failed.
209    #[error("output write failed: {0}")]
210    Output(#[from] io::Error),
211    /// JSON serialization failed.
212    #[error("json serialization failed: {0}")]
213    Json(#[from] serde_json::Error),
214    /// MCP runtime failed.
215    #[error("mcp server failed: {0}")]
216    Mcp(String),
217    /// Watcher runtime failed.
218    #[error("watcher failed: {0}")]
219    Watcher(String),
220    /// Atlas map operation failed.
221    #[error("{0}")]
222    AtlasMap(#[from] atlas_map::AtlasMapError),
223    /// Optional parser-pack lifecycle operation failed.
224    #[cfg(feature = "optional-parser-supervisor")]
225    #[error("{0}")]
226    ParserPack(#[from] OptionalParserPackLifecycleError),
227    /// Optional parsing failed and the requested process cleanup also failed.
228    #[cfg(feature = "optional-parser-supervisor")]
229    #[error(
230        "optional parser operation failed: {operation}; mandatory cleanup also failed: {cleanup}"
231    )]
232    OptionalParserOperationAndCleanup {
233        /// Original staging or parser failure.
234        operation: Box<Self>,
235        /// Cleanup failure observed before releasing process ownership.
236        cleanup: Box<Self>,
237    },
238    /// Purpose mutation failed and its mandatory rollback also failed.
239    #[error("purpose mutation failed: {operation}; mandatory rollback also failed: {rollback}")]
240    PurposeMutationRollback {
241        /// Original mutation, freshness, or cancellation failure.
242        operation: Box<Self>,
243        /// Storage failure observed while rolling the transaction back.
244        rollback: DbError,
245    },
246    /// User input was invalid.
247    #[error("invalid input: {0}")]
248    InvalidInput(String),
249    /// The selected source root has not been initialized.
250    #[error("{0}")]
251    InitRequired(Box<runtime::IndexInitRequired>),
252    /// A bare/common Git directory was selected instead of checked-out source.
253    #[error("{0}")]
254    WorktreeRequired(Box<runtime::ProjectWorktreeRequired>),
255    /// Current local source differs from the durable index.
256    #[error("{0}")]
257    RefreshRequired(Box<runtime::IndexRefreshRequired>),
258    /// Current local source could not be verified completely.
259    #[error("{0}")]
260    VerificationIncomplete(Box<runtime::IndexVerificationIncomplete>),
261    /// The selected project root does not own the opened index.
262    #[error("{0}")]
263    ProjectMismatch(Box<runtime::IndexProjectMismatch>),
264    /// A durable root transition committed before generated configuration failed.
265    #[error("{message}: {source}")]
266    RootTransitionFollowup {
267        /// Lossless root display when the host can represent the committed native identity.
268        root: Option<String>,
269        /// Durable transition that already completed.
270        transition: RootTransition,
271        /// Recovery guidance that never substitutes a lossy root display.
272        message: String,
273        /// Follow-up configuration failure.
274        #[source]
275        source: Box<CliError>,
276    },
277}
278
279/// Structured CLI error payload for typed agent-recoverable failures.
280#[derive(Serialize)]
281struct CliErrorResponse<'a> {
282    /// Typed error details.
283    error: CliErrorPayload<'a>,
284}
285
286/// Stable CLI error details shared by TOON and JSON output.
287#[derive(Serialize)]
288struct CliErrorPayload<'a> {
289    /// Machine-readable error kind.
290    kind: AgentErrorKind,
291    /// Human-readable recovery guidance.
292    message: String,
293    /// Local-source mismatch details when a refresh is required.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    refresh_required: Option<&'a runtime::IndexRefreshRequired>,
296    /// Exact selected-root initialization handoff.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    init_required: Option<&'a runtime::IndexInitRequired>,
299    /// Bare/common Git root selection diagnostic.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    worktree_required: Option<&'a runtime::ProjectWorktreeRequired>,
302    /// Source/policy diagnostic when verification cannot complete.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    verification_incomplete: Option<&'a runtime::IndexVerificationIncomplete>,
305    /// Project/index identity mismatch details.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    project_mismatch: Option<&'a runtime::IndexProjectMismatch>,
308    /// Content-free database placement details for a rejected `SQLite` profile.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    database_filesystem: Option<DatabaseFilesystemErrorPayload>,
311    /// Optional retrieval capability state and recovery guidance.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    search_capability: Option<SearchCapabilityErrorPayload>,
314    /// Direct CLI recovery selector for a confirmed mismatch.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    next: Option<CliNextCall<'a>>,
317}
318
319/// Stable error kinds shared by CLI and MCP agent payloads.
320#[derive(Clone, Copy, Debug, Serialize)]
321#[serde(rename_all = "snake_case")]
322enum AgentErrorKind {
323    /// General command, input, storage, or service failure.
324    Error,
325    /// A complete legacy relation array reached its work, row or output bound.
326    LegacyRelationLimit,
327    /// The selected project root needs explicit initialization.
328    InitRequired,
329    /// A checked-out source worktree must be selected.
330    WorktreeRequired,
331    /// Current saved local source differs from the durable index.
332    RefreshRequired,
333    /// Current saved local source could not be inspected completely.
334    VerificationIncomplete,
335    /// The selected project root does not own the opened index.
336    ProjectMismatch,
337    /// The database is on a known unsupported network or distributed filesystem.
338    DatabaseFilesystemUnsupported,
339    /// The database's required local filesystem guarantees could not be established.
340    DatabaseFilesystemUncertain,
341    /// The selected database schema is unsupported by this runtime.
342    SchemaVersionMismatch,
343    /// The selected database schema has a supported migration route.
344    SchemaMigrationRequired,
345    /// The host has no accepted optional parser containment adapter.
346    #[cfg(feature = "optional-parser-supervisor")]
347    UnsupportedContainment,
348    /// The requested optional search mode has no ready generation.
349    SearchCapabilityUnavailable,
350    /// A complete map cannot fit in the supported response budget.
351    ResponseLimitExceeded,
352}
353
354/// Content-free database placement details with direct recovery guidance.
355#[derive(Clone, Debug, Serialize)]
356struct DatabaseFilesystemErrorPayload {
357    /// Database path rejected before mutation.
358    path: Option<String>,
359    /// Resolved owning mount when available.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    mount_point: Option<String>,
362    /// Normalized filesystem type when available.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    filesystem_type: Option<String>,
365    /// Bounded reason when placement was uncertain.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    reason: Option<String>,
368    /// Safe recovery action.
369    recovery: &'static str,
370}
371
372/// Typed schema-version mismatch shared by CLI and MCP adapters.
373#[derive(Clone, Debug, Serialize)]
374struct SchemaVersionMismatchPayload {
375    /// Schema version stored by the database owner.
376    found_schema_version: i64,
377    /// Schema version supported by this runtime.
378    supported_schema_version: i64,
379    /// Public `ProjectAtlas` package version executing the request.
380    runtime_version: &'static str,
381    /// Safe recovery action.
382    recovery: &'static str,
383}
384
385/// CLI error details for an incompatible database schema.
386#[derive(Serialize)]
387struct SchemaVersionMismatchErrorPayload {
388    /// Machine-readable error kind.
389    kind: AgentErrorKind,
390    /// Human-readable recovery guidance.
391    message: String,
392    /// Content-free incompatible schema details.
393    schema_version_mismatch: SchemaVersionMismatchPayload,
394}
395
396/// Structured CLI response for an incompatible database schema.
397#[derive(Serialize)]
398struct SchemaVersionMismatchErrorResponse {
399    /// Typed error details.
400    error: SchemaVersionMismatchErrorPayload,
401}
402
403/// Typed supported-schema migration handoff shared by CLI and MCP adapters.
404#[derive(Clone, Debug, Serialize)]
405struct SchemaMigrationRequiredPayload {
406    /// Schema version stored by the database owner.
407    found_schema_version: i64,
408    /// Schema version supported by this runtime.
409    supported_schema_version: i64,
410    /// Remaining ordered database-owned migration steps.
411    migration_steps_remaining: u32,
412    /// Public `ProjectAtlas` package version executing the request.
413    runtime_version: &'static str,
414    /// Safe migration action.
415    recovery: &'static str,
416}
417
418impl SchemaMigrationRequiredPayload {
419    /// Render one content-free explanation for a command that requires the current schema.
420    fn message(&self) -> String {
421        format!(
422            "database schema version {} requires {} supported migration step(s) to version {} before this command can run",
423            self.found_schema_version,
424            self.migration_steps_remaining,
425            self.supported_schema_version
426        )
427    }
428}
429
430/// CLI error details for an admitted predecessor schema.
431#[derive(Serialize)]
432struct SchemaMigrationRequiredErrorPayload {
433    /// Machine-readable error kind.
434    kind: AgentErrorKind,
435    /// Human-readable migration handoff.
436    message: String,
437    /// Content-free supported migration details.
438    schema_migration_required: SchemaMigrationRequiredPayload,
439}
440
441/// Structured CLI response for an admitted predecessor schema.
442#[derive(Serialize)]
443struct SchemaMigrationRequiredErrorResponse {
444    /// Typed error details.
445    error: SchemaMigrationRequiredErrorPayload,
446}
447
448/// Typed optional search-capability failure shared by CLI and MCP adapters.
449#[derive(Clone, Debug, Serialize)]
450struct SearchCapabilityErrorPayload {
451    /// Explicit retrieval mode requested by the caller.
452    requested_mode: SearchRetrievalMode,
453    /// Stable optional-capability lifecycle state.
454    state: &'static str,
455    /// Actionable recovery guidance.
456    recovery: &'static str,
457}
458
459/// Existing CLI command that repairs a confirmed stale index.
460#[derive(Serialize)]
461struct CliNextCall<'a> {
462    /// Command family accepted by the current runtime.
463    command: &'static str,
464    /// Selected project root to refresh.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    project_path: Option<&'a str>,
467    /// Run exactly one refresh cycle.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    once: Option<bool>,
470}
471
472/// CLI output serialization format.
473#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)]
474#[serde(rename_all = "lowercase")]
475enum OutputFormat {
476    /// Token-efficient object notation for agent-facing responses.
477    Toon,
478    /// Pretty JSON for scripts and external machine consumers.
479    Json,
480}
481
482/// Symbol-relation response contract selected by the caller.
483#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
484enum RelationViewArg {
485    /// Return complete small legacy arrays (1 through 200 rows).
486    Legacy,
487    /// Use bounded normalized-graph navigation.
488    Detailed,
489    /// Project one closed architecture, impact, or static-trace analysis.
490    Analysis,
491}
492
493impl From<RelationViewArg> for projectatlas_service::RelationNavigationView {
494    fn from(view: RelationViewArg) -> Self {
495        match view {
496            RelationViewArg::Detailed => Self::Detailed,
497            RelationViewArg::Legacy => Self::Legacy,
498            RelationViewArg::Analysis => Self::Analysis,
499        }
500    }
501}
502
503/// Closed relation analysis selected on the existing symbol-relations route.
504#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
505enum RelationAnalysisModeArg {
506    /// Components, communities, cycles, purpose, complexity, and bottlenecks.
507    Architecture,
508    /// VCS-aware affected nodes and conservative dead-code candidates.
509    Impact,
510    /// One node-simple static relationship path.
511    Trace,
512    /// Bounded reachability from an explicit entrypoint profile.
513    Entrypoint,
514}
515
516impl From<RelationAnalysisModeArg> for RelationAnalysisMode {
517    fn from(value: RelationAnalysisModeArg) -> Self {
518        match value {
519            RelationAnalysisModeArg::Architecture => Self::Architecture,
520            RelationAnalysisModeArg::Impact => Self::Impact,
521            RelationAnalysisModeArg::Trace => Self::Trace,
522            RelationAnalysisModeArg::Entrypoint => Self::Entrypoint,
523        }
524    }
525}
526
527/// Detailed relation traversal direction.
528#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
529enum RelationDirectionArg {
530    /// Follow relations from each source frontier.
531    Outbound,
532    /// Follow relations into each target frontier.
533    Inbound,
534}
535
536impl From<RelationDirectionArg> for RelationDirection {
537    fn from(value: RelationDirectionArg) -> Self {
538        match value {
539            RelationDirectionArg::Outbound => Self::Outbound,
540            RelationDirectionArg::Inbound => Self::Inbound,
541        }
542    }
543}
544
545/// Lowest confidence retained by detailed relation navigation.
546#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
547enum RelationConfidenceArg {
548    /// Parser-proven exact relation.
549    Exact,
550    /// High-confidence inferred relation.
551    High,
552    /// Medium-confidence structural relation.
553    Medium,
554    /// Low-confidence fallback relation.
555    Low,
556}
557
558impl From<RelationConfidenceArg> for ConfidenceClass {
559    fn from(value: RelationConfidenceArg) -> Self {
560        match value {
561            RelationConfidenceArg::Exact => Self::Exact,
562            RelationConfidenceArg::High => Self::High,
563            RelationConfidenceArg::Medium => Self::Medium,
564            RelationConfidenceArg::Low => Self::Low,
565        }
566    }
567}
568
569/// Resolution state retained by detailed relation navigation.
570#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
571enum RelationResolutionArg {
572    /// Retain every resolution state.
573    Any,
574    /// Retain exact local targets.
575    Resolved,
576    /// Retain ambiguous references.
577    Ambiguous,
578    /// Retain unresolved references.
579    Unresolved,
580    /// Retain external targets.
581    External,
582}
583
584impl From<RelationResolutionArg> for RelationResolutionFilter {
585    fn from(value: RelationResolutionArg) -> Self {
586        match value {
587            RelationResolutionArg::Any => Self::Any,
588            RelationResolutionArg::Resolved => Self::Resolved,
589            RelationResolutionArg::Ambiguous => Self::Ambiguous,
590            RelationResolutionArg::Unresolved => Self::Unresolved,
591            RelationResolutionArg::External => Self::External,
592        }
593    }
594}
595
596/// Additive options used only by the detailed relation view.
597#[derive(Args, Debug)]
598struct DetailedRelationArgs {
599    /// Resume one exact generation- and purpose-bound detailed page.
600    #[arg(long)]
601    cursor: Option<String>,
602    /// Complete ordered project-root set for one read-only federated call.
603    #[arg(long = "root", value_name = "PATH")]
604    roots: Vec<PathBuf>,
605    /// Exact local anchor controls.
606    #[command(flatten)]
607    anchor: DetailedRelationAnchorArgs,
608    /// Relation direction and trust filters.
609    #[command(flatten)]
610    filters: DetailedRelationFilterArgs,
611    /// Traversal and output ceilings.
612    #[command(flatten)]
613    limits: DetailedRelationLimitArgs,
614    /// Optional controls used only by the analysis view.
615    #[command(flatten)]
616    analysis: Box<RelationAnalysisArgs>,
617}
618
619/// Closed controls accepted only by `symbols relations --view analysis`.
620#[derive(Args, Debug)]
621struct RelationAnalysisArgs {
622    /// Closed analysis projection computed over the bounded relation traversal.
623    #[arg(long, value_enum)]
624    analysis_mode: Option<RelationAnalysisModeArg>,
625    /// Stable name for an explicit entrypoint profile.
626    #[arg(long)]
627    profile_name: Option<String>,
628    /// JSON `RelationAnchor` value; repeat for multiple entrypoints.
629    #[arg(long = "entrypoint")]
630    entrypoints: Vec<String>,
631    /// Relation family admitted by an entrypoint profile; repeat to add families.
632    #[arg(long = "profile-relation")]
633    profile_relations: Vec<String>,
634    /// Exact JSON `RelationAnchor` (`file` or fully disambiguated `symbol`) for trace mode.
635    #[arg(long)]
636    trace_target: Option<String>,
637    /// Git scope: `working-tree`, `index`, or exact `base..head`; defaults to working-tree.
638    #[arg(long)]
639    vcs: Option<String>,
640    /// Include relationship-derived communities with containment excluded.
641    #[arg(long)]
642    include_communities: bool,
643    /// Include iterative SCC dependency-cycle findings.
644    #[arg(long)]
645    include_cycles: bool,
646    /// Include conservative dead-code candidates in impact mode.
647    #[arg(long)]
648    include_dead_code: bool,
649}
650
651/// Exact local anchor controls for detailed relation navigation.
652#[derive(Args, Debug)]
653struct DetailedRelationAnchorArgs {
654    /// Exact symbol name used as the detailed anchor; omit for a file anchor.
655    #[arg(long)]
656    symbol: Option<String>,
657    /// Optional exact parent used to disambiguate the detailed symbol anchor.
658    #[arg(long)]
659    symbol_parent: Option<String>,
660    /// Optional exact kind used to disambiguate the detailed symbol anchor.
661    #[arg(long)]
662    symbol_kind: Option<String>,
663    /// Optional exact signature used to disambiguate the detailed symbol anchor.
664    #[arg(long)]
665    symbol_signature: Option<String>,
666}
667
668/// Direction and trust filters for detailed relation navigation.
669#[derive(Args, Debug)]
670struct DetailedRelationFilterArgs {
671    /// Optional classified-content selection: source, documentation, or both.
672    #[arg(long, value_name = "source|documentation|both")]
673    content_selection: Option<ContentSelection>,
674    /// Direction followed from every detailed frontier (default outbound).
675    #[arg(long, value_enum)]
676    direction: Option<RelationDirectionArg>,
677    /// Optional exact legacy or extended relation family.
678    #[arg(long)]
679    relation: Option<String>,
680    /// Lowest confidence retained by detailed navigation (default low).
681    #[arg(long, value_enum)]
682    minimum_confidence: Option<RelationConfidenceArg>,
683    /// Resolution state retained by detailed navigation (default any).
684    #[arg(long, value_enum)]
685    resolution: Option<RelationResolutionArg>,
686}
687
688/// Traversal and output ceilings for detailed relation navigation.
689#[derive(Args, Debug)]
690struct DetailedRelationLimitArgs {
691    /// Maximum detailed traversal depth (default 1).
692    #[arg(long)]
693    depth: Option<u32>,
694    /// Retain bounded exact source occurrences in detailed rows.
695    #[arg(long)]
696    include_occurrences: bool,
697    /// Maximum exact occurrences retained per detailed relation (default 25).
698    #[arg(long)]
699    occurrence_limit: Option<u32>,
700    /// Maximum adjacency rows inspected across the complete request.
701    #[arg(long)]
702    edge_limit: Option<u32>,
703    /// Maximum unique traversal nodes retained across the complete request.
704    #[arg(long)]
705    node_limit: Option<u32>,
706    /// Maximum unique visited identities retained across continuation pages.
707    #[arg(long)]
708    visited_limit: Option<u32>,
709    /// Maximum exact occurrences retained across the complete request.
710    #[arg(long)]
711    occurrence_total_limit: Option<u32>,
712    /// Maximum decoded, cursor, and service-composition intermediate bytes.
713    #[arg(long)]
714    intermediate_bytes: Option<u64>,
715    /// Maximum service-owned elapsed milliseconds.
716    #[arg(long)]
717    deadline_ms: Option<u64>,
718    /// Encoded output bytes (detailed/legacy 32768; analysis 262144).
719    #[arg(long)]
720    output_bytes: Option<u32>,
721}
722
723/// Optional exact symbol selector shared by the top-level slice command.
724#[derive(Args, Debug)]
725struct OptionalSymbolSelectorArgs {
726    /// Slice a symbol by name instead of passing line numbers.
727    #[arg(long)]
728    symbol: Option<String>,
729    /// Optional parent symbol for disambiguating `--symbol`.
730    #[arg(long)]
731    symbol_parent: Option<String>,
732    /// Optional symbol kind for disambiguating `--symbol`.
733    #[arg(long)]
734    symbol_kind: Option<String>,
735    /// Optional exact symbol signature for disambiguating `--symbol`.
736    #[arg(long)]
737    symbol_signature: Option<String>,
738    /// Optional source line for disambiguating `--symbol`.
739    #[arg(long)]
740    symbol_line: Option<usize>,
741    /// Maximum encoded bytes admitted to the slice response.
742    #[arg(long, default_value_t = CodeSliceBudget::DEFAULT_OUTPUT_BYTES)]
743    output_bytes: u32,
744}
745
746/// Required exact symbol selector shared by the symbol slice command.
747#[derive(Args, Debug)]
748struct RequiredSymbolSelectorArgs {
749    /// Symbol name to locate.
750    symbol: String,
751    /// Optional parent symbol for disambiguation.
752    #[arg(long)]
753    symbol_parent: Option<String>,
754    /// Optional symbol kind for disambiguation.
755    #[arg(long)]
756    symbol_kind: Option<String>,
757    /// Optional exact symbol signature for disambiguation.
758    #[arg(long)]
759    symbol_signature: Option<String>,
760    /// Optional source line for disambiguation.
761    #[arg(long)]
762    symbol_line: Option<usize>,
763    /// Maximum encoded bytes admitted to the slice response.
764    #[arg(long, default_value_t = CodeSliceBudget::DEFAULT_OUTPUT_BYTES)]
765    output_bytes: u32,
766}
767
768/// Token report presentation mode.
769#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
770enum TokenView {
771    /// Structured agent/script output controlled by the global format flag.
772    Agent,
773    /// Human terminal dashboard with a compact savings diagram.
774    Tui,
775}
776
777/// Token TUI color theme.
778#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
779enum TokenTheme {
780    /// Dark reference dashboard theme.
781    Dark,
782    /// Light dashboard theme for light terminal backgrounds.
783    Light,
784    /// Preserve the terminal background while retaining semantic accents.
785    Terminal,
786}
787
788impl From<TokenTheme> for TokenDashboardTheme {
789    fn from(theme: TokenTheme) -> Self {
790        match theme {
791            TokenTheme::Dark => Self::Dark,
792            TokenTheme::Light => Self::Light,
793            TokenTheme::Terminal => Self::Terminal,
794        }
795    }
796}
797
798/// Token trend grouping window.
799#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
800enum TokenTrendWindow {
801    /// Group token telemetry by day.
802    Day,
803    /// Group token telemetry by week.
804    Week,
805    /// Group token telemetry by month.
806    Month,
807    /// Group token telemetry by year.
808    Year,
809}
810
811impl From<TokenTrendWindow> for CoreTokenTrendWindow {
812    fn from(window: TokenTrendWindow) -> Self {
813        match window {
814            TokenTrendWindow::Day => Self::Day,
815            TokenTrendWindow::Week => Self::Week,
816            TokenTrendWindow::Month => Self::Month,
817            TokenTrendWindow::Year => Self::Year,
818        }
819    }
820}
821
822/// Health severity filter accepted by CLI commands.
823#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
824enum HealthSeverityArg {
825    /// Informational finding.
826    Info,
827    /// Warning finding.
828    Warning,
829    /// Error finding.
830    Error,
831}
832
833impl From<HealthSeverityArg> for Severity {
834    fn from(value: HealthSeverityArg) -> Self {
835        match value {
836            HealthSeverityArg::Info => Self::Info,
837            HealthSeverityArg::Warning => Self::Warning,
838            HealthSeverityArg::Error => Self::Error,
839        }
840    }
841}
842
843/// Purpose curation strictness accepted by `projectatlas lint`.
844#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
845enum PurposeLintLevelArg {
846    /// Require agent review for folders and high-impact files only.
847    Low,
848    /// Also require agent review for source files.
849    Medium,
850    /// Require agent review for every indexed file and folder.
851    Strict,
852}
853
854/// Retrieval family accepted by CLI and MCP search adapters.
855#[derive(
856    Clone,
857    Copy,
858    Debug,
859    Default,
860    Deserialize,
861    Eq,
862    PartialEq,
863    Serialize,
864    ValueEnum,
865    schemars::JsonSchema,
866)]
867#[schemars(inline)]
868#[serde(rename_all = "lowercase")]
869enum SearchRetrievalModeArg {
870    /// Correctness-authoritative lexical search.
871    #[default]
872    Lexical,
873    /// Optional semantic retrieval generation.
874    Semantic,
875    /// Lexical-complete search with optional semantic ranking.
876    Hybrid,
877}
878
879impl From<SearchRetrievalModeArg> for SearchRetrievalMode {
880    fn from(value: SearchRetrievalModeArg) -> Self {
881        match value {
882            SearchRetrievalModeArg::Lexical => Self::Lexical,
883            SearchRetrievalModeArg::Semantic => Self::Semantic,
884            SearchRetrievalModeArg::Hybrid => Self::Hybrid,
885        }
886    }
887}
888
889impl From<PurposeLintLevelArg> for PurposeLintLevel {
890    fn from(value: PurposeLintLevelArg) -> Self {
891        match value {
892            PurposeLintLevelArg::Low => Self::Low,
893            PurposeLintLevelArg::Medium => Self::Medium,
894            PurposeLintLevelArg::Strict => Self::Strict,
895        }
896    }
897}
898
899/// Manual `ProjectAtlas` ignore entry kind for CLI input.
900#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
901enum IgnoreKind {
902    /// Ignore every directory with this name anywhere under the repository.
903    DirName,
904    /// Ignore one repository-relative path subtree.
905    PathPrefix,
906}
907
908/// Explicit durable root transition selected by CLI or MCP callers.
909#[derive(
910    Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum, schemars::JsonSchema,
911)]
912#[schemars(inline)]
913#[serde(rename_all = "snake_case")]
914enum RootTransition {
915    /// Initialize a missing binding or verify an identical existing binding.
916    Bind,
917    /// Preserve identity only after the previously recorded root is proven absent.
918    Move,
919    /// Rotate identity for an independent copy, clone, or worktree.
920    Detach,
921    /// Explicitly adopt the selected native root for an intact schema-19 database.
922    AdoptLegacy,
923}
924
925impl From<RootTransition> for ProjectRootTransition {
926    fn from(value: RootTransition) -> Self {
927        match value {
928            RootTransition::Bind => Self::Bind,
929            RootTransition::Move => Self::Move,
930            RootTransition::Detach => Self::Detach,
931            RootTransition::AdoptLegacy => Self::AdoptLegacy,
932        }
933    }
934}
935
936impl From<ProjectRootTransition> for RootTransition {
937    fn from(value: ProjectRootTransition) -> Self {
938        match value {
939            ProjectRootTransition::Bind => Self::Bind,
940            ProjectRootTransition::Move => Self::Move,
941            ProjectRootTransition::Detach => Self::Detach,
942            ProjectRootTransition::AdoptLegacy => Self::AdoptLegacy,
943        }
944    }
945}
946
947/// MCP host configuration format.
948#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
949#[clap(rename_all = "kebab-case")]
950enum HarnessConfig {
951    /// Standard `.mcp.json` shape with `mcpServers`.
952    McpJson,
953    /// Codex-compatible `.mcp.json` shape with a project-root `cwd` hint.
954    Codex,
955    /// Claude Code plugin/project MCP config shape.
956    ClaudeCode,
957    /// `OpenCode` `opencode.json` MCP config shape.
958    #[value(name = "opencode")]
959    OpenCode,
960}
961
962impl From<IgnoreKind> for IgnoreEntryKind {
963    fn from(value: IgnoreKind) -> Self {
964        match value {
965            IgnoreKind::DirName => Self::DirName,
966            IgnoreKind::PathPrefix => Self::PathPrefix,
967        }
968    }
969}
970
971/// Top-level parsed CLI arguments.
972#[derive(Debug, Parser)]
973#[command(name = "projectatlas")]
974#[command(about = "ProjectAtlas 3 repository intelligence engine")]
975#[command(version)]
976struct Cli {
977    /// Path to the `SQLite` index file.
978    #[arg(long, default_value = DEFAULT_DB_PATH)]
979    db: PathBuf,
980    /// Whether the database path came from the command line rather than the default.
981    #[arg(skip)]
982    database_path_is_explicit: bool,
983    /// Response format to emit.
984    #[arg(long, value_enum, default_value_t = OutputFormat::Toon)]
985    format: OutputFormat,
986    /// Caller-visible compatibility label recorded with token telemetry.
987    #[arg(long, default_value = DEFAULT_CALLER_LABEL)]
988    session: String,
989    /// Path to `ProjectAtlas` config.toml for map/lint/init workflows.
990    #[arg(long)]
991    config: Option<PathBuf>,
992    /// Require this exact runtime version before executing the selected command.
993    #[arg(long)]
994    require_version: Option<String>,
995    /// Subcommand to execute.
996    #[command(subcommand)]
997    command: Box<Command>,
998}
999
1000impl Cli {
1001    /// Rebase the conventional database path to the structurally selected source root.
1002    fn resolve_implicit_database_path(&mut self) -> Result<(), CliError> {
1003        if self.database_path_is_explicit || !command_uses_implicit_database(self.command.as_ref())
1004        {
1005            return Ok(());
1006        }
1007        let root = if let Some(config_path) = self.config.as_deref() {
1008            canonical_source_project_root(&load_atlas_config(Some(config_path))?.root)?
1009        } else {
1010            let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
1011                path: PathBuf::from("."),
1012                source,
1013            })?;
1014            canonical_source_project_root(&current_dir)?
1015        };
1016        self.db = root.join(DEFAULT_DB_PATH);
1017        Ok(())
1018    }
1019
1020    /// Resolve this invocation's selected source root before opening an implicit database.
1021    fn project_root(&self) -> Result<PathBuf, CliError> {
1022        default_cli_project_root(
1023            &self.db,
1024            self.config.as_deref(),
1025            self.database_path_is_explicit,
1026        )
1027    }
1028
1029    /// Resolve an optional command root using this invocation's database selection.
1030    fn project_root_for_path(&self, path: &Path) -> Result<PathBuf, CliError> {
1031        defaultable_cli_project_root(
1032            path,
1033            &self.db,
1034            self.config.as_deref(),
1035            self.database_path_is_explicit,
1036        )
1037    }
1038
1039    /// Validate only the implicit conventional root without changing explicit authority.
1040    fn preflight_implicit_project_root(&self) -> Result<(), CliError> {
1041        if !self.database_path_is_explicit && self.config.is_none() {
1042            drop(self.project_root()?);
1043        }
1044        Ok(())
1045    }
1046}
1047
1048/// Supported `ProjectAtlas` CLI commands.
1049#[derive(Debug, Subcommand)]
1050enum Command {
1051    /// Initialize `ProjectAtlas` files in a repository.
1052    Init {
1053        /// Create/verify the project surface without running the scan/index pipeline.
1054        #[arg(long)]
1055        no_scan: bool,
1056        /// Run the scan/index phase even when a future freshness check could skip it.
1057        #[arg(long)]
1058        force_rescan: bool,
1059        /// Maximum UTF-8 file size persisted into `SQLite` text search during the init scan.
1060        #[arg(long)]
1061        text_index_max_bytes: Option<u64>,
1062    },
1063    /// Return the current `ProjectAtlas` map without writing a TOON file.
1064    Map {
1065        /// Also write an adjacent JSON sidecar independently of stdout format.
1066        #[arg(long)]
1067        json: bool,
1068        /// Write the requested JSON sidecar even in CI.
1069        #[arg(long)]
1070        force: bool,
1071    },
1072    /// Scan a repository and replace the durable index.
1073    Scan {
1074        /// Repository root to scan.
1075        #[arg(default_value = ".")]
1076        path: PathBuf,
1077        /// Maximum UTF-8 file size persisted into `SQLite` text search.
1078        #[arg(long)]
1079        text_index_max_bytes: Option<u64>,
1080    },
1081    /// Print a repository overview.
1082    Overview,
1083    /// Rank folders before inspecting files.
1084    Folders {
1085        /// Search query for path and purpose matching.
1086        query: String,
1087        /// Maximum number of folders to return.
1088        #[arg(long, default_value_t = 10)]
1089        limit: usize,
1090    },
1091    /// Rank files, optionally inside an already-selected folder.
1092    Files {
1093        /// Search query for path and purpose matching.
1094        query: Option<String>,
1095        /// Folder path to constrain the search.
1096        #[arg(long)]
1097        folder: Option<String>,
1098        /// Optional repository-relative glob filter.
1099        #[arg(long)]
1100        file_pattern: Option<String>,
1101        /// Include indexed file text as a bounded fallback ranking signal.
1102        #[arg(long, default_value_t = false)]
1103        include_content: bool,
1104        /// Optional classified-content selection: source, documentation, or both.
1105        #[arg(long, value_name = "source|documentation|both")]
1106        content_selection: Option<ContentSelection>,
1107        /// Maximum number of files to return.
1108        #[arg(long, default_value_t = 10)]
1109        limit: usize,
1110    },
1111    /// Recommend the next indexed folders, files, and inspection commands.
1112    Next {
1113        /// Task or navigation query.
1114        query: String,
1115        /// Maximum number of folders and files to return.
1116        #[arg(long, default_value_t = 3)]
1117        limit: usize,
1118        /// Optional classified-content selection: source, documentation, or both.
1119        #[arg(long, value_name = "source|documentation|both")]
1120        content_selection: Option<ContentSelection>,
1121    },
1122    /// Build a compact outline for a chosen file.
1123    Outline {
1124        /// File path to outline.
1125        file: PathBuf,
1126        /// Number of non-empty preview lines to include.
1127        #[arg(long, default_value_t = 12)]
1128        lines: usize,
1129    },
1130    /// Return structured deterministic file intelligence from the deep index.
1131    Summary {
1132        /// Repository-relative file path to summarize.
1133        file: PathBuf,
1134        /// Maximum rows per functions/methods/classes/types/calls section.
1135        #[arg(long, default_value_t = DEFAULT_FILE_SUMMARY_LIMIT)]
1136        limit: usize,
1137        /// Optional classified-content selection: source, documentation, or both.
1138        #[arg(long, value_name = "source|documentation|both")]
1139        content_selection: Option<ContentSelection>,
1140    },
1141    /// Search indexed files with literal, regex, or fuzzy matching.
1142    Search {
1143        /// Literal, regex, or fuzzy pattern to search for.
1144        pattern: String,
1145        /// Retrieval family; lexical remains the default and always-available mode.
1146        #[arg(long, value_enum, default_value_t)]
1147        retrieval_mode: SearchRetrievalModeArg,
1148        /// Treat the pattern as a regex.
1149        #[arg(long, conflicts_with = "fuzzy")]
1150        regex: bool,
1151        /// Treat the pattern as a fuzzy subsequence.
1152        #[arg(long, conflicts_with = "regex")]
1153        fuzzy: bool,
1154        /// Match case-sensitively.
1155        #[arg(long)]
1156        case_sensitive: bool,
1157        /// Optional repository-relative glob filter.
1158        #[arg(long)]
1159        file_pattern: Option<String>,
1160        /// Number of context lines before and after a match.
1161        #[arg(long, default_value_t = 0)]
1162        context_lines: usize,
1163        /// Pagination start index.
1164        #[arg(long, default_value_t = 0)]
1165        start_index: usize,
1166        /// Maximum matches to return.
1167        #[arg(long, default_value_t = 20)]
1168        limit: usize,
1169        /// Optional classified-content selection: source, documentation, or both.
1170        #[arg(long, value_name = "source|documentation|both")]
1171        content_selection: Option<ContentSelection>,
1172    },
1173    /// Return an exact source line slice after a file has been selected.
1174    Slice {
1175        /// File path to slice.
1176        file: PathBuf,
1177        /// One-based start line.
1178        #[arg(long)]
1179        start_line: Option<usize>,
1180        /// Optional one-based end line.
1181        #[arg(long)]
1182        end_line: Option<usize>,
1183        /// Optional classified-content selection: source, documentation, or both.
1184        #[arg(long, value_name = "source|documentation|both")]
1185        content_selection: Option<ContentSelection>,
1186        /// Optional exact declaration selector.
1187        #[command(flatten)]
1188        selector: OptionalSymbolSelectorArgs,
1189    },
1190    /// Inspect and rebuild the `ProjectAtlas` symbol graph.
1191    Symbols {
1192        /// Symbol graph subcommand to run.
1193        #[command(subcommand)]
1194        command: Box<SymbolsCommand>,
1195    },
1196    /// Print local `ProjectAtlas` settings and cache/index locations.
1197    Settings,
1198    /// Export or import a portable derived-only graph snapshot.
1199    #[cfg(feature = "derived-snapshot")]
1200    Snapshot {
1201        /// Snapshot operation.
1202        #[arg(value_enum)]
1203        action: SnapshotAction,
1204        /// Destination archive for export or source archive for import.
1205        path: PathBuf,
1206        /// Require this exact lowercase BLAKE3 digest during import.
1207        #[arg(long)]
1208        require_digest: Option<String>,
1209        /// Optional raw 32-byte Ed25519 secret key encoded as 64 hexadecimal characters.
1210        #[cfg(feature = "derived-snapshot-signatures")]
1211        #[arg(long)]
1212        signing_key: Option<PathBuf>,
1213        /// Require an import signature from this raw 32-byte Ed25519 public key.
1214        #[cfg(feature = "derived-snapshot-signatures")]
1215        #[arg(long)]
1216        trusted_public_key: Option<PathBuf>,
1217    },
1218    /// Manage the separately shipped optional parser pack.
1219    #[cfg(feature = "optional-parser-supervisor")]
1220    ParserPack {
1221        /// Override the user-owned pack storage root for isolated verification and tests.
1222        #[arg(long, hide = true)]
1223        storage_root: Option<PathBuf>,
1224        /// Explicit lifecycle operation.
1225        #[command(subcommand)]
1226        command: ParserPackCommand,
1227    },
1228    /// Show, verify, or bind the project-local root.
1229    Root {
1230        /// Root subcommand to run.
1231        #[command(subcommand)]
1232        command: Option<RootCommand>,
1233    },
1234    /// Print the effective `ProjectAtlas` configuration.
1235    Config {
1236        /// Print the normalized configuration used by scan, map, lint, and watch.
1237        #[arg(long)]
1238        print: bool,
1239    },
1240    /// Manage the manual `ProjectAtlas` ignore layer in config.
1241    Ignore {
1242        /// Ignore subcommand to run.
1243        #[command(subcommand)]
1244        command: IgnoreCommand,
1245    },
1246    /// Print watcher availability and current status.
1247    WatchStatus,
1248    /// Watch a repository and refresh the index when files change.
1249    Watch {
1250        /// Repository root to watch.
1251        #[arg(default_value = ".")]
1252        path: PathBuf,
1253        /// Run one refresh pass and exit.
1254        #[arg(long)]
1255        once: bool,
1256        /// Debounce interval in seconds for event mode and poll interval for fallback mode.
1257        #[arg(long, default_value_t = 2)]
1258        poll_seconds: u64,
1259        /// Maximum refresh cycles before exiting. Zero means no limit.
1260        #[arg(long, default_value_t = 0)]
1261        max_cycles: usize,
1262        /// Maximum parser worker threads during refresh.
1263        #[arg(long)]
1264        max_workers: Option<usize>,
1265        /// Cooperative indexing-work budget per refresh in seconds (30-minute default and ceiling).
1266        /// Larger representable budgets are clamped; unrepresentable deadlines fail closed.
1267        /// Covers root/config/store setup after work-control creation, traversal, source hashing,
1268        /// parsing and publication checkpoints. Earlier startup/target selection is outside this budget.
1269        #[arg(long)]
1270        timeout_seconds: Option<u64>,
1271        /// Maximum UTF-8 file size persisted into `SQLite` text search.
1272        #[arg(long)]
1273        text_index_max_bytes: Option<u64>,
1274    },
1275    /// Report structural health findings (the `health-check` compatibility command).
1276    HealthCheck {
1277        /// Read-only health report filters.
1278        #[command(flatten)]
1279        report: HealthReportArgs,
1280    },
1281    /// Report health findings or resolve one with agent rationale.
1282    #[command(args_conflicts_with_subcommands = true)]
1283    Health {
1284        /// Read-only health report filters when no administrative subcommand is selected.
1285        #[command(flatten)]
1286        report: HealthReportArgs,
1287        /// Health subcommand to run.
1288        #[command(subcommand)]
1289        command: Option<HealthCommand>,
1290    },
1291    /// Validate database purpose metadata, untracked files, and structure drift.
1292    Lint {
1293        /// Deprecated compatibility flag; database folder purpose linting uses `--purpose-level`.
1294        #[arg(long)]
1295        strict_folders: bool,
1296        /// Purpose curation strictness for `SQLite` health linting.
1297        #[arg(long, value_enum, default_value_t = PurposeLintLevelArg::Low)]
1298        purpose_level: PurposeLintLevelArg,
1299        /// Report non-source files not covered by source scanning.
1300        #[arg(long)]
1301        report_untracked: bool,
1302        /// Fail when disallowed untracked files exist.
1303        #[arg(long)]
1304        strict_untracked: bool,
1305    },
1306    /// Print estimated token savings for recorded funnel usage.
1307    Token {
1308        /// Optional caller-visible compatibility-label filter.
1309        #[arg(long)]
1310        session: Option<String>,
1311        /// Presentation mode for the token report.
1312        #[arg(long, value_enum, default_value_t = TokenView::Agent)]
1313        view: TokenView,
1314        /// Optional trend grouping window.
1315        #[arg(long, value_enum)]
1316        trend: Option<TokenTrendWindow>,
1317        /// Optional local tokenizer calibration for indexed UTF-8 files.
1318        #[arg(long, value_parser = ["o200k_base", "cl100k_base"])]
1319        tokenizer: Option<String>,
1320        /// Optional repository-relative agent-navigation benchmark result.
1321        #[arg(long, value_name = "PATH")]
1322        benchmark_results: Option<PathBuf>,
1323        /// Color theme for the human terminal dashboard.
1324        #[arg(long, value_enum, default_value_t = TokenTheme::Dark)]
1325        theme: TokenTheme,
1326    },
1327    /// Check repository-intelligence parity readiness.
1328    Parity {
1329        /// Parity subcommand to run.
1330        #[command(subcommand)]
1331        command: Option<ParityCommand>,
1332        /// Parity profile to evaluate when omitting the `report` subcommand.
1333        #[arg(long, default_value = REPOSITORY_INTELLIGENCE_PROFILE)]
1334        profile: String,
1335    },
1336    /// Dry-run or apply cleanup of legacy `.purpose` metadata files.
1337    StripLegacyPurpose {
1338        /// Repository root to inspect.
1339        #[arg(default_value = ".")]
1340        path: PathBuf,
1341        /// Remove legacy `.purpose` files.
1342        #[arg(long, conflicts_with = "dry_run")]
1343        apply: bool,
1344        /// Preview cleanup without modifying files.
1345        #[arg(long)]
1346        dry_run: bool,
1347        /// Also report conservative source Purpose header candidates.
1348        #[arg(long)]
1349        strip_source_headers: bool,
1350    },
1351    /// Preview or clear local runtime index/cache files.
1352    ResetIndex {
1353        /// Remove runtime index/cache files. Without this flag the command previews only.
1354        #[arg(long, conflicts_with = "dry_run")]
1355        apply: bool,
1356        /// Preview cleanup without modifying files.
1357        #[arg(long)]
1358        dry_run: bool,
1359        /// Also remove generated project-local MCP config.
1360        #[arg(long)]
1361        include_mcp_config: bool,
1362    },
1363    /// Run the native `ProjectAtlas` MCP server over stdio.
1364    Mcp {
1365        /// Allow absolute path MCP calls to route to the nearest already-indexed `ProjectAtlas` root.
1366        #[arg(long)]
1367        nearest_project: bool,
1368    },
1369    /// Print a project-local MCP configuration with absolute runtime paths.
1370    McpConfig {
1371        /// MCP server name to emit.
1372        #[arg(long, default_value = "projectatlas")]
1373        server_name: String,
1374        /// Harness-specific config shape to emit.
1375        #[arg(long, value_enum, default_value_t = HarnessConfig::McpJson)]
1376        harness: HarnessConfig,
1377        /// Include `mcp --nearest-project` in the generated server startup args.
1378        #[arg(long)]
1379        nearest_project: bool,
1380    },
1381    /// Print structured runtime identity and capability information.
1382    RuntimeInfo,
1383    /// Acquire the POSIX installer's update-lock descriptor inherited through stdin.
1384    #[cfg(unix)]
1385    #[command(hide = true)]
1386    AcquireInstallerLock {
1387        /// Device identity captured from the trusted lock path before it was opened.
1388        expected_device: u64,
1389        /// Inode identity captured from the trusted lock path before it was opened.
1390        expected_inode: u64,
1391        /// Optional bounded wait supplied by the installer lock-set deadline.
1392        timeout_milliseconds: Option<u64>,
1393        /// Print the monotonic elapsed wait after successful acquisition.
1394        #[arg(long)]
1395        report_elapsed: bool,
1396    },
1397    /// Manage purpose metadata stored in the durable index.
1398    Purpose {
1399        /// Purpose subcommand to run.
1400        #[command(subcommand)]
1401        command: PurposeCommand,
1402    },
1403}
1404
1405/// Portable derived graph snapshot operation.
1406#[cfg(feature = "derived-snapshot")]
1407#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
1408#[clap(rename_all = "kebab-case")]
1409enum SnapshotAction {
1410    /// Export a fresh bounded tar.zst artifact without overwriting an existing file.
1411    Export,
1412    /// Validate and atomically import one portable derived graph archive.
1413    Import,
1414}
1415
1416/// Explicit optional parser-pack lifecycle commands.
1417#[cfg(feature = "optional-parser-supervisor")]
1418#[derive(Debug, Subcommand)]
1419enum ParserPackCommand {
1420    /// Validate a local completed archive without installing it.
1421    Verify {
1422        /// Completed platform archive to validate.
1423        #[arg(long)]
1424        archive: PathBuf,
1425    },
1426    /// Install a local completed archive without enabling it.
1427    Install {
1428        /// Completed platform archive to install.
1429        #[arg(long)]
1430        archive: PathBuf,
1431    },
1432    /// Enable one explicitly named installed artifact for this project.
1433    ///
1434    /// Selecting the artifact reported by `status.rollback` performs an explicit rollback.
1435    Enable {
1436        /// BLAKE3 identity of the installed artifact manifest.
1437        #[arg(long)]
1438        artifact: String,
1439    },
1440    /// Install and atomically select a replacement while retaining rollback identity.
1441    Update {
1442        /// Completed replacement platform archive.
1443        #[arg(long)]
1444        archive: PathBuf,
1445    },
1446    /// Disable the optional pack for this project without deleting installed slots.
1447    Disable,
1448    /// Disable this project and remove this logical pack's user-owned slots.
1449    Remove,
1450    /// Print bounded content-free lifecycle state.
1451    Status,
1452}
1453
1454/// Project root diagnostics and binding subcommands.
1455#[derive(Debug, Subcommand)]
1456enum RootCommand {
1457    /// Bind a repository root and regenerate project-local MCP configs.
1458    Set {
1459        /// Repository root to bind.
1460        path: PathBuf,
1461        /// Explicit binding behavior for an existing database.
1462        #[arg(long, value_enum, default_value_t = RootTransition::Bind)]
1463        transition: RootTransition,
1464        /// Include `mcp --nearest-project` in generated project-local MCP configs.
1465        #[arg(long)]
1466        nearest_project: bool,
1467    },
1468    /// Show the root, DB, config, and runtime identity `ProjectAtlas` will use.
1469    Show,
1470    /// Show bounded structural Git worktree routing state without opening an atlas.
1471    Status {
1472        /// Checkout, descendant, or Git common directory to inspect.
1473        #[arg(default_value = ".")]
1474        path: PathBuf,
1475    },
1476    /// Verify DB/config/root identity agree.
1477    Verify {
1478        /// Check schema and root binding without scanning database integrity.
1479        #[arg(long, requires = "project_root")]
1480        binding_only: bool,
1481        /// Exact selected root for a binding-only check.
1482        #[arg(long, requires = "binding_only")]
1483        project_root: Option<PathBuf>,
1484    },
1485}
1486
1487/// Manual ignore management subcommands.
1488#[derive(Debug, Subcommand)]
1489enum IgnoreCommand {
1490    /// List effective `ProjectAtlas` manual ignore policy.
1491    List,
1492    /// Create a project-root `.gitignore` when it is missing.
1493    InitGitignore,
1494    /// Add one manual ignore entry to `.projectatlas/config.toml`.
1495    Add {
1496        /// Ignore kind to add.
1497        #[arg(long, value_enum)]
1498        kind: IgnoreKind,
1499        /// Directory name or repository-relative path prefix.
1500        value: String,
1501    },
1502    /// Remove one manual ignore entry from `.projectatlas/config.toml`.
1503    Remove {
1504        /// Ignore kind to remove. Omit to remove from both manual ignore lists.
1505        #[arg(long, value_enum)]
1506        kind: Option<IgnoreKind>,
1507        /// Directory name or repository-relative path prefix.
1508        value: String,
1509    },
1510}
1511
1512/// Purpose metadata subcommands.
1513#[derive(Debug, Subcommand)]
1514enum PurposeCommand {
1515    /// Set an agent-approved purpose for an indexed path.
1516    Set {
1517        /// Indexed repository-relative path.
1518        path: String,
1519        /// Agent-approved purpose one-liner.
1520        purpose: String,
1521    },
1522    /// Batch review existing purpose records from a JSON file.
1523    Review {
1524        /// JSON file containing review items or an object with an `items` array.
1525        #[arg(long)]
1526        from_file: PathBuf,
1527        /// Apply reviewed purposes. Without this flag the command previews only.
1528        #[arg(long)]
1529        apply: bool,
1530    },
1531    /// Return a bounded queue of paths that need purpose curation.
1532    Queue {
1533        /// Host-owned task label for deterministic curator work identity.
1534        #[arg(long)]
1535        task: Option<String>,
1536        /// Pagination start index after filters are applied.
1537        #[arg(long, default_value_t = 0)]
1538        start_index: usize,
1539        /// Maximum findings to return.
1540        #[arg(long, default_value_t = DEFAULT_HEALTH_LIMIT)]
1541        limit: usize,
1542        /// Optional finding category filter.
1543        #[arg(long)]
1544        category: Option<String>,
1545        /// Optional severity filter.
1546        #[arg(long, value_enum)]
1547        severity: Option<HealthSeverityArg>,
1548        /// Optional repository-relative primary or related path prefix.
1549        #[arg(long)]
1550        path_prefix: Option<String>,
1551        /// Return counts and paging metadata without queue rows.
1552        #[arg(long)]
1553        summary_only: bool,
1554        /// Include non-source files and asset-only folders in the queue.
1555        #[arg(long)]
1556        include_assets: bool,
1557        /// Include low-priority files instead of the default folder-first queue.
1558        #[arg(long)]
1559        include_low_priority_files: bool,
1560    },
1561}
1562
1563/// Parity gate subcommands.
1564#[derive(Debug, Subcommand)]
1565enum ParityCommand {
1566    /// Report whether the current index satisfies a parity profile.
1567    Report {
1568        /// Parity profile to evaluate.
1569        #[arg(long, default_value = REPOSITORY_INTELLIGENCE_PROFILE)]
1570        profile: String,
1571    },
1572}
1573
1574/// Health metadata subcommands.
1575#[derive(Debug, Subcommand)]
1576enum HealthCommand {
1577    /// Mark a deterministic finding as resolved for this project.
1578    Resolve {
1579        /// Stable finding id from `projectatlas health-check`.
1580        finding_id: String,
1581        /// Finding category.
1582        category: String,
1583        /// Primary path.
1584        path: String,
1585        /// Optional related path.
1586        #[arg(long)]
1587        related_path: Option<String>,
1588        /// Agent rationale for resolving the finding.
1589        #[arg(long)]
1590        rationale: String,
1591    },
1592}
1593
1594/// Read-only health report filters shared by `health` and `health-check`.
1595#[derive(Debug, Args)]
1596struct HealthReportArgs {
1597    /// Pagination start index after filters are applied.
1598    #[arg(long, default_value_t = 0)]
1599    start_index: usize,
1600    /// Maximum findings to return.
1601    #[arg(long, default_value_t = DEFAULT_HEALTH_LIMIT)]
1602    limit: usize,
1603    /// Optional finding category filter.
1604    #[arg(long)]
1605    category: Option<String>,
1606    /// Optional severity filter.
1607    #[arg(long, value_enum)]
1608    severity: Option<HealthSeverityArg>,
1609    /// Optional repository-relative primary or related path prefix.
1610    #[arg(long)]
1611    path_prefix: Option<String>,
1612    /// Return counts and paging metadata without finding rows.
1613    #[arg(long)]
1614    summary_only: bool,
1615    /// Restrict findings to source files and folders that contain source files.
1616    #[arg(long)]
1617    source_only: bool,
1618    /// Opt in to bounded current coverage discovery instead of structural findings.
1619    #[arg(long)]
1620    coverage: bool,
1621    /// Optional source parser coverage filter.
1622    #[arg(long, requires = "coverage")]
1623    parser: Option<String>,
1624    /// Optional derived-fact provider coverage filter.
1625    #[arg(long, requires = "coverage")]
1626    provider: Option<String>,
1627    /// Optional relationship-family coverage filter.
1628    #[arg(long, requires = "coverage")]
1629    relation: Option<String>,
1630    /// Optional complete, `no_candidates`, partial, failed, ignored, oversized, quarantined, or stale filter.
1631    #[arg(long, requires = "coverage")]
1632    coverage_state: Option<String>,
1633    /// Optional exact coverage reason filter.
1634    #[arg(long, requires = "coverage")]
1635    reason: Option<String>,
1636}
1637
1638/// Symbol graph subcommands.
1639#[derive(Debug, Subcommand)]
1640enum SymbolsCommand {
1641    /// Rebuild symbols for indexed files.
1642    Build {
1643        /// Repository root used to read indexed files.
1644        #[arg(default_value = ".")]
1645        path: PathBuf,
1646        /// Maximum file size parsed for symbols.
1647        #[arg(long, default_value_t = MAX_SYMBOL_FILE_BYTES)]
1648        max_bytes: u64,
1649        /// Maximum parser worker threads. Defaults to Rayon automatic sizing.
1650        #[arg(long)]
1651        max_workers: Option<usize>,
1652        /// Cooperative indexing-work budget in seconds (30-minute default and ceiling).
1653        /// Larger representable budgets are clamped; unrepresentable deadlines fail closed.
1654        /// Covers root/config/store setup after work-control creation, traversal, source hashing,
1655        /// parsing and publication checkpoints. Earlier startup/target selection is outside this budget.
1656        #[arg(long)]
1657        timeout_seconds: Option<u64>,
1658    },
1659    /// List symbols by optional file and query.
1660    List {
1661        /// Optional repository-relative file path.
1662        #[arg(long)]
1663        file: Option<String>,
1664        /// Optional symbol or signature query.
1665        #[arg(long)]
1666        query: Option<String>,
1667        /// Optional classified-content selection: source, documentation, or both.
1668        #[arg(long, value_name = "source|documentation|both")]
1669        content_selection: Option<ContentSelection>,
1670        /// Maximum symbols to return.
1671        #[arg(long, default_value_t = 50)]
1672        limit: usize,
1673    },
1674    /// Navigate anchored detailed pages, explicit legacy arrays or opt-in analysis.
1675    Relations {
1676        /// Anchored detailed pages by default; legacy is an explicit complete small array.
1677        #[arg(long, value_enum, default_value_t = RelationViewArg::Detailed)]
1678        view: RelationViewArg,
1679        /// Optional repository-relative file path.
1680        #[arg(long)]
1681        file: Option<String>,
1682        /// Optional source, target, or context query.
1683        #[arg(long)]
1684        query: Option<String>,
1685        /// Additive normalized-graph traversal controls.
1686        #[command(flatten)]
1687        detailed: Box<DetailedRelationArgs>,
1688        /// Row limit (detailed 10; explicit legacy or analysis 50).
1689        #[arg(long)]
1690        limit: Option<usize>,
1691    },
1692    /// Return an exact source slice for a named symbol.
1693    Slice {
1694        /// Repository-relative file path.
1695        file: PathBuf,
1696        /// Optional classified-content selection: source, documentation, or both.
1697        #[arg(long, value_name = "source|documentation|both")]
1698        content_selection: Option<ContentSelection>,
1699        /// Exact declaration selector.
1700        #[command(flatten)]
1701        selector: RequiredSymbolSelectorArgs,
1702    },
1703}
1704
1705/// Parse arguments, execute the command, and convert failures to process exit.
1706fn main() {
1707    let mut cli = parse_cli();
1708    if let Err(error) = run(&mut cli) {
1709        let rendered =
1710            render_cli_error(cli.format, &error).unwrap_or_else(|_| format!("error: {error}\n"));
1711        if write_stderr(&rendered).is_err() {
1712            std::process::exit(1);
1713        }
1714        std::process::exit(1);
1715    }
1716}
1717
1718/// Parse CLI arguments while retaining whether `--db` was explicitly selected.
1719fn parse_cli() -> Cli {
1720    let matches = Cli::command().get_matches();
1721    let database_path_is_explicit = matches.value_source("db") == Some(ValueSource::CommandLine);
1722    let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
1723    cli.database_path_is_explicit = database_path_is_explicit;
1724    cli
1725}
1726
1727/// Load map and lint config with an explicit CLI database override when present.
1728fn load_cli_atlas_config(cli: &Cli) -> Result<AtlasMapConfig, CliError> {
1729    let config = load_atlas_config(cli.config.as_deref())?;
1730    if cli.database_path_is_explicit {
1731        return Ok(config.with_database_path(&cli.db));
1732    }
1733    Ok(config)
1734}
1735
1736/// Execute the read-only health report shared by `health` and `health-check`.
1737fn run_health_report(
1738    cli: &Cli,
1739    report: &HealthReportArgs,
1740    usage_instance: Option<UsageRuntimeInstance>,
1741    command_name: &str,
1742) -> Result<(), CliError> {
1743    let store = open_index_for_read(cli)?;
1744    if report.coverage {
1745        let query = coverage_query_from_cli(
1746            report.start_index,
1747            report.limit,
1748            &CoverageCliFilters {
1749                path_prefix: report.path_prefix.as_deref(),
1750                parser: report.parser.as_deref(),
1751                provider: report.provider.as_deref(),
1752                relation: report.relation.as_deref(),
1753                state: report.coverage_state.as_deref(),
1754                reason: report.reason.as_deref(),
1755            },
1756        )?;
1757        let mut coverage_report = load_coverage_discovery(&store, query)?;
1758        let toon = finalize_coverage_output(cli.format, &mut coverage_report)?;
1759        print_tracked_directory_output_estimate(
1760            cli.format,
1761            &store,
1762            usage_instance,
1763            &cli.session,
1764            command_name,
1765            None,
1766            None,
1767            || estimated_source_tokens_for_indexed_files(&store, None, None),
1768            &toon,
1769            &coverage_report,
1770        )?;
1771        return Ok(());
1772    }
1773
1774    let query = health_query_from_cli(
1775        report.start_index,
1776        report.limit,
1777        report.category.as_deref(),
1778        report.severity,
1779        report.path_prefix.as_deref(),
1780        report.summary_only,
1781        if report.source_only {
1782            HealthScope::source_only()
1783        } else {
1784            HealthScope::all()
1785        },
1786    );
1787    let page = store.unresolved_health_findings_page_current(&query)?;
1788    let toon = render_health_page(&page, &query);
1789    print_tracked_directory_output_estimate(
1790        cli.format,
1791        &store,
1792        usage_instance,
1793        &cli.session,
1794        command_name,
1795        None,
1796        None,
1797        || estimated_source_tokens_for_indexed_files(&store, None, None),
1798        &toon,
1799        &page,
1800    )?;
1801    Ok(())
1802}
1803
1804/// Execute the selected CLI command.
1805fn run(cli: &mut Cli) -> Result<(), CliError> {
1806    if let Some(required_version) = cli.require_version.as_deref() {
1807        validate_required_runtime_version(required_version)?;
1808    }
1809    cli.resolve_implicit_database_path()?;
1810    let usage_instance = UsageRuntimeInstance::new(UsageInstanceOwner::CliInvocation);
1811    match cli.command.as_ref() {
1812        Command::Init {
1813            no_scan,
1814            force_rescan,
1815            text_index_max_bytes,
1816        } => {
1817            let current_dir = std::env::current_dir().map_err(|source| CliError::Io {
1818                path: PathBuf::from("."),
1819                source,
1820            })?;
1821            let root = canonical_source_project_root(&current_dir)?;
1822            let db_path = if cli.db.is_absolute() {
1823                cli.db.clone()
1824            } else {
1825                root.join(&cli.db)
1826            };
1827            let config_path = init_config_path(&root, cli.config.as_deref());
1828            let mut report = run_init_bootstrap(
1829                &root,
1830                &db_path,
1831                Some(&config_path),
1832                &InitBootstrapOptions {
1833                    no_scan: *no_scan,
1834                    force_rescan: *force_rescan,
1835                    text_index_max_bytes: *text_index_max_bytes,
1836                },
1837            )?;
1838            write_init_mcp_config_files(
1839                &mut report,
1840                &root.join(".projectatlas"),
1841                &db_path,
1842                &config_path,
1843                false,
1844            );
1845            print_output(
1846                cli.format,
1847                &encode_agent_payload(&json!({ "init": report })),
1848                &report,
1849            )?;
1850            if !report.ok {
1851                return Err(CliError::InvalidInput(
1852                    "projectatlas init completed with failed phase(s); see report".to_string(),
1853                ));
1854            }
1855        }
1856        Command::Map { json, force } => {
1857            cli.preflight_implicit_project_root()?;
1858            let config = load_cli_atlas_config(cli)?;
1859            let write_json =
1860                *json && (*force || !(truthy_env("CI") || truthy_env("GITHUB_ACTIONS")));
1861            let response =
1862                atlas_map::render_map(&config, cli.format == OutputFormat::Json, write_json)?;
1863            if *json && !write_json {
1864                write_stderr("Skipping ProjectAtlas JSON sidecar update in CI.\n")?;
1865            }
1866            write_stdout(&response.content)?;
1867        }
1868        Command::Scan {
1869            path,
1870            text_index_max_bytes,
1871        } => {
1872            let path = cli.project_root_for_path(path)?;
1873            let symbol_options = SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None);
1874            let control = index_work_control(&symbol_options);
1875            let plan = ScanRuntimePlan::for_path_controlled(
1876                cli.config.as_deref(),
1877                &path,
1878                *text_index_max_bytes,
1879                &control,
1880            )?;
1881            let mut store = open_atlas_store_for_project(&cli.db, &plan.root)?;
1882            let report =
1883                run_scan_pipeline_controlled(&mut store, &plan, &symbol_options, &control)?;
1884            print_output(
1885                cli.format,
1886                &encode_agent_payload(&json!({ "scan": report })),
1887                &report,
1888            )?;
1889        }
1890        Command::Overview => {
1891            let store = open_index_for_read(cli)?;
1892            let overview = store.overview()?;
1893            let toon = render_overview(&overview);
1894            print_tracked_directory_output_estimate(
1895                cli.format,
1896                &store,
1897                usage_instance,
1898                &cli.session,
1899                "overview",
1900                None,
1901                None,
1902                || estimated_source_tokens_for_indexed_files(&store, None, None),
1903                &toon,
1904                &overview,
1905            )?;
1906        }
1907        Command::Folders { query, limit } => {
1908            let store = open_index_for_read(cli)?;
1909            let selected = ranked_folder_nodes_with_reasons(&store, query, *limit)?;
1910            let toon = render_ranked_nodes("folders", &selected);
1911            let payload = render_ranked_node_rows("folders", &selected);
1912            print_tracked_directory_output_estimate(
1913                cli.format,
1914                &store,
1915                usage_instance,
1916                &cli.session,
1917                "folders",
1918                None,
1919                Some(query.clone()),
1920                || estimated_source_tokens_for_indexed_files(&store, None, None),
1921                &toon,
1922                &payload,
1923            )?;
1924        }
1925        Command::Files {
1926            query,
1927            folder,
1928            file_pattern,
1929            include_content,
1930            content_selection,
1931            limit,
1932        } => {
1933            let store = open_index_for_read(cli)?;
1934            let query_text = query.as_deref().unwrap_or("");
1935            let folder_filter = folder
1936                .as_deref()
1937                .map(normalized_folder_filter)
1938                .transpose()?;
1939            let selected = classified_ranked_file_nodes_with_reasons(
1940                &store,
1941                query_text,
1942                folder_filter.as_deref(),
1943                file_pattern.as_deref(),
1944                *limit,
1945                *include_content,
1946                content_selection.unwrap_or_default(),
1947            )?;
1948            let payload = render_classified_ranked_file_rows(&selected);
1949            let toon = encode_agent_payload(&json!({ "files": &payload }));
1950            print_tracked_output_estimate(
1951                cli.format,
1952                &store,
1953                usage_instance,
1954                &cli.session,
1955                "files",
1956                file_pattern.clone().or_else(|| folder_filter.clone()),
1957                query.clone(),
1958                || {
1959                    estimated_source_tokens_for_indexed_files(
1960                        &store,
1961                        folder_filter.as_deref(),
1962                        file_pattern.as_deref(),
1963                    )
1964                },
1965                &toon,
1966                &payload,
1967            )?;
1968        }
1969        Command::Next {
1970            query,
1971            limit,
1972            content_selection,
1973        } => {
1974            let store = open_index_for_read(cli)?;
1975            let report = next_step_report_with_selection(
1976                &store,
1977                query,
1978                Some(*limit),
1979                content_selection.unwrap_or_default(),
1980            )?;
1981            let payload = next_step_report_payload(&report);
1982            let toon = encode_agent_payload(&json!({ "next": payload }));
1983            print_tracked_directory_output_estimate(
1984                cli.format,
1985                &store,
1986                usage_instance,
1987                &cli.session,
1988                "next",
1989                None,
1990                Some(query.clone()),
1991                || estimated_source_tokens_for_indexed_files(&store, None, None),
1992                &toon,
1993                &payload,
1994            )?;
1995        }
1996        Command::Outline { file, lines } => {
1997            let store = open_index_for_read(cli)?;
1998            let file_key = validated_indexed_file_key(&store, file)?;
1999            let content = read_indexed_file_content(&store, &file_key)?;
2000            let language = store
2001                .load_node_by_path(&file_key)?
2002                .and_then(|node| node.node.language);
2003            let outline = build_outline(&file_key, language, &content, *lines);
2004            let toon = render_outline(&outline);
2005            print_tracked_output_text(
2006                cli.format,
2007                &store,
2008                usage_instance,
2009                &cli.session,
2010                "outline",
2011                Some(file_key),
2012                None,
2013                &content,
2014                &toon,
2015                &outline,
2016            )?;
2017        }
2018        Command::Summary {
2019            file,
2020            limit,
2021            content_selection,
2022        } => {
2023            let store = open_index_for_read(cli)?;
2024            let file_key = validated_indexed_file_key(&store, file)?;
2025            let content = read_indexed_file_content(&store, &file_key)?;
2026            let report = build_file_summary_from_source_with_selection(
2027                &store,
2028                Path::new(&file_key),
2029                *limit,
2030                &content,
2031                content_selection.unwrap_or_default(),
2032            )?;
2033            let toon = render_file_summary(&report);
2034            print_tracked_output_text(
2035                cli.format,
2036                &store,
2037                usage_instance,
2038                &cli.session,
2039                "summary",
2040                Some(report.file_path.clone()),
2041                None,
2042                &content,
2043                &toon,
2044                &report,
2045            )?;
2046        }
2047        Command::Search {
2048            pattern,
2049            retrieval_mode,
2050            regex,
2051            fuzzy,
2052            case_sensitive,
2053            file_pattern,
2054            context_lines,
2055            start_index,
2056            limit,
2057            content_selection,
2058        } => {
2059            let store = open_index_for_read(cli)?;
2060            let report = search_indexed_files_with_control(
2061                &store,
2062                &SearchQuery {
2063                    pattern,
2064                    regex: *regex,
2065                    fuzzy: *fuzzy,
2066                    case_sensitive: *case_sensitive,
2067                    file_pattern: file_pattern.as_deref(),
2068                    context_lines: *context_lines,
2069                    start_index: *start_index,
2070                    limit: *limit,
2071                    content_selection: content_selection.unwrap_or_default(),
2072                    retrieval_mode: (*retrieval_mode).into(),
2073                },
2074                None,
2075            )?;
2076            let toon = render_search_report(&report);
2077            print_tracked_output_estimate(
2078                cli.format,
2079                &store,
2080                usage_instance,
2081                &cli.session,
2082                "search",
2083                file_pattern.clone(),
2084                Some(pattern.clone()),
2085                || Ok(byte_count_to_tokens(report.searched_bytes)),
2086                &toon,
2087                &report,
2088            )?;
2089        }
2090        Command::Slice {
2091            file,
2092            start_line,
2093            end_line,
2094            content_selection,
2095            selector:
2096                OptionalSymbolSelectorArgs {
2097                    symbol,
2098                    symbol_parent,
2099                    symbol_kind,
2100                    symbol_signature,
2101                    symbol_line,
2102                    output_bytes,
2103                },
2104        } => {
2105            let store = open_index_for_read(cli)?;
2106            let file_key = validated_indexed_file_key(&store, file)?;
2107            let content = read_indexed_file_content(&store, &file_key)?;
2108            let output_budget = CodeSliceBudget::new(*output_bytes)?;
2109            let report = if let Some(symbol) = symbol {
2110                read_symbol_slice_from_source_bounded_with_selection(
2111                    &store,
2112                    Path::new(&file_key),
2113                    &SymbolSliceSelector {
2114                        name: symbol,
2115                        parent: symbol_parent.as_deref(),
2116                        kind: symbol_kind.as_deref(),
2117                        signature: symbol_signature.as_deref(),
2118                        line: *symbol_line,
2119                    },
2120                    &content,
2121                    output_budget,
2122                    content_selection.unwrap_or_default(),
2123                )?
2124            } else {
2125                if symbol_parent.is_some()
2126                    || symbol_kind.is_some()
2127                    || symbol_signature.is_some()
2128                    || symbol_line.is_some()
2129                {
2130                    return Err(CliError::InvalidInput(
2131                        "symbol disambiguators require --symbol".to_string(),
2132                    ));
2133                }
2134                let start_line = start_line.ok_or_else(|| {
2135                    CliError::InvalidInput(
2136                        "start-line is required unless --symbol is provided".to_string(),
2137                    )
2138                })?;
2139                read_indexed_code_slice_from_source_bounded_with_selection(
2140                    &store,
2141                    Path::new(&file_key),
2142                    start_line,
2143                    *end_line,
2144                    &content,
2145                    output_budget,
2146                    content_selection.unwrap_or_default(),
2147                )?
2148            };
2149            print_tracked_slice_output(
2150                cli.format,
2151                &store,
2152                usage_instance,
2153                &cli.session,
2154                "slice",
2155                Some(report.slice().path.clone()),
2156                None,
2157                &content,
2158                &report,
2159            )?;
2160        }
2161        Command::Symbols { command } => match command.as_ref() {
2162            SymbolsCommand::Build {
2163                path,
2164                max_bytes,
2165                max_workers,
2166                timeout_seconds,
2167            } => {
2168                let path = cli.project_root_for_path(path)?;
2169                let options = SymbolBuildOptions::new(*max_bytes, *max_workers, *timeout_seconds);
2170                let control = index_work_control(&options);
2171                let plan = ScanRuntimePlan::for_path_controlled(
2172                    cli.config.as_deref(),
2173                    &path,
2174                    None,
2175                    &control,
2176                )?;
2177                let mut store = open_atlas_store_for_project(&cli.db, &plan.root)?;
2178                let report = run_symbol_build_pipeline_controlled(
2179                    &mut store, &plan, &options, None, &control,
2180                )?;
2181                print_output(
2182                    cli.format,
2183                    &encode_agent_payload(&json!({ "symbols_build": report })),
2184                    &report,
2185                )?;
2186            }
2187            SymbolsCommand::List {
2188                file,
2189                query,
2190                content_selection,
2191                limit,
2192            } => {
2193                let store = open_index_for_read(cli)?;
2194                let symbols = store.load_classified_symbols(
2195                    file.as_deref(),
2196                    query.as_deref(),
2197                    content_selection.unwrap_or_default(),
2198                    *limit,
2199                )?;
2200                let symbol_rows = render_classified_symbol_rows(&symbols);
2201                let toon = encode_agent_payload(&json!({
2202                    "symbols": &symbol_rows,
2203                }));
2204                print_tracked_output_estimate(
2205                    cli.format,
2206                    &store,
2207                    usage_instance,
2208                    &cli.session,
2209                    "symbols",
2210                    file.clone(),
2211                    query.clone(),
2212                    || {
2213                        estimated_source_tokens_for_paths(
2214                            &store,
2215                            symbols
2216                                .iter()
2217                                .map(|classified| classified.symbol.path.as_str()),
2218                        )
2219                    },
2220                    &toon,
2221                    &symbol_rows,
2222                )?;
2223            }
2224            SymbolsCommand::Relations {
2225                view,
2226                file,
2227                query,
2228                detailed,
2229                limit,
2230            } => {
2231                let DetailedRelationArgs {
2232                    cursor,
2233                    roots,
2234                    anchor:
2235                        DetailedRelationAnchorArgs {
2236                            symbol,
2237                            symbol_parent,
2238                            symbol_kind,
2239                            symbol_signature,
2240                        },
2241                    filters:
2242                        DetailedRelationFilterArgs {
2243                            direction,
2244                            relation,
2245                            content_selection,
2246                            minimum_confidence,
2247                            resolution,
2248                        },
2249                    limits:
2250                        DetailedRelationLimitArgs {
2251                            depth,
2252                            include_occurrences,
2253                            occurrence_limit,
2254                            edge_limit,
2255                            node_limit,
2256                            visited_limit,
2257                            occurrence_total_limit,
2258                            intermediate_bytes,
2259                            deadline_ms,
2260                            output_bytes,
2261                        },
2262                    analysis,
2263                } = detailed.as_ref();
2264                let RelationAnalysisArgs {
2265                    analysis_mode,
2266                    profile_name,
2267                    entrypoints,
2268                    profile_relations,
2269                    trace_target,
2270                    vcs,
2271                    include_communities,
2272                    include_cycles,
2273                    include_dead_code,
2274                } = analysis.as_ref();
2275                let policy = projectatlas_service::RelationNavigationView::from(*view);
2276                let row_limit = limit.unwrap_or(policy.default_rows());
2277                let output_limit = policy.output_bytes(*output_bytes)?;
2278                let legacy_budget = if *view == RelationViewArg::Legacy {
2279                    if cursor.is_some()
2280                        || symbol.is_some()
2281                        || symbol_parent.is_some()
2282                        || symbol_kind.is_some()
2283                        || symbol_signature.is_some()
2284                        || direction.is_some()
2285                        || relation.is_some()
2286                        || minimum_confidence.is_some()
2287                        || resolution.is_some()
2288                        || depth.is_some()
2289                        || *include_occurrences
2290                        || occurrence_limit.is_some()
2291                        || edge_limit.is_some()
2292                        || node_limit.is_some()
2293                        || visited_limit.is_some()
2294                        || occurrence_total_limit.is_some()
2295                    {
2296                        return Err(CliError::Service(ServiceError::InvalidInput(
2297                            "graph selectors, traversal and continuation controls require --view detailed or --view analysis".to_string(),
2298                        )));
2299                    }
2300                    Some(projectatlas_db::LegacyRelationReadBudget::new(
2301                        row_limit,
2302                        Duration::from_millis(deadline_ms.unwrap_or(1_000)),
2303                        intermediate_bytes.unwrap_or(
2304                            projectatlas_db::LegacyRelationReadBudget::MAX_INTERMEDIATE_BYTES,
2305                        ),
2306                    )?)
2307                } else {
2308                    None
2309                };
2310                if *view == RelationViewArg::Detailed && file.is_none() {
2311                    return Err(CliError::Service(ServiceError::InvalidInput(
2312                        "detailed relations require an exact --file anchor; discover candidates with atlas files <query> --limit 3, then atlas symbols relations --file <path>".to_string(),
2313                    )));
2314                }
2315                let analysis_controls_explicit = analysis_mode.is_some()
2316                    || profile_name.is_some()
2317                    || !entrypoints.is_empty()
2318                    || !profile_relations.is_empty()
2319                    || trace_target.is_some()
2320                    || vcs.is_some()
2321                    || *include_communities
2322                    || *include_cycles
2323                    || *include_dead_code;
2324                if *view != RelationViewArg::Analysis && analysis_controls_explicit {
2325                    return Err(CliError::Service(ServiceError::InvalidInput(
2326                        "analysis controls require --view analysis".to_string(),
2327                    )));
2328                }
2329                if *view == RelationViewArg::Legacy && !roots.is_empty() {
2330                    return Err(CliError::Service(ServiceError::InvalidInput(
2331                        "--root requires --view detailed or --view analysis".to_string(),
2332                    )));
2333                }
2334                if *view == RelationViewArg::Legacy && content_selection.is_some() {
2335                    return Err(CliError::Service(ServiceError::InvalidInput(
2336                        "--content-selection requires --view detailed or --view analysis"
2337                            .to_string(),
2338                    )));
2339                }
2340                let mode = analysis_mode
2341                    .unwrap_or(RelationAnalysisModeArg::Architecture)
2342                    .into();
2343                if mode == RelationAnalysisMode::Entrypoint && !roots.is_empty() {
2344                    return Err(CliError::Service(ServiceError::InvalidInput(
2345                        CLI_ERROR_ENTRYPOINT_FEDERATED.to_string(),
2346                    )));
2347                }
2348                let federation_control = (!roots.is_empty()).then(|| {
2349                    standalone_index_work_control()
2350                        .with_timeout_ceiling(Duration::from_millis(10_000))
2351                });
2352                let mut federated_stores = if let Some(control) = federation_control.as_ref() {
2353                    let selected_root = cli.project_root()?;
2354                    Some(open_federated_atlas_stores_for_project(
2355                        &cli.db,
2356                        &selected_root,
2357                        cli.config.as_deref(),
2358                        roots,
2359                        None,
2360                        control,
2361                    )?)
2362                } else {
2363                    None
2364                };
2365                let single_store = if federated_stores.is_none() {
2366                    Some(open_index_for_read(cli)?)
2367                } else {
2368                    None
2369                };
2370                let store = match (&federated_stores, &single_store) {
2371                    (Some(stores), _) => stores.first().map(FederatedStore::store),
2372                    (None, store) => store.as_ref(),
2373                }
2374                .ok_or_else(|| {
2375                    CliError::Service(ServiceError::InvalidInput(
2376                        "relation request opened no project store".to_string(),
2377                    ))
2378                })?;
2379                if *view == RelationViewArg::Legacy {
2380                    let budget = legacy_budget.ok_or_else(|| {
2381                        CliError::Service(ServiceError::InvalidInput(
2382                            "legacy relation budget was not admitted".to_string(),
2383                        ))
2384                    })?;
2385                    let control = standalone_index_work_control();
2386                    let relations = store
2387                        .load_symbol_relations_bounded(
2388                            file.as_deref(),
2389                            query.as_deref(),
2390                            budget,
2391                            &control,
2392                        )?
2393                        .relations;
2394                    let toon = if cli.format == OutputFormat::Toon {
2395                        render_symbol_relations(&relations)
2396                    } else {
2397                        String::new()
2398                    };
2399                    let output = serialized_output(cli.format, &toon, &relations)?;
2400                    projectatlas_service::check_legacy_relation_output(&output, output_limit)?;
2401                    print_tracked_serialized_output_estimate(
2402                        store,
2403                        usage_instance,
2404                        &cli.session,
2405                        "symbol-relations",
2406                        file.clone(),
2407                        query.clone(),
2408                        || {
2409                            estimated_source_tokens_for_paths(
2410                                store,
2411                                relations.iter().map(|relation| relation.path.as_str()),
2412                            )
2413                        },
2414                        &output,
2415                    )?;
2416                } else {
2417                    if query.is_some() {
2418                        return Err(CliError::Service(ServiceError::InvalidInput(
2419                            "detailed symbol relations use exact --symbol selectors, not --query"
2420                                .to_string(),
2421                        )));
2422                    }
2423                    let parsed_entrypoints = if mode == RelationAnalysisMode::Entrypoint {
2424                        entrypoints
2425                            .iter()
2426                            .map(|value| serde_json::from_str::<RelationAnchor>(value))
2427                            .collect::<Result<Vec<_>, _>>()
2428                            .map_err(|error| {
2429                                CliError::Service(ServiceError::InvalidInput(format!(
2430                                    "--entrypoint must be an exact RelationAnchor JSON object: {error}"
2431                                )))
2432                            })?
2433                    } else {
2434                        Vec::new()
2435                    };
2436                    if mode == RelationAnalysisMode::Entrypoint
2437                        && !parsed_entrypoints.is_empty()
2438                        && (symbol.is_some()
2439                            || symbol_parent.is_some()
2440                            || symbol_kind.is_some()
2441                            || symbol_signature.is_some())
2442                    {
2443                        return Err(CliError::Service(ServiceError::InvalidInput(
2444                            "--entrypoint anchors cannot be combined with detailed symbol selectors"
2445                                .to_string(),
2446                        )));
2447                    }
2448                    let file = match file.as_deref() {
2449                        Some(file) => file.to_string(),
2450                        None if mode == RelationAnalysisMode::Entrypoint => parsed_entrypoints
2451                            .first()
2452                            .map(|anchor| match anchor {
2453                                RelationAnchor::File { file }
2454                                | RelationAnchor::Symbol { file, .. } => file.as_str().to_string(),
2455                            })
2456                            .ok_or_else(|| {
2457                                CliError::Service(ServiceError::InvalidInput(
2458                                    "entrypoint analysis requires --entrypoint or --file"
2459                                        .to_string(),
2460                                ))
2461                            })?,
2462                        None => {
2463                            return Err(CliError::Service(ServiceError::InvalidInput(
2464                                "detailed symbol relations require --file".to_string(),
2465                            )));
2466                        }
2467                    };
2468                    let file = validated_indexed_file_key(store, Path::new(&file))?;
2469                    let file = RepositoryFilePath::new(Path::new(&file)).map_err(|error| {
2470                        CliError::Service(ServiceError::InvalidInput(error.to_string()))
2471                    })?;
2472                    let anchor = if let Some(anchor) = parsed_entrypoints.first() {
2473                        anchor.clone()
2474                    } else if let Some(symbol) = symbol {
2475                        if symbol.is_empty() {
2476                            return Err(CliError::Service(ServiceError::InvalidInput(
2477                                "detailed relation symbol must not be empty".to_string(),
2478                            )));
2479                        }
2480                        RelationAnchor::Symbol {
2481                            file,
2482                            name: symbol.clone(),
2483                            symbol_kind: symbol_kind
2484                                .as_deref()
2485                                .map(parse_symbol_kind)
2486                                .transpose()?,
2487                            parent: symbol_parent.clone(),
2488                            signature: symbol_signature.clone(),
2489                        }
2490                    } else {
2491                        if symbol_parent.is_some()
2492                            || symbol_kind.is_some()
2493                            || symbol_signature.is_some()
2494                        {
2495                            return Err(CliError::Service(ServiceError::InvalidInput(
2496                                "symbol disambiguators require --symbol".to_string(),
2497                            )));
2498                        }
2499                        RelationAnchor::File { file }
2500                    };
2501                    let rows = u32::try_from(row_limit).map_err(|_overflow| {
2502                        CliError::Service(ServiceError::InvalidInput(
2503                            "detailed relation limit exceeds the u32 range".to_string(),
2504                        ))
2505                    })?;
2506                    let limits = GraphLimits::new(
2507                        rows,
2508                        occurrence_limit.unwrap_or(25),
2509                        depth.unwrap_or(1),
2510                        output_limit,
2511                    )
2512                    .map_err(|error| {
2513                        CliError::Service(ServiceError::InvalidInput(error.to_string()))
2514                    })?;
2515                    let relations = DetailedRelationQuery {
2516                        anchor,
2517                        direction: direction.unwrap_or(RelationDirectionArg::Outbound).into(),
2518                        relation: relation
2519                            .as_deref()
2520                            .map(parse_coverage_relation)
2521                            .transpose()?,
2522                        minimum_confidence: minimum_confidence
2523                            .unwrap_or(RelationConfidenceArg::Low)
2524                            .into(),
2525                        resolution: resolution.unwrap_or(RelationResolutionArg::Any).into(),
2526                        content_selection: content_selection.unwrap_or_default(),
2527                        include_occurrences: *include_occurrences,
2528                        budget: DetailedRelationBudget::from_graph_limits(limits)
2529                            .with_aggregate_limits(
2530                                *edge_limit,
2531                                *node_limit,
2532                                *visited_limit,
2533                                *occurrence_total_limit,
2534                                *intermediate_bytes,
2535                                *deadline_ms,
2536                            )?,
2537                        cursor: cursor.clone(),
2538                    };
2539                    let output = if *view == RelationViewArg::Detailed {
2540                        if let Some(stores) = federated_stores.take() {
2541                            let control = federation_control.as_ref().ok_or_else(|| {
2542                                CliError::Service(ServiceError::InvalidInput(
2543                                    "federated relation control is unavailable".to_string(),
2544                                ))
2545                            })?;
2546                            let draft = load_federated_detailed_relations(
2547                                stores,
2548                                &relations,
2549                                Some(control),
2550                            )?;
2551                            let (_report, output) = draft.fit_output(Some(control), |report| {
2552                                let payload = json!({ "symbol_relations": report });
2553                                let toon = encode_agent_payload(&payload);
2554                                serialized_output(cli.format, &toon, &payload)
2555                            })?;
2556                            output
2557                        } else {
2558                            let draft = load_detailed_relation_page(
2559                                single_store.as_ref().ok_or_else(|| {
2560                                    CliError::Service(ServiceError::InvalidInput(
2561                                        "single-project relation store is unavailable".to_string(),
2562                                    ))
2563                                })?,
2564                                &relations,
2565                                None,
2566                            )?;
2567                            let (_report, output) = draft.fit_output(None, |report| {
2568                                let payload = json!({ "symbol_relations": report });
2569                                let toon = encode_agent_payload(&payload);
2570                                serialized_output(cli.format, &toon, &payload)
2571                            })?;
2572                            output
2573                        }
2574                    } else {
2575                        let vcs_explicit = vcs.is_some();
2576                        let vcs = match vcs.as_deref().unwrap_or("working-tree") {
2577                            "working-tree" => GitImpactSelection::WorkingTree,
2578                            "index" => GitImpactSelection::Index,
2579                            range => {
2580                                let (base, head) = range.split_once("..").ok_or_else(|| {
2581                                    CliError::Service(ServiceError::InvalidInput(
2582                                        "--vcs must be working-tree, index, or an exact base..head range"
2583                                            .to_string(),
2584                                    ))
2585                                })?;
2586                                GitImpactSelection::RevisionRange {
2587                                    base: base.to_string(),
2588                                    head: head.to_string(),
2589                                }
2590                            }
2591                        };
2592                        let trace_target = trace_target
2593                            .as_deref()
2594                            .map(serde_json::from_str::<RelationAnchor>)
2595                            .transpose()
2596                            .map_err(|error| {
2597                                CliError::Service(ServiceError::InvalidInput(format!(
2598                                    "--trace-target must be an exact RelationAnchor JSON object: {error}"
2599                                )))
2600                            })?;
2601                        let entrypoint_profile = if mode == RelationAnalysisMode::Entrypoint {
2602                            let anchors = if parsed_entrypoints.is_empty() {
2603                                vec![relations.anchor.clone()]
2604                            } else {
2605                                parsed_entrypoints
2606                            };
2607                            let relation_families = if profile_relations.is_empty() {
2608                                GraphRelationKind::ALL.to_vec()
2609                            } else {
2610                                profile_relations
2611                                    .iter()
2612                                    .map(|value| parse_coverage_relation(value))
2613                                    .collect::<Result<Vec<_>, _>>()?
2614                            };
2615                            Some(EntrypointProfile {
2616                                name: profile_name
2617                                    .clone()
2618                                    .unwrap_or_else(|| "entrypoint-profile".to_string()),
2619                                anchors,
2620                                relations: relation_families,
2621                            })
2622                        } else {
2623                            if profile_name.is_some()
2624                                || !entrypoints.is_empty()
2625                                || !profile_relations.is_empty()
2626                            {
2627                                return Err(CliError::Service(ServiceError::InvalidInput(
2628                                    "entrypoint profile controls require --analysis-mode entrypoint"
2629                                        .to_string(),
2630                                )));
2631                            }
2632                            None
2633                        };
2634                        let query = RelationAnalysisQuery {
2635                            relations,
2636                            mode,
2637                            trace_target,
2638                            vcs: (mode == RelationAnalysisMode::Impact || vcs_explicit)
2639                                .then_some(vcs),
2640                            include_communities: *include_communities,
2641                            include_cycles: *include_cycles,
2642                            include_dead_code: *include_dead_code,
2643                            entrypoint_profile,
2644                        };
2645                        if let Some(stores) = federated_stores.take() {
2646                            let control = federation_control.as_ref().ok_or_else(|| {
2647                                CliError::Service(ServiceError::InvalidInput(
2648                                    "federated analysis control is unavailable".to_string(),
2649                                ))
2650                            })?;
2651                            let draft =
2652                                load_federated_relation_analysis(stores, &query, Some(control))?;
2653                            let (_report, output) = draft.fit_output(|report, control| {
2654                                controlled_named_output(
2655                                    cli.format,
2656                                    CLI_PAYLOAD_SYMBOL_RELATIONS,
2657                                    report,
2658                                    control,
2659                                )
2660                            })?;
2661                            output
2662                        } else {
2663                            let draft = load_relation_analysis(
2664                                single_store.as_ref().ok_or_else(|| {
2665                                    CliError::Service(ServiceError::InvalidInput(
2666                                        "single-project analysis store is unavailable".to_string(),
2667                                    ))
2668                                })?,
2669                                &query,
2670                                None,
2671                            )?;
2672                            let (_report, output) = draft.fit_output(|report, control| {
2673                                controlled_named_output(
2674                                    cli.format,
2675                                    CLI_PAYLOAD_SYMBOL_RELATIONS,
2676                                    report,
2677                                    control,
2678                                )
2679                            })?;
2680                            output
2681                        }
2682                    };
2683                    write_stdout(&output)?;
2684                }
2685            }
2686            SymbolsCommand::Slice {
2687                file,
2688                content_selection,
2689                selector:
2690                    RequiredSymbolSelectorArgs {
2691                        symbol,
2692                        symbol_parent,
2693                        symbol_kind,
2694                        symbol_signature,
2695                        symbol_line,
2696                        output_bytes,
2697                    },
2698            } => {
2699                let store = open_index_for_read(cli)?;
2700                let file_key = validated_indexed_file_key(&store, file)?;
2701                let content = read_indexed_file_content(&store, &file_key)?;
2702                let report = read_symbol_slice_from_source_bounded_with_selection(
2703                    &store,
2704                    Path::new(&file_key),
2705                    &SymbolSliceSelector {
2706                        name: symbol,
2707                        parent: symbol_parent.as_deref(),
2708                        kind: symbol_kind.as_deref(),
2709                        signature: symbol_signature.as_deref(),
2710                        line: *symbol_line,
2711                    },
2712                    &content,
2713                    CodeSliceBudget::new(*output_bytes)?,
2714                    content_selection.unwrap_or_default(),
2715                )?;
2716                print_tracked_slice_output(
2717                    cli.format,
2718                    &store,
2719                    usage_instance,
2720                    &cli.session,
2721                    "symbol-slice",
2722                    Some(report.slice().path.clone()),
2723                    Some(symbol.clone()),
2724                    &content,
2725                    &report,
2726                )?;
2727            }
2728        },
2729        Command::Settings => {
2730            cli.preflight_implicit_project_root()?;
2731            let report = build_settings_report(&cli.db, cli.config.as_deref(), cli.format)?;
2732            let toon = render_settings_report(&report);
2733            print_output(cli.format, &toon, &report)?;
2734        }
2735        #[cfg(feature = "derived-snapshot")]
2736        Command::Snapshot {
2737            action,
2738            path,
2739            require_digest,
2740            #[cfg(feature = "derived-snapshot-signatures")]
2741            signing_key,
2742            #[cfg(feature = "derived-snapshot-signatures")]
2743            trusted_public_key,
2744        } => match action {
2745            SnapshotAction::Export => {
2746                if require_digest.is_some() {
2747                    return Err(CliError::InvalidInput(
2748                        "--require-digest applies only to snapshot import".to_string(),
2749                    ));
2750                }
2751                #[cfg(feature = "derived-snapshot-signatures")]
2752                if trusted_public_key.is_some() {
2753                    return Err(CliError::InvalidInput(
2754                        "--trusted-public-key applies only to snapshot import".to_string(),
2755                    ));
2756                }
2757                let store = open_index_for_read(cli)?;
2758                let report = derived_snapshot_archive::export_snapshot_archive(
2759                    &store,
2760                    path,
2761                    #[cfg(feature = "derived-snapshot-signatures")]
2762                    signing_key.as_deref(),
2763                )?;
2764                store.finish_index_read_snapshot()?;
2765                print_output(
2766                    cli.format,
2767                    &encode_agent_payload(&json!({ "snapshot_export": report })),
2768                    &report,
2769                )?;
2770            }
2771            SnapshotAction::Import => {
2772                #[cfg(feature = "derived-snapshot-signatures")]
2773                if signing_key.is_some() {
2774                    return Err(CliError::InvalidInput(
2775                        "--signing-key applies only to snapshot export".to_string(),
2776                    ));
2777                }
2778                let fresh = open_index_for_read(cli)?;
2779                fresh.finish_index_read_snapshot()?;
2780                drop(fresh);
2781                let mut store = open_index_for_mutation(cli)?;
2782                let report = derived_snapshot_archive::import_snapshot_archive(
2783                    &mut store,
2784                    path,
2785                    require_digest.as_deref(),
2786                    #[cfg(feature = "derived-snapshot-signatures")]
2787                    trusted_public_key.as_deref(),
2788                )?;
2789                print_output(
2790                    cli.format,
2791                    &encode_agent_payload(&json!({ "snapshot_import": report })),
2792                    &report,
2793                )?;
2794            }
2795        },
2796        #[cfg(feature = "optional-parser-supervisor")]
2797        Command::ParserPack {
2798            storage_root,
2799            command,
2800        } => run_parser_pack_command(cli.format, storage_root.as_ref(), command)?,
2801        Command::Root { command } => match command {
2802            Some(RootCommand::Set {
2803                path,
2804                transition,
2805                nearest_project,
2806            }) => {
2807                let root = canonical_project_root(path)?;
2808                let report = bind_project_root(&root, *transition, *nearest_project)?;
2809                print_output(cli.format, &render_root_report(&report), &report)?;
2810            }
2811            None | Some(RootCommand::Show) => {
2812                cli.preflight_implicit_project_root()?;
2813                let report = build_root_report(&cli.db, cli.config.as_deref())?;
2814                print_output(cli.format, &render_root_report(&report), &report)?;
2815            }
2816            Some(RootCommand::Status { path }) => {
2817                let report = build_repository_control_report(path)?;
2818                print_output(
2819                    cli.format,
2820                    &render_repository_control_report(&report),
2821                    &report,
2822                )?;
2823            }
2824            Some(RootCommand::Verify {
2825                binding_only,
2826                project_root,
2827            }) => {
2828                cli.preflight_implicit_project_root()?;
2829                if *binding_only {
2830                    let selected_root =
2831                        canonical_source_project_root(project_root.as_deref().ok_or_else(
2832                            || CliError::InvalidInput("binding-only root is required".to_string()),
2833                        )?)?;
2834                    let store = open_atlas_store_read_only_for_project(&cli.db, &selected_root)?;
2835                    let config_root =
2836                        projectatlas_core::CanonicalProjectRoot::from_path(&cli.project_root()?)
2837                            .map_err(|source| {
2838                                CliError::InvalidInput(format!(
2839                                    "project config root is invalid: {source}"
2840                                ))
2841                            })?;
2842                    if !store.project_root_identity_matches(&config_root) {
2843                        return Err(CliError::InvalidInput(
2844                            "selected root disagrees with the project config".to_string(),
2845                        ));
2846                    }
2847                    print_output(
2848                        cli.format,
2849                        "binding_verified: true",
2850                        &json!({ "binding_verified": true }),
2851                    )?;
2852                } else {
2853                    let report = build_root_report(&cli.db, cli.config.as_deref())?;
2854                    let verified = report.verified;
2855                    if verified {
2856                        let root = cli.project_root()?;
2857                        let admission = runtime::preflight_managed_database(&cli.db, &root)?;
2858                        verify_project_database(
2859                            runtime::admitted_database_path(&cli.db, admission.as_ref()),
2860                            &root,
2861                        )?;
2862                    }
2863                    print_output(cli.format, &render_root_report(&report), &report)?;
2864                    if !verified {
2865                        std::process::exit(1);
2866                    }
2867                }
2868            }
2869        },
2870        Command::Config { print: _ } => {
2871            cli.preflight_implicit_project_root()?;
2872            let config = load_cli_atlas_config(cli)?;
2873            let report = effective_config_report(&config);
2874            print_output(
2875                cli.format,
2876                &encode_agent_payload(&json!({ "config": report })),
2877                &report,
2878            )?;
2879        }
2880        Command::Ignore { command } => match command {
2881            IgnoreCommand::List => {
2882                let project_root = cli.project_root()?;
2883                let report = list_ignore_entries(cli.config.as_deref(), &project_root)?;
2884                print_output(
2885                    cli.format,
2886                    &encode_agent_payload(&json!({ "ignore": report })),
2887                    &report,
2888                )?;
2889            }
2890            IgnoreCommand::InitGitignore => {
2891                let project_root = cli.project_root()?;
2892                let report = init_gitignore(cli.config.as_deref(), &project_root)?;
2893                print_output(
2894                    cli.format,
2895                    &encode_agent_payload(&json!({ "gitignore": report })),
2896                    &report,
2897                )?;
2898            }
2899            IgnoreCommand::Add { kind, value } => {
2900                let project_root = cli.project_root()?;
2901                let report =
2902                    add_ignore_entry(cli.config.as_deref(), &project_root, (*kind).into(), value)?;
2903                print_output(
2904                    cli.format,
2905                    &encode_agent_payload(&json!({ "ignore": report })),
2906                    &report,
2907                )?;
2908            }
2909            IgnoreCommand::Remove { kind, value } => {
2910                let project_root = cli.project_root()?;
2911                let report = remove_ignore_entry(
2912                    cli.config.as_deref(),
2913                    &project_root,
2914                    kind.map(Into::into),
2915                    value,
2916                )?;
2917                print_output(
2918                    cli.format,
2919                    &encode_agent_payload(&json!({ "ignore": report })),
2920                    &report,
2921                )?;
2922            }
2923        },
2924        Command::WatchStatus => {
2925            let report = watcher_status_report(false);
2926            let toon = render_watch_status(&report);
2927            print_output(cli.format, &toon, &report)?;
2928        }
2929        Command::Watch {
2930            path,
2931            once,
2932            poll_seconds,
2933            max_cycles,
2934            max_workers,
2935            timeout_seconds,
2936            text_index_max_bytes,
2937        } => {
2938            let path = cli.project_root_for_path(path)?;
2939            let symbol_options =
2940                SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, *max_workers, *timeout_seconds);
2941            if !cli.db.is_file() {
2942                return Err(runtime::index_init_required(&path, &cli.db));
2943            }
2944            let report = if *once {
2945                let control = index_work_control(&symbol_options);
2946                let plan = ScanRuntimePlan::for_path_controlled(
2947                    cli.config.as_deref(),
2948                    &path,
2949                    *text_index_max_bytes,
2950                    &control,
2951                )?;
2952                let mut store =
2953                    runtime::open_existing_atlas_store_for_project(&cli.db, &plan.root)?;
2954                run_single_watch_refresh_controlled(&mut store, &plan, &symbol_options, &control)?
2955            } else {
2956                let plan =
2957                    ScanRuntimePlan::for_path(cli.config.as_deref(), &path, *text_index_max_bytes)?;
2958                let mut store =
2959                    runtime::open_existing_atlas_store_for_project(&cli.db, &plan.root)?;
2960                run_watch_loop(
2961                    &mut store,
2962                    &plan,
2963                    false,
2964                    *poll_seconds,
2965                    *max_cycles,
2966                    &symbol_options,
2967                )?
2968            };
2969            print_output(
2970                cli.format,
2971                &encode_agent_payload(&json!({ "watch": report })),
2972                &report,
2973            )?;
2974        }
2975        Command::HealthCheck { report } => {
2976            run_health_report(cli, report, usage_instance, "health-check")?;
2977        }
2978        Command::Health { report, command } => match command {
2979            Some(HealthCommand::Resolve {
2980                finding_id,
2981                category,
2982                path,
2983                related_path,
2984                rationale,
2985            }) => {
2986                let store = open_index_for_mutation(cli)?;
2987                let resolution = HealthResolution {
2988                    finding_id: finding_id.clone(),
2989                    category: category.clone(),
2990                    path: path.clone(),
2991                    related_path: related_path.clone(),
2992                    rationale: rationale.clone(),
2993                };
2994                store.resolve_health_finding(&resolution)?;
2995                print_output(
2996                    cli.format,
2997                    &encode_agent_payload(&json!({ "health_resolution": resolution })),
2998                    &resolution,
2999                )?;
3000            }
3001            None => run_health_report(cli, report, usage_instance, "health")?,
3002        },
3003        Command::Lint {
3004            strict_folders,
3005            purpose_level,
3006            report_untracked,
3007            strict_untracked,
3008        } => {
3009            cli.preflight_implicit_project_root()?;
3010            let config = load_cli_atlas_config(cli)?;
3011            let report = lint_project(
3012                &config,
3013                &cli.db,
3014                cli.config.as_deref(),
3015                LintOptions {
3016                    strict_folders: *strict_folders,
3017                    report_untracked: *report_untracked,
3018                    strict_untracked: *strict_untracked,
3019                },
3020                (*purpose_level).into(),
3021            )?;
3022            let payload = NamedPayload {
3023                key: "lint",
3024                payload: &report,
3025            };
3026            let output = match cli.format {
3027                OutputFormat::Toon => encode_agent_payload(&payload),
3028                OutputFormat::Json => format!("{}\n", serde_json::to_string_pretty(&payload)?),
3029            };
3030            write_stdout(&output)?;
3031            if report.exit_code != 0 {
3032                std::process::exit(report.exit_code);
3033            }
3034        }
3035        Command::Token {
3036            session,
3037            view,
3038            trend,
3039            tokenizer,
3040            benchmark_results,
3041            theme,
3042        } => {
3043            let store = open_index_for_current_read(cli)?;
3044            let load_report = |request: TokenReportRequest<'_>| {
3045                if session.is_none() {
3046                    load_synchronized_repository_token_report(
3047                        &cli.db,
3048                        &cli.project_root()?,
3049                        None,
3050                        request,
3051                    )
3052                } else {
3053                    load_token_report(&store, request).map_err(CliError::from)
3054                }
3055            };
3056            if let Some(window) = trend {
3057                if tokenizer.is_some() {
3058                    return Err(CliError::InvalidInput(
3059                        "--tokenizer is only supported for token overview reports".to_string(),
3060                    ));
3061                }
3062                if benchmark_results.is_some() {
3063                    return Err(CliError::InvalidInput(
3064                        "--benchmark-results is only supported for token overview reports"
3065                            .to_string(),
3066                    ));
3067                }
3068                let request = session.as_deref().map_or_else(
3069                    || TokenReportRequest::RepositoryTrends {
3070                        window: (*window).into(),
3071                    },
3072                    |caller_label| TokenReportRequest::Trends {
3073                        caller_label: Some(caller_label),
3074                        window: (*window).into(),
3075                    },
3076                );
3077                let report = match load_report(request)? {
3078                    TokenReport::Trends(report) => report,
3079                    TokenReport::Overview(_) => {
3080                        return Err(CliError::InvalidInput(
3081                            "token trend request returned an overview".to_string(),
3082                        ));
3083                    }
3084                };
3085                match view {
3086                    TokenView::Agent => {
3087                        print_output(cli.format, &render_token_trends(&report), &report)?;
3088                    }
3089                    TokenView::Tui => {
3090                        let dashboard =
3091                            render_token_trend_dashboard_with_theme(&report, (*theme).into())?;
3092                        write_stdout(&dashboard)?;
3093                    }
3094                }
3095            } else {
3096                let request = session.as_deref().map_or_else(
3097                    || TokenReportRequest::RepositoryOverview {
3098                        benchmark_results: benchmark_results.as_deref(),
3099                    },
3100                    |caller_label| TokenReportRequest::Overview {
3101                        caller_label: Some(caller_label),
3102                        benchmark_results: benchmark_results.as_deref(),
3103                    },
3104                );
3105                let mut overview = match load_report(request)? {
3106                    TokenReport::Overview(overview) => overview,
3107                    TokenReport::Trends(_) => {
3108                        return Err(CliError::InvalidInput(
3109                            "token overview request returned trends".to_string(),
3110                        ));
3111                    }
3112                };
3113                if let Some(tokenizer) = tokenizer.as_deref() {
3114                    overview.set_calibration(build_token_calibration(&store, tokenizer)?);
3115                }
3116                match view {
3117                    TokenView::Agent => {
3118                        print_output(cli.format, &render_token_overview(&overview), &overview)?;
3119                    }
3120                    TokenView::Tui => {
3121                        let viewport = capture_token_dashboard_viewport();
3122                        let atlas = if token_dashboard_wants_atlas(viewport) {
3123                            load_token_atlas_preview(&store)
3124                        } else {
3125                            TokenAtlasPreview::empty()
3126                        };
3127                        let dashboard = render_token_dashboard_with_atlas(
3128                            &overview,
3129                            session.as_deref(),
3130                            &atlas,
3131                            (*theme).into(),
3132                            viewport,
3133                        )?;
3134                        write_stdout(&dashboard)?;
3135                    }
3136                }
3137            }
3138        }
3139        Command::Parity { command, profile } => {
3140            let profile = match command {
3141                Some(ParityCommand::Report { profile }) => profile,
3142                None => profile,
3143            };
3144            let store = open_index_for_read(cli)?;
3145            let report = build_parity_report(&store, profile)?;
3146            let ok = report.ok;
3147            print_output(cli.format, &render_parity_report(&report), &report)?;
3148            if !ok {
3149                std::process::exit(1);
3150            }
3151        }
3152        Command::StripLegacyPurpose {
3153            path,
3154            apply,
3155            dry_run,
3156            strip_source_headers,
3157        } => {
3158            let path = cli.project_root_for_path(path)?;
3159            let report = strip_legacy_purpose(
3160                &path,
3161                cli.config.as_deref(),
3162                *apply,
3163                *dry_run,
3164                *strip_source_headers,
3165            )?;
3166            print_output(
3167                cli.format,
3168                &encode_agent_payload(&json!({ "legacy_purpose_migration": report })),
3169                &report,
3170            )?;
3171        }
3172        Command::ResetIndex {
3173            apply,
3174            dry_run,
3175            include_mcp_config,
3176        } => {
3177            let root = runtime::selected_database_admission_root(
3178                &cli.db,
3179                cli.config.as_deref(),
3180                cli.database_path_is_explicit,
3181            )?;
3182            let admission = runtime::preflight_managed_reset(&cli.db, &root)?;
3183            let database = runtime::admitted_database_path(&cli.db, admission.as_ref());
3184            let report = reset_index_files(
3185                database,
3186                *apply,
3187                *dry_run,
3188                *include_mcp_config,
3189                admission.as_ref(),
3190            )?;
3191            print_output(
3192                cli.format,
3193                &encode_agent_payload(&json!({ "reset_index": report })),
3194                &report,
3195            )?;
3196        }
3197        Command::Mcp { nearest_project } => {
3198            cli.preflight_implicit_project_root()?;
3199            mcp::run_mcp_server(
3200                cli.db.clone(),
3201                cli.config.clone(),
3202                cli.session.clone(),
3203                *nearest_project,
3204            )?;
3205        }
3206        Command::McpConfig {
3207            server_name,
3208            harness,
3209            nearest_project,
3210        } => {
3211            cli.preflight_implicit_project_root()?;
3212            let report = build_harness_mcp_config_report(
3213                *harness,
3214                server_name,
3215                &cli.db,
3216                cli.config.as_deref(),
3217                *nearest_project,
3218            )?;
3219            print_output(cli.format, &render_mcp_config_report(&report), &report)?;
3220        }
3221        Command::RuntimeInfo => {
3222            let report = build_runtime_info();
3223            print_output(cli.format, &render_runtime_info(&report), &report)?;
3224        }
3225        #[cfg(unix)]
3226        Command::AcquireInstallerLock {
3227            expected_device,
3228            expected_inode,
3229            timeout_milliseconds,
3230            report_elapsed,
3231        } => {
3232            let started = Instant::now();
3233            let result = io::stdin()
3234                .as_fd()
3235                .try_clone_to_owned()
3236                .map(fs::File::from)
3237                .and_then(|lock_file| {
3238                    acquire_installer_lock(
3239                        &lock_file,
3240                        *expected_device,
3241                        *expected_inode,
3242                        timeout_milliseconds.map_or(INSTALLER_LOCK_TIMEOUT, |milliseconds| {
3243                            Duration::from_millis(milliseconds)
3244                        }),
3245                    )
3246                });
3247            if result.is_ok() && *report_elapsed {
3248                write_stdout(&format!("{}\n", started.elapsed().as_millis()))?;
3249            }
3250            result.map_err(|source| CliError::Io {
3251                path: PathBuf::from("inherited installer lock standard input"),
3252                source,
3253            })?;
3254        }
3255        Command::Purpose { command } => match command {
3256            PurposeCommand::Set { path, purpose } => {
3257                let report = with_admitted_purpose_mutation(cli, |store| {
3258                    store.set_purpose(path, purpose, PurposeSource::Agent)?;
3259                    let classification = if store
3260                        .load_node_by_path(path)?
3261                        .is_some_and(|node| node.node.kind == projectatlas_core::NodeKind::File)
3262                    {
3263                        store
3264                            .file_content_classifications_for_paths(std::slice::from_ref(path))?
3265                            .first()
3266                            .map(|row| row.classification)
3267                    } else {
3268                        None
3269                    };
3270                    Ok(PurposeSetReport {
3271                        purpose_set: PurposeSetPayload {
3272                            path: path.clone(),
3273                            classification,
3274                            status: PurposeStatus::Approved,
3275                            source: PurposeSource::Agent,
3276                            agent_reviewed: true,
3277                        },
3278                    })
3279                })?;
3280                print_output(cli.format, &encode_agent_payload(&report), &report)?;
3281            }
3282            PurposeCommand::Review { from_file, apply } => {
3283                let requests = load_purpose_review_requests(from_file)?;
3284                validate_purpose_review_admission(&requests)?;
3285                let report = if *apply {
3286                    with_admitted_purpose_mutation(cli, |store| {
3287                        review_purposes(store, &requests, true)
3288                    })?
3289                } else {
3290                    let store = open_index_for_read(cli)?;
3291                    review_purposes(&store, &requests, false)?
3292                };
3293                print_output(cli.format, &render_purpose_review_report(&report), &report)?;
3294                if report.failed > 0 {
3295                    std::process::exit(1);
3296                }
3297            }
3298            PurposeCommand::Queue {
3299                task,
3300                start_index,
3301                limit,
3302                category,
3303                severity,
3304                path_prefix,
3305                summary_only,
3306                include_assets,
3307                include_low_priority_files,
3308            } => {
3309                let store = open_index_for_read(cli)?;
3310                let query = health_query_from_cli(
3311                    *start_index,
3312                    *limit,
3313                    category.as_deref(),
3314                    *severity,
3315                    path_prefix.as_deref(),
3316                    *summary_only,
3317                    purpose_queue_scope(*include_assets, *include_low_priority_files),
3318                );
3319                let page = purpose_curation_page(
3320                    &store,
3321                    &query,
3322                    task.as_deref().unwrap_or("purpose-curation"),
3323                )?;
3324                let toon = render_purpose_curation_page(&page);
3325                store.finish_index_read_snapshot()?;
3326                print_output(cli.format, &toon, &page)?;
3327            }
3328        },
3329    }
3330    Ok(())
3331}
3332
3333/// Return whether this command consumes the conventional project database selection.
3334fn command_uses_implicit_database(command: &Command) -> bool {
3335    match command {
3336        #[cfg(feature = "optional-parser-supervisor")]
3337        Command::ParserPack { .. } => false,
3338        Command::Init { .. }
3339        | Command::Root {
3340            command: Some(RootCommand::Set { .. } | RootCommand::Status { .. }),
3341        }
3342        | Command::RuntimeInfo => false,
3343        #[cfg(unix)]
3344        Command::AcquireInstallerLock { .. } => false,
3345        _ => true,
3346    }
3347}
3348
3349/// Execute one explicit optional parser-pack lifecycle command from the selected project root.
3350#[cfg(feature = "optional-parser-supervisor")]
3351fn run_parser_pack_command(
3352    format: OutputFormat,
3353    storage_root: Option<&PathBuf>,
3354    command: &ParserPackCommand,
3355) -> Result<(), CliError> {
3356    let project_root = std::env::current_dir().map_err(|source| CliError::Io {
3357        path: PathBuf::from("."),
3358        source,
3359    })?;
3360    let lifecycle = OptionalParserPackLifecycle::new(&project_root, storage_root.cloned())?;
3361    let report = match command {
3362        ParserPackCommand::Verify { archive } => lifecycle.verify(archive)?,
3363        ParserPackCommand::Install { archive } => lifecycle.install(archive)?,
3364        ParserPackCommand::Enable { artifact } => lifecycle.enable(artifact)?,
3365        ParserPackCommand::Update { archive } => lifecycle.update(archive)?,
3366        ParserPackCommand::Disable => lifecycle.disable()?,
3367        ParserPackCommand::Remove => lifecycle.remove()?,
3368        ParserPackCommand::Status => lifecycle.status()?,
3369    };
3370    let toon = encode_agent_payload(&json!({ "parser_pack": report }));
3371    print_output(format, &toon, &report)
3372}
3373
3374/// Shared CLI/MCP envelope for a complete-array resource refusal.
3375#[derive(Serialize)]
3376struct LegacyRelationErrorResponse {
3377    /// Typed bounded error and actionable narrowing guidance.
3378    error: LegacyRelationErrorPayload,
3379}
3380
3381/// Exact reason and observed work for a refused compatibility array.
3382#[derive(Serialize)]
3383struct LegacyRelationErrorPayload {
3384    /// Stable agent-facing error category.
3385    kind: AgentErrorKind,
3386    /// Human-readable narrowing/continuation guidance.
3387    message: String,
3388    /// Storage work or actual selected-adapter output boundary.
3389    legacy_relation_limit: LegacyRelationLimitDetail,
3390}
3391
3392/// Independent storage-work and output boundaries of compatibility reads.
3393#[derive(Serialize)]
3394#[serde(tag = "scope", rename_all = "snake_case")]
3395enum LegacyRelationLimitDetail {
3396    /// Storage refused before a complete result could be returned.
3397    Read {
3398        /// Resource whose bound was reached.
3399        limit: projectatlas_db::LegacyRelationReadLimit,
3400        /// Actual bounded work before refusal.
3401        work: projectatlas_db::LegacyRelationReadWork,
3402    },
3403    /// The complete encoded adapter payload cannot fit.
3404    Output {
3405        /// Requested inclusive byte ceiling.
3406        limit_bytes: u32,
3407        /// Actual encoded payload size.
3408        encoded_bytes: u64,
3409    },
3410}
3411
3412/// Preserve complete-array resource failures identically across CLI and MCP.
3413fn legacy_relation_error_response(error: &CliError) -> Option<LegacyRelationErrorResponse> {
3414    let detail = match error {
3415        CliError::Db(DbError::LegacyRelationReadLimit { limit, work })
3416        | CliError::Service(ServiceError::Db(DbError::LegacyRelationReadLimit { limit, work })) => {
3417            LegacyRelationLimitDetail::Read {
3418                limit: *limit,
3419                work: *work,
3420            }
3421        }
3422        CliError::Service(ServiceError::LegacyRelationOutputLimit {
3423            limit_bytes,
3424            encoded_bytes,
3425        }) => LegacyRelationLimitDetail::Output {
3426            limit_bytes: *limit_bytes,
3427            encoded_bytes: *encoded_bytes,
3428        },
3429        _ => return None,
3430    };
3431    Some(LegacyRelationErrorResponse {
3432        error: LegacyRelationErrorPayload {
3433            kind: AgentErrorKind::LegacyRelationLimit,
3434            message: error.to_string(),
3435            legacy_relation_limit: detail,
3436        },
3437    })
3438}
3439
3440/// Render typed source-state failures in the selected agent/script format.
3441fn render_cli_error(format: OutputFormat, error: &CliError) -> Result<String, serde_json::Error> {
3442    if let Some(response) = legacy_relation_error_response(error) {
3443        return match format {
3444            OutputFormat::Toon => {
3445                serde_json::to_value(response).map(|value| encode_agent_payload(&value))
3446            }
3447            OutputFormat::Json => {
3448                serde_json::to_string_pretty(&response).map(|text| format!("{text}\n"))
3449            }
3450        };
3451    }
3452    if let Some(schema_version_mismatch) = schema_version_mismatch_payload(error) {
3453        let response = SchemaVersionMismatchErrorResponse {
3454            error: SchemaVersionMismatchErrorPayload {
3455                kind: AgentErrorKind::SchemaVersionMismatch,
3456                message: error.to_string(),
3457                schema_version_mismatch,
3458            },
3459        };
3460        return match format {
3461            OutputFormat::Toon => {
3462                serde_json::to_value(response).map(|value| encode_agent_payload(&value))
3463            }
3464            OutputFormat::Json => {
3465                serde_json::to_string_pretty(&response).map(|text| format!("{text}\n"))
3466            }
3467        };
3468    }
3469    if let Some(schema_migration_required) = schema_migration_required_payload(error) {
3470        let message = schema_migration_required.message();
3471        let response = SchemaMigrationRequiredErrorResponse {
3472            error: SchemaMigrationRequiredErrorPayload {
3473                kind: AgentErrorKind::SchemaMigrationRequired,
3474                message,
3475                schema_migration_required,
3476            },
3477        };
3478        return match format {
3479            OutputFormat::Toon => {
3480                serde_json::to_value(response).map(|value| encode_agent_payload(&value))
3481            }
3482            OutputFormat::Json => {
3483                serde_json::to_string_pretty(&response).map(|text| format!("{text}\n"))
3484            }
3485        };
3486    }
3487    let details = match error {
3488        CliError::AtlasMap(atlas_map::AtlasMapError::ResponseLimitExceeded { .. }) => {
3489            Some(CliErrorPayload {
3490                kind: AgentErrorKind::ResponseLimitExceeded,
3491                message: error.to_string(),
3492                refresh_required: None,
3493                init_required: None,
3494                worktree_required: None,
3495                verification_incomplete: None,
3496                project_mismatch: None,
3497                database_filesystem: None,
3498                search_capability: None,
3499                next: None,
3500            })
3501        }
3502        #[cfg(feature = "optional-parser-supervisor")]
3503        CliError::ParserPack(source) if source.is_unsupported_containment() => {
3504            Some(CliErrorPayload {
3505                kind: AgentErrorKind::UnsupportedContainment,
3506                message: error.to_string(),
3507                refresh_required: None,
3508                init_required: None,
3509                worktree_required: None,
3510                verification_incomplete: None,
3511                project_mismatch: None,
3512                database_filesystem: None,
3513                search_capability: None,
3514                next: None,
3515            })
3516        }
3517        CliError::InitRequired(report) => Some(CliErrorPayload {
3518            kind: AgentErrorKind::InitRequired,
3519            message: error.to_string(),
3520            refresh_required: None,
3521            init_required: Some(report.as_ref()),
3522            worktree_required: None,
3523            verification_incomplete: None,
3524            project_mismatch: None,
3525            database_filesystem: None,
3526            search_capability: None,
3527            next: Some(CliNextCall {
3528                command: CLI_INIT_COMMAND,
3529                project_path: report.project_root.as_deref(),
3530                once: None,
3531            }),
3532        }),
3533        CliError::WorktreeRequired(report) => Some(CliErrorPayload {
3534            kind: AgentErrorKind::WorktreeRequired,
3535            message: error.to_string(),
3536            refresh_required: None,
3537            init_required: None,
3538            worktree_required: Some(report.as_ref()),
3539            verification_incomplete: None,
3540            project_mismatch: None,
3541            database_filesystem: None,
3542            search_capability: None,
3543            next: None,
3544        }),
3545        CliError::RefreshRequired(report) => Some(CliErrorPayload {
3546            kind: AgentErrorKind::RefreshRequired,
3547            message: error.to_string(),
3548            refresh_required: Some(report.as_ref()),
3549            init_required: None,
3550            worktree_required: None,
3551            verification_incomplete: None,
3552            project_mismatch: None,
3553            database_filesystem: None,
3554            search_capability: None,
3555            next: Some(CliNextCall {
3556                command: CLI_REFRESH_COMMAND,
3557                project_path: report.project_root.as_deref(),
3558                once: Some(true),
3559            }),
3560        }),
3561        CliError::VerificationIncomplete(report) => Some(CliErrorPayload {
3562            kind: AgentErrorKind::VerificationIncomplete,
3563            message: error.to_string(),
3564            refresh_required: None,
3565            init_required: None,
3566            worktree_required: None,
3567            verification_incomplete: Some(report.as_ref()),
3568            project_mismatch: None,
3569            database_filesystem: None,
3570            search_capability: None,
3571            next: None,
3572        }),
3573        CliError::ProjectMismatch(report) => Some(CliErrorPayload {
3574            kind: AgentErrorKind::ProjectMismatch,
3575            message: error.to_string(),
3576            refresh_required: None,
3577            init_required: None,
3578            worktree_required: None,
3579            verification_incomplete: None,
3580            project_mismatch: Some(report.as_ref()),
3581            database_filesystem: None,
3582            search_capability: None,
3583            next: None,
3584        }),
3585        CliError::Service(ServiceError::SearchCapabilityUnavailable {
3586            requested_mode,
3587            state,
3588            guidance,
3589        }) => Some(CliErrorPayload {
3590            kind: AgentErrorKind::SearchCapabilityUnavailable,
3591            message: error.to_string(),
3592            refresh_required: None,
3593            init_required: None,
3594            worktree_required: None,
3595            verification_incomplete: None,
3596            project_mismatch: None,
3597            database_filesystem: None,
3598            search_capability: Some(SearchCapabilityErrorPayload {
3599                requested_mode: *requested_mode,
3600                state,
3601                recovery: guidance,
3602            }),
3603            next: None,
3604        }),
3605        _ => database_filesystem_error_payload(error).map(|(kind, database_filesystem)| {
3606            CliErrorPayload {
3607                kind,
3608                message: error.to_string(),
3609                refresh_required: None,
3610                init_required: None,
3611                worktree_required: None,
3612                verification_incomplete: None,
3613                project_mismatch: None,
3614                database_filesystem: Some(database_filesystem),
3615                search_capability: None,
3616                next: None,
3617            }
3618        }),
3619    };
3620    let Some(error) = details else {
3621        return Ok(format!("error: {error}\n"));
3622    };
3623    let response = CliErrorResponse { error };
3624    match format {
3625        OutputFormat::Toon => {
3626            serde_json::to_value(response).map(|value| encode_agent_payload(&value))
3627        }
3628        OutputFormat::Json => {
3629            serde_json::to_string_pretty(&response).map(|text| format!("{text}\n"))
3630        }
3631    }
3632}
3633
3634/// Extract a stable content-free `SQLite` placement failure from a CLI error.
3635fn database_filesystem_error_payload(
3636    error: &CliError,
3637) -> Option<(AgentErrorKind, DatabaseFilesystemErrorPayload)> {
3638    let (kind, path, mount_point, filesystem_type, reason) = match error {
3639        CliError::Db(DbError::DatabaseFilesystemUnsupported {
3640            path,
3641            mount_point,
3642            filesystem_type,
3643        }) => (
3644            AgentErrorKind::DatabaseFilesystemUnsupported,
3645            path,
3646            mount_point,
3647            filesystem_type,
3648            None,
3649        ),
3650        CliError::Db(DbError::DatabaseFilesystemUncertain {
3651            path,
3652            mount_point,
3653            filesystem_type,
3654            reason,
3655        }) => (
3656            AgentErrorKind::DatabaseFilesystemUncertain,
3657            path,
3658            mount_point,
3659            filesystem_type,
3660            Some(reason.clone()),
3661        ),
3662        _ => return None,
3663    };
3664    Some((
3665        kind,
3666        DatabaseFilesystemErrorPayload {
3667            path: lossless_native_path_display(path),
3668            mount_point: mount_point
3669                .as_deref()
3670                .and_then(lossless_native_path_display),
3671            filesystem_type: filesystem_type.clone(),
3672            reason,
3673            recovery: DATABASE_FILESYSTEM_RECOVERY,
3674        },
3675    ))
3676}
3677
3678/// Extract one privacy-safe schema mismatch from the shared database error.
3679fn schema_version_mismatch_payload(error: &CliError) -> Option<SchemaVersionMismatchPayload> {
3680    let (CliError::Db(database_error) | CliError::Service(ServiceError::Db(database_error))) =
3681        error
3682    else {
3683        return None;
3684    };
3685    let (found_schema_version, supported_schema_version) =
3686        database_error.unsupported_schema_version()?;
3687    Some(SchemaVersionMismatchPayload {
3688        found_schema_version,
3689        supported_schema_version,
3690        runtime_version: env!("CARGO_PKG_VERSION"),
3691        recovery: SCHEMA_VERSION_MISMATCH_RECOVERY,
3692    })
3693}
3694
3695/// Extract one privacy-safe migration handoff from the shared database error.
3696fn schema_migration_required_payload(error: &CliError) -> Option<SchemaMigrationRequiredPayload> {
3697    let (CliError::Db(database_error) | CliError::Service(ServiceError::Db(database_error))) =
3698        error
3699    else {
3700        return None;
3701    };
3702    let (found_schema_version, supported_schema_version, migration_steps_remaining) =
3703        database_error.supported_schema_migration()?;
3704    Some(SchemaMigrationRequiredPayload {
3705        found_schema_version,
3706        supported_schema_version,
3707        migration_steps_remaining,
3708        runtime_version: env!("CARGO_PKG_VERSION"),
3709        recovery: SCHEMA_MIGRATION_REQUIRED_RECOVERY,
3710    })
3711}
3712
3713/// Open the selected current index through one root-bound read snapshot.
3714fn open_index_for_current_read(cli: &Cli) -> Result<AtlasStore, CliError> {
3715    let root = cli.project_root()?;
3716    if !cli.db.is_file() {
3717        return Err(runtime::index_init_required(&root, &cli.db));
3718    }
3719    open_atlas_store_read_only_for_project(&cli.db, &root)
3720}
3721
3722/// Open and verify the durable index before a normal CLI read.
3723fn open_index_for_read(cli: &Cli) -> Result<AtlasStore, CliError> {
3724    let root = cli.project_root()?;
3725    if !cli.db.is_file() {
3726        return Err(runtime::index_init_required(&root, &cli.db));
3727    }
3728    open_fresh_atlas_store_for_project(&cli.db, &root, cli.config.as_deref())
3729}
3730
3731/// Open a selected project database for purpose or health mutation.
3732fn open_index_for_mutation(cli: &Cli) -> Result<AtlasStore, CliError> {
3733    let root = cli.project_root()?;
3734    if !cli.db.is_file() {
3735        return Err(runtime::index_init_required(&root, &cli.db));
3736    }
3737    open_atlas_store_for_project(&cli.db, &root)
3738}
3739
3740/// Apply one purpose mutation under a source witness retained through commit.
3741fn with_admitted_purpose_mutation<T>(
3742    cli: &Cli,
3743    mutation: impl FnOnce(&AtlasStore) -> Result<T, CliError>,
3744) -> Result<T, CliError> {
3745    let root = cli.project_root()?;
3746    if !cli.db.is_file() {
3747        return Err(runtime::index_init_required(&root, &cli.db));
3748    }
3749    let control = index_work_control(&SymbolBuildOptions::new(MAX_SYMBOL_FILE_BYTES, None, None));
3750    let observations = SourceObservationRegistry::default();
3751    let database = absolute_path(&cli.db)?;
3752    let config = cli.config.as_deref().map(absolute_path).transpose()?;
3753    let admission = observations.admit_mutation(&database, &root, config.as_deref(), &control)?;
3754    let store = open_index_for_mutation(cli)?;
3755    let transaction = store.begin_purpose_mutation()?;
3756    let operation = (|| {
3757        let value = mutation(&store)?;
3758        admission.verify()?;
3759        Ok(value)
3760    })();
3761    match operation {
3762        Ok(value) => {
3763            transaction.commit()?;
3764            Ok(value)
3765        }
3766        Err(operation) => Err(rollback_rejected_purpose_mutation(transaction, operation)),
3767    }
3768}
3769
3770/// Preserve a rejected purpose mutation when its explicit rollback also fails.
3771fn rollback_rejected_purpose_mutation(
3772    transaction: PurposeMutationTransaction<'_>,
3773    operation: CliError,
3774) -> CliError {
3775    match transaction.rollback() {
3776        Ok(()) => operation,
3777        Err(rollback) => CliError::PurposeMutationRollback {
3778            operation: Box::new(operation),
3779            rollback,
3780        },
3781    }
3782}
3783
3784/// Build a harness-specific MCP configuration document for this binary.
3785fn build_harness_mcp_config_report(
3786    harness: HarnessConfig,
3787    server_name: &str,
3788    db: &Path,
3789    config: Option<&Path>,
3790    nearest_project: bool,
3791) -> Result<serde_json::Value, CliError> {
3792    let config = build_mcp_config_report(server_name, db, config, nearest_project)?;
3793    Ok(match harness {
3794        HarnessConfig::McpJson | HarnessConfig::Codex => serde_json::to_value(config)?,
3795        HarnessConfig::ClaudeCode => {
3796            let mut mcp_servers = BTreeMap::new();
3797            for (name, server) in config.mcp_servers {
3798                mcp_servers.insert(
3799                    name,
3800                    ClaudeMcpServerConfig {
3801                        command: server.command,
3802                        args: server.args,
3803                    },
3804                );
3805            }
3806            serde_json::to_value(ClaudeMcpConfigDocument { mcp_servers })?
3807        }
3808        HarnessConfig::OpenCode => {
3809            let mut mcp = BTreeMap::new();
3810            for (name, server) in config.mcp_servers {
3811                let mut command = Vec::with_capacity(server.args.len() + 1);
3812                command.push(server.command);
3813                command.extend(server.args);
3814                mcp.insert(
3815                    name,
3816                    OpenCodeMcpServerConfig {
3817                        server_type: "local".to_string(),
3818                        command,
3819                        cwd: server.cwd,
3820                        enabled: true,
3821                    },
3822                );
3823            }
3824            serde_json::to_value(OpenCodeConfigDocument {
3825                schema: "https://opencode.ai/config.json".to_string(),
3826                mcp,
3827            })?
3828        }
3829    })
3830}
3831
3832/// Build a standards-compliant MCP configuration document for this binary.
3833fn build_mcp_config_report(
3834    server_name: &str,
3835    db: &Path,
3836    config: Option<&Path>,
3837    nearest_project: bool,
3838) -> Result<McpConfigDocument, CliError> {
3839    let executable = std::env::current_exe().map_err(|source| CliError::Io {
3840        path: PathBuf::from("current executable"),
3841        source,
3842    })?;
3843    let absolute_db = absolute_path(db)?;
3844    let mut args = vec![
3845        "--require-version".to_string(),
3846        env!("CARGO_PKG_VERSION").to_string(),
3847        "--db".to_string(),
3848        mcp_launch_path(&absolute_db)?,
3849    ];
3850    let resolved_config = resolved_mcp_config_path(&absolute_db, config)?;
3851    if let Some(config_path) = resolved_config.as_ref() {
3852        args.push("--config".to_string());
3853        args.push(mcp_launch_path(config_path)?);
3854    }
3855    args.push("mcp".to_string());
3856    if nearest_project {
3857        args.push("--nearest-project".to_string());
3858    }
3859    let project_root = default_mcp_project_root(&absolute_db, resolved_config.as_deref())?;
3860    let mut mcp_servers = BTreeMap::new();
3861    mcp_servers.insert(
3862        server_name.to_string(),
3863        McpServerConfig {
3864            command: mcp_launch_path(&executable)?,
3865            args,
3866            cwd: mcp_launch_path(&project_root)?,
3867        },
3868    );
3869    Ok(McpConfigDocument { mcp_servers })
3870}
3871
3872/// Validate a caller-provided runtime version guard.
3873fn validate_required_runtime_version(required_version: &str) -> Result<(), CliError> {
3874    let normalized = required_version.trim().trim_start_matches('v');
3875    let current = env!("CARGO_PKG_VERSION");
3876    if normalized == current {
3877        Ok(())
3878    } else {
3879        Err(CliError::InvalidInput(format!(
3880            "ProjectAtlas runtime version {current} does not satisfy required version {required_version}"
3881        )))
3882    }
3883}
3884
3885/// Render a lossless native path for an MCP launch config.
3886fn mcp_launch_path(path: &Path) -> Result<String, CliError> {
3887    let value = projectatlas_core::lossless_native_path_display(path)
3888        .ok()
3889        .ok_or_else(|| {
3890            CliError::InvalidInput(
3891                "native MCP configuration path has no lossless UTF-8 representation".to_string(),
3892            )
3893        })?;
3894    Ok(native_launch_path(&value))
3895}
3896
3897/// Convert a lossless projected path to a Windows-native launcher path.
3898#[cfg(windows)]
3899fn native_launch_path(path: &str) -> String {
3900    if let Some(rest) = path.strip_prefix("//") {
3901        format!(r"\\{}", rest.replace('/', "\\"))
3902    } else {
3903        path.replace('/', "\\")
3904    }
3905}
3906
3907/// Return non-Windows paths unchanged.
3908#[cfg(not(windows))]
3909fn native_launch_path(path: &str) -> String {
3910    path.to_string()
3911}
3912
3913/// Render MCP configuration as TOON for agents.
3914fn render_mcp_config_report(report: &serde_json::Value) -> String {
3915    encode_agent_payload(&json!({ "mcp_config": report }))
3916}
3917
3918/// Build stable runtime identity and capability information.
3919fn build_runtime_info() -> RuntimeInfoReport {
3920    RuntimeInfoReport {
3921        project: "ProjectAtlas".to_string(),
3922        major_version: PROJECTATLAS_MAJOR_VERSION,
3923        version: env!("CARGO_PKG_VERSION").to_string(),
3924        executable: std::env::current_exe()
3925            .ok()
3926            .and_then(|path| lossless_native_path_display(&path)),
3927        repository: env!("CARGO_PKG_REPOSITORY").to_string(),
3928        capabilities: vec![
3929            "cli".to_string(),
3930            "mcp".to_string(),
3931            "sqlite".to_string(),
3932            "toon".to_string(),
3933            "symbol-index".to_string(),
3934            "text-search".to_string(),
3935            "watch".to_string(),
3936            "token-telemetry".to_string(),
3937        ],
3938        text_format: "TOON".to_string(),
3939        output_formats: vec!["toon".to_string(), "json".to_string()],
3940        mcp_tools: mcp::REQUIRED_MCP_TOOL_NAMES
3941            .iter()
3942            .map(|name| (*name).to_string())
3943            .collect(),
3944    }
3945}
3946
3947/// Render runtime information as compact TOON.
3948fn render_runtime_info(report: &RuntimeInfoReport) -> String {
3949    encode_agent_payload(&json!({ "runtime": report }))
3950}
3951
3952/// Bind, move, or detach a project root without machine-global root state.
3953fn bind_project_root(
3954    root: &Path,
3955    transition: RootTransition,
3956    nearest_project: bool,
3957) -> Result<RootReport, CliError> {
3958    let root = canonical_source_project_root(root)?;
3959    if !root.is_dir() {
3960        return Err(CliError::InvalidInput(format!(
3961            "project root {} is not a directory",
3962            root.display()
3963        )));
3964    }
3965    let atlas_dir = root.join(".projectatlas");
3966    let db_path = atlas_dir.join("projectatlas.db");
3967    let config_path = init_config_path(&root, None);
3968    let state_guard = runtime::preflight_generated_state(&root, &db_path, &config_path)?;
3969    if config_path.exists() {
3970        let config = load_atlas_config(Some(&config_path))?;
3971        let config_root = canonical_project_root(&config.root)?;
3972        if config_root != root {
3973            return Err(config_root_mismatch_error(
3974                &config_path,
3975                &config_root,
3976                &root,
3977            ));
3978        }
3979    }
3980
3981    let database_exists = db_path.exists();
3982    if !database_exists && transition != RootTransition::Bind {
3983        return Err(CliError::Db(
3984            DbError::ProjectRootTransitionRequiresExistingRoot,
3985        ));
3986    }
3987    if !database_exists {
3988        preflight_existing_project_binding(&db_path, &root)?;
3989        init_project_with_config(&root, Some(&config_path))?;
3990    }
3991    state_guard.verify().map_err(|source| CliError::Io {
3992        path: atlas_dir.clone(),
3993        source,
3994    })?;
3995    let transition_result = AtlasStore::transition_project_root(&db_path, &root, transition.into())
3996        .map_err(runtime::project_store_error)?;
3997    let configuration_result: Result<(), CliError> = (|| {
3998        if database_exists {
3999            init_project_with_config(&root, Some(&config_path))?;
4000        }
4001
4002        write_mcp_config_file(
4003            &atlas_dir.join("projectatlas.mcp.json"),
4004            HarnessConfig::McpJson,
4005            &db_path,
4006            &config_path,
4007            nearest_project,
4008        )?;
4009        write_mcp_config_file(
4010            &atlas_dir.join("projectatlas.claude.mcp.json"),
4011            HarnessConfig::ClaudeCode,
4012            &db_path,
4013            &config_path,
4014            nearest_project,
4015        )?;
4016        write_mcp_config_file(
4017            &atlas_dir.join("projectatlas.opencode.json"),
4018            HarnessConfig::OpenCode,
4019            &db_path,
4020            &config_path,
4021            nearest_project,
4022        )?;
4023        Ok(())
4024    })();
4025    configuration_result.map_err(|source| {
4026        let root_display = lossless_project_root_display(&root);
4027        let message = root_display.as_deref().map_or_else(
4028            || {
4029                format!(
4030                    "root transition {transition:?} committed, but generated project configuration is incomplete: the native project root has no lossless UTF-8 representation; rerun from the actual selected directory or provide an explicit native project selection"
4031                )
4032            },
4033            |root| {
4034                format!(
4035                    "root transition {transition:?} committed for {root:?}, but generated project configuration is incomplete; rerun `projectatlas root set {root:?}` with the default bind transition to repair it without repeating the transition"
4036                )
4037            },
4038        );
4039        CliError::RootTransitionFollowup {
4040            root: root_display,
4041            transition,
4042            message,
4043            source: Box::new(source),
4044        }
4045    })?;
4046    build_root_report_with_transition(&db_path, Some(&config_path), Some(&transition_result))
4047}
4048
4049/// Write all generated host MCP configs expected after first-run init.
4050fn write_init_mcp_config_files(
4051    report: &mut InitSetupReport,
4052    atlas_dir: &Path,
4053    db_path: &Path,
4054    config_path: &Path,
4055    nearest_project: bool,
4056) {
4057    for (harness_name, file_name, harness) in [
4058        ("mcp_json", "projectatlas.mcp.json", HarnessConfig::McpJson),
4059        (
4060            "claude_code",
4061            "projectatlas.claude.mcp.json",
4062            HarnessConfig::ClaudeCode,
4063        ),
4064        (
4065            "opencode",
4066            "projectatlas.opencode.json",
4067            HarnessConfig::OpenCode,
4068        ),
4069    ] {
4070        let path = atlas_dir.join(file_name);
4071        let existed = path.exists();
4072        let (status, error) =
4073            match write_mcp_config_file(&path, harness, db_path, config_path, nearest_project) {
4074                Ok(()) => (init_path_status(existed), None),
4075                Err(error) => {
4076                    report.ok = false;
4077                    (runtime::InitPhaseStatus::Failed, Some(error.to_string()))
4078                }
4079            };
4080        report.host_configs.push(InitHostConfigStatus {
4081            harness: harness_name,
4082            status,
4083            path: lossless_native_path_display(&path),
4084            error,
4085        });
4086    }
4087    if !report.ok {
4088        report
4089            .next_steps
4090            .push("Fix generated host MCP config errors and rerun projectatlas init.".to_string());
4091    }
4092}
4093
4094/// Write one generated MCP config document as pretty JSON.
4095fn write_mcp_config_file(
4096    path: &Path,
4097    harness: HarnessConfig,
4098    db_path: &Path,
4099    config_path: &Path,
4100    nearest_project: bool,
4101) -> Result<(), CliError> {
4102    let value = build_harness_mcp_config_report(
4103        harness,
4104        "projectatlas",
4105        db_path,
4106        Some(config_path),
4107        nearest_project,
4108    )?;
4109    let text = format!("{}\n", serde_json::to_string_pretty(&value)?);
4110    let root = path.parent().and_then(Path::parent).ok_or_else(|| {
4111        CliError::InvalidInput("generated host output has no project-state owner".to_string())
4112    })?;
4113    let mut state =
4114        crate::project_state::ProjectStateGuard::preflight(root, None, Some(config_path)).map_err(
4115            |source| CliError::Io {
4116                path: path.to_path_buf(),
4117                source,
4118            },
4119        )?;
4120    state.ensure_directory().map_err(|source| CliError::Io {
4121        path: path.to_path_buf(),
4122        source,
4123    })?;
4124    state
4125        .write_generated(path, text.as_bytes())
4126        .map_err(|source| CliError::Io {
4127            path: path.to_path_buf(),
4128            source,
4129        })
4130}
4131
4132/// Load batch purpose review requests from a JSON file.
4133fn load_purpose_review_requests(path: &Path) -> Result<Vec<PurposeReviewRequest>, CliError> {
4134    let metadata = fs::metadata(path).map_err(|source| CliError::Io {
4135        path: path.to_path_buf(),
4136        source,
4137    })?;
4138    if metadata.len() > MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES {
4139        return Err(CliError::InvalidInput(format!(
4140            "purpose review input file contains {} bytes; maximum is {MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES}",
4141            metadata.len()
4142        )));
4143    }
4144    let file = fs::File::open(path).map_err(|source| CliError::Io {
4145        path: path.to_path_buf(),
4146        source,
4147    })?;
4148    let mut bytes = Vec::with_capacity(
4149        usize::try_from(metadata.len())
4150            .unwrap_or(MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES as usize)
4151            .min(MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES as usize),
4152    );
4153    file.take(MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES + 1)
4154        .read_to_end(&mut bytes)
4155        .map_err(|source| CliError::Io {
4156            path: path.to_path_buf(),
4157            source,
4158        })?;
4159    if bytes.len() as u64 > MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES {
4160        return Err(CliError::InvalidInput(format!(
4161            "purpose review input file exceeds {MAX_PURPOSE_REVIEW_INPUT_FILE_BYTES} bytes"
4162        )));
4163    }
4164    let text = String::from_utf8(bytes).map_err(|source| {
4165        CliError::InvalidInput(format!(
4166            "purpose review input file {} is not UTF-8: {source}",
4167            path.display()
4168        ))
4169    })?;
4170    let value: serde_json::Value = serde_json::from_str(&text)?;
4171    let items = value.get("items").cloned().unwrap_or(value);
4172    let requests: Vec<PurposeReviewRequest> = serde_json::from_value(items)?;
4173    Ok(requests)
4174}
4175
4176/// Build a project-local root identity report.
4177fn build_root_report(db: &Path, config_path: Option<&Path>) -> Result<RootReport, CliError> {
4178    build_root_report_with_transition(db, config_path, None)
4179}
4180
4181/// Build a root report with optional completed transition details.
4182fn build_root_report_with_transition(
4183    db: &Path,
4184    config_path: Option<&Path>,
4185    transition: Option<&ProjectRootTransitionResult>,
4186) -> Result<RootReport, CliError> {
4187    let admission = runtime::preflight_database_discovery(db, config_path, true)?;
4188    let db = runtime::admitted_database_path(db, admission.as_ref());
4189    let settings = build_settings_report(db, config_path, OutputFormat::Toon)?;
4190    let db_project_root = settings
4191        .index
4192        .as_ref()
4193        .and_then(|index| index.project_root.clone());
4194    let absolute_db = absolute_path(db)?;
4195    let atlas_dir = absolute_db
4196        .parent()
4197        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
4198    let runtime = build_runtime_info();
4199    let project_instance_id = if db.exists() {
4200        AtlasStore::open_read_only(db)?
4201            .project_instance_id()?
4202            .map(|identity| identity.to_string())
4203    } else {
4204        None
4205    };
4206    Ok(RootReport {
4207        root: settings.repo_root.clone(),
4208        detection_source: settings.root_detection_source.clone(),
4209        db_path: settings.db.path.clone(),
4210        config_path: settings.config_path.clone(),
4211        config_project_root: settings
4212            .config_path
4213            .as_ref()
4214            .and(settings.repo_root.clone()),
4215        db_project_root,
4216        mcp_config_path: settings.mcp_config.path.clone(),
4217        claude_mcp_config_path: lossless_native_path_display(
4218            &atlas_dir.join("projectatlas.claude.mcp.json"),
4219        ),
4220        opencode_config_path: lossless_native_path_display(
4221            &atlas_dir.join("projectatlas.opencode.json"),
4222        ),
4223        runtime_executable: runtime.executable,
4224        runtime_version: runtime.version,
4225        project_instance_id,
4226        transition: transition.map(|result| result.transition.into()),
4227        previous_root: transition.and_then(|result| result.previous_root.clone()),
4228        identity_changed: transition.map(|result| result.identity_changed),
4229        publication_invalidated: transition.map(|result| result.publication_invalidated),
4230        verified: settings.root_verified,
4231        mismatches: settings.root_mismatches,
4232    })
4233}
4234
4235/// Return whether an environment variable is set to a truthy value.
4236fn truthy_env(name: &str) -> bool {
4237    std::env::var(name).is_ok_and(|value| {
4238        matches!(
4239            value.trim().to_ascii_lowercase().as_str(),
4240            "1" | "true" | "yes" | "on"
4241        )
4242    })
4243}
4244
4245/// Emit either TOON or JSON to stdout.
4246fn print_output<T: serde::Serialize>(
4247    format: OutputFormat,
4248    toon: &str,
4249    payload: &T,
4250) -> Result<(), CliError> {
4251    write_stdout(&serialized_output(format, toon, payload)?)
4252}
4253
4254/// Serialize output exactly as the CLI will emit it.
4255fn serialized_output<T: serde::Serialize>(
4256    format: OutputFormat,
4257    toon: &str,
4258    payload: &T,
4259) -> Result<String, CliError> {
4260    match format {
4261        OutputFormat::Toon => Ok(toon.to_string()),
4262        OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(payload)?)),
4263    }
4264}
4265
4266/// Maximum bytes copied between cooperative output-control checks.
4267const CONTROLLED_ENCODING_CHUNK_BYTES: usize = 8 * 1024;
4268
4269/// One dynamic top-level payload field without an intermediate JSON value.
4270struct NamedPayload<'a, T: ?Sized> {
4271    /// Stable adapter-owned field name.
4272    key: &'a str,
4273    /// Borrowed service report.
4274    payload: &'a T,
4275}
4276
4277impl<T> Serialize for NamedPayload<'_, T>
4278where
4279    T: Serialize + ?Sized,
4280{
4281    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
4282    where
4283        S: serde::Serializer,
4284    {
4285        use serde::ser::SerializeMap as _;
4286
4287        let mut map = serializer.serialize_map(Some(1))?;
4288        map.serialize_entry(self.key, self.payload)?;
4289        map.end()
4290    }
4291}
4292
4293/// Bounded output buffer that observes the request control between chunks.
4294struct ControlledOutput<'a> {
4295    /// Serialized bytes retained for the adapter result.
4296    bytes: Vec<u8>,
4297    /// Exact request control shared with service analysis.
4298    control: &'a IndexWorkControl,
4299    /// Whether a write stopped because the request became terminal.
4300    interrupted: bool,
4301}
4302
4303impl<'a> ControlledOutput<'a> {
4304    /// Create an empty controlled output buffer.
4305    const fn new(control: &'a IndexWorkControl) -> Self {
4306        Self {
4307            bytes: Vec::new(),
4308            control,
4309            interrupted: false,
4310        }
4311    }
4312
4313    /// Translate a terminal writer error back to the typed request failure.
4314    fn check_terminal(&self) -> Result<(), CliError> {
4315        self.control.check(IndexWorkStage::RepositoryTraversal)?;
4316        Ok(())
4317    }
4318
4319    /// Convert verified encoder output into UTF-8 text.
4320    fn into_string(self) -> Result<String, CliError> {
4321        String::from_utf8(self.bytes).map_err(|source| {
4322            CliError::Output(io::Error::new(
4323                io::ErrorKind::InvalidData,
4324                format!("encoded output was not UTF-8: {source}"),
4325            ))
4326        })
4327    }
4328}
4329
4330impl Write for ControlledOutput<'_> {
4331    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
4332        if self
4333            .control
4334            .check(IndexWorkStage::RepositoryTraversal)
4335            .is_err()
4336        {
4337            self.interrupted = true;
4338            return Err(io::Error::other("analysis output encoding interrupted"));
4339        }
4340        let retained = buffer.len().min(CONTROLLED_ENCODING_CHUNK_BYTES);
4341        self.bytes.extend_from_slice(&buffer[..retained]);
4342        Ok(retained)
4343    }
4344
4345    fn flush(&mut self) -> io::Result<()> {
4346        Ok(())
4347    }
4348}
4349
4350/// Controlled reader used by the installed TOON streaming encoder.
4351struct ControlledInput<'a> {
4352    /// Compact JSON bytes consumed by the TOON encoder.
4353    bytes: &'a [u8],
4354    /// Current input offset.
4355    offset: usize,
4356    /// Exact request control shared with service analysis.
4357    control: &'a IndexWorkControl,
4358    /// Whether a read stopped because the request became terminal.
4359    interrupted: bool,
4360}
4361
4362impl<'a> ControlledInput<'a> {
4363    /// Borrow one serialized payload as a cooperatively bounded input stream.
4364    const fn new(bytes: &'a [u8], control: &'a IndexWorkControl) -> Self {
4365        Self {
4366            bytes,
4367            offset: 0,
4368            control,
4369            interrupted: false,
4370        }
4371    }
4372}
4373
4374impl Read for ControlledInput<'_> {
4375    fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
4376        if self
4377            .control
4378            .check(IndexWorkStage::RepositoryTraversal)
4379            .is_err()
4380        {
4381            self.interrupted = true;
4382            return Err(io::Error::other("analysis output encoding interrupted"));
4383        }
4384        let remaining = &self.bytes[self.offset..];
4385        let read = remaining
4386            .len()
4387            .min(buffer.len())
4388            .min(CONTROLLED_ENCODING_CHUNK_BYTES);
4389        buffer[..read].copy_from_slice(&remaining[..read]);
4390        self.offset = self.offset.saturating_add(read);
4391        Ok(read)
4392    }
4393}
4394
4395/// Serialize one named analysis envelope while retaining deadline and cancellation.
4396fn controlled_named_output<T>(
4397    format: OutputFormat,
4398    key: &str,
4399    payload: &T,
4400    control: &IndexWorkControl,
4401) -> Result<String, CliError>
4402where
4403    T: Serialize + ?Sized,
4404{
4405    let payload = NamedPayload { key, payload };
4406    let mut json = ControlledOutput::new(control);
4407    let json_result = match format {
4408        OutputFormat::Toon => serde_json::to_writer(&mut json, &payload),
4409        OutputFormat::Json => serde_json::to_writer_pretty(&mut json, &payload),
4410    };
4411    if json.interrupted {
4412        json.check_terminal()?;
4413    }
4414    json_result?;
4415    json.check_terminal()?;
4416    if format == OutputFormat::Json {
4417        json.bytes.push(b'\n');
4418        return json.into_string();
4419    }
4420
4421    let mut input = ControlledInput::new(&json.bytes, control);
4422    let mut output = ControlledOutput::new(control);
4423    let toon_result = toon_format::encode_json_stream_default(&mut input, &mut output);
4424    if input.interrupted || output.interrupted {
4425        control.check(IndexWorkStage::RepositoryTraversal)?;
4426    }
4427    control.check(IndexWorkStage::RepositoryTraversal)?;
4428    if let Err(error) = toon_result {
4429        return Ok(format!(
4430            "toon_error: {}\n",
4431            encode_error_text(&error.to_string())
4432        ));
4433    }
4434    output.bytes.push(b'\n');
4435    output.into_string()
4436}
4437
4438/// Build a bounded DB health query from CLI filter arguments.
4439fn health_query_from_cli(
4440    start_index: usize,
4441    limit: usize,
4442    category: Option<&str>,
4443    severity: Option<HealthSeverityArg>,
4444    path_prefix: Option<&str>,
4445    summary_only: bool,
4446    scope: HealthScope,
4447) -> HealthQuery {
4448    HealthQuery {
4449        start_index,
4450        limit: limit.clamp(1, MAX_HEALTH_LIMIT),
4451        category: trimmed_cli_filter(category),
4452        severity: severity.map(Severity::from),
4453        path_prefix: trimmed_cli_filter(path_prefix)
4454            .map(|value| normalize_repo_path_prefix(&value)),
4455        summary_only,
4456        scope,
4457    }
4458}
4459
4460/// Borrowed CLI-only coverage filters before typed service parsing.
4461struct CoverageCliFilters<'a> {
4462    /// Optional repository path prefix.
4463    path_prefix: Option<&'a str>,
4464    /// Optional source parser pass.
4465    parser: Option<&'a str>,
4466    /// Optional fact provider pass.
4467    provider: Option<&'a str>,
4468    /// Optional relation family.
4469    relation: Option<&'a str>,
4470    /// Optional coverage state.
4471    state: Option<&'a str>,
4472    /// Optional exact reason.
4473    reason: Option<&'a str>,
4474}
4475
4476/// Build one typed bounded coverage query from explicit CLI filters.
4477fn coverage_query_from_cli(
4478    start_index: usize,
4479    limit: usize,
4480    filters: &CoverageCliFilters<'_>,
4481) -> Result<RepositoryCoverageQuery, CliError> {
4482    Ok(RepositoryCoverageQuery {
4483        start_index: u32::try_from(start_index).map_err(|error| {
4484            CliError::InvalidInput(format!("coverage start index is too large: {error}"))
4485        })?,
4486        limit: limit.clamp(1, COVERAGE_PAGE_MAX_LIMIT as usize) as u32,
4487        path_prefix: trimmed_cli_filter(filters.path_prefix)
4488            .map(|value| normalize_repo_path_prefix(&value)),
4489        parser: trimmed_cli_filter(filters.parser)
4490            .as_deref()
4491            .map(parse_coverage_parser)
4492            .transpose()?,
4493        provider: trimmed_cli_filter(filters.provider)
4494            .as_deref()
4495            .map(parse_coverage_parser)
4496            .transpose()?,
4497        relation: trimmed_cli_filter(filters.relation)
4498            .as_deref()
4499            .map(parse_coverage_relation)
4500            .transpose()?,
4501        state: trimmed_cli_filter(filters.state)
4502            .as_deref()
4503            .map(parse_coverage_state)
4504            .transpose()?,
4505        reason: trimmed_cli_filter(filters.reason),
4506    })
4507}
4508
4509/// Stabilize format-specific encoded byte metadata before output and telemetry.
4510fn finalize_coverage_output(
4511    format: OutputFormat,
4512    report: &mut CoverageDiscoveryReport,
4513) -> Result<String, CliError> {
4514    for _ in 0..4 {
4515        let toon = render_coverage_report(report);
4516        let rendered = serialized_output(format, &toon, report)?;
4517        let output_bytes = u32::try_from(rendered.len()).map_err(|error| {
4518            CliError::InvalidInput(format!("coverage output size did not fit u32: {error}"))
4519        })?;
4520        if output_bytes > report.max_output_bytes {
4521            return Err(CliError::InvalidInput(format!(
4522                "coverage output exceeded {} bytes",
4523                report.max_output_bytes
4524            )));
4525        }
4526        if report.output_bytes == output_bytes {
4527            return Ok(rendered);
4528        }
4529        report.output_bytes = output_bytes;
4530    }
4531    Err(CliError::InvalidInput(
4532        "coverage output byte metadata did not stabilize".to_string(),
4533    ))
4534}
4535
4536/// Return the DB scope for purpose queue CLI switches.
4537fn purpose_queue_scope(include_assets: bool, include_low_priority_files: bool) -> HealthScope {
4538    match (include_assets, include_low_priority_files) {
4539        (false, false) => HealthScope::purpose_default(),
4540        (true, false) => HealthScope::purpose_with_assets(),
4541        (false, true) => HealthScope::purpose_with_source_files(),
4542        (true, true) => HealthScope::all(),
4543    }
4544}
4545
4546/// Return a trimmed non-empty CLI string filter.
4547fn trimmed_cli_filter(value: Option<&str>) -> Option<String> {
4548    value
4549        .map(str::trim)
4550        .filter(|value| !value.is_empty())
4551        .map(ToOwned::to_owned)
4552}
4553
4554/// Record estimated-token telemetry for the exact emitted CLI payload.
4555fn print_tracked_directory_output_estimate<T, F>(
4556    format: OutputFormat,
4557    store: &AtlasStore,
4558    usage_instance: Option<UsageRuntimeInstance>,
4559    session: &str,
4560    command: &str,
4561    path: Option<String>,
4562    query: Option<String>,
4563    estimate_without_projectatlas: F,
4564    toon: &str,
4565    payload: &T,
4566) -> Result<(), CliError>
4567where
4568    T: serde::Serialize,
4569    F: FnOnce() -> Result<usize, CliError>,
4570{
4571    let output = serialized_output(format, toon, payload)?;
4572    write_stdout(&output)?;
4573    if usage_instance.is_none() || runtime::telemetry_disabled() {
4574        return Ok(());
4575    }
4576    let Ok(estimated_without_projectatlas) = estimate_without_projectatlas() else {
4577        return Ok(());
4578    };
4579    drop(record_directory_walk_usage_estimate(
4580        store,
4581        usage_instance,
4582        session,
4583        command,
4584        path,
4585        query,
4586        estimated_without_projectatlas,
4587        &output,
4588    ));
4589    Ok(())
4590}
4591
4592/// Record candidate-set telemetry for the exact emitted CLI payload.
4593fn print_tracked_output_estimate<T, F>(
4594    format: OutputFormat,
4595    store: &AtlasStore,
4596    usage_instance: Option<UsageRuntimeInstance>,
4597    session: &str,
4598    command: &str,
4599    path: Option<String>,
4600    query: Option<String>,
4601    estimate_without_projectatlas: F,
4602    toon: &str,
4603    payload: &T,
4604) -> Result<(), CliError>
4605where
4606    T: serde::Serialize,
4607    F: FnOnce() -> Result<usize, CliError>,
4608{
4609    let output = serialized_output(format, toon, payload)?;
4610    print_tracked_serialized_output_estimate(
4611        store,
4612        usage_instance,
4613        session,
4614        command,
4615        path,
4616        query,
4617        estimate_without_projectatlas,
4618        &output,
4619    )
4620}
4621
4622/// Emit and record an already encoded payload without serializing it again.
4623fn print_tracked_serialized_output_estimate<F>(
4624    store: &AtlasStore,
4625    usage_instance: Option<UsageRuntimeInstance>,
4626    session: &str,
4627    command: &str,
4628    path: Option<String>,
4629    query: Option<String>,
4630    estimate_without_projectatlas: F,
4631    output: &str,
4632) -> Result<(), CliError>
4633where
4634    F: FnOnce() -> Result<usize, CliError>,
4635{
4636    write_stdout(output)?;
4637    if usage_instance.is_none() || runtime::telemetry_disabled() {
4638        return Ok(());
4639    }
4640    let Ok(estimated_without_projectatlas) = estimate_without_projectatlas() else {
4641        return Ok(());
4642    };
4643    drop(record_usage_estimate(
4644        store,
4645        usage_instance,
4646        session,
4647        command,
4648        path,
4649        query,
4650        estimated_without_projectatlas,
4651        output,
4652    ));
4653    Ok(())
4654}
4655
4656/// Record baseline-text telemetry for the exact emitted CLI payload.
4657fn print_tracked_output_text<T: serde::Serialize>(
4658    format: OutputFormat,
4659    store: &AtlasStore,
4660    usage_instance: Option<UsageRuntimeInstance>,
4661    session: &str,
4662    command: &str,
4663    path: Option<String>,
4664    query: Option<String>,
4665    baseline_text: &str,
4666    toon: &str,
4667    payload: &T,
4668) -> Result<(), CliError> {
4669    let output = serialized_output(format, toon, payload)?;
4670    write_stdout(&output)?;
4671    drop(record_usage_text(
4672        store,
4673        usage_instance,
4674        session,
4675        command,
4676        path,
4677        query,
4678        baseline_text,
4679        &output,
4680    ));
4681    Ok(())
4682}
4683
4684/// Emit a bounded exact slice and record telemetry for the accepted bytes.
4685fn print_tracked_slice_output(
4686    format: OutputFormat,
4687    store: &AtlasStore,
4688    usage_instance: Option<UsageRuntimeInstance>,
4689    session: &str,
4690    command: &str,
4691    path: Option<String>,
4692    query: Option<String>,
4693    baseline_text: &str,
4694    report: &CodeSliceDraft,
4695) -> Result<(), CliError> {
4696    let output = report.fit_output(|report| {
4697        let toon = render_code_slice(report);
4698        serialized_output(format, &toon, report)
4699    })?;
4700    write_stdout(&output)?;
4701    drop(record_usage_text(
4702        store,
4703        usage_instance,
4704        session,
4705        command,
4706        path,
4707        query,
4708        baseline_text,
4709        &output,
4710    ));
4711    Ok(())
4712}
4713
4714/// Agent-facing payload for a CLI purpose update.
4715#[derive(Debug, Serialize)]
4716struct PurposeSetReport {
4717    /// Purpose update result details.
4718    purpose_set: PurposeSetPayload,
4719}
4720
4721/// Stable serialized schema for a CLI purpose update.
4722#[derive(Debug, Serialize)]
4723struct PurposeSetPayload {
4724    /// Indexed repository-relative path whose purpose was updated.
4725    path: String,
4726    /// Registry-owned content role when the selected path is a file.
4727    #[serde(skip_serializing_if = "Option::is_none")]
4728    classification: Option<ContentClassification>,
4729    /// Durable purpose status after the update.
4730    status: PurposeStatus,
4731    /// Source of the durable purpose after the update.
4732    source: PurposeSource,
4733    /// Whether the purpose has been agent-reviewed.
4734    agent_reviewed: bool,
4735}
4736
4737/// Repository-intelligence parity report.
4738#[derive(Debug, Serialize)]
4739struct ParityReport {
4740    /// Evaluated parity profile.
4741    profile: String,
4742    /// Whether every required check passed.
4743    ok: bool,
4744    /// Current repository overview.
4745    overview: projectatlas_core::Overview,
4746    /// Files with persisted UTF-8 search text.
4747    indexed_text_files: usize,
4748    /// UTF-8 source bytes available through SQLite-backed search.
4749    indexed_text_bytes: usize,
4750    /// Persisted symbols.
4751    symbols: usize,
4752    /// Persisted symbol relations.
4753    relations: usize,
4754    /// Current unresolved health finding count.
4755    health_findings: usize,
4756    /// Token telemetry events counted for the active/default report.
4757    token_calls: usize,
4758    /// Runtime watcher mode detected in this process.
4759    watcher_mode: String,
4760    /// Required parity checks.
4761    checks: Vec<ParityCheck>,
4762}
4763
4764/// Agent-facing parity payload wrapper.
4765#[derive(Debug, Serialize)]
4766struct ParityPayload<'a> {
4767    /// Repository-intelligence parity report.
4768    parity: &'a ParityReport,
4769}
4770
4771/// One parity check row.
4772#[derive(Debug, Serialize)]
4773struct ParityCheck {
4774    /// Stable check name.
4775    name: String,
4776    /// Stable check status.
4777    status: ParityCheckStatus,
4778    /// Concrete evidence for this check.
4779    detail: String,
4780}
4781
4782/// Stable status values for repository-intelligence parity checks.
4783#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
4784#[serde(rename_all = "lowercase")]
4785enum ParityCheckStatus {
4786    /// The parity check passed.
4787    Pass,
4788    /// The parity check failed.
4789    Fail,
4790}
4791
4792impl ParityCheckStatus {
4793    /// Return a check status from a boolean predicate.
4794    fn from_passed(passed: bool) -> Self {
4795        if passed { Self::Pass } else { Self::Fail }
4796    }
4797}
4798
4799/// Required CLI command families whose variants must remain constructible.
4800#[derive(Clone, Copy, Debug)]
4801enum RequiredCliCommand {
4802    /// `projectatlas init`.
4803    Init,
4804    /// `projectatlas map`.
4805    Map,
4806    /// `projectatlas scan`.
4807    Scan,
4808    /// `projectatlas overview`.
4809    Overview,
4810    /// `projectatlas folders`.
4811    Folders,
4812    /// `projectatlas files`.
4813    Files,
4814    /// `projectatlas next`.
4815    Next,
4816    /// `projectatlas outline`.
4817    Outline,
4818    /// `projectatlas summary`.
4819    Summary,
4820    /// `projectatlas search`.
4821    Search,
4822    /// `projectatlas slice`.
4823    Slice,
4824    /// `projectatlas symbols`.
4825    Symbols,
4826    /// `projectatlas settings`.
4827    Settings,
4828    /// `projectatlas snapshot`.
4829    #[cfg(feature = "derived-snapshot")]
4830    Snapshot,
4831    /// `projectatlas parser-pack`.
4832    #[cfg(feature = "optional-parser-supervisor")]
4833    ParserPack,
4834    /// `projectatlas root`.
4835    Root,
4836    /// `projectatlas config`.
4837    Config,
4838    /// `projectatlas ignore`.
4839    Ignore,
4840    /// `projectatlas watch-status`.
4841    WatchStatus,
4842    /// `projectatlas watch`.
4843    Watch,
4844    /// `projectatlas health-check`.
4845    HealthCheck,
4846    /// `projectatlas health`.
4847    Health,
4848    /// `projectatlas lint`.
4849    Lint,
4850    /// `projectatlas token`.
4851    Token,
4852    /// `projectatlas parity`.
4853    Parity,
4854    /// `projectatlas strip-legacy-purpose`.
4855    StripLegacyPurpose,
4856    /// `projectatlas reset-index`.
4857    ResetIndex,
4858    /// `projectatlas mcp`.
4859    Mcp,
4860    /// `projectatlas mcp-config`.
4861    McpConfig,
4862    /// `projectatlas runtime-info`.
4863    RuntimeInfo,
4864    /// `projectatlas purpose`.
4865    Purpose,
4866}
4867
4868impl RequiredCliCommand {
4869    /// Stable command name used in reports and parity diagnostics.
4870    fn name(self) -> &'static str {
4871        match self {
4872            Self::Init => "init",
4873            Self::Map => "map",
4874            Self::Scan => "scan",
4875            Self::Overview => "overview",
4876            Self::Folders => "folders",
4877            Self::Files => "files",
4878            Self::Next => "next",
4879            Self::Outline => "outline",
4880            Self::Summary => "summary",
4881            Self::Search => "search",
4882            Self::Slice => "slice",
4883            Self::Symbols => "symbols",
4884            Self::Settings => "settings",
4885            #[cfg(feature = "derived-snapshot")]
4886            Self::Snapshot => "snapshot",
4887            #[cfg(feature = "optional-parser-supervisor")]
4888            Self::ParserPack => "parser-pack",
4889            Self::Root => "root",
4890            Self::Config => "config",
4891            Self::Ignore => "ignore",
4892            Self::WatchStatus => "watch-status",
4893            Self::Watch => "watch",
4894            Self::HealthCheck => "health-check",
4895            Self::Health => "health",
4896            Self::Lint => "lint",
4897            Self::Token => "token",
4898            Self::Parity => "parity",
4899            Self::StripLegacyPurpose => "strip-legacy-purpose",
4900            Self::ResetIndex => "reset-index",
4901            Self::Mcp => "mcp",
4902            Self::McpConfig => "mcp-config",
4903            Self::RuntimeInfo => "runtime-info",
4904            Self::Purpose => "purpose",
4905        }
4906    }
4907
4908    /// Construct the actual CLI enum variant so parity is tied to compiled command families.
4909    fn command(self) -> Command {
4910        match self {
4911            Self::Init => Command::Init {
4912                no_scan: true,
4913                force_rescan: false,
4914                text_index_max_bytes: None,
4915            },
4916            Self::Map => Command::Map {
4917                json: false,
4918                force: false,
4919            },
4920            Self::Scan => Command::Scan {
4921                path: PathBuf::from("."),
4922                text_index_max_bytes: None,
4923            },
4924            Self::Overview => Command::Overview,
4925            Self::Folders => Command::Folders {
4926                query: String::new(),
4927                limit: 1,
4928            },
4929            Self::Files => Command::Files {
4930                query: None,
4931                folder: None,
4932                file_pattern: None,
4933                include_content: false,
4934                content_selection: None,
4935                limit: 1,
4936            },
4937            Self::Next => Command::Next {
4938                query: String::new(),
4939                limit: 1,
4940                content_selection: None,
4941            },
4942            Self::Outline => Command::Outline {
4943                file: PathBuf::from("src/lib.rs"),
4944                lines: 1,
4945            },
4946            Self::Summary => Command::Summary {
4947                file: PathBuf::from("src/lib.rs"),
4948                limit: 1,
4949                content_selection: None,
4950            },
4951            Self::Search => Command::Search {
4952                pattern: String::new(),
4953                retrieval_mode: SearchRetrievalModeArg::Lexical,
4954                regex: false,
4955                fuzzy: false,
4956                case_sensitive: false,
4957                file_pattern: None,
4958                context_lines: 0,
4959                start_index: 0,
4960                limit: 1,
4961                content_selection: None,
4962            },
4963            Self::Slice => Command::Slice {
4964                file: PathBuf::from("src/lib.rs"),
4965                start_line: Some(1),
4966                end_line: None,
4967                content_selection: None,
4968                selector: OptionalSymbolSelectorArgs {
4969                    symbol: None,
4970                    symbol_parent: None,
4971                    symbol_kind: None,
4972                    symbol_signature: None,
4973                    symbol_line: None,
4974                    output_bytes: CodeSliceBudget::DEFAULT_OUTPUT_BYTES,
4975                },
4976            },
4977            Self::Symbols => Command::Symbols {
4978                command: Box::new(SymbolsCommand::List {
4979                    file: None,
4980                    query: None,
4981                    content_selection: None,
4982                    limit: 1,
4983                }),
4984            },
4985            Self::Settings => Command::Settings,
4986            #[cfg(feature = "derived-snapshot")]
4987            Self::Snapshot => Command::Snapshot {
4988                action: SnapshotAction::Export,
4989                path: PathBuf::from("snapshot.tar.zst"),
4990                require_digest: None,
4991                #[cfg(feature = "derived-snapshot-signatures")]
4992                signing_key: None,
4993                #[cfg(feature = "derived-snapshot-signatures")]
4994                trusted_public_key: None,
4995            },
4996            #[cfg(feature = "optional-parser-supervisor")]
4997            Self::ParserPack => Command::ParserPack {
4998                storage_root: None,
4999                command: ParserPackCommand::Status,
5000            },
5001            Self::Root => Command::Root {
5002                command: Some(RootCommand::Show),
5003            },
5004            Self::Config => Command::Config { print: true },
5005            Self::Ignore => Command::Ignore {
5006                command: IgnoreCommand::List,
5007            },
5008            Self::WatchStatus => Command::WatchStatus,
5009            Self::Watch => Command::Watch {
5010                path: PathBuf::from("."),
5011                once: true,
5012                poll_seconds: 1,
5013                max_cycles: 1,
5014                max_workers: None,
5015                timeout_seconds: None,
5016                text_index_max_bytes: None,
5017            },
5018            Self::HealthCheck => Command::HealthCheck {
5019                report: HealthReportArgs {
5020                    start_index: 0,
5021                    limit: 1,
5022                    category: None,
5023                    severity: None,
5024                    path_prefix: None,
5025                    summary_only: true,
5026                    source_only: false,
5027                    coverage: false,
5028                    parser: None,
5029                    provider: None,
5030                    relation: None,
5031                    coverage_state: None,
5032                    reason: None,
5033                },
5034            },
5035            Self::Health => Command::Health {
5036                report: HealthReportArgs {
5037                    start_index: 0,
5038                    limit: 1,
5039                    category: None,
5040                    severity: None,
5041                    path_prefix: None,
5042                    summary_only: true,
5043                    source_only: false,
5044                    coverage: false,
5045                    parser: None,
5046                    provider: None,
5047                    relation: None,
5048                    coverage_state: None,
5049                    reason: None,
5050                },
5051                command: None,
5052            },
5053            Self::Lint => Command::Lint {
5054                strict_folders: false,
5055                purpose_level: PurposeLintLevelArg::Low,
5056                report_untracked: false,
5057                strict_untracked: false,
5058            },
5059            Self::Token => Command::Token {
5060                session: None,
5061                view: TokenView::Agent,
5062                trend: None,
5063                tokenizer: None,
5064                benchmark_results: None,
5065                theme: TokenTheme::Dark,
5066            },
5067            Self::Parity => Command::Parity {
5068                command: Some(ParityCommand::Report {
5069                    profile: REPOSITORY_INTELLIGENCE_PROFILE.to_string(),
5070                }),
5071                profile: REPOSITORY_INTELLIGENCE_PROFILE.to_string(),
5072            },
5073            Self::StripLegacyPurpose => Command::StripLegacyPurpose {
5074                path: PathBuf::from("."),
5075                apply: false,
5076                dry_run: true,
5077                strip_source_headers: false,
5078            },
5079            Self::ResetIndex => Command::ResetIndex {
5080                apply: false,
5081                dry_run: true,
5082                include_mcp_config: false,
5083            },
5084            Self::Mcp => Command::Mcp {
5085                nearest_project: false,
5086            },
5087            Self::McpConfig => Command::McpConfig {
5088                server_name: "projectatlas".to_string(),
5089                harness: HarnessConfig::McpJson,
5090                nearest_project: false,
5091            },
5092            Self::RuntimeInfo => Command::RuntimeInfo,
5093            Self::Purpose => Command::Purpose {
5094                command: PurposeCommand::Queue {
5095                    task: None,
5096                    start_index: 0,
5097                    limit: 1,
5098                    category: None,
5099                    severity: None,
5100                    path_prefix: None,
5101                    summary_only: true,
5102                    include_assets: false,
5103                    include_low_priority_files: false,
5104                },
5105            },
5106        }
5107    }
5108}
5109
5110/// `.mcp.json` compatible server configuration document.
5111#[derive(Debug, Serialize)]
5112struct McpConfigDocument {
5113    /// MCP server map keyed by server name.
5114    #[serde(rename = "mcpServers")]
5115    mcp_servers: BTreeMap<String, McpServerConfig>,
5116}
5117
5118/// MCP server launch entry.
5119#[derive(Debug, Serialize)]
5120struct McpServerConfig {
5121    /// Absolute command path for the native `projectatlas` binary.
5122    command: String,
5123    /// Global CLI arguments followed by the `mcp` subcommand.
5124    args: Vec<String>,
5125    /// Project root working directory hint for MCP hosts that support it.
5126    cwd: String,
5127}
5128
5129/// Claude Code MCP server launch entry.
5130#[derive(Debug, Serialize)]
5131struct ClaudeMcpServerConfig {
5132    /// Absolute command path for the native `projectatlas` binary.
5133    command: String,
5134    /// Global CLI arguments followed by the `mcp` subcommand.
5135    args: Vec<String>,
5136}
5137
5138/// Claude Code `.mcp.json` compatible configuration document.
5139#[derive(Debug, Serialize)]
5140struct ClaudeMcpConfigDocument {
5141    /// MCP server map keyed by server name.
5142    #[serde(rename = "mcpServers")]
5143    mcp_servers: BTreeMap<String, ClaudeMcpServerConfig>,
5144}
5145
5146/// `OpenCode` `opencode.json` compatible configuration document.
5147#[derive(Debug, Serialize)]
5148struct OpenCodeConfigDocument {
5149    /// `OpenCode` JSON schema URL.
5150    #[serde(rename = "$schema")]
5151    schema: String,
5152    /// MCP server map keyed by server name.
5153    mcp: BTreeMap<String, OpenCodeMcpServerConfig>,
5154}
5155
5156/// `OpenCode` local MCP server launch entry.
5157#[derive(Debug, Serialize)]
5158struct OpenCodeMcpServerConfig {
5159    /// `OpenCode` local MCP type discriminator.
5160    #[serde(rename = "type")]
5161    server_type: String,
5162    /// Command array: executable followed by arguments.
5163    command: Vec<String>,
5164    /// Project root working directory.
5165    cwd: String,
5166    /// Whether the server is enabled by default.
5167    enabled: bool,
5168}
5169
5170/// Stable runtime identity and capability report for installers.
5171#[derive(Debug, Serialize)]
5172struct RuntimeInfoReport {
5173    /// Product name.
5174    project: String,
5175    /// Major `ProjectAtlas` architecture version.
5176    major_version: u8,
5177    /// Cargo package version.
5178    version: String,
5179    /// Exact executable path for this runtime process, when available.
5180    executable: Option<String>,
5181    /// Repository URL embedded at build time.
5182    repository: String,
5183    /// Runtime capabilities available in this binary.
5184    capabilities: Vec<String>,
5185    /// Agent-facing payload format.
5186    text_format: String,
5187    /// Supported CLI output formats.
5188    output_formats: Vec<String>,
5189    /// Required MCP tool names compiled into the runtime.
5190    mcp_tools: Vec<String>,
5191}
5192
5193/// Bounded structural routing state for agents operating across Git worktrees.
5194#[derive(Debug, Serialize)]
5195pub(crate) struct RepositoryControlReport {
5196    /// Canonical checkout or Git common directory that owns the inventory.
5197    control_root: Option<String>,
5198    /// How the supplied path selected source.
5199    source_selection: &'static str,
5200    /// Exact source root selected without guessing, when available.
5201    #[serde(skip_serializing_if = "Option::is_none")]
5202    selected_root: Option<String>,
5203    /// Whether source operations require an explicit exact worktree root.
5204    worktree_required: bool,
5205    /// Bounded deterministic worktree inventory.
5206    worktrees: Vec<RepositoryWorktreeReport>,
5207    /// Whether additional structurally registered entries were omitted.
5208    truncated: bool,
5209    /// Closed diagnostics that prevent safe implicit selection.
5210    blockers: Vec<String>,
5211}
5212
5213/// One content-free structural worktree row.
5214#[derive(Debug, Serialize)]
5215struct RepositoryWorktreeReport {
5216    /// Primary, linked, or non-Git source role.
5217    role: &'static str,
5218    /// Active, missing, or invalid structural state.
5219    state: &'static str,
5220    /// Exact source root when active.
5221    #[serde(skip_serializing_if = "Option::is_none")]
5222    root: Option<String>,
5223    /// Git-owned administrative directory when applicable.
5224    #[serde(skip_serializing_if = "Option::is_none")]
5225    administrative_directory: Option<String>,
5226    /// Bounded structural failure when invalid.
5227    #[serde(skip_serializing_if = "Option::is_none")]
5228    blocker: Option<String>,
5229}
5230
5231/// Maximum structural worktree rows returned through CLI or MCP status.
5232const REPOSITORY_CONTROL_WORKTREE_LIMIT: usize = 256;
5233
5234/// Build one mutation-free structural repository/worktree status report.
5235pub(crate) fn build_repository_control_report(
5236    path: &Path,
5237) -> Result<RepositoryControlReport, CliError> {
5238    let structure = discover_repository_structure(path)?;
5239    match structure {
5240        RepositoryStructure::NonGit { selected_root } => Ok(RepositoryControlReport {
5241            control_root: lossless_project_root_display(&selected_root),
5242            source_selection: "exact_non_git",
5243            selected_root: lossless_project_root_display(&selected_root),
5244            worktree_required: false,
5245            worktrees: vec![RepositoryWorktreeReport {
5246                role: "non_git",
5247                state: "active",
5248                root: lossless_project_root_display(&selected_root),
5249                administrative_directory: None,
5250                blocker: None,
5251            }],
5252            truncated: false,
5253            blockers: Vec::new(),
5254        }),
5255        RepositoryStructure::InvalidGit {
5256            selected_root,
5257            issue,
5258        } => Ok(RepositoryControlReport {
5259            control_root: lossless_project_root_display(&selected_root),
5260            source_selection: "invalid_git",
5261            selected_root: None,
5262            worktree_required: true,
5263            worktrees: Vec::new(),
5264            truncated: false,
5265            blockers: vec![format!(
5266                "{:?}:{}",
5267                issue.kind,
5268                normalize_native_path_display(issue.path)
5269            )],
5270        }),
5271        RepositoryStructure::Git(repository) => {
5272            let (source_selection, selected_root, worktree_required, mut blockers) =
5273                match repository.selection {
5274                    GitRepositorySelection::Worktree { root, .. } => (
5275                        "exact_worktree",
5276                        lossless_project_root_display(&root),
5277                        false,
5278                        Vec::new(),
5279                    ),
5280                    GitRepositorySelection::CommonManager {
5281                        source_selection: GitManagerSourceSelection::Unambiguous { root },
5282                    } => (
5283                        "single_worktree",
5284                        lossless_project_root_display(&root),
5285                        false,
5286                        Vec::new(),
5287                    ),
5288                    GitRepositorySelection::CommonManager {
5289                        source_selection: GitManagerSourceSelection::None,
5290                    } => (
5291                        "worktree_unavailable",
5292                        None,
5293                        true,
5294                        vec!["active_worktree_unavailable".to_string()],
5295                    ),
5296                    GitRepositorySelection::CommonManager {
5297                        source_selection: GitManagerSourceSelection::Ambiguous { .. },
5298                    } => (
5299                        "explicit_worktree_required",
5300                        None,
5301                        true,
5302                        vec!["exact_worktree_selection_required".to_string()],
5303                    ),
5304                };
5305            let truncated = repository.worktrees.len() > REPOSITORY_CONTROL_WORKTREE_LIMIT;
5306            let worktrees = repository
5307                .worktrees
5308                .into_iter()
5309                .take(REPOSITORY_CONTROL_WORKTREE_LIMIT)
5310                .map(|entry| {
5311                    let role = match entry.role {
5312                        GitWorktreeRole::Primary => "primary",
5313                        GitWorktreeRole::Linked => "linked",
5314                    };
5315                    let administrative_directory =
5316                        lossless_native_path_display(&entry.administrative_directory);
5317                    match entry.state {
5318                        GitWorktreeState::Active { root, .. } => RepositoryWorktreeReport {
5319                            role,
5320                            state: "active",
5321                            root: lossless_project_root_display(&root),
5322                            administrative_directory,
5323                            blocker: None,
5324                        },
5325                        GitWorktreeState::Missing { git_control_path } => {
5326                            RepositoryWorktreeReport {
5327                                role,
5328                                state: "missing",
5329                                root: git_control_path
5330                                    .parent()
5331                                    .and_then(lossless_project_root_display),
5332                                administrative_directory,
5333                                blocker: None,
5334                            }
5335                        }
5336                        GitWorktreeState::Invalid { issue } => RepositoryWorktreeReport {
5337                            role,
5338                            state: "invalid",
5339                            root: None,
5340                            administrative_directory,
5341                            blocker: Some(format!(
5342                                "{:?}:{}",
5343                                issue.kind,
5344                                normalize_native_path_display(issue.path)
5345                            )),
5346                        },
5347                    }
5348                })
5349                .collect();
5350            blockers.sort();
5351            blockers.dedup();
5352            Ok(RepositoryControlReport {
5353                control_root: lossless_project_root_display(&repository.common_directory),
5354                source_selection,
5355                selected_root,
5356                worktree_required,
5357                worktrees,
5358                truncated,
5359                blockers,
5360            })
5361        }
5362    }
5363}
5364
5365/// Render structural worktree status for agent-facing text output.
5366pub(crate) fn render_repository_control_report(report: &RepositoryControlReport) -> String {
5367    encode_agent_payload(&json!({ "worktree_status": report }))
5368}
5369
5370/// Project-local root identity report.
5371#[derive(Debug, Serialize)]
5372struct RootReport {
5373    /// Canonical project root `ProjectAtlas` will use.
5374    root: Option<String>,
5375    /// Detection source for the selected root.
5376    detection_source: String,
5377    /// Durable `SQLite` database path.
5378    db_path: Option<String>,
5379    /// Config path used for project policy.
5380    config_path: Option<String>,
5381    /// Root stored in config, when config exists.
5382    config_project_root: Option<String>,
5383    /// Root stored in the DB metadata, when the DB exists.
5384    db_project_root: Option<String>,
5385    /// Generated generic MCP config path.
5386    mcp_config_path: Option<String>,
5387    /// Generated Claude Code MCP config path.
5388    claude_mcp_config_path: Option<String>,
5389    /// Generated `OpenCode` MCP config path.
5390    opencode_config_path: Option<String>,
5391    /// Current runtime executable path, when available.
5392    runtime_executable: Option<String>,
5393    /// Current runtime version.
5394    runtime_version: String,
5395    /// Durable identity of this local project instance, when initialized.
5396    project_instance_id: Option<String>,
5397    /// Explicit transition completed by this request.
5398    #[serde(skip_serializing_if = "Option::is_none")]
5399    transition: Option<RootTransition>,
5400    /// Previously recorded root for move or detach.
5401    #[serde(skip_serializing_if = "Option::is_none")]
5402    previous_root: Option<String>,
5403    /// Whether the transition created or rotated project identity.
5404    #[serde(skip_serializing_if = "Option::is_none")]
5405    identity_changed: Option<bool>,
5406    /// Whether the transition invalidated derived publication trust.
5407    #[serde(skip_serializing_if = "Option::is_none")]
5408    publication_invalidated: Option<bool>,
5409    /// Whether config and DB roots agree with the selected root.
5410    verified: bool,
5411    /// Root mismatches that must be fixed before trusting the binding.
5412    mismatches: Vec<String>,
5413}
5414
5415/// Render a search report as compact TOON.
5416fn render_search_report(report: &SearchReport) -> String {
5417    encode_agent_payload(&json!({ "search": report }))
5418}
5419
5420/// Render repository-intelligence parity as compact TOON.
5421fn render_parity_report(report: &ParityReport) -> String {
5422    encode_agent_payload(&ParityPayload { parity: report })
5423}
5424
5425/// Render a code slice as compact TOON.
5426fn render_code_slice(slice: &CodeSlice) -> String {
5427    encode_agent_payload(&json!({ "slice": slice }))
5428}
5429
5430/// Render settings as compact TOON.
5431fn render_settings_report(report: &SettingsReport) -> String {
5432    encode_agent_payload(&json!({ "settings": report }))
5433}
5434
5435/// Build an optional local tokenizer calibration over indexed UTF-8 files.
5436fn build_token_calibration(
5437    store: &AtlasStore,
5438    tokenizer: &str,
5439) -> Result<TokenCalibrationOverview, CliError> {
5440    let encoding = tiktoken::get_encoding(tokenizer).ok_or_else(|| {
5441        CliError::InvalidInput(format!(
5442            "unsupported tokenizer {tokenizer:?}; use o200k_base or cl100k_base"
5443        ))
5444    })?;
5445    let mut files = 0usize;
5446    let mut bytes = 0usize;
5447    let mut heuristic_tokens = 0usize;
5448    let mut calibrated_tokens = 0usize;
5449    store.visit_file_texts_for_search(None, false, |text| {
5450        files = files.saturating_add(1);
5451        bytes = bytes.saturating_add(text.byte_count);
5452        heuristic_tokens = heuristic_tokens.saturating_add(byte_count_to_tokens(text.byte_count));
5453        calibrated_tokens = calibrated_tokens.saturating_add(encoding.count(&text.content));
5454        Ok(true)
5455    })?;
5456    Ok(TokenCalibrationOverview {
5457        tokenizer: tokenizer.to_string(),
5458        provider: "local_tiktoken".to_string(),
5459        model: "tokenizer_calibration".to_string(),
5460        tokenizer_backend: tokenizer.to_string(),
5461        accuracy: "calibrated_local_tokenizer".to_string(),
5462        files,
5463        bytes,
5464        heuristic_tokens,
5465        calibrated_tokens,
5466        heuristic_to_calibrated_ratio: if calibrated_tokens == 0 {
5467            None
5468        } else {
5469            Some(heuristic_tokens as f64 / calibrated_tokens as f64)
5470        },
5471    })
5472}
5473
5474/// Load the tiny optional atlas preview through existing indexed relation-family reads.
5475fn load_token_atlas_preview(store: &AtlasStore) -> TokenAtlasPreview {
5476    let control = IndexWorkControl::new(
5477        projectatlas_core::IndexCancellation::new(),
5478        Some(TOKEN_ATLAS_READ_TIMEOUT),
5479    );
5480    let Some((relations, truncated)) = load_token_atlas_relations(store, &control) else {
5481        return TokenAtlasPreview::unavailable();
5482    };
5483    TokenAtlasPreview::from_relations(&relations, truncated)
5484}
5485
5486/// Load the bounded resolved-relation input owned by the optional atlas preview.
5487fn load_token_atlas_relations(
5488    store: &AtlasStore,
5489    control: &IndexWorkControl,
5490) -> Option<(Vec<LogicalRelation>, bool)> {
5491    const ADJACENCY_ROWS_PER_ROUND: usize = 512;
5492    const ADJACENCY_ROUNDS: usize = 2;
5493    const ADJACENCY_FRONTIER_MAX: usize = 128;
5494    const SEEDS_PER_RELATION_FAMILY: usize = 4;
5495    const FIRST_ROUND_ROWS_PER_SEED: usize = 16;
5496
5497    let network_relation_kinds = GraphRelationKind::ALL
5498        .into_iter()
5499        .filter(|relation| token_atlas_network_relation(*relation))
5500        .collect::<Vec<_>>();
5501    let mut relations = Vec::new();
5502    let mut adjacency_relation_kinds = Vec::new();
5503    let mut seeds = Vec::new();
5504    let mut seen = BTreeSet::new();
5505    let mut truncated = false;
5506    for &relation in &network_relation_kinds {
5507        let Ok(page) = store.repository_graph_resolved_relation_hubs(
5508            relation,
5509            u32::try_from(SEEDS_PER_RELATION_FAMILY).unwrap_or(1),
5510            Some(control),
5511        ) else {
5512            return None;
5513        };
5514        if !page.rows.is_empty() {
5515            adjacency_relation_kinds.push(relation);
5516        }
5517        truncated |= page.truncated;
5518        for seed in page.rows {
5519            if seen.insert(seed.key().digest().to_string()) {
5520                seeds.push(seed.key().clone());
5521            }
5522        }
5523    }
5524    let mut frontier = seeds;
5525    for round in 0..ADJACENCY_ROUNDS {
5526        if frontier.is_empty() {
5527            break;
5528        }
5529        let mut next = BTreeMap::new();
5530        for direction in [
5531            RepositoryGraphDirection::Outbound,
5532            RepositoryGraphDirection::Inbound,
5533        ] {
5534            let mut remaining_rows = ADJACENCY_ROWS_PER_ROUND;
5535            for (index, &relation_kind) in adjacency_relation_kinds.iter().enumerate() {
5536                if remaining_rows == 0 {
5537                    truncated = true;
5538                    break;
5539                }
5540                let remaining_families = adjacency_relation_kinds.len() - index;
5541                let family_limit = remaining_rows.div_ceil(remaining_families);
5542                let frontier_batches: Vec<_> = frontier.iter().map(std::slice::from_ref).collect();
5543                let mut family_rows = family_limit;
5544                let mut remaining_batches = frontier_batches.len();
5545                for batch in frontier_batches {
5546                    if family_rows == 0 {
5547                        truncated = true;
5548                        break;
5549                    }
5550                    let mut batch_limit = family_rows.div_ceil(remaining_batches);
5551                    if round == 0 {
5552                        batch_limit = batch_limit.min(FIRST_ROUND_ROWS_PER_SEED);
5553                    }
5554                    let Ok(page) = store.repository_graph_resolved_adjacency_page(
5555                        batch,
5556                        direction,
5557                        relation_kind,
5558                        None,
5559                        u32::try_from(batch_limit).unwrap_or(1),
5560                        Some(control),
5561                    ) else {
5562                        return None;
5563                    };
5564                    remaining_batches -= 1;
5565                    truncated |= page.truncated;
5566                    family_rows = family_rows.saturating_sub(page.rows.len());
5567                    remaining_rows = remaining_rows.saturating_sub(page.rows.len());
5568                    for row in page.rows {
5569                        let relation = row.detail.relation;
5570                        if let Some(target) = relation.resolution().resolved_target() {
5571                            for endpoint in [relation.source(), target] {
5572                                if seen.insert(endpoint.digest().to_string()) {
5573                                    next.insert(endpoint.digest().to_string(), endpoint.clone());
5574                                }
5575                            }
5576                        }
5577                        relations.push(relation);
5578                    }
5579                }
5580            }
5581        }
5582        frontier = next.into_values().take(ADJACENCY_FRONTIER_MAX).collect();
5583    }
5584    Some((relations, truncated))
5585}
5586
5587/// Render root diagnostics as compact TOON.
5588fn render_root_report(report: &RootReport) -> String {
5589    encode_agent_payload(&json!({ "root": report }))
5590}
5591
5592/// Render watcher status as compact TOON.
5593fn render_watch_status(report: &WatchStatusReport) -> String {
5594    encode_agent_payload(&json!({ "watch_status": report }))
5595}
5596
5597/// Build the current repository-intelligence parity report.
5598fn build_parity_report(store: &AtlasStore, profile: &str) -> Result<ParityReport, CliError> {
5599    if profile != REPOSITORY_INTELLIGENCE_PROFILE {
5600        return Err(CliError::InvalidInput(format!(
5601            "unsupported parity profile {profile:?}"
5602        )));
5603    }
5604    let overview = store.overview()?;
5605    let file_count = overview.files;
5606    let indexed_text_files = store.file_text_count()?;
5607    let indexed_text_bytes = store.file_text_byte_count()?;
5608    let symbols = store.symbol_count()?;
5609    let relations = store.symbol_relation_count()?;
5610    let health_findings = store.unresolved_health_finding_count_current()?;
5611    let token_calls = store.token_overview(None)?.calls;
5612    let watcher_status = watcher_status_report(false);
5613    let watcher_mode = watcher_status.mode.clone();
5614
5615    let mut checks = Vec::new();
5616    push_check(
5617        &mut checks,
5618        "profile-supported",
5619        true,
5620        "repository-intelligence profile is implemented",
5621    );
5622    push_check(
5623        &mut checks,
5624        "project-root",
5625        store.project_root_identity()?.is_some(),
5626        "database records the authoritative native project root",
5627    );
5628    push_check(
5629        &mut checks,
5630        "structure-index",
5631        overview.files > 0 || overview.folders > 0,
5632        &format!(
5633            "{} files and {} folders indexed",
5634            overview.files, overview.folders
5635        ),
5636    );
5637    push_check(
5638        &mut checks,
5639        "purpose-health-surface",
5640        true,
5641        &format!(
5642            "{} missing, {} suggested, {} stale purposes visible through health and purpose queue",
5643            overview.missing_purposes, overview.suggested_purposes, overview.stale_purposes
5644        ),
5645    );
5646    push_check(
5647        &mut checks,
5648        "text-index",
5649        file_count == 0 || indexed_text_files > 0,
5650        &format!("{indexed_text_files}/{file_count} files have persisted UTF-8 search text"),
5651    );
5652    push_check(
5653        &mut checks,
5654        "symbol-index",
5655        file_count == 0 || symbols > 0,
5656        &format!("{symbols} symbols and {relations} relations persisted"),
5657    );
5658    push_check(
5659        &mut checks,
5660        "watcher-refresh",
5661        watcher_status.available,
5662        &format!(
5663            "watch-status probe reports mode {watcher_mode} and event backend available={}",
5664            watcher_status.event_backend_available
5665        ),
5666    );
5667    push_check(
5668        &mut checks,
5669        "health-surface",
5670        true,
5671        &format!("{health_findings} unresolved health findings currently visible"),
5672    );
5673    push_check(
5674        &mut checks,
5675        "token-telemetry",
5676        true,
5677        &format!("{token_calls} token telemetry events recorded"),
5678    );
5679    push_check(
5680        &mut checks,
5681        "cli-surface",
5682        required_cli_surface_present(),
5683        "required CLI command families are constructible from compiled command variants",
5684    );
5685    push_check(
5686        &mut checks,
5687        "mcp-surface",
5688        mcp::required_mcp_surface_present(),
5689        "required atlas_* tools are present in the generated RMCP route table",
5690    );
5691    let ok = checks
5692        .iter()
5693        .all(|check| check.status == ParityCheckStatus::Pass);
5694    Ok(ParityReport {
5695        profile: profile.to_string(),
5696        ok,
5697        overview,
5698        indexed_text_files,
5699        indexed_text_bytes,
5700        symbols,
5701        relations,
5702        health_findings,
5703        token_calls,
5704        watcher_mode,
5705        checks,
5706    })
5707}
5708
5709/// Append one parity check.
5710fn push_check(checks: &mut Vec<ParityCheck>, name: &str, passed: bool, detail: &str) {
5711    checks.push(ParityCheck {
5712        name: name.to_string(),
5713        status: ParityCheckStatus::from_passed(passed),
5714        detail: detail.to_string(),
5715    });
5716}
5717
5718/// Return whether the compiled CLI surface contains required command families.
5719fn required_cli_surface_present() -> bool {
5720    !REQUIRED_CLI_COMMANDS.is_empty()
5721        && REQUIRED_CLI_COMMANDS
5722            .iter()
5723            .all(|command| cli_command_name(&command.command()) == command.name())
5724}
5725
5726/// Return the stable CLI name for a parsed command variant.
5727fn cli_command_name(command: &Command) -> &'static str {
5728    match command {
5729        Command::Init { .. } => "init",
5730        Command::Map { .. } => "map",
5731        Command::Scan { .. } => "scan",
5732        Command::Overview => "overview",
5733        Command::Folders { .. } => "folders",
5734        Command::Files { .. } => "files",
5735        Command::Next { .. } => "next",
5736        Command::Outline { .. } => "outline",
5737        Command::Summary { .. } => "summary",
5738        Command::Search { .. } => "search",
5739        Command::Slice { .. } => "slice",
5740        Command::Symbols { .. } => "symbols",
5741        Command::Settings => "settings",
5742        #[cfg(feature = "derived-snapshot")]
5743        Command::Snapshot { .. } => "snapshot",
5744        #[cfg(feature = "optional-parser-supervisor")]
5745        Command::ParserPack { .. } => "parser-pack",
5746        Command::Root { .. } => "root",
5747        Command::Config { .. } => "config",
5748        Command::Ignore { .. } => "ignore",
5749        Command::WatchStatus => "watch-status",
5750        Command::Watch { .. } => "watch",
5751        Command::HealthCheck { .. } => "health-check",
5752        Command::Health { .. } => "health",
5753        Command::Lint { .. } => "lint",
5754        Command::Token { .. } => "token",
5755        Command::Parity { .. } => "parity",
5756        Command::StripLegacyPurpose { .. } => "strip-legacy-purpose",
5757        Command::ResetIndex { .. } => "reset-index",
5758        Command::Mcp { .. } => "mcp",
5759        Command::McpConfig { .. } => "mcp-config",
5760        Command::RuntimeInfo => "runtime-info",
5761        #[cfg(unix)]
5762        Command::AcquireInstallerLock { .. } => "acquire-installer-lock",
5763        Command::Purpose { .. } => "purpose",
5764    }
5765}
5766
5767/// Acquire one inherited installer file lock without reopening its authority path.
5768#[cfg(unix)]
5769fn acquire_installer_lock(
5770    file: &fs::File,
5771    expected_device: u64,
5772    expected_inode: u64,
5773    timeout: Duration,
5774) -> io::Result<()> {
5775    let metadata = file.metadata()?;
5776    if !metadata.is_file() {
5777        return Err(io::Error::new(
5778            io::ErrorKind::InvalidInput,
5779            "inherited installer lock descriptor is not a regular file",
5780        ));
5781    }
5782    if metadata.dev() != expected_device || metadata.ino() != expected_inode {
5783        return Err(io::Error::new(
5784            io::ErrorKind::InvalidData,
5785            "inherited installer lock descriptor identity changed",
5786        ));
5787    }
5788    let started = Instant::now();
5789    let deadline = started.checked_add(timeout).unwrap_or(started);
5790    loop {
5791        match file.try_lock() {
5792            Ok(()) => return Ok(()),
5793            Err(fs::TryLockError::WouldBlock) => {
5794                let now = Instant::now();
5795                if now >= deadline {
5796                    return Err(io::Error::new(
5797                        io::ErrorKind::TimedOut,
5798                        "another installer owns the update lock",
5799                    ));
5800                }
5801                std::thread::sleep(
5802                    INSTALLER_LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)),
5803                );
5804            }
5805            Err(fs::TryLockError::Error(source)) => return Err(source),
5806        }
5807    }
5808}
5809
5810/// Render a deterministic file summary as compact TOON.
5811fn render_file_summary(report: &FileSummaryReport) -> String {
5812    encode_agent_payload(&json!({ "file_summary": report }))
5813}
5814
5815/// Write and flush text to stdout without using print macros.
5816fn write_stdout(text: &str) -> Result<(), CliError> {
5817    let mut stdout = io::stdout().lock();
5818    stdout.write_all(text.as_bytes())?;
5819    stdout.flush()?;
5820    Ok(())
5821}
5822
5823/// Write text to stderr without using print macros.
5824fn write_stderr(text: &str) -> Result<(), CliError> {
5825    io::stderr().write_all(text.as_bytes())?;
5826    Ok(())
5827}
5828
5829#[cfg(test)]
5830mod tests {
5831    use super::mcp::{
5832        ProjectAtlasMcpServer, REQUIRED_MCP_TOOL_NAMES, mcp_tool_route_present,
5833        required_mcp_surface_present,
5834    };
5835    #[cfg(unix)]
5836    use super::runtime::{
5837        IndexProjectMismatch, IndexReadStatus, IndexRefreshReason, IndexRefreshRequired,
5838        IndexRefreshScope, lossless_project_root_display,
5839    };
5840    use super::runtime::{
5841        TextIndexOptions, byte_count_to_tokens, estimated_source_tokens_for_file_node,
5842        event_kind_affects_index, is_symbol_candidate, primary_symbol_names,
5843        refresh_structural_summaries_for_nodes, refresh_text_index_for_nodes,
5844        refresh_text_index_for_nodes_with_rows, relation_targets, reset_index_files,
5845        suggest_file_purpose, summarize_symbol_graph, watch_path_affects_index,
5846        watch_path_requires_full_scan, watcher_status_report,
5847    };
5848    use super::{
5849        Cli, CliError, Command, DEFAULT_HEALTH_LIMIT, GraphRelationKind, HealthCommand,
5850        OutputFormat, SCHEMA_MIGRATION_REQUIRED_RECOVERY, SCHEMA_VERSION_MISMATCH_RECOVERY,
5851        SearchRetrievalMode, SearchRetrievalModeArg, ServiceError, build_runtime_info,
5852        controlled_named_output, load_token_atlas_preview, load_token_atlas_relations,
5853        render_cli_error, render_token_dashboard, render_token_dashboard_with_atlas_at_width,
5854        schema_migration_required_payload, schema_version_mismatch_payload, serialized_output,
5855        token_atlas_network_relation, truthy_env,
5856    };
5857    #[cfg(feature = "optional-parser-supervisor")]
5858    use super::{OptionalParserPackLifecycleError, ParserPackCommand};
5859    #[cfg(unix)]
5860    use super::{RootTransition, bind_project_root, build_repository_control_report};
5861    use clap::Parser as _;
5862    use notify::EventKind;
5863    #[cfg(unix)]
5864    use projectatlas_core::CanonicalProjectRoot;
5865    use projectatlas_core::graph::{
5866        Completeness, ConfidenceClass, EntitySelector, GraphEntity, GraphIdentityText,
5867        LogicalRelation, RelationResolution, RepositoryFilePath,
5868    };
5869    use projectatlas_core::symbols::{
5870        CodeSymbol, ParserKind, RelationKind, SymbolGraph, SymbolKind, SymbolRelation,
5871    };
5872    use projectatlas_core::telemetry::TokenOverview;
5873    use projectatlas_core::{
5874        IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
5875        Node, NodeKind, normalize_native_path_display,
5876    };
5877    use projectatlas_db::{AtlasStore, DbError, RepositoryGraphRelationQuery};
5878    use projectatlas_fs::ScanOptions;
5879    use rmcp::model::{CallToolRequestParams, ClientConfig};
5880    use rmcp::{ClientHandler, ServiceExt};
5881    use serde_json::{Map, Value, json};
5882    use std::collections::BTreeMap;
5883    use std::error::Error;
5884    #[cfg(unix)]
5885    use std::ffi::OsString;
5886    use std::fs;
5887    use std::io;
5888    #[cfg(unix)]
5889    use std::os::unix::ffi::OsStringExt;
5890    #[cfg(unix)]
5891    use std::os::unix::fs::MetadataExt;
5892    use std::path::{Path, PathBuf};
5893
5894    /// Minimal MCP client handler for in-process routing tests.
5895    #[derive(Clone, Default)]
5896    struct TestMcpClient;
5897
5898    impl ClientHandler for TestMcpClient {
5899        fn get_info(&self) -> ClientConfig {
5900            ClientConfig::default()
5901        }
5902    }
5903
5904    #[cfg(unix)]
5905    fn create_directory_symlink(target: &Path, link: &Path) -> io::Result<()> {
5906        std::os::unix::fs::symlink(target, link)
5907    }
5908
5909    #[cfg(windows)]
5910    fn create_directory_symlink(target: &Path, link: &Path) -> io::Result<()> {
5911        match std::os::windows::fs::symlink_dir(target, link) {
5912            Ok(()) => Ok(()),
5913            Err(source) if source.raw_os_error() == Some(1314) => {
5914                let status = std::process::Command::new("cmd")
5915                    .arg("/C")
5916                    .arg("mklink")
5917                    .arg("/J")
5918                    .arg(link)
5919                    .arg(target)
5920                    .status()?;
5921                if status.success() {
5922                    Ok(())
5923                } else {
5924                    Err(source)
5925                }
5926            }
5927            Err(source) => Err(source),
5928        }
5929    }
5930
5931    fn require_selected_project_audit(
5932        text: &str,
5933        root: &Path,
5934        db: &Path,
5935        context: &str,
5936    ) -> Result<(), Box<dyn Error>> {
5937        let root_display = normalize_native_path_display(root.canonicalize()?);
5938        let db_display = normalize_native_path_display(db);
5939        if text.contains("selected_project:")
5940            && text.contains(&root_display)
5941            && text.contains(&db_display)
5942        {
5943            return Ok(());
5944        }
5945        Err(io::Error::other(format!(
5946            "{context} missing selected project audit root/db: {text}"
5947        ))
5948        .into())
5949    }
5950
5951    #[cfg(unix)]
5952    #[test]
5953    fn parity_uses_native_identity_for_non_utf8_root() -> Result<(), Box<dyn Error>> {
5954        let temp = tempfile::tempdir()?;
5955        let root = temp
5956            .path()
5957            .join(OsString::from_vec(b"parity-root-\x80".to_vec()));
5958        let database = root.join(".projectatlas/projectatlas.db");
5959        fs::create_dir_all(
5960            database
5961                .parent()
5962                .ok_or_else(|| io::Error::other("native parity database has no parent"))?,
5963        )?;
5964        let store = AtlasStore::open_for_project(&database, &root)?;
5965        require_condition(
5966            store.project_root()?.is_none() && store.project_root_identity()?.is_some(),
5967            "parity fixture did not create a native-only root binding",
5968        )?;
5969
5970        let report = super::build_parity_report(&store, super::REPOSITORY_INTELLIGENCE_PROFILE)?;
5971        let root_check = report
5972            .checks
5973            .iter()
5974            .find(|check| check.name == "project-root")
5975            .ok_or_else(|| io::Error::other("parity report omitted project-root check"))?;
5976        require_condition(
5977            root_check.status == super::ParityCheckStatus::Pass,
5978            "parity report rejected a valid native-only root binding",
5979        )?;
5980        Ok(())
5981    }
5982
5983    #[test]
5984    fn summarizes_symbol_graph_from_observed_symbols_and_imports() {
5985        let graph = SymbolGraph {
5986            path: "src/service.rs".to_string(),
5987            language: Some("rust".to_string()),
5988            parser: ParserKind::TreeSitter,
5989            symbols: vec![
5990                test_symbol("src/service.rs", SymbolKind::Struct, "Service"),
5991                test_symbol("src/service.rs", SymbolKind::Method, "run"),
5992            ],
5993            relations: vec![test_relation(
5994                "src/service.rs",
5995                RelationKind::Imports,
5996                "std::path::Path",
5997            )],
5998        };
5999
6000        assert_eq!(
6001            summarize_symbol_graph(&graph, Some("rust file, 10 bytes")),
6002            "rust source defining type and function Service, run with imports std::path::Path."
6003        );
6004    }
6005
6006    #[test]
6007    fn summarizes_manifest_graph_from_dependencies() {
6008        let graph = SymbolGraph {
6009            path: "Cargo.toml".to_string(),
6010            language: Some("cargo-manifest".to_string()),
6011            parser: ParserKind::Manifest,
6012            symbols: vec![
6013                test_symbol("Cargo.toml", SymbolKind::Package, "projectatlas"),
6014                test_symbol("Cargo.toml", SymbolKind::Dependency, "serde"),
6015                test_symbol("Cargo.toml", SymbolKind::Dependency, "rmcp"),
6016            ],
6017            relations: vec![
6018                test_relation("Cargo.toml", RelationKind::DependsOn, "rmcp"),
6019                test_relation("Cargo.toml", RelationKind::DependsOn, "serde"),
6020            ],
6021        };
6022
6023        assert_eq!(
6024            summarize_symbol_graph(&graph, None),
6025            "cargo manifest declaring projectatlas and depending on rmcp, serde."
6026        );
6027    }
6028
6029    #[test]
6030    fn summarizes_empty_graph_from_fallback_without_approving_intent() {
6031        let graph = SymbolGraph {
6032            path: "src/empty.rs".to_string(),
6033            language: Some("rust".to_string()),
6034            parser: ParserKind::TreeSitter,
6035            symbols: Vec::new(),
6036            relations: Vec::new(),
6037        };
6038
6039        assert_eq!(
6040            summarize_symbol_graph(&graph, Some("rust file, 0 bytes")),
6041            "rust source file with no declarations found."
6042        );
6043        assert_eq!(
6044            suggest_file_purpose(
6045                "src/empty.rs",
6046                "rust source file with no declarations found."
6047            ),
6048            "Implement the empty source."
6049        );
6050        assert_eq!(
6051            suggest_file_purpose(
6052                "src/customers/service.rs",
6053                "rust source defining type and function CustomerService, boot."
6054            ),
6055            "Implement the customers service source around CustomerService and boot."
6056        );
6057        assert_eq!(
6058            suggest_file_purpose(
6059                "build.gradle.kts",
6060                "kotlin source defining functions bootRunE2E, copyE2EReports, verifyAtlas."
6061            ),
6062            "Define Gradle build tasks around bootRunE2E, copyE2EReports, and verifyAtlas."
6063        );
6064        assert_eq!(
6065            suggest_file_purpose(
6066                "src/auth/session.test.ts",
6067                "typescript source defining functions createsSession, rejectsExpiredSession."
6068            ),
6069            "Implement the auth session test source around createsSession and rejectsExpiredSession."
6070        );
6071    }
6072
6073    #[test]
6074    fn summarizes_vue_composition_bindings_without_functions() {
6075        let graph = SymbolGraph {
6076            path: "src/ProductPanel.vue".to_string(),
6077            language: Some("vue".to_string()),
6078            parser: ParserKind::Structural,
6079            symbols: vec![
6080                test_symbol("src/ProductPanel.vue", SymbolKind::Value, "props"),
6081                test_symbol("src/ProductPanel.vue", SymbolKind::Value, "emit"),
6082                test_symbol(
6083                    "src/ProductPanel.vue",
6084                    SymbolKind::Value,
6085                    "currentPriceLabel",
6086                ),
6087            ],
6088            relations: vec![test_relation(
6089                "src/ProductPanel.vue",
6090                RelationKind::Imports,
6091                "import { computed, ref } from \"vue\";",
6092            )],
6093        };
6094
6095        assert_eq!(
6096            summarize_symbol_graph(&graph, Some("vue file, 9990 bytes")),
6097            "vue source defining bindings currentPriceLabel, emit, props with imports import { computed, ref } from \"vue\";."
6098        );
6099    }
6100
6101    #[test]
6102    fn summarizes_value_only_non_javascript_files_as_values() {
6103        let graph = SymbolGraph {
6104            path: "src/constants.rs".to_string(),
6105            language: Some("rust".to_string()),
6106            parser: ParserKind::TreeSitter,
6107            symbols: vec![
6108                test_symbol("src/constants.rs", SymbolKind::Value, "CACHE_LIMIT"),
6109                test_symbol("src/constants.rs", SymbolKind::Value, "DEFAULT_TIMEOUT"),
6110            ],
6111            relations: Vec::new(),
6112        };
6113
6114        assert_eq!(
6115            summarize_symbol_graph(&graph, None),
6116            "rust source defining values CACHE_LIMIT, DEFAULT_TIMEOUT."
6117        );
6118    }
6119
6120    #[test]
6121    fn symbol_candidate_policy_admits_owned_formats_only() {
6122        assert!(is_symbol_candidate("Cargo.toml", Some("cargo-manifest")));
6123        assert!(is_symbol_candidate("src/lib.rs", Some("rust")));
6124        assert!(!is_symbol_candidate(
6125            "fixtures/baselines.toon",
6126            Some("toon")
6127        ));
6128        assert!(is_symbol_candidate("README.md", Some("markdown")));
6129    }
6130
6131    #[test]
6132    fn summarizes_functions_before_javascript_constants_when_both_exist() {
6133        let graph = SymbolGraph {
6134            path: "scripts/generate.mjs".to_string(),
6135            language: Some("javascript".to_string()),
6136            parser: ParserKind::TreeSitter,
6137            symbols: vec![
6138                test_symbol("scripts/generate.mjs", SymbolKind::Value, "DATA_DIRECTORY"),
6139                test_symbol("scripts/generate.mjs", SymbolKind::Value, "OUTPUT_FILE"),
6140                test_symbol("scripts/generate.mjs", SymbolKind::Function, "sha256"),
6141                test_symbol(
6142                    "scripts/generate.mjs",
6143                    SymbolKind::Function,
6144                    "readDatasetEntry",
6145                ),
6146                test_symbol("scripts/generate.mjs", SymbolKind::Function, "main"),
6147            ],
6148            relations: vec![test_relation(
6149                "scripts/generate.mjs",
6150                RelationKind::Imports,
6151                "import path from \"node:path\";",
6152            )],
6153        };
6154
6155        assert_eq!(
6156            summarize_symbol_graph(&graph, None),
6157            "javascript source defining functions main, readDatasetEntry, sha256 with imports import path from \"node:path\";."
6158        );
6159    }
6160
6161    #[test]
6162    fn watcher_filters_relevant_index_events() -> Result<(), Box<dyn Error>> {
6163        let temp = tempfile::tempdir()?;
6164        let root = temp.path();
6165        let scan_options = ScanOptions {
6166            exclude_dir_names: vec![
6167                ".git".to_string(),
6168                ".projectatlas".to_string(),
6169                "target".to_string(),
6170                "generated".to_string(),
6171            ],
6172            exclude_dir_suffixes: Vec::new(),
6173            exclude_path_prefixes: vec!["docs/api".to_string()],
6174            language_overrides: BTreeMap::new(),
6175            admit_optional_languages: false,
6176        };
6177        require_condition(
6178            watch_path_affects_index(root, &root.join("src/lib.rs"), &scan_options),
6179            "source file event should refresh the index",
6180        )?;
6181        require_condition(
6182            !watch_path_affects_index(root, &root.join("../outside.rs"), &scan_options),
6183            "absolute parent traversal events should be ignored",
6184        )?;
6185        require_condition(
6186            !watch_path_affects_index(root, Path::new("../outside.rs"), &scan_options),
6187            "relative parent traversal events should be ignored",
6188        )?;
6189        require_condition(
6190            !watch_path_requires_full_scan(root, &root.join("src/lib.rs")),
6191            "source file event should use incremental refresh",
6192        )?;
6193        fs::create_dir(root.join("src"))?;
6194        require_condition(
6195            watch_path_requires_full_scan(root, &root.join("src")),
6196            "directory event should use full refresh",
6197        )?;
6198        require_condition(
6199            watch_path_requires_full_scan(root, &root.join(".gitignore")),
6200            "gitignore event should use full refresh",
6201        )?;
6202        require_condition(
6203            watch_path_affects_index(root, &root.join(".gitignore"), &scan_options),
6204            "gitignore event should refresh scanner rules",
6205        )?;
6206        fs::create_dir(root.join("local-state"))?;
6207        fs::write(root.join("local-state/cache.md"), "ignored local cache\n")?;
6208        fs::write(root.join(".gitignore"), "local-state/\n")?;
6209        require_condition(
6210            !watch_path_affects_index(root, &root.join("local-state/cache.md"), &scan_options),
6211            "gitignore-ignored local state events should be ignored",
6212        )?;
6213        require_condition(
6214            !watch_path_affects_index(
6215                root,
6216                &root.join(".projectatlas/projectatlas.db"),
6217                &scan_options,
6218            ),
6219            "ProjectAtlas database events should be ignored",
6220        )?;
6221        require_condition(
6222            !watch_path_affects_index(root, &root.join("target/debug/projectatlas"), &scan_options),
6223            "target directory events should be ignored",
6224        )?;
6225        require_condition(
6226            !watch_path_affects_index(root, &root.join("src/.purpose"), &scan_options),
6227            "legacy .purpose metadata events should be ignored",
6228        )?;
6229        require_condition(
6230            !watch_path_affects_index(root, &root.join("generated/out.rs"), &scan_options),
6231            "configured exclude directory events should be ignored",
6232        )?;
6233        require_condition(
6234            !watch_path_affects_index(root, &root.join("docs/api/noise.rs"), &scan_options),
6235            "configured exclude path-prefix events should be ignored",
6236        )?;
6237        require_condition(
6238            watch_path_affects_index(root, &root.join("src/api/live.rs"), &scan_options),
6239            "same directory name outside excluded prefix should be indexed",
6240        )?;
6241        require_condition(
6242            !event_kind_affects_index(EventKind::Access(notify::event::AccessKind::Any)),
6243            "access-only events should not refresh the index",
6244        )?;
6245        require_condition(
6246            event_kind_affects_index(EventKind::Modify(notify::event::ModifyKind::Any)),
6247            "modify events should refresh the index",
6248        )?;
6249        Ok(())
6250    }
6251
6252    /// Return an error instead of panicking when a test condition fails.
6253    fn require_condition(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
6254        if condition {
6255            Ok(())
6256        } else {
6257            Err(io::Error::other(message.to_string()).into())
6258        }
6259    }
6260
6261    #[test]
6262    fn cli_map_response_limit_is_typed_in_json_and_toon() -> Result<(), Box<dyn Error>> {
6263        let error = CliError::AtlasMap(super::atlas_map::AtlasMapError::ResponseLimitExceeded {
6264            observed: 5,
6265            limit: 4,
6266        });
6267        for format in [OutputFormat::Json, OutputFormat::Toon] {
6268            let rendered = render_cli_error(format, &error)?;
6269            let payload: Value = if format == OutputFormat::Json {
6270                serde_json::from_str(&rendered)?
6271            } else {
6272                toon_format::decode_default(&rendered)?
6273            };
6274            require_condition(
6275                payload["error"]["kind"] == "response_limit_exceeded"
6276                    && payload["error"]["message"]
6277                        .as_str()
6278                        .is_some_and(|message| message.contains("observed 5, limit 4")),
6279                "CLI map response limit lost its typed error or exact byte counts",
6280            )?;
6281        }
6282        Ok(())
6283    }
6284
6285    #[test]
6286    fn cli_database_filesystem_failures_are_typed_in_json_and_toon() -> Result<(), Box<dyn Error>> {
6287        let database = PathBuf::from("project")
6288            .join(".projectatlas")
6289            .join("projectatlas.db");
6290        let error = CliError::Db(DbError::DatabaseFilesystemUncertain {
6291            path: database,
6292            mount_point: None,
6293            filesystem_type: Some("unknown-local".to_string()),
6294            reason: "filesystem type is not in the supported local profile".to_string(),
6295        });
6296
6297        let json_text = render_cli_error(OutputFormat::Json, &error)?;
6298        let json: Value = serde_json::from_str(&json_text)?;
6299        require_condition(
6300            json.pointer("/error/kind").and_then(Value::as_str)
6301                == Some("database_filesystem_uncertain"),
6302            "CLI JSON lost the typed filesystem error kind",
6303        )?;
6304        require_condition(
6305            json.pointer("/error/database_filesystem/path")
6306                .and_then(Value::as_str)
6307                .is_some_and(|path| path.ends_with("projectatlas.db")),
6308            "CLI JSON lost the rejected database path",
6309        )?;
6310        require_condition(
6311            json.pointer("/error/database_filesystem/recovery")
6312                .and_then(Value::as_str)
6313                .is_some_and(|recovery| recovery.contains("supported local filesystem")),
6314            "CLI JSON lost database recovery guidance",
6315        )?;
6316
6317        let toon = render_cli_error(OutputFormat::Toon, &error)?;
6318        require_condition(
6319            toon.contains("database_filesystem_uncertain")
6320                && toon.contains("unknown-local")
6321                && toon.contains("supported local filesystem"),
6322            "CLI TOON lost typed filesystem details",
6323        )?;
6324        Ok(())
6325    }
6326
6327    #[test]
6328    fn cli_project_mismatch_preserves_lossless_store_roots() -> Result<(), Box<dyn Error>> {
6329        let temp = tempfile::tempdir()?;
6330        let selected_root = temp.path().join("selected-root");
6331        let indexed_root = temp.path().join("indexed-root");
6332        fs::create_dir_all(&selected_root)?;
6333        fs::create_dir_all(&indexed_root)?;
6334        let database = indexed_root.join(".projectatlas").join("projectatlas.db");
6335        fs::create_dir_all(
6336            database
6337                .parent()
6338                .ok_or_else(|| io::Error::other("indexed database path has no parent"))?,
6339        )?;
6340        drop(AtlasStore::open_for_project(&database, &indexed_root)?);
6341
6342        let Err(error) = super::runtime::open_atlas_store_for_project(&database, &selected_root)
6343        else {
6344            return Err(io::Error::other("wrong-root store open unexpectedly succeeded").into());
6345        };
6346        let selected_display =
6347            projectatlas_core::CanonicalProjectRoot::from_path(&selected_root)?.display_string()?;
6348        let indexed_display =
6349            projectatlas_core::CanonicalProjectRoot::from_path(&indexed_root)?.display_string()?;
6350
6351        let json: Value = serde_json::from_str(&render_cli_error(OutputFormat::Json, &error)?)?;
6352        require_condition(
6353            json.pointer("/error/project_mismatch/selected_project_root")
6354                == Some(&Value::String(selected_display.clone()))
6355                && json.pointer("/error/project_mismatch/indexed_project_root")
6356                    == Some(&Value::String(indexed_display.clone())),
6357            "CLI JSON omitted lossless roots from a store mismatch",
6358        )?;
6359        let toon = render_cli_error(OutputFormat::Toon, &error)?;
6360        let toon_value: Value = toon_format::decode_default(&toon)?;
6361        require_condition(
6362            toon_value.pointer("/error/project_mismatch/selected_project_root")
6363                == Some(&Value::String(selected_display))
6364                && toon_value.pointer("/error/project_mismatch/indexed_project_root")
6365                    == Some(&Value::String(indexed_display)),
6366            "CLI TOON omitted lossless roots from a store mismatch",
6367        )?;
6368        Ok(())
6369    }
6370
6371    #[cfg(unix)]
6372    #[test]
6373    fn cli_project_mismatch_keeps_native_display_unavailable_typed() -> Result<(), Box<dyn Error>> {
6374        let temp = tempfile::tempdir()?;
6375        let raw_root = temp
6376            .path()
6377            .join(OsString::from_vec(b"raw-root-\x80".to_vec()));
6378        let replacement_root = temp.path().join("raw-root-�");
6379        fs::create_dir(&raw_root)?;
6380        fs::create_dir(&replacement_root)?;
6381        let raw_identity = CanonicalProjectRoot::from_path(&raw_root)?;
6382        let replacement_identity = CanonicalProjectRoot::from_path(&replacement_root)?;
6383        let replacement_display = replacement_identity.display_string()?;
6384        let error = CliError::ProjectMismatch(Box::new(IndexProjectMismatch::from_native_roots(
6385            &raw_identity,
6386            &replacement_identity,
6387        )));
6388
6389        let json_text = render_cli_error(OutputFormat::Json, &error)?;
6390        let json: Value = serde_json::from_str(&json_text)?;
6391        require_condition(
6392            json.pointer("/error/project_mismatch/selected_project_root") == Some(&Value::Null)
6393                && json
6394                    .pointer("/error/project_mismatch/indexed_project_root")
6395                    .and_then(Value::as_str)
6396                    == Some(replacement_display.as_str()),
6397            "CLI JSON collapsed native-unavailable and replacement-character roots",
6398        )?;
6399
6400        let toon = render_cli_error(OutputFormat::Toon, &error)?;
6401        require_condition(
6402            toon.contains("selected_project_root: null")
6403                && toon.contains("indexed_project_root: ")
6404                && toon.contains("raw-root-�"),
6405            "CLI TOON lost the typed unavailable root projection",
6406        )?;
6407
6408        let mapped = super::runtime::project_store_error(DbError::ProjectRootMismatch {
6409            expected: raw_root.to_string_lossy().into_owned(),
6410            found: replacement_root.to_string_lossy().into_owned(),
6411            identities: None,
6412        });
6413        let mapped_json: Value =
6414            serde_json::from_str(&render_cli_error(OutputFormat::Json, &mapped)?)?;
6415        require_condition(
6416            mapped_json.pointer("/error/project_mismatch/selected_project_root")
6417                == Some(&Value::Null)
6418                && mapped_json.pointer("/error/project_mismatch/indexed_project_root")
6419                    == Some(&Value::Null)
6420                && mapped_json
6421                    .pointer("/error/message")
6422                    .and_then(Value::as_str)
6423                    .is_some_and(|message| message.contains("does not match")),
6424            "CLI promoted lossy store mismatch text into structured roots",
6425        )?;
6426        Ok(())
6427    }
6428
6429    #[cfg(unix)]
6430    #[test]
6431    fn cli_recovery_reports_omit_unavailable_native_root_selectors() -> Result<(), Box<dyn Error>> {
6432        let temp = tempfile::tempdir()?;
6433        let raw_root = temp.path().join(OsString::from_vec(b"repo-\x80".to_vec()));
6434        let replacement_root = temp.path().join("repo-�");
6435        fs::create_dir_all(raw_root.join(".projectatlas"))?;
6436        fs::create_dir_all(replacement_root.join(".projectatlas"))?;
6437        let raw_db = raw_root.join(".projectatlas").join("projectatlas.db");
6438        let replacement_db = replacement_root
6439            .join(".projectatlas")
6440            .join("projectatlas.db");
6441        let replacement_display = lossless_project_root_display(&replacement_root)
6442            .ok_or_else(|| io::Error::other("replacement root lost its UTF-8 display"))?;
6443
6444        let init_errors = [
6445            (
6446                super::runtime::index_init_required(&raw_root, &raw_db),
6447                false,
6448            ),
6449            (
6450                super::runtime::index_init_required(&replacement_root, &replacement_db),
6451                true,
6452            ),
6453        ];
6454        for (error, displayable) in init_errors {
6455            let json: Value = serde_json::from_str(&render_cli_error(OutputFormat::Json, &error)?)?;
6456            let expected_root = if displayable {
6457                Some(replacement_display.as_str())
6458            } else {
6459                None
6460            };
6461            require_condition(
6462                json.pointer("/error/init_required/project_root")
6463                    .and_then(Value::as_str)
6464                    == expected_root,
6465                "CLI init report did not preserve unavailable/displayable root state",
6466            )?;
6467            require_condition(
6468                json.pointer("/error/next/project_path")
6469                    .and_then(Value::as_str)
6470                    == expected_root,
6471                "CLI init recovery selector did not fail closed for a raw root",
6472            )?;
6473            let toon = render_cli_error(OutputFormat::Toon, &error)?;
6474            let toon_value: Value = toon_format::decode_default(&toon)?;
6475            if displayable {
6476                let expected = Value::String(replacement_display.clone());
6477                require_condition(
6478                    toon_value.pointer("/error/init_required/project_root") == Some(&expected)
6479                        && toon_value.pointer("/error/next/project_path") == Some(&expected),
6480                    "CLI TOON init recovery lost a displayable root selector",
6481                )?;
6482            } else {
6483                require_condition(
6484                    toon_value
6485                        .pointer("/error/init_required/project_root")
6486                        .is_some_and(Value::is_null)
6487                        && toon_value.pointer("/error/next/project_path").is_none()
6488                        && !toon.contains("repo-�"),
6489                    "CLI TOON init recovery exposed a lossy raw-root selector",
6490                )?;
6491            }
6492        }
6493
6494        let refresh_errors = [
6495            (
6496                CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6497                    project_root: lossless_project_root_display(&raw_root),
6498                    worktree: None,
6499                    status: IndexReadStatus::RefreshRequired,
6500                    reason: IndexRefreshReason::SourceChanged,
6501                    scope: IndexRefreshScope::Incremental,
6502                    changed: 1,
6503                    added: 0,
6504                    removed: 0,
6505                    modified: 1,
6506                    sample_paths: vec!["src/lib.rs".to_string()],
6507                })),
6508                false,
6509            ),
6510            (
6511                CliError::RefreshRequired(Box::new(IndexRefreshRequired {
6512                    project_root: lossless_project_root_display(&replacement_root),
6513                    worktree: None,
6514                    status: IndexReadStatus::RefreshRequired,
6515                    reason: IndexRefreshReason::SourceChanged,
6516                    scope: IndexRefreshScope::Incremental,
6517                    changed: 1,
6518                    added: 0,
6519                    removed: 0,
6520                    modified: 1,
6521                    sample_paths: vec!["src/lib.rs".to_string()],
6522                })),
6523                true,
6524            ),
6525        ];
6526        for (error, displayable) in refresh_errors {
6527            let json: Value = serde_json::from_str(&render_cli_error(OutputFormat::Json, &error)?)?;
6528            let expected_root = if displayable {
6529                Some(replacement_display.as_str())
6530            } else {
6531                None
6532            };
6533            require_condition(
6534                json.pointer("/error/refresh_required/project_root")
6535                    .and_then(Value::as_str)
6536                    == expected_root
6537                    && json
6538                        .pointer("/error/next/project_path")
6539                        .and_then(Value::as_str)
6540                        == expected_root,
6541                "CLI refresh recovery did not preserve lossless root selector state",
6542            )?;
6543            let toon = render_cli_error(OutputFormat::Toon, &error)?;
6544            let toon_value: Value = toon_format::decode_default(&toon)?;
6545            if displayable {
6546                let expected = Value::String(replacement_display.clone());
6547                require_condition(
6548                    toon_value.pointer("/error/refresh_required/project_root") == Some(&expected)
6549                        && toon_value.pointer("/error/next/project_path") == Some(&expected),
6550                    "CLI TOON refresh recovery lost a displayable root selector",
6551                )?;
6552            } else {
6553                require_condition(
6554                    toon_value
6555                        .pointer("/error/refresh_required/project_root")
6556                        .is_some_and(Value::is_null)
6557                        && toon_value.pointer("/error/next/project_path").is_none()
6558                        && !toon.contains("repo-�"),
6559                    "CLI TOON refresh recovery exposed a lossy raw-root selector",
6560                )?;
6561            }
6562        }
6563        let control_report = build_repository_control_report(&raw_root)?;
6564        let control_value = serde_json::to_value(control_report)?;
6565        require_condition(
6566            control_value.get("control_root") == Some(&Value::Null)
6567                && control_value.get("selected_root").is_none()
6568                && control_value.pointer("/worktrees/0/root").is_none()
6569                && !control_value.to_string().contains("repo-�"),
6570            "CLI repository-control report exposed a lossy raw-root projection",
6571        )?;
6572        let displayable_control =
6573            serde_json::to_value(build_repository_control_report(&replacement_root)?)?;
6574        let expected_display = Value::String(replacement_display);
6575        require_condition(
6576            displayable_control.get("control_root") == Some(&expected_display)
6577                && displayable_control.get("selected_root") == Some(&expected_display)
6578                && displayable_control.pointer("/worktrees/0/root") == Some(&expected_display),
6579            "CLI repository-control report lost a displayable root",
6580        )?;
6581        Ok(())
6582    }
6583
6584    #[cfg(unix)]
6585    #[test]
6586    fn cli_root_transition_config_failure_keeps_native_state_without_lossy_recovery()
6587    -> Result<(), Box<dyn Error>> {
6588        let temp = tempfile::tempdir()?;
6589        let raw_root = temp.path().join(OsString::from_vec(b"repo-\x80".to_vec()));
6590        let replacement_root = temp.path().join("repo-�");
6591        let atlas_dir = raw_root.join(".projectatlas");
6592        fs::create_dir_all(&atlas_dir)?;
6593        fs::create_dir(&replacement_root)?;
6594        let raw_db = atlas_dir.join("projectatlas.db");
6595
6596        let Err(error) = bind_project_root(&raw_root, RootTransition::Bind, false) else {
6597            return Err(io::Error::other(
6598                "generated configuration unexpectedly succeeded for a raw native root",
6599            )
6600            .into());
6601        };
6602        require_condition(
6603            matches!(error, super::CliError::RootTransitionFollowup { .. }),
6604            "late configuration failure lost the committed-transition error contract",
6605        )?;
6606        let message = error.to_string();
6607        require_condition(
6608            !message.contains("repo-�")
6609                && !message.contains("projectatlas root set")
6610                && message.contains("native project root has no lossless UTF-8 representation"),
6611            "raw-root transition failure exposed a fabricated recovery selector",
6612        )?;
6613        for format in [OutputFormat::Json, OutputFormat::Toon] {
6614            let rendered = render_cli_error(format, &error)?;
6615            require_condition(
6616                !rendered.contains("repo-�") && !rendered.contains("projectatlas root set"),
6617                "serialized raw-root transition failure exposed a fabricated recovery selector",
6618            )?;
6619        }
6620
6621        let expected_identity = CanonicalProjectRoot::from_path(&raw_root)?;
6622        let store = AtlasStore::open_for_project(&raw_db, &raw_root)?;
6623        require_condition(
6624            store.project_root_identity()?.as_ref() == Some(&expected_identity)
6625                && store.project_root()?.is_none()
6626                && store.project_instance_id()?.is_some(),
6627            "native transition state was not retained after host-config failure",
6628        )?;
6629        require_condition(
6630            !replacement_root.join(".projectatlas").exists()
6631                && !replacement_root.join("projectatlas.toml").exists(),
6632            "lossy recovery failure mutated the replacement-character sibling",
6633        )?;
6634        Ok(())
6635    }
6636
6637    #[cfg(windows)]
6638    #[test]
6639    fn mcp_launch_config_preserves_volume_and_verbatim_paths() -> Result<(), Box<dyn Error>> {
6640        let project_root =
6641            PathBuf::from(r"\\?\Volume{01234567-89AB-CDEF-0123-456789ABCDEF}\ProjectAtlas\CON.");
6642        let executable = project_root.join("projectatlas.exe");
6643        let database = project_root.join(".projectatlas").join("projectatlas.db");
6644        let config = project_root.join(".projectatlas").join("config.toml");
6645
6646        let expected = |path: &Path| {
6647            path.to_str()
6648                .map(str::to_owned)
6649                .ok_or_else(|| io::Error::other("synthetic Windows path was not UTF-8"))
6650        };
6651        let command = super::mcp_launch_path(&executable)?;
6652        let database = super::mcp_launch_path(&database)?;
6653        let config = super::mcp_launch_path(&config)?;
6654        let cwd = super::mcp_launch_path(&project_root)?;
6655        let server = super::McpServerConfig {
6656            command: command.clone(),
6657            args: vec![
6658                "--require-version".to_string(),
6659                env!("CARGO_PKG_VERSION").to_string(),
6660                "--db".to_string(),
6661                database.clone(),
6662                "--config".to_string(),
6663                config.clone(),
6664                "mcp".to_string(),
6665            ],
6666            cwd: cwd.clone(),
6667        };
6668        let config_document = serde_json::to_value(super::McpConfigDocument {
6669            mcp_servers: BTreeMap::from([("projectatlas".to_string(), server)]),
6670        })?;
6671        let server = config_document
6672            .pointer("/mcpServers/projectatlas")
6673            .ok_or_else(|| io::Error::other("serialized MCP server was missing"))?;
6674        require_condition(
6675            server.get("command") == Some(&Value::String(command.clone()))
6676                && server.pointer("/args/3") == Some(&Value::String(database.clone()))
6677                && server.pointer("/args/5") == Some(&Value::String(config.clone()))
6678                && server.get("cwd") == Some(&Value::String(cwd.clone())),
6679            "serialized MCP launch fields changed a verbatim path",
6680        )?;
6681        for (label, path) in [
6682            ("command", command.as_str()),
6683            ("database", database.as_str()),
6684            ("config", config.as_str()),
6685            ("cwd", cwd.as_str()),
6686        ] {
6687            require_condition(
6688                Path::new(path).is_absolute(),
6689                &format!("serialized MCP {label} path was not absolute"),
6690            )?;
6691        }
6692        require_condition(
6693            command == expected(&executable)?
6694                && database
6695                    == expected(&project_root.join(".projectatlas").join("projectatlas.db"))?
6696                && config == expected(&project_root.join(".projectatlas").join("config.toml"))?
6697                && cwd == expected(&project_root)?,
6698            "MCP launch configuration lost volume-GUID/verbatim spelling",
6699        )?;
6700
6701        let drive = super::mcp_launch_path(Path::new(r"\\?\C:\repo\projectatlas.exe"))?;
6702        let unc = super::mcp_launch_path(Path::new(r"\\?\UNC\server\share\repo"))?;
6703        require_condition(
6704            drive == r"C:\repo\projectatlas.exe" && unc == r"\\server\share\repo",
6705            "ordinary extended drive/UNC MCP launch compatibility changed",
6706        )?;
6707        Ok(())
6708    }
6709
6710    #[test]
6711    fn cli_schema_version_mismatches_are_typed_and_content_free() -> Result<(), Box<dyn Error>> {
6712        let supported = projectatlas_db::CURRENT_SCHEMA_VERSION;
6713        let future = supported + 1;
6714        for (error, found) in [
6715            (
6716                CliError::Db(DbError::SchemaVersion {
6717                    found: future,
6718                    expected: supported,
6719                }),
6720                future,
6721            ),
6722            (
6723                CliError::Service(ServiceError::Db(DbError::SchemaVersion {
6724                    found: future,
6725                    expected: supported,
6726                })),
6727                future,
6728            ),
6729            (
6730                CliError::Db(DbError::SchemaVersion {
6731                    found: 7,
6732                    expected: supported,
6733                }),
6734                7,
6735            ),
6736            (
6737                CliError::Service(ServiceError::Db(DbError::SchemaVersion {
6738                    found: 7,
6739                    expected: supported,
6740                })),
6741                7,
6742            ),
6743        ] {
6744            let expected_message =
6745                format!("unsupported schema version {found}, expected {supported}");
6746            let json_text = render_cli_error(OutputFormat::Json, &error)?;
6747            let json: Value = serde_json::from_str(&json_text)?;
6748            require_condition(
6749                json.pointer("/error/kind").and_then(Value::as_str)
6750                    == Some("schema_version_mismatch")
6751                    && json
6752                        .pointer("/error/schema_version_mismatch/found_schema_version")
6753                        .and_then(Value::as_i64)
6754                        == Some(found)
6755                    && json
6756                        .pointer("/error/schema_version_mismatch/supported_schema_version")
6757                        .and_then(Value::as_i64)
6758                        == Some(supported)
6759                    && json
6760                        .pointer("/error/schema_version_mismatch/runtime_version")
6761                        .and_then(Value::as_str)
6762                        == Some(env!("CARGO_PKG_VERSION"))
6763                    && json
6764                        .pointer("/error/schema_version_mismatch/recovery")
6765                        .and_then(Value::as_str)
6766                        .is_some_and(|value| value.contains("do not reset"))
6767                    && json.pointer("/error/message").and_then(Value::as_str)
6768                        == Some(expected_message.as_str()),
6769                "CLI JSON lost the typed schema-version mismatch contract",
6770            )?;
6771            require_condition(
6772                !json_text.contains(".projectatlas")
6773                    && !json_text.contains("session_id")
6774                    && !json_text.contains("project_root"),
6775                "CLI schema mismatch exposed private database context",
6776            )?;
6777
6778            let toon = render_cli_error(OutputFormat::Toon, &error)?;
6779            require_condition(
6780                toon.contains("kind: schema_version_mismatch")
6781                    && toon.contains(&format!("found_schema_version: {found}"))
6782                    && toon.contains(&format!("supported_schema_version: {supported}"))
6783                    && toon.contains(env!("CARGO_PKG_VERSION"))
6784                    && toon.contains("do not reset"),
6785                "CLI TOON lost typed schema-version details or recovery guidance",
6786            )?;
6787        }
6788        for error in [
6789            CliError::Db(DbError::SchemaVersion {
6790                found: 8,
6791                expected: supported,
6792            }),
6793            CliError::Service(ServiceError::Db(DbError::SchemaVersion {
6794                found: 15,
6795                expected: supported,
6796            })),
6797        ] {
6798            require_condition(
6799                schema_version_mismatch_payload(&error).is_none(),
6800                "CLI treated an admitted predecessor as unsupported",
6801            )?;
6802            let migration = schema_migration_required_payload(&error).ok_or_else(|| {
6803                std::io::Error::other("CLI omitted the admitted-predecessor migration handoff")
6804            })?;
6805            let expected_steps = u32::try_from(supported - migration.found_schema_version)?;
6806            require_condition(
6807                migration.supported_schema_version == supported
6808                    && migration.migration_steps_remaining == expected_steps,
6809                "CLI migration handoff drifted from the database migration inventory",
6810            )?;
6811            let rendered = render_cli_error(OutputFormat::Json, &error)?;
6812            let json: Value = serde_json::from_str(&rendered)?;
6813            require_condition(
6814                json.pointer("/error/kind").and_then(Value::as_str)
6815                    == Some("schema_migration_required")
6816                    && json
6817                        .pointer("/error/schema_migration_required/migration_steps_remaining")
6818                        .and_then(Value::as_u64)
6819                        == Some(u64::from(expected_steps))
6820                    && json
6821                        .pointer("/error/schema_migration_required/recovery")
6822                        .and_then(Value::as_str)
6823                        == Some(SCHEMA_MIGRATION_REQUIRED_RECOVERY)
6824                    && rendered.contains("same global `--db`/`--config` selection")
6825                    && rendered.contains("same MCP server/database binding")
6826                    && json
6827                        .pointer("/error/message")
6828                        .and_then(Value::as_str)
6829                        .is_some_and(|message| message.contains("supported migration step"))
6830                    && !rendered.contains("schema_version_mismatch")
6831                    && !rendered.contains(SCHEMA_VERSION_MISMATCH_RECOVERY)
6832                    && !rendered.contains(".projectatlas")
6833                    && !rendered.contains("project_root"),
6834                "CLI did not return a private, actionable supported-migration handoff",
6835            )?;
6836        }
6837        Ok(())
6838    }
6839
6840    #[test]
6841    fn cli_search_modes_parse_and_unavailable_state_is_typed() -> Result<(), Box<dyn Error>> {
6842        let cli = Cli::try_parse_from([
6843            "projectatlas",
6844            "search",
6845            "needle",
6846            "--retrieval-mode",
6847            "semantic",
6848        ])?;
6849        require_condition(
6850            matches!(
6851                *cli.command,
6852                Command::Search {
6853                    retrieval_mode: SearchRetrievalModeArg::Semantic,
6854                    ..
6855                }
6856            ),
6857            "CLI did not parse explicit semantic retrieval",
6858        )?;
6859
6860        let error = CliError::Service(ServiceError::SearchCapabilityUnavailable {
6861            requested_mode: SearchRetrievalMode::Semantic,
6862            state: "not-installed",
6863            guidance: "install a compatible semantic generation",
6864        });
6865        let json_text = render_cli_error(OutputFormat::Json, &error)?;
6866        let json: Value = serde_json::from_str(&json_text)?;
6867        require_condition(
6868            json.pointer("/error/kind").and_then(Value::as_str)
6869                == Some("search_capability_unavailable")
6870                && json
6871                    .pointer("/error/search_capability/requested_mode")
6872                    .and_then(Value::as_str)
6873                    == Some("semantic")
6874                && json
6875                    .pointer("/error/search_capability/state")
6876                    .and_then(Value::as_str)
6877                    == Some("not-installed")
6878                && json
6879                    .pointer("/error/search_capability/recovery")
6880                    .and_then(Value::as_str)
6881                    .is_some_and(|value| value.contains("compatible semantic")),
6882            "CLI JSON lost typed semantic capability state",
6883        )?;
6884        let toon = render_cli_error(OutputFormat::Toon, &error)?;
6885        require_condition(
6886            toon.contains("search_capability_unavailable")
6887                && toon.contains("requested_mode")
6888                && toon.contains("semantic")
6889                && toon.contains("state")
6890                && toon.contains("not-installed")
6891                && toon.contains("compatible semantic"),
6892            "CLI TOON lost typed semantic capability state",
6893        )?;
6894        Ok(())
6895    }
6896
6897    #[test]
6898    fn cli_rejects_federated_entrypoint_analysis_before_opening_roots() -> Result<(), Box<dyn Error>>
6899    {
6900        let mut cli = Cli::try_parse_from([
6901            "projectatlas",
6902            "--db",
6903            "unused.sqlite",
6904            "symbols",
6905            "relations",
6906            "--view",
6907            "analysis",
6908            "--root",
6909            "missing-a",
6910            "--root",
6911            "missing-b",
6912            "--analysis-mode",
6913            "entrypoint",
6914            "--file",
6915            "src/a.rs",
6916        ])?;
6917        cli.database_path_is_explicit = true;
6918        let error = match super::run(&mut cli) {
6919            Ok(()) => {
6920                return Err(io::Error::other("federated entrypoint analysis was accepted").into());
6921            }
6922            Err(error) => error,
6923        };
6924        require_condition(
6925            error
6926                .to_string()
6927                .contains(super::CLI_ERROR_ENTRYPOINT_FEDERATED),
6928            "federated entrypoint rejection did not preserve its typed boundary",
6929        )?;
6930        Ok(())
6931    }
6932
6933    #[test]
6934    fn health_report_and_resolve_are_structurally_exclusive() -> Result<(), Box<dyn Error>> {
6935        let default = Cli::try_parse_from(["projectatlas", "health"])?;
6936        require_condition(
6937            matches!(
6938                default.command.as_ref(),
6939                Command::Health {
6940                    report,
6941                    command: None,
6942                } if report.start_index == 0
6943                    && report.limit == DEFAULT_HEALTH_LIMIT
6944                    && !report.summary_only
6945            ),
6946            "health without a subcommand did not select the read-only report",
6947        )?;
6948
6949        let resolve = Cli::try_parse_from([
6950            "projectatlas",
6951            "health",
6952            "resolve",
6953            "finding-id",
6954            "category",
6955            "src/lib.rs",
6956            "--rationale",
6957            "fixed",
6958        ])?;
6959        require_condition(
6960            matches!(
6961                resolve.command.as_ref(),
6962                Command::Health {
6963                    report,
6964                    command: Some(HealthCommand::Resolve { finding_id, .. }),
6965                } if finding_id == "finding-id"
6966                    && report.start_index == 0
6967                    && report.limit == DEFAULT_HEALTH_LIMIT
6968            ),
6969            "health resolve did not remain an administrative subcommand",
6970        )?;
6971
6972        let Err(mixed) = Cli::try_parse_from([
6973            "projectatlas",
6974            "health",
6975            "--summary-only",
6976            "resolve",
6977            "finding-id",
6978            "category",
6979            "src/lib.rs",
6980            "--rationale",
6981            "fixed",
6982        ]) else {
6983            return Err(io::Error::other(
6984                "report flags must conflict with health resolve at parse time",
6985            )
6986            .into());
6987        };
6988        require_condition(
6989            mixed.to_string().contains("cannot be used with")
6990                && mixed.to_string().contains("resolve"),
6991            "health mixed report/resolve parse error did not identify the boundary",
6992        )?;
6993
6994        let health_check = Cli::try_parse_from([
6995            "projectatlas",
6996            "--format",
6997            "json",
6998            "health-check",
6999            "--coverage",
7000            "--parser",
7001            "tree-sitter",
7002        ])?;
7003        require_condition(
7004            matches!(
7005                health_check.command.as_ref(),
7006                Command::HealthCheck { report }
7007                    if report.coverage
7008                        && report.parser.as_deref() == Some("tree-sitter")
7009            ),
7010            "health-check compatibility parsing lost report filters",
7011        )?;
7012        Ok(())
7013    }
7014
7015    #[cfg(unix)]
7016    #[test]
7017    fn installer_lock_uses_the_inherited_open_file_description() -> Result<(), Box<dyn Error>> {
7018        let temp = tempfile::tempdir()?;
7019        let path = temp.path().join("installer.lock");
7020        let parent = fs::OpenOptions::new()
7021            .read(true)
7022            .write(true)
7023            .create_new(true)
7024            .open(&path)?;
7025        let metadata = parent.metadata()?;
7026        require_condition(
7027            super::acquire_installer_lock(
7028                &parent.try_clone()?,
7029                metadata.dev(),
7030                metadata.ino() ^ 1,
7031                std::time::Duration::ZERO,
7032            )
7033            .is_err(),
7034            "installer lock accepted a descriptor with the wrong captured identity",
7035        )?;
7036        super::acquire_installer_lock(
7037            &parent.try_clone()?,
7038            metadata.dev(),
7039            metadata.ino(),
7040            std::time::Duration::ZERO,
7041        )?;
7042
7043        let contender = fs::OpenOptions::new().read(true).write(true).open(&path)?;
7044        require_condition(
7045            matches!(contender.try_lock(), Err(fs::TryLockError::WouldBlock)),
7046            "closing the helper copy released the parent installer's inherited lock",
7047        )?;
7048        drop(parent);
7049        contender.try_lock()?;
7050
7051        let blocked = fs::OpenOptions::new().read(true).write(true).open(&path)?;
7052        let blocked_result = super::acquire_installer_lock(
7053            &blocked,
7054            metadata.dev(),
7055            metadata.ino(),
7056            std::time::Duration::ZERO,
7057        );
7058        require_condition(
7059            blocked_result.is_err_and(|source| source.kind() == io::ErrorKind::TimedOut),
7060            "installer lock did not preserve its bounded contention timeout",
7061        )?;
7062
7063        let directory = fs::File::open(temp.path())?;
7064        let directory_metadata = directory.metadata()?;
7065        require_condition(
7066            super::acquire_installer_lock(
7067                &directory,
7068                directory_metadata.dev(),
7069                directory_metadata.ino(),
7070                std::time::Duration::ZERO,
7071            )
7072            .is_err(),
7073            "installer lock accepted a directory",
7074        )?;
7075
7076        let detached_path = temp.path().join("detached-installer.lock");
7077        let detached = fs::OpenOptions::new()
7078            .read(true)
7079            .write(true)
7080            .create_new(true)
7081            .open(&detached_path)?;
7082        fs::remove_file(&detached_path)?;
7083        let detached_metadata = detached.metadata()?;
7084        require_condition(
7085            detached_metadata.nlink() == 0,
7086            "installer lock pathless-descriptor fixture remained linked",
7087        )?;
7088        super::acquire_installer_lock(
7089            &detached,
7090            detached_metadata.dev(),
7091            detached_metadata.ino(),
7092            std::time::Duration::ZERO,
7093        )?;
7094        Ok(())
7095    }
7096
7097    #[cfg(feature = "optional-parser-supervisor")]
7098    #[test]
7099    fn parser_pack_cli_exposes_every_explicit_lifecycle_operation() -> Result<(), Box<dyn Error>> {
7100        let artifact = "a".repeat(64);
7101        let commands = [
7102            vec![
7103                "projectatlas",
7104                "parser-pack",
7105                "verify",
7106                "--archive",
7107                "pack.tar.zst",
7108            ],
7109            vec![
7110                "projectatlas",
7111                "parser-pack",
7112                "install",
7113                "--archive",
7114                "pack.tar.zst",
7115            ],
7116            vec![
7117                "projectatlas",
7118                "parser-pack",
7119                "enable",
7120                "--artifact",
7121                artifact.as_str(),
7122            ],
7123            vec![
7124                "projectatlas",
7125                "parser-pack",
7126                "update",
7127                "--archive",
7128                "pack.tar.zst",
7129            ],
7130            vec!["projectatlas", "parser-pack", "disable"],
7131            vec!["projectatlas", "parser-pack", "remove"],
7132            vec!["projectatlas", "parser-pack", "status"],
7133        ];
7134        for arguments in commands {
7135            let parsed = Cli::try_parse_from(arguments)?;
7136            require_condition(
7137                matches!(
7138                    *parsed.command,
7139                    Command::ParserPack {
7140                        command: ParserPackCommand::Verify { .. }
7141                            | ParserPackCommand::Install { .. }
7142                            | ParserPackCommand::Enable { .. }
7143                            | ParserPackCommand::Update { .. }
7144                            | ParserPackCommand::Disable
7145                            | ParserPackCommand::Remove
7146                            | ParserPackCommand::Status,
7147                        ..
7148                    }
7149                ),
7150                "parser-pack command did not route to an explicit lifecycle operation",
7151            )?;
7152        }
7153        Ok(())
7154    }
7155
7156    #[cfg(feature = "optional-parser-supervisor")]
7157    #[test]
7158    fn parser_pack_unsupported_containment_is_typed() -> Result<(), Box<dyn Error>> {
7159        let error =
7160            CliError::ParserPack(OptionalParserPackLifecycleError::UnsupportedContainment {
7161                os: "test-os",
7162                architecture: "test-arch",
7163            });
7164        let json_text = render_cli_error(OutputFormat::Json, &error)?;
7165        let json: Value = serde_json::from_str(&json_text)?;
7166        require_condition(
7167            json.pointer("/error/kind").and_then(Value::as_str) == Some("unsupported_containment"),
7168            "parser-pack unsupported host did not retain its typed error kind",
7169        )
7170    }
7171
7172    #[test]
7173    fn required_mcp_surface_checks_actual_tool_routes() {
7174        assert!(required_mcp_surface_present());
7175        for required_tool in REQUIRED_MCP_TOOL_NAMES {
7176            assert!(
7177                mcp_tool_route_present(required_tool),
7178                "{required_tool} missing"
7179            );
7180        }
7181    }
7182
7183    #[test]
7184    fn runtime_info_reports_stable_installer_contract() {
7185        let info = build_runtime_info();
7186
7187        assert_eq!(info.project, "ProjectAtlas");
7188        assert_eq!(info.major_version, 3);
7189        assert!(
7190            info.capabilities
7191                .iter()
7192                .any(|capability| capability == "mcp")
7193        );
7194        assert_eq!(info.text_format, "TOON");
7195        assert!(
7196            info.mcp_tools.iter().any(|tool| tool == "atlas_scan"),
7197            "atlas_scan missing from runtime-info"
7198        );
7199    }
7200
7201    #[test]
7202    fn text_index_skips_oversized_files_without_hiding_nodes() -> Result<(), Box<dyn Error>> {
7203        let temp = tempfile::tempdir()?;
7204        let root = temp.path();
7205        fs::write(root.join("small.txt"), "small")?;
7206        fs::write(root.join("large.txt"), "large content")?;
7207        let nodes = vec![
7208            Node {
7209                path: "small.txt".to_string(),
7210                kind: NodeKind::File,
7211                parent_path: None,
7212                extension: Some(".txt".to_string()),
7213                language: Some("text".to_string()),
7214                size_bytes: Some(5),
7215                mtime_ns: Some(1),
7216                content_hash: Some(blake3::hash(b"small").to_hex().to_string()),
7217            },
7218            Node {
7219                path: "large.txt".to_string(),
7220                kind: NodeKind::File,
7221                parent_path: None,
7222                extension: Some(".txt".to_string()),
7223                language: Some("text".to_string()),
7224                size_bytes: Some(13),
7225                mtime_ns: Some(1),
7226                content_hash: Some("large-hash".to_string()),
7227            },
7228        ];
7229        let mut store = AtlasStore::in_memory()?;
7230        let report =
7231            refresh_text_index_for_nodes(&mut store, root, &nodes, TextIndexOptions::new(5))?;
7232
7233        require_condition(report.candidates == 2, "candidate count")?;
7234        require_condition(report.indexed == 1, "indexed count")?;
7235        require_condition(report.too_large == 1, "too-large count")?;
7236        require_condition(report.binary_or_non_utf8 == 0, "binary count")?;
7237        require_condition(report.skipped == 1, "skipped count")?;
7238        require_condition(report.max_bytes == 5, "max byte policy")?;
7239        require_condition(
7240            store.load_file_text("small.txt")?.is_some(),
7241            "small text indexed",
7242        )?;
7243        require_condition(
7244            store.load_file_text("large.txt")?.is_none(),
7245            "large text skipped",
7246        )?;
7247        Ok(())
7248    }
7249
7250    #[test]
7251    fn structural_summary_refresh_clears_stale_summary_when_text_is_skipped()
7252    -> Result<(), Box<dyn Error>> {
7253        let temp = tempfile::tempdir()?;
7254        let root = temp.path();
7255        fs::write(root.join("config.toml"), "[project]\nroot = \".\"\n")?;
7256        let nodes = vec![Node {
7257            path: "config.toml".to_string(),
7258            kind: NodeKind::File,
7259            parent_path: None,
7260            extension: Some(".toml".to_string()),
7261            language: Some("toml".to_string()),
7262            size_bytes: Some(19),
7263            mtime_ns: Some(1),
7264            content_hash: Some(
7265                blake3::hash(b"[project]\nroot = \".\"\n")
7266                    .to_hex()
7267                    .to_string(),
7268            ),
7269        }];
7270        let mut store = AtlasStore::in_memory()?;
7271        store.replace_scan(&nodes)?;
7272        let text_refresh = refresh_text_index_for_nodes_with_rows(
7273            &mut store,
7274            root,
7275            &nodes,
7276            TextIndexOptions::new(100),
7277        )?;
7278        let first_report =
7279            refresh_structural_summaries_for_nodes(&mut store, &nodes, &text_refresh.rows)?;
7280        require_condition(first_report.summarized == 1, "initial structural summary")?;
7281        require_condition(
7282            store
7283                .load_node_by_path("config.toml")?
7284                .and_then(|node| node.summary)
7285                .is_some(),
7286            "summary should exist before skip",
7287        )?;
7288        store.replace_symbol_graph(&SymbolGraph {
7289            path: "config.toml".to_string(),
7290            language: Some("toml".to_string()),
7291            parser: ParserKind::Manifest,
7292            symbols: vec![test_symbol("config.toml", SymbolKind::Value, "project")],
7293            relations: Vec::new(),
7294        })?;
7295
7296        let skipped_text = refresh_text_index_for_nodes_with_rows(
7297            &mut store,
7298            root,
7299            &nodes,
7300            TextIndexOptions::new(5),
7301        )?;
7302        let stale_report =
7303            refresh_structural_summaries_for_nodes(&mut store, &nodes, &skipped_text.rows)?;
7304        require_condition(stale_report.too_large == 1, "structural too-large count")?;
7305        require_condition(stale_report.cleared == 1, "cleared stale summary count")?;
7306        require_condition(
7307            store
7308                .load_node_by_path("config.toml")?
7309                .and_then(|node| node.summary)
7310                .is_none(),
7311            "summary should be cleared after current text is skipped",
7312        )?;
7313        Ok(())
7314    }
7315
7316    #[test]
7317    fn watcher_status_does_not_report_background_activity() {
7318        let status = watcher_status_report(false);
7319
7320        assert!(status.available);
7321        assert!(!status.active);
7322        assert!(!status.mode.is_empty());
7323    }
7324
7325    #[test]
7326    fn reset_index_preview_and_apply_are_file_scoped() -> Result<(), Box<dyn Error>> {
7327        let temp = tempfile::tempdir()?;
7328        let db = temp.path().join("projectatlas.db");
7329        fs::write(&db, "db")?;
7330        fs::write(temp.path().join("projectatlas.db-wal"), "wal")?;
7331        fs::write(temp.path().join("projectatlas.mcp.json"), "{}")?;
7332
7333        let preview = reset_index_files(&db, false, false, true, None)?;
7334        require_condition(!preview.applied, "preview should not apply")?;
7335        require_condition(preview.removed == 0, "preview should not remove files")?;
7336        require_condition(db.exists(), "preview removed database")?;
7337
7338        let applied = reset_index_files(&db, true, false, true, None)?;
7339        require_condition(applied.applied, "apply should mark report applied")?;
7340        require_condition(applied.removed == 3, "apply removed unexpected file count")?;
7341        require_condition(!db.exists(), "database remained after apply")?;
7342        require_condition(
7343            !temp.path().join("projectatlas.db-wal").exists(),
7344            "wal remained after apply",
7345        )?;
7346        require_condition(
7347            !temp.path().join("projectatlas.mcp.json").exists(),
7348            "mcp config remained after apply",
7349        )?;
7350        Ok(())
7351    }
7352
7353    #[test]
7354    fn primary_symbol_names_are_stable_deduped_and_limited() {
7355        let graph = SymbolGraph {
7356            path: "src/lib.rs".to_string(),
7357            language: Some("rust".to_string()),
7358            parser: ParserKind::TreeSitter,
7359            symbols: vec![
7360                test_symbol("src/lib.rs", SymbolKind::Function, "zeta"),
7361                test_symbol("src/lib.rs", SymbolKind::Function, "alpha"),
7362                test_symbol("src/lib.rs", SymbolKind::Function, "alpha"),
7363                test_symbol("src/lib.rs", SymbolKind::Function, "beta"),
7364            ],
7365            relations: Vec::new(),
7366        };
7367
7368        assert_eq!(
7369            primary_symbol_names(&graph, 2),
7370            vec!["alpha".to_string(), "beta".to_string()]
7371        );
7372    }
7373
7374    #[test]
7375    fn relation_targets_are_stable_deduped_and_limited() {
7376        let graph = SymbolGraph {
7377            path: "src/lib.rs".to_string(),
7378            language: Some("rust".to_string()),
7379            parser: ParserKind::TreeSitter,
7380            symbols: Vec::new(),
7381            relations: vec![
7382                test_relation("src/lib.rs", RelationKind::Imports, "zeta"),
7383                test_relation("src/lib.rs", RelationKind::Imports, "alpha"),
7384                test_relation("src/lib.rs", RelationKind::Imports, "alpha"),
7385            ],
7386        };
7387
7388        assert_eq!(
7389            relation_targets(&graph, RelationKind::Imports, 2),
7390            vec!["alpha".to_string(), "zeta".to_string()]
7391        );
7392    }
7393
7394    #[test]
7395    fn token_dashboard_is_human_readable_and_chart_backed() {
7396        let dashboard = render_token_dashboard(
7397            &TokenOverview::from_estimated_totals(3, 12_000, 3_000),
7398            Some("session-a"),
7399        );
7400
7401        assert!(dashboard.contains("ProjectAtlas"));
7402        assert!(dashboard.contains("Token Impact"));
7403        assert!(dashboard.contains("session-a"));
7404        assert!(dashboard.contains("A V E R A G E   T O K E N S   A V O I D E D"));
7405        assert!(dashboard.contains("Total Tokens Avoided"));
7406        assert!(dashboard.contains("Without ProjectAtlas"));
7407        assert!(dashboard.contains("With ProjectAtlas"));
7408        assert!(dashboard.contains("Average avoided"));
7409        assert!(dashboard.contains("Maximum avoided"));
7410        assert!(dashboard.contains("N A V I G A T I O N   W O R K   A V O I D E D"));
7411        assert!(
7412            dashboard
7413                .to_ascii_lowercase()
7414                .contains("file reads avoided")
7415        );
7416        assert!(!dashboard.contains("Broad folder walks skipped"));
7417        assert!(!dashboard.contains("Candidate files not opened"));
7418        assert!(!dashboard.contains("source steps account for"));
7419        assert!(dashboard.contains("S A V I N G S   C O M P O S I T I O N"));
7420        assert!(dashboard.contains("S I G N A L"));
7421        assert!(dashboard.contains("W H E R E   T H E   S A V I N G S   C A M E   F R O M"));
7422        assert!(dashboard.contains("C A L I B R A T I O N   &   N O T E S"));
7423        assert!(dashboard.contains("Confidence"));
7424        assert!(dashboard.contains("Tokenizer audit"));
7425        assert!(
7426            dashboard
7427                .chars()
7428                .any(|character| matches!(character, 'â–ˆ' | '\u{2801}'..='\u{28ff}'))
7429        );
7430        assert!(!dashboard.contains("Gross tokens: without vs with ProjectAtlas"));
7431        assert!(!dashboard.contains("REQUESTED BENCHMARK EVIDENCE"));
7432        assert!(!dashboard.contains("How ProjectAtlas helped"));
7433        assert!(!dashboard.contains("Saved-token trends"));
7434    }
7435
7436    #[test]
7437    fn token_atlas_network_excludes_containment() {
7438        assert!(!token_atlas_network_relation(GraphRelationKind::Legacy(
7439            RelationKind::Contains,
7440        )));
7441        assert!(token_atlas_network_relation(GraphRelationKind::Legacy(
7442            RelationKind::Imports,
7443        )));
7444    }
7445
7446    #[test]
7447    fn token_atlas_loader_ranks_resolved_hubs_before_bounded_rendering()
7448    -> Result<(), Box<dyn Error>> {
7449        const UNRESOLVED_PREFIX_ROWS: usize = 129;
7450        const BRANCHES: usize = 15;
7451        const LEAVES_PER_BRANCH: usize = 3;
7452        const DENSE_HUBS: usize = 4;
7453        const DENSE_LEAVES_PER_HUB: usize = 200;
7454        const ADVERSARY_LEAVES: usize = 128;
7455        let temp = tempfile::tempdir()?;
7456        let root = temp.path().join("token-atlas-loader");
7457        fs::create_dir_all(root.join("src"))?;
7458        let mut store = AtlasStore::open_for_project(&root.join("projectatlas.db"), &root)?;
7459        let project = store
7460            .project_instance_id()?
7461            .ok_or("token atlas fixture project identity is missing")?;
7462        let generation = IndexGeneration::new(1);
7463        let entity = |path: &str| {
7464            Ok::<_, Box<dyn Error>>(GraphEntity::new(
7465                project,
7466                EntitySelector::File {
7467                    path: RepositoryFilePath::new(Path::new(path))?,
7468                },
7469                generation,
7470            )?)
7471        };
7472        let node = |path: &str, kind: NodeKind, hash: Option<&str>| {
7473            let is_file = kind == NodeKind::File;
7474            Node {
7475                path: path.to_string(),
7476                kind,
7477                parent_path: is_file.then(|| "src".to_string()),
7478                extension: is_file.then(|| ".rs".to_string()),
7479                language: is_file.then(|| "rust".to_string()),
7480                size_bytes: is_file.then_some(17),
7481                mtime_ns: is_file.then_some(1),
7482                content_hash: hash.map(str::to_string),
7483            }
7484        };
7485        let mut nodes = vec![node("src", NodeKind::Folder, None)];
7486        let mut entities = Vec::new();
7487        let mut add_file_entity = |path: String| -> Result<GraphEntity, Box<dyn Error>> {
7488            let graph_entity = entity(&path)?;
7489            nodes.push(node(&path, NodeKind::File, Some(&path)));
7490            entities.push(graph_entity.clone());
7491            Ok(graph_entity)
7492        };
7493        let source = add_file_entity("src/source.rs".to_string())?;
7494        let mut resolved_calls = Vec::new();
7495        let mut dense_hubs = Vec::new();
7496        for hub_index in 0..DENSE_HUBS {
7497            let hub = add_file_entity(format!("src/dense-{hub_index}-hub.rs"))?;
7498            for leaf in 0..DENSE_LEAVES_PER_HUB {
7499                let leaf = add_file_entity(format!("src/dense-{hub_index}-leaf-{leaf:03}.rs"))?;
7500                resolved_calls.push(LogicalRelation::new(
7501                    &hub,
7502                    GraphRelationKind::Legacy(RelationKind::Calls),
7503                    RelationResolution::resolved(&leaf)?,
7504                    ConfidenceClass::Exact,
7505                    Completeness::Complete,
7506                    generation,
7507                )?);
7508            }
7509            dense_hubs.push(hub);
7510        }
7511        resolved_calls.push(LogicalRelation::new(
7512            &source,
7513            GraphRelationKind::Legacy(RelationKind::Calls),
7514            RelationResolution::resolved(&dense_hubs[0])?,
7515            ConfidenceClass::Exact,
7516            Completeness::Complete,
7517            generation,
7518        )?);
7519        let mut branch_roots = Vec::with_capacity(BRANCHES);
7520        for branch in 0..BRANCHES {
7521            let branch_root = add_file_entity(format!("src/branch-{branch:02}-root.rs"))?;
7522            resolved_calls.push(LogicalRelation::new(
7523                &source,
7524                GraphRelationKind::Legacy(RelationKind::Calls),
7525                RelationResolution::resolved(&branch_root)?,
7526                ConfidenceClass::Exact,
7527                Completeness::Complete,
7528                generation,
7529            )?);
7530            branch_roots.push(branch_root.clone());
7531            let mut leaves = Vec::new();
7532            for leaf in 0..LEAVES_PER_BRANCH {
7533                let entity = add_file_entity(format!("src/branch-{branch:02}-leaf-{leaf}.rs"))?;
7534                resolved_calls.push(LogicalRelation::new(
7535                    &branch_root,
7536                    GraphRelationKind::Legacy(RelationKind::Calls),
7537                    RelationResolution::resolved(&entity)?,
7538                    ConfidenceClass::Exact,
7539                    Completeness::Complete,
7540                    generation,
7541                )?);
7542                leaves.push(entity);
7543            }
7544            for (left, right) in [(0, 1), (1, 2), (2, 0), (1, 0), (2, 1), (0, 2)] {
7545                resolved_calls.push(LogicalRelation::new(
7546                    &leaves[left],
7547                    GraphRelationKind::Legacy(RelationKind::Calls),
7548                    RelationResolution::resolved(&leaves[right])?,
7549                    ConfidenceClass::Exact,
7550                    Completeness::Complete,
7551                    generation,
7552                )?);
7553            }
7554        }
7555        branch_roots.sort_by_key(|branch| branch.key().digest().to_string());
7556        let adversary_branch = &branch_roots[0];
7557        let later_branch = &branch_roots[1];
7558        let later_branch_edge = resolved_calls
7559            .iter()
7560            .filter(|relation| relation.source() == later_branch.key())
7561            .min_by_key(|relation| relation.key().digest().to_string())
7562            .cloned()
7563            .ok_or("later branch edge fixture is missing")?;
7564        for leaf in 0..ADVERSARY_LEAVES {
7565            let entity = add_file_entity(format!("src/adversary-leaf-{leaf:03}.rs"))?;
7566            resolved_calls.push(LogicalRelation::new(
7567                adversary_branch,
7568                GraphRelationKind::Legacy(RelationKind::Calls),
7569                RelationResolution::resolved(&entity)?,
7570                ConfidenceClass::Exact,
7571                Completeness::Complete,
7572                generation,
7573            )?);
7574        }
7575        let mut resolved_import = None;
7576        for index in 0..1_000 {
7577            let target = add_file_entity(format!("src/import-target-{index:03}.rs"))?;
7578            let relation = LogicalRelation::new(
7579                &source,
7580                GraphRelationKind::Legacy(RelationKind::Imports),
7581                RelationResolution::resolved(&target)?,
7582                ConfidenceClass::Exact,
7583                Completeness::Complete,
7584                generation,
7585            )?;
7586            if relation.key().digest().starts_with('f') {
7587                resolved_import = Some(relation);
7588                break;
7589            }
7590        }
7591        let resolved_import = resolved_import.ok_or(
7592            "could not construct a deterministic resolved relation after the prefix boundary",
7593        )?;
7594        let mut unresolved_imports = Vec::with_capacity(UNRESOLVED_PREFIX_ROWS);
7595        for index in 0..1_000 {
7596            let relation = LogicalRelation::new(
7597                &source,
7598                GraphRelationKind::Legacy(RelationKind::Imports),
7599                RelationResolution::Unresolved {
7600                    reference: GraphIdentityText::new(format!("missing-import-{index:04}"))?,
7601                },
7602                ConfidenceClass::Low,
7603                Completeness::Partial,
7604                generation,
7605            )?;
7606            if relation.key().digest() < resolved_import.key().digest() {
7607                unresolved_imports.push(relation);
7608                if unresolved_imports.len() == UNRESOLVED_PREFIX_ROWS {
7609                    break;
7610                }
7611            }
7612        }
7613        if unresolved_imports.len() != UNRESOLVED_PREFIX_ROWS {
7614            return Err(io::Error::other(
7615                "could not construct the deterministic unresolved relation-key prefix",
7616            )
7617            .into());
7618        }
7619        let contained = add_file_entity("src/contained.rs".to_string())?;
7620        let containment = LogicalRelation::new(
7621            &source,
7622            GraphRelationKind::Legacy(RelationKind::Contains),
7623            RelationResolution::resolved(&contained)?,
7624            ConfidenceClass::Exact,
7625            Completeness::Complete,
7626            generation,
7627        )?;
7628        let mut graph_relations = unresolved_imports;
7629        graph_relations.push(resolved_import.clone());
7630        graph_relations.extend(resolved_calls);
7631        graph_relations.push(containment);
7632        let mut publication = store.begin_index_publication("token-atlas-loader")?;
7633        publication.begin_scan_replacement()?;
7634        publication.upsert_scan_node_batch(&nodes)?;
7635        publication.finish_scan_replacement()?;
7636        publication.replace_repository_graph(project, &entities, &graph_relations, &[], &[])?;
7637        publication.complete()?;
7638
7639        let raw = store.repository_graph_relations(
7640            RepositoryGraphRelationQuery::Family {
7641                relation: GraphRelationKind::Legacy(RelationKind::Imports),
7642            },
7643            128,
7644        )?;
7645        if !raw.truncated {
7646            return Err(io::Error::other("raw relation-family fixture was not truncated").into());
7647        }
7648        if raw
7649            .rows
7650            .iter()
7651            .any(|relation| relation.resolution().resolved_target().is_some())
7652        {
7653            return Err(io::Error::other(
7654                "raw relation-key prefix unexpectedly reached a resolved relation",
7655            )
7656            .into());
7657        }
7658        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7659        let (relations, truncated) = load_token_atlas_relations(&store, &control)
7660            .ok_or("token atlas relation loader unexpectedly failed")?;
7661        if !truncated {
7662            return Err(io::Error::other("token atlas omitted bounded-source state").into());
7663        }
7664        if !relations.iter().all(|relation| {
7665            relation.resolution().resolved_target().is_some()
7666                && token_atlas_network_relation(relation.kind())
7667        }) {
7668            return Err(io::Error::other(
7669                "token atlas retained unresolved or containment relations",
7670            )
7671            .into());
7672        }
7673        for hub in &dense_hubs {
7674            if !relations
7675                .iter()
7676                .any(|relation| relation.source() == hub.key())
7677            {
7678                return Err(io::Error::other(
7679                    "token atlas adjacency budget omitted a ranked dense hub",
7680                )
7681                .into());
7682            }
7683        }
7684        if !relations
7685            .iter()
7686            .any(|relation| relation.key() == later_branch_edge.key())
7687        {
7688            return Err(io::Error::other(
7689                "token atlas adjacency budget omitted a later second-round branch",
7690            )
7691            .into());
7692        }
7693        if !relations
7694            .iter()
7695            .any(|relation| relation.key() == resolved_import.key())
7696        {
7697            return Err(io::Error::other(
7698                "token atlas did not recover the resolved relation behind the unresolved prefix",
7699            )
7700            .into());
7701        }
7702        let atlas = load_token_atlas_preview(&store);
7703        let dashboard = render_token_dashboard_with_atlas_at_width(
7704            &TokenOverview::from_estimated_totals(4, 16_000, 4_000),
7705            Some("resolved-loader"),
7706            &atlas,
7707            200,
7708        );
7709        if !dashboard.contains("48 nodes •") {
7710            let status = dashboard
7711                .lines()
7712                .find(|line| line.contains(" nodes • "))
7713                .unwrap_or("atlas status line missing");
7714            return Err(io::Error::other(format!(
7715                "resolved full-family atlas did not fill the bounded 200x50 node preview: {status}"
7716            ))
7717            .into());
7718        }
7719        let cancellation = IndexCancellation::new();
7720        cancellation.cancel();
7721        let cancelled = IndexWorkControl::new(cancellation, None);
7722        if load_token_atlas_relations(&store, &cancelled).is_some() {
7723            return Err(io::Error::other(
7724                "token atlas loader ignored its shared cancellation boundary",
7725            )
7726            .into());
7727        }
7728        Ok(())
7729    }
7730
7731    #[test]
7732    fn telemetry_baselines_use_source_size_without_reading_all_files() {
7733        let node = Node {
7734            path: "src/main.rs".to_string(),
7735            kind: NodeKind::File,
7736            parent_path: Some("src".to_string()),
7737            extension: Some(".rs".to_string()),
7738            language: Some("rust".to_string()),
7739            size_bytes: Some(41),
7740            mtime_ns: Some(1),
7741            content_hash: Some("hash".to_string()),
7742        };
7743
7744        assert_eq!(estimated_source_tokens_for_file_node(&node), 11);
7745        assert_eq!(byte_count_to_tokens(9), 3);
7746    }
7747
7748    #[test]
7749    fn json_output_serialization_is_measurable_for_telemetry() -> Result<(), Box<dyn Error>> {
7750        let payload = serde_json::json!({ "path": "src/main.rs", "lines": [1, 2, 3] });
7751        let toon = "path: src/main.rs\n";
7752        let json = serialized_output(OutputFormat::Json, toon, &payload)?;
7753
7754        if !json.contains("\"path\": \"src/main.rs\"") {
7755            return Err(io::Error::other("json output did not contain path").into());
7756        }
7757        if !json.ends_with('\n') {
7758            return Err(io::Error::other("json output did not end with newline").into());
7759        }
7760        if json.len() <= toon.len() {
7761            return Err(io::Error::other("json output was not larger than toon fixture").into());
7762        }
7763        Ok(())
7764    }
7765
7766    #[test]
7767    fn analysis_output_encoding_is_equivalent_and_cancellable() -> Result<(), Box<dyn Error>> {
7768        struct CancelDuringSerialize(IndexCancellation);
7769
7770        impl serde::Serialize for CancelDuringSerialize {
7771            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
7772            where
7773                S: serde::Serializer,
7774            {
7775                use serde::ser::SerializeSeq as _;
7776
7777                let mut sequence = serializer.serialize_seq(Some(2))?;
7778                sequence.serialize_element(&1_u8)?;
7779                self.0.cancel();
7780                sequence.serialize_element(&2_u8)?;
7781                sequence.end()
7782            }
7783        }
7784
7785        let payload = json!({ "mode": "impact", "findings": [1, 2, 3] });
7786        let control = IndexWorkControl::new(IndexCancellation::new(), None);
7787        let expected =
7788            projectatlas_core::toon::encode_agent_payload(&json!({ "symbol_relations": payload }));
7789        let encoded =
7790            controlled_named_output(OutputFormat::Toon, "symbol_relations", &payload, &control)?;
7791        if encoded != expected {
7792            return Err(io::Error::other("controlled TOON output changed its wire format").into());
7793        }
7794
7795        let cancellation = IndexCancellation::new();
7796        let control = IndexWorkControl::new(cancellation.clone(), None);
7797        let result = controlled_named_output(
7798            OutputFormat::Json,
7799            "symbol_relations",
7800            &CancelDuringSerialize(cancellation),
7801            &control,
7802        );
7803        if !matches!(
7804            result,
7805            Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
7806                stage: IndexWorkStage::RepositoryTraversal
7807            }))
7808        ) {
7809            return Err(
7810                io::Error::other("analysis adapter continued encoding after cancellation").into(),
7811            );
7812        }
7813        Ok(())
7814    }
7815
7816    /// Build a compact test symbol.
7817    fn test_symbol(path: &str, kind: SymbolKind, name: &str) -> CodeSymbol {
7818        CodeSymbol {
7819            path: path.to_string(),
7820            language: Some("rust".to_string()),
7821            name: name.to_string(),
7822            kind,
7823            signature: name.to_string(),
7824            exported: false,
7825            documentation: None,
7826            line_start: 1,
7827            line_end: 1,
7828            source_selector: None,
7829            parent: None,
7830            parser: ParserKind::TreeSitter,
7831            detail: None,
7832        }
7833    }
7834
7835    /// Build a compact test relation.
7836    fn test_relation(path: &str, kind: RelationKind, target: &str) -> SymbolRelation {
7837        SymbolRelation {
7838            path: path.to_string(),
7839            source_name: "module".to_string(),
7840            target_name: target.to_string(),
7841            kind,
7842            line: 1,
7843            context: target.to_string(),
7844            parser: ParserKind::TreeSitter,
7845        }
7846    }
7847
7848    #[tokio::test]
7849    async fn mcp_tools_return_toon_text_payloads() -> Result<(), Box<dyn Error>> {
7850        let temp = tempfile::tempdir()?;
7851        let repo = temp.path().join("repo");
7852        fs::create_dir(&repo)?;
7853        fs::create_dir(repo.join("src"))?;
7854        fs::create_dir(repo.join("assets"))?;
7855        fs::write(
7856            repo.join("src").join("main.rs"),
7857            "fn main() {\n    helper();\n}\n\nfn helper() {}\n",
7858        )?;
7859        fs::write(repo.join("src").join("detail.rs"), "fn detail() {}\n")?;
7860        fs::write(
7861            repo.join("assets").join("logo.svg"),
7862            "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
7863        )?;
7864        let db = repo.join(".projectatlas").join("projectatlas.db");
7865        let server = ProjectAtlasMcpServer::new(db, None, "mcp-test".to_string(), false);
7866        let (server_transport, client_transport) = tokio::io::duplex(16_384);
7867        let server_handle = tokio::spawn(async move {
7868            server
7869                .serve(server_transport)
7870                .await
7871                .map_err(|error| error.to_string())?
7872                .waiting()
7873                .await
7874                .map_err(|error| error.to_string())?;
7875            Ok::<(), String>(())
7876        });
7877        let client = TestMcpClient.serve(client_transport).await?;
7878        let tools = client.peer().list_tools(Option::default()).await?;
7879        for required_tool in REQUIRED_MCP_TOOL_NAMES {
7880            if !tools.tools.iter().any(|tool| tool.name == *required_tool) {
7881                return Err(format!("{required_tool} tool was not registered").into());
7882            }
7883        }
7884        if tools.tools.len() != REQUIRED_MCP_TOOL_NAMES.len() {
7885            return Err(format!(
7886                "MCP inventory grew outside the closed required surface: {} != {}",
7887                tools.tools.len(),
7888                REQUIRED_MCP_TOOL_NAMES.len()
7889            )
7890            .into());
7891        }
7892        let schema_has_property =
7893            |tool_name: &str, property: &str| -> Result<bool, Box<dyn Error>> {
7894                let tool = tools
7895                    .tools
7896                    .iter()
7897                    .find(|tool| tool.name.as_ref() == tool_name)
7898                    .ok_or_else(|| std::io::Error::other(format!("{tool_name} missing")))?;
7899                let schema = serde_json::to_value(&tool.input_schema)?;
7900                Ok(schema
7901                    .get("properties")
7902                    .and_then(Value::as_object)
7903                    .is_some_and(|properties| properties.contains_key(property)))
7904            };
7905        if schema_has_property("atlas_folders", "nearest_project")? {
7906            return Err("atlas_folders advertised unused nearest_project parameter".into());
7907        }
7908        if !schema_has_property("atlas_files", "nearest_project")? {
7909            return Err("atlas_files did not advertise nearest_project parameter".into());
7910        }
7911        if schema_has_property("atlas_next", "nearest_project")? {
7912            return Err("atlas_next advertised unused nearest_project parameter".into());
7913        }
7914        if !schema_has_property("atlas_root_set", "transition")? {
7915            return Err("atlas_root_set did not advertise the transition selector".into());
7916        }
7917        if schema_has_property("atlas_health", "task")? {
7918            return Err("atlas_health advertised the purpose-queue task parameter".into());
7919        }
7920        if !schema_has_property("atlas_purpose_queue", "task")? {
7921            return Err("atlas_purpose_queue did not advertise the curator task parameter".into());
7922        }
7923        if !schema_has_property("atlas_session_brief", "purpose_task")?
7924            || !schema_has_property("atlas_session_brief", "purpose_limit")?
7925        {
7926            return Err("atlas_session_brief did not advertise purpose handoff controls".into());
7927        }
7928
7929        let scan = client
7930            .peer()
7931            .call_tool(CallToolRequestParams::new("atlas_scan").with_arguments(Map::new()))
7932            .await?;
7933        let scan_text = scan
7934            .content
7935            .first()
7936            .and_then(|content| content.as_text())
7937            .map(|text| text.text.as_str())
7938            .ok_or_else(|| std::io::Error::other("scan result did not contain text"))?;
7939        if !scan_text.contains("scan:") {
7940            return Err("atlas_scan result did not contain scan payload".into());
7941        }
7942        if !scan_text.contains("symbols:") {
7943            return Err("atlas_scan result did not contain symbols payload".into());
7944        }
7945
7946        let mut symbols_args = Map::new();
7947        symbols_args.insert("file".to_string(), json!("src/main.rs"));
7948        let symbols = client
7949            .peer()
7950            .call_tool(CallToolRequestParams::new("atlas_symbols").with_arguments(symbols_args))
7951            .await?;
7952        let symbols_text = symbols
7953            .content
7954            .first()
7955            .and_then(|content| content.as_text())
7956            .map(|text| text.text.as_str())
7957            .ok_or_else(|| std::io::Error::other("symbols result did not contain text"))?;
7958        if !symbols_text.contains("symbols[") {
7959            return Err("atlas_symbols result did not contain symbols table".into());
7960        }
7961        if !symbols_text.contains("helper") {
7962            return Err("atlas_symbols result did not contain helper symbol".into());
7963        }
7964
7965        let mut summary_args = Map::new();
7966        summary_args.insert("file".to_string(), json!("src/main.rs"));
7967        let summary = client
7968            .peer()
7969            .call_tool(
7970                CallToolRequestParams::new("atlas_file_summary").with_arguments(summary_args),
7971            )
7972            .await?;
7973        let summary_text = summary
7974            .content
7975            .first()
7976            .and_then(|content| content.as_text())
7977            .map(|text| text.text.as_str())
7978            .ok_or_else(|| std::io::Error::other("summary result did not contain text"))?;
7979        if !summary_text.contains("file_summary:") {
7980            return Err("atlas_file_summary result did not contain summary payload".into());
7981        }
7982        if !summary_text.contains("file_purpose_status: suggested") {
7983            return Err("atlas_file_summary result did not expose purpose status".into());
7984        }
7985        if !summary_text.contains("parser_kind: \"tree-sitter-symbol-graph\"") {
7986            return Err("atlas_file_summary result did not expose parser kind".into());
7987        }
7988        if !summary_text.contains("summary_status: ok") {
7989            return Err("atlas_file_summary result did not expose summary status".into());
7990        }
7991        if !summary_text.contains("helper") {
7992            return Err("atlas_file_summary result did not contain helper symbol".into());
7993        }
7994
7995        let outside_path = temp.path().join("outside-project.txt");
7996        fs::write(&outside_path, "outside repo proof")?;
7997        let mut slice_args = Map::new();
7998        slice_args.insert(
7999            "file".to_string(),
8000            json!(outside_path.to_string_lossy().to_string()),
8001        );
8002        slice_args.insert("start_line".to_string(), json!(1));
8003        let slice = client
8004            .peer()
8005            .call_tool(CallToolRequestParams::new("atlas_slice").with_arguments(slice_args))
8006            .await?;
8007        let slice_text = slice
8008            .content
8009            .first()
8010            .and_then(|content| content.as_text())
8011            .map(|text| text.text.as_str())
8012            .ok_or_else(|| std::io::Error::other("slice result did not contain text"))?;
8013        if !slice_text.contains("indexed ProjectAtlas project")
8014            || !slice_text.contains("Get-Content")
8015        {
8016            return Err(format!(
8017                "atlas_slice did not reject outside-repository absolute paths: {slice_text}"
8018            )
8019            .into());
8020        }
8021
8022        let token_report = client
8023            .peer()
8024            .call_tool(CallToolRequestParams::new("atlas_token_report").with_arguments(Map::new()))
8025            .await?;
8026        let token_text = token_report
8027            .content
8028            .first()
8029            .and_then(|content| content.as_text())
8030            .map(|text| text.text.as_str())
8031            .ok_or_else(|| std::io::Error::other("token report did not contain text"))?;
8032        if !token_text.contains("token_savings:") {
8033            return Err("atlas_token_report result did not contain token payload".into());
8034        }
8035        if truthy_env("PROJECTATLAS_NO_TELEMETRY") {
8036            if !token_text.contains("calls: 0") {
8037                return Err("atlas_token_report recorded MCP usage in no-telemetry mode".into());
8038            }
8039        } else {
8040            if !token_text.contains("calls: 2") {
8041                return Err("atlas_token_report did not count MCP usage events".into());
8042            }
8043            if !token_text.contains("buckets[") || !token_text.contains("heuristic_estimate") {
8044                return Err(
8045                    "atlas_token_report result did not contain bucket accuracy labels".into(),
8046                );
8047            }
8048        }
8049
8050        let parity_report = client
8051            .peer()
8052            .call_tool(CallToolRequestParams::new("atlas_parity_report").with_arguments(Map::new()))
8053            .await?;
8054        let parity_text = parity_report
8055            .content
8056            .first()
8057            .and_then(|content| content.as_text())
8058            .map(|text| text.text.as_str())
8059            .ok_or_else(|| std::io::Error::other("parity report did not contain text"))?;
8060        if !parity_text.contains("parity:")
8061            || !parity_text.contains("profile: \"repository-intelligence\"")
8062        {
8063            return Err("atlas_parity_report result did not contain parity payload".into());
8064        }
8065
8066        let mut health_args = Map::new();
8067        health_args.insert("category".to_string(), json!("missing-purpose"));
8068        health_args.insert("path_prefix".to_string(), json!(".\\src\\"));
8069        health_args.insert("limit".to_string(), json!(1));
8070        let health = client
8071            .peer()
8072            .call_tool(CallToolRequestParams::new("atlas_health").with_arguments(health_args))
8073            .await?;
8074        let health_text = health
8075            .content
8076            .first()
8077            .and_then(|content| content.as_text())
8078            .map(|text| text.text.as_str())
8079            .ok_or_else(|| std::io::Error::other("health result did not contain text"))?;
8080        if !health_text.contains("health:")
8081            || !health_text.contains("returned: 1")
8082            || !health_text.contains("limit: 1")
8083            || !health_text.contains("next_start_index: null")
8084            || !health_text.contains("source_only: false")
8085            || !health_text.contains("path_prefix: src")
8086            || !health_text.contains("health_findings[1]")
8087            || health_text.contains("suggested-purpose-review")
8088        {
8089            return Err(
8090                format!("atlas_health result was not bounded and filtered: {health_text}").into(),
8091            );
8092        }
8093
8094        let mut coverage_health_args = Map::new();
8095        coverage_health_args.insert("coverage".to_string(), json!(true));
8096        coverage_health_args.insert("path_prefix".to_string(), json!("src"));
8097        coverage_health_args.insert("limit".to_string(), json!(1));
8098        let coverage_health = client
8099            .peer()
8100            .call_tool(
8101                CallToolRequestParams::new("atlas_health").with_arguments(coverage_health_args),
8102            )
8103            .await?;
8104        let coverage_health_text = coverage_health
8105            .content
8106            .first()
8107            .and_then(|content| content.as_text())
8108            .map(|text| text.text.as_str())
8109            .ok_or_else(|| io::Error::other("coverage health result did not contain text"))?;
8110        if !coverage_health_text.contains("coverage:")
8111            || !coverage_health_text.contains("limit: 1")
8112            || !coverage_health_text.contains("path: src/detail.rs")
8113        {
8114            return Err(format!(
8115                "atlas_health coverage result lost structured filtered output: {coverage_health_text}"
8116            )
8117            .into());
8118        }
8119
8120        let mut summary_health_args = Map::new();
8121        summary_health_args.insert("category".to_string(), json!("missing-purpose"));
8122        summary_health_args.insert("path_prefix".to_string(), json!(".\\src\\"));
8123        summary_health_args.insert("limit".to_string(), json!(1));
8124        summary_health_args.insert("summary_only".to_string(), json!(true));
8125        let summary_health = client
8126            .peer()
8127            .call_tool(
8128                CallToolRequestParams::new("atlas_health").with_arguments(summary_health_args),
8129            )
8130            .await?;
8131        let summary_health_text = summary_health
8132            .content
8133            .first()
8134            .and_then(|content| content.as_text())
8135            .map(|text| text.text.as_str())
8136            .ok_or_else(|| std::io::Error::other("summary health result did not contain text"))?;
8137        if !summary_health_text.contains("returned: 0")
8138            || !summary_health_text.contains("limit: 1")
8139            || !summary_health_text.contains("next_start_index: null")
8140            || !summary_health_text.contains("summary_only: true")
8141            || !summary_health_text.contains("health_findings[0]")
8142        {
8143            return Err(format!(
8144                "atlas_health summary_only result lost paging metadata: {summary_health_text}"
8145            )
8146            .into());
8147        }
8148
8149        let mut purpose_queue_args = Map::new();
8150        purpose_queue_args.insert("task".to_string(), json!("mcp-smoke-purpose"));
8151        let purpose_queue = client
8152            .peer()
8153            .call_tool(
8154                CallToolRequestParams::new("atlas_purpose_queue")
8155                    .with_arguments(purpose_queue_args),
8156            )
8157            .await?;
8158        let purpose_queue_text = purpose_queue
8159            .content
8160            .first()
8161            .and_then(|content| content.as_text())
8162            .map(|text| text.text.as_str())
8163            .ok_or_else(|| std::io::Error::other("purpose queue result did not contain text"))?;
8164        if !purpose_queue_text.contains("purpose_curation:")
8165            || !purpose_queue_text.contains("project_instance_id:")
8166            || !purpose_queue_text.contains("active_generation:")
8167            || !purpose_queue_text.contains("task: \"mcp-smoke-purpose\"")
8168            || !purpose_queue_text.contains("work_key:")
8169            || !purpose_queue_text.contains("actionable: true")
8170            || !purpose_queue_text.contains("curation_scope: low")
8171            || !purpose_queue_text.contains("source_only: true")
8172            || !purpose_queue_text.contains("folder_scope: all")
8173            || !purpose_queue_text.contains("file_scope: high_impact")
8174            || !purpose_queue_text.contains("purpose_curation_items[")
8175            || !purpose_queue_text.contains("work_key,state_token")
8176            || !purpose_queue_text.contains("purpose_agent_reviewed,review_priority,review_reason")
8177            || !purpose_queue_text.contains("false,high,high_impact_file")
8178            || !purpose_queue_text.contains("suggested-purpose-review:src/main.rs:")
8179            || purpose_queue_text.contains("suggested-purpose-review:src/detail.rs:")
8180            || purpose_queue_text.contains("assets/logo.svg")
8181        {
8182            return Err(format!(
8183                "atlas_purpose_queue result did not contain folder-first curation payload: {purpose_queue_text}"
8184            )
8185            .into());
8186        }
8187
8188        let mut asset_queue_args = Map::new();
8189        asset_queue_args.insert("include_assets".to_string(), json!(true));
8190        let asset_purpose_queue = client
8191            .peer()
8192            .call_tool(
8193                CallToolRequestParams::new("atlas_purpose_queue").with_arguments(asset_queue_args),
8194            )
8195            .await?;
8196        let asset_purpose_queue_text = asset_purpose_queue
8197            .content
8198            .first()
8199            .and_then(|content| content.as_text())
8200            .map(|text| text.text.as_str())
8201            .ok_or_else(|| {
8202                std::io::Error::other("asset purpose queue result did not contain text")
8203            })?;
8204        if !asset_purpose_queue_text.contains("source_only: false")
8205            || !asset_purpose_queue_text.contains("folder_scope: all")
8206            || !asset_purpose_queue_text.contains("file_scope: high_impact_and_assets")
8207            || !asset_purpose_queue_text.contains("missing-purpose:assets/logo.svg:")
8208            || asset_purpose_queue_text.contains("suggested-purpose-review:src/detail.rs:")
8209        {
8210            return Err(format!(
8211                "atlas_purpose_queue include_assets did not include assets without low-priority source cleanup: {asset_purpose_queue_text}"
8212            )
8213            .into());
8214        }
8215
8216        let mut broad_queue_args = Map::new();
8217        broad_queue_args.insert("include_low_priority_files".to_string(), json!(true));
8218        let broad_purpose_queue = client
8219            .peer()
8220            .call_tool(
8221                CallToolRequestParams::new("atlas_purpose_queue").with_arguments(broad_queue_args),
8222            )
8223            .await?;
8224        let broad_purpose_queue_text = broad_purpose_queue
8225            .content
8226            .first()
8227            .and_then(|content| content.as_text())
8228            .map(|text| text.text.as_str())
8229            .ok_or_else(|| {
8230                std::io::Error::other("broad purpose queue result did not contain text")
8231            })?;
8232        if !broad_purpose_queue_text.contains("suggested-purpose-review:src/detail.rs:")
8233            || !broad_purpose_queue_text.contains("folder_scope: source_relevant")
8234            || !broad_purpose_queue_text.contains("file_scope: all_source")
8235            || !broad_purpose_queue_text.contains("false,low,generated_file_suggestion")
8236        {
8237            return Err(format!(
8238                "atlas_purpose_queue include_low_priority_files missed low-priority file payload: {broad_purpose_queue_text}"
8239            )
8240            .into());
8241        }
8242
8243        client.cancel().await?;
8244        server_handle.await?.map_err(std::io::Error::other)?;
8245        Ok(())
8246    }
8247
8248    #[tokio::test]
8249    async fn mcp_project_path_overrides_keep_projects_isolated() -> Result<(), Box<dyn Error>> {
8250        let temp = tempfile::tempdir()?;
8251        let repo_a = temp.path().join("repo-a");
8252        let repo_b = temp.path().join("repo-b");
8253        for repo in [&repo_a, &repo_b] {
8254            fs::create_dir(repo)?;
8255            fs::create_dir(repo.join("src"))?;
8256        }
8257        fs::write(
8258            repo_a.join("src").join("lib.rs"),
8259            "pub fn alpha_project_a_marker() {}\n",
8260        )?;
8261        fs::write(
8262            repo_b.join("src").join("lib.rs"),
8263            "pub fn beta_project_b_marker() {}\n",
8264        )?;
8265
8266        let db_a = repo_a.join(".projectatlas").join("projectatlas.db");
8267        let db_b = repo_b.join(".projectatlas").join("projectatlas.db");
8268        let server = ProjectAtlasMcpServer::new(
8269            db_a.clone(),
8270            None,
8271            "mcp-multi-project-test".to_string(),
8272            false,
8273        );
8274        let (server_transport, client_transport) = tokio::io::duplex(16_384);
8275        let server_handle = tokio::spawn(async move {
8276            server
8277                .serve(server_transport)
8278                .await
8279                .map_err(|error| error.to_string())?
8280                .waiting()
8281                .await
8282                .map_err(|error| error.to_string())?;
8283            Ok::<(), String>(())
8284        });
8285        let client = TestMcpClient.serve(client_transport).await?;
8286
8287        macro_rules! call_text {
8288            ($tool:literal, $args:expr) => {{
8289                let result = client
8290                    .peer()
8291                    .call_tool(CallToolRequestParams::new($tool).with_arguments($args))
8292                    .await?;
8293                result
8294                    .content
8295                    .first()
8296                    .and_then(|content| content.as_text())
8297                    .map(|text| text.text.clone())
8298                    .ok_or_else(|| {
8299                        std::io::Error::other(format!("{} result did not contain text", $tool))
8300                    })?
8301            }};
8302        }
8303
8304        let scan_a = call_text!("atlas_scan", Map::new());
8305        if !scan_a.contains("scan:") {
8306            return Err("default atlas_scan did not scan the startup project".into());
8307        }
8308        let db_a_before_repo_b_scan = fs::read(&db_a)?;
8309        let db_a_hash_before_repo_b_scan = blake3::hash(&db_a_before_repo_b_scan);
8310        let db_a_metadata_before_repo_b_scan = fs::metadata(&db_a)?;
8311
8312        let mut wrong_path_scan_args = Map::new();
8313        wrong_path_scan_args.insert(
8314            "path".to_string(),
8315            json!(repo_b.to_string_lossy().to_string()),
8316        );
8317        let wrong_path_scan = call_text!("atlas_scan", wrong_path_scan_args);
8318        if !wrong_path_scan.contains("outside the selected project root")
8319            || !wrong_path_scan.contains("normal filesystem tools")
8320        {
8321            return Err(format!(
8322                "atlas_scan allowed unindexed path-based access outside the active project: {wrong_path_scan}"
8323            )
8324            .into());
8325        }
8326
8327        let mut scan_b_args = Map::new();
8328        scan_b_args.insert(
8329            "project_path".to_string(),
8330            json!(repo_b.to_string_lossy().to_string()),
8331        );
8332        let scan_b = call_text!("atlas_scan", scan_b_args);
8333        if !scan_b.contains("scan:") {
8334            return Err("project_path-selected atlas_scan did not scan repo B".into());
8335        }
8336        let db_a_after_repo_b_scan = fs::read(&db_a)?;
8337        let db_a_hash_after_repo_b_scan = blake3::hash(&db_a_after_repo_b_scan);
8338        let db_a_metadata_after_repo_b_scan = fs::metadata(&db_a)?;
8339        if db_a_hash_after_repo_b_scan != db_a_hash_before_repo_b_scan
8340            || db_a_metadata_after_repo_b_scan.len() != db_a_metadata_before_repo_b_scan.len()
8341        {
8342            return Err(
8343                "project_path-selected atlas_scan mutated the startup project database".into(),
8344            );
8345        }
8346        if !db_b.exists() {
8347            return Err("project_path-selected atlas_scan did not create repo B database".into());
8348        }
8349
8350        let mut absolute_summary_a_args = Map::new();
8351        absolute_summary_a_args.insert(
8352            "file".to_string(),
8353            json!(
8354                repo_a
8355                    .join("src")
8356                    .join("lib.rs")
8357                    .to_string_lossy()
8358                    .to_string()
8359            ),
8360        );
8361        let absolute_summary_a = call_text!("atlas_file_summary", absolute_summary_a_args);
8362        if !absolute_summary_a.contains("alpha_project_a_marker")
8363            || !absolute_summary_a.contains("file_path: src/lib.rs")
8364        {
8365            return Err(format!(
8366                "absolute file path inside selected project was not accepted: {absolute_summary_a}"
8367            )
8368            .into());
8369        }
8370
8371        let mut active_subdir_scan_args = Map::new();
8372        active_subdir_scan_args.insert(
8373            "path".to_string(),
8374            json!(repo_a.join("src").to_string_lossy().to_string()),
8375        );
8376        active_subdir_scan_args.insert("nearest_project".to_string(), json!(true));
8377        let active_subdir_scan = call_text!("atlas_scan", active_subdir_scan_args);
8378        if active_subdir_scan.contains("scan:")
8379            || !active_subdir_scan.contains("not the selected project root")
8380            || active_subdir_scan.contains("selected_project:")
8381        {
8382            return Err(format!(
8383                "nearest_project bypassed root assertion for active subdirectory: {active_subdir_scan}"
8384            )
8385            .into());
8386        }
8387        let mut active_relative_subdir_scan_args = Map::new();
8388        active_relative_subdir_scan_args.insert("path".to_string(), json!("src"));
8389        active_relative_subdir_scan_args.insert("nearest_project".to_string(), json!(true));
8390        let active_relative_subdir_scan =
8391            call_text!("atlas_scan", active_relative_subdir_scan_args);
8392        if active_relative_subdir_scan.contains("scan:")
8393            || !active_relative_subdir_scan.contains("not the selected project root")
8394            || active_relative_subdir_scan.contains("selected_project:")
8395        {
8396            return Err(format!(
8397                "nearest_project bypassed root assertion for relative active subdirectory: {active_relative_subdir_scan}"
8398            )
8399            .into());
8400        }
8401
8402        let mut indexed_path_scan_b_args = Map::new();
8403        indexed_path_scan_b_args.insert(
8404            "path".to_string(),
8405            json!(repo_b.to_string_lossy().to_string()),
8406        );
8407        let indexed_path_scan_b = call_text!("atlas_scan", indexed_path_scan_b_args.clone());
8408        if indexed_path_scan_b.contains("scan:")
8409            || !indexed_path_scan_b.contains("outside the selected project root")
8410            || !indexed_path_scan_b.contains("Get-Content")
8411        {
8412            return Err(format!(
8413                "default-off atlas_scan routed to another indexed project: {indexed_path_scan_b}"
8414            )
8415            .into());
8416        }
8417        indexed_path_scan_b_args.insert("nearest_project".to_string(), json!(true));
8418        let indexed_path_scan_b = call_text!("atlas_scan", indexed_path_scan_b_args);
8419        if !indexed_path_scan_b.contains("scan:") {
8420            return Err("atlas_scan nearest_project override did not route indexed repo B".into());
8421        }
8422        let mut indexed_subdir_scan_b_args = Map::new();
8423        indexed_subdir_scan_b_args.insert(
8424            "path".to_string(),
8425            json!(repo_b.join("src").to_string_lossy().to_string()),
8426        );
8427        indexed_subdir_scan_b_args.insert("nearest_project".to_string(), json!(true));
8428        let indexed_subdir_scan_b = call_text!("atlas_scan", indexed_subdir_scan_b_args);
8429        if indexed_subdir_scan_b.contains("scan:")
8430            || !indexed_subdir_scan_b.contains("outside the selected project root")
8431            || indexed_subdir_scan_b.contains("selected_project:")
8432        {
8433            return Err(format!(
8434                "nearest_project treated an indexed project subdirectory as a root assertion: {indexed_subdir_scan_b}"
8435            )
8436            .into());
8437        }
8438
8439        let mut absolute_summary_b_args = Map::new();
8440        absolute_summary_b_args.insert(
8441            "file".to_string(),
8442            json!(
8443                repo_b
8444                    .join("src")
8445                    .join("lib.rs")
8446                    .to_string_lossy()
8447                    .to_string()
8448            ),
8449        );
8450        let rejected_summary_b = call_text!("atlas_file_summary", absolute_summary_b_args.clone());
8451        if rejected_summary_b.contains("beta_project_b_marker")
8452            || !rejected_summary_b.contains("indexed ProjectAtlas project")
8453            || !rejected_summary_b.contains("Get-Content")
8454        {
8455            return Err(format!(
8456                "default-off absolute file routing did not fall back to filesystem guidance: {rejected_summary_b}"
8457            )
8458            .into());
8459        }
8460        absolute_summary_b_args.insert("nearest_project".to_string(), json!(true));
8461        let absolute_summary_b = call_text!("atlas_file_summary", absolute_summary_b_args);
8462        if !absolute_summary_b.contains("beta_project_b_marker")
8463            || !absolute_summary_b.contains("file_path: src/lib.rs")
8464        {
8465            return Err(format!(
8466                "absolute file path did not route to nearest indexed repo B with override: {absolute_summary_b}"
8467            )
8468            .into());
8469        }
8470        require_selected_project_audit(
8471            &absolute_summary_b,
8472            &repo_b,
8473            &db_b,
8474            "nearest-routed file summary",
8475        )?;
8476
8477        let mut absolute_slice_b_args = Map::new();
8478        absolute_slice_b_args.insert(
8479            "file".to_string(),
8480            json!(
8481                repo_b
8482                    .join("src")
8483                    .join("lib.rs")
8484                    .to_string_lossy()
8485                    .to_string()
8486            ),
8487        );
8488        absolute_slice_b_args.insert("start_line".to_string(), json!(1));
8489        absolute_slice_b_args.insert("end_line".to_string(), json!(1));
8490        let rejected_slice_b = call_text!("atlas_slice", absolute_slice_b_args.clone());
8491        if rejected_slice_b.contains("beta_project_b_marker")
8492            || !rejected_slice_b.contains("indexed ProjectAtlas project")
8493            || !rejected_slice_b.contains("Get-Content")
8494        {
8495            return Err(format!(
8496                "default-off atlas_slice read another project instead of returning filesystem guidance: {rejected_slice_b}"
8497            )
8498            .into());
8499        }
8500        absolute_slice_b_args.insert("nearest_project".to_string(), json!(true));
8501        let absolute_slice_b = call_text!("atlas_slice", absolute_slice_b_args);
8502        if !absolute_slice_b.contains("beta_project_b_marker") {
8503            return Err("atlas_slice nearest_project override did not route indexed repo B".into());
8504        }
8505        require_selected_project_audit(&absolute_slice_b, &repo_b, &db_b, "nearest-routed slice")?;
8506
8507        let mut absolute_files_b_args = Map::new();
8508        absolute_files_b_args.insert("query".to_string(), json!("beta"));
8509        absolute_files_b_args.insert(
8510            "folder".to_string(),
8511            json!(repo_b.join("src").to_string_lossy().to_string()),
8512        );
8513        let rejected_files_b = call_text!("atlas_files", absolute_files_b_args.clone());
8514        if rejected_files_b.contains("src/lib.rs")
8515            || !rejected_files_b.contains("indexed ProjectAtlas project")
8516            || !rejected_files_b.contains("Get-Content")
8517        {
8518            return Err(format!(
8519                "default-off atlas_files routed another project folder: {rejected_files_b}"
8520            )
8521            .into());
8522        }
8523        absolute_files_b_args.insert("nearest_project".to_string(), json!(true));
8524        let absolute_files_b = call_text!("atlas_files", absolute_files_b_args);
8525        if !absolute_files_b.contains("src/lib.rs")
8526            || absolute_files_b.contains("alpha_project_a_marker")
8527        {
8528            return Err(
8529                "absolute folder path did not route file ranking to indexed repo B with override"
8530                    .into(),
8531            );
8532        }
8533        require_selected_project_audit(
8534            &absolute_files_b,
8535            &repo_b,
8536            &db_b,
8537            "nearest-routed file ranking",
8538        )?;
8539
8540        let mut explicit_project_summary_args = Map::new();
8541        explicit_project_summary_args.insert(
8542            "project_path".to_string(),
8543            json!(repo_a.to_string_lossy().to_string()),
8544        );
8545        explicit_project_summary_args.insert(
8546            "file".to_string(),
8547            json!(
8548                repo_b
8549                    .join("src")
8550                    .join("lib.rs")
8551                    .to_string_lossy()
8552                    .to_string()
8553            ),
8554        );
8555        explicit_project_summary_args.insert("nearest_project".to_string(), json!(true));
8556        let explicit_project_summary =
8557            call_text!("atlas_file_summary", explicit_project_summary_args);
8558        if explicit_project_summary.contains("beta_project_b_marker")
8559            || !explicit_project_summary.contains("indexed ProjectAtlas project")
8560            || !explicit_project_summary.contains("Get-Content")
8561        {
8562            return Err(format!(
8563                "explicit project_path did not stay isolated from nearest routing: {explicit_project_summary}"
8564            )
8565            .into());
8566        }
8567
8568        let nested_active_repo = repo_a.join("nested-active-project");
8569        fs::create_dir_all(nested_active_repo.join("src"))?;
8570        fs::write(
8571            nested_active_repo.join("src").join("lib.rs"),
8572            "pub fn nested_active_marker() { nested_active_helper(); }\nfn nested_active_helper() {}\n",
8573        )?;
8574        let mut scan_nested_active_args = Map::new();
8575        scan_nested_active_args.insert(
8576            "project_path".to_string(),
8577            json!(nested_active_repo.to_string_lossy().to_string()),
8578        );
8579        let scan_nested_active = call_text!("atlas_scan", scan_nested_active_args);
8580        if !scan_nested_active.contains("scan:") {
8581            return Err("project_path-selected atlas_scan did not scan nested active repo".into());
8582        }
8583        let nested_active_db = nested_active_repo
8584            .join(".projectatlas")
8585            .join("projectatlas.db");
8586        let nested_active_file = nested_active_repo.join("src").join("lib.rs");
8587        let mut nested_active_summary_args = Map::new();
8588        nested_active_summary_args.insert(
8589            "file".to_string(),
8590            json!(nested_active_file.to_string_lossy().to_string()),
8591        );
8592        let rejected_nested_active =
8593            call_text!("atlas_file_summary", nested_active_summary_args.clone());
8594        if rejected_nested_active.contains("nested_active_marker") {
8595            return Err("default-off routing read nested active child through nearest DB".into());
8596        }
8597        nested_active_summary_args.insert("nearest_project".to_string(), json!(true));
8598        let nested_active_summary =
8599            call_text!("atlas_file_summary", nested_active_summary_args.clone());
8600        if !nested_active_summary.contains("nested_active_marker")
8601            || !nested_active_summary.contains("file_path: src/lib.rs")
8602            || nested_active_summary.contains("nested-active-project/src/lib.rs")
8603        {
8604            return Err(format!(
8605                "nearest routing did not prefer nested child DB under active root: {nested_active_summary}"
8606            )
8607            .into());
8608        }
8609        require_selected_project_audit(
8610            &nested_active_summary,
8611            &nested_active_repo,
8612            &nested_active_db,
8613            "nearest-routed nested summary",
8614        )?;
8615        let nested_active_outline = call_text!("atlas_outline", nested_active_summary_args.clone());
8616        if !nested_active_outline.contains("nested_active_marker") {
8617            return Err("atlas_outline did not route to nested child DB".into());
8618        }
8619        require_selected_project_audit(
8620            &nested_active_outline,
8621            &nested_active_repo,
8622            &nested_active_db,
8623            "nearest-routed nested outline",
8624        )?;
8625        let mut nested_active_slice_args = nested_active_summary_args.clone();
8626        nested_active_slice_args.insert("start_line".to_string(), json!(1));
8627        nested_active_slice_args.insert("end_line".to_string(), json!(1));
8628        let nested_active_slice = call_text!("atlas_slice", nested_active_slice_args);
8629        if !nested_active_slice.contains("nested_active_marker") {
8630            return Err("atlas_slice did not route to nested child DB".into());
8631        }
8632        require_selected_project_audit(
8633            &nested_active_slice,
8634            &nested_active_repo,
8635            &nested_active_db,
8636            "nearest-routed nested slice",
8637        )?;
8638        let mut nested_active_symbols_args = Map::new();
8639        nested_active_symbols_args.insert(
8640            "file".to_string(),
8641            json!(nested_active_file.to_string_lossy().to_string()),
8642        );
8643        nested_active_symbols_args.insert("query".to_string(), json!("nested_active_marker"));
8644        nested_active_symbols_args.insert("nearest_project".to_string(), json!(true));
8645        let nested_active_symbols = call_text!("atlas_symbols", nested_active_symbols_args.clone());
8646        if !nested_active_symbols.contains("nested_active_marker") {
8647            return Err("atlas_symbols did not route to nested child DB".into());
8648        }
8649        require_selected_project_audit(
8650            &nested_active_symbols,
8651            &nested_active_repo,
8652            &nested_active_db,
8653            "nearest-routed nested symbols",
8654        )?;
8655        // This route exercises the legacy substring filter; detailed uses exact anchors.
8656        nested_active_symbols_args.insert("view".to_string(), json!("legacy"));
8657        let nested_active_relations =
8658            call_text!("atlas_symbol_relations", nested_active_symbols_args);
8659        if !nested_active_relations.contains("nested_active_marker") {
8660            return Err("atlas_symbol_relations did not route to nested child DB".into());
8661        }
8662        require_selected_project_audit(
8663            &nested_active_relations,
8664            &nested_active_repo,
8665            &nested_active_db,
8666            "nearest-routed nested symbol relations",
8667        )?;
8668        let mut nested_active_files_args = Map::new();
8669        nested_active_files_args.insert("query".to_string(), json!("nested_active_marker"));
8670        nested_active_files_args.insert(
8671            "folder".to_string(),
8672            json!(nested_active_repo.join("src").to_string_lossy().to_string()),
8673        );
8674        nested_active_files_args.insert("include_content".to_string(), json!(true));
8675        nested_active_files_args.insert("nearest_project".to_string(), json!(true));
8676        let nested_active_files = call_text!("atlas_files", nested_active_files_args);
8677        if !nested_active_files.contains("src/lib.rs")
8678            || nested_active_files.contains("nested-active-project/src/lib.rs")
8679        {
8680            return Err("atlas_files did not route folder filter to nested child DB".into());
8681        }
8682        require_selected_project_audit(
8683            &nested_active_files,
8684            &nested_active_repo,
8685            &nested_active_db,
8686            "nearest-routed nested file ranking",
8687        )?;
8688
8689        let empty_repo = temp.path().join("repo-empty");
8690        fs::create_dir_all(empty_repo.join("src"))?;
8691        fs::write(
8692            empty_repo.join("src").join("lib.rs"),
8693            "pub fn unindexed_project_marker() {}\n",
8694        )?;
8695        let mut missing_index_file_args = Map::new();
8696        missing_index_file_args.insert(
8697            "file".to_string(),
8698            json!(
8699                empty_repo
8700                    .join("src")
8701                    .join("lib.rs")
8702                    .to_string_lossy()
8703                    .to_string()
8704            ),
8705        );
8706        missing_index_file_args.insert("nearest_project".to_string(), json!(true));
8707        let missing_index_file = call_text!("atlas_file_summary", missing_index_file_args);
8708        if !missing_index_file.contains("indexed ProjectAtlas project")
8709            || !missing_index_file.contains("Get-Content")
8710            || empty_repo.join(".projectatlas").exists()
8711        {
8712            return Err(
8713                "absolute file routing did not fail cleanly when no ancestor DB exists".into(),
8714            );
8715        }
8716
8717        let partial_repo = temp.path().join("repo-partial-atlas");
8718        fs::create_dir_all(partial_repo.join(".projectatlas"))?;
8719        fs::create_dir_all(partial_repo.join("src"))?;
8720        fs::write(
8721            partial_repo.join("src").join("lib.rs"),
8722            "pub fn partial_project_marker() {}\n",
8723        )?;
8724        let mut partial_index_file_args = Map::new();
8725        partial_index_file_args.insert(
8726            "file".to_string(),
8727            json!(
8728                partial_repo
8729                    .join("src")
8730                    .join("lib.rs")
8731                    .to_string_lossy()
8732                    .to_string()
8733            ),
8734        );
8735        partial_index_file_args.insert("nearest_project".to_string(), json!(true));
8736        let partial_index_file = call_text!("atlas_file_summary", partial_index_file_args);
8737        if !partial_index_file.contains("indexed ProjectAtlas project")
8738            || !partial_index_file.contains("Get-Content")
8739            || partial_repo
8740                .join(".projectatlas")
8741                .join("projectatlas.db")
8742                .exists()
8743        {
8744            return Err(
8745                "nearest routing treated a .projectatlas folder without DB as indexed".into(),
8746            );
8747        }
8748
8749        let invalid_db_repo = temp.path().join("repo-invalid-db");
8750        fs::create_dir_all(invalid_db_repo.join(".projectatlas"))?;
8751        fs::create_dir_all(invalid_db_repo.join("src"))?;
8752        fs::write(
8753            invalid_db_repo.join("src").join("lib.rs"),
8754            "pub fn invalid_db_project_marker() {}\n",
8755        )?;
8756        let invalid_db = invalid_db_repo
8757            .join(".projectatlas")
8758            .join("projectatlas.db");
8759        fs::write(&invalid_db, [])?;
8760        let mut invalid_db_args = Map::new();
8761        invalid_db_args.insert(
8762            "file".to_string(),
8763            json!(
8764                invalid_db_repo
8765                    .join("src")
8766                    .join("lib.rs")
8767                    .to_string_lossy()
8768                    .to_string()
8769            ),
8770        );
8771        invalid_db_args.insert("nearest_project".to_string(), json!(true));
8772        let invalid_db_summary = call_text!("atlas_file_summary", invalid_db_args);
8773        if !invalid_db_summary.contains("indexed ProjectAtlas project")
8774            || !invalid_db_summary.contains("Get-Content")
8775            || fs::metadata(&invalid_db)?.len() != 0
8776            || invalid_db.with_extension("db-wal").exists()
8777            || invalid_db.with_extension("db-shm").exists()
8778        {
8779            return Err(format!(
8780                "nearest routing mutated or accepted an invalid candidate DB: {invalid_db_summary}"
8781            )
8782            .into());
8783        }
8784
8785        let nested_repo = repo_b.join("nested-project");
8786        fs::create_dir_all(nested_repo.join("src"))?;
8787        fs::write(
8788            nested_repo.join("src").join("lib.rs"),
8789            "pub fn nested_project_marker() {}\n",
8790        )?;
8791        let mut scan_nested_args = Map::new();
8792        scan_nested_args.insert(
8793            "project_path".to_string(),
8794            json!(nested_repo.to_string_lossy().to_string()),
8795        );
8796        let scan_nested = call_text!("atlas_scan", scan_nested_args);
8797        if !scan_nested.contains("scan:") {
8798            return Err("project_path-selected atlas_scan did not scan nested repo".into());
8799        }
8800        let mut nested_summary_args = Map::new();
8801        nested_summary_args.insert(
8802            "file".to_string(),
8803            json!(
8804                nested_repo
8805                    .join("src")
8806                    .join("lib.rs")
8807                    .to_string_lossy()
8808                    .to_string()
8809            ),
8810        );
8811        let rejected_nested_summary = call_text!("atlas_file_summary", nested_summary_args.clone());
8812        if rejected_nested_summary.contains("nested_project_marker")
8813            || !rejected_nested_summary.contains("indexed ProjectAtlas project")
8814            || !rejected_nested_summary.contains("Get-Content")
8815        {
8816            return Err(format!(
8817                "default-off nearest nested DB routing did not return filesystem guidance: {rejected_nested_summary}"
8818            )
8819            .into());
8820        }
8821        nested_summary_args.insert("nearest_project".to_string(), json!(true));
8822        let nested_summary = call_text!("atlas_file_summary", nested_summary_args.clone());
8823        if !nested_summary.contains("nested_project_marker")
8824            || !nested_summary.contains("file_path: src/lib.rs")
8825            || nested_summary.contains("nested-project/src/lib.rs")
8826        {
8827            return Err("nearest nested ProjectAtlas DB was not preferred".into());
8828        }
8829        fs::write(
8830            nested_repo.join("projectatlas.toml"),
8831            "[project]\nroot = \"..\"\n",
8832        )?;
8833        let nested_config_mismatch = call_text!("atlas_file_summary", nested_summary_args);
8834        if !nested_config_mismatch.contains("outside selected project root") {
8835            return Err("nearest DB routing did not reject config root mismatch".into());
8836        }
8837
8838        let linked_repo_b = repo_a.join("linked-repo-b");
8839        match create_directory_symlink(&repo_b, &linked_repo_b) {
8840            Ok(()) => {
8841                let mut linked_summary_args = Map::new();
8842                linked_summary_args.insert(
8843                    "file".to_string(),
8844                    json!(
8845                        linked_repo_b
8846                            .join("src")
8847                            .join("lib.rs")
8848                            .to_string_lossy()
8849                            .to_string()
8850                    ),
8851                );
8852                linked_summary_args.insert("nearest_project".to_string(), json!(true));
8853                let linked_summary = call_text!("atlas_file_summary", linked_summary_args);
8854                if linked_summary.contains("beta_project_b_marker")
8855                    || !linked_summary.contains("symlink or junction")
8856                    || !linked_summary.contains("multiple plausible ProjectAtlas roots")
8857                    || !linked_summary.contains("Get-Content")
8858                {
8859                    return Err(format!(
8860                        "nearest routing did not reject symlink/junction ambiguity: {linked_summary}"
8861                    )
8862                    .into());
8863                }
8864            }
8865            Err(error)
8866                if matches!(
8867                    error.kind(),
8868                    io::ErrorKind::PermissionDenied | io::ErrorKind::Unsupported
8869                ) => {}
8870            Err(error) => return Err(error.into()),
8871        }
8872
8873        for changed_repo in [&repo_a, &repo_b] {
8874            let mut refresh_args = Map::new();
8875            refresh_args.insert(
8876                "project_path".to_string(),
8877                json!(changed_repo.to_string_lossy().to_string()),
8878            );
8879            let refresh = call_text!("atlas_scan", refresh_args);
8880            if !refresh.contains("scan:") {
8881                return Err(format!(
8882                    "routing fixture refresh failed for {}: {refresh}",
8883                    changed_repo.display()
8884                )
8885                .into());
8886            }
8887        }
8888
8889        let mut search_b_args = Map::new();
8890        search_b_args.insert(
8891            "project_path".to_string(),
8892            json!(repo_b.to_string_lossy().to_string()),
8893        );
8894        search_b_args.insert("pattern".to_string(), json!("beta_project_b_marker"));
8895        let search_b = call_text!("atlas_search", search_b_args);
8896        if !search_b.contains("beta_project_b_marker") {
8897            return Err("per-call project_path search did not read repo B".into());
8898        }
8899
8900        let mut default_search_a_args = Map::new();
8901        default_search_a_args.insert("pattern".to_string(), json!("alpha_project_a_marker"));
8902        let default_search_a = call_text!("atlas_search", default_search_a_args);
8903        if !default_search_a.contains("alpha_project_a_marker")
8904            || default_search_a.contains("beta_project_b_marker")
8905        {
8906            return Err("project_path-selected scan leaked into the active project".into());
8907        }
8908
8909        let mut rejected_move_args = Map::new();
8910        rejected_move_args.insert(
8911            "root".to_string(),
8912            json!(repo_b.to_string_lossy().to_string()),
8913        );
8914        rejected_move_args.insert("transition".to_string(), json!("move"));
8915        let rejected_move = call_text!("atlas_root_set", rejected_move_args);
8916        if !rejected_move.contains("move destination") {
8917            return Err(
8918                format!("atlas_root_set did not reject a same-root move: {rejected_move}").into(),
8919            );
8920        }
8921        let after_failed_transition = call_text!("atlas_root", Map::new());
8922        if !after_failed_transition.contains(&normalize_native_path_display(&repo_a)) {
8923            return Err("failed durable root transition changed active MCP routing".into());
8924        }
8925
8926        let mut bind_b_args = Map::new();
8927        bind_b_args.insert(
8928            "root".to_string(),
8929            json!(repo_b.to_string_lossy().to_string()),
8930        );
8931        let bind_b = call_text!("atlas_root_set", bind_b_args);
8932        if !bind_b.contains("transition: bind")
8933            || !bind_b.contains("project_instance_id:")
8934            || !bind_b.contains("verified: true")
8935        {
8936            return Err(format!(
8937                "atlas_root_set omitted transition did not preserve bind behavior: {bind_b}"
8938            )
8939            .into());
8940        }
8941        let bound_root_b = call_text!("atlas_root", Map::new());
8942        if !bound_root_b.contains(&normalize_native_path_display(&repo_b)) {
8943            return Err("successful durable root bind did not change active MCP routing".into());
8944        }
8945        let refreshed_bound_b = call_text!("atlas_scan", Map::new());
8946        if !refreshed_bound_b.contains("scan:") {
8947            return Err("durably bound project could not refresh after config generation".into());
8948        }
8949
8950        let mut set_a_args = Map::new();
8951        set_a_args.insert(
8952            "project_path".to_string(),
8953            json!(repo_a.to_string_lossy().to_string()),
8954        );
8955        let set_a = call_text!("atlas_set_project_path", set_a_args);
8956        if !set_a.contains("project:") || !set_a.contains("status: active") {
8957            return Err("atlas_set_project_path did not restore repo A routing".into());
8958        }
8959
8960        let mut set_b_args = Map::new();
8961        set_b_args.insert(
8962            "project_path".to_string(),
8963            json!(repo_b.to_string_lossy().to_string()),
8964        );
8965        let set_b = call_text!("atlas_set_project_path", set_b_args);
8966        if !set_b.contains("project:") || !set_b.contains("status: active") {
8967            return Err("atlas_set_project_path did not report active project state".into());
8968        }
8969
8970        let mut default_search_b_args = Map::new();
8971        default_search_b_args.insert("pattern".to_string(), json!("beta_project_b_marker"));
8972        let default_search_b = call_text!("atlas_search", default_search_b_args);
8973        if !default_search_b.contains("beta_project_b_marker")
8974            || default_search_b.contains("alpha_project_a_marker")
8975        {
8976            return Err("atlas_set_project_path did not switch the active project".into());
8977        }
8978
8979        let mut override_search_a_args = Map::new();
8980        override_search_a_args.insert(
8981            "project_path".to_string(),
8982            json!(repo_a.to_string_lossy().to_string()),
8983        );
8984        override_search_a_args.insert("pattern".to_string(), json!("alpha_project_a_marker"));
8985        let override_search_a = call_text!("atlas_search", override_search_a_args);
8986        if !override_search_a.contains("alpha_project_a_marker") {
8987            return Err("per-call project_path search did not read repo A".into());
8988        }
8989
8990        let mut missing_index_args = Map::new();
8991        missing_index_args.insert(
8992            "project_path".to_string(),
8993            json!(empty_repo.to_string_lossy().to_string()),
8994        );
8995        let missing_index_overview = call_text!("atlas_overview", missing_index_args);
8996        if !missing_index_overview.contains("kind: init_required")
8997            || !missing_index_overview.contains("tool: atlas_init")
8998            || !missing_index_overview.contains(&normalize_native_path_display(
8999                super::runtime::canonical_project_root(&empty_repo)?,
9000            ))
9001            || empty_repo.join(".projectatlas").exists()
9002        {
9003            return Err(
9004                "read-only per-call project_path did not fail cleanly for a missing index".into(),
9005            );
9006        }
9007
9008        let mut still_default_b_args = Map::new();
9009        still_default_b_args.insert("pattern".to_string(), json!("beta_project_b_marker"));
9010        let still_default_b = call_text!("atlas_search", still_default_b_args);
9011        if !still_default_b.contains("beta_project_b_marker")
9012            || still_default_b.contains("alpha_project_a_marker")
9013        {
9014            return Err("per-call project_path override mutated active project state".into());
9015        }
9016
9017        let mut mismatched_scan_args = Map::new();
9018        mismatched_scan_args.insert(
9019            "project_path".to_string(),
9020            json!(repo_b.to_string_lossy().to_string()),
9021        );
9022        mismatched_scan_args.insert(
9023            "path".to_string(),
9024            json!(repo_a.to_string_lossy().to_string()),
9025        );
9026        let mismatched_scan = call_text!("atlas_scan", mismatched_scan_args);
9027        if !mismatched_scan.contains("outside the selected project root") {
9028            return Err("atlas_scan did not reject mismatched project_path/path roots".into());
9029        }
9030
9031        let default_runtime_info = call_text!("atlas_runtime_info", Map::new());
9032        if !default_runtime_info.contains("runtime:")
9033            || default_runtime_info.contains("mcp_nearest_project")
9034        {
9035            return Err(format!(
9036                "atlas_runtime_info should report runtime identity only: {default_runtime_info}"
9037            )
9038            .into());
9039        }
9040        let mut runtime_info_missing_project_args = Map::new();
9041        runtime_info_missing_project_args.insert(
9042            "project_path".to_string(),
9043            json!(empty_repo.to_string_lossy().to_string()),
9044        );
9045        let runtime_info_missing_project =
9046            call_text!("atlas_runtime_info", runtime_info_missing_project_args);
9047        if !runtime_info_missing_project.contains("runtime:")
9048            || runtime_info_missing_project.contains("mcp_nearest_project")
9049        {
9050            return Err(format!(
9051                "atlas_runtime_info should be project-agnostic: {runtime_info_missing_project}"
9052            )
9053            .into());
9054        }
9055
9056        client.cancel().await?;
9057        server_handle.await?.map_err(std::io::Error::other)?;
9058
9059        let server = ProjectAtlasMcpServer::new(
9060            db_a.clone(),
9061            None,
9062            "mcp-nearest-startup-test".to_string(),
9063            true,
9064        );
9065        let (server_transport, client_transport) = tokio::io::duplex(16_384);
9066        let server_handle = tokio::spawn(async move {
9067            server
9068                .serve(server_transport)
9069                .await
9070                .map_err(|error| error.to_string())?
9071                .waiting()
9072                .await
9073                .map_err(|error| error.to_string())?;
9074            Ok::<(), String>(())
9075        });
9076        let client = TestMcpClient.serve(client_transport).await?;
9077
9078        macro_rules! call_text_on {
9079            ($tool:literal, $args:expr) => {{
9080                let result = client
9081                    .peer()
9082                    .call_tool(CallToolRequestParams::new($tool).with_arguments($args))
9083                    .await?;
9084                result
9085                    .content
9086                    .first()
9087                    .and_then(|content| content.as_text())
9088                    .map(|text| text.text.clone())
9089                    .ok_or_else(|| {
9090                        std::io::Error::other(format!("{} result did not contain text", $tool))
9091                    })?
9092            }};
9093        }
9094
9095        let startup_runtime_info = call_text_on!("atlas_runtime_info", Map::new());
9096        if !startup_runtime_info.contains("runtime:")
9097            || startup_runtime_info.contains("mcp_nearest_project")
9098        {
9099            return Err(format!(
9100                "atlas_runtime_info should remain identity-only when nearest-project startup is enabled: {startup_runtime_info}"
9101            )
9102            .into());
9103        }
9104
9105        let mut startup_summary_b_args = Map::new();
9106        startup_summary_b_args.insert(
9107            "file".to_string(),
9108            json!(
9109                repo_b
9110                    .join("src")
9111                    .join("lib.rs")
9112                    .to_string_lossy()
9113                    .to_string()
9114            ),
9115        );
9116        let startup_summary_b = call_text_on!("atlas_file_summary", startup_summary_b_args.clone());
9117        if !startup_summary_b.contains("beta_project_b_marker")
9118            || !startup_summary_b.contains("file_path: src/lib.rs")
9119        {
9120            return Err(format!(
9121                "startup nearest-project setting did not route indexed repo B: {startup_summary_b}"
9122            )
9123            .into());
9124        }
9125        require_selected_project_audit(
9126            &startup_summary_b,
9127            &repo_b,
9128            &db_b,
9129            "startup nearest-routed file summary",
9130        )?;
9131        startup_summary_b_args.insert("nearest_project".to_string(), json!(false));
9132        let disabled_startup_summary_b =
9133            call_text_on!("atlas_file_summary", startup_summary_b_args);
9134        if disabled_startup_summary_b.contains("beta_project_b_marker")
9135            || !disabled_startup_summary_b.contains("indexed ProjectAtlas project")
9136            || !disabled_startup_summary_b.contains("Get-Content")
9137        {
9138            return Err(format!(
9139                "per-call nearest_project=false did not override startup setting: {disabled_startup_summary_b}"
9140            )
9141            .into());
9142        }
9143
9144        let mut startup_partial_file_args = Map::new();
9145        startup_partial_file_args.insert(
9146            "file".to_string(),
9147            json!(
9148                partial_repo
9149                    .join("src")
9150                    .join("lib.rs")
9151                    .to_string_lossy()
9152                    .to_string()
9153            ),
9154        );
9155        let startup_partial_file = call_text_on!("atlas_file_summary", startup_partial_file_args);
9156        if !startup_partial_file.contains("indexed ProjectAtlas project")
9157            || !startup_partial_file.contains("Get-Content")
9158            || partial_repo
9159                .join(".projectatlas")
9160                .join("projectatlas.db")
9161                .exists()
9162        {
9163            return Err(format!(
9164                "startup nearest-project setting did not reject a project without DB: {startup_partial_file}"
9165            )
9166            .into());
9167        }
9168
9169        let mut detach_b_args = Map::new();
9170        detach_b_args.insert(
9171            "root".to_string(),
9172            json!(repo_b.to_string_lossy().to_string()),
9173        );
9174        detach_b_args.insert("transition".to_string(), json!("detach"));
9175        let detach_b = call_text_on!("atlas_root_set", detach_b_args);
9176        if !detach_b.contains("transition: detach")
9177            || !detach_b.contains("identity_changed: true")
9178            || !detach_b.contains("publication_invalidated: true")
9179        {
9180            return Err(
9181                format!("atlas_root_set did not accept explicit detach: {detach_b}").into(),
9182            );
9183        }
9184        let root_after_detach = call_text_on!("atlas_root", Map::new());
9185        if !root_after_detach.contains(&normalize_native_path_display(&repo_b))
9186            || !root_after_detach.contains("verified: true")
9187        {
9188            return Err("explicit detach did not activate the transitioned root".into());
9189        }
9190
9191        client.cancel().await?;
9192        server_handle.await?.map_err(std::io::Error::other)?;
9193        Ok(())
9194    }
9195}