ephemeris_release/adapters/cargo/workspace/
edit.rs1use std::collections::btree_map::Entry;
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::{Path, PathBuf};
6
7use ephemeris_documents::Siblings;
8use toml_edit::{DocumentMut, Item};
9
10use super::{
11 DependencySlot, DependencyUse, VersionSource, Workspace, WorkspaceError, parse_manifest,
12};
13use crate::adapters::cargo::version_groups::distinct;
14use crate::version::Version;
15
16struct Documents<'a> {
18 snapshots: &'a BTreeMap<PathBuf, String>,
19 parsed: BTreeMap<PathBuf, DocumentMut>,
20}
21
22impl Workspace {
23 pub(crate) fn render_updates(
26 &self,
27 proposals: &BTreeMap<String, Version>,
28 ) -> Result<BTreeMap<PathBuf, String>, WorkspaceError> {
29 let mut documents = Documents {
30 snapshots: &self.snapshots,
31 parsed: BTreeMap::new(),
32 };
33 self.update_group_versions(&mut documents, proposals)?;
34 self.update_requirements(&mut documents, proposals)?;
35 Ok(documents
36 .parsed
37 .into_iter()
38 .map(|(path, document)| (path, document.to_string()))
39 .collect())
40 }
41
42 fn update_group_versions(
43 &self,
44 documents: &mut Documents<'_>,
45 proposals: &BTreeMap<String, Version>,
46 ) -> Result<(), WorkspaceError> {
47 for (source, members) in self.version_groups.iter() {
48 let Some(target) = group_target(source, members, proposals)? else {
49 continue;
50 };
51 let (manifest, table_path) = match source {
52 VersionSource::Member(path) => (path, vec!["package".to_owned()]),
53 VersionSource::Workspace(path) => {
54 (path, vec!["workspace".to_owned(), "package".to_owned()])
55 }
56 };
57 set_version(documents.table(manifest, &table_path)?, target.to_string());
58 }
59 Ok(())
60 }
61
62 fn update_requirements(
63 &self,
64 documents: &mut Documents<'_>,
65 proposals: &BTreeMap<String, Version>,
66 ) -> Result<(), WorkspaceError> {
67 for update in self.requirement_updates(proposals)? {
68 let stale = || WorkspaceError::Stale {
69 path: update.slot.manifest.clone(),
70 };
71 let item = documents
72 .table(&update.slot.manifest, &update.slot.table_path)?
73 .as_table_like_mut()
74 .and_then(|table| table.get_mut(&update.slot.alias))
75 .ok_or_else(stale)?;
76 set_version(item, update.replacement);
77 }
78 Ok(())
79 }
80
81 pub(crate) fn check_consumers(
84 &self,
85 proposals: &BTreeMap<String, Version>,
86 ) -> Result<(), WorkspaceError> {
87 for update in self.requirement_updates(proposals)? {
88 let missing: Vec<_> = update
89 .consumers
90 .into_iter()
91 .filter(|consumer| !proposals.contains_key(consumer))
92 .collect();
93 if !missing.is_empty() {
94 return Err(WorkspaceError::UnselectedDependencyConsumers {
95 dependency: update.dependency,
96 missing,
97 });
98 }
99 }
100 Ok(())
101 }
102
103 fn requirement_updates(
105 &self,
106 proposals: &BTreeMap<String, Version>,
107 ) -> Result<Vec<RequirementUpdate<'_>>, WorkspaceError> {
108 let mut slots = BTreeMap::<&DependencySlot, Vec<&DependencyUse>>::new();
109 for dependency in self
110 .packages
111 .iter()
112 .flat_map(|package| &package.dependencies)
113 .filter(|dependency| proposals.contains_key(&dependency.dependency))
114 {
115 slots.entry(&dependency.slot).or_default().push(dependency);
116 }
117 let mut updates = Vec::new();
118 for (slot, uses) in slots {
119 let Some(dependency) = uses.first() else {
120 continue;
121 };
122 let Some(target) = proposals.get(&dependency.dependency) else {
123 continue;
124 };
125 let Some(replacement) = requirement_update(dependency, target)? else {
126 continue;
127 };
128 updates.push(RequirementUpdate {
129 slot,
130 dependency: dependency.dependency.clone(),
131 consumers: uses.iter().map(|use_| use_.consumer.clone()).collect(),
132 replacement,
133 });
134 }
135 Ok(updates)
136 }
137}
138
139struct RequirementUpdate<'a> {
141 slot: &'a DependencySlot,
142 dependency: String,
143 consumers: BTreeSet<String>,
144 replacement: String,
145}
146
147impl Documents<'_> {
148 fn table(&mut self, manifest: &Path, path: &[String]) -> Result<&mut Item, WorkspaceError> {
150 let stale = || WorkspaceError::Stale {
151 path: manifest.to_path_buf(),
152 };
153 let document = match self.parsed.entry(manifest.to_path_buf()) {
154 Entry::Occupied(entry) => entry.into_mut(),
155 Entry::Vacant(entry) => {
156 let original = self.snapshots.get(manifest).ok_or_else(stale)?;
157 entry.insert(parse_manifest(manifest, original)?)
158 }
159 };
160 let mut item = document.as_item_mut();
161 for key in path {
162 item = item
163 .as_table_like_mut()
164 .and_then(|table| table.get_mut(key))
165 .ok_or_else(stale)?;
166 }
167 Ok(item)
168 }
169}
170
171fn set_version(container: &mut Item, version: String) {
173 let existing = container
174 .as_table_like_mut()
175 .and_then(|table| table.get_mut("version"))
176 .and_then(Item::as_value_mut);
177 match existing {
178 Some(value) => {
179 let decor = value.decor().clone();
180 *value = toml_edit::Value::from(version);
181 *value.decor_mut() = decor;
182 }
183 None => Siblings::of(container).append("version", toml_edit::value(version)),
184 }
185}
186
187fn group_target<'a>(
190 source: &VersionSource,
191 members: &[String],
192 proposals: &'a BTreeMap<String, Version>,
193) -> Result<Option<&'a Version>, WorkspaceError> {
194 let targets = distinct(members.iter().filter_map(|name| proposals.get(name)));
195 match targets.as_slice() {
196 [] => Ok(None),
197 [target] if members.iter().all(|name| proposals.contains_key(name)) => Ok(Some(*target)),
198 _ => Err(WorkspaceError::IncompleteVersionGroup {
199 version_source: source.clone(),
200 missing: members
201 .iter()
202 .filter(|name| !proposals.contains_key(*name))
203 .cloned()
204 .collect(),
205 }),
206 }
207}
208
209fn requirement_update(
212 dependency: &DependencyUse,
213 target: &Version,
214) -> Result<Option<String>, WorkspaceError> {
215 let Some(requirement) = &dependency.requirement else {
216 return Ok(Some(target.to_string()));
217 };
218 let satisfied =
219 target
220 .satisfies(requirement)
221 .map_err(|source| WorkspaceError::InvalidRequirement {
222 consumer: dependency.consumer.clone(),
223 dependency: dependency.dependency.clone(),
224 requirement: requirement.clone(),
225 source,
226 })?;
227 if satisfied {
228 return Ok(None);
229 }
230 replace_simple_requirement(requirement, target)
231 .map(Some)
232 .ok_or_else(|| WorkspaceError::UnsupportedDependencyRequirement {
233 consumer: dependency.consumer.clone(),
234 dependency: dependency.dependency.clone(),
235 requirement: requirement.clone(),
236 version: target.to_string(),
237 })
238}
239
240fn replace_simple_requirement(requirement: &str, target: &Version) -> Option<String> {
241 let (operator, version) = match requirement.split_at_checked(1) {
242 Some((operator @ ("^" | "~" | "="), version)) => (operator, version),
243 Some(_) => ("", requirement),
244 None => return None,
245 };
246 if version.is_empty()
247 || !version
248 .bytes()
249 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'))
250 || !version.as_bytes().first().is_some_and(u8::is_ascii_digit)
251 {
252 return None;
253 }
254 Some(format!("{operator}{target}"))
255}