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