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