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)
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, ¤t_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 ¤t,
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 pub fn to_canonical_json(&self) -> Result<String> {
292 canonical_json(self)
293 }
294
295 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
319pub 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
455pub 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}