1mod agent_efficiency;
4mod analysis;
5mod federation;
6mod import_aliases;
7mod relations;
8
9pub use analysis::{
10 AnalysisFinding, AnalysisFindingKind, AnalysisNode, AnalysisStatus, EntrypointProfile,
11 EntrypointProfileCoverage, EntrypointProfileResult, GitImpactSelection, RelationAnalysisDraft,
12 RelationAnalysisMode, RelationAnalysisQuery, RelationAnalysisReport, RelationAnalysisWork,
13 VcsImpact, load_relation_analysis,
14};
15pub use federation::{
16 FederatedAnalysisDraft, FederatedAnalysisReport, FederatedDetailedRelationDraft,
17 FederatedDetailedRelationReport, FederatedInputWork, FederatedParticipant,
18 FederatedRelationEvidence, FederatedRelationWork, FederatedRendezvous, FederatedStore,
19 MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, load_federated_detailed_relations,
20 load_federated_relation_analysis, validate_federated_root_count,
21};
22pub use relations::{
23 DetailedRelationBudget, DetailedRelationNode, DetailedRelationPageDraft, DetailedRelationQuery,
24 DetailedRelationReport, DetailedRelationRow, DetailedRelationWork, RelationAnchor,
25 RelationDirection, RelationNextCall, RelationPurpose, RelationResolutionFilter,
26 RelationTotalState, load_detailed_relation_page, load_detailed_relations,
27 parse_relation_confidence, parse_relation_direction, parse_relation_resolution,
28};
29
30use agent_efficiency::load_agent_efficiency_comparison as load_agent_efficiency_for_binding;
31use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
32use import_aliases::{ImportAliasMap, load_import_alias_map};
33use projectatlas_core::graph::{
34 CoverageScope, CoverageState, ExtendedRelationKind, GraphIdentityRejection, GraphLimitKind,
35 GraphLimits, GraphRelationKind,
36};
37use projectatlas_core::language::{ContentClassification, ContentSelection};
38use projectatlas_core::outline::estimate_tokens;
39use projectatlas_core::symbols::{
40 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolKind, SymbolRelation,
41};
42use projectatlas_core::telemetry::{
43 AgentEfficiencyComparison, TokenOverview, TokenTrendReport, TokenTrendWindow,
44};
45use projectatlas_core::{
46 CanonicalProjectRoot, IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure,
47 IndexWorkStage, IndexedNode, NavigationNextCall, NavigationNextCapability, NodeKind,
48 RankedConnectionKind, RankedConnectionTarget, RankedNode, RankedReasonCode,
49 repo_path_to_native, validated_repo_file_key,
50};
51use projectatlas_db::{
52 AtlasStore, CapturedProjectBinding, DbError, FileTextAdmission, FileTextFtsQuery,
53 IndexedFileText, MAX_FILE_CONTENT_CLASSIFICATION_PATHS, MAX_FILE_TEXT_FTS_CANDIDATES,
54 RepositoryCoverageQuery, RepositoryCoverageRow, RepositoryNavigationConnections,
55 RepositoryNavigationNode,
56};
57use projectatlas_symbols::module_aliases_for_path;
58use regex::RegexBuilder;
59use serde::Serialize;
60use std::cell::Cell;
61use std::collections::{HashMap, HashSet};
62use std::fs;
63use std::ops::Deref;
64use std::path::{Path, PathBuf};
65use std::time::Duration;
66use thiserror::Error;
67
68const CALLERS_PER_SYMBOL_LIMIT: usize = 20;
70const CALLER_RELATION_LIMIT_PER_TARGET: usize = 20;
72const FILE_METADATA_SYMBOL_LIMIT: usize = 20;
74const RANKED_REASON_LIMIT: usize = 6;
76const RANKED_CANDIDATE_LIMIT: usize = 100;
78const RANKED_CONNECTION_FAMILY_LIMIT: u32 = 4;
80const RANKED_CONNECTION_SAMPLE_LIMIT: usize = 3;
82const NEXT_REPORT_DEFAULT_LIMIT: usize = 3;
84const NEXT_REPORT_MAX_LIMIT: usize = 10;
86pub const COVERAGE_PAGE_MAX_LIMIT: u32 = 200;
88const COVERAGE_IDENTITY_REJECTION_LIMIT: u32 = COVERAGE_PAGE_MAX_LIMIT;
90const COVERAGE_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
92const COVERAGE_DIGEST_ROW_LIMIT: u32 = 16;
94const SOURCE_STATUS_LIVE: &str = "live-source";
96const SOURCE_STATUS_INDEXED: &str = "indexed-metadata";
98const SEARCH_MAX_SELECTED_FILES: usize = 50_000;
100const SEARCH_MAX_SELECTED_BYTES: usize = 128 * 1024 * 1024;
102const SEARCH_MAX_ELAPSED: Duration = Duration::from_secs(10);
104const SEARCH_MAX_CONTEXT_LINES: usize = 20;
106const SEARCH_MAX_RESULT_ROWS: usize = 1_000;
108const SEARCH_MAX_RETAINED_BYTES: usize = 2 * 1024 * 1024;
110const SEARCH_MAX_PATTERN_BYTES: usize = 64 * 1024;
112const SEARCH_MAX_FILE_PATTERN_BYTES: usize = 4 * 1024;
114const SEARCH_SEMANTIC_UNAVAILABLE_STATE: &str = "not-installed";
116const SEARCH_SEMANTIC_RECOVERY: &str =
118 "install and enable a compatible semantic retrieval pack, then build a ready generation";
119
120#[derive(Clone, Copy, Debug)]
122struct SearchBounds {
123 selected_files: usize,
125 selected_bytes: usize,
127 elapsed: Duration,
129 retained_bytes: usize,
131}
132
133const DEFAULT_SEARCH_BOUNDS: SearchBounds = SearchBounds {
135 selected_files: SEARCH_MAX_SELECTED_FILES,
136 selected_bytes: SEARCH_MAX_SELECTED_BYTES,
137 elapsed: SEARCH_MAX_ELAPSED,
138 retained_bytes: SEARCH_MAX_RETAINED_BYTES,
139};
140#[derive(Debug, Error)]
142pub enum ServiceError {
143 #[error("{0}")]
145 Db(#[from] DbError),
146 #[error("service request reached the {limit:?} resource limit")]
148 ResourceLimit {
149 limit: GraphLimitKind,
151 },
152 #[error("invalid input: {0}")]
154 InvalidInput(String),
155 #[error("io error for {path:?}: {source}")]
157 Io {
158 path: PathBuf,
160 source: std::io::Error,
162 },
163 #[error("serialization error: {0}")]
165 Serialize(#[from] serde_json::Error),
166 #[error("selected project binding is unavailable")]
168 SelectedProjectUnavailable,
169 #[error("selected project binding changed while loading the token report")]
171 SelectedProjectChanged,
172 #[error("search retrieval mode {requested_mode:?} is unavailable ({state}); {guidance}")]
174 SearchCapabilityUnavailable {
175 requested_mode: SearchRetrievalMode,
177 state: &'static str,
179 guidance: &'static str,
181 },
182 #[error("invalid detailed relation cursor: {reason}; restart the relation request")]
184 RelationCursorInvalid {
185 reason: &'static str,
187 },
188 #[error("detailed relation cursor does not match {field}; restart the relation request")]
190 RelationCursorMismatched {
191 field: &'static str,
193 },
194 #[error("detailed relation cursor is stale for {field}; restart the relation request")]
196 RelationCursorStale {
197 field: &'static str,
199 },
200}
201
202pub type ServiceResult<T> = Result<T, ServiceError>;
204
205pub(crate) fn canonical_root_digest(
207 domain: &str,
208 root: &CanonicalProjectRoot,
209) -> ServiceResult<[u8; 32]> {
210 let canonical_root = CanonicalProjectRoot::from_path(root.as_path())
211 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
212 let encoded = canonical_root
213 .encode()
214 .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
215 let mut hasher = blake3::Hasher::new();
216 hasher.update(domain.as_bytes());
217 hasher.update(&[0]);
218 hasher.update(&encoded);
219 Ok(*hasher.finalize().as_bytes())
220}
221
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224pub enum TokenReportRequest<'a> {
225 Overview {
227 caller_label: Option<&'a str>,
229 benchmark_results: Option<&'a Path>,
231 },
232 Trends {
234 caller_label: Option<&'a str>,
236 window: TokenTrendWindow,
238 },
239 RepositoryOverview {
241 benchmark_results: Option<&'a Path>,
243 },
244 RepositoryTrends {
246 window: TokenTrendWindow,
248 },
249}
250
251#[derive(Clone, Debug, PartialEq)]
253pub enum TokenReport {
254 Overview(Box<TokenOverview>),
256 Trends(TokenTrendReport),
258}
259
260fn selected_project_binding(store: &AtlasStore) -> ServiceResult<CapturedProjectBinding> {
262 match store.captured_project_binding() {
263 Ok(binding) => Ok(binding),
264 Err(DbError::ProjectRootMissing | DbError::ProjectInstanceIdentityMissing) => {
265 Err(ServiceError::SelectedProjectUnavailable)
266 }
267 Err(error) => Err(ServiceError::Db(error)),
268 }
269}
270
271fn revalidate_selected_project_binding(store: &AtlasStore) -> ServiceResult<()> {
273 match store.revalidate_captured_project_binding() {
274 Ok(()) => Ok(()),
275 Err(
276 DbError::ProjectRootMissing
277 | DbError::ProjectInstanceIdentityMissing
278 | DbError::ProjectRootMismatch { .. }
279 | DbError::ProjectRootTransitionChanged { .. },
280 ) => Err(ServiceError::SelectedProjectChanged),
281 Err(error) => Err(ServiceError::Db(error)),
282 }
283}
284
285fn file_content_classifications_by_path(
287 store: &AtlasStore,
288 paths: impl IntoIterator<Item = String>,
289) -> ServiceResult<HashMap<String, ContentClassification>> {
290 let mut paths = paths.into_iter().collect::<Vec<_>>();
291 paths.sort();
292 paths.dedup();
293 let mut classifications = HashMap::with_capacity(paths.len());
294 for chunk in paths.chunks(MAX_FILE_CONTENT_CLASSIFICATION_PATHS) {
295 classifications.extend(
296 store
297 .file_content_classifications_for_paths(chunk)?
298 .into_iter()
299 .map(|row| (row.path, row.classification)),
300 );
301 }
302 Ok(classifications)
303}
304
305fn selected_file_classification(
307 store: &AtlasStore,
308 path: &str,
309 selection: ContentSelection,
310) -> ServiceResult<ContentClassification> {
311 let mut classifications = file_content_classifications_by_path(store, [path.to_string()])?;
312 let classification = classifications.remove(path).ok_or_else(|| {
313 ServiceError::InvalidInput(format!("file {path:?} has no content classification"))
314 })?;
315 if !selection.includes(classification) {
316 return Err(ServiceError::InvalidInput(format!(
317 "file {path:?} is classified as {classification} and is outside the selected content"
318 )));
319 }
320 Ok(classification)
321}
322
323pub fn load_token_report(
330 store: &AtlasStore,
331 request: TokenReportRequest<'_>,
332) -> ServiceResult<TokenReport> {
333 let selected_project = selected_project_binding(store)?;
334 let report = match request {
335 TokenReportRequest::Overview {
336 caller_label,
337 benchmark_results,
338 } => {
339 let mut overview = store.token_overview(caller_label)?;
340 overview.set_agent_efficiency(load_agent_efficiency_for_binding(
341 &selected_project,
342 benchmark_results,
343 )?);
344 TokenReport::Overview(Box::new(overview))
345 }
346 TokenReportRequest::Trends {
347 caller_label,
348 window,
349 } => TokenReport::Trends(store.token_trends(caller_label, window)?),
350 TokenReportRequest::RepositoryOverview { benchmark_results } => {
351 let mut overview = store.repository_token_overview()?;
352 overview.set_agent_efficiency(load_agent_efficiency_for_binding(
353 &selected_project,
354 benchmark_results,
355 )?);
356 TokenReport::Overview(Box::new(overview))
357 }
358 TokenReportRequest::RepositoryTrends { window } => {
359 TokenReport::Trends(store.repository_token_trends(window)?)
360 }
361 };
362 revalidate_selected_project_binding(store)?;
363 Ok(report)
364}
365
366pub fn load_agent_efficiency_comparison(
373 store: &AtlasStore,
374 benchmark_results: Option<&Path>,
375) -> ServiceResult<AgentEfficiencyComparison> {
376 let selected_project = selected_project_binding(store)?;
377 let comparison = load_agent_efficiency_for_binding(&selected_project, benchmark_results)?;
378 revalidate_selected_project_binding(store)?;
379 Ok(comparison)
380}
381
382#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
384#[serde(rename_all = "snake_case")]
385pub enum CoverageTrustState {
386 Trusted,
388 Partial,
390 Untrusted,
392}
393
394#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
396#[serde(rename_all = "snake_case")]
397pub enum CoverageExtractionPass {
398 GraphProjection,
400 Relationship,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
406#[serde(tag = "state", content = "value", rename_all = "snake_case")]
407pub enum CoverageTotalState {
408 Exact(u32),
410 AtLeast(u32),
412 Unknown,
414}
415
416#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
418pub struct CoverageStateCounts {
419 pub complete: u32,
421 pub no_candidates: u32,
423 pub partial: u32,
425 pub failed: u32,
427 pub ignored: u32,
429 pub oversized: u32,
431 pub quarantined: u32,
433 pub stale: u32,
435}
436
437#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
439pub struct CoverageDigest {
440 pub available: bool,
442 pub active_generation: IndexGeneration,
444 pub parser: Option<ParserKind>,
446 pub provider: Option<ParserKind>,
448 pub states: CoverageStateCounts,
450 pub total: u64,
452 pub covered: u64,
454 pub omitted: u64,
456 pub relation_rows: u32,
458 pub truncated: bool,
460 pub trust: CoverageTrustState,
462 pub next_call: NavigationNextCall,
464}
465
466#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
468pub struct CoverageDiscoveryRow {
469 pub path: String,
471 pub extraction_pass: CoverageExtractionPass,
473 pub relation: Option<GraphRelationKind>,
475 pub state: CoverageState,
477 pub trust: CoverageTrustState,
479 pub total: u64,
481 pub covered: u64,
483 pub omitted: u64,
485 pub reason: Option<String>,
487 pub reached_limit: Option<GraphLimitKind>,
489 pub active_generation: IndexGeneration,
491 pub parser: Option<ParserKind>,
493 pub provider: Option<ParserKind>,
495 #[serde(skip_serializing_if = "Vec::is_empty")]
497 pub identity_rejections: Vec<GraphIdentityRejection>,
498 pub next_call: NavigationNextCall,
500}
501
502#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
504pub struct CoverageDiscoveryReport {
505 pub start_index: u32,
507 pub limit: u32,
509 pub max_limit: u32,
511 pub returned: u32,
513 pub truncated: bool,
515 pub continuation: Option<u32>,
517 pub reached_limit: Option<GraphLimitKind>,
519 pub total: CoverageTotalState,
521 pub output_bytes: u32,
523 pub max_output_bytes: u32,
525 pub identity_rejections_truncated: bool,
527 pub identity_rejections_limit: u32,
529 pub rows: Vec<CoverageDiscoveryRow>,
531}
532
533pub fn parse_coverage_parser(value: &str) -> ServiceResult<ParserKind> {
539 match value.trim().to_ascii_lowercase().as_str() {
540 "tree-sitter" | "tree_sitter" => Ok(ParserKind::TreeSitter),
541 "manifest" => Ok(ParserKind::Manifest),
542 "structural" => Ok(ParserKind::Structural),
543 "fallback" => Ok(ParserKind::Fallback),
544 _ => Err(ServiceError::InvalidInput(format!(
545 "invalid coverage parser/provider '{value}'; expected tree-sitter, manifest, structural, or fallback"
546 ))),
547 }
548}
549
550pub fn parse_coverage_relation(value: &str) -> ServiceResult<GraphRelationKind> {
556 match value.trim().to_ascii_lowercase().as_str() {
557 "contains" => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
558 "imports" => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
559 "calls" => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
560 "depends-on" | "depends_on" => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
561 "references" => Ok(GraphRelationKind::Extended(
562 ExtendedRelationKind::References,
563 )),
564 "tests" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
565 "routes-to" | "routes_to" => {
566 Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
567 }
568 "configures" => Ok(GraphRelationKind::Extended(
569 ExtendedRelationKind::Configures,
570 )),
571 "deploys" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
572 "reads" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
573 "writes" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
574 "documents" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
575 _ => Err(ServiceError::InvalidInput(format!(
576 "invalid coverage relation '{value}'"
577 ))),
578 }
579}
580
581pub fn parse_coverage_state(value: &str) -> ServiceResult<CoverageState> {
587 match value.trim().to_ascii_lowercase().as_str() {
588 "complete" => Ok(CoverageState::Complete),
589 "no-candidates" | "no_candidates" => Ok(CoverageState::NoCandidates),
590 "partial" => Ok(CoverageState::Partial),
591 "failed" => Ok(CoverageState::Failed),
592 "ignored" => Ok(CoverageState::Ignored),
593 "oversized" => Ok(CoverageState::Oversized),
594 "quarantined" => Ok(CoverageState::Quarantined),
595 "stale" => Ok(CoverageState::Stale),
596 _ => Err(ServiceError::InvalidInput(format!(
597 "invalid coverage state '{value}'"
598 ))),
599 }
600}
601
602pub fn load_coverage_discovery(
609 store: &AtlasStore,
610 query: RepositoryCoverageQuery,
611) -> ServiceResult<CoverageDiscoveryReport> {
612 let control = IndexWorkControl::new(IndexCancellation::new(), Some(COVERAGE_DISCOVERY_TIMEOUT));
613 load_coverage_discovery_controlled(store, query, &control)
614}
615
616pub fn load_coverage_discovery_controlled(
623 store: &AtlasStore,
624 mut query: RepositoryCoverageQuery,
625 control: &IndexWorkControl,
626) -> ServiceResult<CoverageDiscoveryReport> {
627 if query.start_index >= GraphLimits::MAX_ROWS {
628 return Err(ServiceError::InvalidInput(format!(
629 "coverage start index must be below {}",
630 GraphLimits::MAX_ROWS
631 )));
632 }
633 query.limit = query
634 .limit
635 .clamp(1, COVERAGE_PAGE_MAX_LIMIT)
636 .min(GraphLimits::MAX_ROWS - query.start_index);
637 let project = store
638 .project_instance_id()?
639 .ok_or(ServiceError::SelectedProjectUnavailable)?;
640 let control = control.with_timeout_ceiling(COVERAGE_DISCOVERY_TIMEOUT);
641 let page = store.repository_coverage_page_controlled(project, &query, Some(&control))?;
642 let page_rows = page.rows;
643 let mut selected_paths = HashSet::new();
644 let paths = page_rows
645 .iter()
646 .filter_map(|row| match row.coverage.scope() {
647 CoverageScope::Path { path } => {
648 selected_paths.insert(path.clone()).then_some(path.clone())
649 }
650 CoverageScope::Project => None,
651 })
652 .collect::<Vec<_>>();
653 let identity_rejections_query_limit = COVERAGE_IDENTITY_REJECTION_LIMIT
654 .checked_add(1)
655 .ok_or_else(|| {
656 ServiceError::InvalidInput("coverage rejection limit overflowed".to_string())
657 })?;
658 let rejections = store.repository_graph_identity_rejections(
659 project,
660 &paths,
661 identity_rejections_query_limit,
662 Some(&control),
663 )?;
664 let companion_coverage = if paths.is_empty() {
671 Vec::new()
672 } else {
673 store
674 .repository_graph_path_coverage(project, &paths, Some(&control))?
675 .rows
676 };
677 let identity_rejections_truncated = rejections.len()
678 > COVERAGE_IDENTITY_REJECTION_LIMIT as usize
679 || page_rows.iter().any(|row| {
680 matches!(row.coverage.scope(), CoverageScope::Path { .. })
681 && row.coverage.relation().is_none()
682 && row.coverage.reached_limit() == Some(GraphLimitKind::Rows)
683 })
684 || companion_coverage.iter().any(|coverage| {
685 matches!(coverage.scope(), CoverageScope::Path { .. })
686 && coverage.relation().is_none()
687 && coverage.reached_limit() == Some(GraphLimitKind::Rows)
688 });
689 let mut rejections_by_path = HashMap::<String, Vec<GraphIdentityRejection>>::new();
690 for rejection in rejections
691 .into_iter()
692 .take(COVERAGE_IDENTITY_REJECTION_LIMIT as usize)
693 {
694 rejections_by_path
695 .entry(rejection.path.as_str().to_owned())
696 .or_default()
697 .push(rejection);
698 }
699 let rows = page_rows
700 .into_iter()
701 .map(|row| coverage_discovery_row(row, &mut rejections_by_path))
702 .collect::<Vec<_>>();
703 let returned = u32::try_from(rows.len()).map_err(|error| {
704 ServiceError::InvalidInput(format!("coverage row count did not fit u32: {error}"))
705 })?;
706 let proved = query.start_index.saturating_add(returned);
707 let total = if page.truncated {
708 CoverageTotalState::AtLeast(proved.saturating_add(1))
709 } else if query.start_index == 0 || returned > 0 {
710 CoverageTotalState::Exact(proved)
711 } else {
712 CoverageTotalState::Unknown
713 };
714 let next_index = query.start_index.saturating_add(returned);
715 let continuation = (page.truncated && next_index < GraphLimits::MAX_ROWS).then_some(next_index);
716 let reached_limit = (page.truncated && continuation.is_none()).then_some(GraphLimitKind::Rows);
717 Ok(CoverageDiscoveryReport {
718 start_index: query.start_index,
719 limit: query.limit,
720 max_limit: COVERAGE_PAGE_MAX_LIMIT,
721 returned,
722 truncated: page.truncated,
723 continuation,
724 reached_limit,
725 total,
726 output_bytes: 0,
727 max_output_bytes: GraphLimits::MAX_OUTPUT_BYTES,
728 identity_rejections_truncated,
729 identity_rejections_limit: COVERAGE_IDENTITY_REJECTION_LIMIT,
730 rows,
731 })
732}
733
734fn coverage_discovery_row(
736 row: RepositoryCoverageRow,
737 rejections_by_path: &mut HashMap<String, Vec<GraphIdentityRejection>>,
738) -> CoverageDiscoveryRow {
739 let coverage = row.coverage;
740 let path = match coverage.scope() {
741 CoverageScope::Project => ".".to_string(),
742 CoverageScope::Path { path } => path.as_str().to_string(),
743 };
744 let relation = coverage.relation();
745 let identity_rejections = rejections_by_path.remove(&path).unwrap_or_default();
746 CoverageDiscoveryRow {
747 next_call: NavigationNextCall {
748 capability: if matches!(coverage.scope(), CoverageScope::Path { .. }) {
749 NavigationNextCapability::Summary
750 } else {
751 NavigationNextCapability::Health
752 },
753 path: path.clone(),
754 },
755 path,
756 extraction_pass: if relation.is_some() {
757 CoverageExtractionPass::Relationship
758 } else {
759 CoverageExtractionPass::GraphProjection
760 },
761 relation,
762 state: coverage.state(),
763 trust: coverage_trust(coverage.state()),
764 total: coverage.total(),
765 covered: coverage.covered(),
766 omitted: coverage.omitted(),
767 reason: coverage.reason().map(|reason| reason.as_str().to_string()),
768 reached_limit: coverage.reached_limit(),
769 active_generation: coverage.generation(),
770 parser: row.parser,
771 provider: row.provider,
772 identity_rejections,
773 }
774}
775
776const fn coverage_trust(state: CoverageState) -> CoverageTrustState {
778 match state {
779 CoverageState::Complete | CoverageState::NoCandidates => CoverageTrustState::Trusted,
780 CoverageState::Partial => CoverageTrustState::Partial,
781 CoverageState::Failed
782 | CoverageState::Ignored
783 | CoverageState::Oversized
784 | CoverageState::Quarantined
785 | CoverageState::Stale => CoverageTrustState::Untrusted,
786 }
787}
788
789fn load_coverage_digest(
791 store: &AtlasStore,
792 path: &str,
793 parse_metadata: Option<&SourceParseMetadata>,
794) -> ServiceResult<CoverageDigest> {
795 let project = store
796 .project_instance_id()?
797 .ok_or(ServiceError::SelectedProjectUnavailable)?;
798 let path_page = store.repository_coverage_page(
799 project,
800 &RepositoryCoverageQuery {
801 start_index: 0,
802 limit: COVERAGE_DIGEST_ROW_LIMIT,
803 path_prefix: Some(path.to_string()),
804 parser: None,
805 provider: None,
806 relation: None,
807 state: None,
808 reason: None,
809 },
810 )?;
811 let rows = path_page
812 .rows
813 .into_iter()
814 .filter(|row| {
815 matches!(
816 row.coverage.scope(),
817 CoverageScope::Path { path: row_path } if row_path.as_str() == path
818 )
819 })
820 .collect::<Vec<_>>();
821 let mut states = CoverageStateCounts::default();
822 let mut total = 0_u64;
823 let mut covered = 0_u64;
824 let mut omitted = 0_u64;
825 let mut relation_rows = 0_u32;
826 let mut trust = CoverageTrustState::Trusted;
827 for row in &rows {
828 let record = &row.coverage;
829 increment_coverage_state(&mut states, record.state());
830 total = checked_coverage_sum(total, record.total(), "total")?;
831 covered = checked_coverage_sum(covered, record.covered(), "covered")?;
832 omitted = checked_coverage_sum(omitted, record.omitted(), "omitted")?;
833 if record.relation().is_some() {
834 relation_rows = relation_rows.saturating_add(1);
835 }
836 trust = match (trust, coverage_trust(record.state())) {
837 (_, CoverageTrustState::Untrusted) => CoverageTrustState::Untrusted,
838 (CoverageTrustState::Trusted, CoverageTrustState::Partial) => {
839 CoverageTrustState::Partial
840 }
841 (current, _) => current,
842 };
843 }
844 let available = !rows.is_empty();
845 if !available {
846 trust = CoverageTrustState::Untrusted;
847 }
848 Ok(CoverageDigest {
849 available,
850 active_generation: rows
851 .first()
852 .map_or(IndexGeneration::ZERO, |row| row.coverage.generation()),
853 parser: rows
854 .iter()
855 .find_map(|row| row.parser)
856 .or_else(|| parse_metadata.map(|metadata| metadata.parser)),
857 provider: rows.iter().find_map(|row| row.provider),
858 states,
859 total,
860 covered,
861 omitted,
862 relation_rows,
863 truncated: path_page.truncated,
864 trust,
865 next_call: NavigationNextCall {
866 capability: NavigationNextCapability::Health,
867 path: path.to_string(),
868 },
869 })
870}
871
872fn checked_coverage_sum(current: u64, value: u64, field: &str) -> ServiceResult<u64> {
874 current.checked_add(value).ok_or_else(|| {
875 ServiceError::InvalidInput(format!("selected-file coverage {field} overflowed u64"))
876 })
877}
878
879fn increment_coverage_state(counts: &mut CoverageStateCounts, state: CoverageState) {
881 let count = match state {
882 CoverageState::Complete => &mut counts.complete,
883 CoverageState::NoCandidates => &mut counts.no_candidates,
884 CoverageState::Partial => &mut counts.partial,
885 CoverageState::Failed => &mut counts.failed,
886 CoverageState::Ignored => &mut counts.ignored,
887 CoverageState::Oversized => &mut counts.oversized,
888 CoverageState::Quarantined => &mut counts.quarantined,
889 CoverageState::Stale => &mut counts.stale,
890 };
891 *count = count.saturating_add(1);
892}
893
894#[derive(Debug, Serialize)]
896pub struct FileSummaryReport {
897 pub file_path: String,
899 pub language: String,
901 pub classification: ContentClassification,
903 pub line_count: usize,
905 pub source_status: String,
907 pub source_error: String,
909 pub parser_kind: String,
911 pub summary_status: String,
913 pub file_purpose: String,
915 pub file_purpose_status: String,
917 pub file_purpose_source: String,
919 pub file_purpose_agent_reviewed: bool,
921 pub content_summary: String,
923 pub package: String,
925 pub docstring: String,
927 pub symbol_count: usize,
929 pub limit: usize,
931 pub total_functions: usize,
933 pub total_methods: usize,
935 pub total_classes: usize,
937 pub total_types: usize,
939 pub total_calls: usize,
941 pub total_imports: usize,
943 pub total_dependencies: usize,
945 pub total_exports: usize,
947 pub truncated: bool,
949 pub functions: Vec<FileSymbolSummary>,
951 pub methods: Vec<FileSymbolSummary>,
953 pub classes: Vec<FileSymbolSummary>,
955 pub types: Vec<FileSymbolSummary>,
957 pub imports: Vec<String>,
959 pub dependencies: Vec<String>,
961 pub exports: Vec<String>,
963 pub calls: Vec<FileCallSummary>,
965 pub coverage: CoverageDigest,
967}
968
969#[derive(Debug, Serialize)]
971pub struct FileSymbolSummary {
972 pub name: String,
974 pub kind: String,
976 pub line: usize,
978 pub end_line: usize,
980 pub signature: String,
982 pub exported: bool,
984 pub documentation: String,
986 pub parent: String,
988 pub called_by: Vec<String>,
990}
991
992#[derive(Debug, Serialize)]
994pub struct FileCallSummary {
995 pub source: String,
997 pub target: String,
999 pub line: usize,
1001 pub context: String,
1003}
1004
1005#[derive(Debug, Serialize)]
1007pub struct SearchMatch {
1008 pub path: String,
1010 pub classification: ContentClassification,
1012 pub line: usize,
1014 pub context_before: Vec<String>,
1016 pub text: String,
1018 pub context_after: Vec<String>,
1020}
1021
1022#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
1024#[serde(rename_all = "lowercase")]
1025pub enum SearchRetrievalMode {
1026 #[default]
1028 Lexical,
1029 Semantic,
1031 Hybrid,
1033}
1034
1035impl SearchRetrievalMode {
1036 #[must_use]
1038 pub const fn as_str(self) -> &'static str {
1039 match self {
1040 Self::Lexical => "lexical",
1041 Self::Semantic => "semantic",
1042 Self::Hybrid => "hybrid",
1043 }
1044 }
1045}
1046
1047#[derive(Clone, Copy, Debug)]
1049pub struct SearchQuery<'query> {
1050 pub pattern: &'query str,
1052 pub regex: bool,
1054 pub fuzzy: bool,
1056 pub case_sensitive: bool,
1058 pub file_pattern: Option<&'query str>,
1060 pub context_lines: usize,
1062 pub start_index: usize,
1064 pub limit: usize,
1066 pub content_selection: ContentSelection,
1068 pub retrieval_mode: SearchRetrievalMode,
1070}
1071
1072#[derive(Debug, Serialize)]
1074pub struct SearchReport {
1075 pub query: String,
1077 pub mode: String,
1079 pub retrieval_mode: String,
1081 #[serde(skip_serializing_if = "Option::is_none")]
1083 pub content_selection: Option<ContentSelection>,
1084 pub source: String,
1086 pub strategy: String,
1088 pub start_index: usize,
1090 pub total: usize,
1092 pub observed_total: usize,
1094 pub total_is_complete: bool,
1096 pub returned: usize,
1098 pub searched_files: usize,
1100 pub searched_bytes: usize,
1102 pub candidate_files: usize,
1104 pub retained_bytes: usize,
1106 pub truncated: bool,
1108 pub truncation_reason: Option<String>,
1110 pub results: Vec<SearchMatch>,
1112}
1113
1114#[derive(Debug, Serialize)]
1116pub struct NextStepReport {
1117 pub query: String,
1119 pub folders: Vec<RankedNode>,
1121 pub files: Vec<ClassifiedRankedNode>,
1123 pub suggestions: Vec<String>,
1125}
1126
1127#[derive(Debug, Serialize)]
1129pub struct ClassifiedRankedNode {
1130 #[serde(flatten)]
1132 pub ranked: RankedNode,
1133 pub classification: ContentClassification,
1135}
1136
1137impl Deref for ClassifiedRankedNode {
1138 type Target = RankedNode;
1139
1140 fn deref(&self) -> &Self::Target {
1141 &self.ranked
1142 }
1143}
1144
1145#[derive(Debug, Serialize)]
1147pub struct CodeSlice {
1148 pub path: String,
1150 #[serde(skip_serializing_if = "Option::is_none")]
1152 pub classification: Option<ContentClassification>,
1153 pub start_line: usize,
1155 pub end_line: usize,
1157 pub line_count: usize,
1159 pub estimated_tokens: usize,
1161 pub content: String,
1163}
1164
1165#[derive(Debug)]
1167pub struct CodeSliceDraft {
1168 slice: CodeSlice,
1170 output_budget: CodeSliceBudget,
1172}
1173
1174impl CodeSliceDraft {
1175 #[must_use]
1177 pub const fn slice(&self) -> &CodeSlice {
1178 &self.slice
1179 }
1180
1181 pub fn fit_output<F, E, O>(&self, encode: F) -> Result<O, E>
1188 where
1189 F: FnOnce(&CodeSlice) -> Result<O, E>,
1190 E: From<ServiceError>,
1191 O: AsRef<[u8]>,
1192 {
1193 let output = encode(&self.slice)?;
1194 if output.as_ref().len() > self.output_budget.output_bytes() as usize {
1195 return Err(E::from(ServiceError::InvalidInput(format!(
1196 "slice output exceeds the requested {}-byte ceiling; narrow the line or symbol range or raise output-bytes",
1197 self.output_budget.output_bytes()
1198 ))));
1199 }
1200 Ok(output)
1201 }
1202}
1203
1204#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1206pub struct CodeSliceBudget {
1207 output_bytes: u32,
1209}
1210
1211impl CodeSliceBudget {
1212 pub const DEFAULT_OUTPUT_BYTES: u32 = 256 * 1_024;
1214
1215 pub fn new(output_bytes: u32) -> ServiceResult<Self> {
1222 if output_bytes == 0 || output_bytes > GraphLimits::MAX_OUTPUT_BYTES {
1223 return Err(ServiceError::InvalidInput(format!(
1224 "slice output byte limit must be between 1 and {}",
1225 GraphLimits::MAX_OUTPUT_BYTES
1226 )));
1227 }
1228 Ok(Self { output_bytes })
1229 }
1230
1231 #[must_use]
1233 pub const fn output_bytes(self) -> u32 {
1234 self.output_bytes
1235 }
1236}
1237
1238impl Default for CodeSliceBudget {
1239 fn default() -> Self {
1240 Self {
1241 output_bytes: Self::DEFAULT_OUTPUT_BYTES,
1242 }
1243 }
1244}
1245
1246#[derive(Debug, Default)]
1248pub struct SymbolSliceSelector<'a> {
1249 pub name: &'a str,
1251 pub parent: Option<&'a str>,
1253 pub kind: Option<&'a str>,
1255 pub signature: Option<&'a str>,
1257 pub line: Option<usize>,
1259}
1260
1261impl From<&SymbolRelation> for FileCallSummary {
1262 fn from(relation: &SymbolRelation) -> Self {
1263 Self {
1264 source: relation.source_name.clone(),
1265 target: relation.target_name.clone(),
1266 line: relation.line,
1267 context: relation.context.clone(),
1268 }
1269 }
1270}
1271
1272pub fn build_file_summary(
1279 store: &AtlasStore,
1280 file: &Path,
1281 limit: usize,
1282) -> ServiceResult<FileSummaryReport> {
1283 build_file_summary_with_selection(store, file, limit, ContentSelection::UnspecifiedLegacy)
1284}
1285
1286pub fn build_file_summary_with_selection(
1293 store: &AtlasStore,
1294 file: &Path,
1295 limit: usize,
1296 content_selection: ContentSelection,
1297) -> ServiceResult<FileSummaryReport> {
1298 let file_key = validated_indexed_file_key(store, file)?;
1299 let classification = selected_file_classification(store, &file_key, content_selection)?;
1300 let source_read =
1301 indexed_native_path(store, &file_key).and_then(|path| read_file_content(&path));
1302 match source_read {
1303 Ok(content) => build_file_summary_with_source_state(
1304 store,
1305 file_key,
1306 limit,
1307 Some(&content),
1308 SOURCE_STATUS_LIVE.to_string(),
1309 String::new(),
1310 classification,
1311 ),
1312 Err(error) => build_file_summary_with_source_state(
1313 store,
1314 file_key,
1315 limit,
1316 None,
1317 SOURCE_STATUS_INDEXED.to_string(),
1318 error.to_string(),
1319 classification,
1320 ),
1321 }
1322}
1323
1324pub fn build_file_summary_from_source(
1331 store: &AtlasStore,
1332 file: &Path,
1333 limit: usize,
1334 source: &str,
1335) -> ServiceResult<FileSummaryReport> {
1336 build_file_summary_from_source_with_selection(
1337 store,
1338 file,
1339 limit,
1340 source,
1341 ContentSelection::UnspecifiedLegacy,
1342 )
1343}
1344
1345pub fn build_file_summary_from_source_with_selection(
1352 store: &AtlasStore,
1353 file: &Path,
1354 limit: usize,
1355 source: &str,
1356 content_selection: ContentSelection,
1357) -> ServiceResult<FileSummaryReport> {
1358 let file_key = validated_indexed_file_key(store, file)?;
1359 let classification = selected_file_classification(store, &file_key, content_selection)?;
1360 build_file_summary_with_source_state(
1361 store,
1362 file_key,
1363 limit,
1364 Some(source),
1365 SOURCE_STATUS_LIVE.to_string(),
1366 String::new(),
1367 classification,
1368 )
1369}
1370
1371fn build_file_summary_with_source_state(
1373 store: &AtlasStore,
1374 file_key: String,
1375 limit: usize,
1376 file_content: Option<&str>,
1377 source_status: String,
1378 source_error: String,
1379 classification: ContentClassification,
1380) -> ServiceResult<FileSummaryReport> {
1381 let effective_limit = limit.max(1);
1382 let indexed = store
1383 .load_node_by_path(&file_key)?
1384 .ok_or_else(|| ServiceError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
1385 let metadata_symbols = store.load_symbols_by_kinds(
1386 &file_key,
1387 &metadata_symbol_kinds(),
1388 FILE_METADATA_SYMBOL_LIMIT,
1389 )?;
1390 let line_count = file_content.map_or_else(
1391 || store.max_symbol_end_line_for_path(&file_key),
1392 |content| Ok(line_count_from_content(content)),
1393 )?;
1394 let docstring = file_content
1395 .and_then(file_level_docstring)
1396 .unwrap_or_else(|| file_docstring(&metadata_symbols));
1397 let function_symbols =
1398 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Function], effective_limit)?;
1399 let method_symbols =
1400 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Method], effective_limit)?;
1401 let class_symbols =
1402 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Class], effective_limit)?;
1403 let type_kinds = type_symbol_kinds();
1404 let type_symbols = store.load_symbols_by_kinds(&file_key, &type_kinds, effective_limit)?;
1405 let summarized_symbols = summarized_symbol_set(
1406 &function_symbols,
1407 &method_symbols,
1408 &class_symbols,
1409 &type_symbols,
1410 );
1411 let summarized_names = symbol_names(&summarized_symbols);
1412 let symbol_name_counts = store.symbol_name_counts(&summarized_names)?;
1413 let alias_scope_symbols = store.load_symbols_by_names(&summarized_names)?;
1414 let alias_counts = symbol_alias_counts(&alias_scope_symbols);
1415 let import_aliases = load_import_alias_map(store, &summarized_symbols, &alias_counts)?;
1416 let caller_targets = caller_target_names(&summarized_symbols, &import_aliases);
1417 let caller_relations =
1418 store.load_call_relations_to_targets(&caller_targets, CALLER_RELATION_LIMIT_PER_TARGET)?;
1419 let called_by = called_by_map(
1420 &summarized_symbols,
1421 &caller_relations,
1422 &symbol_name_counts,
1423 &alias_counts,
1424 &import_aliases,
1425 );
1426 let functions = summarize_symbols(&function_symbols, &called_by);
1427 let methods = summarize_symbols(&method_symbols, &called_by);
1428 let classes = summarize_symbols(&class_symbols, &called_by);
1429 let types = summarize_symbols(&type_symbols, &called_by);
1430 let imports = store.load_distinct_relation_targets_by_kind(
1431 &file_key,
1432 RelationKind::Imports,
1433 effective_limit,
1434 )?;
1435 let dependencies = store.load_distinct_relation_targets_by_kind(
1436 &file_key,
1437 RelationKind::DependsOn,
1438 effective_limit,
1439 )?;
1440 let exports = store.load_exported_symbol_names_for_path(&file_key, effective_limit)?;
1441 let calls = store
1442 .load_symbol_relations_by_kind(&file_key, RelationKind::Calls, effective_limit)?
1443 .iter()
1444 .map(FileCallSummary::from)
1445 .collect::<Vec<_>>();
1446 let total_functions = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Function])?;
1447 let total_methods = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Method])?;
1448 let total_classes = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Class])?;
1449 let total_types = store.count_symbols_by_kinds(&file_key, &type_kinds)?;
1450 let total_calls = store.count_symbol_relations_by_kind(&file_key, RelationKind::Calls)?;
1451 let total_imports =
1452 store.count_distinct_relation_targets_by_kind(&file_key, RelationKind::Imports)?;
1453 let total_dependencies =
1454 store.count_distinct_relation_targets_by_kind(&file_key, RelationKind::DependsOn)?;
1455 let total_exports = store.exported_symbol_count_for_path(&file_key)?;
1456 let symbol_count = store.symbol_count_for_path(&file_key)?;
1457 let symbol_parser_kinds = store.symbol_parser_kinds_for_path(&file_key)?;
1458 let parse_metadata = store.load_source_parse_metadata(&file_key)?;
1459 let coverage = load_coverage_digest(store, &file_key, parse_metadata.as_ref())?;
1460 let truncated = [
1461 total_functions,
1462 total_methods,
1463 total_classes,
1464 total_types,
1465 total_calls,
1466 total_imports,
1467 total_dependencies,
1468 total_exports,
1469 ]
1470 .iter()
1471 .any(|total| *total > effective_limit);
1472
1473 let content_summary = indexed.summary.unwrap_or_default();
1474 let parser_kind = summary_parser_kind(
1475 &content_summary,
1476 symbol_count,
1477 &symbol_parser_kinds,
1478 parse_metadata.as_ref(),
1479 )
1480 .to_string();
1481 let summary_status = summary_status(
1482 &content_summary,
1483 symbol_count,
1484 &symbol_parser_kinds,
1485 parse_metadata.as_ref(),
1486 )
1487 .to_string();
1488
1489 Ok(FileSummaryReport {
1490 file_path: file_key,
1491 language: indexed
1492 .node
1493 .language
1494 .clone()
1495 .unwrap_or_else(|| "unknown".to_string()),
1496 classification,
1497 line_count,
1498 file_purpose: indexed.purpose.purpose.clone().unwrap_or_default(),
1499 file_purpose_status: indexed.purpose.status.to_string(),
1500 file_purpose_source: indexed.purpose.source.to_string(),
1501 file_purpose_agent_reviewed: indexed.purpose.agent_reviewed(),
1502 content_summary,
1503 package: package_name(&metadata_symbols),
1504 docstring,
1505 symbol_count,
1506 source_status,
1507 source_error,
1508 parser_kind,
1509 summary_status,
1510 limit: effective_limit,
1511 total_functions,
1512 total_methods,
1513 total_classes,
1514 total_types,
1515 total_calls,
1516 total_imports,
1517 total_dependencies,
1518 total_exports,
1519 truncated,
1520 functions,
1521 methods,
1522 classes,
1523 types,
1524 imports,
1525 dependencies,
1526 exports,
1527 calls,
1528 coverage,
1529 })
1530}
1531
1532pub fn file_summary_baseline_text(report: &FileSummaryReport) -> ServiceResult<String> {
1538 Ok(serde_json::to_string(report)?)
1539}
1540
1541fn summary_parser_kind(
1543 summary: &str,
1544 symbol_count: usize,
1545 parser_kinds: &[ParserKind],
1546 parse_metadata: Option<&SourceParseMetadata>,
1547) -> &'static str {
1548 if symbol_count > 0 && !parser_kinds.is_empty() {
1549 return symbol_parser_kind(parser_kinds);
1550 }
1551 if let Some(metadata) = parse_metadata {
1552 return parser_kind_label(metadata.parser);
1553 }
1554 if is_symbol_graph_empty_summary(summary) {
1555 "symbol-graph"
1556 } else if summary.is_empty() {
1557 "missing"
1558 } else if is_scanner_fallback_summary(summary) {
1559 "scanner-metadata"
1560 } else {
1561 "structural"
1562 }
1563}
1564
1565fn summary_status(
1567 summary: &str,
1568 symbol_count: usize,
1569 parser_kinds: &[ParserKind],
1570 parse_metadata: Option<&SourceParseMetadata>,
1571) -> &'static str {
1572 if summary.is_empty() {
1573 "missing"
1574 } else if is_scanner_fallback_summary(summary)
1575 || parse_metadata.is_some_and(|metadata| metadata.parser == ParserKind::Fallback)
1576 || fallback_only_symbols(symbol_count, parser_kinds)
1577 {
1578 "fallback"
1579 } else {
1580 "ok"
1581 }
1582}
1583
1584fn parser_kind_label(parser: ParserKind) -> &'static str {
1586 match parser {
1587 ParserKind::TreeSitter => "tree-sitter-symbol-graph",
1588 ParserKind::Manifest => "manifest-symbol-graph",
1589 ParserKind::Structural => "structural-symbol-graph",
1590 ParserKind::Fallback => "fallback-symbol-graph",
1591 }
1592}
1593
1594fn symbol_parser_kind(parser_kinds: &[ParserKind]) -> &'static str {
1596 let has_tree_sitter = parser_kinds.contains(&ParserKind::TreeSitter);
1597 let has_manifest = parser_kinds.contains(&ParserKind::Manifest);
1598 let has_structural = parser_kinds.contains(&ParserKind::Structural);
1599 let has_fallback = parser_kinds.contains(&ParserKind::Fallback);
1600 let family_count = usize::from(has_tree_sitter)
1601 .saturating_add(usize::from(has_manifest))
1602 .saturating_add(usize::from(has_structural))
1603 .saturating_add(usize::from(has_fallback));
1604 match (
1605 family_count,
1606 has_tree_sitter,
1607 has_manifest,
1608 has_structural,
1609 has_fallback,
1610 ) {
1611 (1, true, false, false, false) => "tree-sitter-symbol-graph",
1612 (1, false, true, false, false) => "manifest-symbol-graph",
1613 (1, false, false, true, false) => "structural-symbol-graph",
1614 (1, false, false, false, true) => "fallback-symbol-graph",
1615 _ => "mixed-symbol-graph",
1616 }
1617}
1618
1619fn fallback_only_symbols(symbol_count: usize, parser_kinds: &[ParserKind]) -> bool {
1621 symbol_count > 0
1622 && !parser_kinds.is_empty()
1623 && parser_kinds
1624 .iter()
1625 .all(|parser_kind| *parser_kind == ParserKind::Fallback)
1626}
1627
1628fn is_symbol_graph_empty_summary(summary: &str) -> bool {
1630 summary
1631 .trim()
1632 .ends_with("source file with no declarations found.")
1633}
1634
1635fn is_scanner_fallback_summary(summary: &str) -> bool {
1637 let trimmed = summary.trim_end_matches('.');
1638 let Some((_, tail)) = trimmed.rsplit_once(", ") else {
1639 return false;
1640 };
1641 let Some(number) = tail.strip_suffix(" bytes") else {
1642 return false;
1643 };
1644 !number.is_empty() && number.chars().all(|character| character.is_ascii_digit())
1645}
1646
1647pub fn search_indexed_files(
1654 store: &AtlasStore,
1655 pattern: &str,
1656 regex: bool,
1657 fuzzy: bool,
1658 case_sensitive: bool,
1659 file_pattern: Option<&str>,
1660 context_lines: usize,
1661 start_index: usize,
1662 limit: usize,
1663) -> ServiceResult<SearchReport> {
1664 search_indexed_files_with_control(
1665 store,
1666 &SearchQuery {
1667 pattern,
1668 regex,
1669 fuzzy,
1670 case_sensitive,
1671 file_pattern,
1672 context_lines,
1673 start_index,
1674 limit,
1675 content_selection: ContentSelection::UnspecifiedLegacy,
1676 retrieval_mode: SearchRetrievalMode::Lexical,
1677 },
1678 None,
1679 )
1680}
1681
1682pub fn search_indexed_files_with_control(
1695 store: &AtlasStore,
1696 query: &SearchQuery<'_>,
1697 control: Option<&IndexWorkControl>,
1698) -> ServiceResult<SearchReport> {
1699 search_indexed_files_with_bounds(store, query, control, DEFAULT_SEARCH_BOUNDS)
1700}
1701
1702fn search_indexed_files_with_bounds(
1704 store: &AtlasStore,
1705 query: &SearchQuery<'_>,
1706 control: Option<&IndexWorkControl>,
1707 bounds: SearchBounds,
1708) -> ServiceResult<SearchReport> {
1709 if query.retrieval_mode != SearchRetrievalMode::Lexical {
1710 return Err(ServiceError::SearchCapabilityUnavailable {
1711 requested_mode: query.retrieval_mode,
1712 state: SEARCH_SEMANTIC_UNAVAILABLE_STATE,
1713 guidance: SEARCH_SEMANTIC_RECOVERY,
1714 });
1715 }
1716 if query.regex && query.fuzzy {
1717 return Err(ServiceError::InvalidInput(
1718 "search cannot combine regex and fuzzy modes".to_string(),
1719 ));
1720 }
1721 if query.pattern.len() > SEARCH_MAX_PATTERN_BYTES {
1722 return Err(ServiceError::InvalidInput(format!(
1723 "search pattern cannot exceed {SEARCH_MAX_PATTERN_BYTES} UTF-8 bytes"
1724 )));
1725 }
1726 if query
1727 .file_pattern
1728 .is_some_and(|pattern| pattern.len() > SEARCH_MAX_FILE_PATTERN_BYTES)
1729 {
1730 return Err(ServiceError::InvalidInput(format!(
1731 "search file pattern cannot exceed {SEARCH_MAX_FILE_PATTERN_BYTES} UTF-8 bytes"
1732 )));
1733 }
1734 if query.context_lines > SEARCH_MAX_CONTEXT_LINES {
1735 return Err(ServiceError::InvalidInput(format!(
1736 "search context lines cannot exceed {SEARCH_MAX_CONTEXT_LINES}"
1737 )));
1738 }
1739 if query.limit > SEARCH_MAX_RESULT_ROWS {
1740 return Err(ServiceError::InvalidInput(format!(
1741 "search result limit cannot exceed {SEARCH_MAX_RESULT_ROWS}"
1742 )));
1743 }
1744 let path_matcher = build_path_matcher(query.file_pattern)?;
1745 let matcher = if query.regex {
1746 LineMatcher::Regex(
1747 RegexBuilder::new(query.pattern)
1748 .case_insensitive(!query.case_sensitive)
1749 .build()
1750 .map_err(|source| ServiceError::InvalidInput(source.to_string()))?,
1751 )
1752 } else if query.fuzzy {
1753 LineMatcher::Fuzzy {
1754 needle: normalized_search_text(query.pattern, query.case_sensitive),
1755 case_sensitive: query.case_sensitive,
1756 }
1757 } else {
1758 LineMatcher::Literal {
1759 needle: normalized_search_text(query.pattern, query.case_sensitive),
1760 case_sensitive: query.case_sensitive,
1761 }
1762 };
1763 let mut report = SearchReport {
1764 query: query.pattern.to_string(),
1765 mode: matcher.mode().to_string(),
1766 retrieval_mode: query.retrieval_mode.as_str().to_string(),
1767 content_selection: query
1768 .content_selection
1769 .explicit_value()
1770 .map(|_| query.content_selection),
1771 source: "sqlite-file-text".to_string(),
1772 strategy: "persisted-text-fallback".to_string(),
1773 start_index: query.start_index,
1774 total: 0,
1775 observed_total: 0,
1776 total_is_complete: true,
1777 returned: 0,
1778 searched_files: 0,
1779 searched_bytes: 0,
1780 candidate_files: 0,
1781 retained_bytes: 0,
1782 truncated: false,
1783 truncation_reason: None,
1784 results: Vec::new(),
1785 };
1786 let bounded_control = control.map_or_else(
1787 || IndexWorkControl::new(IndexCancellation::new(), Some(bounds.elapsed)),
1788 |control| control.with_timeout_ceiling(bounds.elapsed),
1789 );
1790 if let Err(failure) = bounded_control.check(IndexWorkStage::TextIndex) {
1791 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1792 mark_search_truncated(&mut report, "elapsed-time-limit");
1793 return Ok(finalize_search_report(report));
1794 }
1795 return Err(DbError::from(failure).into());
1796 }
1797 if query.limit == 0 {
1798 return Ok(report);
1799 }
1800 let needed = query.start_index.saturating_add(query.limit);
1801 let path_prefix = search_path_prefix(query.file_pattern);
1802 let mut used_fts = false;
1803 if let Some(literal_token) = matcher.fts_literal_token()
1804 && store.file_text_fts_ready()?
1805 {
1806 let mut page = match store.query_file_text_fts_candidates(
1807 &FileTextFtsQuery {
1808 literal_token,
1809 path_prefix: path_prefix.as_deref(),
1810 limit: MAX_FILE_TEXT_FTS_CANDIDATES,
1811 },
1812 Some(&bounded_control),
1813 ) {
1814 Ok(page) => page,
1815 Err(error) if is_search_deadline(&error) => {
1816 mark_search_truncated(&mut report, "elapsed-time-limit");
1817 return Ok(finalize_search_report(report));
1818 }
1819 Err(error) => return Err(error.into()),
1820 };
1821 report.candidate_files = page.candidates.len();
1822 if !page.overflow {
1823 page.candidates
1824 .sort_by(|left, right| left.path.cmp(&right.path));
1825 let classifications = file_content_classifications_by_path(
1826 store,
1827 page.candidates
1828 .iter()
1829 .map(|candidate| candidate.path.clone()),
1830 )?;
1831 report.strategy = "fts5-bm25-candidates-exact-verified".to_string();
1832 used_fts = true;
1833 for candidate in page.candidates {
1834 if !path_matches(&candidate.path, path_matcher.as_ref()) {
1835 continue;
1836 }
1837 let classification =
1838 classifications
1839 .get(&candidate.path)
1840 .copied()
1841 .ok_or_else(|| {
1842 ServiceError::InvalidInput(format!(
1843 "FTS candidate {:?} has no content classification",
1844 candidate.path
1845 ))
1846 })?;
1847 if !query.content_selection.includes(classification) {
1848 continue;
1849 }
1850 if let Err(failure) = bounded_control.check(IndexWorkStage::RepositoryTraversal) {
1851 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1852 mark_search_truncated(&mut report, "elapsed-time-limit");
1853 break;
1854 }
1855 return Err(DbError::from(failure).into());
1856 }
1857 if !search_metadata_within_bounds(
1858 &mut report,
1859 candidate.byte_count,
1860 bounds.selected_files,
1861 bounds.selected_bytes,
1862 ) {
1863 break;
1864 }
1865 let text = store.load_file_text(&candidate.path)?.ok_or_else(|| {
1866 ServiceError::InvalidInput(format!(
1867 "FTS candidate {:?} has no authoritative persisted text",
1868 candidate.path
1869 ))
1870 })?;
1871 if text.byte_count != candidate.byte_count
1872 || text.line_count != candidate.line_count
1873 || text.content_hash != candidate.content_hash
1874 {
1875 return Err(ServiceError::InvalidInput(format!(
1876 "FTS candidate metadata changed for {:?}",
1877 candidate.path
1878 )));
1879 }
1880 report.searched_files += 1;
1881 report.searched_bytes += candidate.byte_count;
1882 if let Err(failure) = inspect_search_text(
1883 &mut report,
1884 &text,
1885 classification,
1886 &matcher,
1887 query.context_lines,
1888 needed,
1889 bounds.retained_bytes,
1890 &bounded_control,
1891 ) {
1892 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1893 mark_search_truncated(&mut report, "elapsed-time-limit");
1894 break;
1895 }
1896 return Err(DbError::from(failure).into());
1897 }
1898 if report.truncated {
1899 break;
1900 }
1901 }
1902 }
1903 }
1904 if !used_fts {
1905 let mut selected_files = 0usize;
1906 let mut selected_bytes = 0usize;
1907 let mut searched_files = 0usize;
1908 let mut searched_bytes = 0usize;
1909 let mut admission_truncation = None;
1910 let admitted_classification = Cell::new(None);
1911 let fallback_result = store.visit_file_texts_for_fallback(
1912 path_prefix.as_deref(),
1913 Some(&bounded_control),
1914 |metadata| {
1915 if !path_matches(&metadata.path, path_matcher.as_ref()) {
1916 return Ok(FileTextAdmission::Skip);
1917 }
1918 if !query.content_selection.includes(metadata.classification) {
1919 return Ok(FileTextAdmission::Skip);
1920 }
1921 if selected_files >= bounds.selected_files {
1922 admission_truncation = Some("selected-file-limit");
1923 return Ok(FileTextAdmission::Stop);
1924 }
1925 let Some(next_bytes) = selected_bytes.checked_add(metadata.byte_count) else {
1926 admission_truncation = Some("selected-byte-limit");
1927 return Ok(FileTextAdmission::Stop);
1928 };
1929 if next_bytes > bounds.selected_bytes {
1930 admission_truncation = Some("selected-byte-limit");
1931 return Ok(FileTextAdmission::Stop);
1932 }
1933 selected_files += 1;
1934 selected_bytes = next_bytes;
1935 admitted_classification.set(Some(metadata.classification));
1936 Ok(FileTextAdmission::Read)
1937 },
1938 |text| {
1939 let classification = admitted_classification.take().ok_or_else(|| {
1940 DbError::FileContentClassificationMissing {
1941 path: text.path.clone(),
1942 }
1943 })?;
1944 searched_files += 1;
1945 searched_bytes += text.byte_count;
1946 inspect_search_text(
1947 &mut report,
1948 &text,
1949 classification,
1950 &matcher,
1951 query.context_lines,
1952 needed,
1953 bounds.retained_bytes,
1954 &bounded_control,
1955 )
1956 .map_err(DbError::from)?;
1957 Ok(!report.truncated)
1958 },
1959 );
1960 report.searched_files = searched_files;
1961 report.searched_bytes = searched_bytes;
1962 match fallback_result {
1963 Ok(()) => {}
1964 Err(error) if is_search_deadline(&error) => {
1965 mark_search_truncated(&mut report, "elapsed-time-limit");
1966 }
1967 Err(error) => return Err(error.into()),
1968 }
1969 if let Some(reason) = admission_truncation {
1970 mark_search_truncated(&mut report, reason);
1971 }
1972 }
1973 Ok(finalize_search_report(report))
1974}
1975
1976fn finalize_search_report(mut report: SearchReport) -> SearchReport {
1978 report.returned = report.results.len();
1979 report.observed_total = report.total;
1980 report.total_is_complete = !report.truncated;
1981 report
1982}
1983
1984fn is_search_deadline(error: &DbError) -> bool {
1986 matches!(
1987 error,
1988 DbError::IndexWork(IndexWorkFailure::DeadlineExceeded { .. })
1989 )
1990}
1991
1992pub fn filter_files_by_glob(
1998 nodes: Vec<IndexedNode>,
1999 file_pattern: Option<&str>,
2000) -> ServiceResult<Vec<IndexedNode>> {
2001 let matcher = FilePathMatcher::new(file_pattern)?;
2002 Ok(nodes
2003 .into_iter()
2004 .filter(|node| node.node.kind == NodeKind::File)
2005 .filter(|node| matcher.is_match(&node.node.path))
2006 .collect())
2007}
2008
2009pub fn load_ranked_file_nodes(
2016 store: &AtlasStore,
2017 query: &str,
2018 folder: Option<&str>,
2019 file_pattern: Option<&str>,
2020 limit: usize,
2021 include_content: bool,
2022) -> ServiceResult<Vec<IndexedNode>> {
2023 let target = limit.max(1);
2024 let selected = load_ranked_file_node_candidates(
2025 store,
2026 query,
2027 folder,
2028 file_pattern,
2029 target,
2030 include_content,
2031 )?;
2032 Ok(ranked_nodes_with_reasons(store, query, selected)?
2033 .into_iter()
2034 .take(target)
2035 .map(|ranked| ranked.node)
2036 .collect())
2037}
2038
2039fn load_ranked_file_node_candidates(
2041 store: &AtlasStore,
2042 query: &str,
2043 folder: Option<&str>,
2044 file_pattern: Option<&str>,
2045 target: usize,
2046 include_content: bool,
2047) -> ServiceResult<Vec<IndexedNode>> {
2048 let matcher = FilePathMatcher::new(file_pattern)?;
2049 let candidate_target = ranked_candidate_target(query, target);
2050 let mut selected = if matcher.filters() {
2051 load_ranked_file_nodes_matching_glob(store, query, folder, &matcher, candidate_target)?
2052 } else {
2053 store.load_ranked_nodes(query, NodeKind::File, folder, candidate_target, 0)?
2054 };
2055 if include_content && !query.trim().is_empty() && selected.len() < candidate_target {
2056 append_content_ranked_file_nodes(
2057 store,
2058 query,
2059 folder,
2060 &matcher,
2061 candidate_target,
2062 &mut selected,
2063 )?;
2064 }
2065 append_paired_file_nodes(store, &matcher, candidate_target, &mut selected)?;
2066 Ok(selected)
2067}
2068
2069fn load_ranked_file_node_candidates_with_selection(
2071 store: &AtlasStore,
2072 query: &str,
2073 folder: Option<&str>,
2074 file_pattern: Option<&str>,
2075 target: usize,
2076 include_content: bool,
2077 content_selection: ContentSelection,
2078) -> ServiceResult<Vec<IndexedNode>> {
2079 if content_selection == ContentSelection::UnspecifiedLegacy {
2080 return load_ranked_file_node_candidates(
2081 store,
2082 query,
2083 folder,
2084 file_pattern,
2085 target,
2086 include_content,
2087 );
2088 }
2089 let matcher = FilePathMatcher::new(file_pattern)?;
2090 let candidate_target = ranked_candidate_target(query, target);
2091 let mut selected = load_ranked_file_nodes_matching_selection(
2092 store,
2093 query,
2094 folder,
2095 &matcher,
2096 candidate_target,
2097 content_selection,
2098 )?;
2099 if include_content && !query.trim().is_empty() && selected.len() < candidate_target {
2100 append_content_ranked_file_nodes_selected(
2101 store,
2102 query,
2103 folder,
2104 &matcher,
2105 candidate_target,
2106 content_selection,
2107 &mut selected,
2108 )?;
2109 }
2110 append_paired_file_nodes_selected(
2111 store,
2112 &matcher,
2113 candidate_target,
2114 content_selection,
2115 &mut selected,
2116 )?;
2117 Ok(selected)
2118}
2119
2120pub fn load_ranked_folder_nodes_with_reasons(
2126 store: &AtlasStore,
2127 query: &str,
2128 limit: usize,
2129) -> ServiceResult<Vec<RankedNode>> {
2130 let target = limit.max(1);
2131 let candidate_target = ranked_candidate_target(query, target);
2132 let selected = store.load_ranked_nodes(query, NodeKind::Folder, None, candidate_target, 0)?;
2133 let mut ranked = ranked_nodes_with_reasons(store, query, selected)?;
2134 ranked.truncate(target);
2135 Ok(ranked)
2136}
2137
2138pub fn load_ranked_file_nodes_with_reasons(
2144 store: &AtlasStore,
2145 query: &str,
2146 folder: Option<&str>,
2147 file_pattern: Option<&str>,
2148 limit: usize,
2149 include_content: bool,
2150) -> ServiceResult<Vec<RankedNode>> {
2151 let target = limit.max(1);
2152 let selected = load_ranked_file_node_candidates(
2153 store,
2154 query,
2155 folder,
2156 file_pattern,
2157 target,
2158 include_content,
2159 )?;
2160 let mut ranked = ranked_nodes_with_reasons(store, query, selected)?;
2161 ranked.truncate(target);
2162 Ok(ranked)
2163}
2164
2165pub fn load_classified_ranked_file_nodes_with_reasons(
2171 store: &AtlasStore,
2172 query: &str,
2173 folder: Option<&str>,
2174 file_pattern: Option<&str>,
2175 limit: usize,
2176 include_content: bool,
2177 content_selection: ContentSelection,
2178) -> ServiceResult<Vec<ClassifiedRankedNode>> {
2179 let target = limit.max(1);
2180 let selected = load_ranked_file_node_candidates_with_selection(
2181 store,
2182 query,
2183 folder,
2184 file_pattern,
2185 target,
2186 include_content,
2187 content_selection,
2188 )?;
2189 let mut ranked = ranked_nodes_with_reasons(store, query, selected)?;
2190 ranked.truncate(target);
2191 let classifications = file_content_classifications_by_path(
2192 store,
2193 ranked.iter().map(|node| node.node.node.path.clone()),
2194 )?;
2195 ranked
2196 .into_iter()
2197 .map(|ranked| {
2198 let classification = classifications
2199 .get(&ranked.node.node.path)
2200 .copied()
2201 .ok_or_else(|| {
2202 ServiceError::InvalidInput(format!(
2203 "ranked file {:?} has no content classification",
2204 ranked.node.node.path
2205 ))
2206 })?;
2207 Ok(ClassifiedRankedNode {
2208 ranked,
2209 classification,
2210 })
2211 })
2212 .collect()
2213}
2214
2215pub fn build_next_report(
2221 store: &AtlasStore,
2222 query: &str,
2223 limit: Option<usize>,
2224) -> ServiceResult<NextStepReport> {
2225 build_next_report_with_selection(store, query, limit, ContentSelection::UnspecifiedLegacy)
2226}
2227
2228pub fn build_next_report_with_selection(
2234 store: &AtlasStore,
2235 query: &str,
2236 limit: Option<usize>,
2237 content_selection: ContentSelection,
2238) -> ServiceResult<NextStepReport> {
2239 let target = limit
2240 .unwrap_or(NEXT_REPORT_DEFAULT_LIMIT)
2241 .clamp(1, NEXT_REPORT_MAX_LIMIT);
2242 let folders = load_ranked_folder_nodes_with_reasons(store, query, target)?;
2243 let files = load_classified_ranked_file_nodes_with_reasons(
2244 store,
2245 query,
2246 None,
2247 None,
2248 target,
2249 true,
2250 content_selection,
2251 )?;
2252 let suggestions = next_report_suggestions(query, &folders, &files, content_selection);
2253 Ok(NextStepReport {
2254 query: query.to_string(),
2255 folders,
2256 files,
2257 suggestions,
2258 })
2259}
2260
2261#[derive(Debug)]
2262struct RankedEvidence {
2264 exact_path: bool,
2266 exact_name: bool,
2268 reviewed_purpose: bool,
2270 context_score: usize,
2272 reasons: Vec<String>,
2274 reason_codes: Vec<RankedReasonCode>,
2276}
2277
2278fn ranked_candidate_target(query: &str, target: usize) -> usize {
2280 if query.trim().is_empty() {
2281 target
2282 } else {
2283 target
2284 .saturating_mul(3)
2285 .clamp(target, RANKED_CANDIDATE_LIMIT)
2286 }
2287}
2288
2289fn ranked_nodes_with_reasons(
2291 store: &AtlasStore,
2292 query: &str,
2293 selected: Vec<IndexedNode>,
2294) -> ServiceResult<Vec<RankedNode>> {
2295 let terms = normalize_ranking_terms(query);
2296 let text_hit_paths = indexed_text_hit_paths(store, &selected, &terms)?;
2297 let owners = selected
2298 .iter()
2299 .map(|node| RepositoryNavigationNode {
2300 path: node.node.path.clone(),
2301 kind: node.node.kind,
2302 })
2303 .collect::<Vec<_>>();
2304 let connections = store.repository_navigation_connections(
2305 &owners,
2306 RANKED_CONNECTION_FAMILY_LIMIT,
2307 RANKED_CONNECTION_SAMPLE_LIMIT,
2308 )?;
2309 let mut connections_by_path = connections
2310 .into_iter()
2311 .map(|page| (page.path.clone(), page))
2312 .collect::<HashMap<_, _>>();
2313 let exact_query = normalize_exact_ranking_query(query);
2314 let mut scored = selected
2315 .into_iter()
2316 .enumerate()
2317 .map(|(index, node)| {
2318 let page = connections_by_path.remove(&node.node.path).ok_or_else(|| {
2319 ServiceError::InvalidInput(format!(
2320 "graph navigation batch omitted indexed path {:?}",
2321 node.node.path
2322 ))
2323 })?;
2324 let evidence =
2325 ranked_node_evidence(store, &node, &terms, &exact_query, &text_hit_paths, &page)?;
2326 let next_capability = match node.node.kind {
2327 NodeKind::Folder => NavigationNextCapability::Files,
2328 NodeKind::File if page.truncated => NavigationNextCapability::Relations,
2329 NodeKind::File => NavigationNextCapability::Summary,
2330 };
2331 Ok((
2332 index,
2333 RankedEvidence {
2334 exact_path: evidence.exact_path,
2335 exact_name: evidence.exact_name,
2336 reviewed_purpose: evidence.reviewed_purpose,
2337 context_score: evidence.context_score,
2338 reasons: Vec::new(),
2339 reason_codes: Vec::new(),
2340 },
2341 RankedNode {
2342 node,
2343 reasons: evidence.reasons,
2344 reason_codes: evidence.reason_codes,
2345 connection_counts: page.counts,
2346 connections: page.connections,
2347 connections_truncated: page.truncated,
2348 next_call: NavigationNextCall {
2349 capability: next_capability,
2350 path: owners[index].path.clone(),
2351 },
2352 },
2353 ))
2354 })
2355 .collect::<ServiceResult<Vec<_>>>()?;
2356 scored.sort_by(|left, right| {
2357 ranked_evidence_order(&left.1, &right.1)
2358 .then_with(|| left.0.cmp(&right.0))
2359 .then_with(|| left.2.node.node.path.cmp(&right.2.node.node.path))
2360 });
2361 Ok(scored.into_iter().map(|(_, _, node)| node).collect())
2362}
2363
2364fn ranked_evidence_order(left: &RankedEvidence, right: &RankedEvidence) -> std::cmp::Ordering {
2366 right
2367 .exact_path
2368 .cmp(&left.exact_path)
2369 .then_with(|| right.exact_name.cmp(&left.exact_name))
2370 .then_with(|| right.reviewed_purpose.cmp(&left.reviewed_purpose))
2371 .then_with(|| right.context_score.cmp(&left.context_score))
2372}
2373
2374fn ranked_node_evidence(
2376 store: &AtlasStore,
2377 node: &IndexedNode,
2378 terms: &[String],
2379 exact_query: &str,
2380 text_hit_paths: &HashSet<String>,
2381 connections: &RepositoryNavigationConnections,
2382) -> ServiceResult<RankedEvidence> {
2383 let normalized_path = node.node.path.replace('\\', "/").to_lowercase();
2384 let normalized_name = normalized_path
2385 .rsplit('/')
2386 .next()
2387 .unwrap_or(&normalized_path);
2388 let exact_path = !exact_query.is_empty() && normalized_path == exact_query;
2389 let exact_name = !exact_query.is_empty() && normalized_name == exact_query;
2390 let mut reviewed_purpose = false;
2391 let mut context_score = 0usize;
2392 let mut reasons = Vec::new();
2393 let mut reason_codes = Vec::new();
2394
2395 if exact_path {
2396 push_ranked_reason(&mut reasons, "exact path".to_string());
2397 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ExactPath);
2398 }
2399 if exact_name {
2400 push_ranked_reason(&mut reasons, "exact name".to_string());
2401 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ExactName);
2402 }
2403
2404 if let Some(term) = first_matching_term(&node.node.path, terms)
2405 && !exact_path
2406 {
2407 context_score = context_score.saturating_add(40);
2408 push_ranked_reason(&mut reasons, format!("path matched {term}"));
2409 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Path);
2410 }
2411 if node.purpose.agent_reviewed()
2412 && let Some(term) = node
2413 .purpose
2414 .purpose
2415 .as_deref()
2416 .and_then(|purpose| first_matching_term(purpose, terms))
2417 {
2418 reviewed_purpose = true;
2419 push_ranked_reason(&mut reasons, format!("purpose matched {term}"));
2420 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ReviewedPurpose);
2421 }
2422 if let Some(term) = node
2423 .summary
2424 .as_deref()
2425 .and_then(|summary| first_matching_term(summary, terms))
2426 {
2427 context_score = context_score.saturating_add(20);
2428 push_ranked_reason(&mut reasons, format!("summary matched {term}"));
2429 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Summary);
2430 }
2431 if node.node.kind == NodeKind::File {
2432 if let Some((symbol_name, term)) = first_symbol_match(store, &node.node.path, terms)? {
2433 context_score = context_score.saturating_add(35);
2434 push_ranked_reason(&mut reasons, format!("symbol {symbol_name} matched {term}"));
2435 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Symbol);
2436 }
2437 if text_hit_paths.contains(&node.node.path)
2438 && let Some(term) = indexed_text_match_term(store, &node.node.path, terms)?
2439 {
2440 context_score = context_score.saturating_add(15);
2441 push_ranked_reason(&mut reasons, format!("indexed text matched {term}"));
2442 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::IndexedText);
2443 }
2444 if let Some(reason) = paired_path_reason(store, &node.node.path)? {
2445 context_score = context_score.saturating_add(10);
2446 push_ranked_reason(&mut reasons, reason);
2447 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::PairedFile);
2448 }
2449 }
2450
2451 let mut graph_context_score = 0usize;
2452 for count in &connections.counts {
2453 if count.count == 0 {
2454 continue;
2455 }
2456 graph_context_score = graph_context_score.saturating_add(2);
2457 push_ranked_reason_code(&mut reason_codes, graph_reason_code(count.kind));
2458 }
2459 for connection in &connections.connections {
2460 if ranked_connection_matches_terms(&connection.target, terms) {
2461 graph_context_score = graph_context_score.saturating_add(18);
2462 push_ranked_reason_code(&mut reason_codes, graph_reason_code(connection.kind));
2463 }
2464 }
2465 context_score = context_score.saturating_add(graph_context_score.min(32));
2466
2467 Ok(RankedEvidence {
2468 exact_path,
2469 exact_name,
2470 reviewed_purpose,
2471 context_score,
2472 reasons,
2473 reason_codes,
2474 })
2475}
2476
2477fn normalize_exact_ranking_query(query: &str) -> String {
2479 query.trim().replace('\\', "/").to_lowercase()
2480}
2481
2482fn ranked_connection_matches_terms(target: &RankedConnectionTarget, terms: &[String]) -> bool {
2484 let fields = match target {
2485 RankedConnectionTarget::Local { path, symbol } => {
2486 [Some(path.as_str()), symbol.as_deref(), None]
2487 }
2488 RankedConnectionTarget::Package {
2489 manager,
2490 name,
2491 manifest,
2492 } => [
2493 Some(manager.as_str()),
2494 Some(name.as_str()),
2495 Some(manifest.as_str()),
2496 ],
2497 RankedConnectionTarget::External { system, identity } => {
2498 [Some(system.as_str()), Some(identity.as_str()), None]
2499 }
2500 RankedConnectionTarget::Unresolved { reference } => [Some(reference.as_str()), None, None],
2501 };
2502 fields
2503 .into_iter()
2504 .flatten()
2505 .any(|field| first_matching_term(field, terms).is_some())
2506}
2507
2508const fn graph_reason_code(kind: RankedConnectionKind) -> RankedReasonCode {
2510 match kind {
2511 RankedConnectionKind::Package => RankedReasonCode::GraphPackage,
2512 RankedConnectionKind::Import => RankedReasonCode::GraphImport,
2513 RankedConnectionKind::Call => RankedReasonCode::GraphCall,
2514 RankedConnectionKind::Reference => RankedReasonCode::GraphReference,
2515 RankedConnectionKind::Test => RankedReasonCode::GraphTest,
2516 RankedConnectionKind::Route => RankedReasonCode::GraphRoute,
2517 RankedConnectionKind::Config => RankedReasonCode::GraphConfig,
2518 }
2519}
2520
2521fn normalize_ranking_terms(query: &str) -> Vec<String> {
2523 let mut terms = query
2524 .split(|character: char| !character.is_alphanumeric())
2525 .filter(|term| !term.is_empty())
2526 .map(str::to_lowercase)
2527 .collect::<Vec<_>>();
2528 terms.sort();
2529 terms.dedup();
2530 terms
2531}
2532
2533fn first_matching_term(text: &str, terms: &[String]) -> Option<String> {
2535 let haystack = normalized_search_text(text, false);
2536 terms
2537 .iter()
2538 .find(|term| haystack.contains(term.as_str()))
2539 .cloned()
2540}
2541
2542fn push_ranked_reason(reasons: &mut Vec<String>, reason: String) {
2544 if reasons.len() < RANKED_REASON_LIMIT && !reasons.contains(&reason) {
2545 reasons.push(reason);
2546 }
2547}
2548
2549fn push_ranked_reason_code(codes: &mut Vec<RankedReasonCode>, code: RankedReasonCode) {
2551 if !codes.contains(&code) {
2552 codes.push(code);
2553 }
2554}
2555
2556fn indexed_text_hit_paths(
2558 store: &AtlasStore,
2559 selected: &[IndexedNode],
2560 terms: &[String],
2561) -> ServiceResult<HashSet<String>> {
2562 let mut hits = HashSet::new();
2563 if terms.is_empty() {
2564 return Ok(hits);
2565 }
2566 for node in selected
2567 .iter()
2568 .filter(|node| node.node.kind == NodeKind::File)
2569 {
2570 if indexed_text_match_term(store, &node.node.path, terms)?.is_some() {
2571 hits.insert(node.node.path.clone());
2572 }
2573 }
2574 Ok(hits)
2575}
2576
2577fn indexed_text_match_term(
2579 store: &AtlasStore,
2580 path: &str,
2581 terms: &[String],
2582) -> ServiceResult<Option<String>> {
2583 let Some(text) = store.load_file_text(path)? else {
2584 return Ok(None);
2585 };
2586 Ok(first_matching_term(&text.content, terms))
2587}
2588
2589fn first_symbol_match(
2591 store: &AtlasStore,
2592 path: &str,
2593 terms: &[String],
2594) -> ServiceResult<Option<(String, String)>> {
2595 const RANKING_SYMBOL_KINDS: &[SymbolKind] = &[
2596 SymbolKind::Function,
2597 SymbolKind::Method,
2598 SymbolKind::Class,
2599 SymbolKind::Struct,
2600 SymbolKind::Enum,
2601 SymbolKind::Trait,
2602 SymbolKind::Interface,
2603 SymbolKind::Type,
2604 SymbolKind::Module,
2605 SymbolKind::Value,
2606 ];
2607 for symbol in store.load_symbols_by_kinds(path, RANKING_SYMBOL_KINDS, 50)? {
2608 if let Some(term) = first_matching_term(&symbol.name, terms)
2609 .or_else(|| first_matching_term(&symbol.signature, terms))
2610 {
2611 return Ok(Some((symbol.name, term)));
2612 }
2613 }
2614 Ok(None)
2615}
2616
2617fn append_paired_file_nodes(
2619 store: &AtlasStore,
2620 matcher: &FilePathMatcher,
2621 target: usize,
2622 selected: &mut Vec<IndexedNode>,
2623) -> ServiceResult<()> {
2624 if selected.len() >= target {
2625 return Ok(());
2626 }
2627 let mut seen = selected
2628 .iter()
2629 .map(|node| node.node.path.clone())
2630 .collect::<HashSet<_>>();
2631 let seed_paths = selected
2632 .iter()
2633 .map(|node| node.node.path.clone())
2634 .collect::<Vec<_>>();
2635 for path in seed_paths {
2636 for candidate in paired_path_candidates(&path) {
2637 if selected.len() >= target {
2638 return Ok(());
2639 }
2640 if seen.contains(&candidate) || !matcher.is_match(&candidate) {
2641 continue;
2642 }
2643 if let Some(node) = store.load_node_by_path(&candidate)? {
2644 seen.insert(candidate);
2645 selected.push(node);
2646 }
2647 }
2648 }
2649 Ok(())
2650}
2651
2652fn append_paired_file_nodes_selected(
2654 store: &AtlasStore,
2655 matcher: &FilePathMatcher,
2656 target: usize,
2657 content_selection: ContentSelection,
2658 selected: &mut Vec<IndexedNode>,
2659) -> ServiceResult<()> {
2660 if selected.len() >= target {
2661 return Ok(());
2662 }
2663 let seen = selected
2664 .iter()
2665 .map(|node| node.node.path.clone())
2666 .collect::<HashSet<_>>();
2667 let mut candidates = selected
2668 .iter()
2669 .flat_map(|node| paired_path_candidates(&node.node.path))
2670 .filter(|path| !seen.contains(path) && matcher.is_match(path))
2671 .collect::<Vec<_>>();
2672 candidates.sort();
2673 candidates.dedup();
2674 let classifications = file_content_classifications_by_path(store, candidates.clone())?;
2675 let hydrated = store
2676 .load_nodes_by_paths(&candidates)?
2677 .into_iter()
2678 .map(|node| (node.node.path.clone(), node))
2679 .collect::<HashMap<_, _>>();
2680 for path in candidates {
2681 if selected.len() >= target {
2682 break;
2683 }
2684 if classifications
2685 .get(&path)
2686 .is_some_and(|classification| content_selection.includes(*classification))
2687 && let Some(node) = hydrated.get(&path)
2688 {
2689 selected.push(node.clone());
2690 }
2691 }
2692 Ok(())
2693}
2694
2695fn paired_path_reason(store: &AtlasStore, path: &str) -> ServiceResult<Option<String>> {
2697 for candidate in paired_path_candidates(path) {
2698 if store.load_node_by_path(&candidate)?.is_some() {
2699 let relation = if is_test_path(path) {
2700 "paired source file"
2701 } else {
2702 "paired test file"
2703 };
2704 return Ok(Some(format!("{relation} {candidate}")));
2705 }
2706 }
2707 Ok(None)
2708}
2709
2710fn paired_path_candidates(path: &str) -> Vec<String> {
2712 let Some((stem_path, extension)) = path.rsplit_once('.') else {
2713 return Vec::new();
2714 };
2715 let extension = format!(".{extension}");
2716 let file_stem = stem_path.rsplit('/').next().unwrap_or(stem_path);
2717 let mut candidates = Vec::new();
2718 if let Some(source_name) = file_stem.strip_suffix("_test") {
2719 let prefix = stem_path
2720 .strip_suffix(file_stem)
2721 .unwrap_or("")
2722 .trim_end_matches('/');
2723 candidates.push(join_repo_path(prefix, &format!("{source_name}{extension}")));
2724 } else if let Some(source_name) = file_stem.strip_suffix(".test") {
2725 let prefix = stem_path
2726 .strip_suffix(file_stem)
2727 .unwrap_or("")
2728 .trim_end_matches('/');
2729 candidates.push(join_repo_path(prefix, &format!("{source_name}{extension}")));
2730 }
2731 if let Some(test_name) = stem_path.strip_prefix("tests/") {
2732 candidates.push(format!("src/{test_name}{extension}"));
2733 } else if let Some(source_name) = stem_path.strip_prefix("src/") {
2734 candidates.push(format!("tests/{source_name}{extension}"));
2735 candidates.push(format!("src/{source_name}_test{extension}"));
2736 } else {
2737 candidates.push(format!("tests/{file_stem}{extension}"));
2738 }
2739 candidates.sort();
2740 candidates.dedup();
2741 candidates
2742}
2743
2744fn is_test_path(path: &str) -> bool {
2746 path.starts_with("tests/")
2747 || path.contains("/tests/")
2748 || path.contains("_test.")
2749 || path.contains(".test.")
2750}
2751
2752fn join_repo_path(prefix: &str, leaf: &str) -> String {
2754 if prefix.is_empty() {
2755 leaf.to_string()
2756 } else {
2757 format!("{prefix}/{leaf}")
2758 }
2759}
2760
2761fn next_report_suggestions(
2763 query: &str,
2764 folders: &[RankedNode],
2765 files: &[ClassifiedRankedNode],
2766 content_selection: ContentSelection,
2767) -> Vec<String> {
2768 let mut suggestions = Vec::new();
2769 let selection = content_selection
2770 .explicit_value()
2771 .map_or_else(String::new, |value| format!(" --content-selection {value}"));
2772 if let Some(file) = files.first() {
2773 let path = quoted_command_arg(&file.node.node.path);
2774 suggestions.push(format!("projectatlas summary {path} --limit 25{selection}"));
2775 suggestions.push(format!("projectatlas outline {path}{selection}"));
2776 }
2777 if let Some(folder) = folders.first() {
2778 let query_arg = quoted_command_arg(query);
2779 let folder_arg = quoted_command_arg(&folder.node.node.path);
2780 suggestions.push(format!(
2781 "projectatlas files {query_arg} --folder {folder_arg} --limit 5{selection}"
2782 ));
2783 }
2784 if !query.trim().is_empty() {
2785 let query_arg = quoted_command_arg(query);
2786 suggestions.push(format!(
2787 "projectatlas search {query_arg} --file-pattern **/* --context-lines 2{selection}"
2788 ));
2789 }
2790 suggestions.truncate(4);
2791 suggestions
2792}
2793
2794fn quoted_command_arg(value: &str) -> String {
2796 if value.is_empty() {
2797 "\"\"".to_string()
2798 } else if value
2799 .chars()
2800 .any(|character| character.is_whitespace() || matches!(character, '"' | '\''))
2801 {
2802 format!("\"{}\"", value.replace('"', "\\\""))
2803 } else {
2804 value.to_string()
2805 }
2806}
2807
2808fn load_ranked_file_nodes_matching_glob(
2810 store: &AtlasStore,
2811 query: &str,
2812 folder: Option<&str>,
2813 matcher: &FilePathMatcher,
2814 target: usize,
2815) -> ServiceResult<Vec<IndexedNode>> {
2816 if !matcher.filters() {
2817 return Ok(store.load_ranked_nodes(query, NodeKind::File, folder, target, 0)?);
2818 }
2819 let batch_size = target.saturating_mul(20).clamp(50, 500);
2820 let mut offset = 0usize;
2821 let mut selected = Vec::new();
2822 loop {
2823 let batch = store.load_ranked_nodes(query, NodeKind::File, folder, batch_size, offset)?;
2824 if batch.is_empty() {
2825 break;
2826 }
2827 offset = offset.saturating_add(batch.len());
2828 for node in batch {
2829 if matcher.is_match(&node.node.path) {
2830 selected.push(node);
2831 if selected.len() >= target {
2832 return Ok(selected);
2833 }
2834 }
2835 }
2836 }
2837 Ok(selected)
2838}
2839
2840fn load_ranked_file_nodes_matching_selection(
2842 store: &AtlasStore,
2843 query: &str,
2844 folder: Option<&str>,
2845 matcher: &FilePathMatcher,
2846 target: usize,
2847 content_selection: ContentSelection,
2848) -> ServiceResult<Vec<IndexedNode>> {
2849 let batch_size = target.saturating_mul(20).clamp(50, 500);
2850 let mut offset = 0usize;
2851 let mut selected = Vec::new();
2852 loop {
2853 let batch = store.load_ranked_nodes(query, NodeKind::File, folder, batch_size, offset)?;
2854 if batch.is_empty() {
2855 break;
2856 }
2857 offset = offset.saturating_add(batch.len());
2858 let candidates = batch
2859 .into_iter()
2860 .filter(|node| matcher.is_match(&node.node.path))
2861 .collect::<Vec<_>>();
2862 let classifications = file_content_classifications_by_path(
2863 store,
2864 candidates.iter().map(|node| node.node.path.clone()),
2865 )?;
2866 for node in candidates {
2867 if classifications
2868 .get(&node.node.path)
2869 .is_some_and(|classification| content_selection.includes(*classification))
2870 {
2871 selected.push(node);
2872 if selected.len() >= target {
2873 return Ok(selected);
2874 }
2875 }
2876 }
2877 }
2878 Ok(selected)
2879}
2880
2881fn append_content_ranked_file_nodes(
2883 store: &AtlasStore,
2884 query: &str,
2885 folder: Option<&str>,
2886 matcher: &FilePathMatcher,
2887 target: usize,
2888 selected: &mut Vec<IndexedNode>,
2889) -> ServiceResult<()> {
2890 let terms = normalize_ranking_terms(query);
2891 if terms.is_empty() {
2892 return Ok(());
2893 }
2894 let mut seen = selected
2895 .iter()
2896 .map(|node| node.node.path.clone())
2897 .collect::<HashSet<_>>();
2898 store.visit_file_texts_for_search(None, false, |text| {
2899 if selected.len() >= target {
2900 return Ok(false);
2901 }
2902 let indexed_text = normalized_search_text(&text.content, false);
2903 if !seen.contains(&text.path)
2904 && path_is_inside_folder(&text.path, folder)
2905 && matcher.is_match(&text.path)
2906 && terms.iter().any(|term| indexed_text.contains(term))
2907 && let Some(node) = store.load_node_by_path(&text.path)?
2908 {
2909 seen.insert(text.path);
2910 selected.push(node);
2911 }
2912 Ok(selected.len() < target)
2913 })?;
2914 Ok(())
2915}
2916
2917fn append_content_ranked_file_nodes_selected(
2919 store: &AtlasStore,
2920 query: &str,
2921 folder: Option<&str>,
2922 matcher: &FilePathMatcher,
2923 target: usize,
2924 content_selection: ContentSelection,
2925 selected: &mut Vec<IndexedNode>,
2926) -> ServiceResult<()> {
2927 let terms = normalize_ranking_terms(query);
2928 if terms.is_empty() {
2929 return Ok(());
2930 }
2931 let mut seen = selected
2932 .iter()
2933 .map(|node| node.node.path.clone())
2934 .collect::<HashSet<_>>();
2935 store.visit_file_texts_for_fallback(
2936 None,
2937 None,
2938 |metadata| {
2939 Ok(
2940 if path_is_inside_folder(&metadata.path, folder)
2941 && matcher.is_match(&metadata.path)
2942 && content_selection.includes(metadata.classification)
2943 {
2944 FileTextAdmission::Read
2945 } else {
2946 FileTextAdmission::Skip
2947 },
2948 )
2949 },
2950 |text| {
2951 if selected.len() >= target {
2952 return Ok(false);
2953 }
2954 let indexed_text = normalized_search_text(&text.content, false);
2955 if !seen.contains(&text.path)
2956 && terms.iter().any(|term| indexed_text.contains(term))
2957 && let Some(node) = store.load_node_by_path(&text.path)?
2958 {
2959 seen.insert(text.path);
2960 selected.push(node);
2961 }
2962 Ok(selected.len() < target)
2963 },
2964 )?;
2965 Ok(())
2966}
2967
2968fn path_is_inside_folder(path: &str, folder: Option<&str>) -> bool {
2970 let Some(folder) = folder
2971 .map(|folder| folder.trim_matches('/').trim_matches('\\'))
2972 .filter(|folder| !folder.is_empty() && *folder != ".")
2973 else {
2974 return true;
2975 };
2976 let folder = folder.replace('\\', "/");
2977 path == folder
2978 || path
2979 .strip_prefix(&folder)
2980 .is_some_and(|tail| tail.starts_with('/'))
2981}
2982
2983pub fn file_path_matches_glob(path: &str, file_pattern: Option<&str>) -> ServiceResult<bool> {
2989 Ok(FilePathMatcher::new(file_pattern)?.is_match(path))
2990}
2991
2992pub struct FilePathMatcher {
2994 matcher: Option<GlobSet>,
2996}
2997
2998impl FilePathMatcher {
2999 pub fn new(file_pattern: Option<&str>) -> ServiceResult<Self> {
3005 Ok(Self {
3006 matcher: build_path_matcher(file_pattern)?,
3007 })
3008 }
3009
3010 #[must_use]
3012 pub fn filters(&self) -> bool {
3013 self.matcher.is_some()
3014 }
3015
3016 #[must_use]
3018 pub fn is_match(&self, path: &str) -> bool {
3019 path_matches(path, self.matcher.as_ref())
3020 }
3021}
3022
3023fn indexed_text_lines(text: &IndexedFileText) -> Vec<&str> {
3025 text.content.lines().collect()
3026}
3027
3028pub fn read_indexed_code_slice(
3035 store: &AtlasStore,
3036 file: &Path,
3037 start_line: usize,
3038 end_line: Option<usize>,
3039) -> ServiceResult<CodeSlice> {
3040 read_indexed_code_slice_with_selection(
3041 store,
3042 file,
3043 start_line,
3044 end_line,
3045 ContentSelection::UnspecifiedLegacy,
3046 )
3047}
3048
3049pub fn read_indexed_code_slice_with_selection(
3056 store: &AtlasStore,
3057 file: &Path,
3058 start_line: usize,
3059 end_line: Option<usize>,
3060 content_selection: ContentSelection,
3061) -> ServiceResult<CodeSlice> {
3062 let file_key = validated_indexed_file_key(store, file)?;
3063 let classification = selected_file_classification(store, &file_key, content_selection)?;
3064 let native_file = indexed_native_path(store, &file_key)?;
3065 let content = read_file_content(&native_file)?;
3066 let mut draft = read_code_slice(
3067 &content,
3068 &file_key,
3069 start_line,
3070 end_line,
3071 CodeSliceBudget::default(),
3072 )?;
3073 draft.slice.classification = Some(classification);
3074 Ok(draft.slice)
3075}
3076
3077pub fn read_indexed_code_slice_from_source(
3083 store: &AtlasStore,
3084 file: &Path,
3085 start_line: usize,
3086 end_line: Option<usize>,
3087 source: &str,
3088) -> ServiceResult<CodeSlice> {
3089 read_indexed_code_slice_from_source_bounded_with_selection(
3090 store,
3091 file,
3092 start_line,
3093 end_line,
3094 source,
3095 CodeSliceBudget::default(),
3096 ContentSelection::UnspecifiedLegacy,
3097 )
3098 .map(|draft| draft.slice)
3099}
3100
3101pub fn read_indexed_code_slice_from_source_with_selection(
3108 store: &AtlasStore,
3109 file: &Path,
3110 start_line: usize,
3111 end_line: Option<usize>,
3112 source: &str,
3113 content_selection: ContentSelection,
3114) -> ServiceResult<CodeSlice> {
3115 read_indexed_code_slice_from_source_bounded_with_selection(
3116 store,
3117 file,
3118 start_line,
3119 end_line,
3120 source,
3121 CodeSliceBudget::default(),
3122 content_selection,
3123 )
3124 .map(|draft| draft.slice)
3125}
3126
3127pub fn read_indexed_code_slice_from_source_bounded(
3134 store: &AtlasStore,
3135 file: &Path,
3136 start_line: usize,
3137 end_line: Option<usize>,
3138 source: &str,
3139 output_budget: CodeSliceBudget,
3140) -> ServiceResult<CodeSliceDraft> {
3141 read_indexed_code_slice_from_source_bounded_with_selection(
3142 store,
3143 file,
3144 start_line,
3145 end_line,
3146 source,
3147 output_budget,
3148 ContentSelection::UnspecifiedLegacy,
3149 )
3150}
3151
3152pub fn read_indexed_code_slice_from_source_bounded_with_selection(
3159 store: &AtlasStore,
3160 file: &Path,
3161 start_line: usize,
3162 end_line: Option<usize>,
3163 source: &str,
3164 output_budget: CodeSliceBudget,
3165 content_selection: ContentSelection,
3166) -> ServiceResult<CodeSliceDraft> {
3167 let file_key = validated_indexed_file_key(store, file)?;
3168 let classification = selected_file_classification(store, &file_key, content_selection)?;
3169 let mut draft = read_code_slice(source, &file_key, start_line, end_line, output_budget)?;
3170 draft.slice.classification = Some(classification);
3171 Ok(draft)
3172}
3173
3174pub fn read_symbol_slice(
3181 store: &AtlasStore,
3182 file: &Path,
3183 selector: &SymbolSliceSelector<'_>,
3184) -> ServiceResult<CodeSlice> {
3185 read_symbol_slice_with_selection(store, file, selector, ContentSelection::UnspecifiedLegacy)
3186}
3187
3188pub fn read_symbol_slice_with_selection(
3195 store: &AtlasStore,
3196 file: &Path,
3197 selector: &SymbolSliceSelector<'_>,
3198 content_selection: ContentSelection,
3199) -> ServiceResult<CodeSlice> {
3200 let file_key = validated_indexed_file_key(store, file)?;
3201 let classification = selected_file_classification(store, &file_key, content_selection)?;
3202 let native_file = indexed_native_path(store, &file_key)?;
3203 let content = read_file_content(&native_file)?;
3204 read_symbol_slice_from_source_for_file(
3205 store,
3206 &file_key,
3207 selector,
3208 &content,
3209 CodeSliceBudget::default(),
3210 classification,
3211 )
3212 .map(|draft| draft.slice)
3213}
3214
3215pub fn read_symbol_slice_from_source(
3222 store: &AtlasStore,
3223 file: &Path,
3224 selector: &SymbolSliceSelector<'_>,
3225 source: &str,
3226) -> ServiceResult<CodeSlice> {
3227 read_symbol_slice_from_source_bounded_with_selection(
3228 store,
3229 file,
3230 selector,
3231 source,
3232 CodeSliceBudget::default(),
3233 ContentSelection::UnspecifiedLegacy,
3234 )
3235 .map(|draft| draft.slice)
3236}
3237
3238pub fn read_symbol_slice_from_source_with_selection(
3245 store: &AtlasStore,
3246 file: &Path,
3247 selector: &SymbolSliceSelector<'_>,
3248 source: &str,
3249 content_selection: ContentSelection,
3250) -> ServiceResult<CodeSlice> {
3251 read_symbol_slice_from_source_bounded_with_selection(
3252 store,
3253 file,
3254 selector,
3255 source,
3256 CodeSliceBudget::default(),
3257 content_selection,
3258 )
3259 .map(|draft| draft.slice)
3260}
3261
3262pub fn read_symbol_slice_from_source_bounded(
3270 store: &AtlasStore,
3271 file: &Path,
3272 selector: &SymbolSliceSelector<'_>,
3273 source: &str,
3274 output_budget: CodeSliceBudget,
3275) -> ServiceResult<CodeSliceDraft> {
3276 read_symbol_slice_from_source_bounded_with_selection(
3277 store,
3278 file,
3279 selector,
3280 source,
3281 output_budget,
3282 ContentSelection::UnspecifiedLegacy,
3283 )
3284}
3285
3286pub fn read_symbol_slice_from_source_bounded_with_selection(
3293 store: &AtlasStore,
3294 file: &Path,
3295 selector: &SymbolSliceSelector<'_>,
3296 source: &str,
3297 output_budget: CodeSliceBudget,
3298 content_selection: ContentSelection,
3299) -> ServiceResult<CodeSliceDraft> {
3300 let file_key = validated_indexed_file_key(store, file)?;
3301 let classification = selected_file_classification(store, &file_key, content_selection)?;
3302 read_symbol_slice_from_source_for_file(
3303 store,
3304 &file_key,
3305 selector,
3306 source,
3307 output_budget,
3308 classification,
3309 )
3310}
3311
3312fn read_symbol_slice_from_source_for_file(
3314 store: &AtlasStore,
3315 file_key: &str,
3316 selector: &SymbolSliceSelector<'_>,
3317 source: &str,
3318 output_budget: CodeSliceBudget,
3319 classification: ContentClassification,
3320) -> ServiceResult<CodeSliceDraft> {
3321 let requested_kind = selector.kind.map(parse_symbol_kind).transpose()?;
3322 let mut symbols = store.load_symbols_by_exact_file_and_name(file_key, selector.name)?;
3323 if let Some(parent) = selector.parent {
3324 symbols.retain(|symbol| symbol.parent.as_deref() == Some(parent));
3325 }
3326 if let Some(kind) = requested_kind {
3327 symbols.retain(|symbol| symbol.kind == kind);
3328 }
3329 if let Some(signature) = selector.signature {
3330 symbols.retain(|symbol| symbol.signature == signature);
3331 }
3332 if let Some(line) = selector.line {
3333 symbols.retain(|symbol| symbol.line_start <= line && line <= symbol.line_end);
3334 }
3335 let symbol = match symbols.as_slice() {
3336 [symbol] => symbol,
3337 [] => {
3338 return Err(ServiceError::InvalidInput(format!(
3339 "symbol {:?} was not found in indexed file {file_key}",
3340 selector.name
3341 )));
3342 }
3343 _ => {
3344 return Err(ServiceError::InvalidInput(format!(
3345 "symbol {:?} is ambiguous in {file_key}; pass symbol_parent, symbol_kind, symbol_signature, or symbol_line. candidates: {}",
3346 selector.name,
3347 describe_symbol_candidates(&symbols)
3348 )));
3349 }
3350 };
3351 let mut draft = read_code_slice(
3352 source,
3353 file_key,
3354 symbol.line_start,
3355 Some(symbol.line_end),
3356 output_budget,
3357 )?;
3358 draft.slice.classification = Some(classification);
3359 Ok(draft)
3360}
3361
3362fn validated_file_key(file: &Path) -> ServiceResult<String> {
3364 validated_repo_file_key(file).map_err(|source| ServiceError::InvalidInput(source.to_string()))
3365}
3366
3367fn validated_indexed_file_key(store: &AtlasStore, file: &Path) -> ServiceResult<String> {
3369 let file_key = validated_file_key(file)?;
3370 let indexed = store
3371 .load_node_by_path(&file_key)?
3372 .ok_or_else(|| ServiceError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
3373 if indexed.node.kind != NodeKind::File {
3374 return Err(ServiceError::InvalidInput(format!(
3375 "path {file_key:?} is not an indexed file"
3376 )));
3377 }
3378 Ok(file_key)
3379}
3380
3381fn indexed_project_root(store: &AtlasStore) -> ServiceResult<CanonicalProjectRoot> {
3383 store.project_root_identity()?.ok_or_else(|| {
3384 ServiceError::InvalidInput(
3385 "indexed project root is missing; run projectatlas scan <project-root> first"
3386 .to_string(),
3387 )
3388 })
3389}
3390
3391fn indexed_native_path(store: &AtlasStore, file_key: &str) -> ServiceResult<PathBuf> {
3393 Ok(indexed_project_root(store)?
3394 .as_path()
3395 .join(repo_path_to_native(file_key)))
3396}
3397
3398fn read_file_content(file: &Path) -> ServiceResult<String> {
3400 fs::read_to_string(file).map_err(|source| ServiceError::Io {
3401 path: file.to_path_buf(),
3402 source,
3403 })
3404}
3405
3406fn build_path_matcher(pattern: Option<&str>) -> ServiceResult<Option<GlobSet>> {
3408 let Some(pattern) = pattern else {
3409 return Ok(None);
3410 };
3411 let normalized = pattern.trim().replace('\\', "/");
3412 if normalized.is_empty() || normalized == "*" {
3413 return Ok(None);
3414 }
3415 let mut builder = GlobSetBuilder::new();
3416 add_glob(&mut builder, &normalized)?;
3417 if !normalized.contains('/') {
3418 add_glob(&mut builder, &format!("**/{normalized}"))?;
3419 }
3420 builder
3421 .build()
3422 .map(Some)
3423 .map_err(|source| ServiceError::InvalidInput(source.to_string()))
3424}
3425
3426fn search_path_prefix(pattern: Option<&str>) -> Option<String> {
3428 let normalized = pattern?.trim().replace('\\', "/");
3429 if normalized.is_empty() || normalized == "*" {
3430 return None;
3431 }
3432 let wildcard = normalized
3433 .char_indices()
3434 .find_map(|(index, character)| "*?[{".contains(character).then_some(index));
3435 let prefix = wildcard.map_or(normalized.as_str(), |index| &normalized[..index]);
3436 let prefix = if wildcard.is_some() {
3437 prefix.rsplit_once('/').map_or("", |(parent, _)| parent)
3438 } else {
3439 prefix.trim_end_matches('/')
3440 };
3441 (!prefix.is_empty()).then(|| prefix.to_string())
3442}
3443
3444fn search_metadata_within_bounds(
3446 report: &mut SearchReport,
3447 byte_count: usize,
3448 max_files: usize,
3449 max_bytes: usize,
3450) -> bool {
3451 if report.searched_files >= max_files {
3452 mark_search_truncated(report, "selected-file-limit");
3453 return false;
3454 }
3455 let Some(next_bytes) = report.searched_bytes.checked_add(byte_count) else {
3456 mark_search_truncated(report, "selected-byte-limit");
3457 return false;
3458 };
3459 if next_bytes > max_bytes {
3460 mark_search_truncated(report, "selected-byte-limit");
3461 return false;
3462 }
3463 true
3464}
3465
3466fn mark_search_truncated(report: &mut SearchReport, reason: &'static str) {
3468 report.truncated = true;
3469 if report.truncation_reason.is_none() {
3470 report.truncation_reason = Some(reason.to_string());
3471 }
3472}
3473
3474fn inspect_search_text(
3476 report: &mut SearchReport,
3477 text: &IndexedFileText,
3478 classification: ContentClassification,
3479 matcher: &LineMatcher,
3480 context_lines: usize,
3481 needed: usize,
3482 max_retained_bytes: usize,
3483 control: &IndexWorkControl,
3484) -> Result<(), IndexWorkFailure> {
3485 let lines = indexed_text_lines(text);
3486 append_line_matches(
3487 report,
3488 &text.path,
3489 classification,
3490 &lines,
3491 matcher,
3492 context_lines,
3493 needed,
3494 max_retained_bytes,
3495 control,
3496 )
3497}
3498
3499fn add_glob(builder: &mut GlobSetBuilder, pattern: &str) -> ServiceResult<()> {
3501 let glob = GlobBuilder::new(pattern)
3502 .literal_separator(true)
3503 .build()
3504 .map_err(|source| ServiceError::InvalidInput(source.to_string()))?;
3505 builder.add(glob);
3506 Ok(())
3507}
3508
3509fn path_matches(path: &str, matcher: Option<&GlobSet>) -> bool {
3511 matcher.is_none_or(|matcher| matcher.is_match(path))
3512}
3513
3514enum LineMatcher {
3516 Regex(regex::Regex),
3518 Literal {
3520 needle: String,
3522 case_sensitive: bool,
3524 },
3525 Fuzzy {
3527 needle: String,
3529 case_sensitive: bool,
3531 },
3532}
3533
3534impl LineMatcher {
3535 fn mode(&self) -> &'static str {
3537 match self {
3538 Self::Regex(_) => "regex",
3539 Self::Literal { .. } => "literal",
3540 Self::Fuzzy { .. } => "fuzzy",
3541 }
3542 }
3543
3544 fn fts_literal_token(&self) -> Option<&str> {
3546 match self {
3547 Self::Literal { needle, .. }
3548 if needle.len() >= 3
3549 && needle
3550 .chars()
3551 .all(|character| character.is_ascii_alphanumeric()) =>
3552 {
3553 Some(needle.as_str())
3554 }
3555 Self::Regex(_) | Self::Fuzzy { .. } | Self::Literal { .. } => None,
3556 }
3557 }
3558
3559 fn is_match(&self, line: &str) -> bool {
3561 match self {
3562 Self::Regex(regex) => regex.is_match(line),
3563 Self::Literal {
3564 needle,
3565 case_sensitive,
3566 } => normalized_search_text(line, *case_sensitive).contains(needle),
3567 Self::Fuzzy {
3568 needle,
3569 case_sensitive,
3570 } => fuzzy_subsequence_matches(needle, &normalized_search_text(line, *case_sensitive)),
3571 }
3572 }
3573}
3574
3575fn append_line_matches(
3577 report: &mut SearchReport,
3578 path: &str,
3579 classification: ContentClassification,
3580 lines: &[&str],
3581 matcher: &LineMatcher,
3582 context_lines: usize,
3583 needed: usize,
3584 max_retained_bytes: usize,
3585 control: &IndexWorkControl,
3586) -> Result<(), IndexWorkFailure> {
3587 let result_limit = needed.saturating_sub(report.start_index);
3588 for (index, line) in lines.iter().enumerate() {
3589 control.check(IndexWorkStage::TextIndex)?;
3590 if !matcher.is_match(line) {
3591 continue;
3592 }
3593 report.total += 1;
3594 if report.total <= report.start_index {
3595 continue;
3596 }
3597 if report.results.len() >= result_limit {
3598 mark_search_truncated(report, "result-limit");
3599 control.check(IndexWorkStage::TextIndex)?;
3600 return Ok(());
3601 }
3602 let row = SearchMatch {
3603 path: path.to_string(),
3604 classification,
3605 line: index + 1,
3606 context_before: context_before(lines, index, context_lines),
3607 text: (*line).to_string(),
3608 context_after: context_after(lines, index, context_lines),
3609 };
3610 let retained_bytes = row
3611 .path
3612 .len()
3613 .saturating_add(row.text.len())
3614 .saturating_add(
3615 row.context_before
3616 .iter()
3617 .chain(&row.context_after)
3618 .map(String::len)
3619 .sum::<usize>(),
3620 );
3621 let Some(next_retained_bytes) = report.retained_bytes.checked_add(retained_bytes) else {
3622 mark_search_truncated(report, "retained-byte-limit");
3623 control.check(IndexWorkStage::TextIndex)?;
3624 return Ok(());
3625 };
3626 if next_retained_bytes > max_retained_bytes {
3627 mark_search_truncated(report, "retained-byte-limit");
3628 control.check(IndexWorkStage::TextIndex)?;
3629 return Ok(());
3630 }
3631 report.retained_bytes = next_retained_bytes;
3632 report.results.push(row);
3633 if report.results.len() >= result_limit {
3634 mark_search_truncated(report, "result-limit");
3635 control.check(IndexWorkStage::TextIndex)?;
3636 return Ok(());
3637 }
3638 }
3639 control.check(IndexWorkStage::TextIndex)
3640}
3641
3642fn normalized_search_text(text: &str, case_sensitive: bool) -> String {
3644 if case_sensitive {
3645 text.to_string()
3646 } else {
3647 text.to_ascii_lowercase()
3648 }
3649}
3650
3651fn fuzzy_subsequence_matches(needle: &str, candidate: &str) -> bool {
3653 if needle.is_empty() {
3654 return true;
3655 }
3656 let mut needle = needle.chars();
3657 let Some(mut expected) = needle.next() else {
3658 return true;
3659 };
3660 for character in candidate.chars() {
3661 if character == expected {
3662 let Some(next) = needle.next() else {
3663 return true;
3664 };
3665 expected = next;
3666 }
3667 }
3668 false
3669}
3670
3671fn context_before(lines: &[&str], index: usize, context_lines: usize) -> Vec<String> {
3673 let start = index.saturating_sub(context_lines);
3674 lines[start..index]
3675 .iter()
3676 .map(|line| (*line).to_string())
3677 .collect()
3678}
3679
3680fn context_after(lines: &[&str], index: usize, context_lines: usize) -> Vec<String> {
3682 let start = index.saturating_add(1);
3683 let end = lines.len().min(start.saturating_add(context_lines));
3684 lines[start..end]
3685 .iter()
3686 .map(|line| (*line).to_string())
3687 .collect()
3688}
3689
3690fn read_code_slice(
3692 content: &str,
3693 file_key: &str,
3694 start_line: usize,
3695 end_line: Option<usize>,
3696 output_budget: CodeSliceBudget,
3697) -> ServiceResult<CodeSliceDraft> {
3698 if start_line == 0 {
3699 return Err(ServiceError::InvalidInput(
3700 "start-line must be one or greater".to_string(),
3701 ));
3702 }
3703 let requested_end_line = end_line.unwrap_or(start_line);
3704 if requested_end_line < start_line {
3705 return Err(ServiceError::InvalidInput(
3706 "end-line must be greater than or equal to start-line".to_string(),
3707 ));
3708 }
3709 let mut line_count = 0usize;
3710 let mut offset = 0usize;
3711 let mut selected_start = None;
3712 let mut selected_end = None;
3713 for line in content.split_inclusive('\n') {
3714 line_count = line_count.saturating_add(1);
3715 let line_start = offset;
3716 let line_end_with_terminator = offset.checked_add(line.len()).ok_or_else(|| {
3717 ServiceError::InvalidInput("slice source byte offset overflowed".to_string())
3718 })?;
3719 let line_end = if line.ends_with("\r\n") {
3720 line_end_with_terminator - 2
3721 } else if line.ends_with('\n') {
3722 line_end_with_terminator - 1
3723 } else {
3724 line_end_with_terminator
3725 };
3726 if line_count == start_line {
3727 selected_start = Some(line_start);
3728 }
3729 if line_count >= start_line && line_count <= requested_end_line {
3730 selected_end = Some(line_end);
3731 }
3732 offset = line_end_with_terminator;
3733 }
3734 if start_line > line_count {
3735 return Err(ServiceError::InvalidInput(format!(
3736 "start-line {start_line} exceeds file line count {line_count}"
3737 )));
3738 }
3739 let end_index = requested_end_line.min(line_count);
3740 let selected_start = selected_start
3741 .ok_or_else(|| ServiceError::InvalidInput("slice start byte was not found".to_string()))?;
3742 let selected_end = selected_end
3743 .ok_or_else(|| ServiceError::InvalidInput("slice end byte was not found".to_string()))?;
3744 let content_bytes = selected_end.checked_sub(selected_start).ok_or_else(|| {
3745 ServiceError::InvalidInput("slice content byte range was invalid".to_string())
3746 })?;
3747 if content_bytes > output_budget.output_bytes() as usize {
3748 return Err(ServiceError::InvalidInput(format!(
3749 "verbatim slice content exceeds the requested {}-byte output ceiling; narrow the line or symbol range or raise output-bytes",
3750 output_budget.output_bytes()
3751 )));
3752 }
3753 let content = content[selected_start..selected_end].to_string();
3754 Ok(CodeSliceDraft {
3755 slice: CodeSlice {
3756 path: file_key.to_string(),
3757 classification: None,
3758 start_line,
3759 end_line: end_index,
3760 line_count,
3761 estimated_tokens: estimate_tokens(&content),
3762 content,
3763 },
3764 output_budget,
3765 })
3766}
3767
3768pub fn parse_symbol_kind(kind: &str) -> ServiceResult<SymbolKind> {
3774 let normalized = kind.trim().to_ascii_lowercase();
3775 let parsed = SymbolKind::from_db(&normalized);
3776 if parsed == SymbolKind::Unknown && normalized != "unknown" {
3777 return Err(ServiceError::InvalidInput(format!(
3778 "unsupported symbol kind {kind:?}"
3779 )));
3780 }
3781 Ok(parsed)
3782}
3783
3784fn describe_symbol_candidates(symbols: &[CodeSymbol]) -> String {
3786 symbols
3787 .iter()
3788 .map(|symbol| {
3789 format!(
3790 "{} parent={} kind={} lines={}-{}",
3791 symbol.name,
3792 symbol.parent.as_deref().unwrap_or(""),
3793 symbol.kind,
3794 symbol.line_start,
3795 symbol.line_end
3796 )
3797 })
3798 .collect::<Vec<_>>()
3799 .join("; ")
3800}
3801
3802fn line_count_from_content(content: &str) -> usize {
3804 content.lines().count()
3805}
3806
3807fn symbol_names(symbols: &[CodeSymbol]) -> Vec<String> {
3809 let mut names = symbols
3810 .iter()
3811 .map(|symbol| symbol.name.clone())
3812 .collect::<Vec<_>>();
3813 names.sort();
3814 names.dedup();
3815 names
3816}
3817
3818fn metadata_symbol_kinds() -> [SymbolKind; 3] {
3820 [
3821 SymbolKind::Package,
3822 SymbolKind::Workspace,
3823 SymbolKind::Module,
3824 ]
3825}
3826
3827fn type_symbol_kinds() -> [SymbolKind; 7] {
3829 [
3830 SymbolKind::Struct,
3831 SymbolKind::Enum,
3832 SymbolKind::Trait,
3833 SymbolKind::Interface,
3834 SymbolKind::Type,
3835 SymbolKind::Package,
3836 SymbolKind::Workspace,
3837 ]
3838}
3839
3840fn summarized_symbol_set(
3842 functions: &[CodeSymbol],
3843 methods: &[CodeSymbol],
3844 classes: &[CodeSymbol],
3845 types: &[CodeSymbol],
3846) -> Vec<CodeSymbol> {
3847 functions
3848 .iter()
3849 .chain(methods)
3850 .chain(classes)
3851 .chain(types)
3852 .cloned()
3853 .collect()
3854}
3855
3856fn caller_target_names(symbols: &[CodeSymbol], import_aliases: &ImportAliasMap) -> Vec<String> {
3858 let mut targets = HashSet::new();
3859 for symbol in symbols {
3860 targets.insert(symbol.name.clone());
3861 for alias in symbol_target_aliases(symbol) {
3862 targets.insert(alias);
3863 }
3864 }
3865 for alias in import_aliases.values().flatten() {
3866 targets.insert(alias.target_name.clone());
3867 }
3868 let mut values = targets.into_iter().collect::<Vec<_>>();
3869 values.sort();
3870 values
3871}
3872
3873fn called_by_map(
3875 symbols: &[CodeSymbol],
3876 relations: &[SymbolRelation],
3877 name_counts: &HashMap<String, usize>,
3878 alias_counts: &HashMap<String, usize>,
3879 import_aliases: &ImportAliasMap,
3880) -> HashMap<String, Vec<String>> {
3881 let mut map: HashMap<String, Vec<String>> = HashMap::new();
3882 for symbol in symbols {
3883 let symbol_key = symbol_summary_key(symbol);
3884 for relation in relations.iter().filter(|relation| {
3885 relation_matches_symbol(relation, symbol, name_counts, alias_counts, import_aliases)
3886 }) {
3887 let caller = caller_reference(relation);
3888 let callers = map.entry(symbol_key.clone()).or_default();
3889 if !callers.iter().any(|existing| existing == &caller) {
3890 callers.push(caller);
3891 }
3892 }
3893 }
3894 for callers in map.values_mut() {
3895 callers.sort();
3896 callers.truncate(CALLERS_PER_SYMBOL_LIMIT);
3897 }
3898 map
3899}
3900
3901fn relation_matches_symbol(
3903 relation: &SymbolRelation,
3904 symbol: &CodeSymbol,
3905 name_counts: &HashMap<String, usize>,
3906 alias_counts: &HashMap<String, usize>,
3907 import_aliases: &ImportAliasMap,
3908) -> bool {
3909 if relation.kind != RelationKind::Calls {
3910 return false;
3911 }
3912 let target = relation.target_name.trim();
3913 if target == symbol.name
3914 && (relation.path == symbol.path
3915 || name_counts.get(&symbol.name).copied().unwrap_or(0) <= 1)
3916 {
3917 return true;
3918 }
3919 if symbol_target_aliases(symbol)
3920 .iter()
3921 .any(|alias| alias == target && alias_counts.get(alias).copied().unwrap_or(0) <= 1)
3922 {
3923 return true;
3924 }
3925 import_aliases
3926 .get(&symbol_summary_key(symbol))
3927 .is_some_and(|aliases| {
3928 aliases.iter().any(|alias| {
3929 alias.caller_path == relation.path && alias.target_name == relation.target_name
3930 })
3931 })
3932}
3933
3934fn symbol_alias_counts(symbols: &[CodeSymbol]) -> HashMap<String, usize> {
3936 let mut counts = HashMap::new();
3937 for alias in symbols.iter().flat_map(symbol_target_aliases) {
3938 *counts.entry(alias).or_insert(0) += 1;
3939 }
3940 counts
3941}
3942
3943fn symbol_target_aliases(symbol: &CodeSymbol) -> Vec<String> {
3945 let mut aliases = HashSet::new();
3946 let modules = module_aliases_for_path(&symbol.path);
3947 for module in &modules {
3948 aliases.insert(format!("{module}::{}", symbol.name));
3949 aliases.insert(format!("{module}.{}", symbol.name));
3950 aliases.insert(format!("crate::{module}::{}", symbol.name));
3951 aliases.insert(format!("crate.{module}.{}", symbol.name));
3952 }
3953 if modules.is_empty() {
3954 aliases.insert(format!("crate::{}", symbol.name));
3955 aliases.insert(format!("self::{}", symbol.name));
3956 }
3957 let mut values = aliases.into_iter().collect::<Vec<_>>();
3958 values.sort();
3959 values
3960}
3961
3962fn symbol_summary_key(symbol: &CodeSymbol) -> String {
3964 format!("{}\0{}\0{}", symbol.path, symbol.name, symbol.line_start)
3965}
3966
3967fn caller_reference(relation: &SymbolRelation) -> String {
3969 format!("{}::{}", relation.path, relation.source_name)
3970}
3971
3972fn summarize_symbols(
3974 symbols: &[CodeSymbol],
3975 called_by: &HashMap<String, Vec<String>>,
3976) -> Vec<FileSymbolSummary> {
3977 let mut rows = symbols
3978 .iter()
3979 .map(|symbol| FileSymbolSummary {
3980 name: symbol.name.clone(),
3981 kind: symbol.kind.to_string(),
3982 line: symbol.line_start,
3983 end_line: symbol.line_end,
3984 signature: symbol.signature.clone(),
3985 exported: symbol.exported,
3986 documentation: symbol.documentation.clone().unwrap_or_default(),
3987 parent: symbol.parent.clone().unwrap_or_default(),
3988 called_by: called_by
3989 .get(&symbol_summary_key(symbol))
3990 .cloned()
3991 .unwrap_or_default(),
3992 })
3993 .collect::<Vec<_>>();
3994 rows.sort_by(|left, right| {
3995 left.line
3996 .cmp(&right.line)
3997 .then_with(|| left.name.cmp(&right.name))
3998 });
3999 rows
4000}
4001
4002fn package_name(symbols: &[CodeSymbol]) -> String {
4004 symbols
4005 .iter()
4006 .find(|symbol| matches!(symbol.kind, SymbolKind::Package | SymbolKind::Workspace))
4007 .or_else(|| {
4008 symbols.iter().find(|symbol| {
4009 symbol.kind == SymbolKind::Module
4010 && matches!(
4011 symbol.detail.as_deref(),
4012 Some(
4013 "package_declaration"
4014 | "package_clause"
4015 | "package_header"
4016 | "namespace_declaration"
4017 | "file_scoped_namespace_declaration"
4018 | "module_declaration"
4019 )
4020 )
4021 })
4022 })
4023 .map(|symbol| symbol.name.clone())
4024 .unwrap_or_default()
4025}
4026
4027fn file_docstring(symbols: &[CodeSymbol]) -> String {
4029 symbols
4030 .iter()
4031 .find(|symbol| {
4032 matches!(
4033 symbol.kind,
4034 SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Module
4035 ) && symbol
4036 .documentation
4037 .as_deref()
4038 .is_some_and(|value| !value.is_empty())
4039 })
4040 .and_then(|symbol| symbol.documentation.clone())
4041 .unwrap_or_default()
4042}
4043
4044fn file_level_docstring(content: &str) -> Option<String> {
4046 leading_string_docstring(content).or_else(|| leading_doc_comments(content))
4047}
4048
4049fn leading_string_docstring(content: &str) -> Option<String> {
4051 let trimmed = content.trim_start();
4052 for quote in ["\"\"\"", "'''"] {
4053 if let Some(rest) = trimmed.strip_prefix(quote)
4054 && let Some(end) = rest.find(quote)
4055 {
4056 return compact_doc_text(&rest[..end]);
4057 }
4058 }
4059 None
4060}
4061
4062fn leading_doc_comments(content: &str) -> Option<String> {
4064 let mut lines = Vec::new();
4065 let mut in_block = false;
4066 let mut module_style: Option<bool> = None;
4067 for line in content.lines() {
4068 let trimmed = line.trim();
4069 if trimmed.is_empty() && lines.is_empty() {
4070 continue;
4071 }
4072 if in_block {
4073 if let Some(end) = trimmed.find("*/") {
4074 lines.push(trimmed[..end].trim_start_matches('*').trim().to_string());
4075 break;
4076 }
4077 lines.push(trimmed.trim_start_matches('*').trim().to_string());
4078 continue;
4079 }
4080 if let Some(value) = trimmed.strip_prefix("//!") {
4081 if module_style.is_some_and(|module| !module) {
4082 break;
4083 }
4084 module_style = Some(true);
4085 lines.push(value.trim().to_string());
4086 } else if let Some(value) = trimmed.strip_prefix("///") {
4087 if module_style.is_some_and(|module| module) {
4088 break;
4089 }
4090 module_style = Some(false);
4091 lines.push(value.trim().to_string());
4092 } else if let Some(value) = trimmed.strip_prefix("/*!") {
4093 if module_style.is_some_and(|module| !module) {
4094 break;
4095 }
4096 module_style = Some(true);
4097 in_block = true;
4098 if let Some(end) = value.find("*/") {
4099 lines.push(value[..end].trim_start_matches('*').trim().to_string());
4100 break;
4101 }
4102 lines.push(value.trim_start_matches('*').trim().to_string());
4103 } else if let Some(value) = trimmed.strip_prefix("/**") {
4104 if module_style.is_some_and(|module| module) {
4105 break;
4106 }
4107 module_style = Some(false);
4108 in_block = true;
4109 if let Some(end) = value.find("*/") {
4110 lines.push(value[..end].trim_start_matches('*').trim().to_string());
4111 break;
4112 }
4113 lines.push(value.trim_start_matches('*').trim().to_string());
4114 } else {
4115 break;
4116 }
4117 }
4118 compact_doc_text(&lines.join(" "))
4119}
4120
4121fn compact_doc_text(raw: &str) -> Option<String> {
4123 let text = raw
4124 .lines()
4125 .map(|line| line.trim().trim_start_matches('*').trim())
4126 .filter(|line| !line.is_empty())
4127 .collect::<Vec<_>>()
4128 .join(" ");
4129 if text.is_empty() { None } else { Some(text) }
4130}
4131
4132#[cfg(test)]
4134fn exported_symbol_names(symbols: &[CodeSymbol]) -> Vec<String> {
4135 let mut names = symbols
4136 .iter()
4137 .filter(|symbol| symbol.exported)
4138 .map(|symbol| symbol.name.clone())
4139 .collect::<Vec<_>>();
4140 names.sort();
4141 names.dedup();
4142 names
4143}
4144
4145#[cfg(test)]
4146mod tests {
4147 use super::*;
4148 use projectatlas_core::graph::{
4149 CoverageRecord, GraphIdentityField, GraphIdentityRejectionReason, GraphIdentityText,
4150 GraphLimitKind, RepositoryNodePath, SourceSpan,
4151 };
4152 use projectatlas_core::symbols::{ParserKind, SymbolGraph};
4153 use projectatlas_core::telemetry::{
4154 AgentEfficiencyBaseline, AgentEfficiencyEvidenceState, UsageDetailAvailability,
4155 };
4156 use projectatlas_core::{Node, Purpose, PurposeSource, PurposeStatus, normalized_parent};
4157 use std::error::Error;
4158 use std::io;
4159
4160 #[cfg(unix)]
4161 #[test]
4162 fn file_summary_reads_non_utf8_native_root_without_display_reconstruction()
4163 -> Result<(), Box<dyn Error>> {
4164 use std::os::unix::ffi::OsStringExt;
4165
4166 let temp = tempfile::tempdir()?;
4167 let root = temp
4168 .path()
4169 .join(std::ffi::OsString::from_vec(vec![b's', b'r', b'c', 0x80]));
4170 fs::create_dir(&root)?;
4171 fs::write(root.join("entry.rs"), "pub fn native_root() {}\n")?;
4172 let mut store = AtlasStore::in_memory()?;
4173 store.set_project_root(&root)?;
4174 let node = test_node("entry.rs", "native-root-hash");
4175 store.replace_scan(std::slice::from_ref(&node))?;
4176 index_test_file_texts(&mut store, &root, std::slice::from_ref(&node))?;
4177
4178 let report = build_file_summary(&store, Path::new("entry.rs"), 10)?;
4179 require_eq(
4180 &report.source_status,
4181 &SOURCE_STATUS_LIVE.to_string(),
4182 "non-UTF-8 root source status",
4183 )?;
4184 require_eq(&report.line_count, &1, "non-UTF-8 root source line count")?;
4185 Ok(())
4186 }
4187
4188 #[test]
4189 fn token_report_service_selects_typed_reports_and_requires_a_project_binding()
4190 -> Result<(), Box<dyn Error>> {
4191 let temp = tempfile::tempdir()?;
4192 let root = temp.path().join("repo");
4193 let atlas_dir = root.join(".projectatlas");
4194 fs::create_dir_all(&atlas_dir)?;
4195 let store = AtlasStore::open_for_project(&atlas_dir.join("projectatlas.db"), &root)?;
4196
4197 let overview = load_token_report(
4198 &store,
4199 TokenReportRequest::Overview {
4200 caller_label: None,
4201 benchmark_results: None,
4202 },
4203 )?;
4204 match overview {
4205 TokenReport::Overview(overview) => {
4206 require_eq(&overview.calls, &0, "empty overview calls")?;
4207 require_eq(
4208 &overview.detail_availability,
4209 &UsageDetailAvailability::Retained,
4210 "empty overview detail availability",
4211 )?;
4212 require_eq(
4213 &overview.agent_efficiency.state,
4214 &AgentEfficiencyEvidenceState::Unavailable,
4215 "empty overview agent-efficiency state",
4216 )?;
4217 }
4218 TokenReport::Trends(_) => {
4219 return Err(io::Error::other("overview request returned token trends").into());
4220 }
4221 }
4222
4223 let trends = load_token_report(
4224 &store,
4225 TokenReportRequest::Trends {
4226 caller_label: None,
4227 window: TokenTrendWindow::Month,
4228 },
4229 )?;
4230 match trends {
4231 TokenReport::Trends(report) => {
4232 require_eq(&report.window, &TokenTrendWindow::Month, "trend window")?;
4233 require_eq(
4234 &report.detail_availability,
4235 &UsageDetailAvailability::Retained,
4236 "empty trend detail availability",
4237 )?;
4238 }
4239 TokenReport::Overview(_) => {
4240 return Err(io::Error::other("trend request returned token overview").into());
4241 }
4242 }
4243
4244 let unbound = AtlasStore::in_memory()?;
4245 if !matches!(
4246 load_token_report(
4247 &unbound,
4248 TokenReportRequest::Overview {
4249 caller_label: None,
4250 benchmark_results: None,
4251 }
4252 ),
4253 Err(ServiceError::SelectedProjectUnavailable)
4254 ) {
4255 return Err(io::Error::other("unbound token report did not fail closed").into());
4256 }
4257 Ok(())
4258 }
4259
4260 #[test]
4261 fn token_report_service_bounds_and_classifies_benchmark_evidence() -> Result<(), Box<dyn Error>>
4262 {
4263 let temp = tempfile::tempdir()?;
4264 let root = temp.path().join("benchmark-service");
4265 let atlas_dir = root.join(".projectatlas");
4266 fs::create_dir_all(&atlas_dir)?;
4267 let store = AtlasStore::open_for_project(&atlas_dir.join("projectatlas.db"), &root)?;
4268 let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4269 .join("../../docs/benchmarks/v0.4-agent-navigation-results.json");
4270 let published = root.join("published.json");
4271 fs::copy(&source, &published)?;
4272
4273 let report = load_token_report(
4274 &store,
4275 TokenReportRequest::Overview {
4276 caller_label: None,
4277 benchmark_results: Some(Path::new("published.json")),
4278 },
4279 )?;
4280 let TokenReport::Overview(report) = report else {
4281 return Err(io::Error::other("benchmark request returned token trends").into());
4282 };
4283 require_eq(
4284 &report.agent_efficiency.state,
4285 &AgentEfficiencyEvidenceState::Partial,
4286 "published benchmark state",
4287 )?;
4288 let comparison =
4289 load_agent_efficiency_comparison(&store, Some(Path::new("published.json")))?;
4290 require_eq(
4291 &comparison.state,
4292 &AgentEfficiencyEvidenceState::Partial,
4293 "standalone benchmark enrichment state",
4294 )?;
4295 let frozen = report
4296 .agent_efficiency
4297 .baselines
4298 .iter()
4299 .find(|row| row.baseline == AgentEfficiencyBaseline::FrozenProjectAtlasV0326)
4300 .ok_or_else(|| io::Error::other("frozen baseline row missing"))?;
4301 require_eq(
4302 &frozen.baseline_failed_trials,
4303 &3,
4304 "published frozen failed trials",
4305 )?;
4306
4307 fs::write(root.join("malformed.json"), b"{")?;
4308 let malformed = load_token_report(
4309 &store,
4310 TokenReportRequest::Overview {
4311 caller_label: None,
4312 benchmark_results: Some(Path::new("malformed.json")),
4313 },
4314 )?;
4315 let TokenReport::Overview(malformed) = malformed else {
4316 return Err(io::Error::other("malformed request returned token trends").into());
4317 };
4318 require_eq(
4319 &malformed.agent_efficiency.state,
4320 &AgentEfficiencyEvidenceState::Failed,
4321 "malformed benchmark state",
4322 )?;
4323
4324 let stale = String::from_utf8(fs::read(&source)?)?.replacen(
4325 "\"schema_version\": 1",
4326 "\"schema_version\": 2",
4327 1,
4328 );
4329 fs::write(root.join("stale.json"), stale)?;
4330 let stale = load_token_report(
4331 &store,
4332 TokenReportRequest::Overview {
4333 caller_label: None,
4334 benchmark_results: Some(Path::new("stale.json")),
4335 },
4336 )?;
4337 let TokenReport::Overview(stale) = stale else {
4338 return Err(io::Error::other("stale request returned token trends").into());
4339 };
4340 require_eq(
4341 &stale.agent_efficiency.state,
4342 &AgentEfficiencyEvidenceState::Incompatible,
4343 "stale benchmark state",
4344 )?;
4345
4346 let missing = load_token_report(
4347 &store,
4348 TokenReportRequest::Overview {
4349 caller_label: None,
4350 benchmark_results: Some(Path::new("missing.json")),
4351 },
4352 )?;
4353 let TokenReport::Overview(missing) = missing else {
4354 return Err(io::Error::other("missing request returned token trends").into());
4355 };
4356 require_eq(
4357 &missing.agent_efficiency.state,
4358 &AgentEfficiencyEvidenceState::Failed,
4359 "missing benchmark state",
4360 )?;
4361
4362 fs::write(
4363 root.join("oversized.json"),
4364 vec![b' '; super::agent_efficiency::BENCHMARK_MAX_BYTES + 1],
4365 )?;
4366 let oversized = load_token_report(
4367 &store,
4368 TokenReportRequest::Overview {
4369 caller_label: None,
4370 benchmark_results: Some(Path::new("oversized.json")),
4371 },
4372 )?;
4373 let TokenReport::Overview(oversized) = oversized else {
4374 return Err(io::Error::other("oversized request returned token trends").into());
4375 };
4376 require_eq(
4377 &oversized.agent_efficiency.state,
4378 &AgentEfficiencyEvidenceState::Failed,
4379 "oversized benchmark state",
4380 )?;
4381
4382 for escaped in [published.as_path(), Path::new("../outside.json")] {
4383 if !matches!(
4384 load_token_report(
4385 &store,
4386 TokenReportRequest::Overview {
4387 caller_label: None,
4388 benchmark_results: Some(escaped),
4389 },
4390 ),
4391 Err(ServiceError::InvalidInput(_))
4392 ) {
4393 return Err(io::Error::other(
4394 "escaping benchmark path did not fail at the service boundary",
4395 )
4396 .into());
4397 }
4398 }
4399 Ok(())
4400 }
4401
4402 #[test]
4403 fn summary_digest_and_opt_in_coverage_page_share_current_typed_rows()
4404 -> Result<(), Box<dyn Error>> {
4405 let temp = tempfile::tempdir()?;
4406 let root = temp.path().join("coverage-service");
4407 fs::create_dir_all(root.join("src"))?;
4408 fs::write(root.join("src/lib.rs"), "pub fn run() {}\n")?;
4409 let db_path = root.join("projectatlas.db");
4410 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
4411 let project = store
4412 .project_instance_id()?
4413 .ok_or_else(|| io::Error::other("service fixture identity is missing"))?;
4414 let generation = IndexGeneration::new(1);
4415 let mut publication = store.begin_index_publication("coverage-service")?;
4416 publication.begin_scan_replacement()?;
4417 publication.upsert_scan_node_batch(&[test_node("src/lib.rs", "coverage-hash")])?;
4418 publication.finish_scan_replacement()?;
4419 publication.replace_symbol_graph(&SymbolGraph {
4420 path: "src/lib.rs".to_string(),
4421 language: Some("rust".to_string()),
4422 parser: ParserKind::TreeSitter,
4423 symbols: Vec::new(),
4424 relations: Vec::new(),
4425 })?;
4426 let mut coverage = vec![
4427 CoverageRecord::new(
4428 CoverageScope::Path {
4429 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
4430 },
4431 None,
4432 CoverageState::Partial,
4433 3,
4434 1,
4435 generation,
4436 Some(GraphIdentityText::new("one fallback fact omitted")?),
4437 Some(GraphLimitKind::Rows),
4438 )?,
4439 CoverageRecord::new(
4440 CoverageScope::Project,
4441 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
4442 CoverageState::Failed,
4443 0,
4444 1,
4445 generation,
4446 Some(GraphIdentityText::new("parser failed")?),
4447 None,
4448 )?,
4449 ];
4450 for index in 0..=COVERAGE_DIGEST_ROW_LIMIT {
4451 let sibling_path = format!("src/lib.rs.{index:02}");
4452 coverage.push(CoverageRecord::new(
4453 CoverageScope::Path {
4454 path: RepositoryNodePath::new(Path::new(&sibling_path))?,
4455 },
4456 None,
4457 CoverageState::Complete,
4458 1,
4459 0,
4460 generation,
4461 None,
4462 None,
4463 )?);
4464 }
4465 publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
4466 publication.replace_graph_identity_rejections(
4467 project,
4468 &[GraphIdentityRejection {
4469 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
4470 span: SourceSpan::new(1, 0, 1, 2)?,
4471 parser: ParserKind::TreeSitter,
4472 field: GraphIdentityField::Symbol,
4473 reason: GraphIdentityRejectionReason::Empty,
4474 fact_index: 0,
4475 }],
4476 )?;
4477 publication.complete()?;
4478 drop(store);
4479
4480 let store = AtlasStore::open_read_only_for_project(&db_path, &root)?;
4481 let summary = build_file_summary(&store, Path::new("src/lib.rs"), 10)?;
4482 require_eq(
4483 &summary.coverage.available,
4484 &true,
4485 "summary coverage availability",
4486 )?;
4487 require_eq(
4488 &summary.coverage.states.partial,
4489 &1,
4490 "summary partial coverage count",
4491 )?;
4492 require_eq(
4493 &summary.coverage.states.complete,
4494 &0,
4495 "summary excluded lexical sibling coverage",
4496 )?;
4497 require_eq(
4498 &summary.coverage.states.failed,
4499 &0,
4500 "summary excluded project-wide coverage",
4501 )?;
4502 require_eq(
4503 &summary.coverage.truncated,
4504 &false,
4505 "summary exact-file coverage truncation",
4506 )?;
4507 require_eq(
4508 &summary.coverage.trust,
4509 &CoverageTrustState::Partial,
4510 "summary exact-file coverage trust",
4511 )?;
4512 require_eq(
4513 &summary.coverage.provider,
4514 &Some(ParserKind::TreeSitter),
4515 "summary fact provider",
4516 )?;
4517 require_eq(
4518 &summary.coverage.next_call.capability,
4519 &NavigationNextCapability::Health,
4520 "summary coverage next call",
4521 )?;
4522
4523 let report = load_coverage_discovery(
4524 &store,
4525 RepositoryCoverageQuery {
4526 start_index: 0,
4527 limit: 10,
4528 path_prefix: None,
4529 parser: None,
4530 provider: None,
4531 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
4532 state: Some(CoverageState::Failed),
4533 reason: Some("parser failed".to_string()),
4534 },
4535 )?;
4536 require_eq(&report.returned, &1, "filtered service coverage row")?;
4537 require_eq(
4538 &report.total,
4539 &CoverageTotalState::Exact(1),
4540 "bounded exact coverage total",
4541 )?;
4542 require_eq(
4543 &report.rows[0].next_call.capability,
4544 &NavigationNextCapability::Health,
4545 "project coverage next call",
4546 )?;
4547 let truncated = load_coverage_discovery(
4548 &store,
4549 RepositoryCoverageQuery {
4550 start_index: 0,
4551 limit: 1,
4552 path_prefix: None,
4553 parser: None,
4554 provider: None,
4555 relation: None,
4556 state: None,
4557 reason: None,
4558 },
4559 )?;
4560 require_eq(
4561 &truncated.total,
4562 &CoverageTotalState::AtLeast(2),
4563 "truncated coverage lower bound",
4564 )?;
4565 require_eq(&truncated.continuation, &Some(1), "coverage continuation")?;
4566 require(
4567 truncated
4568 .rows
4569 .iter()
4570 .any(|row| row.path == "src/lib.rs" && row.identity_rejections.len() == 1),
4571 "structured coverage omitted typed identity rejection details",
4572 )?;
4573 let exhausted = load_coverage_discovery(
4574 &store,
4575 RepositoryCoverageQuery {
4576 start_index: 100,
4577 limit: 1,
4578 path_prefix: None,
4579 parser: None,
4580 provider: None,
4581 relation: None,
4582 state: None,
4583 reason: None,
4584 },
4585 )?;
4586 require_eq(
4587 &exhausted.total,
4588 &CoverageTotalState::Unknown,
4589 "exhausted nonzero continuation total",
4590 )?;
4591 Ok(())
4592 }
4593
4594 #[test]
4595 fn coverage_discovery_bounds_long_identity_rejection_details() -> Result<(), Box<dyn Error>> {
4596 let temp = tempfile::tempdir()?;
4597 let root = temp.path().join("bounded-coverage-service");
4598 fs::create_dir_all(&root)?;
4599 let db_path = root.join("projectatlas.db");
4600 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
4601 let project = store
4602 .project_instance_id()?
4603 .ok_or_else(|| io::Error::other("bounded coverage fixture identity is missing"))?;
4604 let generation = IndexGeneration::new(1);
4605 let long_path = format!("src/{}/entry.ts", "segment".repeat(400));
4606 let path = RepositoryNodePath::new(Path::new(&long_path))?;
4607 let later_path = RepositoryNodePath::new(Path::new("src/later.ts"))?;
4608 let complete_path = RepositoryNodePath::new(Path::new("src/complete.ts"))?;
4609 let coverage = CoverageRecord::new(
4610 CoverageScope::Path { path: path.clone() },
4611 None,
4612 CoverageState::Partial,
4613 10_000,
4614 10_000,
4615 generation,
4616 Some(GraphIdentityText::new("identity details retained")?),
4617 None,
4618 )?;
4619 let later_coverage = CoverageRecord::new(
4620 CoverageScope::Path {
4621 path: later_path.clone(),
4622 },
4623 None,
4624 CoverageState::Partial,
4625 1,
4626 1,
4627 generation,
4628 Some(GraphIdentityText::new("identity details evicted")?),
4629 Some(GraphLimitKind::Rows),
4630 )?;
4631 let later_documents_coverage = CoverageRecord::new(
4632 CoverageScope::Path {
4633 path: later_path.clone(),
4634 },
4635 Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
4636 CoverageState::Partial,
4637 1,
4638 1,
4639 generation,
4640 Some(GraphIdentityText::new("identity details evicted")?),
4641 None,
4642 )?;
4643 let complete_coverage = CoverageRecord::new(
4644 CoverageScope::Path {
4645 path: complete_path.clone(),
4646 },
4647 None,
4648 CoverageState::Complete,
4649 1,
4650 0,
4651 generation,
4652 None,
4653 None,
4654 )?;
4655 let rejections = (0..GraphLimits::MAX_ROWS)
4656 .map(|fact_index| {
4657 Ok(GraphIdentityRejection {
4658 path: path.clone(),
4659 span: SourceSpan::new(fact_index + 1, 0, fact_index + 1, 1)?,
4660 parser: ParserKind::TreeSitter,
4661 field: GraphIdentityField::Symbol,
4662 reason: GraphIdentityRejectionReason::Empty,
4663 fact_index: u64::from(fact_index),
4664 })
4665 })
4666 .collect::<Result<Vec<_>, projectatlas_core::graph::GraphContractError>>()?;
4667 {
4668 let mut publication = store.begin_index_publication("bounded-coverage")?;
4669 publication.begin_scan_replacement()?;
4670 publication.upsert_scan_node_batch(&[
4671 test_node(&long_path, "bounded-coverage-hash"),
4672 test_node("src/later.ts", "later-coverage-hash"),
4673 test_node("src/complete.ts", "complete-coverage-hash"),
4674 ])?;
4675 publication.finish_scan_replacement()?;
4676 publication.replace_symbol_graph(&SymbolGraph {
4677 path: later_path.as_str().to_string(),
4678 language: Some("typescript".to_string()),
4679 parser: ParserKind::TreeSitter,
4680 symbols: Vec::new(),
4681 relations: Vec::new(),
4682 })?;
4683 publication.replace_repository_graph(
4684 project,
4685 &[],
4686 &[],
4687 &[],
4688 &[
4689 coverage,
4690 later_coverage,
4691 later_documents_coverage,
4692 complete_coverage,
4693 ],
4694 )?;
4695 publication.replace_graph_identity_rejections(project, &rejections)?;
4696 publication.complete()?;
4697 }
4698 let query = RepositoryCoverageQuery {
4699 start_index: 0,
4700 limit: 1,
4701 path_prefix: Some(long_path),
4702 parser: None,
4703 provider: None,
4704 relation: None,
4705 state: None,
4706 reason: None,
4707 };
4708 let first = load_coverage_discovery(&store, query.clone())?;
4709 let second = load_coverage_discovery(&store, query)?;
4710 require_eq(&first, &second, "bounded coverage determinism")?;
4711 require_eq(&first.returned, &1, "bounded coverage row count")?;
4712 require_eq(
4713 &first.rows[0].identity_rejections.len(),
4714 &(COVERAGE_IDENTITY_REJECTION_LIMIT as usize),
4715 "bounded identity rejection detail count",
4716 )?;
4717 require_eq(
4718 &first.identity_rejections_limit,
4719 &COVERAGE_IDENTITY_REJECTION_LIMIT,
4720 "bounded identity rejection detail limit",
4721 )?;
4722 require_eq(
4723 &first.identity_rejections_truncated,
4724 &true,
4725 "bounded identity rejection truncation state",
4726 )?;
4727 let encoded = serde_json::to_vec(&first)?;
4728 require(
4729 encoded.len() <= GraphLimits::MAX_OUTPUT_BYTES as usize,
4730 "bounded coverage exceeded the encoded-output ceiling",
4731 )?;
4732
4733 let later = load_coverage_discovery(
4734 &store,
4735 RepositoryCoverageQuery {
4736 start_index: 0,
4737 limit: 1,
4738 path_prefix: Some(later_path.as_str().to_string()),
4739 parser: None,
4740 provider: None,
4741 relation: None,
4742 state: None,
4743 reason: None,
4744 },
4745 )?;
4746 require_eq(&later.returned, &1, "publication-cap causal coverage row")?;
4747 require(
4748 later.rows[0].identity_rejections.is_empty(),
4749 "publication-cap causal path unexpectedly retained rejection details",
4750 )?;
4751 require_eq(
4752 &later.identity_rejections_truncated,
4753 &true,
4754 "publication-cap causal coverage truncation state",
4755 )?;
4756 let later_documents = load_coverage_discovery(
4757 &store,
4758 RepositoryCoverageQuery {
4759 start_index: 0,
4760 limit: 1,
4761 path_prefix: Some(later_path.as_str().to_string()),
4762 parser: None,
4763 provider: None,
4764 relation: Some(GraphRelationKind::Extended(ExtendedRelationKind::Documents)),
4765 state: None,
4766 reason: Some("identity details evicted".to_string()),
4767 },
4768 )?;
4769 require_eq(
4770 &later_documents.returned,
4771 &1,
4772 "relation-filtered publication-cap coverage row",
4773 )?;
4774 require(
4775 later_documents.rows[0].identity_rejections.is_empty(),
4776 "relation-filtered publication-cap path unexpectedly retained rejection details",
4777 )?;
4778 require_eq(
4779 &later_documents.identity_rejections_truncated,
4780 &true,
4781 "relation-filtered publication-cap coverage lost companion truncation marker",
4782 )?;
4783 let complete = load_coverage_discovery(
4784 &store,
4785 RepositoryCoverageQuery {
4786 start_index: 0,
4787 limit: 1,
4788 path_prefix: Some(complete_path.as_str().to_string()),
4789 parser: None,
4790 provider: None,
4791 relation: None,
4792 state: None,
4793 reason: None,
4794 },
4795 )?;
4796 require_eq(
4797 &complete.identity_rejections_truncated,
4798 &false,
4799 "unrelated identity-detail overflow marked a complete path",
4800 )?;
4801 Ok(())
4802 }
4803
4804 #[test]
4805 fn metadata_helpers_are_stable() -> Result<(), Box<dyn Error>> {
4806 let mut package = test_symbol("Cargo.toml", SymbolKind::Package, "projectatlas");
4807 package.documentation = Some("ProjectAtlas package manifest.".to_string());
4808 let mut alpha = test_symbol("src/lib.rs", SymbolKind::Function, "alpha");
4809 alpha.exported = true;
4810 alpha.documentation = Some("Alpha entry point.".to_string());
4811 let mut beta = test_symbol("src/lib.rs", SymbolKind::Function, "beta");
4812 beta.exported = true;
4813 let private = test_symbol("src/lib.rs", SymbolKind::Function, "private");
4814 let symbols = vec![beta, package, private, alpha];
4815
4816 require_eq(
4817 &package_name(&symbols),
4818 &"projectatlas".to_string(),
4819 "package name",
4820 )?;
4821 require_eq(
4822 &file_docstring(&symbols),
4823 &"ProjectAtlas package manifest.".to_string(),
4824 "file docstring",
4825 )?;
4826 require_eq(
4827 &exported_symbol_names(&symbols),
4828 &vec!["alpha".to_string(), "beta".to_string()],
4829 "exported symbols",
4830 )?;
4831 require_eq(
4832 &file_level_docstring("//! Module level docs.\nfn main() {}"),
4833 &Some("Module level docs.".to_string()),
4834 "rust module docs",
4835 )?;
4836 require_eq(
4837 &file_level_docstring("\"\"\"Python module docs.\"\"\"\nclass Atlas: pass"),
4838 &Some("Python module docs.".to_string()),
4839 "python module docs",
4840 )?;
4841 Ok(())
4842 }
4843
4844 #[test]
4845 fn file_summary_marks_fallback_symbol_graph_as_fallback() -> Result<(), Box<dyn Error>> {
4846 let temp = tempfile::tempdir()?;
4847 let root = temp.path();
4848 fs::create_dir(root.join("src"))?;
4849 fs::write(
4850 root.join("src").join("component.vue"),
4851 "<script setup></script>",
4852 )?;
4853 let mut store = AtlasStore::in_memory()?;
4854 store.set_project_root(root)?;
4855 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
4856 store.set_purpose(
4857 "src/component.vue",
4858 "Provide Vue component behavior",
4859 PurposeSource::Agent,
4860 )?;
4861 store.set_node_summary("src/component.vue", "vue component with bindings selected.")?;
4862 let mut fallback_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
4863 fallback_symbol.parser = ParserKind::Fallback;
4864 store.replace_symbol_graph(&SymbolGraph {
4865 path: "src/component.vue".to_string(),
4866 language: Some("vue".to_string()),
4867 parser: ParserKind::Fallback,
4868 symbols: vec![fallback_symbol],
4869 relations: Vec::new(),
4870 })?;
4871
4872 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
4873 require_eq(
4874 &report.parser_kind,
4875 &"fallback-symbol-graph".to_string(),
4876 "fallback parser kind",
4877 )?;
4878 require_eq(
4879 &report.summary_status,
4880 &"fallback".to_string(),
4881 "fallback summary status",
4882 )
4883 }
4884
4885 #[test]
4886 fn file_summary_marks_empty_fallback_graph_as_fallback() -> Result<(), Box<dyn Error>> {
4887 let temp = tempfile::tempdir()?;
4888 let root = temp.path();
4889 fs::create_dir(root.join("scripts"))?;
4890 fs::write(root.join("scripts").join("config.ps1"), "# comment only\n")?;
4891 let mut store = AtlasStore::in_memory()?;
4892 store.set_project_root(root)?;
4893 store.replace_scan(&[test_node("scripts/config.ps1", "hash-ps1")])?;
4894 store.set_node_summary(
4895 "scripts/config.ps1",
4896 "powershell source file with no declarations found.",
4897 )?;
4898 store.replace_symbol_graph(&SymbolGraph {
4899 path: "scripts/config.ps1".to_string(),
4900 language: Some("powershell".to_string()),
4901 parser: ParserKind::Fallback,
4902 symbols: Vec::new(),
4903 relations: Vec::new(),
4904 })?;
4905
4906 let report = build_file_summary(&store, Path::new("scripts/config.ps1"), 10)?;
4907 require_eq(
4908 &report.parser_kind,
4909 &"fallback-symbol-graph".to_string(),
4910 "empty fallback parser kind",
4911 )?;
4912 require_eq(
4913 &report.summary_status,
4914 &"fallback".to_string(),
4915 "empty fallback summary status",
4916 )
4917 }
4918
4919 #[test]
4920 fn file_summary_uses_metadata_parser_for_empty_nonfallback_graphs() -> Result<(), Box<dyn Error>>
4921 {
4922 for (path, language, parser, expected) in [
4923 (
4924 "src/empty.rs",
4925 "rust",
4926 ParserKind::TreeSitter,
4927 "tree-sitter-symbol-graph",
4928 ),
4929 (
4930 "src/component.vue",
4931 "vue",
4932 ParserKind::Structural,
4933 "structural-symbol-graph",
4934 ),
4935 (
4936 "Cargo.toml",
4937 "cargo-manifest",
4938 ParserKind::Manifest,
4939 "manifest-symbol-graph",
4940 ),
4941 ] {
4942 let temp = tempfile::tempdir()?;
4943 let root = temp.path();
4944 if let Some(parent) = Path::new(path).parent() {
4945 fs::create_dir_all(root.join(parent))?;
4946 }
4947 fs::write(root.join(path), "\n")?;
4948 let mut store = AtlasStore::in_memory()?;
4949 store.set_project_root(root)?;
4950 store.replace_scan(&[test_node(path, "hash-empty")])?;
4951 store.set_node_summary(path, "source file with no declarations found.")?;
4952 store.replace_symbol_graph(&SymbolGraph {
4953 path: path.to_string(),
4954 language: Some(language.to_string()),
4955 parser,
4956 symbols: Vec::new(),
4957 relations: Vec::new(),
4958 })?;
4959
4960 let report = build_file_summary(&store, Path::new(path), 10)?;
4961 require_eq(
4962 &report.parser_kind,
4963 &expected.to_string(),
4964 "empty nonfallback parser kind",
4965 )?;
4966 }
4967 Ok(())
4968 }
4969
4970 #[test]
4971 fn file_summary_marks_structural_symbol_graph_as_ok() -> Result<(), Box<dyn Error>> {
4972 let temp = tempfile::tempdir()?;
4973 let root = temp.path();
4974 fs::create_dir(root.join("src"))?;
4975 fs::write(
4976 root.join("src").join("component.vue"),
4977 "<script setup>const selected = ref(false)</script>",
4978 )?;
4979 let mut store = AtlasStore::in_memory()?;
4980 store.set_project_root(root)?;
4981 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
4982 store.set_node_summary("src/component.vue", "vue component with bindings selected.")?;
4983 let mut structural_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
4984 structural_symbol.parser = ParserKind::Structural;
4985 store.replace_symbol_graph(&SymbolGraph {
4986 path: "src/component.vue".to_string(),
4987 language: Some("vue".to_string()),
4988 parser: ParserKind::Structural,
4989 symbols: vec![structural_symbol],
4990 relations: Vec::new(),
4991 })?;
4992
4993 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
4994 require_eq(
4995 &report.parser_kind,
4996 &"structural-symbol-graph".to_string(),
4997 "structural parser kind",
4998 )?;
4999 require_eq(
5000 &report.summary_status,
5001 &"ok".to_string(),
5002 "structural summary status",
5003 )
5004 }
5005
5006 #[test]
5007 fn file_summary_reports_mixed_vue_symbol_graph_with_structural_metadata()
5008 -> Result<(), Box<dyn Error>> {
5009 let temp = tempfile::tempdir()?;
5010 let root = temp.path();
5011 fs::create_dir(root.join("src"))?;
5012 fs::write(
5013 root.join("src").join("component.vue"),
5014 "<script lang=\"ts\">export function submitOrder() {}</script>\n<script setup>const selected = ref(false)</script>",
5015 )?;
5016 let mut store = AtlasStore::in_memory()?;
5017 store.set_project_root(root)?;
5018 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
5019 store.set_node_summary(
5020 "src/component.vue",
5021 "vue source defining values selected and function submitOrder.",
5022 )?;
5023 let mut structural_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
5024 structural_symbol.parser = ParserKind::Structural;
5025 structural_symbol.detail = Some("vue-composition-binding".to_string());
5026 let mut fallback_symbol =
5027 test_symbol("src/component.vue", SymbolKind::Function, "submitOrder");
5028 fallback_symbol.parser = ParserKind::Fallback;
5029 fallback_symbol.detail = Some("fallback-js-function".to_string());
5030 store.replace_symbol_graph(&SymbolGraph {
5031 path: "src/component.vue".to_string(),
5032 language: Some("vue".to_string()),
5033 parser: ParserKind::Structural,
5034 symbols: vec![structural_symbol, fallback_symbol],
5035 relations: Vec::new(),
5036 })?;
5037
5038 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
5039 require_eq(
5040 &report.parser_kind,
5041 &"mixed-symbol-graph".to_string(),
5042 "mixed parser kind",
5043 )?;
5044 require_eq(
5045 &report.summary_status,
5046 &"ok".to_string(),
5047 "mixed summary status",
5048 )
5049 }
5050
5051 #[test]
5052 fn module_aliases_include_package_entries_and_compound_extensions() -> Result<(), Box<dyn Error>>
5053 {
5054 require_eq(
5055 &module_aliases_for_path("src/packages/foo/index.ts"),
5056 &vec![
5057 "foo".to_string(),
5058 "packages.foo".to_string(),
5059 "packages::foo".to_string(),
5060 ],
5061 "typescript package entry aliases",
5062 )?;
5063 require_eq(
5064 &module_aliases_for_path("src/types/api.d.ts"),
5065 &vec![
5066 "api".to_string(),
5067 "types.api".to_string(),
5068 "types::api".to_string(),
5069 ],
5070 "typescript definition aliases",
5071 )?;
5072 require_eq(
5073 &module_aliases_for_path("src/package/__init__.py"),
5074 &vec!["package".to_string()],
5075 "python package entry aliases",
5076 )?;
5077 require_eq(
5078 &module_aliases_for_path("src/lib.rs"),
5079 &Vec::<String>::new(),
5080 "rust root lib aliases",
5081 )
5082 }
5083
5084 #[test]
5085 fn file_summary_includes_cross_file_called_by() -> Result<(), Box<dyn Error>> {
5086 let temp = tempfile::tempdir()?;
5087 let root = temp.path();
5088 fs::create_dir(root.join("src"))?;
5089 fs::write(
5090 root.join("src").join("lib.rs"),
5091 "/// Shared helper.\npub fn helper() {}\n",
5092 )?;
5093 let mut store = AtlasStore::in_memory()?;
5094 store.set_project_root(root)?;
5095 store.replace_scan(&[
5096 test_node("src/lib.rs", "hash-lib"),
5097 test_node("src/main.rs", "hash-main"),
5098 ])?;
5099 store.set_purpose(
5100 "src/lib.rs",
5101 "Provide shared library behavior",
5102 PurposeSource::Agent,
5103 )?;
5104 store.replace_symbol_graph(&SymbolGraph {
5105 path: "src/lib.rs".to_string(),
5106 language: Some("rust".to_string()),
5107 parser: ParserKind::TreeSitter,
5108 symbols: vec![{
5109 let mut symbol = test_symbol("src/lib.rs", SymbolKind::Function, "helper");
5110 symbol.exported = true;
5111 symbol.line_start = 2;
5112 symbol.line_end = 2;
5113 symbol
5114 }],
5115 relations: Vec::new(),
5116 })?;
5117 store.replace_symbol_graph(&SymbolGraph {
5118 path: "src/main.rs".to_string(),
5119 language: Some("rust".to_string()),
5120 parser: ParserKind::TreeSitter,
5121 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
5122 relations: vec![SymbolRelation {
5123 path: "src/main.rs".to_string(),
5124 source_name: "main".to_string(),
5125 target_name: "crate::helper".to_string(),
5126 kind: RelationKind::Calls,
5127 line: 1,
5128 context: "helper();".to_string(),
5129 parser: ParserKind::TreeSitter,
5130 }],
5131 })?;
5132
5133 let report = build_file_summary(&store, Path::new("src/lib.rs"), 10)?;
5134 require_eq(
5135 &report.file_purpose_status,
5136 &PurposeStatus::Approved.to_string(),
5137 "purpose status",
5138 )?;
5139 require_eq(
5140 &report.file_purpose_agent_reviewed,
5141 &true,
5142 "purpose agent reviewed",
5143 )?;
5144 let helper = report
5145 .functions
5146 .iter()
5147 .find(|symbol| symbol.name == "helper")
5148 .ok_or_else(|| io::Error::other("helper summary missing"))?;
5149 require_eq(
5150 &helper.called_by,
5151 &vec!["src/main.rs::main".to_string()],
5152 "cross-file called-by",
5153 )?;
5154 Ok(())
5155 }
5156
5157 #[test]
5158 fn file_summary_rejects_ambiguous_called_by_matches() -> Result<(), Box<dyn Error>> {
5159 let temp = tempfile::tempdir()?;
5160 let root = temp.path();
5161 fs::create_dir(root.join("src"))?;
5162 fs::write(root.join("src").join("a.rs"), "pub fn helper() {}\n")?;
5163 fs::write(root.join("src").join("b.rs"), "pub fn helper() {}\n")?;
5164 fs::write(
5165 root.join("src").join("main.rs"),
5166 "mod a;\nmod b;\nfn main() { b::helper(); }\n",
5167 )?;
5168 let mut store = AtlasStore::in_memory()?;
5169 store.set_project_root(root)?;
5170 store.replace_scan(&[
5171 test_node("src/a.rs", "hash-a"),
5172 test_node("src/b.rs", "hash-b"),
5173 test_node("src/main.rs", "hash-main"),
5174 ])?;
5175 store.replace_symbol_graph(&SymbolGraph {
5176 path: "src/a.rs".to_string(),
5177 language: Some("rust".to_string()),
5178 parser: ParserKind::TreeSitter,
5179 symbols: vec![test_symbol("src/a.rs", SymbolKind::Function, "helper")],
5180 relations: Vec::new(),
5181 })?;
5182 store.replace_symbol_graph(&SymbolGraph {
5183 path: "src/b.rs".to_string(),
5184 language: Some("rust".to_string()),
5185 parser: ParserKind::TreeSitter,
5186 symbols: vec![test_symbol("src/b.rs", SymbolKind::Function, "helper")],
5187 relations: Vec::new(),
5188 })?;
5189 store.replace_symbol_graph(&SymbolGraph {
5190 path: "src/main.rs".to_string(),
5191 language: Some("rust".to_string()),
5192 parser: ParserKind::TreeSitter,
5193 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
5194 relations: vec![SymbolRelation {
5195 path: "src/main.rs".to_string(),
5196 source_name: "main".to_string(),
5197 target_name: "b::helper".to_string(),
5198 kind: RelationKind::Calls,
5199 line: 3,
5200 context: "b::helper();".to_string(),
5201 parser: ParserKind::TreeSitter,
5202 }],
5203 })?;
5204
5205 let a_report = build_file_summary(&store, Path::new("src/a.rs"), 10)?;
5206 let a_helper = a_report
5207 .functions
5208 .iter()
5209 .find(|symbol| symbol.name == "helper")
5210 .ok_or_else(|| io::Error::other("a::helper summary missing"))?;
5211 require_eq(&a_helper.called_by, &Vec::<String>::new(), "a called-by")?;
5212
5213 let b_report = build_file_summary(&store, Path::new("src/b.rs"), 10)?;
5214 let b_helper = b_report
5215 .functions
5216 .iter()
5217 .find(|symbol| symbol.name == "helper")
5218 .ok_or_else(|| io::Error::other("b::helper summary missing"))?;
5219 require_eq(
5220 &b_helper.called_by,
5221 &vec!["src/main.rs::main".to_string()],
5222 "b called-by",
5223 )?;
5224 Ok(())
5225 }
5226
5227 #[test]
5228 fn file_summary_rejects_ambiguous_module_alias_called_by_matches() -> Result<(), Box<dyn Error>>
5229 {
5230 let temp = tempfile::tempdir()?;
5231 let root = temp.path();
5232 fs::create_dir_all(root.join("src").join("foo"))?;
5233 fs::create_dir_all(root.join("src").join("bar"))?;
5234 fs::write(root.join("src/foo/service.rs"), "pub fn run() {}\n")?;
5235 fs::write(root.join("src/bar/service.rs"), "pub fn run() {}\n")?;
5236 fs::write(root.join("src/main.rs"), "fn main() { service::run(); }\n")?;
5237 let mut store = AtlasStore::in_memory()?;
5238 store.set_project_root(root)?;
5239 store.replace_scan(&[
5240 test_node("src/foo/service.rs", "hash-foo"),
5241 test_node("src/bar/service.rs", "hash-bar"),
5242 test_node("src/main.rs", "hash-main"),
5243 ])?;
5244 store.replace_symbol_graph(&SymbolGraph {
5245 path: "src/foo/service.rs".to_string(),
5246 language: Some("rust".to_string()),
5247 parser: ParserKind::TreeSitter,
5248 symbols: vec![test_symbol(
5249 "src/foo/service.rs",
5250 SymbolKind::Function,
5251 "run",
5252 )],
5253 relations: Vec::new(),
5254 })?;
5255 store.replace_symbol_graph(&SymbolGraph {
5256 path: "src/bar/service.rs".to_string(),
5257 language: Some("rust".to_string()),
5258 parser: ParserKind::TreeSitter,
5259 symbols: vec![test_symbol(
5260 "src/bar/service.rs",
5261 SymbolKind::Function,
5262 "run",
5263 )],
5264 relations: Vec::new(),
5265 })?;
5266 store.replace_symbol_graph(&SymbolGraph {
5267 path: "src/main.rs".to_string(),
5268 language: Some("rust".to_string()),
5269 parser: ParserKind::TreeSitter,
5270 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
5271 relations: vec![SymbolRelation {
5272 path: "src/main.rs".to_string(),
5273 source_name: "main".to_string(),
5274 target_name: "service::run".to_string(),
5275 kind: RelationKind::Calls,
5276 line: 1,
5277 context: "service::run();".to_string(),
5278 parser: ParserKind::TreeSitter,
5279 }],
5280 })?;
5281
5282 for path in ["src/foo/service.rs", "src/bar/service.rs"] {
5283 let report = build_file_summary(&store, Path::new(path), 10)?;
5284 let run = report
5285 .functions
5286 .iter()
5287 .find(|symbol| symbol.name == "run")
5288 .ok_or_else(|| io::Error::other("run summary missing"))?;
5289 require_eq(
5290 &run.called_by,
5291 &Vec::<String>::new(),
5292 "ambiguous module alias called-by",
5293 )?;
5294 }
5295 Ok(())
5296 }
5297
5298 #[test]
5299 fn file_summary_resolves_rust_import_alias_called_by() -> Result<(), Box<dyn Error>> {
5300 let temp = tempfile::tempdir()?;
5301 let root = temp.path();
5302 fs::create_dir_all(root.join("src/foo"))?;
5303 fs::write(root.join("src/foo/service.rs"), "pub fn run() {}\n")?;
5304 fs::write(
5305 root.join("src/main.rs"),
5306 "use crate::foo::service as foo_service;\nfn main() { foo_service::run(); }\n",
5307 )?;
5308 let mut store = AtlasStore::in_memory()?;
5309 store.set_project_root(root)?;
5310 store.replace_scan(&[
5311 test_node("src/foo/service.rs", "hash-service"),
5312 test_node("src/main.rs", "hash-main"),
5313 ])?;
5314 store.replace_symbol_graph(&SymbolGraph {
5315 path: "src/foo/service.rs".to_string(),
5316 language: Some("rust".to_string()),
5317 parser: ParserKind::TreeSitter,
5318 symbols: vec![test_symbol(
5319 "src/foo/service.rs",
5320 SymbolKind::Function,
5321 "run",
5322 )],
5323 relations: Vec::new(),
5324 })?;
5325 store.replace_symbol_graph(&SymbolGraph {
5326 path: "src/main.rs".to_string(),
5327 language: Some("rust".to_string()),
5328 parser: ParserKind::TreeSitter,
5329 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
5330 relations: vec![
5331 SymbolRelation {
5332 path: "src/main.rs".to_string(),
5333 source_name: "<module>".to_string(),
5334 target_name: "use crate::foo::service as foo_service;".to_string(),
5335 kind: RelationKind::Imports,
5336 line: 1,
5337 context: "use crate::foo::service as foo_service;".to_string(),
5338 parser: ParserKind::TreeSitter,
5339 },
5340 SymbolRelation {
5341 path: "src/main.rs".to_string(),
5342 source_name: "main".to_string(),
5343 target_name: "foo_service::run".to_string(),
5344 kind: RelationKind::Calls,
5345 line: 2,
5346 context: "foo_service::run();".to_string(),
5347 parser: ParserKind::TreeSitter,
5348 },
5349 ],
5350 })?;
5351
5352 assert_single_called_by(
5353 &build_file_summary(&store, Path::new("src/foo/service.rs"), 10)?,
5354 "run",
5355 "src/main.rs::main",
5356 )
5357 }
5358
5359 #[test]
5360 fn file_summary_resolves_typescript_named_import_alias_called_by() -> Result<(), Box<dyn Error>>
5361 {
5362 let temp = tempfile::tempdir()?;
5363 let root = temp.path();
5364 fs::create_dir_all(root.join("src"))?;
5365 fs::write(root.join("src/service.ts"), "export function run() {}\n")?;
5366 fs::write(
5367 root.join("src/main.ts"),
5368 "import { run as serviceRun } from \"./service\";\nserviceRun();\n",
5369 )?;
5370 let mut store = AtlasStore::in_memory()?;
5371 store.set_project_root(root)?;
5372 store.replace_scan(&[
5373 test_node("src/service.ts", "hash-service"),
5374 test_node("src/main.ts", "hash-main"),
5375 ])?;
5376 store.replace_symbol_graph(&SymbolGraph {
5377 path: "src/service.ts".to_string(),
5378 language: Some("typescript".to_string()),
5379 parser: ParserKind::TreeSitter,
5380 symbols: vec![test_symbol("src/service.ts", SymbolKind::Function, "run")],
5381 relations: Vec::new(),
5382 })?;
5383 store.replace_symbol_graph(&SymbolGraph {
5384 path: "src/main.ts".to_string(),
5385 language: Some("typescript".to_string()),
5386 parser: ParserKind::TreeSitter,
5387 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
5388 relations: vec![
5389 SymbolRelation {
5390 path: "src/main.ts".to_string(),
5391 source_name: "<module>".to_string(),
5392 target_name: "import { run as serviceRun } from \"./service\";".to_string(),
5393 kind: RelationKind::Imports,
5394 line: 1,
5395 context: "import { run as serviceRun } from \"./service\";".to_string(),
5396 parser: ParserKind::TreeSitter,
5397 },
5398 SymbolRelation {
5399 path: "src/main.ts".to_string(),
5400 source_name: "main".to_string(),
5401 target_name: "serviceRun".to_string(),
5402 kind: RelationKind::Calls,
5403 line: 2,
5404 context: "serviceRun();".to_string(),
5405 parser: ParserKind::TreeSitter,
5406 },
5407 ],
5408 })?;
5409
5410 assert_single_called_by(
5411 &build_file_summary(&store, Path::new("src/service.ts"), 10)?,
5412 "run",
5413 "src/main.ts::main",
5414 )
5415 }
5416
5417 #[test]
5418 fn file_summary_resolves_typescript_explicit_index_import_called_by()
5419 -> Result<(), Box<dyn Error>> {
5420 let temp = tempfile::tempdir()?;
5421 let root = temp.path();
5422 fs::create_dir_all(root.join("src/api"))?;
5423 fs::write(root.join("src/api/index.ts"), "export function run() {}\n")?;
5424 fs::write(
5425 root.join("src/main.ts"),
5426 "import { run as apiRun } from \"./api/index\";\napiRun();\n",
5427 )?;
5428 let mut store = AtlasStore::in_memory()?;
5429 store.set_project_root(root)?;
5430 store.replace_scan(&[
5431 test_node("src/api/index.ts", "hash-api"),
5432 test_node("src/main.ts", "hash-main"),
5433 ])?;
5434 store.replace_symbol_graph(&SymbolGraph {
5435 path: "src/api/index.ts".to_string(),
5436 language: Some("typescript".to_string()),
5437 parser: ParserKind::TreeSitter,
5438 symbols: vec![test_symbol("src/api/index.ts", SymbolKind::Function, "run")],
5439 relations: Vec::new(),
5440 })?;
5441 store.replace_symbol_graph(&SymbolGraph {
5442 path: "src/main.ts".to_string(),
5443 language: Some("typescript".to_string()),
5444 parser: ParserKind::TreeSitter,
5445 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
5446 relations: vec![
5447 SymbolRelation {
5448 path: "src/main.ts".to_string(),
5449 source_name: "<module>".to_string(),
5450 target_name: "import { run as apiRun } from \"./api/index\";".to_string(),
5451 kind: RelationKind::Imports,
5452 line: 1,
5453 context: "import { run as apiRun } from \"./api/index\";".to_string(),
5454 parser: ParserKind::TreeSitter,
5455 },
5456 SymbolRelation {
5457 path: "src/main.ts".to_string(),
5458 source_name: "main".to_string(),
5459 target_name: "apiRun".to_string(),
5460 kind: RelationKind::Calls,
5461 line: 2,
5462 context: "apiRun();".to_string(),
5463 parser: ParserKind::TreeSitter,
5464 },
5465 ],
5466 })?;
5467
5468 assert_single_called_by(
5469 &build_file_summary(&store, Path::new("src/api/index.ts"), 10)?,
5470 "run",
5471 "src/main.ts::main",
5472 )
5473 }
5474
5475 #[test]
5476 fn file_summary_rejects_unrelated_typescript_alias_collision() -> Result<(), Box<dyn Error>> {
5477 let temp = tempfile::tempdir()?;
5478 let root = temp.path();
5479 fs::create_dir_all(root.join("src"))?;
5480 fs::write(root.join("src/service.ts"), "export function run() {}\n")?;
5481 fs::write(root.join("src/format.ts"), "export function format() {}\n")?;
5482 fs::write(
5483 root.join("src/main.ts"),
5484 "import { run as call } from \"./service\";\nimport { format as call } from \"./format\";\ncall();\n",
5485 )?;
5486 let mut store = AtlasStore::in_memory()?;
5487 store.set_project_root(root)?;
5488 store.replace_scan(&[
5489 test_node("src/service.ts", "hash-service"),
5490 test_node("src/format.ts", "hash-format"),
5491 test_node("src/main.ts", "hash-main"),
5492 ])?;
5493 store.replace_symbol_graph(&SymbolGraph {
5494 path: "src/service.ts".to_string(),
5495 language: Some("typescript".to_string()),
5496 parser: ParserKind::TreeSitter,
5497 symbols: vec![test_symbol("src/service.ts", SymbolKind::Function, "run")],
5498 relations: Vec::new(),
5499 })?;
5500 store.replace_symbol_graph(&SymbolGraph {
5501 path: "src/format.ts".to_string(),
5502 language: Some("typescript".to_string()),
5503 parser: ParserKind::TreeSitter,
5504 symbols: vec![test_symbol("src/format.ts", SymbolKind::Function, "format")],
5505 relations: Vec::new(),
5506 })?;
5507 store.replace_symbol_graph(&SymbolGraph {
5508 path: "src/main.ts".to_string(),
5509 language: Some("typescript".to_string()),
5510 parser: ParserKind::TreeSitter,
5511 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
5512 relations: vec![
5513 SymbolRelation {
5514 path: "src/main.ts".to_string(),
5515 source_name: "<module>".to_string(),
5516 target_name: "import { run as call } from \"./service\";".to_string(),
5517 kind: RelationKind::Imports,
5518 line: 1,
5519 context: "import { run as call } from \"./service\";".to_string(),
5520 parser: ParserKind::TreeSitter,
5521 },
5522 SymbolRelation {
5523 path: "src/main.ts".to_string(),
5524 source_name: "<module>".to_string(),
5525 target_name: "import { format as call } from \"./format\";".to_string(),
5526 kind: RelationKind::Imports,
5527 line: 2,
5528 context: "import { format as call } from \"./format\";".to_string(),
5529 parser: ParserKind::TreeSitter,
5530 },
5531 SymbolRelation {
5532 path: "src/main.ts".to_string(),
5533 source_name: "main".to_string(),
5534 target_name: "call".to_string(),
5535 kind: RelationKind::Calls,
5536 line: 3,
5537 context: "call();".to_string(),
5538 parser: ParserKind::TreeSitter,
5539 },
5540 ],
5541 })?;
5542
5543 let report = build_file_summary(&store, Path::new("src/service.ts"), 10)?;
5544 let run = report
5545 .functions
5546 .iter()
5547 .find(|symbol| symbol.name == "run")
5548 .ok_or_else(|| io::Error::other("run summary missing"))?;
5549 require_eq(
5550 &run.called_by,
5551 &Vec::<String>::new(),
5552 "unrelated alias collision called-by",
5553 )
5554 }
5555
5556 #[test]
5557 fn file_summary_resolves_python_import_alias_called_by() -> Result<(), Box<dyn Error>> {
5558 let temp = tempfile::tempdir()?;
5559 let root = temp.path();
5560 fs::create_dir_all(root.join("src/package"))?;
5561 fs::write(root.join("src/package/module.py"), "def run():\n pass\n")?;
5562 fs::write(
5563 root.join("src/main.py"),
5564 "import package.module as service\nservice.run()\n",
5565 )?;
5566 let mut store = AtlasStore::in_memory()?;
5567 store.set_project_root(root)?;
5568 store.replace_scan(&[
5569 test_node("src/package/module.py", "hash-module"),
5570 test_node("src/main.py", "hash-main"),
5571 ])?;
5572 store.replace_symbol_graph(&SymbolGraph {
5573 path: "src/package/module.py".to_string(),
5574 language: Some("python".to_string()),
5575 parser: ParserKind::TreeSitter,
5576 symbols: vec![test_symbol(
5577 "src/package/module.py",
5578 SymbolKind::Function,
5579 "run",
5580 )],
5581 relations: Vec::new(),
5582 })?;
5583 store.replace_symbol_graph(&SymbolGraph {
5584 path: "src/main.py".to_string(),
5585 language: Some("python".to_string()),
5586 parser: ParserKind::TreeSitter,
5587 symbols: vec![test_symbol("src/main.py", SymbolKind::Function, "main")],
5588 relations: vec![
5589 SymbolRelation {
5590 path: "src/main.py".to_string(),
5591 source_name: "<module>".to_string(),
5592 target_name: "import package.module as service".to_string(),
5593 kind: RelationKind::Imports,
5594 line: 1,
5595 context: "import package.module as service".to_string(),
5596 parser: ParserKind::TreeSitter,
5597 },
5598 SymbolRelation {
5599 path: "src/main.py".to_string(),
5600 source_name: "main".to_string(),
5601 target_name: "service.run".to_string(),
5602 kind: RelationKind::Calls,
5603 line: 2,
5604 context: "service.run()".to_string(),
5605 parser: ParserKind::TreeSitter,
5606 },
5607 ],
5608 })?;
5609
5610 assert_single_called_by(
5611 &build_file_summary(&store, Path::new("src/package/module.py"), 10)?,
5612 "run",
5613 "src/main.py::main",
5614 )
5615 }
5616
5617 #[test]
5618 fn file_summary_resolves_python_no_alias_import_when_name_is_ambiguous()
5619 -> Result<(), Box<dyn Error>> {
5620 let temp = tempfile::tempdir()?;
5621 let root = temp.path();
5622 fs::create_dir_all(root.join("src/package"))?;
5623 fs::write(root.join("src/package/module.py"), "def run():\n pass\n")?;
5624 fs::write(
5625 root.join("src/main.py"),
5626 "from package.module import run\nrun()\n",
5627 )?;
5628 let mut store = AtlasStore::in_memory()?;
5629 store.set_project_root(root)?;
5630 store.replace_scan(&[
5631 test_node("src/package/module.py", "hash-module"),
5632 test_node("src/main.py", "hash-main"),
5633 ])?;
5634 store.replace_symbol_graph(&SymbolGraph {
5635 path: "src/package/module.py".to_string(),
5636 language: Some("python".to_string()),
5637 parser: ParserKind::TreeSitter,
5638 symbols: vec![test_symbol(
5639 "src/package/module.py",
5640 SymbolKind::Function,
5641 "run",
5642 )],
5643 relations: Vec::new(),
5644 })?;
5645 store.replace_symbol_graph(&SymbolGraph {
5646 path: "src/main.py".to_string(),
5647 language: Some("python".to_string()),
5648 parser: ParserKind::TreeSitter,
5649 symbols: vec![
5650 test_symbol("src/main.py", SymbolKind::Function, "main"),
5651 test_symbol("src/main.py", SymbolKind::Import, "run"),
5652 ],
5653 relations: vec![
5654 SymbolRelation {
5655 path: "src/main.py".to_string(),
5656 source_name: "<module>".to_string(),
5657 target_name: "from package.module import run".to_string(),
5658 kind: RelationKind::Imports,
5659 line: 1,
5660 context: "from package.module import run".to_string(),
5661 parser: ParserKind::TreeSitter,
5662 },
5663 SymbolRelation {
5664 path: "src/main.py".to_string(),
5665 source_name: "main".to_string(),
5666 target_name: "run".to_string(),
5667 kind: RelationKind::Calls,
5668 line: 2,
5669 context: "run()".to_string(),
5670 parser: ParserKind::TreeSitter,
5671 },
5672 ],
5673 })?;
5674
5675 assert_single_called_by(
5676 &build_file_summary(&store, Path::new("src/package/module.py"), 10)?,
5677 "run",
5678 "src/main.py::main",
5679 )
5680 }
5681
5682 #[test]
5683 fn file_summary_rejects_ambiguous_import_alias_called_by() -> Result<(), Box<dyn Error>> {
5684 let temp = tempfile::tempdir()?;
5685 let root = temp.path();
5686 fs::create_dir_all(root.join("src/foo"))?;
5687 fs::create_dir_all(root.join("src/bar"))?;
5688 fs::write(root.join("src/foo/service.py"), "def run():\n pass\n")?;
5689 fs::write(root.join("src/bar/service.py"), "def run():\n pass\n")?;
5690 fs::write(
5691 root.join("src/main.py"),
5692 "from foo.service import run as call_service\nfrom bar.service import run as call_service\ncall_service()\n",
5693 )?;
5694 let mut store = AtlasStore::in_memory()?;
5695 store.set_project_root(root)?;
5696 store.replace_scan(&[
5697 test_node("src/foo/service.py", "hash-foo"),
5698 test_node("src/bar/service.py", "hash-bar"),
5699 test_node("src/main.py", "hash-main"),
5700 ])?;
5701 for path in ["src/foo/service.py", "src/bar/service.py"] {
5702 store.replace_symbol_graph(&SymbolGraph {
5703 path: path.to_string(),
5704 language: Some("python".to_string()),
5705 parser: ParserKind::TreeSitter,
5706 symbols: vec![test_symbol(path, SymbolKind::Function, "run")],
5707 relations: Vec::new(),
5708 })?;
5709 }
5710 store.replace_symbol_graph(&SymbolGraph {
5711 path: "src/main.py".to_string(),
5712 language: Some("python".to_string()),
5713 parser: ParserKind::TreeSitter,
5714 symbols: vec![test_symbol("src/main.py", SymbolKind::Function, "main")],
5715 relations: vec![
5716 SymbolRelation {
5717 path: "src/main.py".to_string(),
5718 source_name: "<module>".to_string(),
5719 target_name: "from foo.service import run as call_service".to_string(),
5720 kind: RelationKind::Imports,
5721 line: 1,
5722 context: "from foo.service import run as call_service".to_string(),
5723 parser: ParserKind::TreeSitter,
5724 },
5725 SymbolRelation {
5726 path: "src/main.py".to_string(),
5727 source_name: "<module>".to_string(),
5728 target_name: "from bar.service import run as call_service".to_string(),
5729 kind: RelationKind::Imports,
5730 line: 2,
5731 context: "from bar.service import run as call_service".to_string(),
5732 parser: ParserKind::TreeSitter,
5733 },
5734 SymbolRelation {
5735 path: "src/main.py".to_string(),
5736 source_name: "main".to_string(),
5737 target_name: "call_service".to_string(),
5738 kind: RelationKind::Calls,
5739 line: 3,
5740 context: "call_service()".to_string(),
5741 parser: ParserKind::TreeSitter,
5742 },
5743 ],
5744 })?;
5745
5746 for path in ["src/foo/service.py", "src/bar/service.py"] {
5747 let report = build_file_summary(&store, Path::new(path), 10)?;
5748 let run = report
5749 .functions
5750 .iter()
5751 .find(|symbol| symbol.name == "run")
5752 .ok_or_else(|| io::Error::other("run summary missing"))?;
5753 require_eq(
5754 &run.called_by,
5755 &Vec::<String>::new(),
5756 "ambiguous import alias called-by",
5757 )?;
5758 }
5759 Ok(())
5760 }
5761
5762 #[test]
5763 fn file_summary_marks_indexed_metadata_fallback() -> Result<(), Box<dyn Error>> {
5764 let temp = tempfile::tempdir()?;
5765 let root = temp.path();
5766 let mut store = AtlasStore::in_memory()?;
5767 store.set_project_root(root)?;
5768 store.replace_scan(&[test_node("src/missing.rs", "hash-missing")])?;
5769
5770 let report = build_file_summary(&store, Path::new("src/missing.rs"), 10)?;
5771 require_eq(
5772 &report.source_status,
5773 &SOURCE_STATUS_INDEXED.to_string(),
5774 "source status",
5775 )?;
5776 if report.source_error.is_empty() {
5777 return Err(io::Error::other("source fallback error was empty").into());
5778 }
5779 Ok(())
5780 }
5781
5782 #[test]
5783 fn search_uses_globset_and_stops_after_requested_page() -> Result<(), Box<dyn Error>> {
5784 let temp = tempfile::tempdir()?;
5785 let root = temp.path();
5786 fs::create_dir_all(root.join("src"))?;
5787 fs::create_dir_all(root.join("docs"))?;
5788 fs::write(root.join("src").join("a.rs"), "needle one\n")?;
5789 fs::write(root.join("src").join("b.rs"), "needle two\n")?;
5790 fs::write(root.join("docs").join("readme.md"), "needle docs\n")?;
5791 let mut store = AtlasStore::in_memory()?;
5792 store.set_project_root(root)?;
5793 store.replace_scan(&[
5794 test_node("src/a.rs", "hash-a"),
5795 test_node("src/b.rs", "hash-b"),
5796 test_node("docs/readme.md", "hash-docs"),
5797 ])?;
5798 index_test_file_texts(
5799 &mut store,
5800 root,
5801 &[
5802 test_node("src/a.rs", "hash-a"),
5803 test_node("src/b.rs", "hash-b"),
5804 test_node("docs/readme.md", "hash-docs"),
5805 ],
5806 )?;
5807
5808 let report =
5809 search_indexed_files(&store, "needle", false, false, false, Some("*.rs"), 0, 0, 1)?;
5810 require_eq(&report.returned, &1, "returned rows")?;
5811 require_eq(&report.searched_files, &1, "bounded searched files")?;
5812 require_eq(&report.truncated, &true, "truncated flag")?;
5813 require_eq(&report.observed_total, &report.total, "observed total")?;
5814 require_eq(
5815 &report.total_is_complete,
5816 &false,
5817 "truncated search completeness",
5818 )?;
5819
5820 let report = search_indexed_files(
5821 &store,
5822 "needle",
5823 false,
5824 false,
5825 false,
5826 Some("src\\*.rs"),
5827 0,
5828 0,
5829 10,
5830 )?;
5831 require_eq(&report.returned, &2, "windows glob returned rows")?;
5832 require_eq(&report.total_is_complete, &true, "complete search total")?;
5833 if report
5834 .results
5835 .iter()
5836 .any(|row| row.path == "docs/readme.md")
5837 {
5838 return Err(io::Error::other("globset filter included docs/readme.md").into());
5839 }
5840 Ok(())
5841 }
5842
5843 #[test]
5844 fn classified_summary_search_and_ranking_filter_before_result_limits()
5845 -> Result<(), Box<dyn Error>> {
5846 let temp = tempfile::tempdir()?;
5847 let root = temp.path();
5848 fs::create_dir_all(root.join("src"))?;
5849 fs::create_dir_all(root.join("docs"))?;
5850 fs::write(root.join("src/a.rs"), "needle source\n")?;
5851 fs::write(root.join("docs/guide.md"), "needle documentation\n")?;
5852 fs::write(root.join("settings.toml"), "needle = 'configuration'\n")?;
5853 let nodes = [
5854 test_node_with_language("src/a.rs", "hash-source", ".rs", "rust"),
5855 test_node_with_language("docs/guide.md", "hash-documentation", ".md", "markdown"),
5856 test_node_with_language("settings.toml", "hash-configuration", ".toml", "toml"),
5857 ];
5858 let mut store = AtlasStore::in_memory()?;
5859 store.set_project_root(root)?;
5860 store.replace_scan(&nodes)?;
5861 index_test_file_texts(&mut store, root, &nodes)?;
5862 let project = store
5863 .project_instance_id()?
5864 .ok_or("classified service fixture project identity is missing")?;
5865 let mut publication = store.begin_index_publication("classified-service-test")?;
5866 publication.upsert_file_content_classification_batch(&[
5867 projectatlas_db::FileContentClassification {
5868 path: "src/a.rs".to_string(),
5869 classification: ContentClassification::Source,
5870 },
5871 projectatlas_db::FileContentClassification {
5872 path: "docs/guide.md".to_string(),
5873 classification: ContentClassification::Documentation,
5874 },
5875 projectatlas_db::FileContentClassification {
5876 path: "settings.toml".to_string(),
5877 classification: ContentClassification::ConfigurationData,
5878 },
5879 ])?;
5880 publication.replace_repository_graph(project, &[], &[], &[], &[])?;
5881 publication.complete()?;
5882 store.set_purpose(
5883 "docs/guide.md",
5884 "Misleading source-code wording must not override classification",
5885 PurposeSource::Agent,
5886 )?;
5887
5888 let summary = build_file_summary_with_selection(
5889 &store,
5890 Path::new("docs/guide.md"),
5891 10,
5892 ContentSelection::Documentation,
5893 )?;
5894 require_eq(
5895 &summary.classification,
5896 &ContentClassification::Documentation,
5897 "summary classification",
5898 )?;
5899 require(
5900 summary.file_purpose.contains("source-code wording")
5901 && summary.classification == ContentClassification::Documentation,
5902 "purpose mutation overrode the registry-owned file classification",
5903 )?;
5904 let legacy_summary = build_file_summary(&store, Path::new("docs/guide.md"), 10)?;
5905 let explicit_legacy_summary = build_file_summary_with_selection(
5906 &store,
5907 Path::new("docs/guide.md"),
5908 10,
5909 ContentSelection::UnspecifiedLegacy,
5910 )?;
5911 require_eq(
5912 &serde_json::to_value(&legacy_summary)?,
5913 &serde_json::to_value(&explicit_legacy_summary)?,
5914 "legacy summary wrapper compatibility",
5915 )?;
5916 let slice = read_indexed_code_slice_from_source_with_selection(
5917 &store,
5918 Path::new("docs/guide.md"),
5919 1,
5920 Some(1),
5921 "# Guide\n",
5922 ContentSelection::Documentation,
5923 )?;
5924 require_eq(
5925 &slice.classification,
5926 &Some(ContentClassification::Documentation),
5927 "indexed slice classification",
5928 )?;
5929 if !matches!(
5930 build_file_summary_with_selection(
5931 &store,
5932 Path::new("docs/guide.md"),
5933 10,
5934 ContentSelection::Source,
5935 ),
5936 Err(ServiceError::InvalidInput(message)) if message.contains("outside the selected content")
5937 ) {
5938 return Err(io::Error::other(
5939 "summary accepted a file outside the explicit content selection",
5940 )
5941 .into());
5942 }
5943
5944 let legacy = search_indexed_files(&store, "needle", false, false, false, None, 0, 0, 10)?;
5945 let explicit_legacy = search_indexed_files_with_control(
5946 &store,
5947 &SearchQuery {
5948 pattern: "needle",
5949 regex: false,
5950 fuzzy: false,
5951 case_sensitive: false,
5952 file_pattern: None,
5953 context_lines: 0,
5954 start_index: 0,
5955 limit: 10,
5956 content_selection: ContentSelection::UnspecifiedLegacy,
5957 retrieval_mode: SearchRetrievalMode::Lexical,
5958 },
5959 None,
5960 )?;
5961 require_eq(
5962 &serde_json::to_value(&legacy)?,
5963 &serde_json::to_value(&explicit_legacy)?,
5964 "legacy search wrapper compatibility",
5965 )?;
5966 require_eq(&legacy.returned, &3, "legacy mixed search rows")?;
5967 require(
5968 legacy.results.iter().map(|row| row.classification).eq([
5969 ContentClassification::Documentation,
5970 ContentClassification::ConfigurationData,
5971 ContentClassification::Source,
5972 ]),
5973 "legacy search omitted or reordered mixed classifications",
5974 )?;
5975
5976 for regex in [false, true] {
5977 let documentation = search_indexed_files_with_control(
5978 &store,
5979 &SearchQuery {
5980 pattern: "needle",
5981 regex,
5982 fuzzy: false,
5983 case_sensitive: false,
5984 file_pattern: None,
5985 context_lines: 0,
5986 start_index: 0,
5987 limit: 1,
5988 content_selection: ContentSelection::Documentation,
5989 retrieval_mode: SearchRetrievalMode::Lexical,
5990 },
5991 None,
5992 )?;
5993 require(
5994 documentation.returned == 1
5995 && documentation.results[0].path == "docs/guide.md"
5996 && documentation.results[0].classification
5997 == ContentClassification::Documentation,
5998 "documentation selection was applied after the search result limit",
5999 )?;
6000 }
6001
6002 let both = search_indexed_files_with_control(
6003 &store,
6004 &SearchQuery {
6005 pattern: "needle",
6006 regex: true,
6007 fuzzy: false,
6008 case_sensitive: false,
6009 file_pattern: None,
6010 context_lines: 0,
6011 start_index: 0,
6012 limit: 10,
6013 content_selection: ContentSelection::Both,
6014 retrieval_mode: SearchRetrievalMode::Lexical,
6015 },
6016 None,
6017 )?;
6018 require(
6019 both.returned == 2
6020 && both.results.iter().all(|row| {
6021 matches!(
6022 row.classification,
6023 ContentClassification::Source | ContentClassification::Documentation
6024 )
6025 }),
6026 "both selection did not exclude configuration data",
6027 )?;
6028
6029 let ranked = load_classified_ranked_file_nodes_with_reasons(
6030 &store,
6031 "",
6032 None,
6033 None,
6034 1,
6035 false,
6036 ContentSelection::Documentation,
6037 )?;
6038 require(
6039 ranked.len() == 1
6040 && ranked[0].node.node.path == "docs/guide.md"
6041 && ranked[0].classification == ContentClassification::Documentation,
6042 "documentation selection was applied after the ranked-file limit",
6043 )?;
6044 let documentation_next =
6045 build_next_report_with_selection(&store, "", Some(1), ContentSelection::Documentation)?;
6046 require(
6047 !documentation_next.suggestions.is_empty()
6048 && documentation_next
6049 .suggestions
6050 .iter()
6051 .all(|suggestion| suggestion.contains("--content-selection documentation")),
6052 "classified next suggestions lost the explicit content selection",
6053 )?;
6054 let legacy_next = build_next_report(&store, "", Some(3))?;
6055 let explicit_legacy_next = build_next_report_with_selection(
6056 &store,
6057 "",
6058 Some(3),
6059 ContentSelection::UnspecifiedLegacy,
6060 )?;
6061 require_eq(
6062 &serde_json::to_value(&legacy_next)?,
6063 &serde_json::to_value(&explicit_legacy_next)?,
6064 "legacy next wrapper compatibility",
6065 )?;
6066 Ok(())
6067 }
6068
6069 #[test]
6070 fn search_fts_candidates_preserve_fallback_results_and_unsafe_shapes_fall_back()
6071 -> Result<(), Box<dyn Error>> {
6072 let temp = tempfile::tempdir()?;
6073 let root = temp.path();
6074 fs::create_dir_all(root.join("src"))?;
6075 fs::create_dir_all(root.join("docs"))?;
6076 fs::write(
6077 root.join("src/a.rs"),
6078 "Needle alpha\nneedle-beta\nnëedle unicode\n",
6079 )?;
6080 fs::write(root.join("src/b.rs"), "prefixneedlesuffix gamma\n")?;
6081 fs::write(root.join("docs/readme.md"), "needle docs\n")?;
6082 let nodes = [
6083 test_node("src/a.rs", "hash-a"),
6084 test_node("src/b.rs", "hash-b"),
6085 test_node("docs/readme.md", "hash-docs"),
6086 ];
6087 let mut store = AtlasStore::in_memory()?;
6088 store.set_project_root(root)?;
6089 store.replace_scan(&nodes)?;
6090 index_test_file_texts(&mut store, root, &nodes)?;
6091
6092 let lexical = search_indexed_files_with_control(
6093 &store,
6094 &SearchQuery {
6095 pattern: "needle",
6096 regex: false,
6097 fuzzy: false,
6098 case_sensitive: false,
6099 file_pattern: Some("src/*.rs"),
6100 context_lines: 0,
6101 start_index: 0,
6102 limit: 20,
6103 content_selection: ContentSelection::UnspecifiedLegacy,
6104 retrieval_mode: SearchRetrievalMode::Lexical,
6105 },
6106 None,
6107 )?;
6108 require_eq(
6109 &lexical.strategy,
6110 &"fts5-bm25-candidates-exact-verified".to_string(),
6111 "safe literal strategy",
6112 )?;
6113 require_eq(&lexical.candidate_files, &2, "safe literal candidates")?;
6114
6115 let fallback = search_indexed_files_with_control(
6116 &store,
6117 &SearchQuery {
6118 pattern: "needle",
6119 regex: true,
6120 fuzzy: false,
6121 case_sensitive: false,
6122 file_pattern: Some("src/*.rs"),
6123 context_lines: 0,
6124 start_index: 0,
6125 limit: 20,
6126 content_selection: ContentSelection::UnspecifiedLegacy,
6127 retrieval_mode: SearchRetrievalMode::Lexical,
6128 },
6129 None,
6130 )?;
6131 let lexical_rows = lexical
6132 .results
6133 .iter()
6134 .map(|row| (&row.path, row.line, &row.text))
6135 .collect::<Vec<_>>();
6136 let fallback_rows = fallback
6137 .results
6138 .iter()
6139 .map(|row| (&row.path, row.line, &row.text))
6140 .collect::<Vec<_>>();
6141 require_eq(
6142 &lexical_rows,
6143 &fallback_rows,
6144 "FTS and fallback exact results",
6145 )?;
6146 require_eq(
6147 &fallback.strategy,
6148 &"persisted-text-fallback".to_string(),
6149 "regex fallback strategy",
6150 )?;
6151
6152 for (pattern, regex, fuzzy, expected_rows) in [
6153 (
6154 "ne",
6155 false,
6156 false,
6157 vec!["src/a.rs:1", "src/a.rs:2", "src/b.rs:1"],
6158 ),
6159 ("needle-", false, false, vec!["src/a.rs:2"]),
6160 ("nëedle", false, false, vec!["src/a.rs:3"]),
6161 (
6162 "needle",
6163 false,
6164 true,
6165 vec!["src/a.rs:1", "src/a.rs:2", "src/b.rs:1"],
6166 ),
6167 ] {
6168 let report = search_indexed_files_with_control(
6169 &store,
6170 &SearchQuery {
6171 pattern,
6172 regex,
6173 fuzzy,
6174 case_sensitive: false,
6175 file_pattern: Some("src/*.rs"),
6176 context_lines: 0,
6177 start_index: 0,
6178 limit: 20,
6179 content_selection: ContentSelection::UnspecifiedLegacy,
6180 retrieval_mode: SearchRetrievalMode::Lexical,
6181 },
6182 None,
6183 )?;
6184 require_eq(
6185 &report.strategy,
6186 &"persisted-text-fallback".to_string(),
6187 "unsafe shape fallback strategy",
6188 )?;
6189 require_eq(&report.candidate_files, &0, "unsafe shape candidates")?;
6190 let rows = report
6191 .results
6192 .iter()
6193 .map(|row| format!("{}:{}", row.path, row.line))
6194 .collect::<Vec<_>>();
6195 require_eq(
6196 &rows,
6197 &expected_rows
6198 .into_iter()
6199 .map(str::to_string)
6200 .collect::<Vec<_>>(),
6201 "unsafe fallback exact rows",
6202 )?;
6203 }
6204
6205 for (pattern, expected_rows) in [("Needle", 1), ("needle", 2)] {
6206 let exact = search_indexed_files_with_control(
6207 &store,
6208 &SearchQuery {
6209 pattern,
6210 regex: false,
6211 fuzzy: false,
6212 case_sensitive: true,
6213 file_pattern: Some("src/*.rs"),
6214 context_lines: 0,
6215 start_index: 0,
6216 limit: 20,
6217 content_selection: ContentSelection::UnspecifiedLegacy,
6218 retrieval_mode: SearchRetrievalMode::Lexical,
6219 },
6220 None,
6221 )?;
6222 let regex = search_indexed_files_with_control(
6223 &store,
6224 &SearchQuery {
6225 pattern,
6226 regex: true,
6227 fuzzy: false,
6228 case_sensitive: true,
6229 file_pattern: Some("src/*.rs"),
6230 context_lines: 0,
6231 start_index: 0,
6232 limit: 20,
6233 content_selection: ContentSelection::UnspecifiedLegacy,
6234 retrieval_mode: SearchRetrievalMode::Lexical,
6235 },
6236 None,
6237 )?;
6238 let exact_rows = exact
6239 .results
6240 .iter()
6241 .map(|row| (&row.path, row.line, &row.text))
6242 .collect::<Vec<_>>();
6243 let regex_rows = regex
6244 .results
6245 .iter()
6246 .map(|row| (&row.path, row.line, &row.text))
6247 .collect::<Vec<_>>();
6248 require_eq(&exact_rows, ®ex_rows, "case-sensitive equivalence")?;
6249 require_eq(&exact.returned, &expected_rows, "case-sensitive exact rows")?;
6250 }
6251 Ok(())
6252 }
6253
6254 #[test]
6255 fn search_fts_candidate_overflow_uses_complete_persisted_text_fallback()
6256 -> Result<(), Box<dyn Error>> {
6257 const MATCHING_FILES: usize = MAX_FILE_TEXT_FTS_CANDIDATES + 1;
6258 const CONTENT: &str = "needle\n";
6259 let mut store = AtlasStore::in_memory()?;
6260 let paths = (0..MATCHING_FILES)
6261 .map(|index| format!("overflow/{index:04}.rs"))
6262 .collect::<Vec<_>>();
6263 let nodes = paths
6264 .iter()
6265 .map(|path| test_node(path, "hash"))
6266 .collect::<Vec<_>>();
6267 let texts = paths
6268 .iter()
6269 .map(|path| IndexedFileText {
6270 path: path.clone(),
6271 content_hash: Some("hash".to_string()),
6272 byte_count: CONTENT.len(),
6273 line_count: 1,
6274 content: CONTENT.to_string(),
6275 })
6276 .collect::<Vec<_>>();
6277 store.replace_scan(&nodes)?;
6278 store.replace_file_texts_for_paths(&paths, &texts)?;
6279
6280 let request = SearchQuery {
6281 pattern: "needle",
6282 regex: false,
6283 fuzzy: false,
6284 case_sensitive: false,
6285 file_pattern: Some("overflow/*.rs"),
6286 context_lines: 0,
6287 start_index: MATCHING_FILES - 1,
6288 limit: 1,
6289 content_selection: ContentSelection::UnspecifiedLegacy,
6290 retrieval_mode: SearchRetrievalMode::Lexical,
6291 };
6292 let overflow = search_indexed_files_with_control(&store, &request, None)?;
6293 require_eq(
6294 &overflow.strategy,
6295 &"persisted-text-fallback".to_string(),
6296 "overflow fallback strategy",
6297 )?;
6298 require_eq(
6299 &overflow.candidate_files,
6300 &MAX_FILE_TEXT_FTS_CANDIDATES,
6301 "overflow retained candidates",
6302 )?;
6303 require_eq(
6304 &overflow.searched_files,
6305 &MATCHING_FILES,
6306 "overflow fallback searched files",
6307 )?;
6308 require_eq(
6309 &overflow.searched_bytes,
6310 &(MATCHING_FILES * CONTENT.len()),
6311 "overflow fallback searched bytes",
6312 )?;
6313 require_eq(
6314 &overflow.results[0].path,
6315 &format!("overflow/{:04}.rs", MATCHING_FILES - 1),
6316 "overflow fallback exact path order",
6317 )?;
6318
6319 let authoritative = search_indexed_files_with_control(
6320 &store,
6321 &SearchQuery {
6322 regex: true,
6323 ..request
6324 },
6325 None,
6326 )?;
6327 let overflow_rows = overflow
6328 .results
6329 .iter()
6330 .map(|row| (&row.path, row.line, &row.text))
6331 .collect::<Vec<_>>();
6332 let authoritative_rows = authoritative
6333 .results
6334 .iter()
6335 .map(|row| (&row.path, row.line, &row.text))
6336 .collect::<Vec<_>>();
6337 require_eq(
6338 &overflow_rows,
6339 &authoritative_rows,
6340 "overflow and authoritative fallback rows",
6341 )?;
6342 Ok(())
6343 }
6344
6345 #[test]
6346 fn search_reports_resource_bounds_cancellation_and_optional_capability_state()
6347 -> Result<(), Box<dyn Error>> {
6348 let temp = tempfile::tempdir()?;
6349 let root = temp.path();
6350 fs::create_dir_all(root.join("src"))?;
6351 fs::write(root.join("src/a.rs"), "needle one\n")?;
6352 fs::write(root.join("src/b.rs"), "needle two\n")?;
6353 let nodes = [
6354 test_node("src/a.rs", "hash-a"),
6355 test_node("src/b.rs", "hash-b"),
6356 ];
6357 let mut store = AtlasStore::in_memory()?;
6358 store.set_project_root(root)?;
6359 store.replace_scan(&nodes)?;
6360 index_test_file_texts(&mut store, root, &nodes)?;
6361 let query = SearchQuery {
6362 pattern: "needle",
6363 regex: true,
6364 fuzzy: false,
6365 case_sensitive: false,
6366 file_pattern: Some("src/*.rs"),
6367 context_lines: 0,
6368 start_index: 0,
6369 limit: 20,
6370 content_selection: ContentSelection::UnspecifiedLegacy,
6371 retrieval_mode: SearchRetrievalMode::Lexical,
6372 };
6373
6374 let file_bounded = search_indexed_files_with_bounds(
6375 &store,
6376 &query,
6377 None,
6378 SearchBounds {
6379 selected_files: 1,
6380 selected_bytes: usize::MAX,
6381 elapsed: Duration::from_secs(1),
6382 retained_bytes: usize::MAX,
6383 },
6384 )?;
6385 require_eq(
6386 &file_bounded.searched_files,
6387 &1,
6388 "file bound searched files",
6389 )?;
6390 require_eq(&file_bounded.truncated, &true, "file bound truncation")?;
6391 require_eq(
6392 &file_bounded.truncation_reason,
6393 &Some("selected-file-limit".to_string()),
6394 "file bound reason",
6395 )?;
6396
6397 let byte_bounded = search_indexed_files_with_bounds(
6398 &store,
6399 &query,
6400 None,
6401 SearchBounds {
6402 selected_files: usize::MAX,
6403 selected_bytes: 1,
6404 elapsed: Duration::from_secs(1),
6405 retained_bytes: usize::MAX,
6406 },
6407 )?;
6408 require_eq(
6409 &byte_bounded.searched_files,
6410 &0,
6411 "byte bound searched files",
6412 )?;
6413 require_eq(
6414 &byte_bounded.truncation_reason,
6415 &Some("selected-byte-limit".to_string()),
6416 "byte bound reason",
6417 )?;
6418
6419 let output_bounded = search_indexed_files_with_bounds(
6420 &store,
6421 &query,
6422 None,
6423 SearchBounds {
6424 selected_files: usize::MAX,
6425 selected_bytes: usize::MAX,
6426 elapsed: Duration::from_secs(1),
6427 retained_bytes: 1,
6428 },
6429 )?;
6430 require_eq(&output_bounded.returned, &0, "output bound returned rows")?;
6431 require_eq(
6432 &output_bounded.truncation_reason,
6433 &Some("retained-byte-limit".to_string()),
6434 "output bound reason",
6435 )?;
6436
6437 let cancellation = IndexCancellation::new();
6438 cancellation.cancel();
6439 let control = IndexWorkControl::new(cancellation, None);
6440 let cancelled = search_indexed_files_with_control(&store, &query, Some(&control));
6441 if !matches!(cancelled, Err(ServiceError::Db(DbError::IndexWork(_)))) {
6442 return Err(io::Error::other("search cancellation was not typed").into());
6443 }
6444 let expired = IndexWorkControl::new(IndexCancellation::new(), Some(Duration::ZERO));
6445 let deadline = search_indexed_files_with_control(&store, &query, Some(&expired))?;
6446 require_eq(&deadline.truncated, &true, "deadline truncation")?;
6447 require_eq(
6448 &deadline.truncation_reason,
6449 &Some("elapsed-time-limit".to_string()),
6450 "deadline truncation reason",
6451 )?;
6452 require_eq(
6453 &deadline.total_is_complete,
6454 &false,
6455 "deadline total completeness",
6456 )?;
6457
6458 let line_cancellation = IndexCancellation::new();
6459 let line_control = IndexWorkControl::new(line_cancellation.clone(), None);
6460 line_cancellation.cancel();
6461 let mut line_report = file_bounded;
6462 let line_match = append_line_matches(
6463 &mut line_report,
6464 "src/a.rs",
6465 ContentClassification::Source,
6466 &["needle"],
6467 &LineMatcher::Literal {
6468 needle: "needle".to_string(),
6469 case_sensitive: true,
6470 },
6471 0,
6472 1,
6473 usize::MAX,
6474 &line_control,
6475 );
6476 if !matches!(
6477 line_match,
6478 Err(IndexWorkFailure::Cancelled {
6479 stage: IndexWorkStage::TextIndex
6480 })
6481 ) {
6482 return Err(io::Error::other("in-memory line matching ignored cancellation").into());
6483 }
6484
6485 let maximum_pattern = "a".repeat(SEARCH_MAX_PATTERN_BYTES);
6486 search_indexed_files_with_control(
6487 &store,
6488 &SearchQuery {
6489 pattern: &maximum_pattern,
6490 regex: false,
6491 limit: 0,
6492 ..query
6493 },
6494 None,
6495 )?;
6496 let oversized_pattern = "a".repeat(SEARCH_MAX_PATTERN_BYTES + 1);
6497 if !matches!(
6498 search_indexed_files_with_control(
6499 &store,
6500 &SearchQuery {
6501 pattern: &oversized_pattern,
6502 regex: false,
6503 limit: 0,
6504 ..query
6505 },
6506 None,
6507 ),
6508 Err(ServiceError::InvalidInput(_))
6509 ) {
6510 return Err(io::Error::other("oversized search pattern was accepted").into());
6511 }
6512 let maximum_file_pattern = "a".repeat(SEARCH_MAX_FILE_PATTERN_BYTES);
6513 search_indexed_files_with_control(
6514 &store,
6515 &SearchQuery {
6516 pattern: "needle",
6517 regex: false,
6518 file_pattern: Some(&maximum_file_pattern),
6519 limit: 0,
6520 ..query
6521 },
6522 None,
6523 )?;
6524 let oversized_file_pattern = "a".repeat(SEARCH_MAX_FILE_PATTERN_BYTES + 1);
6525 if !matches!(
6526 search_indexed_files_with_control(
6527 &store,
6528 &SearchQuery {
6529 pattern: "needle",
6530 regex: false,
6531 file_pattern: Some(&oversized_file_pattern),
6532 limit: 0,
6533 ..query
6534 },
6535 None,
6536 ),
6537 Err(ServiceError::InvalidInput(_))
6538 ) {
6539 return Err(io::Error::other("oversized search file pattern was accepted").into());
6540 }
6541
6542 let unavailable = search_indexed_files_with_control(
6543 &store,
6544 &SearchQuery {
6545 retrieval_mode: SearchRetrievalMode::Semantic,
6546 ..query
6547 },
6548 None,
6549 );
6550 if !matches!(
6551 unavailable,
6552 Err(ServiceError::SearchCapabilityUnavailable {
6553 requested_mode: SearchRetrievalMode::Semantic,
6554 state: SEARCH_SEMANTIC_UNAVAILABLE_STATE,
6555 guidance: SEARCH_SEMANTIC_RECOVERY,
6556 })
6557 ) {
6558 return Err(io::Error::other("semantic unavailable state was not typed").into());
6559 }
6560 Ok(())
6561 }
6562
6563 #[test]
6564 fn file_glob_filter_matches_repository_paths() -> Result<(), Box<dyn Error>> {
6565 let nodes = vec![
6566 test_indexed_node("src/a.rs", "hash-a"),
6567 test_indexed_node("src/nested/b.rs", "hash-b"),
6568 test_indexed_node("docs/readme.md", "hash-docs"),
6569 ];
6570
6571 let filtered = filter_files_by_glob(nodes.clone(), Some("*.rs"))?;
6572 require_eq(&filtered.len(), &2, "rs glob count")?;
6573 let matcher = FilePathMatcher::new(Some("*.rs"))?;
6574 require_eq(&matcher.filters(), &true, "compiled glob filters")?;
6575 require_eq(&matcher.is_match("src/a.rs"), &true, "compiled nested rs")?;
6576 require_eq(&matcher.is_match("a.rs"), &true, "compiled basename rs")?;
6577 require_eq(
6578 &matcher.is_match("docs/readme.md"),
6579 &false,
6580 "compiled markdown miss",
6581 )?;
6582
6583 let nested = filter_files_by_glob(nodes, Some("src\\nested\\*.rs"))?;
6584 require_eq(&nested.len(), &1, "windows glob count")?;
6585 require_eq(
6586 &nested[0].node.path,
6587 &"src/nested/b.rs".to_string(),
6588 "windows glob path",
6589 )?;
6590 Ok(())
6591 }
6592
6593 #[test]
6594 fn ranked_file_nodes_uses_shared_glob_policy() -> Result<(), Box<dyn Error>> {
6595 let mut store = AtlasStore::in_memory()?;
6596 store.replace_scan(&[
6597 test_node("src/a.rs", "hash-a"),
6598 test_node("src/nested/b.rs", "hash-b"),
6599 test_node("docs/readme.md", "hash-docs"),
6600 ])?;
6601 for path in ["src/a.rs", "src/nested/b.rs", "docs/readme.md"] {
6602 store.set_purpose(path, "needle orientation target", PurposeSource::Agent)?;
6603 store.set_node_summary(path, "needle indexed summary")?;
6604 }
6605
6606 let selected = load_ranked_file_nodes(&store, "needle", None, Some("*.rs"), 10, false)?;
6607 require_eq(&selected.len(), &2, "ranked rs glob count")?;
6608 if selected
6609 .iter()
6610 .any(|node| node.node.path == "docs/readme.md")
6611 {
6612 return Err(io::Error::other("ranked glob included docs/readme.md").into());
6613 }
6614
6615 let nested =
6616 load_ranked_file_nodes(&store, "needle", None, Some("src/nested/*.rs"), 10, false)?;
6617 require_eq(&nested.len(), &1, "ranked nested glob count")?;
6618 require_eq(
6619 &nested[0].node.path,
6620 &"src/nested/b.rs".to_string(),
6621 "ranked nested glob path",
6622 )?;
6623 Ok(())
6624 }
6625
6626 #[test]
6627 fn ranked_file_nodes_can_include_indexed_text_hits() -> Result<(), Box<dyn Error>> {
6628 let temp = tempfile::tempdir()?;
6629 let root = temp.path();
6630 fs::create_dir_all(root.join("src"))?;
6631 fs::create_dir_all(root.join("docs"))?;
6632 fs::write(
6633 root.join("src").join("owner.rs"),
6634 "const ROUTE = \"hiddenNeedle\";\n",
6635 )?;
6636 fs::write(root.join("docs").join("owner.md"), "hiddenNeedle docs\n")?;
6637 let mut store = AtlasStore::in_memory()?;
6638 store.set_project_root(root)?;
6639 let nodes = [
6640 test_node("src/owner.rs", "hash-src-owner"),
6641 test_node("docs/owner.md", "hash-doc-owner"),
6642 ];
6643 store.replace_scan(&nodes)?;
6644 index_test_file_texts(&mut store, root, &nodes)?;
6645
6646 let default_ranked =
6647 load_ranked_file_nodes(&store, "hiddenNeedle", Some("src"), Some("*.rs"), 10, false)?;
6648 require_eq(
6649 &default_ranked.len(),
6650 &0,
6651 "default ranking ignores content-only hits",
6652 )?;
6653
6654 let content_ranked =
6655 load_ranked_file_nodes(&store, "hiddenNeedle", Some("src"), Some("*.rs"), 10, true)?;
6656 require_eq(&content_ranked.len(), &1, "content-aware ranked count")?;
6657 require_eq(
6658 &content_ranked[0].node.path,
6659 &"src/owner.rs".to_string(),
6660 "content-aware ranked path",
6661 )?;
6662 Ok(())
6663 }
6664
6665 #[test]
6666 fn ranked_file_reasons_match_indexed_ranking_signals() -> Result<(), Box<dyn Error>> {
6667 let temp = tempfile::tempdir()?;
6668 let root = temp.path();
6669 fs::create_dir_all(root.join("src"))?;
6670 fs::create_dir_all(root.join("tests"))?;
6671 fs::write(
6672 root.join("src").join("installer.rs"),
6673 "pub fn install_runtime() { let _marker = \"hiddenNeedle\"; }\n",
6674 )?;
6675 fs::write(
6676 root.join("tests").join("installer.rs"),
6677 "#[test]\nfn installer_pair() {}\n",
6678 )?;
6679 fs::write(root.join("src").join("noise.rs"), "pub fn unrelated() {}\n")?;
6680
6681 let mut store = AtlasStore::in_memory()?;
6682 store.set_project_root(root)?;
6683 let nodes = [
6684 test_node("src/installer.rs", "hash-installer"),
6685 test_node("tests/installer.rs", "hash-installer-test"),
6686 test_node("src/noise.rs", "hash-noise"),
6687 ];
6688 store.replace_scan(&nodes)?;
6689 store.set_purpose(
6690 "src/installer.rs",
6691 "Installer runtime release target",
6692 PurposeSource::Agent,
6693 )?;
6694 store.set_node_summary("src/installer.rs", "Release installer summary")?;
6695 store.replace_symbol_graph(&SymbolGraph {
6696 path: "src/installer.rs".to_string(),
6697 language: Some("rust".to_string()),
6698 parser: ParserKind::TreeSitter,
6699 symbols: vec![test_symbol(
6700 "src/installer.rs",
6701 SymbolKind::Function,
6702 "install_runtime",
6703 )],
6704 relations: Vec::new(),
6705 })?;
6706 index_test_file_texts(&mut store, root, &nodes)?;
6707
6708 let ranked = load_ranked_file_nodes_with_reasons(
6709 &store,
6710 "installer runtime release hiddenNeedle install_runtime",
6711 None,
6712 Some("*.rs"),
6713 2,
6714 true,
6715 )?;
6716 require_eq(&ranked.len(), &2, "ranked source/test pair count")?;
6717 require_eq(
6718 &ranked[0].node.node.path,
6719 &"src/installer.rs".to_string(),
6720 "strong indexed signal ranks first",
6721 )?;
6722 require_reason(&ranked[0].reasons, "path matched install")?;
6723 require_reason(&ranked[0].reasons, "purpose matched install")?;
6724 require_reason(&ranked[0].reasons, "summary matched install")?;
6725 require_reason(&ranked[0].reasons, "symbol install_runtime matched install")?;
6726 require_reason(&ranked[0].reasons, "indexed text matched hiddenneedle")?;
6727 require_reason(&ranked[0].reasons, "paired test file tests/installer.rs")?;
6728 require_eq(
6729 &ranked[0]
6730 .reason_codes
6731 .contains(&RankedReasonCode::ReviewedPurpose),
6732 &true,
6733 "reviewed purpose reason code",
6734 )?;
6735 require_eq(
6736 &ranked[0].connection_counts,
6737 &Vec::new(),
6738 "deterministic no-graph fallback counts",
6739 )?;
6740 require_eq(
6741 &ranked[0].next_call.capability,
6742 &NavigationNextCapability::Summary,
6743 "no-graph fallback next call",
6744 )?;
6745 if !ranked.iter().any(|node| {
6746 node.node.node.path == "tests/installer.rs"
6747 && node
6748 .reasons
6749 .iter()
6750 .any(|reason| reason == "paired source file src/installer.rs")
6751 }) {
6752 return Err(io::Error::other("paired test result/reason was missing").into());
6753 }
6754 Ok(())
6755 }
6756
6757 #[test]
6758 fn ranked_evidence_keeps_reviewed_purpose_ahead_of_bounded_graph_popularity()
6759 -> Result<(), Box<dyn Error>> {
6760 let store = AtlasStore::in_memory()?;
6761 let mut popular = test_indexed_node("src/popular.rs", "popular-hash");
6762 popular.purpose = Purpose {
6763 path: popular.node.path.clone(),
6764 purpose: Some("generated auth suggestion".to_string()),
6765 source: PurposeSource::Generated,
6766 status: PurposeStatus::Suggested,
6767 };
6768 popular.summary = None;
6769 let counts = [
6770 RankedConnectionKind::Package,
6771 RankedConnectionKind::Import,
6772 RankedConnectionKind::Call,
6773 RankedConnectionKind::Reference,
6774 RankedConnectionKind::Test,
6775 RankedConnectionKind::Route,
6776 RankedConnectionKind::Config,
6777 ]
6778 .into_iter()
6779 .map(|kind| projectatlas_core::RankedConnectionCount {
6780 kind,
6781 count: RANKED_CONNECTION_FAMILY_LIMIT as usize,
6782 truncated: true,
6783 })
6784 .collect::<Vec<_>>();
6785 let popular_connections = RepositoryNavigationConnections {
6786 path: popular.node.path.clone(),
6787 counts,
6788 connections: vec![projectatlas_core::RankedConnection {
6789 kind: RankedConnectionKind::Call,
6790 direction: projectatlas_core::RankedConnectionDirection::Inbound,
6791 target: RankedConnectionTarget::Local {
6792 path: "src/auth.rs".to_string(),
6793 symbol: Some("authenticate".to_string()),
6794 },
6795 }],
6796 truncated: true,
6797 };
6798 let popular_evidence = ranked_node_evidence(
6799 &store,
6800 &popular,
6801 &["auth".to_string()],
6802 "",
6803 &HashSet::new(),
6804 &popular_connections,
6805 )?;
6806 require_eq(
6807 &popular_evidence.reviewed_purpose,
6808 &false,
6809 "generated purpose authority",
6810 )?;
6811 if popular_evidence.context_score > 32 {
6812 return Err(io::Error::other("graph popularity was not saturated").into());
6813 }
6814
6815 let mut reviewed = test_indexed_node("src/responsibility.rs", "reviewed-hash");
6816 reviewed.purpose.purpose = Some("Own auth responsibility".to_string());
6817 reviewed.summary = None;
6818 let reviewed_evidence = ranked_node_evidence(
6819 &store,
6820 &reviewed,
6821 &["auth".to_string()],
6822 "",
6823 &HashSet::new(),
6824 &RepositoryNavigationConnections {
6825 path: reviewed.node.path.clone(),
6826 counts: Vec::new(),
6827 connections: Vec::new(),
6828 truncated: false,
6829 },
6830 )?;
6831 require_eq(
6832 &reviewed_evidence.reviewed_purpose,
6833 &true,
6834 "reviewed purpose tier",
6835 )?;
6836 require_eq(
6837 &ranked_evidence_order(&reviewed_evidence, &popular_evidence),
6838 &std::cmp::Ordering::Less,
6839 "reviewed purpose dominance",
6840 )?;
6841 Ok(())
6842 }
6843
6844 #[test]
6845 fn ranked_service_preserves_dominant_tiers_across_more_than_one_hundred_weaker_matches()
6846 -> Result<(), Box<dyn Error>> {
6847 let mut store = AtlasStore::in_memory()?;
6848 let mut nodes = vec![
6849 test_node("needle", "hash-exact"),
6850 test_node("deep/needle", "hash-name"),
6851 test_node("reviewed.rs", "hash-reviewed"),
6852 ];
6853 nodes
6854 .extend((0..130).map(|index| test_node(&format!("weak/needle-{index:03}.rs"), "hash")));
6855 store.replace_scan(&nodes)?;
6856 store.set_purpose(
6857 "reviewed.rs",
6858 "Own needle responsibility",
6859 PurposeSource::Agent,
6860 )?;
6861 for index in 0..130 {
6862 let path = format!("weak/needle-{index:03}.rs");
6863 store.set_suggested_purpose(&path, "Generated needle suggestion")?;
6864 store.set_node_summary(&path, "Observed needle summary")?;
6865 }
6866
6867 let ranked = load_ranked_file_nodes_with_reasons(&store, "needle", None, None, 3, false)?;
6868 require_eq(
6869 &ranked
6870 .iter()
6871 .map(|node| node.node.node.path.as_str())
6872 .collect::<Vec<_>>(),
6873 &vec!["needle", "deep/needle", "reviewed.rs"],
6874 "service exact path basename and reviewed-purpose order",
6875 )?;
6876 require_eq(
6877 &ranked[0]
6878 .reason_codes
6879 .contains(&RankedReasonCode::ExactPath),
6880 &true,
6881 "exact path reason code",
6882 )?;
6883 require_eq(
6884 &ranked[1]
6885 .reason_codes
6886 .contains(&RankedReasonCode::ExactName),
6887 &true,
6888 "exact basename reason code",
6889 )?;
6890 require_eq(
6891 &ranked[2]
6892 .reason_codes
6893 .contains(&RankedReasonCode::ReviewedPurpose),
6894 &true,
6895 "reviewed purpose reason code after adversarial admission",
6896 )?;
6897 Ok(())
6898 }
6899
6900 #[test]
6901 fn fuzzy_search_matches_approximate_line_terms() -> Result<(), Box<dyn Error>> {
6902 let temp = tempfile::tempdir()?;
6903 let root = temp.path();
6904 fs::create_dir_all(root.join("src"))?;
6905 fs::write(
6906 root.join("src").join("main.rs"),
6907 "fn build_project_atlas() {}\nfn unrelated() {}\n",
6908 )?;
6909 let mut store = AtlasStore::in_memory()?;
6910 store.set_project_root(root)?;
6911 let nodes = [test_node("src/main.rs", "hash-main")];
6912 store.replace_scan(&nodes)?;
6913 index_test_file_texts(&mut store, root, &nodes)?;
6914
6915 let report =
6916 search_indexed_files(&store, "bpa", false, true, false, Some("*.rs"), 0, 0, 10)?;
6917 require_eq(&report.mode, &"fuzzy".to_string(), "search mode")?;
6918 require_eq(&report.returned, &1, "fuzzy returned rows")?;
6919 require_eq(
6920 &report.results[0].text,
6921 &"fn build_project_atlas() {}".to_string(),
6922 "fuzzy match text",
6923 )?;
6924
6925 let invalid = search_indexed_files(&store, "bpa", true, true, false, None, 0, 0, 10);
6926 if invalid.is_ok() {
6927 return Err(io::Error::other("regex+fuzzy search was accepted").into());
6928 }
6929 Ok(())
6930 }
6931
6932 #[test]
6933 fn symbol_slice_reports_ambiguity_and_accepts_parent_selector() -> Result<(), Box<dyn Error>> {
6934 let temp = tempfile::tempdir()?;
6935 let root = temp.path();
6936 fs::create_dir_all(root.join("src"))?;
6937 fs::write(
6938 root.join("src").join("lib.rs"),
6939 "struct A;\nimpl A {\n fn run(&self) {\n a();\n }\n}\nstruct B;\nimpl B {\n fn run(&self) {\n b();\n }\n}\n",
6940 )?;
6941 let mut store = AtlasStore::in_memory()?;
6942 store.set_project_root(root)?;
6943 store.replace_scan(&[test_node("src/lib.rs", "hash-lib")])?;
6944 let mut a_run = test_symbol("src/lib.rs", SymbolKind::Method, "run");
6945 a_run.parent = Some("A".to_string());
6946 a_run.signature = "fn run(&self) for A".to_string();
6947 a_run.line_start = 3;
6948 a_run.line_end = 5;
6949 let mut b_run = test_symbol("src/lib.rs", SymbolKind::Method, "run");
6950 b_run.parent = Some("B".to_string());
6951 b_run.signature = "fn run(&self) for B".to_string();
6952 b_run.line_start = 9;
6953 b_run.line_end = 11;
6954 store.replace_symbol_graph(&SymbolGraph {
6955 path: "src/lib.rs".to_string(),
6956 language: Some("rust".to_string()),
6957 parser: ParserKind::TreeSitter,
6958 symbols: vec![a_run, b_run],
6959 relations: Vec::new(),
6960 })?;
6961
6962 let ambiguous = read_symbol_slice(
6963 &store,
6964 Path::new("src/lib.rs"),
6965 &SymbolSliceSelector {
6966 name: "run",
6967 ..SymbolSliceSelector::default()
6968 },
6969 );
6970 if !matches!(ambiguous, Err(ServiceError::InvalidInput(message)) if message.contains("ambiguous") && message.contains("parent=A") && message.contains("parent=B"))
6971 {
6972 return Err(
6973 io::Error::other("ambiguous symbol slice did not report candidates").into(),
6974 );
6975 }
6976
6977 let slice = read_symbol_slice(
6978 &store,
6979 Path::new("src/lib.rs"),
6980 &SymbolSliceSelector {
6981 name: "run",
6982 parent: Some("B"),
6983 ..SymbolSliceSelector::default()
6984 },
6985 )?;
6986 if !slice.content.contains("b();") || slice.content.contains("a();") {
6987 return Err(io::Error::other("parent selector returned wrong symbol slice").into());
6988 }
6989 let signature_slice = read_symbol_slice(
6990 &store,
6991 Path::new("src/lib.rs"),
6992 &SymbolSliceSelector {
6993 name: "run",
6994 signature: Some("fn run(&self) for A"),
6995 ..SymbolSliceSelector::default()
6996 },
6997 )?;
6998 if !signature_slice.content.contains("a();") || signature_slice.content.contains("b();") {
6999 return Err(io::Error::other("signature selector returned wrong symbol slice").into());
7000 }
7001 Ok(())
7002 }
7003
7004 #[test]
7005 fn code_slice_budget_preserves_verbatim_utf8_and_rejects_oversized_output()
7006 -> Result<(), Box<dyn Error>> {
7007 let source = "fn café() {\r\n println!(\"λ\");\r\n}\r\n";
7008 let budget = CodeSliceBudget::new(512)?;
7009 let slice = read_code_slice(source, "src/lib.rs", 1, Some(3), budget)?;
7010 require_eq(
7011 &slice.slice().content,
7012 &source
7013 .strip_suffix("\r\n")
7014 .ok_or_else(|| io::Error::other("CRLF fixture terminator missing"))?
7015 .to_string(),
7016 "verbatim UTF-8 slice",
7017 )?;
7018 let encoded = slice.fit_output::<_, ServiceError, _>(|slice| {
7019 serde_json::to_vec(slice).map_err(ServiceError::from)
7020 })?;
7021 if encoded.len() > budget.output_bytes() as usize {
7022 return Err(io::Error::other(
7023 "accepted slice exceeded its exact encoded-output ceiling",
7024 )
7025 .into());
7026 }
7027 let payload: serde_json::Value = serde_json::from_slice(&encoded)?;
7028 if payload.get("output_budget").is_some() {
7029 return Err(io::Error::other(
7030 "additive slice budget changed the compatibility payload",
7031 )
7032 .into());
7033 }
7034
7035 let content_error =
7036 read_code_slice(source, "src/lib.rs", 1, Some(3), CodeSliceBudget::new(8)?);
7037 if !matches!(
7038 content_error,
7039 Err(ServiceError::InvalidInput(message))
7040 if message.contains("verbatim slice content exceeds")
7041 ) {
7042 return Err(io::Error::other(
7043 "oversized verbatim slice content was allocated or truncated",
7044 )
7045 .into());
7046 }
7047
7048 let envelope_budget = CodeSliceBudget::new(64)?;
7049 let envelope = read_code_slice("λ", "src/lib.rs", 1, Some(1), envelope_budget)?;
7050 let envelope_error = envelope.fit_output::<_, ServiceError, _>(|slice| {
7051 serde_json::to_vec(slice).map_err(ServiceError::from)
7052 });
7053 if !matches!(
7054 envelope_error,
7055 Err(ServiceError::InvalidInput(message))
7056 if message.contains("slice output exceeds")
7057 ) {
7058 return Err(io::Error::other("oversized encoded slice envelope was accepted").into());
7059 }
7060 if CodeSliceBudget::new(0).is_ok()
7061 || CodeSliceBudget::new(GraphLimits::MAX_OUTPUT_BYTES + 1).is_ok()
7062 {
7063 return Err(io::Error::other("invalid slice output ceilings were accepted").into());
7064 }
7065 Ok(())
7066 }
7067
7068 #[test]
7069 fn line_slice_reads_current_disk_content_after_index_validation() -> Result<(), Box<dyn Error>>
7070 {
7071 let temp = tempfile::tempdir()?;
7072 let root = temp.path();
7073 fs::create_dir_all(root.join("src"))?;
7074 let file = root.join("src").join("lib.rs");
7075 fs::write(&file, "pub fn old_name() {}\n")?;
7076 let mut store = AtlasStore::in_memory()?;
7077 store.set_project_root(root)?;
7078 store.replace_scan(&[test_node("src/lib.rs", "old-hash")])?;
7079
7080 fs::write(&file, "pub fn current_name() {}\n")?;
7081 let slice = read_indexed_code_slice(&store, Path::new("src/lib.rs"), 1, Some(1))?;
7082
7083 require_eq(
7084 &slice.content,
7085 &"pub fn current_name() {}".to_string(),
7086 "slice content",
7087 )?;
7088 Ok(())
7089 }
7090
7091 fn test_node(path: &str, hash: &str) -> Node {
7093 test_node_with_language(path, hash, ".rs", "rust")
7094 }
7095
7096 fn test_node_with_language(path: &str, hash: &str, extension: &str, language: &str) -> Node {
7098 Node {
7099 path: path.to_string(),
7100 kind: NodeKind::File,
7101 parent_path: normalized_parent(path),
7102 extension: Some(extension.to_string()),
7103 language: Some(language.to_string()),
7104 size_bytes: Some(12),
7105 mtime_ns: Some(10),
7106 content_hash: Some(hash.to_string()),
7107 }
7108 }
7109
7110 fn test_indexed_node(path: &str, hash: &str) -> IndexedNode {
7112 IndexedNode {
7113 node: test_node(path, hash),
7114 purpose: Purpose {
7115 path: path.to_string(),
7116 purpose: Some(format!("Purpose for {path}")),
7117 source: PurposeSource::Agent,
7118 status: PurposeStatus::Approved,
7119 },
7120 summary: Some(format!("Summary for {path}")),
7121 }
7122 }
7123
7124 fn index_test_file_texts(
7126 store: &mut AtlasStore,
7127 root: &Path,
7128 nodes: &[Node],
7129 ) -> Result<(), Box<dyn Error>> {
7130 let mut paths = Vec::new();
7131 let mut texts = Vec::new();
7132 for node in nodes {
7133 paths.push(node.path.clone());
7134 let native = root.join(repo_path_to_native(&node.path));
7135 let content = fs::read_to_string(native)?;
7136 texts.push(IndexedFileText {
7137 path: node.path.clone(),
7138 content_hash: node.content_hash.clone(),
7139 byte_count: content.len(),
7140 line_count: content.lines().count(),
7141 content,
7142 });
7143 }
7144 store.replace_file_texts_for_paths(&paths, &texts)?;
7145 Ok(())
7146 }
7147
7148 fn test_symbol(path: &str, kind: SymbolKind, name: &str) -> CodeSymbol {
7150 CodeSymbol {
7151 path: path.to_string(),
7152 language: Some("rust".to_string()),
7153 name: name.to_string(),
7154 kind,
7155 signature: name.to_string(),
7156 exported: false,
7157 documentation: None,
7158 line_start: 1,
7159 line_end: 1,
7160 source_selector: None,
7161 parent: None,
7162 parser: ParserKind::TreeSitter,
7163 detail: None,
7164 }
7165 }
7166
7167 fn assert_single_called_by(
7168 report: &FileSummaryReport,
7169 symbol_name: &str,
7170 caller: &str,
7171 ) -> Result<(), Box<dyn Error>> {
7172 let symbol = report
7173 .functions
7174 .iter()
7175 .find(|symbol| symbol.name == symbol_name)
7176 .ok_or_else(|| io::Error::other(format!("{symbol_name} summary missing")))?;
7177 require_eq(
7178 &symbol.called_by,
7179 &vec![caller.to_string()],
7180 "import alias called-by",
7181 )
7182 }
7183
7184 fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
7186 if condition {
7187 Ok(())
7188 } else {
7189 Err(io::Error::other(message).into())
7190 }
7191 }
7192
7193 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
7195 where
7196 T: std::fmt::Debug + PartialEq,
7197 {
7198 if actual == expected {
7199 Ok(())
7200 } else {
7201 Err(io::Error::other(format!(
7202 "{label} mismatch: expected {expected:?}, got {actual:?}"
7203 ))
7204 .into())
7205 }
7206 }
7207
7208 fn require_reason(reasons: &[String], expected: &str) -> Result<(), Box<dyn Error>> {
7210 if reasons.iter().any(|reason| reason.contains(expected)) {
7211 Ok(())
7212 } else {
7213 Err(io::Error::other(format!("reason {expected:?} missing from {reasons:?}")).into())
7214 }
7215 }
7216}