Skip to main content

projectatlas_service/
lib.rs

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