Skip to main content

projectatlas/runtime/graph_projection/
cargo_roots.rs

1//! Shared invalidation cohorts for manifest-listed sibling Cargo roots.
2
3use super::{
4    CanonicalResolutionKey, CliError, IndexWorkControl, IndexWorkStage,
5    MAX_INCREMENTAL_RESOLUTION_ITEMS, PackageIndex, PackageOwner, ProjectInstanceId,
6    RepositoryFilePath, STAGED_GRAPH_ROW_BYTES, SourceBindingCache, resolution_key_limit_failure,
7};
8use projectatlas_symbols::{
9    SourceCargoTargetKind, SourceCargoTargets, cargo_automatic_target_directory,
10    cargo_root_cohort_key, cargo_target_inventory_key,
11};
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15/// Distinguish automatic candidate discovery from exact configured-root cohorts.
16#[derive(Clone, Copy)]
17enum CohortKind {
18    /// Every flat source can be an automatic target candidate.
19    Automatic,
20    /// Only manifest-listed roots emit the configured cohort key.
21    Explicit,
22}
23
24/// One package/manifest-scoped parent inventory, shared by its exact roots.
25pub(super) struct CargoRootCohort {
26    /// Nonempty repository-relative directory identity; `.` denotes the root.
27    pub(super) directory: Arc<str>,
28    /// Admitted key whose producer is shared with source projection.
29    pub(super) key: CanonicalResolutionKey,
30}
31
32/// Only actual explicit roots receive custom cohort source keys.
33#[derive(Default)]
34pub(super) struct CargoRootCohortIndex {
35    /// Exact listed sibling roots outside the conventional automatic layouts.
36    explicit: BTreeMap<String, Arc<CargoRootCohort>>,
37    /// Existing flat automatic-directory keys, without new placement rules.
38    automatic: BTreeMap<String, Arc<CargoRootCohort>>,
39}
40
41impl CargoRootCohortIndex {
42    /// Borrow a producer only for a manifest-listed custom root.
43    pub(super) fn explicit_directory(&self, path: &str) -> Option<&str> {
44        self.explicit
45            .get(path)
46            .map(|cohort| cohort.directory.as_ref())
47    }
48
49    /// Select an admitted candidate group; callers still prove actual membership.
50    pub(super) fn candidate(&self, path: &str, package_root: &str) -> Option<Arc<CargoRootCohort>> {
51        self.explicit
52            .get(path)
53            .or_else(|| {
54                cargo_automatic_target_directory(path, package_root)
55                    .and_then(|directory| self.automatic.get(directory))
56            })
57            .map(Arc::clone)
58    }
59}
60
61/// Admit manifest-relative paths only inside this repository.
62pub(super) fn repository_join(directory: &str, path: &str) -> Option<String> {
63    let mut parts = directory
64        .split('/')
65        .filter(|part| !part.is_empty())
66        .collect::<Vec<_>>();
67    for component in std::path::Path::new(path).components() {
68        match component {
69            std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
70            std::path::Component::CurDir => {}
71            std::path::Component::ParentDir => {
72                parts.pop()?;
73            }
74            std::path::Component::Normal(part) => parts.push(part.to_str()?),
75        }
76    }
77    let path = parts.join("/");
78    RepositoryFilePath::new(std::path::Path::new(&path))
79        .ok()
80        .map(|_| path)
81}
82
83/// Borrow a canonical repository path's parent without allocating.
84fn parent(path: &str) -> &str {
85    path.rsplit_once('/').map_or("", |(directory, _)| directory)
86}
87
88impl SourceBindingCache<'_> {
89    /// Reserve a derived cache or temporary construction before inserting it.
90    fn reserve_cohort_bytes(&mut self, bytes: u64) -> Result<(), CliError> {
91        let next = self.retained_bytes.saturating_add(bytes);
92        super::enforce_resolution_registry_budget_with_limit(next, self.retained_limit)?;
93        self.retained_bytes = next;
94        Ok(())
95    }
96
97    /// Admit one shared key; an invalid identity retains exact file causes instead.
98    fn cohort(
99        &mut self,
100        project: ProjectInstanceId,
101        package: &PackageOwner,
102        directory: &str,
103        kind: CohortKind,
104    ) -> Result<Option<Arc<CargoRootCohort>>, CliError> {
105        // Includes a conservative key/header envelope and both directory witnesses.
106        let reservation = STAGED_GRAPH_ROW_BYTES
107            .saturating_mul(8)
108            .saturating_add(package.name.len() as u64)
109            .saturating_add(package.manifest.len() as u64)
110            .saturating_add((directory.len() as u64).saturating_mul(2));
111        self.reserve_cohort_bytes(reservation)?;
112        let key = match kind {
113            CohortKind::Automatic => {
114                cargo_target_inventory_key(project, &package.name, &package.manifest, directory)
115            }
116            CohortKind::Explicit => {
117                cargo_root_cohort_key(project, &package.name, &package.manifest, directory)
118            }
119        };
120        let Ok(key) = key else {
121            self.retained_bytes -= reservation;
122            return Ok(None);
123        };
124        Ok(Some(Arc::new(CargoRootCohort {
125            directory: Arc::from(directory),
126            key,
127        })))
128    }
129
130    /// Cache only derived roots, sharing the already captured manifest's parse.
131    pub(super) fn cargo_root_cohorts(
132        &mut self,
133        project: ProjectInstanceId,
134        package: &PackageOwner,
135        targets: &SourceCargoTargets,
136        packages: &PackageIndex,
137        control: &IndexWorkControl,
138    ) -> Result<Arc<CargoRootCohortIndex>, CliError> {
139        if let Some(index) = self.cargo_cohorts.get(&package.manifest) {
140            return Ok(Arc::clone(index));
141        }
142        let before = self.retained_bytes;
143        let result = (|| {
144            let mut index = CargoRootCohortIndex::default();
145            let mut paths = Vec::new();
146            let mut temporary_rows = 0_u64;
147            let mut temporary_strings = 0_u64;
148            for target in &targets.targets {
149                control.check(IndexWorkStage::SymbolParsing)?;
150                let Some(path) = repository_join(&package.root, &target.path) else {
151                    continue;
152                };
153                if cargo_automatic_target_directory(&path, &package.root).is_some_and(|directory| {
154                    targets.automatic.iter().any(|automatic| {
155                        automatic
156                            .kind
157                            .automatic_directory()
158                            .and_then(|relative| repository_join(&package.root, relative))
159                            .is_some_and(|enabled| enabled == directory)
160                    })
161                }) || [
162                    SourceCargoTargetKind::Library,
163                    SourceCargoTargetKind::Binary,
164                    SourceCargoTargetKind::BuildScript,
165                ]
166                .into_iter()
167                .any(|kind| {
168                    kind.conventional_root()
169                        .and_then(|root| repository_join(&package.root, root))
170                        .is_some_and(|root| root == path)
171                }) || packages
172                    .package_owner(&path)
173                    .is_none_or(|owner| owner.manifest != package.manifest)
174                {
175                    continue;
176                }
177                if paths.len() >= MAX_INCREMENTAL_RESOLUTION_ITEMS as usize {
178                    return Err(resolution_key_limit_failure(paths.len().saturating_add(1)));
179                }
180                let string_bytes = path.capacity() as u64;
181                self.reserve_cohort_bytes(STAGED_GRAPH_ROW_BYTES.saturating_add(string_bytes))?;
182                temporary_rows = temporary_rows.saturating_add(STAGED_GRAPH_ROW_BYTES);
183                temporary_strings = temporary_strings.saturating_add(string_bytes);
184                paths.push(path);
185            }
186            paths.sort_unstable_by(|left, right| {
187                parent(left)
188                    .cmp(parent(right))
189                    .then_with(|| left.cmp(right))
190            });
191            // Duplicate target declarations cannot create extra retained roots.
192            paths.dedup();
193            let mut retained_strings = 0_u64;
194            let mut paths = paths.into_iter().peekable();
195            while let Some(path) = paths.next() {
196                control.check(IndexWorkStage::SymbolParsing)?;
197                let directory = parent(&path);
198                if paths.peek().is_none_or(|next| parent(next) != directory) {
199                    continue;
200                }
201                let identity = if directory.is_empty() { "." } else { directory };
202                let Some(cohort) = self.cohort(project, package, identity, CohortKind::Explicit)?
203                else {
204                    continue;
205                };
206                let mut member = path;
207                loop {
208                    self.reserve_cohort_bytes(STAGED_GRAPH_ROW_BYTES)?;
209                    retained_strings = retained_strings.saturating_add(member.capacity() as u64);
210                    index.explicit.insert(member, Arc::clone(&cohort));
211                    if !paths.peek().is_some_and(|next| {
212                        let directory = parent(next);
213                        (if directory.is_empty() { "." } else { directory })
214                            == cohort.directory.as_ref()
215                    }) {
216                        break;
217                    }
218                    let Some(next) = paths.next() else { break };
219                    member = next;
220                }
221            }
222            self.retained_bytes = self
223                .retained_bytes
224                .saturating_sub(temporary_rows)
225                .saturating_sub(temporary_strings.saturating_sub(retained_strings));
226            for automatic in &targets.automatic {
227                control.check(IndexWorkStage::SymbolParsing)?;
228                let Some(relative) = automatic.kind.automatic_directory() else {
229                    continue;
230                };
231                let Some(directory) = repository_join(&package.root, relative) else {
232                    continue;
233                };
234                if let Some(cohort) =
235                    self.cohort(project, package, &directory, CohortKind::Automatic)?
236                {
237                    self.reserve_cohort_bytes(
238                        STAGED_GRAPH_ROW_BYTES.saturating_add(directory.capacity() as u64),
239                    )?;
240                    index.automatic.insert(directory, cohort);
241                }
242            }
243            self.reserve_cohort_bytes(
244                STAGED_GRAPH_ROW_BYTES.saturating_add(package.manifest.len() as u64),
245            )?;
246            Ok(Arc::new(index))
247        })();
248        match result {
249            Ok(index) => {
250                self.cargo_cohorts
251                    .insert(package.manifest.clone(), Arc::clone(&index));
252                Ok(index)
253            }
254            Err(error) => {
255                self.retained_bytes = before;
256                Err(error)
257            }
258        }
259    }
260}
261
262impl PackageIndex {
263    /// Preserve admitted sidecars and hydrate only manifests needed by Rust projection.
264    pub(super) fn from_sources(
265        project: ProjectInstanceId,
266        graphs: &[&super::SourceGraph<'_>],
267        sources: &mut SourceBindingCache<'_>,
268        control: &IndexWorkControl,
269    ) -> Result<Self, CliError> {
270        let mut package_bytes = 0_u64;
271        let mut needed_bytes = 0_u64;
272        let result = (|| -> Result<Self, CliError> {
273            control.check(IndexWorkStage::SymbolParsing)?;
274            let mut index =
275                Self::from_graph_iter(graphs.iter().map(|graph| graph.graph()), &mut |bytes| {
276                    sources.reserve_cohort_bytes(bytes)?;
277                    package_bytes = package_bytes.saturating_add(bytes);
278                    Ok(())
279                })?;
280            let mut needed = std::collections::BTreeSet::new();
281            for graph in graphs {
282                control.check(IndexWorkStage::SymbolParsing)?;
283                if graph
284                    .graph()
285                    .language
286                    .as_deref()
287                    .and_then(projectatlas_core::language::language_capability)
288                    .and_then(|language| language.effective_semantic_provider())
289                    == Some(projectatlas_core::language::SemanticProviderOwner::Rust)
290                    && let Some(package) = index.package_owner(&graph.graph().path)
291                    && !needed.contains(&package.manifest)
292                {
293                    let bytes =
294                        STAGED_GRAPH_ROW_BYTES.saturating_add(package.manifest.len() as u64);
295                    sources.reserve_cohort_bytes(bytes)?;
296                    needed_bytes = needed_bytes.saturating_add(bytes);
297                    needed.insert(package.manifest.clone());
298                }
299            }
300            for position in 0..index.packages.len() {
301                let package = &index.packages[position];
302                if !needed.contains(&package.manifest) {
303                    continue;
304                }
305                control.check(IndexWorkStage::SymbolParsing)?;
306                let fresh = graphs
307                    .iter()
308                    .find(|graph| graph.graph().path == package.manifest)
309                    .and_then(|graph| graph.semantics())
310                    .and_then(|facts| facts.cargo());
311                let captured = if fresh.is_none() {
312                    Some(sources.capture(&package.manifest, control)?)
313                } else {
314                    None
315                };
316                let Some(targets) = fresh
317                    .or_else(|| {
318                        captured
319                            .as_ref()
320                            .and_then(|source| source.semantics.cargo())
321                    })
322                    .filter(|targets| targets.complete)
323                else {
324                    continue;
325                };
326                let cohorts =
327                    sources.cargo_root_cohorts(project, package, targets, &index, control)?;
328                index.packages[position].cohorts = Some(cohorts);
329            }
330            Ok(index)
331        })();
332        // The temporary selector set always drops. A failed constructor also
333        // drops its package rows; keep completed captured/cohort cache owners.
334        let kept_package_bytes = result.as_ref().map_or(0, |index| index.retained_bytes);
335        sources.retained_bytes = sources
336            .retained_bytes
337            .saturating_sub(needed_bytes)
338            .saturating_sub(package_bytes.saturating_sub(kept_package_bytes));
339        result
340    }
341
342    /// Source projection publishes a custom cohort only on an exact listed root.
343    pub(super) fn root_cohort_directory(&self, path: &str) -> Option<&str> {
344        self.package_owner(path)?
345            .cohorts
346            .as_ref()?
347            .explicit_directory(path)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::runtime::graph_projection::SourceGraph;
355    use projectatlas_core::IndexCancellation;
356    use projectatlas_db::AtlasStore;
357    use projectatlas_symbols::extract_symbol_graph_with_semantics_controlled;
358    use std::error::Error;
359    use std::fmt::Write as _;
360    use std::io;
361
362    /// Preserve useful diagnostics without panicking in fallible tests.
363    fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
364        if !condition {
365            return Err(io::Error::other(message).into());
366        }
367        Ok(())
368    }
369
370    #[test]
371    fn explicit_root_cohorts_publish_only_listed_siblings_and_cache_once()
372    -> Result<(), Box<dyn Error>> {
373        let temp = tempfile::tempdir()?;
374        let store = AtlasStore::open_for_project(&temp.path().join("atlas.db"), temp.path())?;
375        let project = store
376            .project_instance_id()?
377            .ok_or_else(|| io::Error::other("missing project"))?;
378        let control = IndexWorkControl::new(IndexCancellation::new(), None);
379        let mut manifest =
380            "[package]\nname=\"cohorts\"\nversion=\"0.1.0\"\nedition=\"2024\"\nautobins=false\n"
381                .to_string();
382        let paths = [
383            "app/a.rs",
384            "app/b.rs",
385            "one.rs",
386            "two.rs",
387            "solo/entry.rs",
388            "nested/one/main.rs",
389            "nested/two/main.rs",
390            "src/lib.rs",
391            "src/bin/a.rs",
392            "src/bin/b.rs",
393        ];
394        for (index, path) in paths.iter().enumerate() {
395            write!(
396                manifest,
397                "[[bin]]\nname=\"target{index}\"\npath=\"{path}\"\n"
398            )?;
399        }
400        let (_, manifest_graph, manifest_facts) = extract_symbol_graph_with_semantics_controlled(
401            "Cargo.toml",
402            Some("cargo-manifest"),
403            &manifest,
404            &control,
405        )?
406        .into_parts();
407        let (_, graph, facts) = extract_symbol_graph_with_semantics_controlled(
408            "app/a.rs",
409            Some("rust"),
410            "fn run(){}",
411            &control,
412        )?
413        .into_parts();
414        let graphs = [
415            SourceGraph::Staged {
416                graph: &manifest_graph,
417                semantics: Some(&manifest_facts),
418            },
419            SourceGraph::Staged {
420                graph: &graph,
421                semantics: Some(&facts),
422            },
423        ];
424        let references = graphs.iter().collect::<Vec<_>>();
425        let mut sources = SourceBindingCache::new(temp.path(), &[], None);
426        let packages = PackageIndex::from_sources(project, &references, &mut sources, &control)?;
427        require(
428            packages.root_cohort_directory("app/a.rs") == Some("app"),
429            "first sibling has no producer",
430        )?;
431        require(
432            packages.root_cohort_directory("app/b.rs") == Some("app"),
433            "missing sibling lost its producer",
434        )?;
435        require(
436            packages.root_cohort_directory("one.rs") == Some("."),
437            "root directory identity was empty",
438        )?;
439        for path in [
440            "app/helper.rs",
441            "solo/entry.rs",
442            "nested/one/main.rs",
443            "src/lib.rs",
444        ] {
445            require(
446                packages.root_cohort_directory(path).is_none(),
447                "non-cohort source gained custom authority",
448            )?;
449        }
450        let package = packages
451            .package_owner("app/a.rs")
452            .ok_or_else(|| io::Error::other("missing package"))?;
453        let cohorts = package
454            .cohorts
455            .as_ref()
456            .ok_or_else(|| io::Error::other("missing cohort index"))?;
457        let first = cohorts
458            .candidate("app/a.rs", "")
459            .ok_or_else(|| io::Error::other("missing first group"))?;
460        let second = cohorts
461            .candidate("app/b.rs", "")
462            .ok_or_else(|| io::Error::other("missing second group"))?;
463        require(
464            Arc::ptr_eq(&first, &second),
465            "siblings copied their cohort owner",
466        )?;
467        let configured = super::super::ConfiguredModuleResolution::default();
468        let context =
469            super::super::resolution_projection_context("app/a.rs", &packages, &configured)
470                .with_source_semantics(&facts);
471        let keys = projectatlas_symbols::derive_resolution_keys_with_context(
472            project,
473            Some("cohorts"),
474            &graph,
475            context,
476        )?;
477        require(
478            keys.source_keys().contains(&first.key),
479            "source producer disagrees with membership",
480        )?;
481        require(
482            packages.root_cohort_directory("src/bin/a.rs") == Some("src/bin"),
483            "disabled-kind explicit root lost its exclusive producer",
484        )?;
485        let disabled = cohorts
486            .candidate("src/bin/a.rs", "")
487            .ok_or_else(|| io::Error::other("missing disabled-kind sibling cohort"))?;
488        let (_, helper_graph, helper_facts) = extract_symbol_graph_with_semantics_controlled(
489            "src/bin/helper.rs",
490            Some("rust"),
491            "fn helper(){}",
492            &control,
493        )?
494        .into_parts();
495        let helper_context = super::super::resolution_projection_context(
496            "src/bin/helper.rs",
497            &packages,
498            &configured,
499        )
500        .with_source_semantics(&helper_facts);
501        let helper_keys = projectatlas_symbols::derive_resolution_keys_with_context(
502            project,
503            Some("cohorts"),
504            &helper_graph,
505            helper_context,
506        )?;
507        require(
508            !helper_keys.source_keys().contains(&disabled.key),
509            "disabled auto discovery made an unlisted helper a root invalidator",
510        )?;
511        let before = sources.retained_bytes;
512        let repeated = PackageIndex::from_sources(project, &references, &mut sources, &control)?;
513        require(
514            sources.retained_bytes == before.saturating_add(repeated.retained_bytes)
515                && sources.captured.is_empty(),
516            "repeated package owner omitted its allocation or duplicated cached metadata",
517        )?;
518        let repeated_index = repeated
519            .package_owner("app/a.rs")
520            .and_then(|package| package.cohorts.as_ref())
521            .ok_or_else(|| io::Error::other("missing repeated index"))?;
522        require(
523            Arc::ptr_eq(cohorts, repeated_index),
524            "operation duplicated the derived root index",
525        )?;
526        Ok(())
527    }
528
529    #[test]
530    fn root_cohort_refusal_and_cancellation_leave_no_partial_cache() -> Result<(), Box<dyn Error>> {
531        let temp = tempfile::tempdir()?;
532        let store = AtlasStore::open_for_project(&temp.path().join("atlas.db"), temp.path())?;
533        let project = store
534            .project_instance_id()?
535            .ok_or_else(|| io::Error::other("missing project"))?;
536        let control = IndexWorkControl::new(IndexCancellation::new(), None);
537        let manifest = "[package]\nname=\"cohorts\"\nversion=\"0.1.0\"\nedition=\"2024\"\n[[bin]]\nname=\"a\"\npath=\"app/a.rs\"\n[[bin]]\nname=\"b\"\npath=\"app/b.rs\"\n";
538        let (_, manifest_graph, manifest_facts) = extract_symbol_graph_with_semantics_controlled(
539            "Cargo.toml",
540            Some("cargo-manifest"),
541            manifest,
542            &control,
543        )?
544        .into_parts();
545        let (_, graph, facts) = extract_symbol_graph_with_semantics_controlled(
546            "app/a.rs",
547            Some("rust"),
548            "fn run(){}",
549            &control,
550        )?
551        .into_parts();
552        let graphs = [
553            SourceGraph::Staged {
554                graph: &manifest_graph,
555                semantics: Some(&manifest_facts),
556            },
557            SourceGraph::Staged {
558                graph: &graph,
559                semantics: Some(&facts),
560            },
561        ];
562        let references = graphs.iter().collect::<Vec<_>>();
563        let mut sources = SourceBindingCache::new(temp.path(), &[], None);
564        let before = super::super::super::MAX_PUBLICATION_STAGING_BYTES - 1;
565        sources.retained_bytes = before;
566        require(
567            PackageIndex::from_sources(project, &references, &mut sources, &control).is_err(),
568            "root cache crossed its owner budget",
569        )?;
570        require(
571            sources.retained_bytes == before && sources.cargo_cohorts.is_empty(),
572            "refusal retained partial cohort state",
573        )?;
574        sources.retained_bytes = 0;
575        let package_envelope =
576            PackageIndex::from_graphs(std::slice::from_ref(&manifest_graph))?.retained_bytes;
577        sources.set_retained_limit(
578            package_envelope
579                .saturating_add(STAGED_GRAPH_ROW_BYTES)
580                .saturating_add("Cargo.toml".len() as u64)
581                .saturating_add(1),
582        )?;
583        require(
584            PackageIndex::from_sources(project, &references, &mut sources, &control).is_err(),
585            "mid-construction resource limit was ignored",
586        )?;
587        require(
588            sources.retained_bytes == 0 && sources.cargo_cohorts.is_empty(),
589            "failed package construction leaked a partial reservation",
590        )?;
591        sources.set_retained_limit(super::super::super::MAX_PUBLICATION_STAGING_BYTES)?;
592        let cancellation = IndexCancellation::new();
593        cancellation.cancel();
594        let cancelled = IndexWorkControl::new(cancellation, None);
595        sources.retained_bytes = 0;
596        require(
597            PackageIndex::from_sources(project, &references, &mut sources, &cancelled).is_err(),
598            "cancelled cohort construction succeeded",
599        )?;
600        require(
601            sources.retained_bytes == 0 && sources.cargo_cohorts.is_empty(),
602            "cancellation retained partial cohort state",
603        )?;
604        Ok(())
605    }
606}