1mod agent_efficiency;
4mod analysis;
5mod federation;
6mod import_aliases;
7mod relations;
8
9pub use analysis::{
10 AnalysisFinding, AnalysisFindingKind, AnalysisNode, AnalysisStatus, GitImpactSelection,
11 RelationAnalysisDraft, RelationAnalysisMode, RelationAnalysisQuery, RelationAnalysisReport,
12 RelationAnalysisWork, VcsImpact, load_relation_analysis,
13};
14pub use federation::{
15 FederatedAnalysisDraft, FederatedAnalysisReport, FederatedDetailedRelationDraft,
16 FederatedDetailedRelationReport, FederatedInputWork, FederatedParticipant,
17 FederatedRelationEvidence, FederatedRelationWork, FederatedRendezvous, FederatedStore,
18 MAX_FEDERATED_DATABASE_BYTES, MAX_FEDERATED_INPUT_BYTES, load_federated_detailed_relations,
19 load_federated_relation_analysis, validate_federated_root_count,
20};
21pub use relations::{
22 DetailedRelationBudget, DetailedRelationNode, DetailedRelationPageDraft, DetailedRelationQuery,
23 DetailedRelationReport, DetailedRelationRow, DetailedRelationWork, RelationAnchor,
24 RelationDirection, RelationNextCall, RelationPurpose, RelationResolutionFilter,
25 RelationTotalState, load_detailed_relation_page, load_detailed_relations,
26 parse_relation_confidence, parse_relation_direction, parse_relation_resolution,
27};
28
29use agent_efficiency::load_agent_efficiency_comparison;
30use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
31use import_aliases::{ImportAliasMap, load_import_alias_map};
32use projectatlas_core::graph::{
33 CoverageScope, CoverageState, ExtendedRelationKind, GraphLimitKind, GraphLimits,
34 GraphRelationKind,
35};
36use projectatlas_core::outline::estimate_tokens;
37use projectatlas_core::symbols::{
38 CodeSymbol, ParserKind, RelationKind, SourceParseMetadata, SymbolKind, SymbolRelation,
39};
40use projectatlas_core::telemetry::{TokenOverview, TokenTrendReport, TokenTrendWindow};
41use projectatlas_core::{
42 IndexCancellation, IndexGeneration, IndexWorkControl, IndexWorkFailure, IndexWorkStage,
43 IndexedNode, NavigationNextCall, NavigationNextCapability, NodeKind, RankedConnectionKind,
44 RankedConnectionTarget, RankedNode, RankedReasonCode, repo_path_to_native,
45 validated_repo_file_key,
46};
47use projectatlas_db::{
48 AtlasStore, CapturedProjectBinding, DbError, FileTextAdmission, FileTextFtsQuery,
49 IndexedFileText, MAX_FILE_TEXT_FTS_CANDIDATES, RepositoryCoverageQuery, RepositoryCoverageRow,
50 RepositoryNavigationConnections, RepositoryNavigationNode,
51};
52use projectatlas_symbols::module_aliases_for_path;
53use regex::RegexBuilder;
54use serde::Serialize;
55use std::collections::{HashMap, HashSet};
56use std::fs;
57use std::path::{Path, PathBuf};
58use std::time::Duration;
59use thiserror::Error;
60
61const CALLERS_PER_SYMBOL_LIMIT: usize = 20;
63const CALLER_RELATION_LIMIT_PER_TARGET: usize = 20;
65const FILE_METADATA_SYMBOL_LIMIT: usize = 20;
67const RANKED_REASON_LIMIT: usize = 6;
69const RANKED_CANDIDATE_LIMIT: usize = 100;
71const RANKED_CONNECTION_FAMILY_LIMIT: u32 = 4;
73const RANKED_CONNECTION_SAMPLE_LIMIT: usize = 3;
75const NEXT_REPORT_DEFAULT_LIMIT: usize = 3;
77const NEXT_REPORT_MAX_LIMIT: usize = 10;
79pub const COVERAGE_PAGE_MAX_LIMIT: u32 = 200;
81const COVERAGE_DIGEST_ROW_LIMIT: u32 = 16;
83const SOURCE_STATUS_LIVE: &str = "live-source";
85const SOURCE_STATUS_INDEXED: &str = "indexed-metadata";
87const SEARCH_MAX_SELECTED_FILES: usize = 50_000;
89const SEARCH_MAX_SELECTED_BYTES: usize = 128 * 1024 * 1024;
91const SEARCH_MAX_ELAPSED: Duration = Duration::from_secs(10);
93const SEARCH_MAX_CONTEXT_LINES: usize = 20;
95const SEARCH_MAX_RESULT_ROWS: usize = 1_000;
97const SEARCH_MAX_RETAINED_BYTES: usize = 2 * 1024 * 1024;
99const SEARCH_MAX_PATTERN_BYTES: usize = 64 * 1024;
101const SEARCH_MAX_FILE_PATTERN_BYTES: usize = 4 * 1024;
103const SEARCH_SEMANTIC_UNAVAILABLE_STATE: &str = "not-installed";
105const SEARCH_SEMANTIC_RECOVERY: &str =
107 "install and enable a compatible semantic retrieval pack, then build a ready generation";
108
109#[derive(Clone, Copy, Debug)]
111struct SearchBounds {
112 selected_files: usize,
114 selected_bytes: usize,
116 elapsed: Duration,
118 retained_bytes: usize,
120}
121
122const DEFAULT_SEARCH_BOUNDS: SearchBounds = SearchBounds {
124 selected_files: SEARCH_MAX_SELECTED_FILES,
125 selected_bytes: SEARCH_MAX_SELECTED_BYTES,
126 elapsed: SEARCH_MAX_ELAPSED,
127 retained_bytes: SEARCH_MAX_RETAINED_BYTES,
128};
129#[derive(Debug, Error)]
131pub enum ServiceError {
132 #[error("{0}")]
134 Db(#[from] DbError),
135 #[error("invalid input: {0}")]
137 InvalidInput(String),
138 #[error("io error for {path:?}: {source}")]
140 Io {
141 path: PathBuf,
143 source: std::io::Error,
145 },
146 #[error("serialization error: {0}")]
148 Serialize(#[from] serde_json::Error),
149 #[error("selected project binding is unavailable")]
151 SelectedProjectUnavailable,
152 #[error("selected project binding changed while loading the token report")]
154 SelectedProjectChanged,
155 #[error("search retrieval mode {requested_mode:?} is unavailable ({state}); {guidance}")]
157 SearchCapabilityUnavailable {
158 requested_mode: SearchRetrievalMode,
160 state: &'static str,
162 guidance: &'static str,
164 },
165 #[error("invalid detailed relation cursor: {reason}; restart the relation request")]
167 RelationCursorInvalid {
168 reason: &'static str,
170 },
171 #[error("detailed relation cursor does not match {field}; restart the relation request")]
173 RelationCursorMismatched {
174 field: &'static str,
176 },
177 #[error("detailed relation cursor is stale for {field}; restart the relation request")]
179 RelationCursorStale {
180 field: &'static str,
182 },
183}
184
185pub type ServiceResult<T> = Result<T, ServiceError>;
187
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
190pub enum TokenReportRequest<'a> {
191 Overview {
193 caller_label: Option<&'a str>,
195 benchmark_results: Option<&'a Path>,
197 },
198 Trends {
200 caller_label: Option<&'a str>,
202 window: TokenTrendWindow,
204 },
205}
206
207#[derive(Clone, Debug, PartialEq)]
209pub enum TokenReport {
210 Overview(Box<TokenOverview>),
212 Trends(TokenTrendReport),
214}
215
216fn selected_project_binding(store: &AtlasStore) -> ServiceResult<CapturedProjectBinding> {
218 match store.captured_project_binding() {
219 Ok(binding) => Ok(binding),
220 Err(DbError::ProjectRootMissing | DbError::ProjectInstanceIdentityMissing) => {
221 Err(ServiceError::SelectedProjectUnavailable)
222 }
223 Err(error) => Err(ServiceError::Db(error)),
224 }
225}
226
227fn revalidate_selected_project_binding(store: &AtlasStore) -> ServiceResult<()> {
229 match store.revalidate_captured_project_binding() {
230 Ok(()) => Ok(()),
231 Err(
232 DbError::ProjectRootMissing
233 | DbError::ProjectInstanceIdentityMissing
234 | DbError::ProjectRootMismatch { .. }
235 | DbError::ProjectRootTransitionChanged { .. },
236 ) => Err(ServiceError::SelectedProjectChanged),
237 Err(error) => Err(ServiceError::Db(error)),
238 }
239}
240
241pub fn load_token_report(
248 store: &AtlasStore,
249 request: TokenReportRequest<'_>,
250) -> ServiceResult<TokenReport> {
251 let selected_project = selected_project_binding(store)?;
252 let report = match request {
253 TokenReportRequest::Overview {
254 caller_label,
255 benchmark_results,
256 } => {
257 let mut overview = store.token_overview(caller_label)?;
258 overview.set_agent_efficiency(load_agent_efficiency_comparison(
259 &selected_project,
260 benchmark_results,
261 )?);
262 TokenReport::Overview(Box::new(overview))
263 }
264 TokenReportRequest::Trends {
265 caller_label,
266 window,
267 } => TokenReport::Trends(store.token_trends(caller_label, window)?),
268 };
269 revalidate_selected_project_binding(store)?;
270 Ok(report)
271}
272
273#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
275#[serde(rename_all = "snake_case")]
276pub enum CoverageTrustState {
277 Trusted,
279 Partial,
281 Untrusted,
283}
284
285#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "snake_case")]
288pub enum CoverageExtractionPass {
289 GraphProjection,
291 Relationship,
293}
294
295#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
297#[serde(tag = "state", content = "value", rename_all = "snake_case")]
298pub enum CoverageTotalState {
299 Exact(u32),
301 AtLeast(u32),
303 Unknown,
305}
306
307#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
309pub struct CoverageStateCounts {
310 pub complete: u32,
312 pub partial: u32,
314 pub failed: u32,
316 pub ignored: u32,
318 pub oversized: u32,
320 pub quarantined: u32,
322 pub stale: u32,
324}
325
326#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
328pub struct CoverageDigest {
329 pub available: bool,
331 pub active_generation: IndexGeneration,
333 pub parser: Option<ParserKind>,
335 pub provider: Option<ParserKind>,
337 pub states: CoverageStateCounts,
339 pub total: u64,
341 pub covered: u64,
343 pub omitted: u64,
345 pub relation_rows: u32,
347 pub truncated: bool,
349 pub trust: CoverageTrustState,
351 pub next_call: NavigationNextCall,
353}
354
355#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
357pub struct CoverageDiscoveryRow {
358 pub path: String,
360 pub extraction_pass: CoverageExtractionPass,
362 pub relation: Option<GraphRelationKind>,
364 pub state: CoverageState,
366 pub trust: CoverageTrustState,
368 pub total: u64,
370 pub covered: u64,
372 pub omitted: u64,
374 pub reason: Option<String>,
376 pub reached_limit: Option<GraphLimitKind>,
378 pub active_generation: IndexGeneration,
380 pub parser: Option<ParserKind>,
382 pub provider: Option<ParserKind>,
384 pub next_call: NavigationNextCall,
386}
387
388#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
390pub struct CoverageDiscoveryReport {
391 pub start_index: u32,
393 pub limit: u32,
395 pub max_limit: u32,
397 pub returned: u32,
399 pub truncated: bool,
401 pub continuation: Option<u32>,
403 pub reached_limit: Option<GraphLimitKind>,
405 pub total: CoverageTotalState,
407 pub output_bytes: u32,
409 pub max_output_bytes: u32,
411 pub rows: Vec<CoverageDiscoveryRow>,
413}
414
415pub fn parse_coverage_parser(value: &str) -> ServiceResult<ParserKind> {
421 match value.trim().to_ascii_lowercase().as_str() {
422 "tree-sitter" | "tree_sitter" => Ok(ParserKind::TreeSitter),
423 "manifest" => Ok(ParserKind::Manifest),
424 "structural" => Ok(ParserKind::Structural),
425 "fallback" => Ok(ParserKind::Fallback),
426 _ => Err(ServiceError::InvalidInput(format!(
427 "invalid coverage parser/provider '{value}'; expected tree-sitter, manifest, structural, or fallback"
428 ))),
429 }
430}
431
432pub fn parse_coverage_relation(value: &str) -> ServiceResult<GraphRelationKind> {
438 match value.trim().to_ascii_lowercase().as_str() {
439 "contains" => Ok(GraphRelationKind::Legacy(RelationKind::Contains)),
440 "imports" => Ok(GraphRelationKind::Legacy(RelationKind::Imports)),
441 "calls" => Ok(GraphRelationKind::Legacy(RelationKind::Calls)),
442 "depends-on" | "depends_on" => Ok(GraphRelationKind::Legacy(RelationKind::DependsOn)),
443 "references" => Ok(GraphRelationKind::Extended(
444 ExtendedRelationKind::References,
445 )),
446 "tests" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Tests)),
447 "routes-to" | "routes_to" => {
448 Ok(GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo))
449 }
450 "configures" => Ok(GraphRelationKind::Extended(
451 ExtendedRelationKind::Configures,
452 )),
453 "deploys" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Deploys)),
454 "reads" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Reads)),
455 "writes" => Ok(GraphRelationKind::Extended(ExtendedRelationKind::Writes)),
456 _ => Err(ServiceError::InvalidInput(format!(
457 "invalid coverage relation '{value}'"
458 ))),
459 }
460}
461
462pub fn parse_coverage_state(value: &str) -> ServiceResult<CoverageState> {
468 match value.trim().to_ascii_lowercase().as_str() {
469 "complete" => Ok(CoverageState::Complete),
470 "partial" => Ok(CoverageState::Partial),
471 "failed" => Ok(CoverageState::Failed),
472 "ignored" => Ok(CoverageState::Ignored),
473 "oversized" => Ok(CoverageState::Oversized),
474 "quarantined" => Ok(CoverageState::Quarantined),
475 "stale" => Ok(CoverageState::Stale),
476 _ => Err(ServiceError::InvalidInput(format!(
477 "invalid coverage state '{value}'"
478 ))),
479 }
480}
481
482pub fn load_coverage_discovery(
489 store: &AtlasStore,
490 mut query: RepositoryCoverageQuery,
491) -> ServiceResult<CoverageDiscoveryReport> {
492 if query.start_index >= GraphLimits::MAX_ROWS {
493 return Err(ServiceError::InvalidInput(format!(
494 "coverage start index must be below {}",
495 GraphLimits::MAX_ROWS
496 )));
497 }
498 query.limit = query
499 .limit
500 .clamp(1, COVERAGE_PAGE_MAX_LIMIT)
501 .min(GraphLimits::MAX_ROWS - query.start_index);
502 let project = store
503 .project_instance_id()?
504 .ok_or(ServiceError::SelectedProjectUnavailable)?;
505 let page = store.repository_coverage_page(project, &query)?;
506 let rows = page
507 .rows
508 .into_iter()
509 .map(coverage_discovery_row)
510 .collect::<Vec<_>>();
511 let returned = u32::try_from(rows.len()).map_err(|error| {
512 ServiceError::InvalidInput(format!("coverage row count did not fit u32: {error}"))
513 })?;
514 let proved = query.start_index.saturating_add(returned);
515 let total = if page.truncated {
516 CoverageTotalState::AtLeast(proved.saturating_add(1))
517 } else if query.start_index == 0 || returned > 0 {
518 CoverageTotalState::Exact(proved)
519 } else {
520 CoverageTotalState::Unknown
521 };
522 let next_index = query.start_index.saturating_add(returned);
523 let continuation = (page.truncated && next_index < GraphLimits::MAX_ROWS).then_some(next_index);
524 let reached_limit = (page.truncated && continuation.is_none()).then_some(GraphLimitKind::Rows);
525 Ok(CoverageDiscoveryReport {
526 start_index: query.start_index,
527 limit: query.limit,
528 max_limit: COVERAGE_PAGE_MAX_LIMIT,
529 returned,
530 truncated: page.truncated,
531 continuation,
532 reached_limit,
533 total,
534 output_bytes: 0,
535 max_output_bytes: GraphLimits::MAX_OUTPUT_BYTES,
536 rows,
537 })
538}
539
540fn coverage_discovery_row(row: RepositoryCoverageRow) -> CoverageDiscoveryRow {
542 let coverage = row.coverage;
543 let path = match coverage.scope() {
544 CoverageScope::Project => ".".to_string(),
545 CoverageScope::Path { path } => path.as_str().to_string(),
546 };
547 let relation = coverage.relation();
548 CoverageDiscoveryRow {
549 next_call: NavigationNextCall {
550 capability: if matches!(coverage.scope(), CoverageScope::Path { .. }) {
551 NavigationNextCapability::Summary
552 } else {
553 NavigationNextCapability::Health
554 },
555 path: path.clone(),
556 },
557 path,
558 extraction_pass: if relation.is_some() {
559 CoverageExtractionPass::Relationship
560 } else {
561 CoverageExtractionPass::GraphProjection
562 },
563 relation,
564 state: coverage.state(),
565 trust: coverage_trust(coverage.state()),
566 total: coverage.total(),
567 covered: coverage.covered(),
568 omitted: coverage.omitted(),
569 reason: coverage.reason().map(|reason| reason.as_str().to_string()),
570 reached_limit: coverage.reached_limit(),
571 active_generation: coverage.generation(),
572 parser: row.parser,
573 provider: row.provider,
574 }
575}
576
577const fn coverage_trust(state: CoverageState) -> CoverageTrustState {
579 match state {
580 CoverageState::Complete => CoverageTrustState::Trusted,
581 CoverageState::Partial => CoverageTrustState::Partial,
582 CoverageState::Failed
583 | CoverageState::Ignored
584 | CoverageState::Oversized
585 | CoverageState::Quarantined
586 | CoverageState::Stale => CoverageTrustState::Untrusted,
587 }
588}
589
590fn load_coverage_digest(
592 store: &AtlasStore,
593 path: &str,
594 parse_metadata: Option<&SourceParseMetadata>,
595) -> ServiceResult<CoverageDigest> {
596 let project = store
597 .project_instance_id()?
598 .ok_or(ServiceError::SelectedProjectUnavailable)?;
599 let path_page = store.repository_coverage_page(
600 project,
601 &RepositoryCoverageQuery {
602 start_index: 0,
603 limit: COVERAGE_DIGEST_ROW_LIMIT,
604 path_prefix: Some(path.to_string()),
605 parser: None,
606 provider: None,
607 relation: None,
608 state: None,
609 reason: None,
610 },
611 )?;
612 let rows = path_page
613 .rows
614 .into_iter()
615 .filter(|row| {
616 matches!(
617 row.coverage.scope(),
618 CoverageScope::Path { path: row_path } if row_path.as_str() == path
619 )
620 })
621 .collect::<Vec<_>>();
622 let mut states = CoverageStateCounts::default();
623 let mut total = 0_u64;
624 let mut covered = 0_u64;
625 let mut omitted = 0_u64;
626 let mut relation_rows = 0_u32;
627 let mut trust = CoverageTrustState::Trusted;
628 for row in &rows {
629 let record = &row.coverage;
630 increment_coverage_state(&mut states, record.state());
631 total = checked_coverage_sum(total, record.total(), "total")?;
632 covered = checked_coverage_sum(covered, record.covered(), "covered")?;
633 omitted = checked_coverage_sum(omitted, record.omitted(), "omitted")?;
634 if record.relation().is_some() {
635 relation_rows = relation_rows.saturating_add(1);
636 }
637 trust = match (trust, coverage_trust(record.state())) {
638 (_, CoverageTrustState::Untrusted) => CoverageTrustState::Untrusted,
639 (CoverageTrustState::Trusted, CoverageTrustState::Partial) => {
640 CoverageTrustState::Partial
641 }
642 (current, _) => current,
643 };
644 }
645 let available = !rows.is_empty();
646 if !available {
647 trust = CoverageTrustState::Untrusted;
648 }
649 Ok(CoverageDigest {
650 available,
651 active_generation: rows
652 .first()
653 .map_or(IndexGeneration::ZERO, |row| row.coverage.generation()),
654 parser: rows
655 .iter()
656 .find_map(|row| row.parser)
657 .or_else(|| parse_metadata.map(|metadata| metadata.parser)),
658 provider: rows.iter().find_map(|row| row.provider),
659 states,
660 total,
661 covered,
662 omitted,
663 relation_rows,
664 truncated: path_page.truncated,
665 trust,
666 next_call: NavigationNextCall {
667 capability: NavigationNextCapability::Health,
668 path: path.to_string(),
669 },
670 })
671}
672
673fn checked_coverage_sum(current: u64, value: u64, field: &str) -> ServiceResult<u64> {
675 current.checked_add(value).ok_or_else(|| {
676 ServiceError::InvalidInput(format!("selected-file coverage {field} overflowed u64"))
677 })
678}
679
680fn increment_coverage_state(counts: &mut CoverageStateCounts, state: CoverageState) {
682 let count = match state {
683 CoverageState::Complete => &mut counts.complete,
684 CoverageState::Partial => &mut counts.partial,
685 CoverageState::Failed => &mut counts.failed,
686 CoverageState::Ignored => &mut counts.ignored,
687 CoverageState::Oversized => &mut counts.oversized,
688 CoverageState::Quarantined => &mut counts.quarantined,
689 CoverageState::Stale => &mut counts.stale,
690 };
691 *count = count.saturating_add(1);
692}
693
694#[derive(Debug, Serialize)]
696pub struct FileSummaryReport {
697 pub file_path: String,
699 pub language: String,
701 pub line_count: usize,
703 pub source_status: String,
705 pub source_error: String,
707 pub parser_kind: String,
709 pub summary_status: String,
711 pub file_purpose: String,
713 pub file_purpose_status: String,
715 pub file_purpose_source: String,
717 pub file_purpose_agent_reviewed: bool,
719 pub content_summary: String,
721 pub package: String,
723 pub docstring: String,
725 pub symbol_count: usize,
727 pub limit: usize,
729 pub total_functions: usize,
731 pub total_methods: usize,
733 pub total_classes: usize,
735 pub total_types: usize,
737 pub total_calls: usize,
739 pub total_imports: usize,
741 pub total_dependencies: usize,
743 pub total_exports: usize,
745 pub truncated: bool,
747 pub functions: Vec<FileSymbolSummary>,
749 pub methods: Vec<FileSymbolSummary>,
751 pub classes: Vec<FileSymbolSummary>,
753 pub types: Vec<FileSymbolSummary>,
755 pub imports: Vec<String>,
757 pub dependencies: Vec<String>,
759 pub exports: Vec<String>,
761 pub calls: Vec<FileCallSummary>,
763 pub coverage: CoverageDigest,
765}
766
767#[derive(Debug, Serialize)]
769pub struct FileSymbolSummary {
770 pub name: String,
772 pub kind: String,
774 pub line: usize,
776 pub end_line: usize,
778 pub signature: String,
780 pub exported: bool,
782 pub documentation: String,
784 pub parent: String,
786 pub called_by: Vec<String>,
788}
789
790#[derive(Debug, Serialize)]
792pub struct FileCallSummary {
793 pub source: String,
795 pub target: String,
797 pub line: usize,
799 pub context: String,
801}
802
803#[derive(Debug, Serialize)]
805pub struct SearchMatch {
806 pub path: String,
808 pub line: usize,
810 pub context_before: Vec<String>,
812 pub text: String,
814 pub context_after: Vec<String>,
816}
817
818#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
820#[serde(rename_all = "lowercase")]
821pub enum SearchRetrievalMode {
822 #[default]
824 Lexical,
825 Semantic,
827 Hybrid,
829}
830
831impl SearchRetrievalMode {
832 #[must_use]
834 pub const fn as_str(self) -> &'static str {
835 match self {
836 Self::Lexical => "lexical",
837 Self::Semantic => "semantic",
838 Self::Hybrid => "hybrid",
839 }
840 }
841}
842
843#[derive(Clone, Copy, Debug)]
845pub struct SearchQuery<'query> {
846 pub pattern: &'query str,
848 pub regex: bool,
850 pub fuzzy: bool,
852 pub case_sensitive: bool,
854 pub file_pattern: Option<&'query str>,
856 pub context_lines: usize,
858 pub start_index: usize,
860 pub limit: usize,
862 pub retrieval_mode: SearchRetrievalMode,
864}
865
866#[derive(Debug, Serialize)]
868pub struct SearchReport {
869 pub query: String,
871 pub mode: String,
873 pub retrieval_mode: String,
875 pub source: String,
877 pub strategy: String,
879 pub start_index: usize,
881 pub total: usize,
883 pub observed_total: usize,
885 pub total_is_complete: bool,
887 pub returned: usize,
889 pub searched_files: usize,
891 pub searched_bytes: usize,
893 pub candidate_files: usize,
895 pub retained_bytes: usize,
897 pub truncated: bool,
899 pub truncation_reason: Option<String>,
901 pub results: Vec<SearchMatch>,
903}
904
905#[derive(Debug, Serialize)]
907pub struct NextStepReport {
908 pub query: String,
910 pub folders: Vec<RankedNode>,
912 pub files: Vec<RankedNode>,
914 pub suggestions: Vec<String>,
916}
917
918#[derive(Debug, Serialize)]
920pub struct CodeSlice {
921 pub path: String,
923 pub start_line: usize,
925 pub end_line: usize,
927 pub line_count: usize,
929 pub estimated_tokens: usize,
931 pub content: String,
933}
934
935#[derive(Debug)]
937pub struct CodeSliceDraft {
938 slice: CodeSlice,
940 output_budget: CodeSliceBudget,
942}
943
944impl CodeSliceDraft {
945 #[must_use]
947 pub const fn slice(&self) -> &CodeSlice {
948 &self.slice
949 }
950
951 pub fn fit_output<F, E, O>(&self, encode: F) -> Result<O, E>
958 where
959 F: FnOnce(&CodeSlice) -> Result<O, E>,
960 E: From<ServiceError>,
961 O: AsRef<[u8]>,
962 {
963 let output = encode(&self.slice)?;
964 if output.as_ref().len() > self.output_budget.output_bytes() as usize {
965 return Err(E::from(ServiceError::InvalidInput(format!(
966 "slice output exceeds the requested {}-byte ceiling; narrow the line or symbol range or raise output-bytes",
967 self.output_budget.output_bytes()
968 ))));
969 }
970 Ok(output)
971 }
972}
973
974#[derive(Clone, Copy, Debug, Eq, PartialEq)]
976pub struct CodeSliceBudget {
977 output_bytes: u32,
979}
980
981impl CodeSliceBudget {
982 pub const DEFAULT_OUTPUT_BYTES: u32 = 256 * 1_024;
984
985 pub fn new(output_bytes: u32) -> ServiceResult<Self> {
992 if output_bytes == 0 || output_bytes > GraphLimits::MAX_OUTPUT_BYTES {
993 return Err(ServiceError::InvalidInput(format!(
994 "slice output byte limit must be between 1 and {}",
995 GraphLimits::MAX_OUTPUT_BYTES
996 )));
997 }
998 Ok(Self { output_bytes })
999 }
1000
1001 #[must_use]
1003 pub const fn output_bytes(self) -> u32 {
1004 self.output_bytes
1005 }
1006}
1007
1008impl Default for CodeSliceBudget {
1009 fn default() -> Self {
1010 Self {
1011 output_bytes: Self::DEFAULT_OUTPUT_BYTES,
1012 }
1013 }
1014}
1015
1016#[derive(Debug, Default)]
1018pub struct SymbolSliceSelector<'a> {
1019 pub name: &'a str,
1021 pub parent: Option<&'a str>,
1023 pub kind: Option<&'a str>,
1025 pub signature: Option<&'a str>,
1027 pub line: Option<usize>,
1029}
1030
1031impl From<&SymbolRelation> for FileCallSummary {
1032 fn from(relation: &SymbolRelation) -> Self {
1033 Self {
1034 source: relation.source_name.clone(),
1035 target: relation.target_name.clone(),
1036 line: relation.line,
1037 context: relation.context.clone(),
1038 }
1039 }
1040}
1041
1042pub fn build_file_summary(
1049 store: &AtlasStore,
1050 file: &Path,
1051 limit: usize,
1052) -> ServiceResult<FileSummaryReport> {
1053 let file_key = validated_indexed_file_key(store, file)?;
1054 let source_read =
1055 indexed_native_path(store, &file_key).and_then(|path| read_file_content(&path));
1056 match source_read {
1057 Ok(content) => build_file_summary_with_source_state(
1058 store,
1059 file,
1060 limit,
1061 Some(&content),
1062 SOURCE_STATUS_LIVE.to_string(),
1063 String::new(),
1064 ),
1065 Err(error) => build_file_summary_with_source_state(
1066 store,
1067 file,
1068 limit,
1069 None,
1070 SOURCE_STATUS_INDEXED.to_string(),
1071 error.to_string(),
1072 ),
1073 }
1074}
1075
1076pub fn build_file_summary_from_source(
1083 store: &AtlasStore,
1084 file: &Path,
1085 limit: usize,
1086 source: &str,
1087) -> ServiceResult<FileSummaryReport> {
1088 build_file_summary_with_source_state(
1089 store,
1090 file,
1091 limit,
1092 Some(source),
1093 SOURCE_STATUS_LIVE.to_string(),
1094 String::new(),
1095 )
1096}
1097
1098fn build_file_summary_with_source_state(
1100 store: &AtlasStore,
1101 file: &Path,
1102 limit: usize,
1103 file_content: Option<&str>,
1104 source_status: String,
1105 source_error: String,
1106) -> ServiceResult<FileSummaryReport> {
1107 let file_key = validated_indexed_file_key(store, file)?;
1108 let effective_limit = limit.max(1);
1109 let indexed = store
1110 .load_node_by_path(&file_key)?
1111 .ok_or_else(|| ServiceError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
1112 let metadata_symbols = store.load_symbols_by_kinds(
1113 &file_key,
1114 &metadata_symbol_kinds(),
1115 FILE_METADATA_SYMBOL_LIMIT,
1116 )?;
1117 let line_count = file_content.map_or_else(
1118 || store.max_symbol_end_line_for_path(&file_key),
1119 |content| Ok(line_count_from_content(content)),
1120 )?;
1121 let docstring = file_content
1122 .and_then(file_level_docstring)
1123 .unwrap_or_else(|| file_docstring(&metadata_symbols));
1124 let function_symbols =
1125 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Function], effective_limit)?;
1126 let method_symbols =
1127 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Method], effective_limit)?;
1128 let class_symbols =
1129 store.load_symbols_by_kinds(&file_key, &[SymbolKind::Class], effective_limit)?;
1130 let type_kinds = type_symbol_kinds();
1131 let type_symbols = store.load_symbols_by_kinds(&file_key, &type_kinds, effective_limit)?;
1132 let summarized_symbols = summarized_symbol_set(
1133 &function_symbols,
1134 &method_symbols,
1135 &class_symbols,
1136 &type_symbols,
1137 );
1138 let summarized_names = symbol_names(&summarized_symbols);
1139 let symbol_name_counts = store.symbol_name_counts(&summarized_names)?;
1140 let alias_scope_symbols = store.load_symbols_by_names(&summarized_names)?;
1141 let alias_counts = symbol_alias_counts(&alias_scope_symbols);
1142 let import_aliases = load_import_alias_map(store, &summarized_symbols, &alias_counts)?;
1143 let caller_targets = caller_target_names(&summarized_symbols, &import_aliases);
1144 let caller_relations =
1145 store.load_call_relations_to_targets(&caller_targets, CALLER_RELATION_LIMIT_PER_TARGET)?;
1146 let called_by = called_by_map(
1147 &summarized_symbols,
1148 &caller_relations,
1149 &symbol_name_counts,
1150 &alias_counts,
1151 &import_aliases,
1152 );
1153 let functions = summarize_symbols(&function_symbols, &called_by);
1154 let methods = summarize_symbols(&method_symbols, &called_by);
1155 let classes = summarize_symbols(&class_symbols, &called_by);
1156 let types = summarize_symbols(&type_symbols, &called_by);
1157 let imports = store.load_distinct_relation_targets_by_kind(
1158 &file_key,
1159 RelationKind::Imports,
1160 effective_limit,
1161 )?;
1162 let dependencies = store.load_distinct_relation_targets_by_kind(
1163 &file_key,
1164 RelationKind::DependsOn,
1165 effective_limit,
1166 )?;
1167 let exports = store.load_exported_symbol_names_for_path(&file_key, effective_limit)?;
1168 let calls = store
1169 .load_symbol_relations_by_kind(&file_key, RelationKind::Calls, effective_limit)?
1170 .iter()
1171 .map(FileCallSummary::from)
1172 .collect::<Vec<_>>();
1173 let total_functions = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Function])?;
1174 let total_methods = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Method])?;
1175 let total_classes = store.count_symbols_by_kinds(&file_key, &[SymbolKind::Class])?;
1176 let total_types = store.count_symbols_by_kinds(&file_key, &type_kinds)?;
1177 let total_calls = store.count_symbol_relations_by_kind(&file_key, RelationKind::Calls)?;
1178 let total_imports =
1179 store.count_distinct_relation_targets_by_kind(&file_key, RelationKind::Imports)?;
1180 let total_dependencies =
1181 store.count_distinct_relation_targets_by_kind(&file_key, RelationKind::DependsOn)?;
1182 let total_exports = store.exported_symbol_count_for_path(&file_key)?;
1183 let symbol_count = store.symbol_count_for_path(&file_key)?;
1184 let symbol_parser_kinds = store.symbol_parser_kinds_for_path(&file_key)?;
1185 let parse_metadata = store.load_source_parse_metadata(&file_key)?;
1186 let coverage = load_coverage_digest(store, &file_key, parse_metadata.as_ref())?;
1187 let truncated = [
1188 total_functions,
1189 total_methods,
1190 total_classes,
1191 total_types,
1192 total_calls,
1193 total_imports,
1194 total_dependencies,
1195 total_exports,
1196 ]
1197 .iter()
1198 .any(|total| *total > effective_limit);
1199
1200 let content_summary = indexed.summary.unwrap_or_default();
1201 let parser_kind = summary_parser_kind(
1202 &content_summary,
1203 symbol_count,
1204 &symbol_parser_kinds,
1205 parse_metadata.as_ref(),
1206 )
1207 .to_string();
1208 let summary_status = summary_status(
1209 &content_summary,
1210 symbol_count,
1211 &symbol_parser_kinds,
1212 parse_metadata.as_ref(),
1213 )
1214 .to_string();
1215
1216 Ok(FileSummaryReport {
1217 file_path: file_key,
1218 language: indexed
1219 .node
1220 .language
1221 .clone()
1222 .unwrap_or_else(|| "unknown".to_string()),
1223 line_count,
1224 file_purpose: indexed.purpose.purpose.clone().unwrap_or_default(),
1225 file_purpose_status: indexed.purpose.status.to_string(),
1226 file_purpose_source: indexed.purpose.source.to_string(),
1227 file_purpose_agent_reviewed: indexed.purpose.agent_reviewed(),
1228 content_summary,
1229 package: package_name(&metadata_symbols),
1230 docstring,
1231 symbol_count,
1232 source_status,
1233 source_error,
1234 parser_kind,
1235 summary_status,
1236 limit: effective_limit,
1237 total_functions,
1238 total_methods,
1239 total_classes,
1240 total_types,
1241 total_calls,
1242 total_imports,
1243 total_dependencies,
1244 total_exports,
1245 truncated,
1246 functions,
1247 methods,
1248 classes,
1249 types,
1250 imports,
1251 dependencies,
1252 exports,
1253 calls,
1254 coverage,
1255 })
1256}
1257
1258pub fn file_summary_baseline_text(report: &FileSummaryReport) -> ServiceResult<String> {
1264 Ok(serde_json::to_string(report)?)
1265}
1266
1267fn summary_parser_kind(
1269 summary: &str,
1270 symbol_count: usize,
1271 parser_kinds: &[ParserKind],
1272 parse_metadata: Option<&SourceParseMetadata>,
1273) -> &'static str {
1274 if symbol_count > 0 && !parser_kinds.is_empty() {
1275 return symbol_parser_kind(parser_kinds);
1276 }
1277 if let Some(metadata) = parse_metadata {
1278 return parser_kind_label(metadata.parser);
1279 }
1280 if is_symbol_graph_empty_summary(summary) {
1281 "symbol-graph"
1282 } else if summary.is_empty() {
1283 "missing"
1284 } else if is_scanner_fallback_summary(summary) {
1285 "scanner-metadata"
1286 } else {
1287 "structural"
1288 }
1289}
1290
1291fn summary_status(
1293 summary: &str,
1294 symbol_count: usize,
1295 parser_kinds: &[ParserKind],
1296 parse_metadata: Option<&SourceParseMetadata>,
1297) -> &'static str {
1298 if summary.is_empty() {
1299 "missing"
1300 } else if is_scanner_fallback_summary(summary)
1301 || parse_metadata.is_some_and(|metadata| metadata.parser == ParserKind::Fallback)
1302 || fallback_only_symbols(symbol_count, parser_kinds)
1303 {
1304 "fallback"
1305 } else {
1306 "ok"
1307 }
1308}
1309
1310fn parser_kind_label(parser: ParserKind) -> &'static str {
1312 match parser {
1313 ParserKind::TreeSitter => "tree-sitter-symbol-graph",
1314 ParserKind::Manifest => "manifest-symbol-graph",
1315 ParserKind::Structural => "structural-symbol-graph",
1316 ParserKind::Fallback => "fallback-symbol-graph",
1317 }
1318}
1319
1320fn symbol_parser_kind(parser_kinds: &[ParserKind]) -> &'static str {
1322 let has_tree_sitter = parser_kinds.contains(&ParserKind::TreeSitter);
1323 let has_manifest = parser_kinds.contains(&ParserKind::Manifest);
1324 let has_structural = parser_kinds.contains(&ParserKind::Structural);
1325 let has_fallback = parser_kinds.contains(&ParserKind::Fallback);
1326 let family_count = usize::from(has_tree_sitter)
1327 .saturating_add(usize::from(has_manifest))
1328 .saturating_add(usize::from(has_structural))
1329 .saturating_add(usize::from(has_fallback));
1330 match (
1331 family_count,
1332 has_tree_sitter,
1333 has_manifest,
1334 has_structural,
1335 has_fallback,
1336 ) {
1337 (1, true, false, false, false) => "tree-sitter-symbol-graph",
1338 (1, false, true, false, false) => "manifest-symbol-graph",
1339 (1, false, false, true, false) => "structural-symbol-graph",
1340 (1, false, false, false, true) => "fallback-symbol-graph",
1341 _ => "mixed-symbol-graph",
1342 }
1343}
1344
1345fn fallback_only_symbols(symbol_count: usize, parser_kinds: &[ParserKind]) -> bool {
1347 symbol_count > 0
1348 && !parser_kinds.is_empty()
1349 && parser_kinds
1350 .iter()
1351 .all(|parser_kind| *parser_kind == ParserKind::Fallback)
1352}
1353
1354fn is_symbol_graph_empty_summary(summary: &str) -> bool {
1356 summary
1357 .trim()
1358 .ends_with("source file with no declarations found.")
1359}
1360
1361fn is_scanner_fallback_summary(summary: &str) -> bool {
1363 let trimmed = summary.trim_end_matches('.');
1364 let Some((_, tail)) = trimmed.rsplit_once(", ") else {
1365 return false;
1366 };
1367 let Some(number) = tail.strip_suffix(" bytes") else {
1368 return false;
1369 };
1370 !number.is_empty() && number.chars().all(|character| character.is_ascii_digit())
1371}
1372
1373pub fn search_indexed_files(
1380 store: &AtlasStore,
1381 pattern: &str,
1382 regex: bool,
1383 fuzzy: bool,
1384 case_sensitive: bool,
1385 file_pattern: Option<&str>,
1386 context_lines: usize,
1387 start_index: usize,
1388 limit: usize,
1389) -> ServiceResult<SearchReport> {
1390 search_indexed_files_with_control(
1391 store,
1392 &SearchQuery {
1393 pattern,
1394 regex,
1395 fuzzy,
1396 case_sensitive,
1397 file_pattern,
1398 context_lines,
1399 start_index,
1400 limit,
1401 retrieval_mode: SearchRetrievalMode::Lexical,
1402 },
1403 None,
1404 )
1405}
1406
1407pub fn search_indexed_files_with_control(
1420 store: &AtlasStore,
1421 query: &SearchQuery<'_>,
1422 control: Option<&IndexWorkControl>,
1423) -> ServiceResult<SearchReport> {
1424 search_indexed_files_with_bounds(store, query, control, DEFAULT_SEARCH_BOUNDS)
1425}
1426
1427fn search_indexed_files_with_bounds(
1429 store: &AtlasStore,
1430 query: &SearchQuery<'_>,
1431 control: Option<&IndexWorkControl>,
1432 bounds: SearchBounds,
1433) -> ServiceResult<SearchReport> {
1434 if query.retrieval_mode != SearchRetrievalMode::Lexical {
1435 return Err(ServiceError::SearchCapabilityUnavailable {
1436 requested_mode: query.retrieval_mode,
1437 state: SEARCH_SEMANTIC_UNAVAILABLE_STATE,
1438 guidance: SEARCH_SEMANTIC_RECOVERY,
1439 });
1440 }
1441 if query.regex && query.fuzzy {
1442 return Err(ServiceError::InvalidInput(
1443 "search cannot combine regex and fuzzy modes".to_string(),
1444 ));
1445 }
1446 if query.pattern.len() > SEARCH_MAX_PATTERN_BYTES {
1447 return Err(ServiceError::InvalidInput(format!(
1448 "search pattern cannot exceed {SEARCH_MAX_PATTERN_BYTES} UTF-8 bytes"
1449 )));
1450 }
1451 if query
1452 .file_pattern
1453 .is_some_and(|pattern| pattern.len() > SEARCH_MAX_FILE_PATTERN_BYTES)
1454 {
1455 return Err(ServiceError::InvalidInput(format!(
1456 "search file pattern cannot exceed {SEARCH_MAX_FILE_PATTERN_BYTES} UTF-8 bytes"
1457 )));
1458 }
1459 if query.context_lines > SEARCH_MAX_CONTEXT_LINES {
1460 return Err(ServiceError::InvalidInput(format!(
1461 "search context lines cannot exceed {SEARCH_MAX_CONTEXT_LINES}"
1462 )));
1463 }
1464 if query.limit > SEARCH_MAX_RESULT_ROWS {
1465 return Err(ServiceError::InvalidInput(format!(
1466 "search result limit cannot exceed {SEARCH_MAX_RESULT_ROWS}"
1467 )));
1468 }
1469 let path_matcher = build_path_matcher(query.file_pattern)?;
1470 let matcher = if query.regex {
1471 LineMatcher::Regex(
1472 RegexBuilder::new(query.pattern)
1473 .case_insensitive(!query.case_sensitive)
1474 .build()
1475 .map_err(|source| ServiceError::InvalidInput(source.to_string()))?,
1476 )
1477 } else if query.fuzzy {
1478 LineMatcher::Fuzzy {
1479 needle: normalized_search_text(query.pattern, query.case_sensitive),
1480 case_sensitive: query.case_sensitive,
1481 }
1482 } else {
1483 LineMatcher::Literal {
1484 needle: normalized_search_text(query.pattern, query.case_sensitive),
1485 case_sensitive: query.case_sensitive,
1486 }
1487 };
1488 let mut report = SearchReport {
1489 query: query.pattern.to_string(),
1490 mode: matcher.mode().to_string(),
1491 retrieval_mode: query.retrieval_mode.as_str().to_string(),
1492 source: "sqlite-file-text".to_string(),
1493 strategy: "persisted-text-fallback".to_string(),
1494 start_index: query.start_index,
1495 total: 0,
1496 observed_total: 0,
1497 total_is_complete: true,
1498 returned: 0,
1499 searched_files: 0,
1500 searched_bytes: 0,
1501 candidate_files: 0,
1502 retained_bytes: 0,
1503 truncated: false,
1504 truncation_reason: None,
1505 results: Vec::new(),
1506 };
1507 let bounded_control = control.map_or_else(
1508 || IndexWorkControl::new(IndexCancellation::new(), Some(bounds.elapsed)),
1509 |control| control.with_timeout_ceiling(bounds.elapsed),
1510 );
1511 if let Err(failure) = bounded_control.check(IndexWorkStage::TextIndex) {
1512 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1513 mark_search_truncated(&mut report, "elapsed-time-limit");
1514 return Ok(finalize_search_report(report));
1515 }
1516 return Err(DbError::from(failure).into());
1517 }
1518 if query.limit == 0 {
1519 return Ok(report);
1520 }
1521 let needed = query.start_index.saturating_add(query.limit);
1522 let path_prefix = search_path_prefix(query.file_pattern);
1523 let mut used_fts = false;
1524 if let Some(literal_token) = matcher.fts_literal_token()
1525 && store.file_text_fts_ready()?
1526 {
1527 let mut page = match store.query_file_text_fts_candidates(
1528 &FileTextFtsQuery {
1529 literal_token,
1530 path_prefix: path_prefix.as_deref(),
1531 limit: MAX_FILE_TEXT_FTS_CANDIDATES,
1532 },
1533 Some(&bounded_control),
1534 ) {
1535 Ok(page) => page,
1536 Err(error) if is_search_deadline(&error) => {
1537 mark_search_truncated(&mut report, "elapsed-time-limit");
1538 return Ok(finalize_search_report(report));
1539 }
1540 Err(error) => return Err(error.into()),
1541 };
1542 report.candidate_files = page.candidates.len();
1543 if !page.overflow {
1544 page.candidates
1545 .sort_by(|left, right| left.path.cmp(&right.path));
1546 report.strategy = "fts5-bm25-candidates-exact-verified".to_string();
1547 used_fts = true;
1548 for candidate in page.candidates {
1549 if !path_matches(&candidate.path, path_matcher.as_ref()) {
1550 continue;
1551 }
1552 if let Err(failure) = bounded_control.check(IndexWorkStage::RepositoryTraversal) {
1553 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1554 mark_search_truncated(&mut report, "elapsed-time-limit");
1555 break;
1556 }
1557 return Err(DbError::from(failure).into());
1558 }
1559 if !search_metadata_within_bounds(
1560 &mut report,
1561 candidate.byte_count,
1562 bounds.selected_files,
1563 bounds.selected_bytes,
1564 ) {
1565 break;
1566 }
1567 let text = store.load_file_text(&candidate.path)?.ok_or_else(|| {
1568 ServiceError::InvalidInput(format!(
1569 "FTS candidate {:?} has no authoritative persisted text",
1570 candidate.path
1571 ))
1572 })?;
1573 if text.byte_count != candidate.byte_count
1574 || text.line_count != candidate.line_count
1575 || text.content_hash != candidate.content_hash
1576 {
1577 return Err(ServiceError::InvalidInput(format!(
1578 "FTS candidate metadata changed for {:?}",
1579 candidate.path
1580 )));
1581 }
1582 report.searched_files += 1;
1583 report.searched_bytes += candidate.byte_count;
1584 if let Err(failure) = inspect_search_text(
1585 &mut report,
1586 &text,
1587 &matcher,
1588 query.context_lines,
1589 needed,
1590 bounds.retained_bytes,
1591 &bounded_control,
1592 ) {
1593 if matches!(failure, IndexWorkFailure::DeadlineExceeded { .. }) {
1594 mark_search_truncated(&mut report, "elapsed-time-limit");
1595 break;
1596 }
1597 return Err(DbError::from(failure).into());
1598 }
1599 if report.truncated {
1600 break;
1601 }
1602 }
1603 }
1604 }
1605 if !used_fts {
1606 let mut selected_files = 0usize;
1607 let mut selected_bytes = 0usize;
1608 let mut searched_files = 0usize;
1609 let mut searched_bytes = 0usize;
1610 let mut admission_truncation = None;
1611 let fallback_result = store.visit_file_texts_for_fallback(
1612 path_prefix.as_deref(),
1613 Some(&bounded_control),
1614 |metadata| {
1615 if !path_matches(&metadata.path, path_matcher.as_ref()) {
1616 return Ok(FileTextAdmission::Skip);
1617 }
1618 if selected_files >= bounds.selected_files {
1619 admission_truncation = Some("selected-file-limit");
1620 return Ok(FileTextAdmission::Stop);
1621 }
1622 let Some(next_bytes) = selected_bytes.checked_add(metadata.byte_count) else {
1623 admission_truncation = Some("selected-byte-limit");
1624 return Ok(FileTextAdmission::Stop);
1625 };
1626 if next_bytes > bounds.selected_bytes {
1627 admission_truncation = Some("selected-byte-limit");
1628 return Ok(FileTextAdmission::Stop);
1629 }
1630 selected_files += 1;
1631 selected_bytes = next_bytes;
1632 Ok(FileTextAdmission::Read)
1633 },
1634 |text| {
1635 searched_files += 1;
1636 searched_bytes += text.byte_count;
1637 inspect_search_text(
1638 &mut report,
1639 &text,
1640 &matcher,
1641 query.context_lines,
1642 needed,
1643 bounds.retained_bytes,
1644 &bounded_control,
1645 )
1646 .map_err(DbError::from)?;
1647 Ok(!report.truncated)
1648 },
1649 );
1650 report.searched_files = searched_files;
1651 report.searched_bytes = searched_bytes;
1652 match fallback_result {
1653 Ok(()) => {}
1654 Err(error) if is_search_deadline(&error) => {
1655 mark_search_truncated(&mut report, "elapsed-time-limit");
1656 }
1657 Err(error) => return Err(error.into()),
1658 }
1659 if let Some(reason) = admission_truncation {
1660 mark_search_truncated(&mut report, reason);
1661 }
1662 }
1663 Ok(finalize_search_report(report))
1664}
1665
1666fn finalize_search_report(mut report: SearchReport) -> SearchReport {
1668 report.returned = report.results.len();
1669 report.observed_total = report.total;
1670 report.total_is_complete = !report.truncated;
1671 report
1672}
1673
1674fn is_search_deadline(error: &DbError) -> bool {
1676 matches!(
1677 error,
1678 DbError::IndexWork(IndexWorkFailure::DeadlineExceeded { .. })
1679 )
1680}
1681
1682pub fn filter_files_by_glob(
1688 nodes: Vec<IndexedNode>,
1689 file_pattern: Option<&str>,
1690) -> ServiceResult<Vec<IndexedNode>> {
1691 let matcher = FilePathMatcher::new(file_pattern)?;
1692 Ok(nodes
1693 .into_iter()
1694 .filter(|node| node.node.kind == NodeKind::File)
1695 .filter(|node| matcher.is_match(&node.node.path))
1696 .collect())
1697}
1698
1699pub fn load_ranked_file_nodes(
1706 store: &AtlasStore,
1707 query: &str,
1708 folder: Option<&str>,
1709 file_pattern: Option<&str>,
1710 limit: usize,
1711 include_content: bool,
1712) -> ServiceResult<Vec<IndexedNode>> {
1713 let target = limit.max(1);
1714 let selected = load_ranked_file_node_candidates(
1715 store,
1716 query,
1717 folder,
1718 file_pattern,
1719 target,
1720 include_content,
1721 )?;
1722 Ok(ranked_nodes_with_reasons(store, query, selected)?
1723 .into_iter()
1724 .take(target)
1725 .map(|ranked| ranked.node)
1726 .collect())
1727}
1728
1729fn load_ranked_file_node_candidates(
1731 store: &AtlasStore,
1732 query: &str,
1733 folder: Option<&str>,
1734 file_pattern: Option<&str>,
1735 target: usize,
1736 include_content: bool,
1737) -> ServiceResult<Vec<IndexedNode>> {
1738 let matcher = FilePathMatcher::new(file_pattern)?;
1739 let candidate_target = ranked_candidate_target(query, target);
1740 let mut selected = if matcher.filters() {
1741 load_ranked_file_nodes_matching_glob(store, query, folder, &matcher, candidate_target)?
1742 } else {
1743 store.load_ranked_nodes(query, NodeKind::File, folder, candidate_target, 0)?
1744 };
1745 if include_content && !query.trim().is_empty() && selected.len() < candidate_target {
1746 append_content_ranked_file_nodes(
1747 store,
1748 query,
1749 folder,
1750 &matcher,
1751 candidate_target,
1752 &mut selected,
1753 )?;
1754 }
1755 append_paired_file_nodes(store, &matcher, candidate_target, &mut selected)?;
1756 Ok(selected)
1757}
1758
1759pub fn load_ranked_folder_nodes_with_reasons(
1765 store: &AtlasStore,
1766 query: &str,
1767 limit: usize,
1768) -> ServiceResult<Vec<RankedNode>> {
1769 let target = limit.max(1);
1770 let candidate_target = ranked_candidate_target(query, target);
1771 let selected = store.load_ranked_nodes(query, NodeKind::Folder, None, candidate_target, 0)?;
1772 let mut ranked = ranked_nodes_with_reasons(store, query, selected)?;
1773 ranked.truncate(target);
1774 Ok(ranked)
1775}
1776
1777pub fn load_ranked_file_nodes_with_reasons(
1783 store: &AtlasStore,
1784 query: &str,
1785 folder: Option<&str>,
1786 file_pattern: Option<&str>,
1787 limit: usize,
1788 include_content: bool,
1789) -> ServiceResult<Vec<RankedNode>> {
1790 let target = limit.max(1);
1791 let selected = load_ranked_file_node_candidates(
1792 store,
1793 query,
1794 folder,
1795 file_pattern,
1796 target,
1797 include_content,
1798 )?;
1799 let mut ranked = ranked_nodes_with_reasons(store, query, selected)?;
1800 ranked.truncate(target);
1801 Ok(ranked)
1802}
1803
1804pub fn build_next_report(
1810 store: &AtlasStore,
1811 query: &str,
1812 limit: Option<usize>,
1813) -> ServiceResult<NextStepReport> {
1814 let target = limit
1815 .unwrap_or(NEXT_REPORT_DEFAULT_LIMIT)
1816 .clamp(1, NEXT_REPORT_MAX_LIMIT);
1817 let folders = load_ranked_folder_nodes_with_reasons(store, query, target)?;
1818 let files = load_ranked_file_nodes_with_reasons(store, query, None, None, target, true)?;
1819 let suggestions = next_report_suggestions(query, &folders, &files);
1820 Ok(NextStepReport {
1821 query: query.to_string(),
1822 folders,
1823 files,
1824 suggestions,
1825 })
1826}
1827
1828#[derive(Debug)]
1829struct RankedEvidence {
1831 exact_path: bool,
1833 exact_name: bool,
1835 reviewed_purpose: bool,
1837 context_score: usize,
1839 reasons: Vec<String>,
1841 reason_codes: Vec<RankedReasonCode>,
1843}
1844
1845fn ranked_candidate_target(query: &str, target: usize) -> usize {
1847 if query.trim().is_empty() {
1848 target
1849 } else {
1850 target
1851 .saturating_mul(3)
1852 .clamp(target, RANKED_CANDIDATE_LIMIT)
1853 }
1854}
1855
1856fn ranked_nodes_with_reasons(
1858 store: &AtlasStore,
1859 query: &str,
1860 selected: Vec<IndexedNode>,
1861) -> ServiceResult<Vec<RankedNode>> {
1862 let terms = normalize_ranking_terms(query);
1863 let text_hit_paths = indexed_text_hit_paths(store, &selected, &terms)?;
1864 let owners = selected
1865 .iter()
1866 .map(|node| RepositoryNavigationNode {
1867 path: node.node.path.clone(),
1868 kind: node.node.kind,
1869 })
1870 .collect::<Vec<_>>();
1871 let connections = store.repository_navigation_connections(
1872 &owners,
1873 RANKED_CONNECTION_FAMILY_LIMIT,
1874 RANKED_CONNECTION_SAMPLE_LIMIT,
1875 )?;
1876 let mut connections_by_path = connections
1877 .into_iter()
1878 .map(|page| (page.path.clone(), page))
1879 .collect::<HashMap<_, _>>();
1880 let exact_query = normalize_exact_ranking_query(query);
1881 let mut scored = selected
1882 .into_iter()
1883 .enumerate()
1884 .map(|(index, node)| {
1885 let page = connections_by_path.remove(&node.node.path).ok_or_else(|| {
1886 ServiceError::InvalidInput(format!(
1887 "graph navigation batch omitted indexed path {:?}",
1888 node.node.path
1889 ))
1890 })?;
1891 let evidence =
1892 ranked_node_evidence(store, &node, &terms, &exact_query, &text_hit_paths, &page)?;
1893 let next_capability = match node.node.kind {
1894 NodeKind::Folder => NavigationNextCapability::Files,
1895 NodeKind::File if page.truncated => NavigationNextCapability::Relations,
1896 NodeKind::File => NavigationNextCapability::Summary,
1897 };
1898 Ok((
1899 index,
1900 RankedEvidence {
1901 exact_path: evidence.exact_path,
1902 exact_name: evidence.exact_name,
1903 reviewed_purpose: evidence.reviewed_purpose,
1904 context_score: evidence.context_score,
1905 reasons: Vec::new(),
1906 reason_codes: Vec::new(),
1907 },
1908 RankedNode {
1909 node,
1910 reasons: evidence.reasons,
1911 reason_codes: evidence.reason_codes,
1912 connection_counts: page.counts,
1913 connections: page.connections,
1914 connections_truncated: page.truncated,
1915 next_call: NavigationNextCall {
1916 capability: next_capability,
1917 path: owners[index].path.clone(),
1918 },
1919 },
1920 ))
1921 })
1922 .collect::<ServiceResult<Vec<_>>>()?;
1923 scored.sort_by(|left, right| {
1924 ranked_evidence_order(&left.1, &right.1)
1925 .then_with(|| left.0.cmp(&right.0))
1926 .then_with(|| left.2.node.node.path.cmp(&right.2.node.node.path))
1927 });
1928 Ok(scored.into_iter().map(|(_, _, node)| node).collect())
1929}
1930
1931fn ranked_evidence_order(left: &RankedEvidence, right: &RankedEvidence) -> std::cmp::Ordering {
1933 right
1934 .exact_path
1935 .cmp(&left.exact_path)
1936 .then_with(|| right.exact_name.cmp(&left.exact_name))
1937 .then_with(|| right.reviewed_purpose.cmp(&left.reviewed_purpose))
1938 .then_with(|| right.context_score.cmp(&left.context_score))
1939}
1940
1941fn ranked_node_evidence(
1943 store: &AtlasStore,
1944 node: &IndexedNode,
1945 terms: &[String],
1946 exact_query: &str,
1947 text_hit_paths: &HashSet<String>,
1948 connections: &RepositoryNavigationConnections,
1949) -> ServiceResult<RankedEvidence> {
1950 let normalized_path = node.node.path.replace('\\', "/").to_lowercase();
1951 let normalized_name = normalized_path
1952 .rsplit('/')
1953 .next()
1954 .unwrap_or(&normalized_path);
1955 let exact_path = !exact_query.is_empty() && normalized_path == exact_query;
1956 let exact_name = !exact_query.is_empty() && normalized_name == exact_query;
1957 let mut reviewed_purpose = false;
1958 let mut context_score = 0usize;
1959 let mut reasons = Vec::new();
1960 let mut reason_codes = Vec::new();
1961
1962 if exact_path {
1963 push_ranked_reason(&mut reasons, "exact path".to_string());
1964 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ExactPath);
1965 }
1966 if exact_name {
1967 push_ranked_reason(&mut reasons, "exact name".to_string());
1968 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ExactName);
1969 }
1970
1971 if let Some(term) = first_matching_term(&node.node.path, terms)
1972 && !exact_path
1973 {
1974 context_score = context_score.saturating_add(40);
1975 push_ranked_reason(&mut reasons, format!("path matched {term}"));
1976 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Path);
1977 }
1978 if node.purpose.agent_reviewed()
1979 && let Some(term) = node
1980 .purpose
1981 .purpose
1982 .as_deref()
1983 .and_then(|purpose| first_matching_term(purpose, terms))
1984 {
1985 reviewed_purpose = true;
1986 push_ranked_reason(&mut reasons, format!("purpose matched {term}"));
1987 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::ReviewedPurpose);
1988 }
1989 if let Some(term) = node
1990 .summary
1991 .as_deref()
1992 .and_then(|summary| first_matching_term(summary, terms))
1993 {
1994 context_score = context_score.saturating_add(20);
1995 push_ranked_reason(&mut reasons, format!("summary matched {term}"));
1996 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Summary);
1997 }
1998 if node.node.kind == NodeKind::File {
1999 if let Some((symbol_name, term)) = first_symbol_match(store, &node.node.path, terms)? {
2000 context_score = context_score.saturating_add(35);
2001 push_ranked_reason(&mut reasons, format!("symbol {symbol_name} matched {term}"));
2002 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::Symbol);
2003 }
2004 if text_hit_paths.contains(&node.node.path)
2005 && let Some(term) = indexed_text_match_term(store, &node.node.path, terms)?
2006 {
2007 context_score = context_score.saturating_add(15);
2008 push_ranked_reason(&mut reasons, format!("indexed text matched {term}"));
2009 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::IndexedText);
2010 }
2011 if let Some(reason) = paired_path_reason(store, &node.node.path)? {
2012 context_score = context_score.saturating_add(10);
2013 push_ranked_reason(&mut reasons, reason);
2014 push_ranked_reason_code(&mut reason_codes, RankedReasonCode::PairedFile);
2015 }
2016 }
2017
2018 let mut graph_context_score = 0usize;
2019 for count in &connections.counts {
2020 if count.count == 0 {
2021 continue;
2022 }
2023 graph_context_score = graph_context_score.saturating_add(2);
2024 push_ranked_reason_code(&mut reason_codes, graph_reason_code(count.kind));
2025 }
2026 for connection in &connections.connections {
2027 if ranked_connection_matches_terms(&connection.target, terms) {
2028 graph_context_score = graph_context_score.saturating_add(18);
2029 push_ranked_reason_code(&mut reason_codes, graph_reason_code(connection.kind));
2030 }
2031 }
2032 context_score = context_score.saturating_add(graph_context_score.min(32));
2033
2034 Ok(RankedEvidence {
2035 exact_path,
2036 exact_name,
2037 reviewed_purpose,
2038 context_score,
2039 reasons,
2040 reason_codes,
2041 })
2042}
2043
2044fn normalize_exact_ranking_query(query: &str) -> String {
2046 query.trim().replace('\\', "/").to_lowercase()
2047}
2048
2049fn ranked_connection_matches_terms(target: &RankedConnectionTarget, terms: &[String]) -> bool {
2051 let fields = match target {
2052 RankedConnectionTarget::Local { path, symbol } => {
2053 [Some(path.as_str()), symbol.as_deref(), None]
2054 }
2055 RankedConnectionTarget::Package {
2056 manager,
2057 name,
2058 manifest,
2059 } => [
2060 Some(manager.as_str()),
2061 Some(name.as_str()),
2062 Some(manifest.as_str()),
2063 ],
2064 RankedConnectionTarget::External { system, identity } => {
2065 [Some(system.as_str()), Some(identity.as_str()), None]
2066 }
2067 RankedConnectionTarget::Unresolved { reference } => [Some(reference.as_str()), None, None],
2068 };
2069 fields
2070 .into_iter()
2071 .flatten()
2072 .any(|field| first_matching_term(field, terms).is_some())
2073}
2074
2075const fn graph_reason_code(kind: RankedConnectionKind) -> RankedReasonCode {
2077 match kind {
2078 RankedConnectionKind::Package => RankedReasonCode::GraphPackage,
2079 RankedConnectionKind::Import => RankedReasonCode::GraphImport,
2080 RankedConnectionKind::Call => RankedReasonCode::GraphCall,
2081 RankedConnectionKind::Reference => RankedReasonCode::GraphReference,
2082 RankedConnectionKind::Test => RankedReasonCode::GraphTest,
2083 RankedConnectionKind::Route => RankedReasonCode::GraphRoute,
2084 RankedConnectionKind::Config => RankedReasonCode::GraphConfig,
2085 }
2086}
2087
2088fn normalize_ranking_terms(query: &str) -> Vec<String> {
2090 let mut terms = query
2091 .split(|character: char| !character.is_alphanumeric())
2092 .filter(|term| !term.is_empty())
2093 .map(str::to_lowercase)
2094 .collect::<Vec<_>>();
2095 terms.sort();
2096 terms.dedup();
2097 terms
2098}
2099
2100fn first_matching_term(text: &str, terms: &[String]) -> Option<String> {
2102 let haystack = normalized_search_text(text, false);
2103 terms
2104 .iter()
2105 .find(|term| haystack.contains(term.as_str()))
2106 .cloned()
2107}
2108
2109fn push_ranked_reason(reasons: &mut Vec<String>, reason: String) {
2111 if reasons.len() < RANKED_REASON_LIMIT && !reasons.contains(&reason) {
2112 reasons.push(reason);
2113 }
2114}
2115
2116fn push_ranked_reason_code(codes: &mut Vec<RankedReasonCode>, code: RankedReasonCode) {
2118 if !codes.contains(&code) {
2119 codes.push(code);
2120 }
2121}
2122
2123fn indexed_text_hit_paths(
2125 store: &AtlasStore,
2126 selected: &[IndexedNode],
2127 terms: &[String],
2128) -> ServiceResult<HashSet<String>> {
2129 let mut hits = HashSet::new();
2130 if terms.is_empty() {
2131 return Ok(hits);
2132 }
2133 for node in selected
2134 .iter()
2135 .filter(|node| node.node.kind == NodeKind::File)
2136 {
2137 if indexed_text_match_term(store, &node.node.path, terms)?.is_some() {
2138 hits.insert(node.node.path.clone());
2139 }
2140 }
2141 Ok(hits)
2142}
2143
2144fn indexed_text_match_term(
2146 store: &AtlasStore,
2147 path: &str,
2148 terms: &[String],
2149) -> ServiceResult<Option<String>> {
2150 let Some(text) = store.load_file_text(path)? else {
2151 return Ok(None);
2152 };
2153 Ok(first_matching_term(&text.content, terms))
2154}
2155
2156fn first_symbol_match(
2158 store: &AtlasStore,
2159 path: &str,
2160 terms: &[String],
2161) -> ServiceResult<Option<(String, String)>> {
2162 const RANKING_SYMBOL_KINDS: &[SymbolKind] = &[
2163 SymbolKind::Function,
2164 SymbolKind::Method,
2165 SymbolKind::Class,
2166 SymbolKind::Struct,
2167 SymbolKind::Enum,
2168 SymbolKind::Trait,
2169 SymbolKind::Interface,
2170 SymbolKind::Type,
2171 SymbolKind::Module,
2172 SymbolKind::Value,
2173 ];
2174 for symbol in store.load_symbols_by_kinds(path, RANKING_SYMBOL_KINDS, 50)? {
2175 if let Some(term) = first_matching_term(&symbol.name, terms)
2176 .or_else(|| first_matching_term(&symbol.signature, terms))
2177 {
2178 return Ok(Some((symbol.name, term)));
2179 }
2180 }
2181 Ok(None)
2182}
2183
2184fn append_paired_file_nodes(
2186 store: &AtlasStore,
2187 matcher: &FilePathMatcher,
2188 target: usize,
2189 selected: &mut Vec<IndexedNode>,
2190) -> ServiceResult<()> {
2191 if selected.len() >= target {
2192 return Ok(());
2193 }
2194 let mut seen = selected
2195 .iter()
2196 .map(|node| node.node.path.clone())
2197 .collect::<HashSet<_>>();
2198 let seed_paths = selected
2199 .iter()
2200 .map(|node| node.node.path.clone())
2201 .collect::<Vec<_>>();
2202 for path in seed_paths {
2203 for candidate in paired_path_candidates(&path) {
2204 if selected.len() >= target {
2205 return Ok(());
2206 }
2207 if seen.contains(&candidate) || !matcher.is_match(&candidate) {
2208 continue;
2209 }
2210 if let Some(node) = store.load_node_by_path(&candidate)? {
2211 seen.insert(candidate);
2212 selected.push(node);
2213 }
2214 }
2215 }
2216 Ok(())
2217}
2218
2219fn paired_path_reason(store: &AtlasStore, path: &str) -> ServiceResult<Option<String>> {
2221 for candidate in paired_path_candidates(path) {
2222 if store.load_node_by_path(&candidate)?.is_some() {
2223 let relation = if is_test_path(path) {
2224 "paired source file"
2225 } else {
2226 "paired test file"
2227 };
2228 return Ok(Some(format!("{relation} {candidate}")));
2229 }
2230 }
2231 Ok(None)
2232}
2233
2234fn paired_path_candidates(path: &str) -> Vec<String> {
2236 let Some((stem_path, extension)) = path.rsplit_once('.') else {
2237 return Vec::new();
2238 };
2239 let extension = format!(".{extension}");
2240 let file_stem = stem_path.rsplit('/').next().unwrap_or(stem_path);
2241 let mut candidates = Vec::new();
2242 if let Some(source_name) = file_stem.strip_suffix("_test") {
2243 let prefix = stem_path
2244 .strip_suffix(file_stem)
2245 .unwrap_or("")
2246 .trim_end_matches('/');
2247 candidates.push(join_repo_path(prefix, &format!("{source_name}{extension}")));
2248 } else if let Some(source_name) = file_stem.strip_suffix(".test") {
2249 let prefix = stem_path
2250 .strip_suffix(file_stem)
2251 .unwrap_or("")
2252 .trim_end_matches('/');
2253 candidates.push(join_repo_path(prefix, &format!("{source_name}{extension}")));
2254 }
2255 if let Some(test_name) = stem_path.strip_prefix("tests/") {
2256 candidates.push(format!("src/{test_name}{extension}"));
2257 } else if let Some(source_name) = stem_path.strip_prefix("src/") {
2258 candidates.push(format!("tests/{source_name}{extension}"));
2259 candidates.push(format!("src/{source_name}_test{extension}"));
2260 } else {
2261 candidates.push(format!("tests/{file_stem}{extension}"));
2262 }
2263 candidates.sort();
2264 candidates.dedup();
2265 candidates
2266}
2267
2268fn is_test_path(path: &str) -> bool {
2270 path.starts_with("tests/")
2271 || path.contains("/tests/")
2272 || path.contains("_test.")
2273 || path.contains(".test.")
2274}
2275
2276fn join_repo_path(prefix: &str, leaf: &str) -> String {
2278 if prefix.is_empty() {
2279 leaf.to_string()
2280 } else {
2281 format!("{prefix}/{leaf}")
2282 }
2283}
2284
2285fn next_report_suggestions(
2287 query: &str,
2288 folders: &[RankedNode],
2289 files: &[RankedNode],
2290) -> Vec<String> {
2291 let mut suggestions = Vec::new();
2292 if let Some(file) = files.first() {
2293 let path = quoted_command_arg(&file.node.node.path);
2294 suggestions.push(format!("projectatlas summary {path} --limit 25"));
2295 suggestions.push(format!("projectatlas outline {path}"));
2296 }
2297 if let Some(folder) = folders.first() {
2298 let query_arg = quoted_command_arg(query);
2299 let folder_arg = quoted_command_arg(&folder.node.node.path);
2300 suggestions.push(format!(
2301 "projectatlas files {query_arg} --folder {folder_arg} --limit 5"
2302 ));
2303 }
2304 if !query.trim().is_empty() {
2305 let query_arg = quoted_command_arg(query);
2306 suggestions.push(format!(
2307 "projectatlas search {query_arg} --file-pattern **/* --context-lines 2"
2308 ));
2309 }
2310 suggestions.truncate(4);
2311 suggestions
2312}
2313
2314fn quoted_command_arg(value: &str) -> String {
2316 if value.is_empty() {
2317 "\"\"".to_string()
2318 } else if value
2319 .chars()
2320 .any(|character| character.is_whitespace() || matches!(character, '"' | '\''))
2321 {
2322 format!("\"{}\"", value.replace('"', "\\\""))
2323 } else {
2324 value.to_string()
2325 }
2326}
2327
2328fn load_ranked_file_nodes_matching_glob(
2330 store: &AtlasStore,
2331 query: &str,
2332 folder: Option<&str>,
2333 matcher: &FilePathMatcher,
2334 target: usize,
2335) -> ServiceResult<Vec<IndexedNode>> {
2336 if !matcher.filters() {
2337 return Ok(store.load_ranked_nodes(query, NodeKind::File, folder, target, 0)?);
2338 }
2339 let batch_size = target.saturating_mul(20).clamp(50, 500);
2340 let mut offset = 0usize;
2341 let mut selected = Vec::new();
2342 loop {
2343 let batch = store.load_ranked_nodes(query, NodeKind::File, folder, batch_size, offset)?;
2344 if batch.is_empty() {
2345 break;
2346 }
2347 offset = offset.saturating_add(batch.len());
2348 for node in batch {
2349 if matcher.is_match(&node.node.path) {
2350 selected.push(node);
2351 if selected.len() >= target {
2352 return Ok(selected);
2353 }
2354 }
2355 }
2356 }
2357 Ok(selected)
2358}
2359
2360fn append_content_ranked_file_nodes(
2362 store: &AtlasStore,
2363 query: &str,
2364 folder: Option<&str>,
2365 matcher: &FilePathMatcher,
2366 target: usize,
2367 selected: &mut Vec<IndexedNode>,
2368) -> ServiceResult<()> {
2369 let terms = normalize_ranking_terms(query);
2370 if terms.is_empty() {
2371 return Ok(());
2372 }
2373 let mut seen = selected
2374 .iter()
2375 .map(|node| node.node.path.clone())
2376 .collect::<HashSet<_>>();
2377 store.visit_file_texts_for_search(None, false, |text| {
2378 if selected.len() >= target {
2379 return Ok(false);
2380 }
2381 let indexed_text = normalized_search_text(&text.content, false);
2382 if !seen.contains(&text.path)
2383 && path_is_inside_folder(&text.path, folder)
2384 && matcher.is_match(&text.path)
2385 && terms.iter().any(|term| indexed_text.contains(term))
2386 && let Some(node) = store.load_node_by_path(&text.path)?
2387 {
2388 seen.insert(text.path);
2389 selected.push(node);
2390 }
2391 Ok(selected.len() < target)
2392 })?;
2393 Ok(())
2394}
2395
2396fn path_is_inside_folder(path: &str, folder: Option<&str>) -> bool {
2398 let Some(folder) = folder
2399 .map(|folder| folder.trim_matches('/').trim_matches('\\'))
2400 .filter(|folder| !folder.is_empty() && *folder != ".")
2401 else {
2402 return true;
2403 };
2404 let folder = folder.replace('\\', "/");
2405 path == folder
2406 || path
2407 .strip_prefix(&folder)
2408 .is_some_and(|tail| tail.starts_with('/'))
2409}
2410
2411pub fn file_path_matches_glob(path: &str, file_pattern: Option<&str>) -> ServiceResult<bool> {
2417 Ok(FilePathMatcher::new(file_pattern)?.is_match(path))
2418}
2419
2420pub struct FilePathMatcher {
2422 matcher: Option<GlobSet>,
2424}
2425
2426impl FilePathMatcher {
2427 pub fn new(file_pattern: Option<&str>) -> ServiceResult<Self> {
2433 Ok(Self {
2434 matcher: build_path_matcher(file_pattern)?,
2435 })
2436 }
2437
2438 #[must_use]
2440 pub fn filters(&self) -> bool {
2441 self.matcher.is_some()
2442 }
2443
2444 #[must_use]
2446 pub fn is_match(&self, path: &str) -> bool {
2447 path_matches(path, self.matcher.as_ref())
2448 }
2449}
2450
2451fn indexed_text_lines(text: &IndexedFileText) -> Vec<&str> {
2453 text.content.lines().collect()
2454}
2455
2456pub fn read_indexed_code_slice(
2463 store: &AtlasStore,
2464 file: &Path,
2465 start_line: usize,
2466 end_line: Option<usize>,
2467) -> ServiceResult<CodeSlice> {
2468 let file_key = validated_indexed_file_key(store, file)?;
2469 let native_file = indexed_native_path(store, &file_key)?;
2470 let content = read_file_content(&native_file)?;
2471 read_indexed_code_slice_from_source(store, file, start_line, end_line, &content)
2472}
2473
2474pub fn read_indexed_code_slice_from_source(
2480 store: &AtlasStore,
2481 file: &Path,
2482 start_line: usize,
2483 end_line: Option<usize>,
2484 source: &str,
2485) -> ServiceResult<CodeSlice> {
2486 read_indexed_code_slice_from_source_bounded(
2487 store,
2488 file,
2489 start_line,
2490 end_line,
2491 source,
2492 CodeSliceBudget::default(),
2493 )
2494 .map(|draft| draft.slice)
2495}
2496
2497pub fn read_indexed_code_slice_from_source_bounded(
2504 store: &AtlasStore,
2505 file: &Path,
2506 start_line: usize,
2507 end_line: Option<usize>,
2508 source: &str,
2509 output_budget: CodeSliceBudget,
2510) -> ServiceResult<CodeSliceDraft> {
2511 let file_key = validated_indexed_file_key(store, file)?;
2512 read_code_slice(source, &file_key, start_line, end_line, output_budget)
2513}
2514
2515pub fn read_symbol_slice(
2522 store: &AtlasStore,
2523 file: &Path,
2524 selector: &SymbolSliceSelector<'_>,
2525) -> ServiceResult<CodeSlice> {
2526 let file_key = validated_indexed_file_key(store, file)?;
2527 let native_file = indexed_native_path(store, &file_key)?;
2528 let content = read_file_content(&native_file)?;
2529 read_symbol_slice_from_source(store, file, selector, &content)
2530}
2531
2532pub fn read_symbol_slice_from_source(
2539 store: &AtlasStore,
2540 file: &Path,
2541 selector: &SymbolSliceSelector<'_>,
2542 source: &str,
2543) -> ServiceResult<CodeSlice> {
2544 read_symbol_slice_from_source_bounded(store, file, selector, source, CodeSliceBudget::default())
2545 .map(|draft| draft.slice)
2546}
2547
2548pub fn read_symbol_slice_from_source_bounded(
2556 store: &AtlasStore,
2557 file: &Path,
2558 selector: &SymbolSliceSelector<'_>,
2559 source: &str,
2560 output_budget: CodeSliceBudget,
2561) -> ServiceResult<CodeSliceDraft> {
2562 let file_key = validated_indexed_file_key(store, file)?;
2563 let requested_kind = selector.kind.map(parse_symbol_kind).transpose()?;
2564 let mut symbols = store.load_symbols_by_exact_file_and_name(&file_key, selector.name)?;
2565 if let Some(parent) = selector.parent {
2566 symbols.retain(|symbol| symbol.parent.as_deref() == Some(parent));
2567 }
2568 if let Some(kind) = requested_kind {
2569 symbols.retain(|symbol| symbol.kind == kind);
2570 }
2571 if let Some(signature) = selector.signature {
2572 symbols.retain(|symbol| symbol.signature == signature);
2573 }
2574 if let Some(line) = selector.line {
2575 symbols.retain(|symbol| symbol.line_start <= line && line <= symbol.line_end);
2576 }
2577 let symbol = match symbols.as_slice() {
2578 [symbol] => symbol,
2579 [] => {
2580 return Err(ServiceError::InvalidInput(format!(
2581 "symbol {:?} was not found in indexed file {file_key}",
2582 selector.name
2583 )));
2584 }
2585 _ => {
2586 return Err(ServiceError::InvalidInput(format!(
2587 "symbol {:?} is ambiguous in {file_key}; pass symbol_parent, symbol_kind, symbol_signature, or symbol_line. candidates: {}",
2588 selector.name,
2589 describe_symbol_candidates(&symbols)
2590 )));
2591 }
2592 };
2593 read_code_slice(
2594 source,
2595 &file_key,
2596 symbol.line_start,
2597 Some(symbol.line_end),
2598 output_budget,
2599 )
2600}
2601
2602fn validated_file_key(file: &Path) -> ServiceResult<String> {
2604 validated_repo_file_key(file).map_err(|source| ServiceError::InvalidInput(source.to_string()))
2605}
2606
2607fn validated_indexed_file_key(store: &AtlasStore, file: &Path) -> ServiceResult<String> {
2609 let file_key = validated_file_key(file)?;
2610 let indexed = store
2611 .load_node_by_path(&file_key)?
2612 .ok_or_else(|| ServiceError::InvalidInput(format!("file {file_key:?} is not indexed")))?;
2613 if indexed.node.kind != NodeKind::File {
2614 return Err(ServiceError::InvalidInput(format!(
2615 "path {file_key:?} is not an indexed file"
2616 )));
2617 }
2618 Ok(file_key)
2619}
2620
2621fn indexed_project_root(store: &AtlasStore) -> ServiceResult<PathBuf> {
2623 store.project_root()?.map(PathBuf::from).ok_or_else(|| {
2624 ServiceError::InvalidInput(
2625 "indexed project root is missing; run projectatlas scan <project-root> first"
2626 .to_string(),
2627 )
2628 })
2629}
2630
2631fn indexed_native_path(store: &AtlasStore, file_key: &str) -> ServiceResult<PathBuf> {
2633 Ok(indexed_project_root(store)?.join(repo_path_to_native(file_key)))
2634}
2635
2636fn read_file_content(file: &Path) -> ServiceResult<String> {
2638 fs::read_to_string(file).map_err(|source| ServiceError::Io {
2639 path: file.to_path_buf(),
2640 source,
2641 })
2642}
2643
2644fn build_path_matcher(pattern: Option<&str>) -> ServiceResult<Option<GlobSet>> {
2646 let Some(pattern) = pattern else {
2647 return Ok(None);
2648 };
2649 let normalized = pattern.trim().replace('\\', "/");
2650 if normalized.is_empty() || normalized == "*" {
2651 return Ok(None);
2652 }
2653 let mut builder = GlobSetBuilder::new();
2654 add_glob(&mut builder, &normalized)?;
2655 if !normalized.contains('/') {
2656 add_glob(&mut builder, &format!("**/{normalized}"))?;
2657 }
2658 builder
2659 .build()
2660 .map(Some)
2661 .map_err(|source| ServiceError::InvalidInput(source.to_string()))
2662}
2663
2664fn search_path_prefix(pattern: Option<&str>) -> Option<String> {
2666 let normalized = pattern?.trim().replace('\\', "/");
2667 if normalized.is_empty() || normalized == "*" {
2668 return None;
2669 }
2670 let wildcard = normalized
2671 .char_indices()
2672 .find_map(|(index, character)| "*?[{".contains(character).then_some(index));
2673 let prefix = wildcard.map_or(normalized.as_str(), |index| &normalized[..index]);
2674 let prefix = if wildcard.is_some() {
2675 prefix.rsplit_once('/').map_or("", |(parent, _)| parent)
2676 } else {
2677 prefix.trim_end_matches('/')
2678 };
2679 (!prefix.is_empty()).then(|| prefix.to_string())
2680}
2681
2682fn search_metadata_within_bounds(
2684 report: &mut SearchReport,
2685 byte_count: usize,
2686 max_files: usize,
2687 max_bytes: usize,
2688) -> bool {
2689 if report.searched_files >= max_files {
2690 mark_search_truncated(report, "selected-file-limit");
2691 return false;
2692 }
2693 let Some(next_bytes) = report.searched_bytes.checked_add(byte_count) else {
2694 mark_search_truncated(report, "selected-byte-limit");
2695 return false;
2696 };
2697 if next_bytes > max_bytes {
2698 mark_search_truncated(report, "selected-byte-limit");
2699 return false;
2700 }
2701 true
2702}
2703
2704fn mark_search_truncated(report: &mut SearchReport, reason: &'static str) {
2706 report.truncated = true;
2707 if report.truncation_reason.is_none() {
2708 report.truncation_reason = Some(reason.to_string());
2709 }
2710}
2711
2712fn inspect_search_text(
2714 report: &mut SearchReport,
2715 text: &IndexedFileText,
2716 matcher: &LineMatcher,
2717 context_lines: usize,
2718 needed: usize,
2719 max_retained_bytes: usize,
2720 control: &IndexWorkControl,
2721) -> Result<(), IndexWorkFailure> {
2722 let lines = indexed_text_lines(text);
2723 append_line_matches(
2724 report,
2725 &text.path,
2726 &lines,
2727 matcher,
2728 context_lines,
2729 needed,
2730 max_retained_bytes,
2731 control,
2732 )
2733}
2734
2735fn add_glob(builder: &mut GlobSetBuilder, pattern: &str) -> ServiceResult<()> {
2737 let glob = GlobBuilder::new(pattern)
2738 .literal_separator(true)
2739 .build()
2740 .map_err(|source| ServiceError::InvalidInput(source.to_string()))?;
2741 builder.add(glob);
2742 Ok(())
2743}
2744
2745fn path_matches(path: &str, matcher: Option<&GlobSet>) -> bool {
2747 matcher.is_none_or(|matcher| matcher.is_match(path))
2748}
2749
2750enum LineMatcher {
2752 Regex(regex::Regex),
2754 Literal {
2756 needle: String,
2758 case_sensitive: bool,
2760 },
2761 Fuzzy {
2763 needle: String,
2765 case_sensitive: bool,
2767 },
2768}
2769
2770impl LineMatcher {
2771 fn mode(&self) -> &'static str {
2773 match self {
2774 Self::Regex(_) => "regex",
2775 Self::Literal { .. } => "literal",
2776 Self::Fuzzy { .. } => "fuzzy",
2777 }
2778 }
2779
2780 fn fts_literal_token(&self) -> Option<&str> {
2782 match self {
2783 Self::Literal { needle, .. }
2784 if needle.len() >= 3
2785 && needle
2786 .chars()
2787 .all(|character| character.is_ascii_alphanumeric()) =>
2788 {
2789 Some(needle.as_str())
2790 }
2791 Self::Regex(_) | Self::Fuzzy { .. } | Self::Literal { .. } => None,
2792 }
2793 }
2794
2795 fn is_match(&self, line: &str) -> bool {
2797 match self {
2798 Self::Regex(regex) => regex.is_match(line),
2799 Self::Literal {
2800 needle,
2801 case_sensitive,
2802 } => normalized_search_text(line, *case_sensitive).contains(needle),
2803 Self::Fuzzy {
2804 needle,
2805 case_sensitive,
2806 } => fuzzy_subsequence_matches(needle, &normalized_search_text(line, *case_sensitive)),
2807 }
2808 }
2809}
2810
2811fn append_line_matches(
2813 report: &mut SearchReport,
2814 path: &str,
2815 lines: &[&str],
2816 matcher: &LineMatcher,
2817 context_lines: usize,
2818 needed: usize,
2819 max_retained_bytes: usize,
2820 control: &IndexWorkControl,
2821) -> Result<(), IndexWorkFailure> {
2822 let result_limit = needed.saturating_sub(report.start_index);
2823 for (index, line) in lines.iter().enumerate() {
2824 control.check(IndexWorkStage::TextIndex)?;
2825 if !matcher.is_match(line) {
2826 continue;
2827 }
2828 report.total += 1;
2829 if report.total <= report.start_index {
2830 continue;
2831 }
2832 if report.results.len() >= result_limit {
2833 mark_search_truncated(report, "result-limit");
2834 control.check(IndexWorkStage::TextIndex)?;
2835 return Ok(());
2836 }
2837 let row = SearchMatch {
2838 path: path.to_string(),
2839 line: index + 1,
2840 context_before: context_before(lines, index, context_lines),
2841 text: (*line).to_string(),
2842 context_after: context_after(lines, index, context_lines),
2843 };
2844 let retained_bytes = row
2845 .path
2846 .len()
2847 .saturating_add(row.text.len())
2848 .saturating_add(
2849 row.context_before
2850 .iter()
2851 .chain(&row.context_after)
2852 .map(String::len)
2853 .sum::<usize>(),
2854 );
2855 let Some(next_retained_bytes) = report.retained_bytes.checked_add(retained_bytes) else {
2856 mark_search_truncated(report, "retained-byte-limit");
2857 control.check(IndexWorkStage::TextIndex)?;
2858 return Ok(());
2859 };
2860 if next_retained_bytes > max_retained_bytes {
2861 mark_search_truncated(report, "retained-byte-limit");
2862 control.check(IndexWorkStage::TextIndex)?;
2863 return Ok(());
2864 }
2865 report.retained_bytes = next_retained_bytes;
2866 report.results.push(row);
2867 if report.results.len() >= result_limit {
2868 mark_search_truncated(report, "result-limit");
2869 control.check(IndexWorkStage::TextIndex)?;
2870 return Ok(());
2871 }
2872 }
2873 control.check(IndexWorkStage::TextIndex)
2874}
2875
2876fn normalized_search_text(text: &str, case_sensitive: bool) -> String {
2878 if case_sensitive {
2879 text.to_string()
2880 } else {
2881 text.to_ascii_lowercase()
2882 }
2883}
2884
2885fn fuzzy_subsequence_matches(needle: &str, candidate: &str) -> bool {
2887 if needle.is_empty() {
2888 return true;
2889 }
2890 let mut needle = needle.chars();
2891 let Some(mut expected) = needle.next() else {
2892 return true;
2893 };
2894 for character in candidate.chars() {
2895 if character == expected {
2896 let Some(next) = needle.next() else {
2897 return true;
2898 };
2899 expected = next;
2900 }
2901 }
2902 false
2903}
2904
2905fn context_before(lines: &[&str], index: usize, context_lines: usize) -> Vec<String> {
2907 let start = index.saturating_sub(context_lines);
2908 lines[start..index]
2909 .iter()
2910 .map(|line| (*line).to_string())
2911 .collect()
2912}
2913
2914fn context_after(lines: &[&str], index: usize, context_lines: usize) -> Vec<String> {
2916 let start = index.saturating_add(1);
2917 let end = lines.len().min(start.saturating_add(context_lines));
2918 lines[start..end]
2919 .iter()
2920 .map(|line| (*line).to_string())
2921 .collect()
2922}
2923
2924fn read_code_slice(
2926 content: &str,
2927 file_key: &str,
2928 start_line: usize,
2929 end_line: Option<usize>,
2930 output_budget: CodeSliceBudget,
2931) -> ServiceResult<CodeSliceDraft> {
2932 if start_line == 0 {
2933 return Err(ServiceError::InvalidInput(
2934 "start-line must be one or greater".to_string(),
2935 ));
2936 }
2937 let requested_end_line = end_line.unwrap_or(start_line);
2938 if requested_end_line < start_line {
2939 return Err(ServiceError::InvalidInput(
2940 "end-line must be greater than or equal to start-line".to_string(),
2941 ));
2942 }
2943 let mut line_count = 0usize;
2944 let mut offset = 0usize;
2945 let mut selected_start = None;
2946 let mut selected_end = None;
2947 for line in content.split_inclusive('\n') {
2948 line_count = line_count.saturating_add(1);
2949 let line_start = offset;
2950 let line_end_with_terminator = offset.checked_add(line.len()).ok_or_else(|| {
2951 ServiceError::InvalidInput("slice source byte offset overflowed".to_string())
2952 })?;
2953 let line_end = if line.ends_with("\r\n") {
2954 line_end_with_terminator - 2
2955 } else if line.ends_with('\n') {
2956 line_end_with_terminator - 1
2957 } else {
2958 line_end_with_terminator
2959 };
2960 if line_count == start_line {
2961 selected_start = Some(line_start);
2962 }
2963 if line_count >= start_line && line_count <= requested_end_line {
2964 selected_end = Some(line_end);
2965 }
2966 offset = line_end_with_terminator;
2967 }
2968 if start_line > line_count {
2969 return Err(ServiceError::InvalidInput(format!(
2970 "start-line {start_line} exceeds file line count {line_count}"
2971 )));
2972 }
2973 let end_index = requested_end_line.min(line_count);
2974 let selected_start = selected_start
2975 .ok_or_else(|| ServiceError::InvalidInput("slice start byte was not found".to_string()))?;
2976 let selected_end = selected_end
2977 .ok_or_else(|| ServiceError::InvalidInput("slice end byte was not found".to_string()))?;
2978 let content_bytes = selected_end.checked_sub(selected_start).ok_or_else(|| {
2979 ServiceError::InvalidInput("slice content byte range was invalid".to_string())
2980 })?;
2981 if content_bytes > output_budget.output_bytes() as usize {
2982 return Err(ServiceError::InvalidInput(format!(
2983 "verbatim slice content exceeds the requested {}-byte output ceiling; narrow the line or symbol range or raise output-bytes",
2984 output_budget.output_bytes()
2985 )));
2986 }
2987 let content = content[selected_start..selected_end].to_string();
2988 Ok(CodeSliceDraft {
2989 slice: CodeSlice {
2990 path: file_key.to_string(),
2991 start_line,
2992 end_line: end_index,
2993 line_count,
2994 estimated_tokens: estimate_tokens(&content),
2995 content,
2996 },
2997 output_budget,
2998 })
2999}
3000
3001pub fn parse_symbol_kind(kind: &str) -> ServiceResult<SymbolKind> {
3007 let normalized = kind.trim().to_ascii_lowercase();
3008 let parsed = SymbolKind::from_db(&normalized);
3009 if parsed == SymbolKind::Unknown && normalized != "unknown" {
3010 return Err(ServiceError::InvalidInput(format!(
3011 "unsupported symbol kind {kind:?}"
3012 )));
3013 }
3014 Ok(parsed)
3015}
3016
3017fn describe_symbol_candidates(symbols: &[CodeSymbol]) -> String {
3019 symbols
3020 .iter()
3021 .map(|symbol| {
3022 format!(
3023 "{} parent={} kind={} lines={}-{}",
3024 symbol.name,
3025 symbol.parent.as_deref().unwrap_or(""),
3026 symbol.kind,
3027 symbol.line_start,
3028 symbol.line_end
3029 )
3030 })
3031 .collect::<Vec<_>>()
3032 .join("; ")
3033}
3034
3035fn line_count_from_content(content: &str) -> usize {
3037 content.lines().count()
3038}
3039
3040fn symbol_names(symbols: &[CodeSymbol]) -> Vec<String> {
3042 let mut names = symbols
3043 .iter()
3044 .map(|symbol| symbol.name.clone())
3045 .collect::<Vec<_>>();
3046 names.sort();
3047 names.dedup();
3048 names
3049}
3050
3051fn metadata_symbol_kinds() -> [SymbolKind; 3] {
3053 [
3054 SymbolKind::Package,
3055 SymbolKind::Workspace,
3056 SymbolKind::Module,
3057 ]
3058}
3059
3060fn type_symbol_kinds() -> [SymbolKind; 7] {
3062 [
3063 SymbolKind::Struct,
3064 SymbolKind::Enum,
3065 SymbolKind::Trait,
3066 SymbolKind::Interface,
3067 SymbolKind::Type,
3068 SymbolKind::Package,
3069 SymbolKind::Workspace,
3070 ]
3071}
3072
3073fn summarized_symbol_set(
3075 functions: &[CodeSymbol],
3076 methods: &[CodeSymbol],
3077 classes: &[CodeSymbol],
3078 types: &[CodeSymbol],
3079) -> Vec<CodeSymbol> {
3080 functions
3081 .iter()
3082 .chain(methods)
3083 .chain(classes)
3084 .chain(types)
3085 .cloned()
3086 .collect()
3087}
3088
3089fn caller_target_names(symbols: &[CodeSymbol], import_aliases: &ImportAliasMap) -> Vec<String> {
3091 let mut targets = HashSet::new();
3092 for symbol in symbols {
3093 targets.insert(symbol.name.clone());
3094 for alias in symbol_target_aliases(symbol) {
3095 targets.insert(alias);
3096 }
3097 }
3098 for alias in import_aliases.values().flatten() {
3099 targets.insert(alias.target_name.clone());
3100 }
3101 let mut values = targets.into_iter().collect::<Vec<_>>();
3102 values.sort();
3103 values
3104}
3105
3106fn called_by_map(
3108 symbols: &[CodeSymbol],
3109 relations: &[SymbolRelation],
3110 name_counts: &HashMap<String, usize>,
3111 alias_counts: &HashMap<String, usize>,
3112 import_aliases: &ImportAliasMap,
3113) -> HashMap<String, Vec<String>> {
3114 let mut map: HashMap<String, Vec<String>> = HashMap::new();
3115 for symbol in symbols {
3116 let symbol_key = symbol_summary_key(symbol);
3117 for relation in relations.iter().filter(|relation| {
3118 relation_matches_symbol(relation, symbol, name_counts, alias_counts, import_aliases)
3119 }) {
3120 let caller = caller_reference(relation);
3121 let callers = map.entry(symbol_key.clone()).or_default();
3122 if !callers.iter().any(|existing| existing == &caller) {
3123 callers.push(caller);
3124 }
3125 }
3126 }
3127 for callers in map.values_mut() {
3128 callers.sort();
3129 callers.truncate(CALLERS_PER_SYMBOL_LIMIT);
3130 }
3131 map
3132}
3133
3134fn relation_matches_symbol(
3136 relation: &SymbolRelation,
3137 symbol: &CodeSymbol,
3138 name_counts: &HashMap<String, usize>,
3139 alias_counts: &HashMap<String, usize>,
3140 import_aliases: &ImportAliasMap,
3141) -> bool {
3142 if relation.kind != RelationKind::Calls {
3143 return false;
3144 }
3145 let target = relation.target_name.trim();
3146 if target == symbol.name
3147 && (relation.path == symbol.path
3148 || name_counts.get(&symbol.name).copied().unwrap_or(0) <= 1)
3149 {
3150 return true;
3151 }
3152 if symbol_target_aliases(symbol)
3153 .iter()
3154 .any(|alias| alias == target && alias_counts.get(alias).copied().unwrap_or(0) <= 1)
3155 {
3156 return true;
3157 }
3158 import_aliases
3159 .get(&symbol_summary_key(symbol))
3160 .is_some_and(|aliases| {
3161 aliases.iter().any(|alias| {
3162 alias.caller_path == relation.path && alias.target_name == relation.target_name
3163 })
3164 })
3165}
3166
3167fn symbol_alias_counts(symbols: &[CodeSymbol]) -> HashMap<String, usize> {
3169 let mut counts = HashMap::new();
3170 for alias in symbols.iter().flat_map(symbol_target_aliases) {
3171 *counts.entry(alias).or_insert(0) += 1;
3172 }
3173 counts
3174}
3175
3176fn symbol_target_aliases(symbol: &CodeSymbol) -> Vec<String> {
3178 let mut aliases = HashSet::new();
3179 let modules = module_aliases_for_path(&symbol.path);
3180 for module in &modules {
3181 aliases.insert(format!("{module}::{}", symbol.name));
3182 aliases.insert(format!("{module}.{}", symbol.name));
3183 aliases.insert(format!("crate::{module}::{}", symbol.name));
3184 aliases.insert(format!("crate.{module}.{}", symbol.name));
3185 }
3186 if modules.is_empty() {
3187 aliases.insert(format!("crate::{}", symbol.name));
3188 aliases.insert(format!("self::{}", symbol.name));
3189 }
3190 let mut values = aliases.into_iter().collect::<Vec<_>>();
3191 values.sort();
3192 values
3193}
3194
3195fn symbol_summary_key(symbol: &CodeSymbol) -> String {
3197 format!("{}\0{}\0{}", symbol.path, symbol.name, symbol.line_start)
3198}
3199
3200fn caller_reference(relation: &SymbolRelation) -> String {
3202 format!("{}::{}", relation.path, relation.source_name)
3203}
3204
3205fn summarize_symbols(
3207 symbols: &[CodeSymbol],
3208 called_by: &HashMap<String, Vec<String>>,
3209) -> Vec<FileSymbolSummary> {
3210 let mut rows = symbols
3211 .iter()
3212 .map(|symbol| FileSymbolSummary {
3213 name: symbol.name.clone(),
3214 kind: symbol.kind.to_string(),
3215 line: symbol.line_start,
3216 end_line: symbol.line_end,
3217 signature: symbol.signature.clone(),
3218 exported: symbol.exported,
3219 documentation: symbol.documentation.clone().unwrap_or_default(),
3220 parent: symbol.parent.clone().unwrap_or_default(),
3221 called_by: called_by
3222 .get(&symbol_summary_key(symbol))
3223 .cloned()
3224 .unwrap_or_default(),
3225 })
3226 .collect::<Vec<_>>();
3227 rows.sort_by(|left, right| {
3228 left.line
3229 .cmp(&right.line)
3230 .then_with(|| left.name.cmp(&right.name))
3231 });
3232 rows
3233}
3234
3235fn package_name(symbols: &[CodeSymbol]) -> String {
3237 symbols
3238 .iter()
3239 .find(|symbol| matches!(symbol.kind, SymbolKind::Package | SymbolKind::Workspace))
3240 .or_else(|| {
3241 symbols.iter().find(|symbol| {
3242 symbol.kind == SymbolKind::Module
3243 && matches!(
3244 symbol.detail.as_deref(),
3245 Some(
3246 "package_declaration"
3247 | "package_clause"
3248 | "package_header"
3249 | "namespace_declaration"
3250 | "file_scoped_namespace_declaration"
3251 | "module_declaration"
3252 )
3253 )
3254 })
3255 })
3256 .map(|symbol| symbol.name.clone())
3257 .unwrap_or_default()
3258}
3259
3260fn file_docstring(symbols: &[CodeSymbol]) -> String {
3262 symbols
3263 .iter()
3264 .find(|symbol| {
3265 matches!(
3266 symbol.kind,
3267 SymbolKind::Package | SymbolKind::Workspace | SymbolKind::Module
3268 ) && symbol
3269 .documentation
3270 .as_deref()
3271 .is_some_and(|value| !value.is_empty())
3272 })
3273 .and_then(|symbol| symbol.documentation.clone())
3274 .unwrap_or_default()
3275}
3276
3277fn file_level_docstring(content: &str) -> Option<String> {
3279 leading_string_docstring(content).or_else(|| leading_doc_comments(content))
3280}
3281
3282fn leading_string_docstring(content: &str) -> Option<String> {
3284 let trimmed = content.trim_start();
3285 for quote in ["\"\"\"", "'''"] {
3286 if let Some(rest) = trimmed.strip_prefix(quote)
3287 && let Some(end) = rest.find(quote)
3288 {
3289 return compact_doc_text(&rest[..end]);
3290 }
3291 }
3292 None
3293}
3294
3295fn leading_doc_comments(content: &str) -> Option<String> {
3297 let mut lines = Vec::new();
3298 let mut in_block = false;
3299 let mut module_style: Option<bool> = None;
3300 for line in content.lines() {
3301 let trimmed = line.trim();
3302 if trimmed.is_empty() && lines.is_empty() {
3303 continue;
3304 }
3305 if in_block {
3306 if let Some(end) = trimmed.find("*/") {
3307 lines.push(trimmed[..end].trim_start_matches('*').trim().to_string());
3308 break;
3309 }
3310 lines.push(trimmed.trim_start_matches('*').trim().to_string());
3311 continue;
3312 }
3313 if let Some(value) = trimmed.strip_prefix("//!") {
3314 if module_style.is_some_and(|module| !module) {
3315 break;
3316 }
3317 module_style = Some(true);
3318 lines.push(value.trim().to_string());
3319 } else if let Some(value) = trimmed.strip_prefix("///") {
3320 if module_style.is_some_and(|module| module) {
3321 break;
3322 }
3323 module_style = Some(false);
3324 lines.push(value.trim().to_string());
3325 } else if let Some(value) = trimmed.strip_prefix("/*!") {
3326 if module_style.is_some_and(|module| !module) {
3327 break;
3328 }
3329 module_style = Some(true);
3330 in_block = true;
3331 if let Some(end) = value.find("*/") {
3332 lines.push(value[..end].trim_start_matches('*').trim().to_string());
3333 break;
3334 }
3335 lines.push(value.trim_start_matches('*').trim().to_string());
3336 } else if let Some(value) = trimmed.strip_prefix("/**") {
3337 if module_style.is_some_and(|module| module) {
3338 break;
3339 }
3340 module_style = Some(false);
3341 in_block = true;
3342 if let Some(end) = value.find("*/") {
3343 lines.push(value[..end].trim_start_matches('*').trim().to_string());
3344 break;
3345 }
3346 lines.push(value.trim_start_matches('*').trim().to_string());
3347 } else {
3348 break;
3349 }
3350 }
3351 compact_doc_text(&lines.join(" "))
3352}
3353
3354fn compact_doc_text(raw: &str) -> Option<String> {
3356 let text = raw
3357 .lines()
3358 .map(|line| line.trim().trim_start_matches('*').trim())
3359 .filter(|line| !line.is_empty())
3360 .collect::<Vec<_>>()
3361 .join(" ");
3362 if text.is_empty() { None } else { Some(text) }
3363}
3364
3365#[cfg(test)]
3367fn exported_symbol_names(symbols: &[CodeSymbol]) -> Vec<String> {
3368 let mut names = symbols
3369 .iter()
3370 .filter(|symbol| symbol.exported)
3371 .map(|symbol| symbol.name.clone())
3372 .collect::<Vec<_>>();
3373 names.sort();
3374 names.dedup();
3375 names
3376}
3377
3378#[cfg(test)]
3379mod tests {
3380 use super::*;
3381 use projectatlas_core::graph::{
3382 CoverageRecord, GraphIdentityText, GraphLimitKind, RepositoryNodePath,
3383 };
3384 use projectatlas_core::symbols::{ParserKind, SymbolGraph};
3385 use projectatlas_core::telemetry::{
3386 AgentEfficiencyBaseline, AgentEfficiencyEvidenceState, UsageDetailAvailability,
3387 };
3388 use projectatlas_core::{Node, Purpose, PurposeSource, PurposeStatus, normalized_parent};
3389 use std::error::Error;
3390 use std::io;
3391
3392 #[test]
3393 fn token_report_service_selects_typed_reports_and_requires_a_project_binding()
3394 -> Result<(), Box<dyn Error>> {
3395 let temp = tempfile::tempdir()?;
3396 let root = temp.path().join("repo");
3397 let atlas_dir = root.join(".projectatlas");
3398 fs::create_dir_all(&atlas_dir)?;
3399 let store = AtlasStore::open_for_project(&atlas_dir.join("projectatlas.db"), &root)?;
3400
3401 let overview = load_token_report(
3402 &store,
3403 TokenReportRequest::Overview {
3404 caller_label: None,
3405 benchmark_results: None,
3406 },
3407 )?;
3408 match overview {
3409 TokenReport::Overview(overview) => {
3410 require_eq(&overview.calls, &0, "empty overview calls")?;
3411 require_eq(
3412 &overview.detail_availability,
3413 &UsageDetailAvailability::Retained,
3414 "empty overview detail availability",
3415 )?;
3416 require_eq(
3417 &overview.agent_efficiency.state,
3418 &AgentEfficiencyEvidenceState::Unavailable,
3419 "empty overview agent-efficiency state",
3420 )?;
3421 }
3422 TokenReport::Trends(_) => {
3423 return Err(io::Error::other("overview request returned token trends").into());
3424 }
3425 }
3426
3427 let trends = load_token_report(
3428 &store,
3429 TokenReportRequest::Trends {
3430 caller_label: None,
3431 window: TokenTrendWindow::Month,
3432 },
3433 )?;
3434 match trends {
3435 TokenReport::Trends(report) => {
3436 require_eq(&report.window, &TokenTrendWindow::Month, "trend window")?;
3437 require_eq(
3438 &report.detail_availability,
3439 &UsageDetailAvailability::Retained,
3440 "empty trend detail availability",
3441 )?;
3442 }
3443 TokenReport::Overview(_) => {
3444 return Err(io::Error::other("trend request returned token overview").into());
3445 }
3446 }
3447
3448 let unbound = AtlasStore::in_memory()?;
3449 if !matches!(
3450 load_token_report(
3451 &unbound,
3452 TokenReportRequest::Overview {
3453 caller_label: None,
3454 benchmark_results: None,
3455 }
3456 ),
3457 Err(ServiceError::SelectedProjectUnavailable)
3458 ) {
3459 return Err(io::Error::other("unbound token report did not fail closed").into());
3460 }
3461 Ok(())
3462 }
3463
3464 #[test]
3465 fn token_report_service_bounds_and_classifies_benchmark_evidence() -> Result<(), Box<dyn Error>>
3466 {
3467 let temp = tempfile::tempdir()?;
3468 let root = temp.path().join("benchmark-service");
3469 let atlas_dir = root.join(".projectatlas");
3470 fs::create_dir_all(&atlas_dir)?;
3471 let store = AtlasStore::open_for_project(&atlas_dir.join("projectatlas.db"), &root)?;
3472 let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3473 .join("../../docs/benchmarks/v0.4-agent-navigation-results.json");
3474 let published = root.join("published.json");
3475 fs::copy(&source, &published)?;
3476
3477 let report = load_token_report(
3478 &store,
3479 TokenReportRequest::Overview {
3480 caller_label: None,
3481 benchmark_results: Some(Path::new("published.json")),
3482 },
3483 )?;
3484 let TokenReport::Overview(report) = report else {
3485 return Err(io::Error::other("benchmark request returned token trends").into());
3486 };
3487 require_eq(
3488 &report.agent_efficiency.state,
3489 &AgentEfficiencyEvidenceState::Partial,
3490 "published benchmark state",
3491 )?;
3492 let frozen = report
3493 .agent_efficiency
3494 .baselines
3495 .iter()
3496 .find(|row| row.baseline == AgentEfficiencyBaseline::FrozenProjectAtlasV0326)
3497 .ok_or_else(|| io::Error::other("frozen baseline row missing"))?;
3498 require_eq(
3499 &frozen.baseline_failed_trials,
3500 &3,
3501 "published frozen failed trials",
3502 )?;
3503
3504 fs::write(root.join("malformed.json"), b"{")?;
3505 let malformed = load_token_report(
3506 &store,
3507 TokenReportRequest::Overview {
3508 caller_label: None,
3509 benchmark_results: Some(Path::new("malformed.json")),
3510 },
3511 )?;
3512 let TokenReport::Overview(malformed) = malformed else {
3513 return Err(io::Error::other("malformed request returned token trends").into());
3514 };
3515 require_eq(
3516 &malformed.agent_efficiency.state,
3517 &AgentEfficiencyEvidenceState::Failed,
3518 "malformed benchmark state",
3519 )?;
3520
3521 let stale = String::from_utf8(fs::read(&source)?)?.replacen(
3522 "\"schema_version\": 1",
3523 "\"schema_version\": 2",
3524 1,
3525 );
3526 fs::write(root.join("stale.json"), stale)?;
3527 let stale = load_token_report(
3528 &store,
3529 TokenReportRequest::Overview {
3530 caller_label: None,
3531 benchmark_results: Some(Path::new("stale.json")),
3532 },
3533 )?;
3534 let TokenReport::Overview(stale) = stale else {
3535 return Err(io::Error::other("stale request returned token trends").into());
3536 };
3537 require_eq(
3538 &stale.agent_efficiency.state,
3539 &AgentEfficiencyEvidenceState::Incompatible,
3540 "stale benchmark state",
3541 )?;
3542
3543 let missing = load_token_report(
3544 &store,
3545 TokenReportRequest::Overview {
3546 caller_label: None,
3547 benchmark_results: Some(Path::new("missing.json")),
3548 },
3549 )?;
3550 let TokenReport::Overview(missing) = missing else {
3551 return Err(io::Error::other("missing request returned token trends").into());
3552 };
3553 require_eq(
3554 &missing.agent_efficiency.state,
3555 &AgentEfficiencyEvidenceState::Failed,
3556 "missing benchmark state",
3557 )?;
3558
3559 fs::write(
3560 root.join("oversized.json"),
3561 vec![b' '; super::agent_efficiency::BENCHMARK_MAX_BYTES + 1],
3562 )?;
3563 let oversized = load_token_report(
3564 &store,
3565 TokenReportRequest::Overview {
3566 caller_label: None,
3567 benchmark_results: Some(Path::new("oversized.json")),
3568 },
3569 )?;
3570 let TokenReport::Overview(oversized) = oversized else {
3571 return Err(io::Error::other("oversized request returned token trends").into());
3572 };
3573 require_eq(
3574 &oversized.agent_efficiency.state,
3575 &AgentEfficiencyEvidenceState::Failed,
3576 "oversized benchmark state",
3577 )?;
3578
3579 for escaped in [published.as_path(), Path::new("../outside.json")] {
3580 if !matches!(
3581 load_token_report(
3582 &store,
3583 TokenReportRequest::Overview {
3584 caller_label: None,
3585 benchmark_results: Some(escaped),
3586 },
3587 ),
3588 Err(ServiceError::InvalidInput(_))
3589 ) {
3590 return Err(io::Error::other(
3591 "escaping benchmark path did not fail at the service boundary",
3592 )
3593 .into());
3594 }
3595 }
3596 Ok(())
3597 }
3598
3599 #[test]
3600 fn summary_digest_and_opt_in_coverage_page_share_current_typed_rows()
3601 -> Result<(), Box<dyn Error>> {
3602 let temp = tempfile::tempdir()?;
3603 let root = temp.path().join("coverage-service");
3604 fs::create_dir_all(root.join("src"))?;
3605 fs::write(root.join("src/lib.rs"), "pub fn run() {}\n")?;
3606 let db_path = root.join("projectatlas.db");
3607 let mut store = AtlasStore::open_for_project(&db_path, &root)?;
3608 let project = store
3609 .project_instance_id()?
3610 .ok_or_else(|| io::Error::other("service fixture identity is missing"))?;
3611 let generation = IndexGeneration::new(1);
3612 let mut publication = store.begin_index_publication("coverage-service")?;
3613 publication.begin_scan_replacement()?;
3614 publication.upsert_scan_node_batch(&[test_node("src/lib.rs", "coverage-hash")])?;
3615 publication.finish_scan_replacement()?;
3616 publication.replace_symbol_graph(&SymbolGraph {
3617 path: "src/lib.rs".to_string(),
3618 language: Some("rust".to_string()),
3619 parser: ParserKind::TreeSitter,
3620 symbols: Vec::new(),
3621 relations: Vec::new(),
3622 })?;
3623 let mut coverage = vec![
3624 CoverageRecord::new(
3625 CoverageScope::Path {
3626 path: RepositoryNodePath::new(Path::new("src/lib.rs"))?,
3627 },
3628 None,
3629 CoverageState::Partial,
3630 3,
3631 1,
3632 generation,
3633 Some(GraphIdentityText::new("one fallback fact omitted")?),
3634 Some(GraphLimitKind::Rows),
3635 )?,
3636 CoverageRecord::new(
3637 CoverageScope::Project,
3638 Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3639 CoverageState::Failed,
3640 0,
3641 1,
3642 generation,
3643 Some(GraphIdentityText::new("parser failed")?),
3644 None,
3645 )?,
3646 ];
3647 for index in 0..=COVERAGE_DIGEST_ROW_LIMIT {
3648 let sibling_path = format!("src/lib.rs.{index:02}");
3649 coverage.push(CoverageRecord::new(
3650 CoverageScope::Path {
3651 path: RepositoryNodePath::new(Path::new(&sibling_path))?,
3652 },
3653 None,
3654 CoverageState::Complete,
3655 1,
3656 0,
3657 generation,
3658 None,
3659 None,
3660 )?);
3661 }
3662 publication.replace_repository_graph(project, &[], &[], &[], &coverage)?;
3663 publication.complete()?;
3664 drop(store);
3665
3666 let store = AtlasStore::open_read_only_for_project(&db_path, &root)?;
3667 let summary = build_file_summary(&store, Path::new("src/lib.rs"), 10)?;
3668 require_eq(
3669 &summary.coverage.available,
3670 &true,
3671 "summary coverage availability",
3672 )?;
3673 require_eq(
3674 &summary.coverage.states.partial,
3675 &1,
3676 "summary partial coverage count",
3677 )?;
3678 require_eq(
3679 &summary.coverage.states.complete,
3680 &0,
3681 "summary excluded lexical sibling coverage",
3682 )?;
3683 require_eq(
3684 &summary.coverage.states.failed,
3685 &0,
3686 "summary excluded project-wide coverage",
3687 )?;
3688 require_eq(
3689 &summary.coverage.truncated,
3690 &false,
3691 "summary exact-file coverage truncation",
3692 )?;
3693 require_eq(
3694 &summary.coverage.trust,
3695 &CoverageTrustState::Partial,
3696 "summary exact-file coverage trust",
3697 )?;
3698 require_eq(
3699 &summary.coverage.provider,
3700 &Some(ParserKind::TreeSitter),
3701 "summary fact provider",
3702 )?;
3703 require_eq(
3704 &summary.coverage.next_call.capability,
3705 &NavigationNextCapability::Health,
3706 "summary coverage next call",
3707 )?;
3708
3709 let report = load_coverage_discovery(
3710 &store,
3711 RepositoryCoverageQuery {
3712 start_index: 0,
3713 limit: 10,
3714 path_prefix: None,
3715 parser: None,
3716 provider: None,
3717 relation: Some(GraphRelationKind::Legacy(RelationKind::Calls)),
3718 state: Some(CoverageState::Failed),
3719 reason: Some("parser failed".to_string()),
3720 },
3721 )?;
3722 require_eq(&report.returned, &1, "filtered service coverage row")?;
3723 require_eq(
3724 &report.total,
3725 &CoverageTotalState::Exact(1),
3726 "bounded exact coverage total",
3727 )?;
3728 require_eq(
3729 &report.rows[0].next_call.capability,
3730 &NavigationNextCapability::Health,
3731 "project coverage next call",
3732 )?;
3733 let truncated = load_coverage_discovery(
3734 &store,
3735 RepositoryCoverageQuery {
3736 start_index: 0,
3737 limit: 1,
3738 path_prefix: None,
3739 parser: None,
3740 provider: None,
3741 relation: None,
3742 state: None,
3743 reason: None,
3744 },
3745 )?;
3746 require_eq(
3747 &truncated.total,
3748 &CoverageTotalState::AtLeast(2),
3749 "truncated coverage lower bound",
3750 )?;
3751 require_eq(&truncated.continuation, &Some(1), "coverage continuation")?;
3752 let exhausted = load_coverage_discovery(
3753 &store,
3754 RepositoryCoverageQuery {
3755 start_index: 100,
3756 limit: 1,
3757 path_prefix: None,
3758 parser: None,
3759 provider: None,
3760 relation: None,
3761 state: None,
3762 reason: None,
3763 },
3764 )?;
3765 require_eq(
3766 &exhausted.total,
3767 &CoverageTotalState::Unknown,
3768 "exhausted nonzero continuation total",
3769 )?;
3770 Ok(())
3771 }
3772
3773 #[test]
3774 fn metadata_helpers_are_stable() -> Result<(), Box<dyn Error>> {
3775 let mut package = test_symbol("Cargo.toml", SymbolKind::Package, "projectatlas");
3776 package.documentation = Some("ProjectAtlas package manifest.".to_string());
3777 let mut alpha = test_symbol("src/lib.rs", SymbolKind::Function, "alpha");
3778 alpha.exported = true;
3779 alpha.documentation = Some("Alpha entry point.".to_string());
3780 let mut beta = test_symbol("src/lib.rs", SymbolKind::Function, "beta");
3781 beta.exported = true;
3782 let private = test_symbol("src/lib.rs", SymbolKind::Function, "private");
3783 let symbols = vec![beta, package, private, alpha];
3784
3785 require_eq(
3786 &package_name(&symbols),
3787 &"projectatlas".to_string(),
3788 "package name",
3789 )?;
3790 require_eq(
3791 &file_docstring(&symbols),
3792 &"ProjectAtlas package manifest.".to_string(),
3793 "file docstring",
3794 )?;
3795 require_eq(
3796 &exported_symbol_names(&symbols),
3797 &vec!["alpha".to_string(), "beta".to_string()],
3798 "exported symbols",
3799 )?;
3800 require_eq(
3801 &file_level_docstring("//! Module level docs.\nfn main() {}"),
3802 &Some("Module level docs.".to_string()),
3803 "rust module docs",
3804 )?;
3805 require_eq(
3806 &file_level_docstring("\"\"\"Python module docs.\"\"\"\nclass Atlas: pass"),
3807 &Some("Python module docs.".to_string()),
3808 "python module docs",
3809 )?;
3810 Ok(())
3811 }
3812
3813 #[test]
3814 fn file_summary_marks_fallback_symbol_graph_as_fallback() -> Result<(), Box<dyn Error>> {
3815 let temp = tempfile::tempdir()?;
3816 let root = temp.path();
3817 fs::create_dir(root.join("src"))?;
3818 fs::write(
3819 root.join("src").join("component.vue"),
3820 "<script setup></script>",
3821 )?;
3822 let mut store = AtlasStore::in_memory()?;
3823 store.set_project_root(root)?;
3824 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
3825 store.set_purpose(
3826 "src/component.vue",
3827 "Provide Vue component behavior",
3828 PurposeSource::Agent,
3829 )?;
3830 store.set_node_summary("src/component.vue", "vue component with bindings selected.")?;
3831 let mut fallback_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
3832 fallback_symbol.parser = ParserKind::Fallback;
3833 store.replace_symbol_graph(&SymbolGraph {
3834 path: "src/component.vue".to_string(),
3835 language: Some("vue".to_string()),
3836 parser: ParserKind::Fallback,
3837 symbols: vec![fallback_symbol],
3838 relations: Vec::new(),
3839 })?;
3840
3841 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
3842 require_eq(
3843 &report.parser_kind,
3844 &"fallback-symbol-graph".to_string(),
3845 "fallback parser kind",
3846 )?;
3847 require_eq(
3848 &report.summary_status,
3849 &"fallback".to_string(),
3850 "fallback summary status",
3851 )
3852 }
3853
3854 #[test]
3855 fn file_summary_marks_empty_fallback_graph_as_fallback() -> Result<(), Box<dyn Error>> {
3856 let temp = tempfile::tempdir()?;
3857 let root = temp.path();
3858 fs::create_dir(root.join("scripts"))?;
3859 fs::write(root.join("scripts").join("config.ps1"), "# comment only\n")?;
3860 let mut store = AtlasStore::in_memory()?;
3861 store.set_project_root(root)?;
3862 store.replace_scan(&[test_node("scripts/config.ps1", "hash-ps1")])?;
3863 store.set_node_summary(
3864 "scripts/config.ps1",
3865 "powershell source file with no declarations found.",
3866 )?;
3867 store.replace_symbol_graph(&SymbolGraph {
3868 path: "scripts/config.ps1".to_string(),
3869 language: Some("powershell".to_string()),
3870 parser: ParserKind::Fallback,
3871 symbols: Vec::new(),
3872 relations: Vec::new(),
3873 })?;
3874
3875 let report = build_file_summary(&store, Path::new("scripts/config.ps1"), 10)?;
3876 require_eq(
3877 &report.parser_kind,
3878 &"fallback-symbol-graph".to_string(),
3879 "empty fallback parser kind",
3880 )?;
3881 require_eq(
3882 &report.summary_status,
3883 &"fallback".to_string(),
3884 "empty fallback summary status",
3885 )
3886 }
3887
3888 #[test]
3889 fn file_summary_uses_metadata_parser_for_empty_nonfallback_graphs() -> Result<(), Box<dyn Error>>
3890 {
3891 for (path, language, parser, expected) in [
3892 (
3893 "src/empty.rs",
3894 "rust",
3895 ParserKind::TreeSitter,
3896 "tree-sitter-symbol-graph",
3897 ),
3898 (
3899 "src/component.vue",
3900 "vue",
3901 ParserKind::Structural,
3902 "structural-symbol-graph",
3903 ),
3904 (
3905 "Cargo.toml",
3906 "cargo-manifest",
3907 ParserKind::Manifest,
3908 "manifest-symbol-graph",
3909 ),
3910 ] {
3911 let temp = tempfile::tempdir()?;
3912 let root = temp.path();
3913 if let Some(parent) = Path::new(path).parent() {
3914 fs::create_dir_all(root.join(parent))?;
3915 }
3916 fs::write(root.join(path), "\n")?;
3917 let mut store = AtlasStore::in_memory()?;
3918 store.set_project_root(root)?;
3919 store.replace_scan(&[test_node(path, "hash-empty")])?;
3920 store.set_node_summary(path, "source file with no declarations found.")?;
3921 store.replace_symbol_graph(&SymbolGraph {
3922 path: path.to_string(),
3923 language: Some(language.to_string()),
3924 parser,
3925 symbols: Vec::new(),
3926 relations: Vec::new(),
3927 })?;
3928
3929 let report = build_file_summary(&store, Path::new(path), 10)?;
3930 require_eq(
3931 &report.parser_kind,
3932 &expected.to_string(),
3933 "empty nonfallback parser kind",
3934 )?;
3935 }
3936 Ok(())
3937 }
3938
3939 #[test]
3940 fn file_summary_marks_structural_symbol_graph_as_ok() -> Result<(), Box<dyn Error>> {
3941 let temp = tempfile::tempdir()?;
3942 let root = temp.path();
3943 fs::create_dir(root.join("src"))?;
3944 fs::write(
3945 root.join("src").join("component.vue"),
3946 "<script setup>const selected = ref(false)</script>",
3947 )?;
3948 let mut store = AtlasStore::in_memory()?;
3949 store.set_project_root(root)?;
3950 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
3951 store.set_node_summary("src/component.vue", "vue component with bindings selected.")?;
3952 let mut structural_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
3953 structural_symbol.parser = ParserKind::Structural;
3954 store.replace_symbol_graph(&SymbolGraph {
3955 path: "src/component.vue".to_string(),
3956 language: Some("vue".to_string()),
3957 parser: ParserKind::Structural,
3958 symbols: vec![structural_symbol],
3959 relations: Vec::new(),
3960 })?;
3961
3962 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
3963 require_eq(
3964 &report.parser_kind,
3965 &"structural-symbol-graph".to_string(),
3966 "structural parser kind",
3967 )?;
3968 require_eq(
3969 &report.summary_status,
3970 &"ok".to_string(),
3971 "structural summary status",
3972 )
3973 }
3974
3975 #[test]
3976 fn file_summary_reports_mixed_vue_symbol_graph_with_structural_metadata()
3977 -> Result<(), Box<dyn Error>> {
3978 let temp = tempfile::tempdir()?;
3979 let root = temp.path();
3980 fs::create_dir(root.join("src"))?;
3981 fs::write(
3982 root.join("src").join("component.vue"),
3983 "<script lang=\"ts\">export function submitOrder() {}</script>\n<script setup>const selected = ref(false)</script>",
3984 )?;
3985 let mut store = AtlasStore::in_memory()?;
3986 store.set_project_root(root)?;
3987 store.replace_scan(&[test_node("src/component.vue", "hash-vue")])?;
3988 store.set_node_summary(
3989 "src/component.vue",
3990 "vue source defining values selected and function submitOrder.",
3991 )?;
3992 let mut structural_symbol = test_symbol("src/component.vue", SymbolKind::Value, "selected");
3993 structural_symbol.parser = ParserKind::Structural;
3994 structural_symbol.detail = Some("vue-composition-binding".to_string());
3995 let mut fallback_symbol =
3996 test_symbol("src/component.vue", SymbolKind::Function, "submitOrder");
3997 fallback_symbol.parser = ParserKind::Fallback;
3998 fallback_symbol.detail = Some("fallback-js-function".to_string());
3999 store.replace_symbol_graph(&SymbolGraph {
4000 path: "src/component.vue".to_string(),
4001 language: Some("vue".to_string()),
4002 parser: ParserKind::Structural,
4003 symbols: vec![structural_symbol, fallback_symbol],
4004 relations: Vec::new(),
4005 })?;
4006
4007 let report = build_file_summary(&store, Path::new("src/component.vue"), 10)?;
4008 require_eq(
4009 &report.parser_kind,
4010 &"mixed-symbol-graph".to_string(),
4011 "mixed parser kind",
4012 )?;
4013 require_eq(
4014 &report.summary_status,
4015 &"ok".to_string(),
4016 "mixed summary status",
4017 )
4018 }
4019
4020 #[test]
4021 fn module_aliases_include_package_entries_and_compound_extensions() -> Result<(), Box<dyn Error>>
4022 {
4023 require_eq(
4024 &module_aliases_for_path("src/packages/foo/index.ts"),
4025 &vec![
4026 "foo".to_string(),
4027 "packages.foo".to_string(),
4028 "packages::foo".to_string(),
4029 ],
4030 "typescript package entry aliases",
4031 )?;
4032 require_eq(
4033 &module_aliases_for_path("src/types/api.d.ts"),
4034 &vec![
4035 "api".to_string(),
4036 "types.api".to_string(),
4037 "types::api".to_string(),
4038 ],
4039 "typescript definition aliases",
4040 )?;
4041 require_eq(
4042 &module_aliases_for_path("src/package/__init__.py"),
4043 &vec!["package".to_string()],
4044 "python package entry aliases",
4045 )?;
4046 require_eq(
4047 &module_aliases_for_path("src/lib.rs"),
4048 &Vec::<String>::new(),
4049 "rust root lib aliases",
4050 )
4051 }
4052
4053 #[test]
4054 fn file_summary_includes_cross_file_called_by() -> Result<(), Box<dyn Error>> {
4055 let temp = tempfile::tempdir()?;
4056 let root = temp.path();
4057 fs::create_dir(root.join("src"))?;
4058 fs::write(
4059 root.join("src").join("lib.rs"),
4060 "/// Shared helper.\npub fn helper() {}\n",
4061 )?;
4062 let mut store = AtlasStore::in_memory()?;
4063 store.set_project_root(root)?;
4064 store.replace_scan(&[
4065 test_node("src/lib.rs", "hash-lib"),
4066 test_node("src/main.rs", "hash-main"),
4067 ])?;
4068 store.set_purpose(
4069 "src/lib.rs",
4070 "Provide shared library behavior",
4071 PurposeSource::Agent,
4072 )?;
4073 store.replace_symbol_graph(&SymbolGraph {
4074 path: "src/lib.rs".to_string(),
4075 language: Some("rust".to_string()),
4076 parser: ParserKind::TreeSitter,
4077 symbols: vec![{
4078 let mut symbol = test_symbol("src/lib.rs", SymbolKind::Function, "helper");
4079 symbol.exported = true;
4080 symbol.line_start = 2;
4081 symbol.line_end = 2;
4082 symbol
4083 }],
4084 relations: Vec::new(),
4085 })?;
4086 store.replace_symbol_graph(&SymbolGraph {
4087 path: "src/main.rs".to_string(),
4088 language: Some("rust".to_string()),
4089 parser: ParserKind::TreeSitter,
4090 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
4091 relations: vec![SymbolRelation {
4092 path: "src/main.rs".to_string(),
4093 source_name: "main".to_string(),
4094 target_name: "crate::helper".to_string(),
4095 kind: RelationKind::Calls,
4096 line: 1,
4097 context: "helper();".to_string(),
4098 parser: ParserKind::TreeSitter,
4099 }],
4100 })?;
4101
4102 let report = build_file_summary(&store, Path::new("src/lib.rs"), 10)?;
4103 require_eq(
4104 &report.file_purpose_status,
4105 &PurposeStatus::Approved.to_string(),
4106 "purpose status",
4107 )?;
4108 require_eq(
4109 &report.file_purpose_agent_reviewed,
4110 &true,
4111 "purpose agent reviewed",
4112 )?;
4113 let helper = report
4114 .functions
4115 .iter()
4116 .find(|symbol| symbol.name == "helper")
4117 .ok_or_else(|| io::Error::other("helper summary missing"))?;
4118 require_eq(
4119 &helper.called_by,
4120 &vec!["src/main.rs::main".to_string()],
4121 "cross-file called-by",
4122 )?;
4123 Ok(())
4124 }
4125
4126 #[test]
4127 fn file_summary_rejects_ambiguous_called_by_matches() -> Result<(), Box<dyn Error>> {
4128 let temp = tempfile::tempdir()?;
4129 let root = temp.path();
4130 fs::create_dir(root.join("src"))?;
4131 fs::write(root.join("src").join("a.rs"), "pub fn helper() {}\n")?;
4132 fs::write(root.join("src").join("b.rs"), "pub fn helper() {}\n")?;
4133 fs::write(
4134 root.join("src").join("main.rs"),
4135 "mod a;\nmod b;\nfn main() { b::helper(); }\n",
4136 )?;
4137 let mut store = AtlasStore::in_memory()?;
4138 store.set_project_root(root)?;
4139 store.replace_scan(&[
4140 test_node("src/a.rs", "hash-a"),
4141 test_node("src/b.rs", "hash-b"),
4142 test_node("src/main.rs", "hash-main"),
4143 ])?;
4144 store.replace_symbol_graph(&SymbolGraph {
4145 path: "src/a.rs".to_string(),
4146 language: Some("rust".to_string()),
4147 parser: ParserKind::TreeSitter,
4148 symbols: vec![test_symbol("src/a.rs", SymbolKind::Function, "helper")],
4149 relations: Vec::new(),
4150 })?;
4151 store.replace_symbol_graph(&SymbolGraph {
4152 path: "src/b.rs".to_string(),
4153 language: Some("rust".to_string()),
4154 parser: ParserKind::TreeSitter,
4155 symbols: vec![test_symbol("src/b.rs", SymbolKind::Function, "helper")],
4156 relations: Vec::new(),
4157 })?;
4158 store.replace_symbol_graph(&SymbolGraph {
4159 path: "src/main.rs".to_string(),
4160 language: Some("rust".to_string()),
4161 parser: ParserKind::TreeSitter,
4162 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
4163 relations: vec![SymbolRelation {
4164 path: "src/main.rs".to_string(),
4165 source_name: "main".to_string(),
4166 target_name: "b::helper".to_string(),
4167 kind: RelationKind::Calls,
4168 line: 3,
4169 context: "b::helper();".to_string(),
4170 parser: ParserKind::TreeSitter,
4171 }],
4172 })?;
4173
4174 let a_report = build_file_summary(&store, Path::new("src/a.rs"), 10)?;
4175 let a_helper = a_report
4176 .functions
4177 .iter()
4178 .find(|symbol| symbol.name == "helper")
4179 .ok_or_else(|| io::Error::other("a::helper summary missing"))?;
4180 require_eq(&a_helper.called_by, &Vec::<String>::new(), "a called-by")?;
4181
4182 let b_report = build_file_summary(&store, Path::new("src/b.rs"), 10)?;
4183 let b_helper = b_report
4184 .functions
4185 .iter()
4186 .find(|symbol| symbol.name == "helper")
4187 .ok_or_else(|| io::Error::other("b::helper summary missing"))?;
4188 require_eq(
4189 &b_helper.called_by,
4190 &vec!["src/main.rs::main".to_string()],
4191 "b called-by",
4192 )?;
4193 Ok(())
4194 }
4195
4196 #[test]
4197 fn file_summary_rejects_ambiguous_module_alias_called_by_matches() -> Result<(), Box<dyn Error>>
4198 {
4199 let temp = tempfile::tempdir()?;
4200 let root = temp.path();
4201 fs::create_dir_all(root.join("src").join("foo"))?;
4202 fs::create_dir_all(root.join("src").join("bar"))?;
4203 fs::write(root.join("src/foo/service.rs"), "pub fn run() {}\n")?;
4204 fs::write(root.join("src/bar/service.rs"), "pub fn run() {}\n")?;
4205 fs::write(root.join("src/main.rs"), "fn main() { service::run(); }\n")?;
4206 let mut store = AtlasStore::in_memory()?;
4207 store.set_project_root(root)?;
4208 store.replace_scan(&[
4209 test_node("src/foo/service.rs", "hash-foo"),
4210 test_node("src/bar/service.rs", "hash-bar"),
4211 test_node("src/main.rs", "hash-main"),
4212 ])?;
4213 store.replace_symbol_graph(&SymbolGraph {
4214 path: "src/foo/service.rs".to_string(),
4215 language: Some("rust".to_string()),
4216 parser: ParserKind::TreeSitter,
4217 symbols: vec![test_symbol(
4218 "src/foo/service.rs",
4219 SymbolKind::Function,
4220 "run",
4221 )],
4222 relations: Vec::new(),
4223 })?;
4224 store.replace_symbol_graph(&SymbolGraph {
4225 path: "src/bar/service.rs".to_string(),
4226 language: Some("rust".to_string()),
4227 parser: ParserKind::TreeSitter,
4228 symbols: vec![test_symbol(
4229 "src/bar/service.rs",
4230 SymbolKind::Function,
4231 "run",
4232 )],
4233 relations: Vec::new(),
4234 })?;
4235 store.replace_symbol_graph(&SymbolGraph {
4236 path: "src/main.rs".to_string(),
4237 language: Some("rust".to_string()),
4238 parser: ParserKind::TreeSitter,
4239 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
4240 relations: vec![SymbolRelation {
4241 path: "src/main.rs".to_string(),
4242 source_name: "main".to_string(),
4243 target_name: "service::run".to_string(),
4244 kind: RelationKind::Calls,
4245 line: 1,
4246 context: "service::run();".to_string(),
4247 parser: ParserKind::TreeSitter,
4248 }],
4249 })?;
4250
4251 for path in ["src/foo/service.rs", "src/bar/service.rs"] {
4252 let report = build_file_summary(&store, Path::new(path), 10)?;
4253 let run = report
4254 .functions
4255 .iter()
4256 .find(|symbol| symbol.name == "run")
4257 .ok_or_else(|| io::Error::other("run summary missing"))?;
4258 require_eq(
4259 &run.called_by,
4260 &Vec::<String>::new(),
4261 "ambiguous module alias called-by",
4262 )?;
4263 }
4264 Ok(())
4265 }
4266
4267 #[test]
4268 fn file_summary_resolves_rust_import_alias_called_by() -> Result<(), Box<dyn Error>> {
4269 let temp = tempfile::tempdir()?;
4270 let root = temp.path();
4271 fs::create_dir_all(root.join("src/foo"))?;
4272 fs::write(root.join("src/foo/service.rs"), "pub fn run() {}\n")?;
4273 fs::write(
4274 root.join("src/main.rs"),
4275 "use crate::foo::service as foo_service;\nfn main() { foo_service::run(); }\n",
4276 )?;
4277 let mut store = AtlasStore::in_memory()?;
4278 store.set_project_root(root)?;
4279 store.replace_scan(&[
4280 test_node("src/foo/service.rs", "hash-service"),
4281 test_node("src/main.rs", "hash-main"),
4282 ])?;
4283 store.replace_symbol_graph(&SymbolGraph {
4284 path: "src/foo/service.rs".to_string(),
4285 language: Some("rust".to_string()),
4286 parser: ParserKind::TreeSitter,
4287 symbols: vec![test_symbol(
4288 "src/foo/service.rs",
4289 SymbolKind::Function,
4290 "run",
4291 )],
4292 relations: Vec::new(),
4293 })?;
4294 store.replace_symbol_graph(&SymbolGraph {
4295 path: "src/main.rs".to_string(),
4296 language: Some("rust".to_string()),
4297 parser: ParserKind::TreeSitter,
4298 symbols: vec![test_symbol("src/main.rs", SymbolKind::Function, "main")],
4299 relations: vec![
4300 SymbolRelation {
4301 path: "src/main.rs".to_string(),
4302 source_name: "<module>".to_string(),
4303 target_name: "use crate::foo::service as foo_service;".to_string(),
4304 kind: RelationKind::Imports,
4305 line: 1,
4306 context: "use crate::foo::service as foo_service;".to_string(),
4307 parser: ParserKind::TreeSitter,
4308 },
4309 SymbolRelation {
4310 path: "src/main.rs".to_string(),
4311 source_name: "main".to_string(),
4312 target_name: "foo_service::run".to_string(),
4313 kind: RelationKind::Calls,
4314 line: 2,
4315 context: "foo_service::run();".to_string(),
4316 parser: ParserKind::TreeSitter,
4317 },
4318 ],
4319 })?;
4320
4321 assert_single_called_by(
4322 &build_file_summary(&store, Path::new("src/foo/service.rs"), 10)?,
4323 "run",
4324 "src/main.rs::main",
4325 )
4326 }
4327
4328 #[test]
4329 fn file_summary_resolves_typescript_named_import_alias_called_by() -> Result<(), Box<dyn Error>>
4330 {
4331 let temp = tempfile::tempdir()?;
4332 let root = temp.path();
4333 fs::create_dir_all(root.join("src"))?;
4334 fs::write(root.join("src/service.ts"), "export function run() {}\n")?;
4335 fs::write(
4336 root.join("src/main.ts"),
4337 "import { run as serviceRun } from \"./service\";\nserviceRun();\n",
4338 )?;
4339 let mut store = AtlasStore::in_memory()?;
4340 store.set_project_root(root)?;
4341 store.replace_scan(&[
4342 test_node("src/service.ts", "hash-service"),
4343 test_node("src/main.ts", "hash-main"),
4344 ])?;
4345 store.replace_symbol_graph(&SymbolGraph {
4346 path: "src/service.ts".to_string(),
4347 language: Some("typescript".to_string()),
4348 parser: ParserKind::TreeSitter,
4349 symbols: vec![test_symbol("src/service.ts", SymbolKind::Function, "run")],
4350 relations: Vec::new(),
4351 })?;
4352 store.replace_symbol_graph(&SymbolGraph {
4353 path: "src/main.ts".to_string(),
4354 language: Some("typescript".to_string()),
4355 parser: ParserKind::TreeSitter,
4356 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
4357 relations: vec![
4358 SymbolRelation {
4359 path: "src/main.ts".to_string(),
4360 source_name: "<module>".to_string(),
4361 target_name: "import { run as serviceRun } from \"./service\";".to_string(),
4362 kind: RelationKind::Imports,
4363 line: 1,
4364 context: "import { run as serviceRun } from \"./service\";".to_string(),
4365 parser: ParserKind::TreeSitter,
4366 },
4367 SymbolRelation {
4368 path: "src/main.ts".to_string(),
4369 source_name: "main".to_string(),
4370 target_name: "serviceRun".to_string(),
4371 kind: RelationKind::Calls,
4372 line: 2,
4373 context: "serviceRun();".to_string(),
4374 parser: ParserKind::TreeSitter,
4375 },
4376 ],
4377 })?;
4378
4379 assert_single_called_by(
4380 &build_file_summary(&store, Path::new("src/service.ts"), 10)?,
4381 "run",
4382 "src/main.ts::main",
4383 )
4384 }
4385
4386 #[test]
4387 fn file_summary_resolves_typescript_explicit_index_import_called_by()
4388 -> Result<(), Box<dyn Error>> {
4389 let temp = tempfile::tempdir()?;
4390 let root = temp.path();
4391 fs::create_dir_all(root.join("src/api"))?;
4392 fs::write(root.join("src/api/index.ts"), "export function run() {}\n")?;
4393 fs::write(
4394 root.join("src/main.ts"),
4395 "import { run as apiRun } from \"./api/index\";\napiRun();\n",
4396 )?;
4397 let mut store = AtlasStore::in_memory()?;
4398 store.set_project_root(root)?;
4399 store.replace_scan(&[
4400 test_node("src/api/index.ts", "hash-api"),
4401 test_node("src/main.ts", "hash-main"),
4402 ])?;
4403 store.replace_symbol_graph(&SymbolGraph {
4404 path: "src/api/index.ts".to_string(),
4405 language: Some("typescript".to_string()),
4406 parser: ParserKind::TreeSitter,
4407 symbols: vec![test_symbol("src/api/index.ts", SymbolKind::Function, "run")],
4408 relations: Vec::new(),
4409 })?;
4410 store.replace_symbol_graph(&SymbolGraph {
4411 path: "src/main.ts".to_string(),
4412 language: Some("typescript".to_string()),
4413 parser: ParserKind::TreeSitter,
4414 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
4415 relations: vec![
4416 SymbolRelation {
4417 path: "src/main.ts".to_string(),
4418 source_name: "<module>".to_string(),
4419 target_name: "import { run as apiRun } from \"./api/index\";".to_string(),
4420 kind: RelationKind::Imports,
4421 line: 1,
4422 context: "import { run as apiRun } from \"./api/index\";".to_string(),
4423 parser: ParserKind::TreeSitter,
4424 },
4425 SymbolRelation {
4426 path: "src/main.ts".to_string(),
4427 source_name: "main".to_string(),
4428 target_name: "apiRun".to_string(),
4429 kind: RelationKind::Calls,
4430 line: 2,
4431 context: "apiRun();".to_string(),
4432 parser: ParserKind::TreeSitter,
4433 },
4434 ],
4435 })?;
4436
4437 assert_single_called_by(
4438 &build_file_summary(&store, Path::new("src/api/index.ts"), 10)?,
4439 "run",
4440 "src/main.ts::main",
4441 )
4442 }
4443
4444 #[test]
4445 fn file_summary_rejects_unrelated_typescript_alias_collision() -> Result<(), Box<dyn Error>> {
4446 let temp = tempfile::tempdir()?;
4447 let root = temp.path();
4448 fs::create_dir_all(root.join("src"))?;
4449 fs::write(root.join("src/service.ts"), "export function run() {}\n")?;
4450 fs::write(root.join("src/format.ts"), "export function format() {}\n")?;
4451 fs::write(
4452 root.join("src/main.ts"),
4453 "import { run as call } from \"./service\";\nimport { format as call } from \"./format\";\ncall();\n",
4454 )?;
4455 let mut store = AtlasStore::in_memory()?;
4456 store.set_project_root(root)?;
4457 store.replace_scan(&[
4458 test_node("src/service.ts", "hash-service"),
4459 test_node("src/format.ts", "hash-format"),
4460 test_node("src/main.ts", "hash-main"),
4461 ])?;
4462 store.replace_symbol_graph(&SymbolGraph {
4463 path: "src/service.ts".to_string(),
4464 language: Some("typescript".to_string()),
4465 parser: ParserKind::TreeSitter,
4466 symbols: vec![test_symbol("src/service.ts", SymbolKind::Function, "run")],
4467 relations: Vec::new(),
4468 })?;
4469 store.replace_symbol_graph(&SymbolGraph {
4470 path: "src/format.ts".to_string(),
4471 language: Some("typescript".to_string()),
4472 parser: ParserKind::TreeSitter,
4473 symbols: vec![test_symbol("src/format.ts", SymbolKind::Function, "format")],
4474 relations: Vec::new(),
4475 })?;
4476 store.replace_symbol_graph(&SymbolGraph {
4477 path: "src/main.ts".to_string(),
4478 language: Some("typescript".to_string()),
4479 parser: ParserKind::TreeSitter,
4480 symbols: vec![test_symbol("src/main.ts", SymbolKind::Function, "main")],
4481 relations: vec![
4482 SymbolRelation {
4483 path: "src/main.ts".to_string(),
4484 source_name: "<module>".to_string(),
4485 target_name: "import { run as call } from \"./service\";".to_string(),
4486 kind: RelationKind::Imports,
4487 line: 1,
4488 context: "import { run as call } from \"./service\";".to_string(),
4489 parser: ParserKind::TreeSitter,
4490 },
4491 SymbolRelation {
4492 path: "src/main.ts".to_string(),
4493 source_name: "<module>".to_string(),
4494 target_name: "import { format as call } from \"./format\";".to_string(),
4495 kind: RelationKind::Imports,
4496 line: 2,
4497 context: "import { format as call } from \"./format\";".to_string(),
4498 parser: ParserKind::TreeSitter,
4499 },
4500 SymbolRelation {
4501 path: "src/main.ts".to_string(),
4502 source_name: "main".to_string(),
4503 target_name: "call".to_string(),
4504 kind: RelationKind::Calls,
4505 line: 3,
4506 context: "call();".to_string(),
4507 parser: ParserKind::TreeSitter,
4508 },
4509 ],
4510 })?;
4511
4512 let report = build_file_summary(&store, Path::new("src/service.ts"), 10)?;
4513 let run = report
4514 .functions
4515 .iter()
4516 .find(|symbol| symbol.name == "run")
4517 .ok_or_else(|| io::Error::other("run summary missing"))?;
4518 require_eq(
4519 &run.called_by,
4520 &Vec::<String>::new(),
4521 "unrelated alias collision called-by",
4522 )
4523 }
4524
4525 #[test]
4526 fn file_summary_resolves_python_import_alias_called_by() -> Result<(), Box<dyn Error>> {
4527 let temp = tempfile::tempdir()?;
4528 let root = temp.path();
4529 fs::create_dir_all(root.join("src/package"))?;
4530 fs::write(root.join("src/package/module.py"), "def run():\n pass\n")?;
4531 fs::write(
4532 root.join("src/main.py"),
4533 "import package.module as service\nservice.run()\n",
4534 )?;
4535 let mut store = AtlasStore::in_memory()?;
4536 store.set_project_root(root)?;
4537 store.replace_scan(&[
4538 test_node("src/package/module.py", "hash-module"),
4539 test_node("src/main.py", "hash-main"),
4540 ])?;
4541 store.replace_symbol_graph(&SymbolGraph {
4542 path: "src/package/module.py".to_string(),
4543 language: Some("python".to_string()),
4544 parser: ParserKind::TreeSitter,
4545 symbols: vec![test_symbol(
4546 "src/package/module.py",
4547 SymbolKind::Function,
4548 "run",
4549 )],
4550 relations: Vec::new(),
4551 })?;
4552 store.replace_symbol_graph(&SymbolGraph {
4553 path: "src/main.py".to_string(),
4554 language: Some("python".to_string()),
4555 parser: ParserKind::TreeSitter,
4556 symbols: vec![test_symbol("src/main.py", SymbolKind::Function, "main")],
4557 relations: vec![
4558 SymbolRelation {
4559 path: "src/main.py".to_string(),
4560 source_name: "<module>".to_string(),
4561 target_name: "import package.module as service".to_string(),
4562 kind: RelationKind::Imports,
4563 line: 1,
4564 context: "import package.module as service".to_string(),
4565 parser: ParserKind::TreeSitter,
4566 },
4567 SymbolRelation {
4568 path: "src/main.py".to_string(),
4569 source_name: "main".to_string(),
4570 target_name: "service.run".to_string(),
4571 kind: RelationKind::Calls,
4572 line: 2,
4573 context: "service.run()".to_string(),
4574 parser: ParserKind::TreeSitter,
4575 },
4576 ],
4577 })?;
4578
4579 assert_single_called_by(
4580 &build_file_summary(&store, Path::new("src/package/module.py"), 10)?,
4581 "run",
4582 "src/main.py::main",
4583 )
4584 }
4585
4586 #[test]
4587 fn file_summary_resolves_python_no_alias_import_when_name_is_ambiguous()
4588 -> Result<(), Box<dyn Error>> {
4589 let temp = tempfile::tempdir()?;
4590 let root = temp.path();
4591 fs::create_dir_all(root.join("src/package"))?;
4592 fs::write(root.join("src/package/module.py"), "def run():\n pass\n")?;
4593 fs::write(
4594 root.join("src/main.py"),
4595 "from package.module import run\nrun()\n",
4596 )?;
4597 let mut store = AtlasStore::in_memory()?;
4598 store.set_project_root(root)?;
4599 store.replace_scan(&[
4600 test_node("src/package/module.py", "hash-module"),
4601 test_node("src/main.py", "hash-main"),
4602 ])?;
4603 store.replace_symbol_graph(&SymbolGraph {
4604 path: "src/package/module.py".to_string(),
4605 language: Some("python".to_string()),
4606 parser: ParserKind::TreeSitter,
4607 symbols: vec![test_symbol(
4608 "src/package/module.py",
4609 SymbolKind::Function,
4610 "run",
4611 )],
4612 relations: Vec::new(),
4613 })?;
4614 store.replace_symbol_graph(&SymbolGraph {
4615 path: "src/main.py".to_string(),
4616 language: Some("python".to_string()),
4617 parser: ParserKind::TreeSitter,
4618 symbols: vec![
4619 test_symbol("src/main.py", SymbolKind::Function, "main"),
4620 test_symbol("src/main.py", SymbolKind::Import, "run"),
4621 ],
4622 relations: vec![
4623 SymbolRelation {
4624 path: "src/main.py".to_string(),
4625 source_name: "<module>".to_string(),
4626 target_name: "from package.module import run".to_string(),
4627 kind: RelationKind::Imports,
4628 line: 1,
4629 context: "from package.module import run".to_string(),
4630 parser: ParserKind::TreeSitter,
4631 },
4632 SymbolRelation {
4633 path: "src/main.py".to_string(),
4634 source_name: "main".to_string(),
4635 target_name: "run".to_string(),
4636 kind: RelationKind::Calls,
4637 line: 2,
4638 context: "run()".to_string(),
4639 parser: ParserKind::TreeSitter,
4640 },
4641 ],
4642 })?;
4643
4644 assert_single_called_by(
4645 &build_file_summary(&store, Path::new("src/package/module.py"), 10)?,
4646 "run",
4647 "src/main.py::main",
4648 )
4649 }
4650
4651 #[test]
4652 fn file_summary_rejects_ambiguous_import_alias_called_by() -> Result<(), Box<dyn Error>> {
4653 let temp = tempfile::tempdir()?;
4654 let root = temp.path();
4655 fs::create_dir_all(root.join("src/foo"))?;
4656 fs::create_dir_all(root.join("src/bar"))?;
4657 fs::write(root.join("src/foo/service.py"), "def run():\n pass\n")?;
4658 fs::write(root.join("src/bar/service.py"), "def run():\n pass\n")?;
4659 fs::write(
4660 root.join("src/main.py"),
4661 "from foo.service import run as call_service\nfrom bar.service import run as call_service\ncall_service()\n",
4662 )?;
4663 let mut store = AtlasStore::in_memory()?;
4664 store.set_project_root(root)?;
4665 store.replace_scan(&[
4666 test_node("src/foo/service.py", "hash-foo"),
4667 test_node("src/bar/service.py", "hash-bar"),
4668 test_node("src/main.py", "hash-main"),
4669 ])?;
4670 for path in ["src/foo/service.py", "src/bar/service.py"] {
4671 store.replace_symbol_graph(&SymbolGraph {
4672 path: path.to_string(),
4673 language: Some("python".to_string()),
4674 parser: ParserKind::TreeSitter,
4675 symbols: vec![test_symbol(path, SymbolKind::Function, "run")],
4676 relations: Vec::new(),
4677 })?;
4678 }
4679 store.replace_symbol_graph(&SymbolGraph {
4680 path: "src/main.py".to_string(),
4681 language: Some("python".to_string()),
4682 parser: ParserKind::TreeSitter,
4683 symbols: vec![test_symbol("src/main.py", SymbolKind::Function, "main")],
4684 relations: vec![
4685 SymbolRelation {
4686 path: "src/main.py".to_string(),
4687 source_name: "<module>".to_string(),
4688 target_name: "from foo.service import run as call_service".to_string(),
4689 kind: RelationKind::Imports,
4690 line: 1,
4691 context: "from foo.service import run as call_service".to_string(),
4692 parser: ParserKind::TreeSitter,
4693 },
4694 SymbolRelation {
4695 path: "src/main.py".to_string(),
4696 source_name: "<module>".to_string(),
4697 target_name: "from bar.service import run as call_service".to_string(),
4698 kind: RelationKind::Imports,
4699 line: 2,
4700 context: "from bar.service import run as call_service".to_string(),
4701 parser: ParserKind::TreeSitter,
4702 },
4703 SymbolRelation {
4704 path: "src/main.py".to_string(),
4705 source_name: "main".to_string(),
4706 target_name: "call_service".to_string(),
4707 kind: RelationKind::Calls,
4708 line: 3,
4709 context: "call_service()".to_string(),
4710 parser: ParserKind::TreeSitter,
4711 },
4712 ],
4713 })?;
4714
4715 for path in ["src/foo/service.py", "src/bar/service.py"] {
4716 let report = build_file_summary(&store, Path::new(path), 10)?;
4717 let run = report
4718 .functions
4719 .iter()
4720 .find(|symbol| symbol.name == "run")
4721 .ok_or_else(|| io::Error::other("run summary missing"))?;
4722 require_eq(
4723 &run.called_by,
4724 &Vec::<String>::new(),
4725 "ambiguous import alias called-by",
4726 )?;
4727 }
4728 Ok(())
4729 }
4730
4731 #[test]
4732 fn file_summary_marks_indexed_metadata_fallback() -> Result<(), Box<dyn Error>> {
4733 let temp = tempfile::tempdir()?;
4734 let root = temp.path();
4735 let mut store = AtlasStore::in_memory()?;
4736 store.set_project_root(root)?;
4737 store.replace_scan(&[test_node("src/missing.rs", "hash-missing")])?;
4738
4739 let report = build_file_summary(&store, Path::new("src/missing.rs"), 10)?;
4740 require_eq(
4741 &report.source_status,
4742 &SOURCE_STATUS_INDEXED.to_string(),
4743 "source status",
4744 )?;
4745 if report.source_error.is_empty() {
4746 return Err(io::Error::other("source fallback error was empty").into());
4747 }
4748 Ok(())
4749 }
4750
4751 #[test]
4752 fn search_uses_globset_and_stops_after_requested_page() -> Result<(), Box<dyn Error>> {
4753 let temp = tempfile::tempdir()?;
4754 let root = temp.path();
4755 fs::create_dir_all(root.join("src"))?;
4756 fs::create_dir_all(root.join("docs"))?;
4757 fs::write(root.join("src").join("a.rs"), "needle one\n")?;
4758 fs::write(root.join("src").join("b.rs"), "needle two\n")?;
4759 fs::write(root.join("docs").join("readme.md"), "needle docs\n")?;
4760 let mut store = AtlasStore::in_memory()?;
4761 store.set_project_root(root)?;
4762 store.replace_scan(&[
4763 test_node("src/a.rs", "hash-a"),
4764 test_node("src/b.rs", "hash-b"),
4765 test_node("docs/readme.md", "hash-docs"),
4766 ])?;
4767 index_test_file_texts(
4768 &mut store,
4769 root,
4770 &[
4771 test_node("src/a.rs", "hash-a"),
4772 test_node("src/b.rs", "hash-b"),
4773 test_node("docs/readme.md", "hash-docs"),
4774 ],
4775 )?;
4776
4777 let report =
4778 search_indexed_files(&store, "needle", false, false, false, Some("*.rs"), 0, 0, 1)?;
4779 require_eq(&report.returned, &1, "returned rows")?;
4780 require_eq(&report.searched_files, &1, "bounded searched files")?;
4781 require_eq(&report.truncated, &true, "truncated flag")?;
4782 require_eq(&report.observed_total, &report.total, "observed total")?;
4783 require_eq(
4784 &report.total_is_complete,
4785 &false,
4786 "truncated search completeness",
4787 )?;
4788
4789 let report = search_indexed_files(
4790 &store,
4791 "needle",
4792 false,
4793 false,
4794 false,
4795 Some("src\\*.rs"),
4796 0,
4797 0,
4798 10,
4799 )?;
4800 require_eq(&report.returned, &2, "windows glob returned rows")?;
4801 require_eq(&report.total_is_complete, &true, "complete search total")?;
4802 if report
4803 .results
4804 .iter()
4805 .any(|row| row.path == "docs/readme.md")
4806 {
4807 return Err(io::Error::other("globset filter included docs/readme.md").into());
4808 }
4809 Ok(())
4810 }
4811
4812 #[test]
4813 fn search_fts_candidates_preserve_fallback_results_and_unsafe_shapes_fall_back()
4814 -> Result<(), Box<dyn Error>> {
4815 let temp = tempfile::tempdir()?;
4816 let root = temp.path();
4817 fs::create_dir_all(root.join("src"))?;
4818 fs::create_dir_all(root.join("docs"))?;
4819 fs::write(
4820 root.join("src/a.rs"),
4821 "Needle alpha\nneedle-beta\nnëedle unicode\n",
4822 )?;
4823 fs::write(root.join("src/b.rs"), "prefixneedlesuffix gamma\n")?;
4824 fs::write(root.join("docs/readme.md"), "needle docs\n")?;
4825 let nodes = [
4826 test_node("src/a.rs", "hash-a"),
4827 test_node("src/b.rs", "hash-b"),
4828 test_node("docs/readme.md", "hash-docs"),
4829 ];
4830 let mut store = AtlasStore::in_memory()?;
4831 store.set_project_root(root)?;
4832 store.replace_scan(&nodes)?;
4833 index_test_file_texts(&mut store, root, &nodes)?;
4834
4835 let lexical = search_indexed_files_with_control(
4836 &store,
4837 &SearchQuery {
4838 pattern: "needle",
4839 regex: false,
4840 fuzzy: false,
4841 case_sensitive: false,
4842 file_pattern: Some("src/*.rs"),
4843 context_lines: 0,
4844 start_index: 0,
4845 limit: 20,
4846 retrieval_mode: SearchRetrievalMode::Lexical,
4847 },
4848 None,
4849 )?;
4850 require_eq(
4851 &lexical.strategy,
4852 &"fts5-bm25-candidates-exact-verified".to_string(),
4853 "safe literal strategy",
4854 )?;
4855 require_eq(&lexical.candidate_files, &2, "safe literal candidates")?;
4856
4857 let fallback = search_indexed_files_with_control(
4858 &store,
4859 &SearchQuery {
4860 pattern: "needle",
4861 regex: true,
4862 fuzzy: false,
4863 case_sensitive: false,
4864 file_pattern: Some("src/*.rs"),
4865 context_lines: 0,
4866 start_index: 0,
4867 limit: 20,
4868 retrieval_mode: SearchRetrievalMode::Lexical,
4869 },
4870 None,
4871 )?;
4872 let lexical_rows = lexical
4873 .results
4874 .iter()
4875 .map(|row| (&row.path, row.line, &row.text))
4876 .collect::<Vec<_>>();
4877 let fallback_rows = fallback
4878 .results
4879 .iter()
4880 .map(|row| (&row.path, row.line, &row.text))
4881 .collect::<Vec<_>>();
4882 require_eq(
4883 &lexical_rows,
4884 &fallback_rows,
4885 "FTS and fallback exact results",
4886 )?;
4887 require_eq(
4888 &fallback.strategy,
4889 &"persisted-text-fallback".to_string(),
4890 "regex fallback strategy",
4891 )?;
4892
4893 for (pattern, regex, fuzzy, expected_rows) in [
4894 (
4895 "ne",
4896 false,
4897 false,
4898 vec!["src/a.rs:1", "src/a.rs:2", "src/b.rs:1"],
4899 ),
4900 ("needle-", false, false, vec!["src/a.rs:2"]),
4901 ("nëedle", false, false, vec!["src/a.rs:3"]),
4902 (
4903 "needle",
4904 false,
4905 true,
4906 vec!["src/a.rs:1", "src/a.rs:2", "src/b.rs:1"],
4907 ),
4908 ] {
4909 let report = search_indexed_files_with_control(
4910 &store,
4911 &SearchQuery {
4912 pattern,
4913 regex,
4914 fuzzy,
4915 case_sensitive: false,
4916 file_pattern: Some("src/*.rs"),
4917 context_lines: 0,
4918 start_index: 0,
4919 limit: 20,
4920 retrieval_mode: SearchRetrievalMode::Lexical,
4921 },
4922 None,
4923 )?;
4924 require_eq(
4925 &report.strategy,
4926 &"persisted-text-fallback".to_string(),
4927 "unsafe shape fallback strategy",
4928 )?;
4929 require_eq(&report.candidate_files, &0, "unsafe shape candidates")?;
4930 let rows = report
4931 .results
4932 .iter()
4933 .map(|row| format!("{}:{}", row.path, row.line))
4934 .collect::<Vec<_>>();
4935 require_eq(
4936 &rows,
4937 &expected_rows
4938 .into_iter()
4939 .map(str::to_string)
4940 .collect::<Vec<_>>(),
4941 "unsafe fallback exact rows",
4942 )?;
4943 }
4944
4945 for (pattern, expected_rows) in [("Needle", 1), ("needle", 2)] {
4946 let exact = search_indexed_files_with_control(
4947 &store,
4948 &SearchQuery {
4949 pattern,
4950 regex: false,
4951 fuzzy: false,
4952 case_sensitive: true,
4953 file_pattern: Some("src/*.rs"),
4954 context_lines: 0,
4955 start_index: 0,
4956 limit: 20,
4957 retrieval_mode: SearchRetrievalMode::Lexical,
4958 },
4959 None,
4960 )?;
4961 let regex = search_indexed_files_with_control(
4962 &store,
4963 &SearchQuery {
4964 pattern,
4965 regex: true,
4966 fuzzy: false,
4967 case_sensitive: true,
4968 file_pattern: Some("src/*.rs"),
4969 context_lines: 0,
4970 start_index: 0,
4971 limit: 20,
4972 retrieval_mode: SearchRetrievalMode::Lexical,
4973 },
4974 None,
4975 )?;
4976 let exact_rows = exact
4977 .results
4978 .iter()
4979 .map(|row| (&row.path, row.line, &row.text))
4980 .collect::<Vec<_>>();
4981 let regex_rows = regex
4982 .results
4983 .iter()
4984 .map(|row| (&row.path, row.line, &row.text))
4985 .collect::<Vec<_>>();
4986 require_eq(&exact_rows, ®ex_rows, "case-sensitive equivalence")?;
4987 require_eq(&exact.returned, &expected_rows, "case-sensitive exact rows")?;
4988 }
4989 Ok(())
4990 }
4991
4992 #[test]
4993 fn search_fts_candidate_overflow_uses_complete_persisted_text_fallback()
4994 -> Result<(), Box<dyn Error>> {
4995 const MATCHING_FILES: usize = MAX_FILE_TEXT_FTS_CANDIDATES + 1;
4996 const CONTENT: &str = "needle\n";
4997 let mut store = AtlasStore::in_memory()?;
4998 let paths = (0..MATCHING_FILES)
4999 .map(|index| format!("overflow/{index:04}.rs"))
5000 .collect::<Vec<_>>();
5001 let nodes = paths
5002 .iter()
5003 .map(|path| test_node(path, "hash"))
5004 .collect::<Vec<_>>();
5005 let texts = paths
5006 .iter()
5007 .map(|path| IndexedFileText {
5008 path: path.clone(),
5009 content_hash: Some("hash".to_string()),
5010 byte_count: CONTENT.len(),
5011 line_count: 1,
5012 content: CONTENT.to_string(),
5013 })
5014 .collect::<Vec<_>>();
5015 store.replace_scan(&nodes)?;
5016 store.replace_file_texts_for_paths(&paths, &texts)?;
5017
5018 let request = SearchQuery {
5019 pattern: "needle",
5020 regex: false,
5021 fuzzy: false,
5022 case_sensitive: false,
5023 file_pattern: Some("overflow/*.rs"),
5024 context_lines: 0,
5025 start_index: MATCHING_FILES - 1,
5026 limit: 1,
5027 retrieval_mode: SearchRetrievalMode::Lexical,
5028 };
5029 let overflow = search_indexed_files_with_control(&store, &request, None)?;
5030 require_eq(
5031 &overflow.strategy,
5032 &"persisted-text-fallback".to_string(),
5033 "overflow fallback strategy",
5034 )?;
5035 require_eq(
5036 &overflow.candidate_files,
5037 &MAX_FILE_TEXT_FTS_CANDIDATES,
5038 "overflow retained candidates",
5039 )?;
5040 require_eq(
5041 &overflow.searched_files,
5042 &MATCHING_FILES,
5043 "overflow fallback searched files",
5044 )?;
5045 require_eq(
5046 &overflow.searched_bytes,
5047 &(MATCHING_FILES * CONTENT.len()),
5048 "overflow fallback searched bytes",
5049 )?;
5050 require_eq(
5051 &overflow.results[0].path,
5052 &format!("overflow/{:04}.rs", MATCHING_FILES - 1),
5053 "overflow fallback exact path order",
5054 )?;
5055
5056 let authoritative = search_indexed_files_with_control(
5057 &store,
5058 &SearchQuery {
5059 regex: true,
5060 ..request
5061 },
5062 None,
5063 )?;
5064 let overflow_rows = overflow
5065 .results
5066 .iter()
5067 .map(|row| (&row.path, row.line, &row.text))
5068 .collect::<Vec<_>>();
5069 let authoritative_rows = authoritative
5070 .results
5071 .iter()
5072 .map(|row| (&row.path, row.line, &row.text))
5073 .collect::<Vec<_>>();
5074 require_eq(
5075 &overflow_rows,
5076 &authoritative_rows,
5077 "overflow and authoritative fallback rows",
5078 )?;
5079 Ok(())
5080 }
5081
5082 #[test]
5083 fn search_reports_resource_bounds_cancellation_and_optional_capability_state()
5084 -> Result<(), Box<dyn Error>> {
5085 let temp = tempfile::tempdir()?;
5086 let root = temp.path();
5087 fs::create_dir_all(root.join("src"))?;
5088 fs::write(root.join("src/a.rs"), "needle one\n")?;
5089 fs::write(root.join("src/b.rs"), "needle two\n")?;
5090 let nodes = [
5091 test_node("src/a.rs", "hash-a"),
5092 test_node("src/b.rs", "hash-b"),
5093 ];
5094 let mut store = AtlasStore::in_memory()?;
5095 store.set_project_root(root)?;
5096 store.replace_scan(&nodes)?;
5097 index_test_file_texts(&mut store, root, &nodes)?;
5098 let query = SearchQuery {
5099 pattern: "needle",
5100 regex: true,
5101 fuzzy: false,
5102 case_sensitive: false,
5103 file_pattern: Some("src/*.rs"),
5104 context_lines: 0,
5105 start_index: 0,
5106 limit: 20,
5107 retrieval_mode: SearchRetrievalMode::Lexical,
5108 };
5109
5110 let file_bounded = search_indexed_files_with_bounds(
5111 &store,
5112 &query,
5113 None,
5114 SearchBounds {
5115 selected_files: 1,
5116 selected_bytes: usize::MAX,
5117 elapsed: Duration::from_secs(1),
5118 retained_bytes: usize::MAX,
5119 },
5120 )?;
5121 require_eq(
5122 &file_bounded.searched_files,
5123 &1,
5124 "file bound searched files",
5125 )?;
5126 require_eq(&file_bounded.truncated, &true, "file bound truncation")?;
5127 require_eq(
5128 &file_bounded.truncation_reason,
5129 &Some("selected-file-limit".to_string()),
5130 "file bound reason",
5131 )?;
5132
5133 let byte_bounded = search_indexed_files_with_bounds(
5134 &store,
5135 &query,
5136 None,
5137 SearchBounds {
5138 selected_files: usize::MAX,
5139 selected_bytes: 1,
5140 elapsed: Duration::from_secs(1),
5141 retained_bytes: usize::MAX,
5142 },
5143 )?;
5144 require_eq(
5145 &byte_bounded.searched_files,
5146 &0,
5147 "byte bound searched files",
5148 )?;
5149 require_eq(
5150 &byte_bounded.truncation_reason,
5151 &Some("selected-byte-limit".to_string()),
5152 "byte bound reason",
5153 )?;
5154
5155 let output_bounded = search_indexed_files_with_bounds(
5156 &store,
5157 &query,
5158 None,
5159 SearchBounds {
5160 selected_files: usize::MAX,
5161 selected_bytes: usize::MAX,
5162 elapsed: Duration::from_secs(1),
5163 retained_bytes: 1,
5164 },
5165 )?;
5166 require_eq(&output_bounded.returned, &0, "output bound returned rows")?;
5167 require_eq(
5168 &output_bounded.truncation_reason,
5169 &Some("retained-byte-limit".to_string()),
5170 "output bound reason",
5171 )?;
5172
5173 let cancellation = IndexCancellation::new();
5174 cancellation.cancel();
5175 let control = IndexWorkControl::new(cancellation, None);
5176 let cancelled = search_indexed_files_with_control(&store, &query, Some(&control));
5177 if !matches!(cancelled, Err(ServiceError::Db(DbError::IndexWork(_)))) {
5178 return Err(io::Error::other("search cancellation was not typed").into());
5179 }
5180 let expired = IndexWorkControl::new(IndexCancellation::new(), Some(Duration::ZERO));
5181 let deadline = search_indexed_files_with_control(&store, &query, Some(&expired))?;
5182 require_eq(&deadline.truncated, &true, "deadline truncation")?;
5183 require_eq(
5184 &deadline.truncation_reason,
5185 &Some("elapsed-time-limit".to_string()),
5186 "deadline truncation reason",
5187 )?;
5188 require_eq(
5189 &deadline.total_is_complete,
5190 &false,
5191 "deadline total completeness",
5192 )?;
5193
5194 let line_cancellation = IndexCancellation::new();
5195 let line_control = IndexWorkControl::new(line_cancellation.clone(), None);
5196 line_cancellation.cancel();
5197 let mut line_report = file_bounded;
5198 let line_match = append_line_matches(
5199 &mut line_report,
5200 "src/a.rs",
5201 &["needle"],
5202 &LineMatcher::Literal {
5203 needle: "needle".to_string(),
5204 case_sensitive: true,
5205 },
5206 0,
5207 1,
5208 usize::MAX,
5209 &line_control,
5210 );
5211 if !matches!(
5212 line_match,
5213 Err(IndexWorkFailure::Cancelled {
5214 stage: IndexWorkStage::TextIndex
5215 })
5216 ) {
5217 return Err(io::Error::other("in-memory line matching ignored cancellation").into());
5218 }
5219
5220 let maximum_pattern = "a".repeat(SEARCH_MAX_PATTERN_BYTES);
5221 search_indexed_files_with_control(
5222 &store,
5223 &SearchQuery {
5224 pattern: &maximum_pattern,
5225 regex: false,
5226 limit: 0,
5227 ..query
5228 },
5229 None,
5230 )?;
5231 let oversized_pattern = "a".repeat(SEARCH_MAX_PATTERN_BYTES + 1);
5232 if !matches!(
5233 search_indexed_files_with_control(
5234 &store,
5235 &SearchQuery {
5236 pattern: &oversized_pattern,
5237 regex: false,
5238 limit: 0,
5239 ..query
5240 },
5241 None,
5242 ),
5243 Err(ServiceError::InvalidInput(_))
5244 ) {
5245 return Err(io::Error::other("oversized search pattern was accepted").into());
5246 }
5247 let maximum_file_pattern = "a".repeat(SEARCH_MAX_FILE_PATTERN_BYTES);
5248 search_indexed_files_with_control(
5249 &store,
5250 &SearchQuery {
5251 pattern: "needle",
5252 regex: false,
5253 file_pattern: Some(&maximum_file_pattern),
5254 limit: 0,
5255 ..query
5256 },
5257 None,
5258 )?;
5259 let oversized_file_pattern = "a".repeat(SEARCH_MAX_FILE_PATTERN_BYTES + 1);
5260 if !matches!(
5261 search_indexed_files_with_control(
5262 &store,
5263 &SearchQuery {
5264 pattern: "needle",
5265 regex: false,
5266 file_pattern: Some(&oversized_file_pattern),
5267 limit: 0,
5268 ..query
5269 },
5270 None,
5271 ),
5272 Err(ServiceError::InvalidInput(_))
5273 ) {
5274 return Err(io::Error::other("oversized search file pattern was accepted").into());
5275 }
5276
5277 let unavailable = search_indexed_files_with_control(
5278 &store,
5279 &SearchQuery {
5280 retrieval_mode: SearchRetrievalMode::Semantic,
5281 ..query
5282 },
5283 None,
5284 );
5285 if !matches!(
5286 unavailable,
5287 Err(ServiceError::SearchCapabilityUnavailable {
5288 requested_mode: SearchRetrievalMode::Semantic,
5289 state: SEARCH_SEMANTIC_UNAVAILABLE_STATE,
5290 guidance: SEARCH_SEMANTIC_RECOVERY,
5291 })
5292 ) {
5293 return Err(io::Error::other("semantic unavailable state was not typed").into());
5294 }
5295 Ok(())
5296 }
5297
5298 #[test]
5299 fn file_glob_filter_matches_repository_paths() -> Result<(), Box<dyn Error>> {
5300 let nodes = vec![
5301 test_indexed_node("src/a.rs", "hash-a"),
5302 test_indexed_node("src/nested/b.rs", "hash-b"),
5303 test_indexed_node("docs/readme.md", "hash-docs"),
5304 ];
5305
5306 let filtered = filter_files_by_glob(nodes.clone(), Some("*.rs"))?;
5307 require_eq(&filtered.len(), &2, "rs glob count")?;
5308 let matcher = FilePathMatcher::new(Some("*.rs"))?;
5309 require_eq(&matcher.filters(), &true, "compiled glob filters")?;
5310 require_eq(&matcher.is_match("src/a.rs"), &true, "compiled nested rs")?;
5311 require_eq(&matcher.is_match("a.rs"), &true, "compiled basename rs")?;
5312 require_eq(
5313 &matcher.is_match("docs/readme.md"),
5314 &false,
5315 "compiled markdown miss",
5316 )?;
5317
5318 let nested = filter_files_by_glob(nodes, Some("src\\nested\\*.rs"))?;
5319 require_eq(&nested.len(), &1, "windows glob count")?;
5320 require_eq(
5321 &nested[0].node.path,
5322 &"src/nested/b.rs".to_string(),
5323 "windows glob path",
5324 )?;
5325 Ok(())
5326 }
5327
5328 #[test]
5329 fn ranked_file_nodes_uses_shared_glob_policy() -> Result<(), Box<dyn Error>> {
5330 let mut store = AtlasStore::in_memory()?;
5331 store.replace_scan(&[
5332 test_node("src/a.rs", "hash-a"),
5333 test_node("src/nested/b.rs", "hash-b"),
5334 test_node("docs/readme.md", "hash-docs"),
5335 ])?;
5336 for path in ["src/a.rs", "src/nested/b.rs", "docs/readme.md"] {
5337 store.set_purpose(path, "needle orientation target", PurposeSource::Agent)?;
5338 store.set_node_summary(path, "needle indexed summary")?;
5339 }
5340
5341 let selected = load_ranked_file_nodes(&store, "needle", None, Some("*.rs"), 10, false)?;
5342 require_eq(&selected.len(), &2, "ranked rs glob count")?;
5343 if selected
5344 .iter()
5345 .any(|node| node.node.path == "docs/readme.md")
5346 {
5347 return Err(io::Error::other("ranked glob included docs/readme.md").into());
5348 }
5349
5350 let nested =
5351 load_ranked_file_nodes(&store, "needle", None, Some("src/nested/*.rs"), 10, false)?;
5352 require_eq(&nested.len(), &1, "ranked nested glob count")?;
5353 require_eq(
5354 &nested[0].node.path,
5355 &"src/nested/b.rs".to_string(),
5356 "ranked nested glob path",
5357 )?;
5358 Ok(())
5359 }
5360
5361 #[test]
5362 fn ranked_file_nodes_can_include_indexed_text_hits() -> Result<(), Box<dyn Error>> {
5363 let temp = tempfile::tempdir()?;
5364 let root = temp.path();
5365 fs::create_dir_all(root.join("src"))?;
5366 fs::create_dir_all(root.join("docs"))?;
5367 fs::write(
5368 root.join("src").join("owner.rs"),
5369 "const ROUTE = \"hiddenNeedle\";\n",
5370 )?;
5371 fs::write(root.join("docs").join("owner.md"), "hiddenNeedle docs\n")?;
5372 let mut store = AtlasStore::in_memory()?;
5373 store.set_project_root(root)?;
5374 let nodes = [
5375 test_node("src/owner.rs", "hash-src-owner"),
5376 test_node("docs/owner.md", "hash-doc-owner"),
5377 ];
5378 store.replace_scan(&nodes)?;
5379 index_test_file_texts(&mut store, root, &nodes)?;
5380
5381 let default_ranked =
5382 load_ranked_file_nodes(&store, "hiddenNeedle", Some("src"), Some("*.rs"), 10, false)?;
5383 require_eq(
5384 &default_ranked.len(),
5385 &0,
5386 "default ranking ignores content-only hits",
5387 )?;
5388
5389 let content_ranked =
5390 load_ranked_file_nodes(&store, "hiddenNeedle", Some("src"), Some("*.rs"), 10, true)?;
5391 require_eq(&content_ranked.len(), &1, "content-aware ranked count")?;
5392 require_eq(
5393 &content_ranked[0].node.path,
5394 &"src/owner.rs".to_string(),
5395 "content-aware ranked path",
5396 )?;
5397 Ok(())
5398 }
5399
5400 #[test]
5401 fn ranked_file_reasons_match_indexed_ranking_signals() -> Result<(), Box<dyn Error>> {
5402 let temp = tempfile::tempdir()?;
5403 let root = temp.path();
5404 fs::create_dir_all(root.join("src"))?;
5405 fs::create_dir_all(root.join("tests"))?;
5406 fs::write(
5407 root.join("src").join("installer.rs"),
5408 "pub fn install_runtime() { let _marker = \"hiddenNeedle\"; }\n",
5409 )?;
5410 fs::write(
5411 root.join("tests").join("installer.rs"),
5412 "#[test]\nfn installer_pair() {}\n",
5413 )?;
5414 fs::write(root.join("src").join("noise.rs"), "pub fn unrelated() {}\n")?;
5415
5416 let mut store = AtlasStore::in_memory()?;
5417 store.set_project_root(root)?;
5418 let nodes = [
5419 test_node("src/installer.rs", "hash-installer"),
5420 test_node("tests/installer.rs", "hash-installer-test"),
5421 test_node("src/noise.rs", "hash-noise"),
5422 ];
5423 store.replace_scan(&nodes)?;
5424 store.set_purpose(
5425 "src/installer.rs",
5426 "Installer runtime release target",
5427 PurposeSource::Agent,
5428 )?;
5429 store.set_node_summary("src/installer.rs", "Release installer summary")?;
5430 store.replace_symbol_graph(&SymbolGraph {
5431 path: "src/installer.rs".to_string(),
5432 language: Some("rust".to_string()),
5433 parser: ParserKind::TreeSitter,
5434 symbols: vec![test_symbol(
5435 "src/installer.rs",
5436 SymbolKind::Function,
5437 "install_runtime",
5438 )],
5439 relations: Vec::new(),
5440 })?;
5441 index_test_file_texts(&mut store, root, &nodes)?;
5442
5443 let ranked = load_ranked_file_nodes_with_reasons(
5444 &store,
5445 "installer runtime release hiddenNeedle install_runtime",
5446 None,
5447 Some("*.rs"),
5448 2,
5449 true,
5450 )?;
5451 require_eq(&ranked.len(), &2, "ranked source/test pair count")?;
5452 require_eq(
5453 &ranked[0].node.node.path,
5454 &"src/installer.rs".to_string(),
5455 "strong indexed signal ranks first",
5456 )?;
5457 require_reason(&ranked[0].reasons, "path matched install")?;
5458 require_reason(&ranked[0].reasons, "purpose matched install")?;
5459 require_reason(&ranked[0].reasons, "summary matched install")?;
5460 require_reason(&ranked[0].reasons, "symbol install_runtime matched install")?;
5461 require_reason(&ranked[0].reasons, "indexed text matched hiddenneedle")?;
5462 require_reason(&ranked[0].reasons, "paired test file tests/installer.rs")?;
5463 require_eq(
5464 &ranked[0]
5465 .reason_codes
5466 .contains(&RankedReasonCode::ReviewedPurpose),
5467 &true,
5468 "reviewed purpose reason code",
5469 )?;
5470 require_eq(
5471 &ranked[0].connection_counts,
5472 &Vec::new(),
5473 "deterministic no-graph fallback counts",
5474 )?;
5475 require_eq(
5476 &ranked[0].next_call.capability,
5477 &NavigationNextCapability::Summary,
5478 "no-graph fallback next call",
5479 )?;
5480 if !ranked.iter().any(|node| {
5481 node.node.node.path == "tests/installer.rs"
5482 && node
5483 .reasons
5484 .iter()
5485 .any(|reason| reason == "paired source file src/installer.rs")
5486 }) {
5487 return Err(io::Error::other("paired test result/reason was missing").into());
5488 }
5489 Ok(())
5490 }
5491
5492 #[test]
5493 fn ranked_evidence_keeps_reviewed_purpose_ahead_of_bounded_graph_popularity()
5494 -> Result<(), Box<dyn Error>> {
5495 let store = AtlasStore::in_memory()?;
5496 let mut popular = test_indexed_node("src/popular.rs", "popular-hash");
5497 popular.purpose = Purpose {
5498 path: popular.node.path.clone(),
5499 purpose: Some("generated auth suggestion".to_string()),
5500 source: PurposeSource::Generated,
5501 status: PurposeStatus::Suggested,
5502 };
5503 popular.summary = None;
5504 let counts = [
5505 RankedConnectionKind::Package,
5506 RankedConnectionKind::Import,
5507 RankedConnectionKind::Call,
5508 RankedConnectionKind::Reference,
5509 RankedConnectionKind::Test,
5510 RankedConnectionKind::Route,
5511 RankedConnectionKind::Config,
5512 ]
5513 .into_iter()
5514 .map(|kind| projectatlas_core::RankedConnectionCount {
5515 kind,
5516 count: RANKED_CONNECTION_FAMILY_LIMIT as usize,
5517 truncated: true,
5518 })
5519 .collect::<Vec<_>>();
5520 let popular_connections = RepositoryNavigationConnections {
5521 path: popular.node.path.clone(),
5522 counts,
5523 connections: vec![projectatlas_core::RankedConnection {
5524 kind: RankedConnectionKind::Call,
5525 direction: projectatlas_core::RankedConnectionDirection::Inbound,
5526 target: RankedConnectionTarget::Local {
5527 path: "src/auth.rs".to_string(),
5528 symbol: Some("authenticate".to_string()),
5529 },
5530 }],
5531 truncated: true,
5532 };
5533 let popular_evidence = ranked_node_evidence(
5534 &store,
5535 &popular,
5536 &["auth".to_string()],
5537 "",
5538 &HashSet::new(),
5539 &popular_connections,
5540 )?;
5541 require_eq(
5542 &popular_evidence.reviewed_purpose,
5543 &false,
5544 "generated purpose authority",
5545 )?;
5546 if popular_evidence.context_score > 32 {
5547 return Err(io::Error::other("graph popularity was not saturated").into());
5548 }
5549
5550 let mut reviewed = test_indexed_node("src/responsibility.rs", "reviewed-hash");
5551 reviewed.purpose.purpose = Some("Own auth responsibility".to_string());
5552 reviewed.summary = None;
5553 let reviewed_evidence = ranked_node_evidence(
5554 &store,
5555 &reviewed,
5556 &["auth".to_string()],
5557 "",
5558 &HashSet::new(),
5559 &RepositoryNavigationConnections {
5560 path: reviewed.node.path.clone(),
5561 counts: Vec::new(),
5562 connections: Vec::new(),
5563 truncated: false,
5564 },
5565 )?;
5566 require_eq(
5567 &reviewed_evidence.reviewed_purpose,
5568 &true,
5569 "reviewed purpose tier",
5570 )?;
5571 require_eq(
5572 &ranked_evidence_order(&reviewed_evidence, &popular_evidence),
5573 &std::cmp::Ordering::Less,
5574 "reviewed purpose dominance",
5575 )?;
5576 Ok(())
5577 }
5578
5579 #[test]
5580 fn ranked_service_preserves_dominant_tiers_across_more_than_one_hundred_weaker_matches()
5581 -> Result<(), Box<dyn Error>> {
5582 let mut store = AtlasStore::in_memory()?;
5583 let mut nodes = vec![
5584 test_node("needle", "hash-exact"),
5585 test_node("deep/needle", "hash-name"),
5586 test_node("reviewed.rs", "hash-reviewed"),
5587 ];
5588 nodes
5589 .extend((0..130).map(|index| test_node(&format!("weak/needle-{index:03}.rs"), "hash")));
5590 store.replace_scan(&nodes)?;
5591 store.set_purpose(
5592 "reviewed.rs",
5593 "Own needle responsibility",
5594 PurposeSource::Agent,
5595 )?;
5596 for index in 0..130 {
5597 let path = format!("weak/needle-{index:03}.rs");
5598 store.set_suggested_purpose(&path, "Generated needle suggestion")?;
5599 store.set_node_summary(&path, "Observed needle summary")?;
5600 }
5601
5602 let ranked = load_ranked_file_nodes_with_reasons(&store, "needle", None, None, 3, false)?;
5603 require_eq(
5604 &ranked
5605 .iter()
5606 .map(|node| node.node.node.path.as_str())
5607 .collect::<Vec<_>>(),
5608 &vec!["needle", "deep/needle", "reviewed.rs"],
5609 "service exact path basename and reviewed-purpose order",
5610 )?;
5611 require_eq(
5612 &ranked[0]
5613 .reason_codes
5614 .contains(&RankedReasonCode::ExactPath),
5615 &true,
5616 "exact path reason code",
5617 )?;
5618 require_eq(
5619 &ranked[1]
5620 .reason_codes
5621 .contains(&RankedReasonCode::ExactName),
5622 &true,
5623 "exact basename reason code",
5624 )?;
5625 require_eq(
5626 &ranked[2]
5627 .reason_codes
5628 .contains(&RankedReasonCode::ReviewedPurpose),
5629 &true,
5630 "reviewed purpose reason code after adversarial admission",
5631 )?;
5632 Ok(())
5633 }
5634
5635 #[test]
5636 fn fuzzy_search_matches_approximate_line_terms() -> Result<(), Box<dyn Error>> {
5637 let temp = tempfile::tempdir()?;
5638 let root = temp.path();
5639 fs::create_dir_all(root.join("src"))?;
5640 fs::write(
5641 root.join("src").join("main.rs"),
5642 "fn build_project_atlas() {}\nfn unrelated() {}\n",
5643 )?;
5644 let mut store = AtlasStore::in_memory()?;
5645 store.set_project_root(root)?;
5646 let nodes = [test_node("src/main.rs", "hash-main")];
5647 store.replace_scan(&nodes)?;
5648 index_test_file_texts(&mut store, root, &nodes)?;
5649
5650 let report =
5651 search_indexed_files(&store, "bpa", false, true, false, Some("*.rs"), 0, 0, 10)?;
5652 require_eq(&report.mode, &"fuzzy".to_string(), "search mode")?;
5653 require_eq(&report.returned, &1, "fuzzy returned rows")?;
5654 require_eq(
5655 &report.results[0].text,
5656 &"fn build_project_atlas() {}".to_string(),
5657 "fuzzy match text",
5658 )?;
5659
5660 let invalid = search_indexed_files(&store, "bpa", true, true, false, None, 0, 0, 10);
5661 if invalid.is_ok() {
5662 return Err(io::Error::other("regex+fuzzy search was accepted").into());
5663 }
5664 Ok(())
5665 }
5666
5667 #[test]
5668 fn symbol_slice_reports_ambiguity_and_accepts_parent_selector() -> Result<(), Box<dyn Error>> {
5669 let temp = tempfile::tempdir()?;
5670 let root = temp.path();
5671 fs::create_dir_all(root.join("src"))?;
5672 fs::write(
5673 root.join("src").join("lib.rs"),
5674 "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",
5675 )?;
5676 let mut store = AtlasStore::in_memory()?;
5677 store.set_project_root(root)?;
5678 store.replace_scan(&[test_node("src/lib.rs", "hash-lib")])?;
5679 let mut a_run = test_symbol("src/lib.rs", SymbolKind::Method, "run");
5680 a_run.parent = Some("A".to_string());
5681 a_run.signature = "fn run(&self) for A".to_string();
5682 a_run.line_start = 3;
5683 a_run.line_end = 5;
5684 let mut b_run = test_symbol("src/lib.rs", SymbolKind::Method, "run");
5685 b_run.parent = Some("B".to_string());
5686 b_run.signature = "fn run(&self) for B".to_string();
5687 b_run.line_start = 9;
5688 b_run.line_end = 11;
5689 store.replace_symbol_graph(&SymbolGraph {
5690 path: "src/lib.rs".to_string(),
5691 language: Some("rust".to_string()),
5692 parser: ParserKind::TreeSitter,
5693 symbols: vec![a_run, b_run],
5694 relations: Vec::new(),
5695 })?;
5696
5697 let ambiguous = read_symbol_slice(
5698 &store,
5699 Path::new("src/lib.rs"),
5700 &SymbolSliceSelector {
5701 name: "run",
5702 ..SymbolSliceSelector::default()
5703 },
5704 );
5705 if !matches!(ambiguous, Err(ServiceError::InvalidInput(message)) if message.contains("ambiguous") && message.contains("parent=A") && message.contains("parent=B"))
5706 {
5707 return Err(
5708 io::Error::other("ambiguous symbol slice did not report candidates").into(),
5709 );
5710 }
5711
5712 let slice = read_symbol_slice(
5713 &store,
5714 Path::new("src/lib.rs"),
5715 &SymbolSliceSelector {
5716 name: "run",
5717 parent: Some("B"),
5718 ..SymbolSliceSelector::default()
5719 },
5720 )?;
5721 if !slice.content.contains("b();") || slice.content.contains("a();") {
5722 return Err(io::Error::other("parent selector returned wrong symbol slice").into());
5723 }
5724 let signature_slice = read_symbol_slice(
5725 &store,
5726 Path::new("src/lib.rs"),
5727 &SymbolSliceSelector {
5728 name: "run",
5729 signature: Some("fn run(&self) for A"),
5730 ..SymbolSliceSelector::default()
5731 },
5732 )?;
5733 if !signature_slice.content.contains("a();") || signature_slice.content.contains("b();") {
5734 return Err(io::Error::other("signature selector returned wrong symbol slice").into());
5735 }
5736 Ok(())
5737 }
5738
5739 #[test]
5740 fn code_slice_budget_preserves_verbatim_utf8_and_rejects_oversized_output()
5741 -> Result<(), Box<dyn Error>> {
5742 let source = "fn café() {\r\n println!(\"λ\");\r\n}\r\n";
5743 let budget = CodeSliceBudget::new(512)?;
5744 let slice = read_code_slice(source, "src/lib.rs", 1, Some(3), budget)?;
5745 require_eq(
5746 &slice.slice().content,
5747 &source
5748 .strip_suffix("\r\n")
5749 .ok_or_else(|| io::Error::other("CRLF fixture terminator missing"))?
5750 .to_string(),
5751 "verbatim UTF-8 slice",
5752 )?;
5753 let encoded = slice.fit_output::<_, ServiceError, _>(|slice| {
5754 serde_json::to_vec(slice).map_err(ServiceError::from)
5755 })?;
5756 if encoded.len() > budget.output_bytes() as usize {
5757 return Err(io::Error::other(
5758 "accepted slice exceeded its exact encoded-output ceiling",
5759 )
5760 .into());
5761 }
5762 let payload: serde_json::Value = serde_json::from_slice(&encoded)?;
5763 if payload.get("output_budget").is_some() {
5764 return Err(io::Error::other(
5765 "additive slice budget changed the compatibility payload",
5766 )
5767 .into());
5768 }
5769
5770 let content_error =
5771 read_code_slice(source, "src/lib.rs", 1, Some(3), CodeSliceBudget::new(8)?);
5772 if !matches!(
5773 content_error,
5774 Err(ServiceError::InvalidInput(message))
5775 if message.contains("verbatim slice content exceeds")
5776 ) {
5777 return Err(io::Error::other(
5778 "oversized verbatim slice content was allocated or truncated",
5779 )
5780 .into());
5781 }
5782
5783 let envelope_budget = CodeSliceBudget::new(64)?;
5784 let envelope = read_code_slice("λ", "src/lib.rs", 1, Some(1), envelope_budget)?;
5785 let envelope_error = envelope.fit_output::<_, ServiceError, _>(|slice| {
5786 serde_json::to_vec(slice).map_err(ServiceError::from)
5787 });
5788 if !matches!(
5789 envelope_error,
5790 Err(ServiceError::InvalidInput(message))
5791 if message.contains("slice output exceeds")
5792 ) {
5793 return Err(io::Error::other("oversized encoded slice envelope was accepted").into());
5794 }
5795 if CodeSliceBudget::new(0).is_ok()
5796 || CodeSliceBudget::new(GraphLimits::MAX_OUTPUT_BYTES + 1).is_ok()
5797 {
5798 return Err(io::Error::other("invalid slice output ceilings were accepted").into());
5799 }
5800 Ok(())
5801 }
5802
5803 #[test]
5804 fn line_slice_reads_current_disk_content_after_index_validation() -> Result<(), Box<dyn Error>>
5805 {
5806 let temp = tempfile::tempdir()?;
5807 let root = temp.path();
5808 fs::create_dir_all(root.join("src"))?;
5809 let file = root.join("src").join("lib.rs");
5810 fs::write(&file, "pub fn old_name() {}\n")?;
5811 let mut store = AtlasStore::in_memory()?;
5812 store.set_project_root(root)?;
5813 store.replace_scan(&[test_node("src/lib.rs", "old-hash")])?;
5814
5815 fs::write(&file, "pub fn current_name() {}\n")?;
5816 let slice = read_indexed_code_slice(&store, Path::new("src/lib.rs"), 1, Some(1))?;
5817
5818 require_eq(
5819 &slice.content,
5820 &"pub fn current_name() {}".to_string(),
5821 "slice content",
5822 )?;
5823 Ok(())
5824 }
5825
5826 fn test_node(path: &str, hash: &str) -> Node {
5828 Node {
5829 path: path.to_string(),
5830 kind: NodeKind::File,
5831 parent_path: normalized_parent(path),
5832 extension: Some(".rs".to_string()),
5833 language: Some("rust".to_string()),
5834 size_bytes: Some(12),
5835 mtime_ns: Some(10),
5836 content_hash: Some(hash.to_string()),
5837 }
5838 }
5839
5840 fn test_indexed_node(path: &str, hash: &str) -> IndexedNode {
5842 IndexedNode {
5843 node: test_node(path, hash),
5844 purpose: Purpose {
5845 path: path.to_string(),
5846 purpose: Some(format!("Purpose for {path}")),
5847 source: PurposeSource::Agent,
5848 status: PurposeStatus::Approved,
5849 },
5850 summary: Some(format!("Summary for {path}")),
5851 }
5852 }
5853
5854 fn index_test_file_texts(
5856 store: &mut AtlasStore,
5857 root: &Path,
5858 nodes: &[Node],
5859 ) -> Result<(), Box<dyn Error>> {
5860 let mut paths = Vec::new();
5861 let mut texts = Vec::new();
5862 for node in nodes {
5863 paths.push(node.path.clone());
5864 let native = root.join(repo_path_to_native(&node.path));
5865 let content = fs::read_to_string(native)?;
5866 texts.push(IndexedFileText {
5867 path: node.path.clone(),
5868 content_hash: node.content_hash.clone(),
5869 byte_count: content.len(),
5870 line_count: content.lines().count(),
5871 content,
5872 });
5873 }
5874 store.replace_file_texts_for_paths(&paths, &texts)?;
5875 Ok(())
5876 }
5877
5878 fn test_symbol(path: &str, kind: SymbolKind, name: &str) -> CodeSymbol {
5880 CodeSymbol {
5881 path: path.to_string(),
5882 language: Some("rust".to_string()),
5883 name: name.to_string(),
5884 kind,
5885 signature: name.to_string(),
5886 exported: false,
5887 documentation: None,
5888 line_start: 1,
5889 line_end: 1,
5890 parent: None,
5891 parser: ParserKind::TreeSitter,
5892 detail: None,
5893 }
5894 }
5895
5896 fn assert_single_called_by(
5897 report: &FileSummaryReport,
5898 symbol_name: &str,
5899 caller: &str,
5900 ) -> Result<(), Box<dyn Error>> {
5901 let symbol = report
5902 .functions
5903 .iter()
5904 .find(|symbol| symbol.name == symbol_name)
5905 .ok_or_else(|| io::Error::other(format!("{symbol_name} summary missing")))?;
5906 require_eq(
5907 &symbol.called_by,
5908 &vec![caller.to_string()],
5909 "import alias called-by",
5910 )
5911 }
5912
5913 fn require_eq<T>(actual: &T, expected: &T, label: &str) -> Result<(), Box<dyn Error>>
5915 where
5916 T: std::fmt::Debug + PartialEq,
5917 {
5918 if actual == expected {
5919 Ok(())
5920 } else {
5921 Err(io::Error::other(format!(
5922 "{label} mismatch: expected {expected:?}, got {actual:?}"
5923 ))
5924 .into())
5925 }
5926 }
5927
5928 fn require_reason(reasons: &[String], expected: &str) -> Result<(), Box<dyn Error>> {
5930 if reasons.iter().any(|reason| reason.contains(expected)) {
5931 Ok(())
5932 } else {
5933 Err(io::Error::other(format!("reason {expected:?} missing from {reasons:?}")).into())
5934 }
5935 }
5936}