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