Skip to main content

intentional_core/
plan.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Deterministic release planning, changelog rendering, and digest sealing.
7
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::intent::Intent;
11use crate::model::{Bump, TagPhase, TagRole};
12use crate::version::{
13    aggregate_bumps, bump_version_with_mapping, resolve_versions, VersionRepository,
14};
15use semver::{Prerelease, Version};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::Path;
21
22/// One release unit in a canonical release plan.
23#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
24pub struct PlanReleaseUnit {
25    /// Release-unit id.
26    pub id: String,
27    /// Latest final tag-derived version.
28    pub old_version: String,
29    /// Intent-derived release version, optionally with a channel suffix.
30    pub new_version: String,
31    /// Effective aggregate bump.
32    pub bump: Bump,
33    /// Directly contributing intent ids.
34    pub contributing_intent_ids: Vec<String>,
35    /// Canonical ids of release-unit tags to create.
36    pub tag_ids: Vec<String>,
37    /// Deterministically rendered changelog section.
38    pub release_notes: String,
39}
40
41/// Generator identity embedded inside the sealed payload.
42#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
43pub struct Generator {
44    /// Tool name.
45    pub tool: String,
46    /// Tool version.
47    pub version: String,
48}
49
50/// One annotated tag record in creation order.
51#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
52pub struct PlanTag {
53    /// Canonical configuration tag id.
54    pub id: String,
55    /// Rendered Git tag name.
56    pub name: String,
57    /// Version recorded by the tag.
58    pub version: String,
59    /// Release-unit id for release-unit tags.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub release_unit: Option<String>,
62    /// Release-unit tag role. Workspace tags have no release-unit role.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub role: Option<TagRole>,
65    /// Required executor declaration.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub require_phase: Option<TagPhase>,
68    /// Canonical prerequisite tag ids.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub tag_after: Vec<String>,
71}
72
73/// Canonical digest-bound release plan.
74#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
75pub struct ReleasePlan {
76    /// SHA-256 digest of the canonical plan payload excluding this seal.
77    pub digest: String,
78    /// Interpretation contract used to compute the plan.
79    pub contract: String,
80    /// Generator identity included in the digest.
81    pub generator: Generator,
82    /// Optional release channel.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub channel: Option<String>,
85    /// Changed release units ordered by id.
86    pub release_units: Vec<PlanReleaseUnit>,
87    /// All release-unit and workspace tags in canonical id order.
88    pub tags: Vec<PlanTag>,
89    /// Observable tag creation order derived only from `tag-after` edges.
90    pub tag_order: Vec<String>,
91}
92
93#[derive(Serialize)]
94struct PlanPayload<'a> {
95    contract: &'a str,
96    generator: &'a Generator,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    channel: &'a Option<String>,
99    release_units: &'a [PlanReleaseUnit],
100    tags: &'a [PlanTag],
101    tag_order: &'a [String],
102}
103
104/// One release note assigned to a semantic bump group.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ChangelogEntry {
107    /// Section group.
108    pub bump: Bump,
109    /// Markdown changelog prose.
110    pub text: String,
111}
112
113impl ReleasePlan {
114    /// Build a release plan from workspace state.
115    pub fn build(root: &Path, channel: Option<&str>) -> Result<Self> {
116        let config = Config::load(root)?;
117        let intents = Intent::load_all(root, &config)?;
118        Self::from_inputs(root, &config, &intents, channel)
119    }
120
121    /// Build a plan from already-loaded configuration and intents.
122    pub fn from_inputs(
123        root: &Path,
124        config: &Config,
125        intents: &[Intent],
126        channel: Option<&str>,
127    ) -> Result<Self> {
128        Self::from_inputs_before(root, config, intents, channel, None)
129    }
130
131    pub(crate) fn from_inputs_before(
132        root: &Path,
133        config: &Config,
134        intents: &[Intent],
135        channel: Option<&str>,
136        excluded_target: Option<gix::ObjectId>,
137    ) -> Result<Self> {
138        if channel.is_some_and(str::is_empty) {
139            return Err(Error::Validation("channel must not be empty".to_owned()));
140        }
141        let repository = VersionRepository::discover(root)?;
142        let declared = aggregate_bumps(intents.iter().map(|intent| &intent.release_units));
143        let mut current_versions = BTreeMap::new();
144        for id in config.release_units.keys() {
145            let (_, primary) = config.primary_tag(id)?;
146            let current = match excluded_target {
147                Some(target) => repository.current_version_before(id, &primary.template, target)?,
148                None => repository.current_version(id, &primary.template)?,
149            };
150            current_versions.insert(id.clone(), current);
151        }
152        let resolved = resolve_versions(config, &declared, &current_versions)?;
153        let changed: BTreeSet<_> = resolved
154            .iter()
155            .filter(|(_, versions)| versions.bump != Bump::None)
156            .map(|(id, _)| id.clone())
157            .collect();
158        let mut versions = BTreeMap::new();
159        for id in &changed {
160            let (_, primary) = config.primary_tag(id)?;
161            let current = resolved[id].current.clone();
162            let base = resolved[id].next.clone();
163            let release = match channel {
164                Some(channel) => channel_version(
165                    &repository,
166                    id,
167                    &primary.template,
168                    &base,
169                    channel,
170                    excluded_target,
171                )?,
172                None => base,
173            };
174            versions.insert(id.clone(), (current, release));
175        }
176
177        let mut release_units = Vec::with_capacity(changed.len());
178        for id in &changed {
179            let (current, release) = &versions[id];
180            let intent_ids = intents
181                .iter()
182                .filter(|intent| intent.release_units.contains_key(id))
183                .map(|intent| intent.id.clone())
184                .collect::<Vec<_>>();
185            let effective = resolved
186                .iter()
187                .map(|(id, versions)| (id.clone(), versions.bump))
188                .collect();
189            let entries = note_entries(id, config, intents, &effective, &versions);
190            let release_notes = render_changelog_section(release, &entries);
191            release_units.push(PlanReleaseUnit {
192                id: id.clone(),
193                old_version: current.to_string(),
194                new_version: release.to_string(),
195                bump: resolved[id].bump,
196                contributing_intent_ids: intent_ids,
197                tag_ids: config.release_units[id]
198                    .tags
199                    .keys()
200                    .map(|tag_id| Config::release_unit_tag_id(id, tag_id))
201                    .collect(),
202                release_notes,
203            });
204        }
205
206        let channel = channel.map(str::to_owned);
207        let mut tags = Vec::new();
208        for release_unit in &release_units {
209            let config_release_unit = &config.release_units[&release_unit.id];
210            let version = Version::parse(&release_unit.new_version)?;
211            for (tag_id, tag) in &config_release_unit.tags {
212                tags.push(PlanTag {
213                    id: Config::release_unit_tag_id(&release_unit.id, tag_id),
214                    name: render_tag(&tag.template, &release_unit.id, &version),
215                    version: version.to_string(),
216                    release_unit: Some(release_unit.id.clone()),
217                    role: Some(tag.role),
218                    require_phase: tag.require_phase,
219                    tag_after: tag.tag_after.clone(),
220                });
221            }
222        }
223        let highest_bump = release_units
224            .iter()
225            .map(|release_unit| release_unit.bump)
226            .max()
227            .unwrap_or(Bump::None);
228        if highest_bump != Bump::None {
229            for (tag_id, tag) in &config.workspace_tags {
230                let current = match excluded_target {
231                    Some(target) => {
232                        repository.current_version_before(tag_id, &tag.template, target)?
233                    }
234                    None => repository.current_version(tag_id, &tag.template)?,
235                };
236                let base = bump_version_with_mapping(
237                    &current,
238                    highest_bump,
239                    config.settings.pre_1_0_bump_mapping,
240                );
241                let version = match channel.as_deref() {
242                    Some(channel) => channel_version(
243                        &repository,
244                        tag_id,
245                        &tag.template,
246                        &base,
247                        channel,
248                        excluded_target,
249                    )?,
250                    None => base,
251                };
252                tags.push(PlanTag {
253                    id: Config::workspace_tag_id(tag_id),
254                    name: render_tag(&tag.template, tag_id, &version),
255                    version: version.to_string(),
256                    release_unit: None,
257                    role: None,
258                    require_phase: tag.require_phase,
259                    tag_after: tag.tag_after.clone(),
260                });
261            }
262        }
263        tags.sort_by(|left, right| left.id.cmp(&right.id));
264        let tag_order = tag_order(&tags)?;
265        let generator = Generator {
266            tool: "intentional".to_owned(),
267            version: crate::VERSION.to_owned(),
268        };
269        let payload = PlanPayload {
270            contract: &config.contract,
271            generator: &generator,
272            channel: &channel,
273            release_units: &release_units,
274            tags: &tags,
275            tag_order: &tag_order,
276        };
277        let payload_json = canonical_json(&payload)?;
278        let digest = format!("sha256:{:x}", Sha256::digest(payload_json.as_bytes()));
279        Ok(Self {
280            digest,
281            contract: config.contract.clone(),
282            generator,
283            channel,
284            release_units,
285            tags,
286            tag_order,
287        })
288    }
289
290    /// Serialize this plan as compact canonical JSON with sorted object keys.
291    pub fn to_canonical_json(&self) -> Result<String> {
292        canonical_json(self)
293    }
294
295    /// Verify that the embedded digest seals the complete plan payload.
296    pub fn verify_digest(&self) -> Result<()> {
297        let payload = PlanPayload {
298            contract: &self.contract,
299            generator: &self.generator,
300            channel: &self.channel,
301            release_units: &self.release_units,
302            tags: &self.tags,
303            tag_order: &self.tag_order,
304        };
305        let actual = format!(
306            "sha256:{:x}",
307            Sha256::digest(canonical_json(&payload)?.as_bytes())
308        );
309        if actual != self.digest {
310            return Err(Error::Validation(format!(
311                "release plan digest mismatch: expected {}, computed {actual}",
312                self.digest
313            )));
314        }
315        Ok(())
316    }
317}
318
319/// Serialize a value as compact JSON with recursively sorted object keys.
320pub fn canonical_json(value: &impl Serialize) -> Result<String> {
321    let value = serde_json::to_value(value)
322        .map_err(|error| Error::Validation(format!("JSON serialization failed: {error}")))?;
323    let mut output = String::new();
324    write_canonical(&value, &mut output)?;
325    Ok(output)
326}
327
328fn write_canonical(value: &Value, output: &mut String) -> Result<()> {
329    match value {
330        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
331            output.push_str(&serde_json::to_string(value).map_err(|error| {
332                Error::Validation(format!("JSON serialization failed: {error}"))
333            })?);
334        }
335        Value::Array(values) => {
336            output.push('[');
337            for (index, value) in values.iter().enumerate() {
338                if index > 0 {
339                    output.push(',');
340                }
341                write_canonical(value, output)?;
342            }
343            output.push(']');
344        }
345        Value::Object(values) => {
346            output.push('{');
347            let mut keys = values.keys().collect::<Vec<_>>();
348            keys.sort();
349            for (index, key) in keys.into_iter().enumerate() {
350                if index > 0 {
351                    output.push(',');
352                }
353                output.push_str(&serde_json::to_string(key).map_err(|error| {
354                    Error::Validation(format!("JSON key serialization failed: {error}"))
355                })?);
356                output.push(':');
357                write_canonical(&values[key], output)?;
358            }
359            output.push('}');
360        }
361    }
362    Ok(())
363}
364
365fn channel_version(
366    repository: &VersionRepository,
367    release_unit_id: &str,
368    template: &str,
369    base: &Version,
370    channel: &str,
371    excluded_target: Option<gix::ObjectId>,
372) -> Result<Version> {
373    let versions = match excluded_target {
374        Some(target) => repository.all_versions_before(release_unit_id, template, target)?,
375        None => repository.all_versions(release_unit_id, template)?,
376    };
377    let max_iteration = versions
378        .into_iter()
379        .filter(|version| {
380            version.major == base.major
381                && version.minor == base.minor
382                && version.patch == base.patch
383        })
384        .filter_map(|version| parse_channel_iteration(&version, channel))
385        .max()
386        .unwrap_or(0);
387    let mut version = base.clone();
388    version.pre = Prerelease::new(&format!("{channel}.{}", max_iteration + 1))?;
389    Ok(version)
390}
391
392fn parse_channel_iteration(version: &Version, channel: &str) -> Option<u64> {
393    let (name, iteration) = version.pre.as_str().split_once('.')?;
394    (name == channel).then(|| iteration.parse().ok()).flatten()
395}
396
397fn render_tag(template: &str, id: &str, version: &Version) -> String {
398    template
399        .replace("{id}", id)
400        .replace("{version}", &version.to_string())
401}
402
403fn note_entries(
404    release_unit_id: &str,
405    config: &Config,
406    intents: &[Intent],
407    effective: &BTreeMap<String, Bump>,
408    versions: &BTreeMap<String, (Version, Version)>,
409) -> Vec<ChangelogEntry> {
410    let mut entries = intents
411        .iter()
412        .filter_map(|intent| {
413            intent
414                .release_units
415                .get(release_unit_id)
416                .map(|bump| ChangelogEntry {
417                    bump: *bump,
418                    text: intent.message.clone(),
419                })
420        })
421        .collect::<Vec<_>>();
422    for dependency in &config.release_units[release_unit_id].depends_on {
423        if effective[dependency] == Bump::None
424            || !versions.contains_key(dependency)
425            || !shares_ecosystem(config, release_unit_id, dependency)
426        {
427            continue;
428        }
429        entries.push(ChangelogEntry {
430            bump: config.settings.internal_dependency_bump,
431            text: format!(
432                "Update internal dependency `{dependency}` to `{}`.",
433                versions[dependency].1
434            ),
435        });
436    }
437    entries
438}
439
440fn shares_ecosystem(config: &Config, left: &str, right: &str) -> bool {
441    config.release_units[left]
442        .projections
443        .iter()
444        .any(|left_projection| {
445            left_projection.adapter.ecosystem().is_some()
446                && config.release_units[right]
447                    .projections
448                    .iter()
449                    .any(|right_projection| {
450                        left_projection.adapter.ecosystem() == right_projection.adapter.ecosystem()
451                    })
452        })
453}
454
455/// Render one deterministic CommonMark changelog section.
456pub fn render_changelog_section(version: &Version, entries: &[ChangelogEntry]) -> String {
457    let mut output = format!("## {version}\n");
458    for (bump, heading) in [
459        (Bump::Major, "Breaking"),
460        (Bump::Minor, "Features"),
461        (Bump::Patch, "Fixes"),
462    ] {
463        let matching = entries
464            .iter()
465            .filter(|entry| entry.bump == bump)
466            .collect::<Vec<_>>();
467        if matching.is_empty() {
468            continue;
469        }
470        output.push_str(&format!("\n### {heading}\n\n"));
471        for entry in matching {
472            let mut lines = entry.text.trim().lines();
473            if let Some(first) = lines.next() {
474                output.push_str("- ");
475                output.push_str(first);
476                output.push('\n');
477            }
478            for line in lines {
479                output.push_str("  ");
480                output.push_str(line);
481                output.push('\n');
482            }
483        }
484    }
485    output
486}
487
488fn tag_order(tags: &[PlanTag]) -> Result<Vec<String>> {
489    let by_id = tags
490        .iter()
491        .map(|tag| (tag.id.as_str(), tag))
492        .collect::<BTreeMap<_, _>>();
493    let mut visited = BTreeSet::new();
494    let mut order = Vec::new();
495    fn visit(
496        id: &str,
497        by_id: &BTreeMap<&str, &PlanTag>,
498        visited: &mut BTreeSet<String>,
499        order: &mut Vec<String>,
500    ) -> Result<()> {
501        if !visited.insert(id.to_owned()) {
502            return Ok(());
503        }
504        for prerequisite in &by_id[id].tag_after {
505            if !by_id.contains_key(prerequisite.as_str()) {
506                return Err(Error::Validation(format!(
507                    "release tag {id} requires {prerequisite}, which is not part of this release"
508                )));
509            }
510            visit(prerequisite, by_id, visited, order)?;
511        }
512        order.push(id.to_owned());
513        Ok(())
514    }
515    for id in by_id.keys() {
516        visit(id, &by_id, &mut visited, &mut order)?;
517    }
518    Ok(order)
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn canonical_json_sorts_keys_and_is_compact() {
527        let first = BTreeMap::from([("z", 1), ("a", 2)]);
528        let second = BTreeMap::from([("a", 2), ("z", 1)]);
529        let first = canonical_json(&first).expect("canonical JSON");
530        let second = canonical_json(&second).expect("canonical JSON");
531        assert_eq!(first, r#"{"a":2,"z":1}"#);
532        assert_eq!(first, second);
533    }
534
535    #[test]
536    fn digest_is_stable_for_same_payload() {
537        let release_units = vec![PlanReleaseUnit {
538            id: "sample".to_owned(),
539            old_version: "1.0.0".to_owned(),
540            new_version: "1.1.0".to_owned(),
541            bump: Bump::Minor,
542            contributing_intent_ids: vec!["clear-river-1234".to_owned()],
543            tag_ids: vec!["release-unit/sample/primary".to_owned()],
544            release_notes: "## 1.1.0\n".to_owned(),
545        }];
546        let generator = Generator {
547            tool: "intentional".to_owned(),
548            version: "1.0.0".to_owned(),
549        };
550        let tags = Vec::new();
551        let order = Vec::new();
552        let payload = PlanPayload {
553            contract: "contract-1",
554            generator: &generator,
555            channel: &None,
556            release_units: &release_units,
557            tags: &tags,
558            tag_order: &order,
559        };
560        let json = canonical_json(&payload).expect("canonical payload");
561        let first = format!("sha256:{:x}", Sha256::digest(json.as_bytes()));
562        let second = format!("sha256:{:x}", Sha256::digest(json.as_bytes()));
563        assert_eq!(first, second);
564        assert_eq!(first.len(), "sha256:".len() + 64);
565    }
566
567    #[test]
568    fn changelog_groups_breaking_features_and_fixes() {
569        let entries = vec![
570            ChangelogEntry {
571                bump: Bump::Patch,
572                text: "Correct a defect.".to_owned(),
573            },
574            ChangelogEntry {
575                bump: Bump::Major,
576                text: "Change a contract.".to_owned(),
577            },
578            ChangelogEntry {
579                bump: Bump::Minor,
580                text: "Add a capability.".to_owned(),
581            },
582        ];
583        let rendered = render_changelog_section(&Version::new(2, 0, 0), &entries);
584        assert_eq!(
585            rendered,
586            "## 2.0.0\n\n### Breaking\n\n- Change a contract.\n\n### Features\n\n- Add a capability.\n\n### Fixes\n\n- Correct a defect.\n"
587        );
588    }
589}