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