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