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