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, 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#[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 pub contributing_intent_ids: Vec<String>,
35 pub tag_ids: Vec<String>,
37 pub release_notes: String,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
43pub struct Generator {
44 pub tool: String,
46 pub version: String,
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
52pub struct PlanTag {
53 pub id: String,
55 pub name: String,
57 pub version: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub release_unit: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub role: Option<TagRole>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub require_phase: Option<TagPhase>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub tag_after: Vec<String>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
75pub struct ReleasePlan {
76 pub digest: String,
78 pub contract: String,
80 pub generator: Generator,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub channel: Option<String>,
85 pub release_units: Vec<PlanReleaseUnit>,
87 pub tags: Vec<PlanTag>,
89 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#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ChangelogEntry {
107 pub bump: Bump,
109 pub text: String,
111}
112
113impl ReleasePlan {
114 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 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, ¤t_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 ¤t,
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 pub fn to_canonical_json(&self) -> Result<String> {
293 canonical_json(self)
294 }
295
296 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
317pub 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
333pub 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
469pub 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}