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