Skip to main content

intentional_core/
apply.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Tree-only release materialization.
7
8use crate::adapters::{
9    CargoAdapter, FormatAdapter, GoAdapter, JsonFormat, MsbuildAdapter, NpmAdapter, PubAdapter,
10    PythonAdapter, TomlFormat, YamlFormat,
11};
12use crate::config::{Config, Projection, ReleaseUnitConfig};
13use crate::error::{Error, Result};
14use crate::intent::Intent;
15use crate::model::{Adapter, ProjectionMode};
16use crate::ownership::check_physical_ownership;
17use crate::plan::{PlanReleaseUnit, ReleasePlan};
18use crate::projection_file::normalized;
19use crate::workspace_files::{contained, write_contained, EditTree, SharedVersions};
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22
23/// One planned file write.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FileWrite {
26    /// Workspace-relative target.
27    pub path: PathBuf,
28    /// Complete new contents.
29    pub contents: String,
30}
31
32/// Complete tree mutation produced by `apply`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApplyResult {
35    /// Changed or created files in path order.
36    pub writes: Vec<FileWrite>,
37    /// Consumed intent files in path order.
38    pub deletes: Vec<PathBuf>,
39    /// Prominent operational notices.
40    pub notices: Vec<String>,
41    /// Canonical plan materialized by these changes.
42    pub plan: ReleasePlan,
43}
44
45impl ApplyResult {
46    /// Plan all release tree mutations.
47    pub fn build(root: &Path, channel: Option<&str>) -> Result<Self> {
48        let config = Config::load(root)?;
49        check_physical_ownership(root, &config)?;
50        let intents = Intent::load_all(root, &config)?;
51        let plan = ReleasePlan::from_inputs(root, &config, &intents, channel)?;
52        let mut tree = EditTree::new(root);
53        let by_id: BTreeMap<_, _> = plan
54            .release_units
55            .iter()
56            .map(|release_unit| (release_unit.id.as_str(), release_unit))
57            .collect();
58        let mut workspace_versions = SharedVersions::default();
59        let mut notices = Vec::new();
60
61        for release_unit in &plan.release_units {
62            let config_release_unit = &config.release_units[&release_unit.id];
63            for projection in &config_release_unit.projections {
64                let go_major = projection.adapter == Adapter::Go
65                    && release_unit.bump == crate::model::Bump::Major;
66                if projection.mode != ProjectionMode::Committed && !go_major {
67                    continue;
68                }
69                edit_projection_version(
70                    &mut tree,
71                    root,
72                    config_release_unit,
73                    projection,
74                    &release_unit.new_version,
75                    &mut workspace_versions,
76                )?;
77                if go_major {
78                    notices.push(format!(
79                        "rewrite Go module major path {}",
80                        projection
81                            .file
82                            .workspace_path(&config_release_unit.path)
83                            .display()
84                    ));
85                }
86            }
87            let changelog = config_release_unit.path.join("CHANGELOG.md");
88            let current = tree.read_optional(&changelog)?;
89            let next = update_changelog(
90                current.as_deref(),
91                &release_unit.release_notes,
92                &release_unit.new_version,
93                channel.is_none(),
94            );
95            tree.set(&changelog, next)?;
96        }
97
98        rewrite_internal_dependencies(&mut tree, root, &config, &by_id)?;
99
100        let deletes = if channel.is_none() {
101            intents
102                .iter()
103                .map(|intent| {
104                    intent
105                        .path
106                        .strip_prefix(root)
107                        .unwrap_or(&intent.path)
108                        .to_owned()
109                })
110                .collect()
111        } else {
112            Vec::new()
113        };
114        Ok(Self {
115            writes: tree.finish(),
116            deletes,
117            notices,
118            plan,
119        })
120    }
121
122    /// Human-readable operations printed identically for dry and real runs.
123    pub fn operations(&self) -> Vec<String> {
124        self.notices
125            .iter()
126            .cloned()
127            .chain(
128                self.writes
129                    .iter()
130                    .map(|write| format!("write {}", write.path.display())),
131            )
132            .chain(
133                self.deletes
134                    .iter()
135                    .map(|path| format!("delete {}", path.display())),
136            )
137            .collect()
138    }
139
140    /// Materialize these changes unless `dry_run` is enabled.
141    pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
142        if dry_run {
143            return Ok(());
144        }
145        write_contained(root, &self.writes)?;
146        for relative in &self.deletes {
147            let path = root.join(relative);
148            std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
149        }
150        Ok(())
151    }
152}
153
154fn edit_projection_version(
155    tree: &mut EditTree<'_>,
156    root: &Path,
157    release_unit: &ReleaseUnitConfig,
158    projection: &Projection,
159    version: &str,
160    workspace_versions: &mut SharedVersions,
161) -> Result<()> {
162    let relative = projection.file.workspace_path(&release_unit.path);
163    let text = tree.read(&relative)?;
164    let edited = match projection.adapter {
165        Adapter::Npm => NpmAdapter.edit_version(&text, version)?,
166        Adapter::Cargo => match CargoAdapter.version(&text)? {
167            Some(_) => CargoAdapter.edit_version(&text, version)?,
168            None => {
169                let workspace_manifest = workspace_manifest(root, &relative)?;
170                if let Some(existing) =
171                    workspace_versions.claim(root, &workspace_manifest, version)?
172                {
173                    return Err(Error::Validation(format!(
174                        "Cargo workspace members require conflicting versions {existing} and {version}"
175                    )));
176                }
177                let workspace_text = tree.read(&workspace_manifest)?;
178                let workspace_edited =
179                    CargoAdapter.edit_workspace_version(&workspace_text, version)?;
180                tree.set(&workspace_manifest, workspace_edited)?;
181                text
182            }
183        },
184        Adapter::Json => JsonFormat.edit_text(
185            &text,
186            projection.pointer.as_deref().expect("validated pointer"),
187            version,
188        )?,
189        Adapter::Toml => TomlFormat.edit_text(
190            &text,
191            projection.pointer.as_deref().expect("validated pointer"),
192            version,
193        )?,
194        Adapter::Yaml => YamlFormat.edit_text(
195            &text,
196            projection.pointer.as_deref().expect("validated pointer"),
197            version,
198        )?,
199        Adapter::Pub => PubAdapter.edit_version(&text, version)?,
200        Adapter::Python => PythonAdapter.edit_version(&text, version)?,
201        Adapter::Msbuild => MsbuildAdapter.edit_version(&text, version)?,
202        Adapter::Go => GoAdapter.edit_major_module_path(&text, version)?,
203    };
204    tree.set(&relative, edited)?;
205    Ok(())
206}
207
208fn rewrite_internal_dependencies(
209    tree: &mut EditTree<'_>,
210    root: &Path,
211    config: &Config,
212    released: &BTreeMap<&str, &PlanReleaseUnit>,
213) -> Result<()> {
214    for (dependent_id, dependent) in &config.release_units {
215        if !released.contains_key(dependent_id.as_str()) {
216            continue;
217        }
218        for dependency_id in &dependent.depends_on {
219            let Some(dependency_release) = released.get(dependency_id.as_str()) else {
220                continue;
221            };
222            let dependency = &config.release_units[dependency_id];
223            for adapter in [
224                Adapter::Npm,
225                Adapter::Cargo,
226                Adapter::Pub,
227                Adapter::Python,
228                Adapter::Msbuild,
229                Adapter::Go,
230            ] {
231                let Some(source_projection) = dependency
232                    .projections
233                    .iter()
234                    .find(|projection| projection.adapter == adapter)
235                else {
236                    continue;
237                };
238                let Some(target_projection) = dependent.projections.iter().find(|projection| {
239                    projection.adapter == adapter && projection.mode == ProjectionMode::Committed
240                }) else {
241                    continue;
242                };
243                let source_path = source_projection.file.workspace_path(&dependency.path);
244                let source_text = tree.read(&source_path)?;
245                let dependency_name = match adapter {
246                    Adapter::Npm => NpmAdapter.name(&source_text)?,
247                    Adapter::Cargo => CargoAdapter.name(&source_text)?,
248                    Adapter::Pub => PubAdapter.name(&source_text)?,
249                    Adapter::Python => PythonAdapter.name(&source_text)?,
250                    Adapter::Msbuild => MsbuildAdapter
251                        .name(&source_text)
252                        .unwrap_or_else(|_| dependency_id.clone()),
253                    Adapter::Go => GoAdapter.name(&source_text)?,
254                    _ => unreachable!(),
255                };
256                let target_path = target_projection.file.workspace_path(&dependent.path);
257                let target_text = tree.read(&target_path)?;
258                let edited = match adapter {
259                    Adapter::Npm => NpmAdapter.edit_dependency(
260                        &target_text,
261                        &dependency_name,
262                        &dependency_release.new_version,
263                    )?,
264                    Adapter::Cargo => {
265                        if CargoAdapter.dependency_is_inherited(&target_text, &dependency_name)? {
266                            let workspace = workspace_manifest(root, &target_path)?;
267                            let workspace_text = tree.read(&workspace)?;
268                            let workspace_edited = CargoAdapter.edit_dependency(
269                                &workspace_text,
270                                &dependency_name,
271                                &dependency_release.new_version,
272                            )?;
273                            tree.set(&workspace, workspace_edited)?;
274                            target_text
275                        } else {
276                            CargoAdapter.edit_dependency(
277                                &target_text,
278                                &dependency_name,
279                                &dependency_release.new_version,
280                            )?
281                        }
282                    }
283                    Adapter::Pub => PubAdapter.edit_dependency(
284                        &target_text,
285                        &dependency_name,
286                        &dependency_release.new_version,
287                    )?,
288                    Adapter::Python => PythonAdapter.edit_dependency(
289                        &target_text,
290                        &dependency_name,
291                        &dependency_release.new_version,
292                    )?,
293                    Adapter::Msbuild => MsbuildAdapter.edit_dependency(
294                        &target_text,
295                        &dependency_name,
296                        &dependency_release.new_version,
297                    )?,
298                    Adapter::Go => GoAdapter.edit_dependency(
299                        &target_text,
300                        &dependency_name,
301                        &dependency_release.new_version,
302                    )?,
303                    _ => unreachable!(),
304                };
305                tree.set(&target_path, edited)?;
306            }
307        }
308    }
309    Ok(())
310}
311
312/// Workspace-relative path of the Cargo workspace manifest for `member`.
313///
314/// Each candidate manifest, from the member's directory up to the workspace
315/// root, is read only after it passes the containment check.
316pub(crate) fn workspace_manifest(root: &Path, member: &Path) -> Result<PathBuf> {
317    let member = normalized(member);
318    let mut directory = member.parent();
319    while let Some(candidate) = directory {
320        let relative = candidate.join("Cargo.toml");
321        let manifest = contained(root, &relative)?;
322        if manifest.exists() {
323            let text =
324                std::fs::read_to_string(&manifest).map_err(|error| Error::io(&manifest, error))?;
325            if text
326                .parse::<toml_edit::DocumentMut>()
327                .ok()
328                .and_then(|document| document.get("workspace").cloned())
329                .is_some()
330            {
331                return Ok(normalized(&relative));
332            }
333        }
334        directory = candidate.parent();
335    }
336    Err(Error::Validation(format!(
337        "could not find Cargo workspace manifest for {}",
338        member.display()
339    )))
340}
341
342fn update_changelog(
343    existing: Option<&str>,
344    section: &str,
345    version: &str,
346    final_release: bool,
347) -> String {
348    let existing = existing.unwrap_or("# Changelog\n");
349    let mut boundaries = existing
350        .match_indices("\n## ")
351        .map(|(index, _)| index + 1)
352        .collect::<Vec<_>>();
353    if existing.starts_with("## ") {
354        boundaries.insert(0, 0);
355    }
356    let preamble_end = boundaries.first().copied().unwrap_or(existing.len());
357    let mut kept = Vec::new();
358    for (index, start) in boundaries.iter().enumerate() {
359        let end = boundaries.get(index + 1).copied().unwrap_or(existing.len());
360        let candidate = &existing[*start..end];
361        let heading = candidate.lines().next().unwrap_or_default();
362        let exact = heading == format!("## {version}");
363        let prerelease = final_release && heading.starts_with(&format!("## {version}-"));
364        if !exact && !prerelease {
365            kept.push(candidate.trim());
366        }
367    }
368    let mut output = String::new();
369    output.push_str(existing[..preamble_end].trim_end());
370    output.push_str("\n\n");
371    output.push_str(section.trim());
372    output.push('\n');
373    for candidate in kept {
374        output.push('\n');
375        output.push_str(candidate);
376        output.push('\n');
377    }
378    output
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn final_changelog_replaces_channel_sections() {
387        let existing = "# Changelog\n\n## 1.2.0-beta.2\n\nOld beta.\n\n## 1.1.0\n\nPrior.\n";
388        let output = update_changelog(
389            Some(existing),
390            "## 1.2.0\n\n### Features\n\n- Final.",
391            "1.2.0",
392            true,
393        );
394        assert!(output.contains("## 1.2.0\n"));
395        assert!(!output.contains("beta.2"));
396        assert!(output.contains("## 1.1.0"));
397    }
398
399    #[test]
400    fn new_changelog_has_lintable_spacing() {
401        let output = update_changelog(
402            None,
403            "## 1.0.1\n\n### Fixes\n\n- Correct a defect.",
404            "1.0.1",
405            true,
406        );
407        assert_eq!(
408            output,
409            "# Changelog\n\n## 1.0.1\n\n### Fixes\n\n- Correct a defect.\n"
410        );
411    }
412}