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, 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        generator_version: Option<&str>,
138    ) -> Result<Self> {
139        if channel.is_some_and(str::is_empty) {
140            return Err(Error::Validation("channel must not be empty".to_owned()));
141        }
142        let repository = VersionRepository::discover(root)?;
143        let declared = aggregate_bumps(intents.iter().map(|intent| &intent.release_units));
144        let mut current_versions = BTreeMap::new();
145        for id in config.release_units.keys() {
146            let (_, primary) = config.primary_tag(id)?;
147            let current = match excluded_target {
148                Some(target) => repository.current_version_before(id, &primary.template, target)?,
149                None => repository.current_version(id, &primary.template)?,
150            };
151            current_versions.insert(id.clone(), current);
152        }
153        let resolved = resolve_versions(config, &declared, &current_versions)?;
154        let changed: BTreeSet<_> = resolved
155            .iter()
156            .filter(|(_, versions)| versions.bump != Bump::None)
157            .map(|(id, _)| id.clone())
158            .collect();
159        let mut versions = BTreeMap::new();
160        for id in &changed {
161            let (_, primary) = config.primary_tag(id)?;
162            let current = resolved[id].current.clone();
163            let base = resolved[id].next.clone();
164            let release = match channel {
165                Some(channel) => channel_version(
166                    &repository,
167                    id,
168                    &primary.template,
169                    &base,
170                    channel,
171                    excluded_target,
172                )?,
173                None => base,
174            };
175            versions.insert(id.clone(), (current, release));
176        }
177
178        let mut release_units = Vec::with_capacity(changed.len());
179        for id in &changed {
180            let (current, release) = &versions[id];
181            let intent_ids = intents
182                .iter()
183                .filter(|intent| intent.release_units.contains_key(id))
184                .map(|intent| intent.id.clone())
185                .collect::<Vec<_>>();
186            let effective = resolved
187                .iter()
188                .map(|(id, versions)| (id.clone(), versions.bump))
189                .collect();
190            let entries = note_entries(id, config, intents, &effective, &versions);
191            let release_notes = render_changelog_section(release, &entries);
192            release_units.push(PlanReleaseUnit {
193                id: id.clone(),
194                old_version: current.to_string(),
195                new_version: release.to_string(),
196                bump: resolved[id].bump,
197                contributing_intent_ids: intent_ids,
198                tag_ids: config.release_units[id]
199                    .tags
200                    .keys()
201                    .map(|tag_id| Config::release_unit_tag_id(id, tag_id))
202                    .collect(),
203                release_notes,
204            });
205        }
206
207        let channel = channel.map(str::to_owned);
208        let mut tags = Vec::new();
209        for release_unit in &release_units {
210            let config_release_unit = &config.release_units[&release_unit.id];
211            let version = Version::parse(&release_unit.new_version)?;
212            for (tag_id, tag) in &config_release_unit.tags {
213                tags.push(PlanTag {
214                    id: Config::release_unit_tag_id(&release_unit.id, tag_id),
215                    name: render_tag(&tag.template, &release_unit.id, &version),
216                    version: version.to_string(),
217                    release_unit: Some(release_unit.id.clone()),
218                    role: Some(tag.role),
219                    require_phase: tag.require_phase,
220                    tag_after: tag.tag_after.clone(),
221                });
222            }
223        }
224        let highest_bump = release_units
225            .iter()
226            .map(|release_unit| release_unit.bump)
227            .max()
228            .unwrap_or(Bump::None);
229        if highest_bump != Bump::None {
230            for (tag_id, tag) in &config.workspace_tags {
231                let current = match excluded_target {
232                    Some(target) => {
233                        repository.current_version_before(tag_id, &tag.template, target)?
234                    }
235                    None => repository.current_version(tag_id, &tag.template)?,
236                };
237                let base = bump_version_with_mapping(
238                    &current,
239                    highest_bump,
240                    config.settings.pre_1_0_bump_mapping,
241                );
242                let version = match channel.as_deref() {
243                    Some(channel) => channel_version(
244                        &repository,
245                        tag_id,
246                        &tag.template,
247                        &base,
248                        channel,
249                        excluded_target,
250                    )?,
251                    None => base,
252                };
253                tags.push(PlanTag {
254                    id: Config::workspace_tag_id(tag_id),
255                    name: render_tag(&tag.template, tag_id, &version),
256                    version: version.to_string(),
257                    release_unit: None,
258                    role: None,
259                    require_phase: tag.require_phase,
260                    tag_after: tag.tag_after.clone(),
261                });
262            }
263        }
264        tags.sort_by(|left, right| left.id.cmp(&right.id));
265        let tag_order = tag_order(&tags)?;
266        let generator = Generator {
267            tool: "intentional".to_owned(),
268            version: generator_version.unwrap_or(crate::VERSION).to_owned(),
269        };
270        let payload = PlanPayload {
271            contract: &config.contract,
272            generator: &generator,
273            channel: &channel,
274            release_units: &release_units,
275            tags: &tags,
276            tag_order: &tag_order,
277        };
278        let payload_json = canonical_json(&payload)?;
279        let digest = sha256_digest(payload_json.as_bytes());
280        Ok(Self {
281            digest,
282            contract: config.contract.clone(),
283            generator,
284            channel,
285            release_units,
286            tags,
287            tag_order,
288        })
289    }
290
291    /// Serialize this plan as compact canonical JSON with sorted object keys.
292    pub fn to_canonical_json(&self) -> Result<String> {
293        canonical_json(self)
294    }
295
296    /// Verify that the embedded digest seals the complete plan payload.
297    pub fn verify_digest(&self) -> Result<()> {
298        let payload = PlanPayload {
299            contract: &self.contract,
300            generator: &self.generator,
301            channel: &self.channel,
302            release_units: &self.release_units,
303            tags: &self.tags,
304            tag_order: &self.tag_order,
305        };
306        let actual = sha256_digest(canonical_json(&payload)?.as_bytes());
307        if actual != self.digest {
308            return Err(Error::Validation(format!(
309                "release plan digest mismatch: expected {}, computed {actual}",
310                self.digest
311            )));
312        }
313        Ok(())
314    }
315}
316
317/// Render the SHA-256 digest of `bytes` as `sha256:` followed by lowercase hex.
318pub fn sha256_digest(bytes: &[u8]) -> String {
319    format!("sha256:{}", lower_hex(&Sha256::digest(bytes)))
320}
321
322pub(crate) fn lower_hex(bytes: &[u8]) -> String {
323    use std::fmt::Write;
324    bytes.iter().fold(
325        String::with_capacity(bytes.len() * 2),
326        |mut output, byte| {
327            let _ = write!(output, "{byte:02x}");
328            output
329        },
330    )
331}
332
333/// Serialize a value as compact JSON with recursively sorted object keys.
334pub fn canonical_json(value: &impl Serialize) -> Result<String> {
335    let value = serde_json::to_value(value)
336        .map_err(|error| Error::Validation(format!("JSON serialization failed: {error}")))?;
337    let mut output = String::new();
338    write_canonical(&value, &mut output)?;
339    Ok(output)
340}
341
342fn write_canonical(value: &Value, output: &mut String) -> Result<()> {
343    match value {
344        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
345            output.push_str(&serde_json::to_string(value).map_err(|error| {
346                Error::Validation(format!("JSON serialization failed: {error}"))
347            })?);
348        }
349        Value::Array(values) => {
350            output.push('[');
351            for (index, value) in values.iter().enumerate() {
352                if index > 0 {
353                    output.push(',');
354                }
355                write_canonical(value, output)?;
356            }
357            output.push(']');
358        }
359        Value::Object(values) => {
360            output.push('{');
361            let mut keys = values.keys().collect::<Vec<_>>();
362            keys.sort();
363            for (index, key) in keys.into_iter().enumerate() {
364                if index > 0 {
365                    output.push(',');
366                }
367                output.push_str(&serde_json::to_string(key).map_err(|error| {
368                    Error::Validation(format!("JSON key serialization failed: {error}"))
369                })?);
370                output.push(':');
371                write_canonical(&values[key], output)?;
372            }
373            output.push('}');
374        }
375    }
376    Ok(())
377}
378
379fn channel_version(
380    repository: &VersionRepository,
381    release_unit_id: &str,
382    template: &str,
383    base: &Version,
384    channel: &str,
385    excluded_target: Option<gix::ObjectId>,
386) -> Result<Version> {
387    let versions = match excluded_target {
388        Some(target) => repository.all_versions_before(release_unit_id, template, target)?,
389        None => repository.all_versions(release_unit_id, template)?,
390    };
391    let max_iteration = versions
392        .into_iter()
393        .filter(|version| {
394            version.major == base.major
395                && version.minor == base.minor
396                && version.patch == base.patch
397        })
398        .filter_map(|version| parse_channel_iteration(&version, channel))
399        .max()
400        .unwrap_or(0);
401    let mut version = base.clone();
402    version.pre = Prerelease::new(&format!("{channel}.{}", max_iteration + 1))?;
403    Ok(version)
404}
405
406fn parse_channel_iteration(version: &Version, channel: &str) -> Option<u64> {
407    let (name, iteration) = version.pre.as_str().split_once('.')?;
408    (name == channel).then(|| iteration.parse().ok()).flatten()
409}
410
411fn render_tag(template: &str, id: &str, version: &Version) -> String {
412    template
413        .replace("{id}", id)
414        .replace("{version}", &version.to_string())
415}
416
417fn note_entries(
418    release_unit_id: &str,
419    config: &Config,
420    intents: &[Intent],
421    effective: &BTreeMap<String, Bump>,
422    versions: &BTreeMap<String, (Version, Version)>,
423) -> Vec<ChangelogEntry> {
424    let mut entries = intents
425        .iter()
426        .filter_map(|intent| {
427            intent
428                .release_units
429                .get(release_unit_id)
430                .map(|bump| ChangelogEntry {
431                    bump: *bump,
432                    text: intent.message.clone(),
433                })
434        })
435        .collect::<Vec<_>>();
436    for dependency in &config.release_units[release_unit_id].depends_on {
437        if effective[dependency] == Bump::None
438            || !versions.contains_key(dependency)
439            || !shares_ecosystem(config, release_unit_id, dependency)
440        {
441            continue;
442        }
443        entries.push(ChangelogEntry {
444            bump: config.settings.internal_dependency_bump,
445            text: format!(
446                "Update internal dependency `{dependency}` to `{}`.",
447                versions[dependency].1
448            ),
449        });
450    }
451    entries
452}
453
454fn shares_ecosystem(config: &Config, left: &str, right: &str) -> bool {
455    config.release_units[left]
456        .projections
457        .iter()
458        .any(|left_projection| {
459            left_projection.adapter.ecosystem().is_some()
460                && config.release_units[right]
461                    .projections
462                    .iter()
463                    .any(|right_projection| {
464                        left_projection.adapter.ecosystem() == right_projection.adapter.ecosystem()
465                    })
466        })
467}
468
469/// Render one deterministic CommonMark changelog section.
470pub fn render_changelog_section(version: &Version, entries: &[ChangelogEntry]) -> String {
471    let mut output = format!("## {version}\n");
472    for (bump, heading) in [
473        (Bump::Major, "Breaking"),
474        (Bump::Minor, "Features"),
475        (Bump::Patch, "Fixes"),
476    ] {
477        let matching = entries
478            .iter()
479            .filter(|entry| entry.bump == bump)
480            .collect::<Vec<_>>();
481        if matching.is_empty() {
482            continue;
483        }
484        output.push_str(&format!("\n### {heading}\n\n"));
485        for entry in matching {
486            let mut lines = entry.text.trim().lines();
487            if let Some(first) = lines.next() {
488                output.push_str("- ");
489                output.push_str(first);
490                output.push('\n');
491            }
492            for line in lines {
493                output.push_str("  ");
494                output.push_str(line);
495                output.push('\n');
496            }
497        }
498    }
499    output
500}
501
502fn tag_order(tags: &[PlanTag]) -> Result<Vec<String>> {
503    let by_id = tags
504        .iter()
505        .map(|tag| (tag.id.as_str(), tag))
506        .collect::<BTreeMap<_, _>>();
507    let mut visited = BTreeSet::new();
508    let mut order = Vec::new();
509    fn visit(
510        id: &str,
511        by_id: &BTreeMap<&str, &PlanTag>,
512        visited: &mut BTreeSet<String>,
513        order: &mut Vec<String>,
514    ) -> Result<()> {
515        if !visited.insert(id.to_owned()) {
516            return Ok(());
517        }
518        for prerequisite in &by_id[id].tag_after {
519            if !by_id.contains_key(prerequisite.as_str()) {
520                return Err(Error::Validation(format!(
521                    "release tag {id} requires {prerequisite}, which is not part of this release"
522                )));
523            }
524            visit(prerequisite, by_id, visited, order)?;
525        }
526        order.push(id.to_owned());
527        Ok(())
528    }
529    for id in by_id.keys() {
530        visit(id, &by_id, &mut visited, &mut order)?;
531    }
532    Ok(order)
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn canonical_json_sorts_keys_and_is_compact() {
541        let first = BTreeMap::from([("z", 1), ("a", 2)]);
542        let second = BTreeMap::from([("a", 2), ("z", 1)]);
543        let first = canonical_json(&first).expect("canonical JSON");
544        let second = canonical_json(&second).expect("canonical JSON");
545        assert_eq!(first, r#"{"a":2,"z":1}"#);
546        assert_eq!(first, second);
547    }
548
549    #[test]
550    fn sha256_digest_renders_prefixed_lowercase_hex() {
551        assert_eq!(
552            sha256_digest(b"abc"),
553            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
554        );
555    }
556
557    #[test]
558    fn digest_is_stable_for_same_payload() {
559        let release_units = vec![PlanReleaseUnit {
560            id: "sample".to_owned(),
561            old_version: "1.0.0".to_owned(),
562            new_version: "1.1.0".to_owned(),
563            bump: Bump::Minor,
564            contributing_intent_ids: vec!["clear-river-1234".to_owned()],
565            tag_ids: vec!["release-unit/sample/primary".to_owned()],
566            release_notes: "## 1.1.0\n".to_owned(),
567        }];
568        let generator = Generator {
569            tool: "intentional".to_owned(),
570            version: "1.0.0".to_owned(),
571        };
572        let tags = Vec::new();
573        let order = Vec::new();
574        let payload = PlanPayload {
575            contract: "contract-1",
576            generator: &generator,
577            channel: &None,
578            release_units: &release_units,
579            tags: &tags,
580            tag_order: &order,
581        };
582        let json = canonical_json(&payload).expect("canonical payload");
583        let first = sha256_digest(json.as_bytes());
584        let second = sha256_digest(json.as_bytes());
585        assert_eq!(first, second);
586        assert_eq!(first.len(), "sha256:".len() + 64);
587    }
588
589    #[test]
590    fn changelog_groups_breaking_features_and_fixes() {
591        let entries = vec![
592            ChangelogEntry {
593                bump: Bump::Patch,
594                text: "Correct a defect.".to_owned(),
595            },
596            ChangelogEntry {
597                bump: Bump::Major,
598                text: "Change a contract.".to_owned(),
599            },
600            ChangelogEntry {
601                bump: Bump::Minor,
602                text: "Add a capability.".to_owned(),
603            },
604        ];
605        let rendered = render_changelog_section(&Version::new(2, 0, 0), &entries);
606        assert_eq!(
607            rendered,
608            "## 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"
609        );
610    }
611}