1use 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#[derive(Clone, Copy)]
17enum CohortKind {
18 Automatic,
20 Explicit,
22}
23
24pub(super) struct CargoRootCohort {
26 pub(super) directory: Arc<str>,
28 pub(super) key: CanonicalResolutionKey,
30}
31
32#[derive(Default)]
34pub(super) struct CargoRootCohortIndex {
35 explicit: BTreeMap<String, Arc<CargoRootCohort>>,
37 automatic: BTreeMap<String, Arc<CargoRootCohort>>,
39}
40
41impl CargoRootCohortIndex {
42 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 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
61pub(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
83fn parent(path: &str) -> &str {
85 path.rsplit_once('/').map_or("", |(directory, _)| directory)
86}
87
88impl SourceBindingCache<'_> {
89 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 fn cohort(
99 &mut self,
100 project: ProjectInstanceId,
101 package: &PackageOwner,
102 directory: &str,
103 kind: CohortKind,
104 ) -> Result<Option<Arc<CargoRootCohort>>, CliError> {
105 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 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 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 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 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 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 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}