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