Skip to main content

projectatlas_service/
federation.rs

1//! Call-scoped read-only composition across explicitly supplied project roots.
2
3use super::analysis::{RelationAnalysisDraft, RelationAnalysisQuery, RelationAnalysisReport};
4use super::relations::{
5    DetailedRelationBudget, DetailedRelationPageDraft, DetailedRelationQuery,
6    DetailedRelationReport, ExternalRelationIdentity, external_relation_identities,
7    relation_request_control, serialized_equivalent_bytes,
8};
9use super::{ServiceError, ServiceResult, canonical_root_digest, selected_project_binding};
10use projectatlas_core::graph::{
11    ExtendedRelationKind, ExternalSelector, GraphLimitKind, GraphRelationKind, LogicalRelation,
12    ProjectInstanceId, RelationResolution,
13};
14use projectatlas_core::language::ContentClassification;
15use projectatlas_core::symbols::RelationKind;
16use projectatlas_core::{CanonicalProjectRoot, IndexGeneration, IndexWorkControl, IndexWorkStage};
17use projectatlas_db::{AtlasStore, DbError};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs;
21use std::path::PathBuf;
22use std::time::{Duration, Instant};
23
24/// Minimum number of explicit roots that constitutes a federated call.
25const MIN_FEDERATED_ROOTS: usize = 2;
26/// Maximum roots and simultaneously open read snapshots in one call.
27const MAX_FEDERATED_ROOTS: usize = 8;
28/// Maximum total size of participating project databases.
29pub const MAX_FEDERATED_DATABASE_BYTES: u64 = 64 * 1_024 * 1_024 * 1_024;
30/// Maximum current source bytes inspected while validating all roots.
31pub const MAX_FEDERATED_INPUT_BYTES: u64 = 16 * 1_024 * 1_024 * 1_024;
32/// Maximum time allowed to finish and drop every participating read snapshot.
33const MAX_FEDERATED_CLOSE_MS: u64 = 1_000;
34
35/// Current opaque federated-continuation representation.
36const FEDERATED_CURSOR_VERSION: u16 = 1;
37/// Absolute serialized federated-continuation ceiling.
38const FEDERATED_CURSOR_MAX_BYTES: usize = 128 * 1_024;
39/// Domain separator for machine-path-free root binding.
40const FEDERATED_ROOT_DIGEST_DOMAIN: &str = "projectatlas:federated-root:v1";
41
42/// Relation families whose exact typed external identities can rendezvous across roots.
43const FEDERATED_RENDEZVOUS_RELATIONS: [GraphRelationKind; 6] = [
44    GraphRelationKind::Legacy(RelationKind::Imports),
45    GraphRelationKind::Legacy(RelationKind::Calls),
46    GraphRelationKind::Legacy(RelationKind::DependsOn),
47    GraphRelationKind::Extended(ExtendedRelationKind::RoutesTo),
48    GraphRelationKind::Extended(ExtendedRelationKind::Configures),
49    GraphRelationKind::Extended(ExtendedRelationKind::Deploys),
50];
51
52/// Validate the explicit root count before an adapter opens any database.
53///
54/// # Errors
55///
56/// Returns an error unless the call supplies between two and eight roots.
57pub fn validate_federated_root_count(count: usize) -> ServiceResult<()> {
58    if (MIN_FEDERATED_ROOTS..=MAX_FEDERATED_ROOTS).contains(&count) {
59        Ok(())
60    } else {
61        Err(ServiceError::InvalidInput(format!(
62            "federation requires {MIN_FEDERATED_ROOTS}..={MAX_FEDERATED_ROOTS} explicit ordered roots"
63        )))
64    }
65}
66
67/// Exact read-only freshness work completed before one root joined federation.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
69pub struct FederatedInputWork {
70    /// Repository entries inspected by exact source verification.
71    pub filesystem_entries: u64,
72    /// Current source bytes hashed by exact source verification.
73    pub filesystem_bytes: u64,
74    /// `SQLite` statements used by exact source verification.
75    pub sqlite_read_statements: u64,
76    /// Indexed nodes decoded by exact source verification.
77    pub decoded_nodes: u64,
78    /// Wall time spent opening and verifying this participant.
79    pub elapsed_ms: u64,
80}
81
82impl FederatedInputWork {
83    /// Add one participant's verification work without counter overflow.
84    fn checked_add(self, other: Self) -> ServiceResult<Self> {
85        Ok(Self {
86            filesystem_entries: checked_sum(
87                self.filesystem_entries,
88                other.filesystem_entries,
89                "federated filesystem-entry work",
90            )?,
91            filesystem_bytes: checked_sum(
92                self.filesystem_bytes,
93                other.filesystem_bytes,
94                "federated input bytes",
95            )?,
96            sqlite_read_statements: checked_sum(
97                self.sqlite_read_statements,
98                other.sqlite_read_statements,
99                "federated freshness statements",
100            )?,
101            decoded_nodes: checked_sum(
102                self.decoded_nodes,
103                other.decoded_nodes,
104                "federated freshness nodes",
105            )?,
106            elapsed_ms: checked_sum(
107                self.elapsed_ms,
108                other.elapsed_ms,
109                "federated freshness time",
110            )?,
111        })
112    }
113}
114
115/// One already verified root-bound read-only store admitted by an adapter.
116pub struct FederatedStore {
117    /// Root-bound read-only database handle.
118    store: AtlasStore,
119    /// Database reopened after snapshot close for generation validation.
120    database_path: PathBuf,
121    /// Canonical explicit project root.
122    root: PathBuf,
123    /// Participating database file size.
124    database_bytes: u64,
125    /// Exact adapter-owned freshness work.
126    input_work: FederatedInputWork,
127    /// Optional validated worktree alias supplied by the adapter.
128    worktree: Option<String>,
129}
130
131impl FederatedStore {
132    /// Admit one root-bound read-only snapshot into a later federated call.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error when the store is writable, has no active read snapshot,
137    /// names another root, or its database path is not an existing regular file.
138    pub fn new(
139        store: AtlasStore,
140        database_path: PathBuf,
141        root: PathBuf,
142        input_work: FederatedInputWork,
143    ) -> ServiceResult<Self> {
144        Self::new_with_worktree(store, database_path, root, input_work, None)
145    }
146
147    /// Admit one labelled root-bound read-only snapshot into a federated call.
148    ///
149    /// # Errors
150    ///
151    /// Returns the same errors as [`Self::new`]. Alias validation remains adapter-owned.
152    pub fn new_with_worktree(
153        store: AtlasStore,
154        database_path: PathBuf,
155        root: PathBuf,
156        input_work: FederatedInputWork,
157        worktree: Option<String>,
158    ) -> ServiceResult<Self> {
159        if !store.is_read_only() || !store.has_active_read_snapshot() {
160            return Err(ServiceError::InvalidInput(
161                "federation accepts only read-only stores with active snapshots".to_string(),
162            ));
163        }
164        let explicit_root = CanonicalProjectRoot::from_path(&root)
165            .map_err(|error| ServiceError::InvalidInput(error.to_string()))?;
166        if !store.project_root_identity_matches(&explicit_root) {
167            return Err(ServiceError::InvalidInput(
168                "federated store does not match its explicit root".to_string(),
169            ));
170        }
171        let metadata = fs::metadata(&database_path).map_err(|source| ServiceError::Io {
172            path: database_path.clone(),
173            source,
174        })?;
175        if !metadata.is_file() {
176            return Err(ServiceError::InvalidInput(
177                "federated database path is not a regular file".to_string(),
178            ));
179        }
180        Ok(Self {
181            store,
182            database_path,
183            root,
184            database_bytes: metadata.len(),
185            input_work,
186            worktree,
187        })
188    }
189
190    /// Borrow the verified primary store while an adapter constructs exact selectors.
191    #[must_use]
192    pub const fn store(&self) -> &AtlasStore {
193        &self.store
194    }
195
196    /// Close this participant without producing a federated result.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error when `SQLite` cannot finish the active read snapshot.
201    pub fn finish(self) -> ServiceResult<()> {
202        self.store.finish_index_read_snapshot().map_err(Into::into)
203    }
204}
205
206/// One ordered participant retained without exposing its machine-local root.
207#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
208pub struct FederatedParticipant {
209    /// Zero-based order supplied by the caller.
210    pub order: u32,
211    /// Registered worktree alias when the adapter selected aliases.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub worktree: Option<String>,
214    /// Project-qualified identity captured by the read-only store.
215    pub project: ProjectInstanceId,
216    /// Complete graph generation captured for this root.
217    pub generation: IndexGeneration,
218    /// Authored-purpose revision captured for this root.
219    pub authored_purpose_revision: u64,
220}
221
222/// One project-qualified relation supporting an exact typed external rendezvous.
223#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
224pub struct FederatedRelationEvidence {
225    /// Registered worktree alias when the adapter selected aliases.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub worktree: Option<String>,
228    /// Participating project identity.
229    pub project: ProjectInstanceId,
230    /// Participating graph generation.
231    pub generation: IndexGeneration,
232    /// Exact local source entity that emitted the relation.
233    pub source: projectatlas_core::graph::GraphEntity,
234    /// Registry-owned role of the source file, when the source is file-bearing.
235    pub classification: Option<ContentClassification>,
236    /// Complete typed logical relation.
237    pub relation: LogicalRelation,
238}
239
240/// Exact shared external identity observed in at least two projects.
241#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
242pub struct FederatedRendezvous {
243    /// Closed relation family shared by the evidence.
244    pub relation: GraphRelationKind,
245    /// Exact typed external namespace and identity; similar text is not enough.
246    pub external: ExternalSelector,
247    /// Deterministically ordered project-qualified evidence.
248    pub evidence: Vec<FederatedRelationEvidence>,
249}
250
251/// Aggregate bounded work for one federated response.
252#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
253pub struct FederatedRelationWork {
254    /// Exact source-verification work across all roots.
255    pub input: FederatedInputWork,
256    /// Aggregate bytes of participating project database files.
257    pub participating_database_bytes: u64,
258    /// Maximum read snapshots held simultaneously.
259    pub simultaneously_open_snapshots: u32,
260    /// Relation rows decoded by cross-root rendezvous reads.
261    pub rendezvous_database_rows: u64,
262    /// Serialized-equivalent bytes decoded by cross-root rendezvous reads.
263    pub rendezvous_database_bytes: u64,
264    /// Exact typed rendezvous evidence retained in memory.
265    pub rendezvous_relations: u32,
266    /// Peak aggregate primary and federation-owned intermediate bytes.
267    pub intermediate_bytes: u64,
268    /// Milliseconds spent finishing and dropping every participant snapshot.
269    pub close_ms: u64,
270    /// Total verification, query, and close elapsed milliseconds.
271    pub elapsed_ms: u64,
272    /// Exact bytes emitted by the selected adapter envelope.
273    pub rendered_output_bytes: u64,
274}
275
276/// Federated detailed-relation response on the existing relation route.
277#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
278pub struct FederatedDetailedRelationReport {
279    /// Ordered validated participants.
280    pub participants: Vec<FederatedParticipant>,
281    /// Worktree alias of the first-root result when aliases selected federation.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub primary_worktree: Option<String>,
284    /// Ordered aliases required to resume the primary continuation.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub continuation_worktrees: Option<Vec<String>>,
287    /// Existing detailed relation result anchored in the first root.
288    pub primary: DetailedRelationReport,
289    /// Exact typed cross-root external rendezvous evidence.
290    pub rendezvous: Vec<FederatedRendezvous>,
291    /// Whether primary traversal or cross-root rendezvous work was truncated.
292    pub truncated: bool,
293    /// Stable aggregate limits reached by either stage.
294    pub reached_limits: Vec<GraphLimitKind>,
295    /// Exact aggregate work for this response.
296    pub work: FederatedRelationWork,
297}
298
299/// Federated closed-analysis response on the existing relation route.
300#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
301pub struct FederatedAnalysisReport {
302    /// Ordered validated participants.
303    pub participants: Vec<FederatedParticipant>,
304    /// Worktree alias of the first-root result when aliases selected federation.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub primary_worktree: Option<String>,
307    /// Ordered aliases required to resume the primary continuation.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub continuation_worktrees: Option<Vec<String>>,
310    /// Existing analysis result anchored in the first root.
311    pub primary: RelationAnalysisReport,
312    /// Exact typed cross-root external rendezvous evidence.
313    pub rendezvous: Vec<FederatedRendezvous>,
314    /// Whether primary analysis or cross-root rendezvous work was truncated.
315    pub truncated: bool,
316    /// Stable aggregate limits reached by either stage.
317    pub reached_limits: Vec<GraphLimitKind>,
318    /// Exact aggregate work for this response.
319    pub work: FederatedRelationWork,
320}
321
322/// Unrendered federated detailed response that fits the exact adapter envelope.
323pub struct FederatedDetailedRelationDraft {
324    /// Existing first-root relation draft.
325    primary: DetailedRelationPageDraft,
326    /// Immutable participant and rendezvous state.
327    context: FederatedContext,
328}
329
330impl FederatedDetailedRelationDraft {
331    /// Fit the largest primary-row prefix to the exact federated adapter envelope.
332    ///
333    /// # Errors
334    ///
335    /// Returns an adapter error when rendering fails, cancellation fires, a
336    /// cursor cannot be wrapped, or the empty federated envelope is oversized.
337    pub fn fit_output<F, E>(
338        self,
339        control: Option<&IndexWorkControl>,
340        mut encode: F,
341    ) -> Result<(FederatedDetailedRelationReport, String), E>
342    where
343        F: FnMut(&FederatedDetailedRelationReport) -> Result<String, E>,
344        E: From<ServiceError>,
345    {
346        let context = self.context;
347        let (primary, encoded) = self.primary.fit_output(control, |primary| {
348            let report = context.detailed_report(primary.clone()).map_err(E::from)?;
349            encode(&report)
350        })?;
351        let report = context.detailed_report(primary).map_err(E::from)?;
352        Ok((report, encoded))
353    }
354}
355
356/// Unrendered federated analysis response that fits the exact adapter envelope.
357pub struct FederatedAnalysisDraft {
358    /// Existing first-root analysis draft.
359    primary: RelationAnalysisDraft,
360    /// Immutable participant and rendezvous state.
361    context: FederatedContext,
362}
363
364impl FederatedAnalysisDraft {
365    /// Fit the largest analysis-finding prefix to the exact federated envelope.
366    ///
367    /// # Errors
368    ///
369    /// Returns an adapter error when rendering fails, cancellation fires, a
370    /// cursor cannot be wrapped, or the empty federated envelope is oversized.
371    pub fn fit_output<F, E, O>(self, mut encode: F) -> Result<(FederatedAnalysisReport, O), E>
372    where
373        F: FnMut(&FederatedAnalysisReport, &IndexWorkControl) -> Result<O, E>,
374        E: From<ServiceError>,
375        O: AsRef<[u8]>,
376    {
377        let context = self.context;
378        let control = self.primary.control().clone();
379        let (primary, encoded) = self.primary.fit_output(|primary, control| {
380            let report = context
381                .analysis_report(primary.clone(), Some(control))
382                .map_err(E::from)?;
383            encode(&report, control)
384        })?;
385        check_control(Some(&control)).map_err(E::from)?;
386        let report = context
387            .analysis_report(primary, Some(&control))
388            .map_err(E::from)?;
389        Ok((report, encoded))
390    }
391}
392
393/// Shared immutable state used while fitting either adapter envelope.
394#[derive(Clone)]
395struct FederatedContext {
396    /// Ordered public participant identities.
397    participants: Vec<FederatedParticipant>,
398    /// Complete participant identity bound into continuations.
399    cursor_participants: Vec<FederatedCursorParticipant>,
400    /// Call-only typed external identity groups.
401    rendezvous: Vec<FederatedRendezvous>,
402    /// Aggregate limits reached while reading rendezvous evidence.
403    rendezvous_limits: Vec<GraphLimitKind>,
404    /// Work completed before adapter output fitting.
405    base_work: FederatedRelationWork,
406    /// Temporary exact identity-set bytes retained during rendezvous discovery.
407    rendezvous_identity_bytes: u64,
408    /// Existing aggregate relation and output budget.
409    budget: DetailedRelationBudget,
410}
411
412impl FederatedContext {
413    /// Compose one detailed envelope and bind its inner continuation.
414    fn detailed_report(
415        &self,
416        mut primary: DetailedRelationReport,
417    ) -> ServiceResult<FederatedDetailedRelationReport> {
418        primary.continuation = wrap_continuation(
419            primary.continuation.as_deref(),
420            &self.cursor_participants,
421            self.budget,
422        )?;
423        let mut reached_limits = primary.reached_limits.clone();
424        for limit in &self.rendezvous_limits {
425            push_limit(&mut reached_limits, *limit);
426        }
427        let mut work = self.base_work.clone();
428        work.rendered_output_bytes = primary.work.rendered_output_bytes;
429        work.intermediate_bytes = federated_intermediate_bytes(
430            primary
431                .work
432                .intermediate_bytes
433                .saturating_add(self.rendezvous_identity_bytes),
434            &self.participants,
435            &self.rendezvous,
436            primary.continuation.as_deref(),
437            self.budget,
438        )?;
439        Ok(FederatedDetailedRelationReport {
440            participants: self.participants.clone(),
441            primary_worktree: self
442                .participants
443                .first()
444                .and_then(|participant| participant.worktree.clone()),
445            continuation_worktrees: federation_continuation_worktrees(
446                &self.participants,
447                primary.continuation.is_some(),
448            ),
449            truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
450            primary,
451            rendezvous: self.rendezvous.clone(),
452            reached_limits,
453            work,
454        })
455    }
456
457    /// Compose one analysis envelope and bind its inner continuation.
458    fn analysis_report(
459        &self,
460        mut primary: RelationAnalysisReport,
461        control: Option<&IndexWorkControl>,
462    ) -> ServiceResult<FederatedAnalysisReport> {
463        check_control(control)?;
464        primary.continuation = wrap_continuation(
465            primary.continuation.as_deref(),
466            &self.cursor_participants,
467            self.budget,
468        )?;
469        let mut reached_limits = primary.reached_limits.clone();
470        for limit in &self.rendezvous_limits {
471            check_control(control)?;
472            push_limit(&mut reached_limits, *limit);
473        }
474        let mut work = self.base_work.clone();
475        work.rendered_output_bytes = primary.work.rendered_output_bytes;
476        work.intermediate_bytes = federated_intermediate_bytes(
477            primary
478                .work
479                .peak_intermediate_bytes
480                .saturating_add(self.rendezvous_identity_bytes),
481            &self.participants,
482            &self.rendezvous,
483            primary.continuation.as_deref(),
484            self.budget,
485        )?;
486        check_control(control)?;
487        Ok(FederatedAnalysisReport {
488            participants: self.participants.clone(),
489            primary_worktree: self
490                .participants
491                .first()
492                .and_then(|participant| participant.worktree.clone()),
493            continuation_worktrees: federation_continuation_worktrees(
494                &self.participants,
495                primary.continuation.is_some(),
496            ),
497            truncated: primary.truncated || !self.rendezvous_limits.is_empty(),
498            primary,
499            rendezvous: self.rendezvous.clone(),
500            reached_limits,
501            work,
502        })
503    }
504}
505
506/// Return the complete ordered aliases only when a continuation and all labels exist.
507fn federation_continuation_worktrees(
508    participants: &[FederatedParticipant],
509    has_continuation: bool,
510) -> Option<Vec<String>> {
511    has_continuation
512        .then(|| {
513            participants
514                .iter()
515                .map(|participant| participant.worktree.clone())
516                .collect::<Option<Vec<_>>>()
517        })
518        .flatten()
519}
520
521/// Versioned outer continuation for an ordered participant set.
522#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
523#[serde(deny_unknown_fields)]
524struct FederatedCursor {
525    /// Outer wire version.
526    version: u16,
527    /// Ordered roots, projects, and generations captured by the first page.
528    participants: Vec<FederatedCursorParticipant>,
529    /// Existing relation or analysis continuation.
530    inner: String,
531}
532
533/// Machine-path-free participant identity retained by a continuation.
534#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
535#[serde(deny_unknown_fields)]
536struct FederatedCursorParticipant {
537    /// Durable project identity.
538    project: ProjectInstanceId,
539    /// Domain-separated exact root digest.
540    root_digest: [u8; 32],
541    /// Complete graph generation.
542    generation: IndexGeneration,
543    /// Authored-purpose generation affecting hydration.
544    authored_purpose_revision: u64,
545}
546
547/// Participant metadata captured while every snapshot remains open.
548struct CapturedFederation {
549    /// Public project-qualified participant state.
550    participants: Vec<FederatedParticipant>,
551    /// Continuation-bound participant state.
552    cursor_participants: Vec<FederatedCursorParticipant>,
553    /// Aggregate exact-source verification work.
554    input_work: FederatedInputWork,
555    /// Aggregate participating database bytes.
556    database_bytes: u64,
557}
558
559/// Bounded cross-root rendezvous rows and measured work.
560struct RendezvousLoad {
561    /// Retained exact typed groups.
562    rows: Vec<FederatedRendezvous>,
563    /// Database rows inspected.
564    database_rows: u64,
565    /// Serialized-equivalent decoded database bytes.
566    database_bytes: u64,
567    /// Retained logical relation evidence rows.
568    relation_rows: u32,
569    /// Aggregate ceilings reached during the read.
570    reached_limits: Vec<GraphLimitKind>,
571}
572
573/// Closed participant details used for post-read generation validation.
574struct ClosedParticipant {
575    /// Exact database path.
576    database_path: PathBuf,
577    /// Canonical expected root.
578    root: PathBuf,
579    /// Binding captured before snapshot close.
580    cursor: FederatedCursorParticipant,
581}
582
583/// Load one federated detailed relation page through already verified stores.
584///
585/// # Errors
586///
587/// Returns an error before any response is produced when any participant,
588/// cursor, budget, query, cleanup, or post-read generation validation fails.
589pub fn load_federated_detailed_relations(
590    stores: Vec<FederatedStore>,
591    query: &DetailedRelationQuery,
592    control: Option<&IndexWorkControl>,
593) -> ServiceResult<FederatedDetailedRelationDraft> {
594    let started = Instant::now();
595    let budget = query.budget;
596    let captured = capture_federation(&stores, control)?;
597    let mut primary_query = query.clone();
598    primary_query.cursor = decode_continuation(
599        query.cursor.as_deref(),
600        &captured.cursor_participants,
601        budget,
602    )?;
603    let operation: ServiceResult<(DetailedRelationPageDraft, RendezvousLoad, u64)> = (|| {
604        check_control(control)?;
605        let primary = super::relations::load_detailed_relation_page(
606            stores[0].store(),
607            &primary_query,
608            control,
609        )?;
610        let candidate = primary.report_for_prefix(primary.candidate_rows())?;
611        let rendezvous_identities = external_relation_identities(&candidate);
612        let rendezvous_identity_bytes = serialized_equivalent_bytes(&rendezvous_identities)?;
613        let primary_edges = u64::from(candidate.work.inspected_edges);
614        let primary_rows = u64::from(candidate.work.database_returned_rows);
615        let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
616        let rendezvous = load_rendezvous(
617            &stores,
618            query,
619            &rendezvous_identities,
620            remaining_edges,
621            aggregate_row_limit(budget).saturating_sub(primary_rows),
622            budget
623                .intermediate_bytes()
624                .saturating_sub(candidate.work.intermediate_bytes)
625                .saturating_sub(rendezvous_identity_bytes),
626            started,
627            control,
628        )?;
629        Ok((primary, rendezvous, rendezvous_identity_bytes))
630    })();
631    let (closed, close_ms, close_error) = close_participants(stores);
632    let (primary, rendezvous, rendezvous_identity_bytes) = operation?;
633    if let Some(error) = close_error {
634        return Err(error);
635    }
636    revalidate_participants(&closed, control)?;
637    let base_work = base_work(&captured, &rendezvous, close_ms, elapsed_ms(started))?;
638    Ok(FederatedDetailedRelationDraft {
639        primary,
640        context: FederatedContext {
641            participants: captured.participants,
642            cursor_participants: captured.cursor_participants,
643            rendezvous: rendezvous.rows,
644            rendezvous_limits: rendezvous.reached_limits,
645            base_work,
646            rendezvous_identity_bytes,
647            budget,
648        },
649    })
650}
651
652/// Load one federated closed analysis through already verified stores.
653///
654/// # Errors
655///
656/// Returns an error before any response is produced when any participant,
657/// cursor, budget, query, cleanup, or post-read generation validation fails.
658pub fn load_federated_relation_analysis(
659    stores: Vec<FederatedStore>,
660    query: &RelationAnalysisQuery,
661    control: Option<&IndexWorkControl>,
662) -> ServiceResult<FederatedAnalysisDraft> {
663    let started = Instant::now();
664    let budget = query.relations.budget;
665    let captured = capture_federation(&stores, control)?;
666    let mut primary_query = query.clone();
667    primary_query.relations.cursor = decode_continuation(
668        query.relations.cursor.as_deref(),
669        &captured.cursor_participants,
670        budget,
671    )?;
672    let operation: ServiceResult<(RelationAnalysisDraft, RendezvousLoad)> = (|| {
673        check_control(control)?;
674        let mut primary = super::analysis::load_relation_analysis_for_federation(
675            stores[0].store(),
676            &primary_query,
677            control,
678        )?;
679        let rendezvous_identities = primary.take_external_relation_identities();
680        let candidate = primary.candidate_report();
681        let primary_edges = u64::from(candidate.work.relations.inspected_edges)
682            .saturating_add(u64::from(candidate.work.closure_inspected_edges));
683        let primary_rows = u64::from(candidate.work.relations.database_returned_rows)
684            .saturating_add(u64::from(candidate.work.closure_inspected_edges));
685        let remaining_edges = u64::from(budget.edges()).saturating_sub(primary_edges);
686        let rendezvous = load_rendezvous(
687            &stores,
688            &query.relations,
689            &rendezvous_identities,
690            remaining_edges,
691            aggregate_row_limit(budget).saturating_sub(primary_rows),
692            budget
693                .intermediate_bytes()
694                .saturating_sub(candidate.work.peak_intermediate_bytes),
695            started,
696            control,
697        )?;
698        Ok((primary, rendezvous))
699    })();
700    let (closed, close_ms, close_error) = close_participants(stores);
701    let (primary, rendezvous) = operation?;
702    if let Some(error) = close_error {
703        return Err(error);
704    }
705    revalidate_participants(&closed, control)?;
706    let base_work = base_work(&captured, &rendezvous, close_ms, elapsed_ms(started))?;
707    Ok(FederatedAnalysisDraft {
708        primary,
709        context: FederatedContext {
710            participants: captured.participants,
711            cursor_participants: captured.cursor_participants,
712            rendezvous: rendezvous.rows,
713            rendezvous_limits: rendezvous.reached_limits,
714            base_work,
715            rendezvous_identity_bytes: 0,
716            budget,
717        },
718    })
719}
720
721/// Validate all open participants and capture their ordered snapshot identity.
722fn capture_federation(
723    stores: &[FederatedStore],
724    control: Option<&IndexWorkControl>,
725) -> ServiceResult<CapturedFederation> {
726    validate_federated_root_count(stores.len())?;
727    let mut roots = BTreeSet::new();
728    let mut projects = Vec::new();
729    let mut participants = Vec::with_capacity(stores.len());
730    let mut cursor_participants = Vec::with_capacity(stores.len());
731    let mut input_work = FederatedInputWork::default();
732    let mut database_bytes = 0_u64;
733    for (order, participant) in stores.iter().enumerate() {
734        check_control(control)?;
735        if !participant.store.is_read_only() || !participant.store.has_active_read_snapshot() {
736            return Err(ServiceError::InvalidInput(
737                "federated participant lost its read-only snapshot".to_string(),
738            ));
739        }
740        let binding = selected_project_binding(&participant.store)?;
741        let root_digest = federated_root_digest(&binding.project_root_identity)?;
742        if !roots.insert(root_digest) || projects.contains(&binding.project_instance_id) {
743            return Err(ServiceError::InvalidInput(
744                "federated roots or project identities must be unique".to_string(),
745            ));
746        }
747        projects.push(binding.project_instance_id);
748        let generation = participant
749            .store
750            .repository_graph_generation()?
751            .ok_or_else(|| {
752                ServiceError::InvalidInput(
753                    "federated root has no complete repository graph generation".to_string(),
754                )
755            })?;
756        let authored_purpose_revision = participant.store.authored_purpose_revision()?;
757        let order = u32::try_from(order).map_err(|_overflow| {
758            ServiceError::InvalidInput("federated root order overflowed".to_string())
759        })?;
760        participants.push(FederatedParticipant {
761            order,
762            worktree: participant.worktree.clone(),
763            project: binding.project_instance_id,
764            generation,
765            authored_purpose_revision,
766        });
767        cursor_participants.push(FederatedCursorParticipant {
768            project: binding.project_instance_id,
769            root_digest,
770            generation,
771            authored_purpose_revision,
772        });
773        input_work = input_work.checked_add(participant.input_work)?;
774        database_bytes = checked_sum(
775            database_bytes,
776            participant.database_bytes,
777            "participating database bytes",
778        )?;
779        if input_work.filesystem_bytes > MAX_FEDERATED_INPUT_BYTES {
780            return Err(ServiceError::InvalidInput(format!(
781                "federated source verification exceeds {MAX_FEDERATED_INPUT_BYTES} bytes"
782            )));
783        }
784        if database_bytes > MAX_FEDERATED_DATABASE_BYTES {
785            return Err(ServiceError::InvalidInput(format!(
786                "participating databases exceed {MAX_FEDERATED_DATABASE_BYTES} bytes"
787            )));
788        }
789    }
790    Ok(CapturedFederation {
791        participants,
792        cursor_participants,
793        input_work,
794        database_bytes,
795    })
796}
797
798/// Load exact typed external identities shared by at least two projects.
799fn load_rendezvous(
800    stores: &[FederatedStore],
801    query: &DetailedRelationQuery,
802    identities: &BTreeSet<ExternalRelationIdentity>,
803    mut remaining_edges: u64,
804    mut remaining_rows: u64,
805    mut remaining_intermediate_bytes: u64,
806    started: Instant,
807    control: Option<&IndexWorkControl>,
808) -> ServiceResult<RendezvousLoad> {
809    if identities.is_empty()
810        || !matches!(
811            query.resolution,
812            super::relations::RelationResolutionFilter::Any
813                | super::relations::RelationResolutionFilter::External
814        )
815    {
816        return Ok(RendezvousLoad {
817            rows: Vec::new(),
818            database_rows: 0,
819            database_bytes: 0,
820            relation_rows: 0,
821            reached_limits: Vec::new(),
822        });
823    }
824    let deadline = started
825        .checked_add(Duration::from_millis(query.budget.deadline_ms()))
826        .unwrap_or(started);
827    let request_control = relation_request_control(control, deadline);
828    let control = Some(&request_control);
829    let families = query.relation.map_or_else(
830        || FEDERATED_RENDEZVOUS_RELATIONS.to_vec(),
831        |relation| vec![relation],
832    );
833    let mut groups: BTreeMap<(String, String, String), FederatedRendezvous> = BTreeMap::new();
834    let mut database_rows = 0_u64;
835    let mut database_bytes = 0_u64;
836    let mut reached_limits = Vec::new();
837    let mut queries_left = stores.len().saturating_mul(families.len());
838    'queries: for family in families {
839        for participant in stores {
840            if elapsed_ms(started) >= query.budget.deadline_ms() {
841                push_limit(&mut reached_limits, GraphLimitKind::Deadline);
842                break 'queries;
843            }
844            check_control(control)?;
845            if remaining_edges == 0 {
846                push_limit(&mut reached_limits, GraphLimitKind::Edges);
847                break 'queries;
848            }
849            if remaining_rows == 0 {
850                push_limit(&mut reached_limits, GraphLimitKind::Rows);
851                break 'queries;
852            }
853            if remaining_intermediate_bytes == 0 {
854                push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
855                break 'queries;
856            }
857            let fair_share = remaining_edges
858                .div_ceil(u64::try_from(queries_left).unwrap_or(u64::MAX))
859                .max(1)
860                .min(u64::from(projectatlas_core::graph::GraphLimits::MAX_ROWS));
861            let limit = u32::try_from(fair_share).map_err(|_overflow| {
862                ServiceError::InvalidInput("federated relation page limit overflowed".to_string())
863            })?;
864            let page = participant
865                .store
866                .repository_graph_classified_relation_family_rows(
867                    family,
868                    query.content_selection,
869                    limit,
870                    control,
871                )?;
872            queries_left = queries_left.saturating_sub(1);
873            let decoded_rows = u64::try_from(page.rows.len()).map_err(|_overflow| {
874                ServiceError::InvalidInput("federated database row count overflowed".to_string())
875            })?;
876            let inspected_rows = decoded_rows.saturating_add(u64::from(page.truncated));
877            database_rows = checked_sum(database_rows, inspected_rows, "federated database rows")?;
878            if inspected_rows > remaining_rows {
879                return Err(ServiceError::InvalidInput(
880                    "federated database row budget was exhausted".to_string(),
881                ));
882            }
883            remaining_rows = remaining_rows.saturating_sub(inspected_rows);
884            remaining_edges = remaining_edges.saturating_sub(decoded_rows);
885            if page.truncated {
886                push_limit(&mut reached_limits, GraphLimitKind::Edges);
887            }
888            for row in page.rows {
889                let encoded_bytes = serialized_equivalent_bytes(&(
890                    &row.detail.source,
891                    &row.detail.relation,
892                    row.source_classification,
893                ))?;
894                if encoded_bytes > remaining_intermediate_bytes {
895                    push_limit(&mut reached_limits, GraphLimitKind::IntermediateBytes);
896                    break 'queries;
897                }
898                remaining_intermediate_bytes =
899                    remaining_intermediate_bytes.saturating_sub(encoded_bytes);
900                database_bytes =
901                    checked_sum(database_bytes, encoded_bytes, "federated decoded bytes")?;
902                if !super::relations::relation_matches(&row.detail.relation, query) {
903                    continue;
904                }
905                let RelationResolution::External { external, .. } =
906                    row.detail.relation.resolution()
907                else {
908                    continue;
909                };
910                let key = (
911                    family.as_str().to_string(),
912                    external.system.as_str().to_string(),
913                    external.identity.as_str().to_string(),
914                );
915                if !identities.contains(&key) {
916                    continue;
917                }
918                let project = row.detail.relation.key().project();
919                let generation = row.detail.relation.generation();
920                let group = groups.entry(key).or_insert_with(|| FederatedRendezvous {
921                    relation: family,
922                    external: external.clone(),
923                    evidence: Vec::new(),
924                });
925                group.evidence.push(FederatedRelationEvidence {
926                    worktree: participant.worktree.clone(),
927                    project,
928                    generation,
929                    source: row.detail.source,
930                    classification: row.source_classification,
931                    relation: row.detail.relation,
932                });
933            }
934        }
935    }
936    let rows = groups
937        .into_values()
938        .filter(|group| {
939            let mut projects = Vec::new();
940            for evidence in &group.evidence {
941                if !projects.contains(&evidence.project) {
942                    projects.push(evidence.project);
943                }
944            }
945            projects.len() >= 2
946        })
947        .collect::<Vec<_>>();
948    let relation_rows = u32::try_from(rows.iter().map(|row| row.evidence.len()).sum::<usize>())
949        .map_err(|_overflow| {
950            ServiceError::InvalidInput("federated rendezvous count overflowed".to_string())
951        })?;
952    Ok(RendezvousLoad {
953        rows,
954        database_rows,
955        database_bytes,
956        relation_rows,
957        reached_limits,
958    })
959}
960
961/// Finish and drop every captured participant snapshot.
962fn close_participants(
963    stores: Vec<FederatedStore>,
964) -> (Vec<ClosedParticipant>, u64, Option<ServiceError>) {
965    let started = Instant::now();
966    let mut closed = Vec::with_capacity(stores.len());
967    let mut first_error = None;
968    for participant in stores {
969        let binding = participant.store.captured_project_binding();
970        let generation = participant.store.repository_graph_generation();
971        let purpose_revision = participant.store.authored_purpose_revision();
972        if let Err(error) = participant.store.finish_index_read_snapshot()
973            && first_error.is_none()
974        {
975            first_error = Some(ServiceError::Db(error));
976        }
977        drop(participant.store);
978        match (binding, generation, purpose_revision) {
979            (Ok(binding), Ok(Some(generation)), Ok(authored_purpose_revision)) => {
980                match federated_root_digest(&binding.project_root_identity) {
981                    Ok(root_digest) => closed.push(ClosedParticipant {
982                        database_path: participant.database_path,
983                        root: participant.root,
984                        cursor: FederatedCursorParticipant {
985                            project: binding.project_instance_id,
986                            root_digest,
987                            generation,
988                            authored_purpose_revision,
989                        },
990                    }),
991                    Err(error) if first_error.is_none() => first_error = Some(error),
992                    Err(_) => {}
993                }
994            }
995            (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error))
996                if first_error.is_none() =>
997            {
998                first_error = Some(ServiceError::Db(error));
999            }
1000            _ => {
1001                if first_error.is_none() {
1002                    first_error = Some(ServiceError::InvalidInput(
1003                        "federated participant lost its graph generation before close".to_string(),
1004                    ));
1005                }
1006            }
1007        }
1008    }
1009    let elapsed = elapsed_ms(started);
1010    if elapsed > MAX_FEDERATED_CLOSE_MS && first_error.is_none() {
1011        first_error = Some(ServiceError::InvalidInput(format!(
1012            "federated snapshots took {elapsed} ms to close; limit is {MAX_FEDERATED_CLOSE_MS} ms"
1013        )));
1014    }
1015    (closed, elapsed, first_error)
1016}
1017
1018/// Reopen each database sequentially and reject a changed participant.
1019fn revalidate_participants(
1020    participants: &[ClosedParticipant],
1021    control: Option<&IndexWorkControl>,
1022) -> ServiceResult<()> {
1023    for participant in participants {
1024        check_control(control)?;
1025        let store =
1026            AtlasStore::open_read_only_for_project(&participant.database_path, &participant.root)?;
1027        let binding = selected_project_binding(&store)?;
1028        let generation = store.repository_graph_generation()?.ok_or_else(|| {
1029            ServiceError::InvalidInput(
1030                "federated root lost its complete graph generation".to_string(),
1031            )
1032        })?;
1033        let purpose_revision = store.authored_purpose_revision()?;
1034        let current = FederatedCursorParticipant {
1035            project: binding.project_instance_id,
1036            root_digest: federated_root_digest(&binding.project_root_identity)?,
1037            generation,
1038            authored_purpose_revision: purpose_revision,
1039        };
1040        store.finish_index_read_snapshot()?;
1041        drop(store);
1042        if current.project != participant.cursor.project
1043            || current.root_digest != participant.cursor.root_digest
1044        {
1045            return Err(ServiceError::RelationCursorStale {
1046                field: "federated project binding",
1047            });
1048        }
1049        if current.generation != participant.cursor.generation {
1050            return Err(ServiceError::RelationCursorStale {
1051                field: "federated graph generation",
1052            });
1053        }
1054        if current.authored_purpose_revision != participant.cursor.authored_purpose_revision {
1055            return Err(ServiceError::RelationCursorStale {
1056                field: "federated authored-purpose revision",
1057            });
1058        }
1059    }
1060    Ok(())
1061}
1062
1063/// Compose work measured before adapter-specific output fitting.
1064fn base_work(
1065    captured: &CapturedFederation,
1066    rendezvous: &RendezvousLoad,
1067    close_ms: u64,
1068    elapsed_ms: u64,
1069) -> ServiceResult<FederatedRelationWork> {
1070    Ok(FederatedRelationWork {
1071        input: captured.input_work,
1072        participating_database_bytes: captured.database_bytes,
1073        simultaneously_open_snapshots: u32::try_from(captured.participants.len()).map_err(
1074            |_overflow| {
1075                ServiceError::InvalidInput("federated open-snapshot count overflowed".to_string())
1076            },
1077        )?,
1078        rendezvous_database_rows: rendezvous.database_rows,
1079        rendezvous_database_bytes: rendezvous.database_bytes,
1080        rendezvous_relations: rendezvous.relation_rows,
1081        intermediate_bytes: 0,
1082        close_ms,
1083        elapsed_ms: captured.input_work.elapsed_ms.saturating_add(elapsed_ms),
1084        rendered_output_bytes: 0,
1085    })
1086}
1087
1088/// Decode and validate an outer continuation against every current root.
1089fn decode_continuation(
1090    encoded: Option<&str>,
1091    expected: &[FederatedCursorParticipant],
1092    budget: DetailedRelationBudget,
1093) -> ServiceResult<Option<String>> {
1094    let Some(encoded) = encoded else {
1095        return Ok(None);
1096    };
1097    if encoded.is_empty()
1098        || encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1099        || encoded.len() > budget.intermediate_bytes() as usize
1100    {
1101        return Err(ServiceError::RelationCursorInvalid {
1102            reason: "federated cursor length is empty or above the product ceiling",
1103        });
1104    }
1105    let cursor: FederatedCursor =
1106        serde_json::from_str(encoded).map_err(|_source| ServiceError::RelationCursorInvalid {
1107            reason: "federated cursor JSON is malformed or contains unknown fields",
1108        })?;
1109    if cursor.version != FEDERATED_CURSOR_VERSION {
1110        return Err(ServiceError::RelationCursorStale {
1111            field: "federated cursor version",
1112        });
1113    }
1114    if cursor.participants.len() != expected.len() {
1115        return Err(ServiceError::RelationCursorStale {
1116            field: "federated roots",
1117        });
1118    }
1119    for (actual, expected) in cursor.participants.iter().zip(expected) {
1120        if actual.project != expected.project || actual.root_digest != expected.root_digest {
1121            return Err(ServiceError::RelationCursorStale {
1122                field: "federated roots",
1123            });
1124        }
1125        if actual.generation != expected.generation {
1126            return Err(ServiceError::RelationCursorStale {
1127                field: "federated graph generation",
1128            });
1129        }
1130        if actual.authored_purpose_revision != expected.authored_purpose_revision {
1131            return Err(ServiceError::RelationCursorStale {
1132                field: "federated authored-purpose revision",
1133            });
1134        }
1135    }
1136    if cursor.inner.is_empty() {
1137        return Err(ServiceError::RelationCursorInvalid {
1138            reason: "federated cursor omitted its inner continuation",
1139        });
1140    }
1141    Ok(Some(cursor.inner))
1142}
1143
1144/// Bind an existing inner continuation to the complete participant set.
1145fn wrap_continuation(
1146    inner: Option<&str>,
1147    participants: &[FederatedCursorParticipant],
1148    budget: DetailedRelationBudget,
1149) -> ServiceResult<Option<String>> {
1150    let Some(inner) = inner else {
1151        return Ok(None);
1152    };
1153    let encoded = serde_json::to_string(&FederatedCursor {
1154        version: FEDERATED_CURSOR_VERSION,
1155        participants: participants.to_vec(),
1156        inner: inner.to_string(),
1157    })?;
1158    if encoded.len() > FEDERATED_CURSOR_MAX_BYTES
1159        || encoded.len() > budget.intermediate_bytes() as usize
1160    {
1161        return Err(ServiceError::RelationCursorInvalid {
1162            reason: "encoded federated cursor exceeds the intermediate-state ceiling",
1163        });
1164    }
1165    Ok(Some(encoded))
1166}
1167
1168/// Charge retained federation state against the existing intermediate ceiling.
1169fn federated_intermediate_bytes(
1170    primary_bytes: u64,
1171    participants: &[FederatedParticipant],
1172    rendezvous: &[FederatedRendezvous],
1173    cursor: Option<&str>,
1174    budget: DetailedRelationBudget,
1175) -> ServiceResult<u64> {
1176    let cursor_bytes = u64::try_from(cursor.map_or(0, str::len)).map_err(|_overflow| {
1177        ServiceError::InvalidInput("federated cursor byte count overflowed".to_string())
1178    })?;
1179    let federation_bytes = checked_sum(
1180        serialized_equivalent_bytes(&(participants, rendezvous))?,
1181        cursor_bytes,
1182        "federated intermediate bytes",
1183    )?;
1184    let total = checked_sum(
1185        primary_bytes,
1186        federation_bytes,
1187        "federated intermediate bytes",
1188    )?;
1189    if total > budget.intermediate_bytes() {
1190        return Err(ServiceError::InvalidInput(
1191            "federated aggregate intermediate-byte budget was exhausted".to_string(),
1192        ));
1193    }
1194    Ok(total)
1195}
1196
1197/// Derive the aggregate row-work ceiling from the existing typed budget.
1198fn aggregate_row_limit(budget: DetailedRelationBudget) -> u64 {
1199    u64::from(budget.nodes())
1200        .saturating_add(u64::from(budget.edges()))
1201        .saturating_add(u64::from(budget.occurrences_total()))
1202        .saturating_add(u64::from(budget.page_rows()))
1203}
1204
1205/// Bind a root without returning its machine-local path.
1206fn federated_root_digest(root: &CanonicalProjectRoot) -> ServiceResult<[u8; 32]> {
1207    canonical_root_digest(FEDERATED_ROOT_DIGEST_DOMAIN, root)
1208}
1209
1210/// Observe request cancellation at a repository-traversal boundary.
1211fn check_control(control: Option<&IndexWorkControl>) -> ServiceResult<()> {
1212    if let Some(control) = control {
1213        control
1214            .check(IndexWorkStage::RepositoryTraversal)
1215            .map_err(DbError::from)?;
1216    }
1217    Ok(())
1218}
1219
1220/// Retain one stable reached-limit value.
1221fn push_limit(limits: &mut Vec<GraphLimitKind>, limit: GraphLimitKind) {
1222    if !limits.contains(&limit) {
1223        limits.push(limit);
1224    }
1225}
1226
1227/// Add two work counters without silent overflow.
1228fn checked_sum(left: u64, right: u64, context: &'static str) -> ServiceResult<u64> {
1229    left.checked_add(right)
1230        .ok_or_else(|| ServiceError::InvalidInput(format!("{context} overflowed")))
1231}
1232
1233/// Return a saturating millisecond duration for public work reports.
1234fn elapsed_ms(started: Instant) -> u64 {
1235    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241    use crate::relations::{RelationAnchor, RelationDirection, RelationResolutionFilter};
1242    use projectatlas_core::graph::{
1243        Completeness, EntitySelector, GraphEntity, GraphIdentityText, GraphLimits,
1244        RepositoryFilePath,
1245    };
1246    use projectatlas_core::language::ContentSelection;
1247    use projectatlas_core::symbols::RelationKind;
1248    use projectatlas_core::{IndexCancellation, Node, NodeKind};
1249    use projectatlas_db::sqlite_progress_test_observer::{
1250        SqliteReadProgressEvent, observe_sqlite_read_progress,
1251    };
1252    use std::cell::Cell;
1253    use std::error::Error;
1254    use std::io;
1255    use std::path::Path;
1256    use std::rc::Rc;
1257
1258    #[cfg(unix)]
1259    #[test]
1260    fn federation_root_digest_preserves_non_utf8_root_collisions() -> Result<(), Box<dyn Error>> {
1261        use std::os::unix::ffi::OsStringExt;
1262
1263        let temp = tempfile::tempdir()?;
1264        let native = temp
1265            .path()
1266            .join(std::ffi::OsString::from_vec(vec![b'r', b'o', b'o', 0x80]));
1267        let replacement = temp.path().join("roo�");
1268        fs::create_dir(&native)?;
1269        fs::create_dir(&replacement)?;
1270        let native_root = CanonicalProjectRoot::from_path(&native)?;
1271        let replacement_root = CanonicalProjectRoot::from_path(&replacement)?;
1272        require(
1273            federated_root_digest(&native_root)? != federated_root_digest(&replacement_root)?,
1274            "federation identity collapsed non-UTF-8 and replacement roots",
1275        )?;
1276        Ok(())
1277    }
1278
1279    #[cfg(windows)]
1280    #[test]
1281    fn federation_recanonicalizes_case_only_root_and_rejects_case_sensitive_sibling()
1282    -> Result<(), Box<dyn Error>> {
1283        let temp = tempfile::tempdir()?;
1284        let original_root = temp.path().join("FederatedCaseRoot");
1285        let staging_root = temp.path().join("FederatedCaseRootStaging");
1286        let renamed_root = temp.path().join("federatedcaseroot");
1287        let original_database = original_root.join("projectatlas.db");
1288        publish_fixture(
1289            &original_root,
1290            &original_database,
1291            IndexGeneration::new(1),
1292            1,
1293        )?;
1294        let duplicate_database = temp.path().join("federated-duplicate.db");
1295        publish_fixture(
1296            &original_root,
1297            &duplicate_database,
1298            IndexGeneration::new(1),
1299            1,
1300        )?;
1301        let captured_root = CanonicalProjectRoot::from_path(&original_root)?;
1302        fs::rename(&original_root, &staging_root)?;
1303        fs::rename(&staging_root, &renamed_root)?;
1304
1305        let renamed_identity = CanonicalProjectRoot::from_path(&renamed_root)?;
1306        let renamed_database = renamed_root.join("projectatlas.db");
1307        let before_repair_store =
1308            AtlasStore::open_read_only_for_project(&renamed_database, &renamed_root)?;
1309        let before_repair = crate::relations::load_detailed_relations(
1310            &before_repair_store,
1311            &relation_query(
1312                None,
1313                RelationResolutionFilter::External,
1314                RelationDirection::Outbound,
1315            )?,
1316            None,
1317        )?;
1318        before_repair_store.finish_index_read_snapshot()?;
1319        drop(before_repair_store);
1320        let captured_digest = federated_root_digest(&captured_root)?;
1321        require(
1322            captured_digest == federated_root_digest(&renamed_identity)?,
1323            "case-only root rename changed the service root digest",
1324        )?;
1325        let repair_store = AtlasStore::open_for_project(&renamed_database, &renamed_root)?;
1326        drop(repair_store);
1327        let after_repair_store =
1328            AtlasStore::open_read_only_for_project(&renamed_database, &renamed_root)?;
1329        let resumed = crate::relations::load_detailed_relations(
1330            &after_repair_store,
1331            &relation_query(
1332                Some(
1333                    before_repair
1334                        .continuation
1335                        .ok_or("case-only rename fixture omitted a relation continuation")?,
1336                ),
1337                RelationResolutionFilter::External,
1338                RelationDirection::Outbound,
1339            )?,
1340            None,
1341        );
1342        after_repair_store.finish_index_read_snapshot()?;
1343        drop(after_repair_store);
1344        require(
1345            resumed.is_ok(),
1346            "case-only root rename invalidated a relation continuation",
1347        )?;
1348
1349        let repaired_binding =
1350            AtlasStore::open_read_only(&renamed_database)?.captured_project_binding()?;
1351        let duplicate_binding =
1352            AtlasStore::open_read_only(&duplicate_database)?.captured_project_binding()?;
1353        require(
1354            repaired_binding.project_instance_id != duplicate_binding.project_instance_id,
1355            "duplicate federation fixture reused the repaired project identity",
1356        )?;
1357        require(
1358            repaired_binding.project_root_identity.encode()?
1359                != duplicate_binding.project_root_identity.encode()?,
1360            "duplicate federation fixture did not retain old and fresh root spellings",
1361        )?;
1362
1363        let secondary_root = temp.path().join("federated-secondary");
1364        let secondary_database = secondary_root.join("projectatlas.db");
1365        publish_fixture(
1366            &secondary_root,
1367            &secondary_database,
1368            IndexGeneration::new(1),
1369            1,
1370        )?;
1371        let participants = open_participants(&[
1372            (renamed_root.clone(), renamed_database.clone()),
1373            (secondary_root, secondary_database),
1374        ])?;
1375        let draft = load_federated_detailed_relations(
1376            participants,
1377            &relation_query(
1378                None,
1379                RelationResolutionFilter::External,
1380                RelationDirection::Outbound,
1381            )?,
1382            None,
1383        )?;
1384        let (report, _) = draft.fit_output(None, |report| {
1385            serde_json::to_string(report).map_err(ServiceError::from)
1386        })?;
1387        require(
1388            report.participants.len() == 2,
1389            "federated query rejected a case-only root rename",
1390        )?;
1391
1392        let duplicate = load_federated_detailed_relations(
1393            open_participants(&[
1394                (renamed_root.clone(), renamed_database),
1395                (renamed_root, duplicate_database),
1396            ])?,
1397            &relation_query(
1398                None,
1399                RelationResolutionFilter::External,
1400                RelationDirection::Outbound,
1401            )?,
1402            None,
1403        );
1404        require(
1405            matches!(
1406                duplicate,
1407                Err(ServiceError::InvalidInput(message))
1408                    if message == "federated roots or project identities must be unique"
1409            ),
1410            "federation admitted two databases for one case-renamed root",
1411        )?;
1412
1413        let case_sensitive_parent = temp.path().join("federated-case-sensitive-parent");
1414        fs::create_dir(&case_sensitive_parent)?;
1415        let enabled = std::process::Command::new("fsutil")
1416            .args(["file", "SetCaseSensitiveInfo"])
1417            .arg(&case_sensitive_parent)
1418            .arg("enable")
1419            .status()
1420            .is_ok_and(|status| status.success());
1421        if !enabled {
1422            return Ok(());
1423        }
1424        let stored_root = case_sensitive_parent.join("Repo");
1425        let selected_root = case_sensitive_parent.join("repo");
1426        let stored_database = stored_root.join("projectatlas.db");
1427        fs::create_dir(&stored_root)?;
1428        fs::create_dir(&selected_root)?;
1429        publish_fixture(&stored_root, &stored_database, IndexGeneration::new(1), 1)?;
1430        let store = AtlasStore::open_read_only(&stored_database)?;
1431        let result = FederatedStore::new(
1432            store,
1433            stored_database,
1434            selected_root,
1435            FederatedInputWork::default(),
1436        );
1437        require(
1438            matches!(result, Err(ServiceError::InvalidInput(message)) if message == "federated store does not match its explicit root"),
1439            "federated admission accepted a case-sensitive sibling",
1440        )?;
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn federation_is_project_qualified_fresh_bounded_and_handle_free() -> Result<(), Box<dyn Error>>
1446    {
1447        let temp = tempfile::tempdir()?;
1448        let mut participants = Vec::new();
1449        for index in 0..4 {
1450            let root = temp.path().join(format!("project-{index}"));
1451            let database = root.join("projectatlas.db");
1452            publish_fixture(&root, &database, IndexGeneration::new(1), 1)?;
1453            participants.push((root, database));
1454        }
1455        let before = participants
1456            .iter()
1457            .map(|(_, database)| fs::read(database))
1458            .collect::<Result<Vec<_>, _>>()?;
1459        let query = relation_query(
1460            None,
1461            RelationResolutionFilter::External,
1462            RelationDirection::Outbound,
1463        )?;
1464        let draft =
1465            load_federated_detailed_relations(open_participants(&participants)?, &query, None)?;
1466        let (report, encoded) = draft.fit_output(None, |report| {
1467            serde_json::to_string(report).map_err(ServiceError::from)
1468        })?;
1469        require(
1470            report.participants.len() == 4
1471                && report.work.simultaneously_open_snapshots == 4
1472                && report.rendezvous.len() == 2
1473                && report.rendezvous.iter().all(|row| row.evidence.len() == 4),
1474            "many-root rendezvous or snapshot accounting changed",
1475        )?;
1476        let projects = report
1477            .rendezvous
1478            .iter()
1479            .flat_map(|row| row.evidence.iter().map(|evidence| evidence.project))
1480            .collect::<BTreeSet<_>>();
1481        require(
1482            projects.len() == 4
1483                && report
1484                    .rendezvous
1485                    .iter()
1486                    .flat_map(|row| &row.evidence)
1487                    .all(|evidence| {
1488                        evidence.classification == Some(ContentClassification::Source)
1489                            && matches!(
1490                                evidence.source.selector(),
1491                                EntitySelector::File { path } if path.as_str() == "src/same.rs"
1492                            )
1493                    }),
1494            "same relative paths collapsed across project identities",
1495        )?;
1496        let classified_query = relation_query_with_selection(
1497            None,
1498            RelationResolutionFilter::External,
1499            RelationDirection::Outbound,
1500            ContentSelection::Source,
1501        )?;
1502        let classified = load_federated_detailed_relations(
1503            open_participants(&participants)?,
1504            &classified_query,
1505            None,
1506        )?;
1507        let (classified, _) = classified.fit_output(None, |report| {
1508            serde_json::to_string(report).map_err(ServiceError::from)
1509        })?;
1510        require(
1511            classified.rendezvous == report.rendezvous
1512                && classified.primary.content_selection == Some(ContentSelection::Source),
1513            "classified federation changed eligible rendezvous evidence or lost selection",
1514        )?;
1515        require(
1516            encoded.len() <= query.budget.output_bytes() as usize
1517                && report.work.intermediate_bytes <= query.budget.intermediate_bytes(),
1518            "federated output escaped its aggregate byte budgets",
1519        )?;
1520        let rendezvous_identities = external_relation_identities(&report.primary);
1521        let encoded_identity_bytes =
1522            u64::try_from(serde_json::to_vec(&rendezvous_identities)?.len())?;
1523        require(
1524            !rendezvous_identities.is_empty()
1525                && serialized_equivalent_bytes(&rendezvous_identities)? == encoded_identity_bytes,
1526            "streamed federation identity accounting diverged from exact JSON bytes",
1527        )?;
1528        let after = participants
1529            .iter()
1530            .map(|(_, database)| fs::read(database))
1531            .collect::<Result<Vec<_>, _>>()?;
1532        require(
1533            before == after,
1534            "read-only federation changed database bytes",
1535        )?;
1536
1537        let mut analysis = load_federated_relation_analysis(
1538            open_participants(&participants)?,
1539            &RelationAnalysisQuery {
1540                relations: query,
1541                mode: crate::analysis::RelationAnalysisMode::Architecture,
1542                trace_target: None,
1543                vcs: None,
1544                include_communities: false,
1545                include_cycles: false,
1546                include_dead_code: false,
1547                entrypoint_profile: None,
1548            },
1549            None,
1550        )?;
1551        require(
1552            analysis
1553                .primary
1554                .take_external_relation_identities()
1555                .is_empty(),
1556            "federated analysis retained temporary rendezvous identities through output fitting",
1557        )?;
1558        let (analysis, _) = analysis.fit_output(|report, _control| {
1559            serde_json::to_vec(report).map_err(ServiceError::from)
1560        })?;
1561        require(
1562            analysis.rendezvous.len() == 2
1563                && analysis
1564                    .rendezvous
1565                    .iter()
1566                    .flat_map(|row| &row.evidence)
1567                    .all(|evidence| {
1568                        matches!(
1569                            evidence.source.selector(),
1570                            EntitySelector::File { path } if path.as_str() == "src/same.rs"
1571                        )
1572                    }),
1573            "analysis rendezvous escaped the primary anchored traversal",
1574        )?;
1575
1576        let inbound_query = relation_query(
1577            None,
1578            RelationResolutionFilter::External,
1579            RelationDirection::Inbound,
1580        )?;
1581        let inbound = load_federated_detailed_relations(
1582            open_participants(&participants)?,
1583            &inbound_query,
1584            None,
1585        )?;
1586        let (inbound, _) = inbound.fit_output(None, |report| {
1587            serde_json::to_string(report).map_err(ServiceError::from)
1588        })?;
1589        require(
1590            inbound.rendezvous.is_empty() && inbound.work.rendezvous_database_rows == 0,
1591            "inbound traversal scanned or returned unrelated external rendezvous",
1592        )?;
1593        let inbound_analysis = load_federated_relation_analysis(
1594            open_participants(&participants)?,
1595            &RelationAnalysisQuery {
1596                relations: inbound_query,
1597                mode: crate::analysis::RelationAnalysisMode::Architecture,
1598                trace_target: None,
1599                vcs: None,
1600                include_communities: false,
1601                include_cycles: false,
1602                include_dead_code: false,
1603                entrypoint_profile: None,
1604            },
1605            None,
1606        )?;
1607        let (inbound_analysis, _) = inbound_analysis.fit_output(|report, _control| {
1608            serde_json::to_vec(report).map_err(ServiceError::from)
1609        })?;
1610        require(
1611            inbound_analysis.rendezvous.is_empty()
1612                && inbound_analysis.work.rendezvous_database_rows == 0,
1613            "inbound analysis scanned or returned unrelated external rendezvous",
1614        )?;
1615
1616        let resolved = load_federated_detailed_relations(
1617            open_participants(&participants)?,
1618            &relation_query(
1619                None,
1620                RelationResolutionFilter::Resolved,
1621                RelationDirection::Outbound,
1622            )?,
1623            None,
1624        )?;
1625        let (resolved, _) = resolved.fit_output(None, |report| {
1626            serde_json::to_string(report).map_err(ServiceError::from)
1627        })?;
1628        require(
1629            resolved.rendezvous.is_empty(),
1630            "federation ignored the requested resolution filter",
1631        )?;
1632
1633        let cursor = report
1634            .primary
1635            .continuation
1636            .ok_or("first federated page omitted its continuation")?;
1637        publish_fixture(
1638            &participants[3].0,
1639            &participants[3].1,
1640            IndexGeneration::new(2),
1641            1,
1642        )?;
1643        let stale = load_federated_detailed_relations(
1644            open_participants(&participants)?,
1645            &relation_query(
1646                Some(cursor),
1647                RelationResolutionFilter::External,
1648                RelationDirection::Outbound,
1649            )?,
1650            None,
1651        );
1652        require(
1653            matches!(
1654                stale,
1655                Err(ServiceError::RelationCursorStale {
1656                    field: "federated graph generation"
1657                })
1658            ),
1659            "a changed secondary generation did not stale the outer cursor",
1660        )?;
1661
1662        let control = IndexWorkControl::new(IndexCancellation::new(), None);
1663        control.cancel();
1664        let canceled = load_federated_detailed_relations(
1665            open_participants(&participants)?,
1666            &relation_query(
1667                None,
1668                RelationResolutionFilter::External,
1669                RelationDirection::Outbound,
1670            )?,
1671            Some(&control),
1672        );
1673        require(canceled.is_err(), "pre-canceled federation returned rows")?;
1674        for (index, (_, database)) in participants.iter().enumerate() {
1675            let moved = database.with_extension(format!("closed-{index}"));
1676            fs::rename(database, &moved)?;
1677            fs::rename(moved, database)?;
1678        }
1679        Ok(())
1680    }
1681
1682    #[test]
1683    fn federation_deadline_interrupts_active_rendezvous_and_releases_snapshots()
1684    -> Result<(), Box<dyn Error>> {
1685        let temp = tempfile::tempdir()?;
1686        let primary_root = temp.path().join("primary");
1687        let primary_database = primary_root.join("projectatlas.db");
1688        publish_fixture(&primary_root, &primary_database, IndexGeneration::new(1), 1)?;
1689        let secondary_root = temp.path().join("secondary");
1690        let secondary_database = secondary_root.join("projectatlas.db");
1691        publish_fixture(
1692            &secondary_root,
1693            &secondary_database,
1694            IndexGeneration::new(1),
1695            usize::try_from(GraphLimits::MAX_ROWS)?,
1696        )?;
1697        let participants = vec![
1698            (primary_root, primary_database),
1699            (secondary_root, secondary_database),
1700        ];
1701        let mut query = relation_query(
1702            None,
1703            RelationResolutionFilter::External,
1704            RelationDirection::Outbound,
1705        )?;
1706        query.budget = query.budget.with_aggregate_limits(
1707            Some(GraphLimits::MAX_ROWS),
1708            None,
1709            None,
1710            None,
1711            None,
1712            None,
1713        )?;
1714        let control = IndexWorkControl::with_deadline(
1715            IndexCancellation::new(),
1716            Instant::now() + Duration::from_secs(1),
1717        );
1718        let family_query_active = Rc::new(Cell::new(false));
1719        let family_query_entered_live = Rc::new(Cell::new(false));
1720        let callback_entered_live = Rc::new(Cell::new(false));
1721        let callback_interrupted = Rc::new(Cell::new(false));
1722        let stores = open_participants(&participants)?;
1723        let deadline = observe_sqlite_read_progress(
1724            {
1725                let observer_control = control.clone();
1726                let family_query_active = Rc::clone(&family_query_active);
1727                let family_query_entered_live = Rc::clone(&family_query_entered_live);
1728                let callback_entered_live = Rc::clone(&callback_entered_live);
1729                let callback_interrupted = Rc::clone(&callback_interrupted);
1730                move |event| match event {
1731                    SqliteReadProgressEvent::RepositoryRelationFamilyQueryEntered => {
1732                        family_query_active.set(true);
1733                        if observer_control
1734                            .check(IndexWorkStage::RepositoryTraversal)
1735                            .is_ok()
1736                        {
1737                            family_query_entered_live.set(true);
1738                        }
1739                    }
1740                    SqliteReadProgressEvent::RepositoryRelationFamilyQueryExited => {
1741                        family_query_active.set(false);
1742                    }
1743                    SqliteReadProgressEvent::CallbackEntered { stage }
1744                        if family_query_active.get() && !callback_entered_live.get() =>
1745                    {
1746                        if observer_control.check(stage).is_ok() {
1747                            callback_entered_live.set(true);
1748                            while observer_control.check(stage).is_ok() {
1749                                std::thread::sleep(Duration::from_millis(1));
1750                            }
1751                        }
1752                    }
1753                    SqliteReadProgressEvent::CallbackEvaluated {
1754                        interrupted: true, ..
1755                    } if family_query_active.get() && callback_entered_live.get() => {
1756                        callback_interrupted.set(true);
1757                    }
1758                    _ => {}
1759                }
1760            },
1761            || load_federated_detailed_relations(stores, &query, Some(&control)),
1762        );
1763        require(
1764            matches!(
1765                deadline,
1766                Err(ServiceError::Db(DbError::IndexWork(
1767                    projectatlas_core::IndexWorkFailure::DeadlineExceeded {
1768                        stage: IndexWorkStage::RepositoryTraversal
1769                    }
1770                )))
1771            ),
1772            "active rendezvous query was not interrupted with its typed deadline",
1773        )?;
1774        require(
1775            family_query_entered_live.get()
1776                && callback_entered_live.get()
1777                && callback_interrupted.get()
1778                && !family_query_active.get(),
1779            "rendezvous deadline did not enter a live family query and interrupt it through SQLite",
1780        )?;
1781        for (index, (_, database)) in participants.iter().enumerate() {
1782            let moved = database.with_extension(format!("deadline-closed-{index}"));
1783            fs::rename(database, &moved)?;
1784            fs::rename(moved, database)?;
1785        }
1786        Ok(())
1787    }
1788
1789    /// Publish three anchored imports plus the requested unrelated same-family imports.
1790    fn publish_fixture(
1791        root: &Path,
1792        database: &Path,
1793        generation: IndexGeneration,
1794        unrelated_relations: usize,
1795    ) -> Result<(), Box<dyn Error>> {
1796        fs::create_dir_all(root.join("src"))?;
1797        fs::write(root.join("src/same.rs"), "pub fn same() {}\n")?;
1798        fs::write(root.join("src/unrelated.rs"), "pub fn unrelated() {}\n")?;
1799        let mut store = AtlasStore::open_for_project(database, root)?;
1800        let project = store
1801            .project_instance_id()?
1802            .ok_or("federation fixture project identity is missing")?;
1803        let source = GraphEntity::new(
1804            project,
1805            EntitySelector::File {
1806                path: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1807            },
1808            generation,
1809        )?;
1810        let unrelated_source = GraphEntity::new(
1811            project,
1812            EntitySelector::File {
1813                path: RepositoryFilePath::new(Path::new("src/unrelated.rs"))?,
1814            },
1815            generation,
1816        )?;
1817        let mut entities = vec![source.clone(), unrelated_source.clone()];
1818        let mut relations = Vec::new();
1819        for identity in ["package/a", "package/b", "package/c"] {
1820            let external = GraphEntity::new(
1821                project,
1822                EntitySelector::External {
1823                    external: ExternalSelector {
1824                        system: GraphIdentityText::new("registry.example")?,
1825                        identity: GraphIdentityText::new(identity)?,
1826                    },
1827                },
1828                generation,
1829            )?;
1830            relations.push(LogicalRelation::new(
1831                &source,
1832                GraphRelationKind::Legacy(RelationKind::Imports),
1833                RelationResolution::external(&external)?,
1834                projectatlas_core::graph::ConfidenceClass::Exact,
1835                Completeness::Complete,
1836                generation,
1837            )?);
1838            entities.push(external);
1839        }
1840        for index in 0..unrelated_relations {
1841            let identity = if unrelated_relations == 1 {
1842                "package/unrelated".to_string()
1843            } else {
1844                format!("package/unrelated/{index:05}")
1845            };
1846            let unrelated_external = GraphEntity::new(
1847                project,
1848                EntitySelector::External {
1849                    external: ExternalSelector {
1850                        system: GraphIdentityText::new("registry.example")?,
1851                        identity: GraphIdentityText::new(identity)?,
1852                    },
1853                },
1854                generation,
1855            )?;
1856            relations.push(LogicalRelation::new(
1857                &unrelated_source,
1858                GraphRelationKind::Legacy(RelationKind::Imports),
1859                RelationResolution::external(&unrelated_external)?,
1860                projectatlas_core::graph::ConfidenceClass::Exact,
1861                Completeness::Complete,
1862                generation,
1863            )?);
1864            entities.push(unrelated_external);
1865        }
1866        let mut publication = store.begin_index_publication("federation-fixture")?;
1867        publication.begin_scan_replacement()?;
1868        publication.upsert_scan_node_batch(&[
1869            fixture_folder_node("src"),
1870            fixture_file_node("src/same.rs"),
1871            fixture_file_node("src/unrelated.rs"),
1872        ])?;
1873        publication.upsert_file_content_classification_batch(&[
1874            projectatlas_db::FileContentClassification {
1875                path: "src/same.rs".to_string(),
1876                classification: ContentClassification::Source,
1877            },
1878            projectatlas_db::FileContentClassification {
1879                path: "src/unrelated.rs".to_string(),
1880                classification: ContentClassification::Documentation,
1881            },
1882        ])?;
1883        publication.finish_scan_replacement()?;
1884        publication.replace_repository_graph(project, &entities, &relations, &[], &[])?;
1885        publication.complete()?;
1886        Ok(())
1887    }
1888
1889    /// Open every fixture through the production root-bound read-only adapter.
1890    fn open_participants(
1891        participants: &[(PathBuf, PathBuf)],
1892    ) -> Result<Vec<FederatedStore>, Box<dyn Error>> {
1893        participants
1894            .iter()
1895            .map(|(root, database)| {
1896                Ok(FederatedStore::new(
1897                    AtlasStore::open_read_only_for_project(database, root)?,
1898                    database.clone(),
1899                    root.clone(),
1900                    FederatedInputWork::default(),
1901                )?)
1902            })
1903            .collect()
1904    }
1905
1906    /// Build one bounded anchored relation request.
1907    fn relation_query(
1908        cursor: Option<String>,
1909        resolution: RelationResolutionFilter,
1910        direction: RelationDirection,
1911    ) -> Result<DetailedRelationQuery, Box<dyn Error>> {
1912        relation_query_with_selection(
1913            cursor,
1914            resolution,
1915            direction,
1916            ContentSelection::UnspecifiedLegacy,
1917        )
1918    }
1919
1920    /// Build one bounded anchored relation request with explicit content selection.
1921    fn relation_query_with_selection(
1922        cursor: Option<String>,
1923        resolution: RelationResolutionFilter,
1924        direction: RelationDirection,
1925        content_selection: ContentSelection,
1926    ) -> Result<DetailedRelationQuery, Box<dyn Error>> {
1927        Ok(DetailedRelationQuery {
1928            anchor: RelationAnchor::File {
1929                file: RepositoryFilePath::new(Path::new("src/same.rs"))?,
1930            },
1931            direction,
1932            relation: Some(GraphRelationKind::Legacy(RelationKind::Imports)),
1933            minimum_confidence: projectatlas_core::graph::ConfidenceClass::Low,
1934            resolution,
1935            include_occurrences: false,
1936            budget: DetailedRelationBudget::from_graph_limits(GraphLimits::new(
1937                2,
1938                1,
1939                1,
1940                512 * 1_024,
1941            )?)
1942            .with_aggregate_limits(Some(100), None, None, None, None, None)?,
1943            cursor,
1944            content_selection,
1945        })
1946    }
1947
1948    /// Build one indexed source-file row.
1949    fn fixture_file_node(path: &str) -> Node {
1950        Node {
1951            path: path.to_string(),
1952            kind: NodeKind::File,
1953            parent_path: Some("src".to_string()),
1954            extension: Some("rs".to_string()),
1955            language: Some("Rust".to_string()),
1956            size_bytes: Some(17),
1957            mtime_ns: Some(1),
1958            content_hash: Some("fixture-hash".to_string()),
1959        }
1960    }
1961
1962    /// Build one indexed source-folder row.
1963    fn fixture_folder_node(path: &str) -> Node {
1964        Node {
1965            path: path.to_string(),
1966            kind: NodeKind::Folder,
1967            parent_path: Some(".".to_string()),
1968            extension: None,
1969            language: None,
1970            size_bytes: None,
1971            mtime_ns: Some(1),
1972            content_hash: None,
1973        }
1974    }
1975
1976    /// Return one readable test failure.
1977    fn require(condition: bool, message: &str) -> Result<(), io::Error> {
1978        condition
1979            .then_some(())
1980            .ok_or_else(|| io::Error::other(message))
1981    }
1982}