Skip to main content

sr_core/
diff.rs

1//! Structured diff between desired and actual release state.
2//!
3//! `sr plan` computes a [`ReleaseDiff`] and renders it for the user.
4//! Every resource the reconciler would touch appears as one [`ResourceDiff`]
5//! entry showing its current observable state, its desired state, and the
6//! `Action` that would converge the two.
7//!
8//! This is Terraform-style: the diff *describes* what would happen, not
9//! what commit-message was parsed. The changelog is still computed (it's
10//! the body of the Release resource) but the user-facing plan output is
11//! resource-by-resource.
12
13use 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/// Kind of resource under reconciliation. Determines the row's prefix
25/// label in the human-readable diff.
26#[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/// What's there vs. what we want there.
51#[derive(Debug, Clone, Serialize)]
52#[serde(tag = "state", rename_all = "snake_case")]
53pub enum ResourceState {
54    Absent,
55    Present {
56        value: String,
57    },
58    /// Present but its shape is opaque to sr (e.g. a remote release
59    /// exists; we haven't fetched the body to compare).
60    PresentOpaque,
61    /// State query failed or unsupported. Carries a reason for the user.
62    Unknown {
63        reason: String,
64    },
65}
66
67impl ResourceState {
68    /// Borrow the inner value if the state is `Present`. Used by renderers
69    /// that want to compare current vs. desired values directly.
70    pub fn value(&self) -> Option<&str> {
71        match self {
72            Self::Present { value } => Some(value),
73            _ => None,
74        }
75    }
76}
77
78/// What the reconciler would do to converge actual → desired.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Action {
82    /// Actual is absent; reconciler will create.
83    Create,
84    /// Actual is present but differs from desired; reconciler will update.
85    Update,
86    /// Actual already matches desired; reconciler will skip.
87    NoChange,
88    /// State is unknown; reconciler will attempt the work and rely on the
89    /// underlying tool's idempotency.
90    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/// One row in the plan output.
105#[derive(Debug, Clone, Serialize)]
106pub struct ResourceDiff {
107    pub kind: ResourceKind,
108    /// Unique, human-readable id (e.g. "v8.0.0", "crates/core/Cargo.toml#version").
109    pub id: String,
110    pub current: ResourceState,
111    pub desired: ResourceState,
112    pub action: Action,
113}
114
115/// Aggregate plan diff.
116#[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    /// Summary counts by action.
126    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
148/// Build the diff by querying actual state for every resource the plan
149/// would touch.
150///
151/// `env` is forwarded to publishers (they run `check()` which may shell out
152/// — for built-in publishers like cargo/npm, `check` is a pure HTTP call,
153/// but `custom` runs the user's check command).
154pub 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    // Tag.
164    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    // Floating tag (optional).
186    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            // Floating tags always move — we don't try to inspect their
192            // current pointee; just record existence.
193            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    // Version files — read current value from each file.
206    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 &current {
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    // Release object. We don't fetch the body (cost), so it's opaque-present
233    // or absent. Updates are implicit every run — report as Update when
234    // present to signal "body may be rewritten".
235    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    // Assets — one row per declared artifact (literal path, not globbed).
255    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        // Rows for files missing on disk → "build hasn't produced them yet".
265        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        // Rows for files on disk → either already uploaded or needs upload.
278        {
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    // Publish resources — one per workspace member per publish target. Empty
308    // for packages without a publish config.
309    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
331/// Enumerate publish resource rows for one package. For workspace mode,
332/// produces one row per member; otherwise one row.
333fn 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 we couldn't identify anything concrete, fall back to a single
391    // publisher-level row using `check()`.
392    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    // Per-target rows. Each uses a single-scope check (not the aggregated
431    // workspace check) so the user sees per-member state.
432    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                // Skip HEAD request for docker in the diff — too much auth
457                // complexity for a preview. Show as Uncertain.
458                (
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
608/// Read the `version` field from a manifest file. Format-aware.
609fn 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        // Cargo: package.version or workspace.package.version
627        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    // Regex fallback for Gradle / pom.xml / Go.
674    // Gradle: version = '...' or version = "..."
675    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    // pom.xml: <version>...</version> (first one, skipping <parent>)
684    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    // Go: var/const Version = "..."
693    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
707/// Human-friendly rendering of the diff (Terraform-style).
708pub 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    // Group by kind for readability.
725    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}