Skip to main content

callisto_graph/
lib.rs

1//! Dependency graph, cascade, aggregation, and config resolution for callisto.
2
3#![allow(clippy::result_large_err)]
4
5use std::cell::{OnceCell, RefCell};
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use callisto_manifests::Manifest;
11use callisto_model::{CommandRunner, Ecosystem, PackageId, Version};
12use callisto_vcs::GitAccess;
13
14pub mod aggregate;
15pub mod apply;
16pub mod cascade;
17pub mod changed;
18pub mod commands;
19pub mod config;
20pub mod crosscheck;
21pub mod error;
22pub mod groups;
23pub mod identity;
24pub mod infer;
25pub mod locate;
26mod manifest_cache;
27pub(crate) mod matrix;
28pub mod napi;
29pub mod plan;
30pub mod resolver;
31pub mod tags;
32pub mod toposort;
33pub mod walk;
34
35pub use aggregate::{aggregate, load_changesets, Aggregation, LoadedChangeset, NamedBy};
36pub use apply::{apply_version_plan, ApplyOptions, ApplyOutcome};
37pub use cascade::{
38    cascade_action, coverage, rewrite_spec, run_cascade, CascadeDecision, CascadeInput, CascadeOutcome, DepWriteTarget,
39    RewriteKey, RewriteOutcome, SpecRewrite,
40};
41pub use config::{load as load_config, GroupDef, GroupTable, ResolvedConfig};
42pub use error::{ConfigError, GraphError};
43pub use groups::{fixed_group_target, pre_mutation_checks, GroupCheckOutcome};
44pub use identity::{IdentityIndex, IdentityResolver};
45pub use infer::{InferenceOutcome, InferenceWindowSpec, NoInference, SeverityInference};
46pub use locate::{find_workspace_root, IgnoreWalkLocator, LocateError, ProjectLocator};
47pub use napi::{napi_drift, role_to_triple, triple_to_role, NapiTargetsIndex};
48pub use plan::{PlannedBump, VersionPlan, VersionWriteTarget};
49pub use resolver::{DependencyResolver, ManifestWalkResolver};
50pub use tags::{last_tag_for, TagIndex};
51pub use toposort::toposort_impl;
52
53pub struct Workspace<'a, R: CommandRunner, D: DependencyResolver = ManifestWalkResolver> {
54    pub root: PathBuf,
55    pub config: ResolvedConfig,
56    pub graph: D,
57    /// Deferred [`TagIndex`]: built at most once, the first time
58    /// [`Workspace::tags`] is called, not eagerly by [`Workspace::load`].
59    ///
60    /// `TagIndex::build` fetches the repo's full tag list -- native gix, or
61    /// (unavailable on `wasm32`) a shelled `git tag --list` Extism
62    /// round-trip. Several command paths never consult tags at all (`add`'s
63    /// non-interactive path only needs [`Workspace::root`]; `init` only
64    /// needs package names/root), so building unconditionally in
65    /// `Workspace::load` charged every caller for work only some need. All
66    /// of `TagIndex::build`'s inputs are already `Workspace` fields, so a
67    /// `OnceCell` needs no extra state -- go through [`Workspace::tags`],
68    /// not this field directly.
69    pub tags: OnceCell<TagIndex>,
70    /// Deferred [`GitAccess`]: built at most once, the first time
71    /// [`Workspace::git_access`] is called, mirroring `tags` above.
72    /// `GitAccess::discover` never fails (native gix, falling back to a
73    /// `CommandRunner` shell round-trip when unavailable), so simpler
74    /// than `tags` -- no `Result` to thread through. Consolidates what
75    /// were multiple independent `GitAccess::discover` calls within one
76    /// command invocation (`plan_publish`'s head_sha resolution,
77    /// `TagIndex::build` via [`Workspace::tags`]) into one shared
78    /// discovery. `pub` so tests can hand-construct a `Workspace` with a
79    /// pre-seeded value, bypassing discovery.
80    pub git: OnceCell<GitAccess<'a>>,
81    pub runner: &'a R,
82    /// Path-keyed cache of manifest handles opened read-only during this
83    /// workspace's lifetime. Populated during graph discovery
84    /// (`ManifestWalkResolver::build`) and reused by read-only accessors
85    /// such as [`Workspace::base_versions`] so a given manifest is opened
86    /// (read + parsed) at most once per command run. Never consulted by the
87    /// manifest-open-for-write path in `apply.rs`, which always needs a
88    /// fresh, exclusively-owned `&mut` handle.
89    pub manifest_cache: RefCell<BTreeMap<PathBuf, Arc<dyn Manifest>>>,
90    pub identity: IdentityIndex,
91}
92
93impl<'a, R: CommandRunner> Workspace<'a, R, ManifestWalkResolver> {
94    pub fn load<L: ProjectLocator>(root: PathBuf, locator: &L, runner: &'a R) -> Result<Self, GraphError> {
95        let mut config = config::load(&root)?;
96        let manifest_cache: RefCell<BTreeMap<PathBuf, Arc<dyn Manifest>>> = RefCell::new(BTreeMap::new());
97        let graph = ManifestWalkResolver::build(&root, locator, runner, &config, &manifest_cache)?;
98
99        config.groups = GroupTable::resolve(&config.raw_groups, graph.identity())?;
100
101        {
102            let mut by_name: BTreeMap<String, Vec<(PackageId, BTreeSet<Ecosystem>)>> = BTreeMap::new();
103            let mut ecosystems_by_id: BTreeMap<(String, PackageId), BTreeSet<Ecosystem>> = BTreeMap::new();
104            for ((eco, name), id) in &graph.identity().prefixed {
105                ecosystems_by_id
106                    .entry((name.clone(), id.clone()))
107                    .or_default()
108                    .insert(*eco);
109            }
110            for ((name, id), ecos) in ecosystems_by_id {
111                by_name.entry(name).or_default().push((id, ecos));
112            }
113            config.promoted_siblings = by_name.into_iter().filter(|(_, ids)| ids.len() >= 2).collect();
114        }
115
116        let identity = graph.identity().clone();
117
118        Ok(Workspace {
119            root,
120            config,
121            graph,
122            tags: OnceCell::new(),
123            git: OnceCell::new(),
124            runner,
125            manifest_cache,
126            identity,
127        })
128    }
129}
130
131impl<'a, R: CommandRunner, D: DependencyResolver> Workspace<'a, R, D> {
132    /// Returns the workspace's [`TagIndex`], building it on first access and
133    /// reusing the cached result afterwards. See the doc comment on the
134    /// `tags` field for why this is deferred rather than built eagerly by
135    /// [`Workspace::load`].
136    pub fn tags(&self) -> Result<&TagIndex, GraphError> {
137        if let Some(existing) = self.tags.get() {
138            return Ok(existing);
139        }
140        let built = TagIndex::build(self.git_access(), &self.graph, &self.config)?;
141        // `OnceCell::set` only fails if another write already raced it in;
142        // `Workspace` is only ever accessed through `&self` here (never
143        // shared across threads -- `R`/`D` carry no such bound), so the
144        // `get()` check above already ruled that out. Fall back to `get()`
145        // either way rather than trusting the `set` call's own return value,
146        // so this stays correct even if that assumption ever changes.
147        self.tags.set(built).ok();
148        Ok(self
149            .tags
150            .get()
151            .expect("tags was just set above, or already set by a prior call"))
152    }
153
154    /// Returns the workspace's shared [`GitAccess`], discovering it on
155    /// first access and reusing the cached result afterwards -- mirrors
156    /// [`Workspace::tags`]. Every command that needs git (tag resolution,
157    /// head SHA lookup, commit history walks, ...) should go through this
158    /// rather than calling `GitAccess::discover` itself, so a single
159    /// command invocation never pays for more than one discovery
160    /// (native gix repository-open, or a `CommandRunner` shell round-trip
161    /// when gix is unavailable) regardless of how many of those it needs.
162    pub fn git_access(&self) -> &GitAccess<'a> {
163        self.git.get_or_init(|| GitAccess::discover(&self.root, self.runner))
164    }
165
166    pub fn base_versions(&self) -> Result<BTreeMap<PackageId, Version>, GraphError> {
167        let cargo_workspace = if self.root.join("Cargo.toml").exists() {
168            if let Ok(resolver) = callisto_manifests::WorkspaceCargoResolver::load(&self.root.join("Cargo.toml")) {
169                resolver.inheritance().ok().map(std::sync::Arc::new)
170            } else {
171                None
172            }
173        } else {
174            None
175        };
176        let npm_workspace_kind = callisto_manifests::detect_npm_workspace_kind(&self.root).ok().flatten();
177        let ctx = callisto_manifests::OpenContext {
178            workspace_root: &self.root,
179            cargo_workspace,
180            npm_workspace_kind,
181        };
182
183        let mut versions = BTreeMap::new();
184        for pkg in self.graph.packages() {
185            let mut found_version = None;
186            for decl in &pkg.manifests {
187                if decl.role == callisto_model::ManifestRole::Canonical {
188                    let handle = manifest_cache::open_cached(&self.manifest_cache, decl, &ctx)?;
189                    let v = handle.current_version()?;
190                    found_version = Some(v);
191                    break;
192                }
193            }
194            if let Some(version) = found_version {
195                versions.insert(pkg.id.clone(), version);
196            } else {
197                return Err(GraphError::Manifest(callisto_model::ManifestError::MissingField {
198                    path: pkg.manifests.first().map(|m| m.path.clone()).unwrap_or_default(),
199                    field: "version",
200                }));
201            }
202        }
203        Ok(versions)
204    }
205
206    pub fn pre_json_key<'b>(&self, id: &'b PackageId) -> Result<&'b str, GraphError> {
207        Ok(id.name())
208    }
209
210    pub fn initial_versions(&self) -> Result<Vec<(String, Version)>, GraphError> {
211        let base = self.base_versions()?;
212        Ok(base.into_iter().map(|(id, v)| (id.name().to_string(), v)).collect())
213    }
214}