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