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