1use std::path::Path;
14
15use serde::Serialize;
16
17use crate::config::{Config, PackageConfig, PublishConfig};
18use crate::error::ReleaseError;
19use crate::git::GitRepository;
20use crate::publishers::{PublishCtx, PublishState, publisher_for};
21use crate::release::{ReleasePlan, VcsProvider, partition_paths};
22use crate::workspaces::{discover_cargo_members, discover_npm_members, discover_uv_members};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ResourceKind {
29 Tag,
30 FloatingTag,
31 Release,
32 Asset,
33 VersionFile,
34 Publish,
35}
36
37impl ResourceKind {
38 fn label(self) -> &'static str {
39 match self {
40 Self::Tag => "tag",
41 Self::FloatingTag => "floating-tag",
42 Self::Release => "release",
43 Self::Asset => "asset",
44 Self::VersionFile => "version-file",
45 Self::Publish => "publish",
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize)]
52#[serde(tag = "state", rename_all = "snake_case")]
53pub enum ResourceState {
54 Absent,
55 Present {
56 value: String,
57 },
58 PresentOpaque,
61 Unknown {
63 reason: String,
64 },
65}
66
67impl ResourceState {
68 pub fn value(&self) -> Option<&str> {
71 match self {
72 Self::Present { value } => Some(value),
73 _ => None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Action {
82 Create,
84 Update,
86 NoChange,
88 Uncertain,
91}
92
93impl Action {
94 fn symbol(self) -> &'static str {
95 match self {
96 Self::Create => "+",
97 Self::Update => "~",
98 Self::NoChange => "=",
99 Self::Uncertain => "?",
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize)]
106pub struct ResourceDiff {
107 pub kind: ResourceKind,
108 pub id: String,
110 pub current: ResourceState,
111 pub desired: ResourceState,
112 pub action: Action,
113}
114
115#[derive(Debug, Clone, Serialize)]
117pub struct ReleaseDiff {
118 pub tag_name: String,
119 pub current_version: Option<String>,
120 pub next_version: String,
121 pub resources: Vec<ResourceDiff>,
122}
123
124impl ReleaseDiff {
125 pub fn summary(&self) -> DiffSummary {
127 let mut s = DiffSummary::default();
128 for r in &self.resources {
129 match r.action {
130 Action::Create => s.create += 1,
131 Action::Update => s.update += 1,
132 Action::NoChange => s.no_change += 1,
133 Action::Uncertain => s.uncertain += 1,
134 }
135 }
136 s
137 }
138}
139
140#[derive(Debug, Default, Clone, Serialize)]
141pub struct DiffSummary {
142 pub create: usize,
143 pub update: usize,
144 pub no_change: usize,
145 pub uncertain: usize,
146}
147
148pub fn build_diff<G: GitRepository, V: VcsProvider + ?Sized>(
155 plan: &ReleasePlan,
156 git: &G,
157 vcs: &V,
158 config: &Config,
159 env: &[(&str, &str)],
160) -> Result<ReleaseDiff, ReleaseError> {
161 let mut resources: Vec<ResourceDiff> = Vec::new();
162
163 let tag_exists = git.tag_exists(&plan.tag_name)?;
165 resources.push(ResourceDiff {
166 kind: ResourceKind::Tag,
167 id: plan.tag_name.clone(),
168 current: if tag_exists {
169 ResourceState::Present {
170 value: plan.tag_name.clone(),
171 }
172 } else {
173 ResourceState::Absent
174 },
175 desired: ResourceState::Present {
176 value: plan.tag_name.clone(),
177 },
178 action: if tag_exists {
179 Action::NoChange
180 } else {
181 Action::Create
182 },
183 });
184
185 if let Some(floating) = &plan.floating_tag_name {
187 let floating_exists = git.tag_exists(floating)?;
188 resources.push(ResourceDiff {
189 kind: ResourceKind::FloatingTag,
190 id: floating.clone(),
191 current: if floating_exists {
194 ResourceState::PresentOpaque
195 } else {
196 ResourceState::Absent
197 },
198 desired: ResourceState::Present {
199 value: format!("{floating} → {}", plan.tag_name),
200 },
201 action: Action::Update,
202 });
203 }
204
205 let mut seen_files: std::collections::HashSet<String> = std::collections::HashSet::new();
207 for pkg_plan in &plan.packages {
208 for file in &pkg_plan.version_files {
209 if !seen_files.insert(file.clone()) {
210 continue;
211 }
212 let current = read_current_version(Path::new(file));
213 let desired_value = plan.next_version.to_string();
214 let action = match ¤t {
215 ResourceState::Present { value } if value == &desired_value => Action::NoChange,
216 ResourceState::Present { .. } => Action::Update,
217 ResourceState::Absent => Action::Create,
218 ResourceState::PresentOpaque | ResourceState::Unknown { .. } => Action::Uncertain,
219 };
220 resources.push(ResourceDiff {
221 kind: ResourceKind::VersionFile,
222 id: file.clone(),
223 current,
224 desired: ResourceState::Present {
225 value: desired_value,
226 },
227 action,
228 });
229 }
230 }
231
232 let release_exists = vcs.release_exists(&plan.tag_name)?;
236 resources.push(ResourceDiff {
237 kind: ResourceKind::Release,
238 id: plan.tag_name.clone(),
239 current: if release_exists {
240 ResourceState::PresentOpaque
241 } else {
242 ResourceState::Absent
243 },
244 desired: ResourceState::Present {
245 value: format!("release {}", plan.tag_name),
246 },
247 action: if release_exists {
248 Action::Update
249 } else {
250 Action::Create
251 },
252 });
253
254 let declared_artifacts = config.all_artifacts();
256 if !declared_artifacts.is_empty() {
257 let existing_assets: std::collections::HashSet<String> = if release_exists {
258 vcs.list_assets(&plan.tag_name)?.into_iter().collect()
259 } else {
260 std::collections::HashSet::new()
261 };
262 let (on_disk, missing_on_disk) = partition_paths(&declared_artifacts);
263
264 for path in &missing_on_disk {
266 resources.push(ResourceDiff {
267 kind: ResourceKind::Asset,
268 id: path.clone(),
269 current: ResourceState::Absent,
270 desired: ResourceState::Unknown {
271 reason: "declared artifact not present on disk (build pending?)".into(),
272 },
273 action: Action::Uncertain,
274 });
275 }
276
277 {
279 for path in &on_disk {
280 let basename = Path::new(path)
281 .file_name()
282 .and_then(|n| n.to_str())
283 .unwrap_or(path.as_str())
284 .to_string();
285 let already = existing_assets.contains(&basename);
286 resources.push(ResourceDiff {
287 kind: ResourceKind::Asset,
288 id: format!("{}/{basename}", plan.tag_name),
289 current: if already {
290 ResourceState::Present {
291 value: basename.clone(),
292 }
293 } else {
294 ResourceState::Absent
295 },
296 desired: ResourceState::Present { value: basename },
297 action: if already {
298 Action::NoChange
299 } else {
300 Action::Create
301 },
302 });
303 }
304 }
305 }
306
307 for pkg in &config.packages {
310 let Some(cfg) = pkg.publish.as_ref() else {
311 continue;
312 };
313 let rows = publish_diff_rows(
314 pkg,
315 cfg,
316 &plan.next_version.to_string(),
317 &plan.tag_name,
318 env,
319 )?;
320 resources.extend(rows);
321 }
322
323 Ok(ReleaseDiff {
324 tag_name: plan.tag_name.clone(),
325 current_version: plan.current_version.as_ref().map(|v| v.to_string()),
326 next_version: plan.next_version.to_string(),
327 resources,
328 })
329}
330
331fn publish_diff_rows(
334 pkg: &PackageConfig,
335 cfg: &PublishConfig,
336 version: &str,
337 tag: &str,
338 env: &[(&str, &str)],
339) -> Result<Vec<ResourceDiff>, ReleaseError> {
340 let targets: Vec<PublishTarget> = match cfg {
341 PublishConfig::Cargo { workspace, .. } => {
342 if *workspace {
343 discover_cargo_members(Path::new(&pkg.path))
344 .iter()
345 .filter_map(|m| read_cargo_name(m).map(|n| PublishTarget::Cargo { name: n }))
346 .collect()
347 } else {
348 read_cargo_name(&Path::new(&pkg.path).join("Cargo.toml"))
349 .map(|n| vec![PublishTarget::Cargo { name: n }])
350 .unwrap_or_default()
351 }
352 }
353 PublishConfig::Npm { workspace, .. } => {
354 if *workspace {
355 discover_npm_members(Path::new(&pkg.path))
356 .iter()
357 .filter_map(|m| read_npm_name(m).map(|n| PublishTarget::Npm { name: n }))
358 .collect()
359 } else {
360 read_npm_name(&Path::new(&pkg.path).join("package.json"))
361 .map(|n| vec![PublishTarget::Npm { name: n }])
362 .unwrap_or_default()
363 }
364 }
365 PublishConfig::Pypi { workspace, .. } => {
366 if *workspace {
367 discover_uv_members(Path::new(&pkg.path))
368 .iter()
369 .filter_map(|m| read_pyproject_name(m).map(|n| PublishTarget::Pypi { name: n }))
370 .collect()
371 } else {
372 read_pyproject_name(&Path::new(&pkg.path).join("pyproject.toml"))
373 .map(|n| vec![PublishTarget::Pypi { name: n }])
374 .unwrap_or_default()
375 }
376 }
377 PublishConfig::Docker { image, .. } => {
378 vec![PublishTarget::Docker {
379 image: image.clone(),
380 }]
381 }
382 PublishConfig::Go => vec![PublishTarget::Go {
383 path: pkg.path.clone(),
384 }],
385 PublishConfig::Custom { command, .. } => vec![PublishTarget::Custom {
386 label: command.clone(),
387 }],
388 };
389
390 if targets.is_empty() {
393 let publisher = publisher_for(cfg);
394 let ctx = PublishCtx {
395 package: pkg,
396 version,
397 tag,
398 dry_run: false,
399 env,
400 };
401 let (current, action) = match publisher.check(&ctx) {
402 Ok(PublishState::Completed) => (
403 ResourceState::Present {
404 value: version.to_string(),
405 },
406 Action::NoChange,
407 ),
408 Ok(PublishState::Needed) => (ResourceState::Absent, Action::Create),
409 Ok(PublishState::Unknown(r)) => {
410 (ResourceState::Unknown { reason: r }, Action::Uncertain)
411 }
412 Err(e) => (
413 ResourceState::Unknown {
414 reason: e.to_string(),
415 },
416 Action::Uncertain,
417 ),
418 };
419 return Ok(vec![ResourceDiff {
420 kind: ResourceKind::Publish,
421 id: format!("{}/{}", publisher.name(), pkg.path),
422 current,
423 desired: ResourceState::Present {
424 value: version.to_string(),
425 },
426 action,
427 }]);
428 }
429
430 let mut rows = Vec::new();
433 for target in targets {
434 let (kind_label, id, action, current) = match &target {
435 PublishTarget::Cargo { name } => {
436 let present =
437 probe_registry(&format!("https://crates.io/api/v1/crates/{name}/{version}"))
438 .unwrap_or(None);
439 state_from_probe("cargo", name, version, present)
440 }
441 PublishTarget::Npm { name } => {
442 let encoded = name.replacen('/', "%2F", 1);
443 let present =
444 probe_registry(&format!("https://registry.npmjs.org/{encoded}/{version}"))
445 .unwrap_or(None);
446 state_from_probe("npm", name, version, present)
447 }
448 PublishTarget::Pypi { name } => {
449 let norm = normalize_pypi_name(name);
450 let present =
451 probe_registry(&format!("https://pypi.org/pypi/{norm}/{version}/json"))
452 .unwrap_or(None);
453 state_from_probe("pypi", name, version, present)
454 }
455 PublishTarget::Docker { image } => {
456 (
459 "docker".to_string(),
460 format!("{image}:{version}"),
461 Action::Uncertain,
462 ResourceState::Unknown {
463 reason: "docker state check deferred to publish".into(),
464 },
465 )
466 }
467 PublishTarget::Go { path } => (
468 "go".to_string(),
469 format!("{path} (via tag)"),
470 Action::NoChange,
471 ResourceState::Present {
472 value: tag.to_string(),
473 },
474 ),
475 PublishTarget::Custom { label } => (
476 "custom".to_string(),
477 label.clone(),
478 Action::Uncertain,
479 ResourceState::Unknown {
480 reason: "custom check deferred to publish".into(),
481 },
482 ),
483 };
484 rows.push(ResourceDiff {
485 kind: ResourceKind::Publish,
486 id: format!("{kind_label}:{id}"),
487 current,
488 desired: ResourceState::Present {
489 value: version.to_string(),
490 },
491 action,
492 });
493 }
494 Ok(rows)
495}
496
497enum PublishTarget {
498 Cargo { name: String },
499 Npm { name: String },
500 Pypi { name: String },
501 Docker { image: String },
502 Go { path: String },
503 Custom { label: String },
504}
505
506fn state_from_probe(
507 publisher: &str,
508 name: &str,
509 version: &str,
510 probe: Option<bool>,
511) -> (String, String, Action, ResourceState) {
512 let id = format!("{name}@{version}");
513 match probe {
514 Some(true) => (
515 publisher.to_string(),
516 id,
517 Action::NoChange,
518 ResourceState::Present {
519 value: version.to_string(),
520 },
521 ),
522 Some(false) => (
523 publisher.to_string(),
524 id,
525 Action::Create,
526 ResourceState::Absent,
527 ),
528 None => (
529 publisher.to_string(),
530 id,
531 Action::Uncertain,
532 ResourceState::Unknown {
533 reason: "registry probe failed".into(),
534 },
535 ),
536 }
537}
538
539fn probe_registry(url: &str) -> Result<Option<bool>, ()> {
540 match ureq::get(url)
541 .header("User-Agent", "sr (+https://github.com/urmzd/sr)")
542 .header("Accept", "application/json")
543 .call()
544 {
545 Ok(resp) if resp.status() == 200 => Ok(Some(true)),
546 Ok(_) => Ok(Some(false)),
547 Err(ureq::Error::StatusCode(404)) => Ok(Some(false)),
548 Err(_) => Ok(None),
549 }
550}
551
552fn read_cargo_name(manifest: &Path) -> Option<String> {
553 let text = std::fs::read_to_string(manifest).ok()?;
554 let doc: toml_edit::DocumentMut = text.parse().ok()?;
555 doc.get("package")
556 .and_then(|p| p.as_table_like())
557 .and_then(|t| t.get("name"))
558 .and_then(|v| v.as_str())
559 .map(|s| s.to_string())
560}
561
562fn read_npm_name(manifest: &Path) -> Option<String> {
563 let text = std::fs::read_to_string(manifest).ok()?;
564 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
565 v.get("name")
566 .and_then(|n| n.as_str())
567 .map(|s| s.to_string())
568}
569
570fn read_pyproject_name(manifest: &Path) -> Option<String> {
571 let text = std::fs::read_to_string(manifest).ok()?;
572 let doc: toml_edit::DocumentMut = text.parse().ok()?;
573 if let Some(n) = doc
574 .get("project")
575 .and_then(|p| p.as_table_like())
576 .and_then(|t| t.get("name"))
577 .and_then(|v| v.as_str())
578 {
579 return Some(n.to_string());
580 }
581 doc.get("tool")
582 .and_then(|t| t.as_table_like())
583 .and_then(|t| t.get("poetry"))
584 .and_then(|p| p.as_table_like())
585 .and_then(|t| t.get("name"))
586 .and_then(|v| v.as_str())
587 .map(|s| s.to_string())
588}
589
590fn normalize_pypi_name(name: &str) -> String {
591 let lower = name.to_lowercase();
592 let mut out = String::with_capacity(lower.len());
593 let mut last_sep = false;
594 for ch in lower.chars() {
595 if ch == '.' || ch == '_' || ch == '-' {
596 if !last_sep {
597 out.push('-');
598 last_sep = true;
599 }
600 } else {
601 out.push(ch);
602 last_sep = false;
603 }
604 }
605 out
606}
607
608fn read_current_version(path: &Path) -> ResourceState {
610 if !path.exists() {
611 return ResourceState::Absent;
612 }
613 let text = match std::fs::read_to_string(path) {
614 Ok(t) => t,
615 Err(e) => {
616 return ResourceState::Unknown {
617 reason: format!("read {}: {e}", path.display()),
618 };
619 }
620 };
621 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
622
623 if (filename == "Cargo.toml" || filename == "pyproject.toml")
624 && let Ok(doc) = text.parse::<toml_edit::DocumentMut>()
625 {
626 if filename == "Cargo.toml"
628 && let Some(v) = doc
629 .get("package")
630 .and_then(|p| p.as_table_like())
631 .and_then(|t| t.get("version"))
632 .and_then(|v| v.as_str())
633 {
634 return ResourceState::Present {
635 value: v.to_string(),
636 };
637 }
638 if filename == "Cargo.toml"
639 && let Some(v) = doc
640 .get("workspace")
641 .and_then(|w| w.as_table_like())
642 .and_then(|w| w.get("package"))
643 .and_then(|p| p.as_table_like())
644 .and_then(|t| t.get("version"))
645 .and_then(|v| v.as_str())
646 {
647 return ResourceState::Present {
648 value: v.to_string(),
649 };
650 }
651 if filename == "pyproject.toml"
652 && let Some(v) = doc
653 .get("project")
654 .and_then(|p| p.as_table_like())
655 .and_then(|t| t.get("version"))
656 .and_then(|v| v.as_str())
657 {
658 return ResourceState::Present {
659 value: v.to_string(),
660 };
661 }
662 }
663
664 if filename == "package.json"
665 && let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
666 && let Some(version) = v.get("version").and_then(|n| n.as_str())
667 {
668 return ResourceState::Present {
669 value: version.to_string(),
670 };
671 }
672
673 let gradle = regex::Regex::new(r#"(?m)^\s*version\s*=\s*["']([^"']+)["']"#).unwrap();
676 if let Some(cap) = gradle.captures(&text)
677 && let Some(m) = cap.get(1)
678 {
679 return ResourceState::Present {
680 value: m.as_str().to_string(),
681 };
682 }
683 let pom = regex::Regex::new(r#"<version>([^<]+)</version>"#).unwrap();
685 if let Some(cap) = pom.captures(&text)
686 && let Some(m) = cap.get(1)
687 {
688 return ResourceState::Present {
689 value: m.as_str().to_string(),
690 };
691 }
692 let go = regex::Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*"([^"]+)""#).unwrap();
694 if let Some(cap) = go.captures(&text)
695 && let Some(m) = cap.get(1)
696 {
697 return ResourceState::Present {
698 value: m.as_str().to_string(),
699 };
700 }
701
702 ResourceState::Unknown {
703 reason: format!("unsupported format: {}", path.display()),
704 }
705}
706
707pub fn render_human(diff: &ReleaseDiff) -> String {
709 let mut out = String::new();
710
711 out.push_str(&format!(
712 "Plan: {} → {}\n\n",
713 diff.current_version
714 .as_deref()
715 .unwrap_or("(initial release)"),
716 diff.next_version
717 ));
718
719 if diff.resources.is_empty() {
720 out.push_str(" (no resources)\n");
721 return out;
722 }
723
724 let kinds = [
726 ResourceKind::Tag,
727 ResourceKind::FloatingTag,
728 ResourceKind::VersionFile,
729 ResourceKind::Release,
730 ResourceKind::Asset,
731 ResourceKind::Publish,
732 ];
733 for kind in kinds {
734 let rows: Vec<&ResourceDiff> = diff.resources.iter().filter(|r| r.kind == kind).collect();
735 if rows.is_empty() {
736 continue;
737 }
738 out.push_str(&format!("{}\n", kind.label()));
739 for r in rows {
740 let detail = match (&r.current, &r.desired) {
741 (ResourceState::Present { value: a }, ResourceState::Present { value: b })
742 if a == b =>
743 {
744 format!("({a})")
745 }
746 (ResourceState::Present { value: a }, ResourceState::Present { value: b }) => {
747 format!("{a} → {b}")
748 }
749 (ResourceState::Absent, ResourceState::Present { value: b }) => b.clone(),
750 (ResourceState::PresentOpaque, _) => "exists → will update".into(),
751 (ResourceState::Unknown { reason }, _) => format!("? ({reason})"),
752 _ => String::new(),
753 };
754 out.push_str(&format!(
755 " {} {:<40} {}\n",
756 r.action.symbol(),
757 r.id,
758 detail
759 ));
760 }
761 out.push('\n');
762 }
763
764 let s = diff.summary();
765 out.push_str(&format!(
766 "Summary: {} to create, {} to update, {} unchanged, {} uncertain.\n",
767 s.create, s.update, s.no_change, s.uncertain
768 ));
769 out
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn action_symbols() {
778 assert_eq!(Action::Create.symbol(), "+");
779 assert_eq!(Action::Update.symbol(), "~");
780 assert_eq!(Action::NoChange.symbol(), "=");
781 assert_eq!(Action::Uncertain.symbol(), "?");
782 }
783
784 #[test]
785 fn read_version_from_cargo_toml() {
786 let dir = tempfile::tempdir().unwrap();
787 let p = dir.path().join("Cargo.toml");
788 std::fs::write(&p, "[package]\nname = \"x\"\nversion = \"1.2.3\"\n").unwrap();
789 match read_current_version(&p) {
790 ResourceState::Present { value } => assert_eq!(value, "1.2.3"),
791 other => panic!("expected present, got {other:?}"),
792 }
793 }
794
795 #[test]
796 fn read_version_from_package_json() {
797 let dir = tempfile::tempdir().unwrap();
798 let p = dir.path().join("package.json");
799 std::fs::write(&p, r#"{"name": "x", "version": "2.0.0"}"#).unwrap();
800 match read_current_version(&p) {
801 ResourceState::Present { value } => assert_eq!(value, "2.0.0"),
802 other => panic!("expected present, got {other:?}"),
803 }
804 }
805
806 #[test]
807 fn read_version_absent() {
808 let dir = tempfile::tempdir().unwrap();
809 let p = dir.path().join("no-such.toml");
810 assert!(matches!(read_current_version(&p), ResourceState::Absent));
811 }
812
813 #[test]
814 fn summary_counts() {
815 let diff = ReleaseDiff {
816 tag_name: "v1".into(),
817 current_version: None,
818 next_version: "1.0.0".into(),
819 resources: vec![
820 ResourceDiff {
821 kind: ResourceKind::Tag,
822 id: "v1".into(),
823 current: ResourceState::Absent,
824 desired: ResourceState::Present { value: "v1".into() },
825 action: Action::Create,
826 },
827 ResourceDiff {
828 kind: ResourceKind::Release,
829 id: "v1".into(),
830 current: ResourceState::PresentOpaque,
831 desired: ResourceState::Present { value: "v1".into() },
832 action: Action::Update,
833 },
834 ],
835 };
836 let s = diff.summary();
837 assert_eq!(s.create, 1);
838 assert_eq!(s.update, 1);
839 }
840}