Skip to main content

ephemeris_release/adapters/cargo/workspace/
edit.rs

1//! Rendering proposed versions into the workspace manifests.
2
3use 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
16/// Manifests parsed from their snapshots on first edit.
17struct Documents<'a> {
18    snapshots: &'a BTreeMap<PathBuf, String>,
19    parsed: BTreeMap<PathBuf, DocumentMut>,
20}
21
22impl Workspace {
23    /// Manifest contents after setting every proposed version, keyed by the
24    /// manifests that change.
25    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    /// Reject a requirement change that reaches a consumer without a
82    /// proposal, as when several packages share one dependency entry.
83    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    /// The dependency entries whose requirement must change for `proposals`.
104    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
139/// A new requirement for one dependency entry.
140struct RequirementUpdate<'a> {
141    slot: &'a DependencySlot,
142    dependency: String,
143    consumers: BTreeSet<String>,
144    replacement: String,
145}
146
147impl Documents<'_> {
148    /// The table at `path` in `manifest`, parsing the manifest on first use.
149    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
171/// Set `version` in `container`, keeping the formatting of an existing value.
172fn 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
187/// The one version a fully proposed group moves to, or `None` when no member
188/// is proposed.
189fn 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
209/// The requirement `dependency` needs to accept `target`, or `None` when the
210/// current one already does.
211fn 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}