1use 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#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
24pub struct PlanReleaseUnit {
25 pub id: String,
27 pub old_version: String,
29 pub new_version: String,
31 pub bump: Bump,
33 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
35 pub graduates: bool,
36 pub contributing_intent_ids: Vec<String>,
38 pub tag_ids: Vec<String>,
40 pub release_notes: String,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
46pub struct Generator {
47 pub tool: String,
49 pub version: String,
51}
52
53#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
55pub struct PlanTag {
56 pub id: String,
58 pub name: String,
60 pub version: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub release_unit: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub role: Option<TagRole>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub require_phase: Option<TagPhase>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub tag_after: Vec<String>,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
78pub struct ReleasePlan {
79 pub digest: String,
81 pub contract: String,
83 pub generator: Generator,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub channel: Option<String>,
88 pub release_units: Vec<PlanReleaseUnit>,
90 pub tags: Vec<PlanTag>,
92 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#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ChangelogEntry {
110 pub bump: Bump,
112 pub text: String,
114}
115
116impl ReleasePlan {
117 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 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, ¤t_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 let base = bump_version(¤t, 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 pub fn to_canonical_json(&self) -> Result<String> {
296 canonical_json(self)
297 }
298
299 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
320pub 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
336pub 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
472pub 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}