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