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::{Component, 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        let relative = &normalized(relative);
170        if let Some(text) = self.current.get(relative) {
171            return Ok(text.clone());
172        }
173        let path = self.root.join(relative);
174        let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
175        self.original
176            .entry(relative.to_owned())
177            .or_insert_with(|| Some(text.clone()));
178        self.current.insert(relative.to_owned(), text.clone());
179        Ok(text)
180    }
181
182    fn read_optional(&mut self, relative: &Path) -> Result<Option<String>> {
183        let relative = &normalized(relative);
184        if let Some(text) = self.current.get(relative) {
185            return Ok(Some(text.clone()));
186        }
187        let path = self.root.join(relative);
188        match std::fs::read_to_string(&path) {
189            Ok(text) => {
190                self.original
191                    .entry(relative.to_owned())
192                    .or_insert_with(|| Some(text.clone()));
193                self.current.insert(relative.to_owned(), text.clone());
194                Ok(Some(text))
195            }
196            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
197                self.original.entry(relative.to_owned()).or_insert(None);
198                Ok(None)
199            }
200            Err(error) => Err(Error::io(path, error)),
201        }
202    }
203
204    fn set(&mut self, relative: PathBuf, contents: String) {
205        let relative = normalized(&relative);
206        self.original.entry(relative.clone()).or_insert(None);
207        self.current.insert(relative, contents);
208    }
209
210    fn finish(self) -> Vec<FileWrite> {
211        self.current
212            .into_iter()
213            .filter(|(path, contents)| {
214                self.original.get(path).and_then(Option::as_ref) != Some(contents)
215            })
216            .map(|(path, contents)| FileWrite { path, contents })
217            .collect()
218    }
219}
220
221/// One working-tree key per file, so `./docs/x` and `docs/x` share edits.
222fn normalized(relative: &Path) -> PathBuf {
223    relative
224        .components()
225        .filter(|component| !matches!(component, Component::CurDir))
226        .collect()
227}
228
229fn edit_projection_version(
230    tree: &mut WorkingTree<'_>,
231    root: &Path,
232    release_unit: &ReleaseUnitConfig,
233    projection: &Projection,
234    version: &str,
235    workspace_versions: &mut BTreeMap<PathBuf, String>,
236) -> Result<()> {
237    let relative = release_unit.path.join(&projection.file);
238    let text = tree.read(&relative)?;
239    let edited = match projection.adapter {
240        Adapter::Npm => NpmAdapter.edit_version(&text, version)?,
241        Adapter::Cargo => match CargoAdapter.version(&text)? {
242            Some(_) => CargoAdapter.edit_version(&text, version)?,
243            None => {
244                let workspace_manifest = workspace_manifest(root, &relative)?;
245                if let Some(existing) = workspace_versions.get(&workspace_manifest) {
246                    if existing != version {
247                        return Err(Error::Validation(format!(
248                            "Cargo workspace members require conflicting versions {existing} and {version}"
249                        )));
250                    }
251                }
252                workspace_versions.insert(workspace_manifest.clone(), version.to_owned());
253                let workspace_text = tree.read(&workspace_manifest)?;
254                let workspace_edited =
255                    CargoAdapter.edit_workspace_version(&workspace_text, version)?;
256                tree.set(workspace_manifest, workspace_edited);
257                text
258            }
259        },
260        Adapter::Json => JsonFormat.edit_text(
261            &text,
262            projection.pointer.as_deref().expect("validated pointer"),
263            version,
264        )?,
265        Adapter::Toml => TomlFormat.edit_text(
266            &text,
267            projection.pointer.as_deref().expect("validated pointer"),
268            version,
269        )?,
270        Adapter::Yaml => YamlFormat.edit_text(
271            &text,
272            projection.pointer.as_deref().expect("validated pointer"),
273            version,
274        )?,
275        Adapter::Pub => PubAdapter.edit_version(&text, version)?,
276        Adapter::Python => PythonAdapter.edit_version(&text, version)?,
277        Adapter::Msbuild => MsbuildAdapter.edit_version(&text, version)?,
278        Adapter::Go => GoAdapter.edit_major_module_path(&text, version)?,
279    };
280    tree.set(relative, edited);
281    Ok(())
282}
283
284fn rewrite_internal_dependencies(
285    tree: &mut WorkingTree<'_>,
286    root: &Path,
287    config: &Config,
288    released: &BTreeMap<&str, &PlanReleaseUnit>,
289) -> Result<()> {
290    for (dependent_id, dependent) in &config.release_units {
291        if !released.contains_key(dependent_id.as_str()) {
292            continue;
293        }
294        for dependency_id in &dependent.depends_on {
295            let Some(dependency_release) = released.get(dependency_id.as_str()) else {
296                continue;
297            };
298            let dependency = &config.release_units[dependency_id];
299            for adapter in [
300                Adapter::Npm,
301                Adapter::Cargo,
302                Adapter::Pub,
303                Adapter::Python,
304                Adapter::Msbuild,
305                Adapter::Go,
306            ] {
307                let Some(source_projection) = dependency
308                    .projections
309                    .iter()
310                    .find(|projection| projection.adapter == adapter)
311                else {
312                    continue;
313                };
314                let Some(target_projection) = dependent.projections.iter().find(|projection| {
315                    projection.adapter == adapter && projection.mode == ProjectionMode::Committed
316                }) else {
317                    continue;
318                };
319                let source_path = dependency.path.join(&source_projection.file);
320                let source_text = tree.read(&source_path)?;
321                let dependency_name = match adapter {
322                    Adapter::Npm => NpmAdapter.name(&source_text)?,
323                    Adapter::Cargo => CargoAdapter.name(&source_text)?,
324                    Adapter::Pub => PubAdapter.name(&source_text)?,
325                    Adapter::Python => PythonAdapter.name(&source_text)?,
326                    Adapter::Msbuild => MsbuildAdapter
327                        .name(&source_text)
328                        .unwrap_or_else(|_| dependency_id.clone()),
329                    Adapter::Go => GoAdapter.name(&source_text)?,
330                    _ => unreachable!(),
331                };
332                let target_path = dependent.path.join(&target_projection.file);
333                let target_text = tree.read(&target_path)?;
334                let edited = match adapter {
335                    Adapter::Npm => NpmAdapter.edit_dependency(
336                        &target_text,
337                        &dependency_name,
338                        &dependency_release.new_version,
339                    )?,
340                    Adapter::Cargo => {
341                        if CargoAdapter.dependency_is_inherited(&target_text, &dependency_name)? {
342                            let workspace = workspace_manifest(root, &target_path)?;
343                            let workspace_text = tree.read(&workspace)?;
344                            let workspace_edited = CargoAdapter.edit_dependency(
345                                &workspace_text,
346                                &dependency_name,
347                                &dependency_release.new_version,
348                            )?;
349                            tree.set(workspace, workspace_edited);
350                            target_text
351                        } else {
352                            CargoAdapter.edit_dependency(
353                                &target_text,
354                                &dependency_name,
355                                &dependency_release.new_version,
356                            )?
357                        }
358                    }
359                    Adapter::Pub => PubAdapter.edit_dependency(
360                        &target_text,
361                        &dependency_name,
362                        &dependency_release.new_version,
363                    )?,
364                    Adapter::Python => PythonAdapter.edit_dependency(
365                        &target_text,
366                        &dependency_name,
367                        &dependency_release.new_version,
368                    )?,
369                    Adapter::Msbuild => MsbuildAdapter.edit_dependency(
370                        &target_text,
371                        &dependency_name,
372                        &dependency_release.new_version,
373                    )?,
374                    Adapter::Go => GoAdapter.edit_dependency(
375                        &target_text,
376                        &dependency_name,
377                        &dependency_release.new_version,
378                    )?,
379                    _ => unreachable!(),
380                };
381                tree.set(target_path, edited);
382            }
383        }
384    }
385    Ok(())
386}
387
388pub(crate) fn workspace_manifest(root: &Path, member: &Path) -> Result<PathBuf> {
389    let absolute_member = root.join(member);
390    let mut directory = absolute_member.parent();
391    while let Some(candidate) = directory {
392        let manifest = candidate.join("Cargo.toml");
393        if manifest.exists() {
394            let text =
395                std::fs::read_to_string(&manifest).map_err(|error| Error::io(&manifest, error))?;
396            if text
397                .parse::<toml_edit::DocumentMut>()
398                .ok()
399                .and_then(|document| document.get("workspace").cloned())
400                .is_some()
401            {
402                return manifest
403                    .strip_prefix(root)
404                    .map(Path::to_owned)
405                    .map_err(|error| Error::Validation(error.to_string()));
406            }
407        }
408        if candidate == root {
409            break;
410        }
411        directory = candidate.parent();
412    }
413    Err(Error::Validation(format!(
414        "could not find Cargo workspace manifest for {}",
415        member.display()
416    )))
417}
418
419fn update_changelog(
420    existing: Option<&str>,
421    section: &str,
422    version: &str,
423    final_release: bool,
424) -> String {
425    let existing = existing.unwrap_or("# Changelog\n");
426    let mut boundaries = existing
427        .match_indices("\n## ")
428        .map(|(index, _)| index + 1)
429        .collect::<Vec<_>>();
430    if existing.starts_with("## ") {
431        boundaries.insert(0, 0);
432    }
433    let preamble_end = boundaries.first().copied().unwrap_or(existing.len());
434    let mut kept = Vec::new();
435    for (index, start) in boundaries.iter().enumerate() {
436        let end = boundaries.get(index + 1).copied().unwrap_or(existing.len());
437        let candidate = &existing[*start..end];
438        let heading = candidate.lines().next().unwrap_or_default();
439        let exact = heading == format!("## {version}");
440        let prerelease = final_release && heading.starts_with(&format!("## {version}-"));
441        if !exact && !prerelease {
442            kept.push(candidate.trim());
443        }
444    }
445    let mut output = String::new();
446    output.push_str(existing[..preamble_end].trim_end());
447    output.push_str("\n\n");
448    output.push_str(section.trim());
449    output.push('\n');
450    for candidate in kept {
451        output.push('\n');
452        output.push_str(candidate);
453        output.push('\n');
454    }
455    output
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn final_changelog_replaces_channel_sections() {
464        let existing = "# Changelog\n\n## 1.2.0-beta.2\n\nOld beta.\n\n## 1.1.0\n\nPrior.\n";
465        let output = update_changelog(
466            Some(existing),
467            "## 1.2.0\n\n### Features\n\n- Final.",
468            "1.2.0",
469            true,
470        );
471        assert!(output.contains("## 1.2.0\n"));
472        assert!(!output.contains("beta.2"));
473        assert!(output.contains("## 1.1.0"));
474    }
475
476    #[test]
477    fn new_changelog_has_lintable_spacing() {
478        let output = update_changelog(
479            None,
480            "## 1.0.1\n\n### Fixes\n\n- Correct a defect.",
481            "1.0.1",
482            true,
483        );
484        assert_eq!(
485            output,
486            "# Changelog\n\n## 1.0.1\n\n### Fixes\n\n- Correct a defect.\n"
487        );
488    }
489
490    #[test]
491    fn working_tree_shares_edits_across_equivalent_relative_paths() {
492        let mut tree = WorkingTree::new(Path::new("/nonexistent-workspace"));
493        tree.set(PathBuf::from("./docs/docs.yml"), "a: 1\nb: 0\n".to_owned());
494        let shared = tree.read(Path::new("docs/docs.yml")).expect("shared edit");
495        tree.set(
496            PathBuf::from("docs/docs.yml"),
497            shared.replace("b: 0", "b: 1"),
498        );
499        assert_eq!(
500            tree.finish(),
501            [FileWrite {
502                path: PathBuf::from("docs/docs.yml"),
503                contents: "a: 1\nb: 1\n".to_owned(),
504            }]
505        );
506    }
507}