Skip to main content

verbs/
spool_plan.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure spool CLI display helpers (no hosted network / repo I/O).
3//!
4//! Owns mount-name defaults, short ids, and child-edge status labels for
5//! `heddle spool …`. Hosted RPC and RecoveryAdvice stay CLI-owned.
6//!
7//! Child-edge status uses a pure enum so `heddle-verbs` does not depend on
8//! `prost` / wire types. CLI maps `ChildEdgeStatus` (or wire `i32`) via
9//! [`edge_status_from_i32`] / [`ChildEdgeStatusKind`].
10
11/// Default mount name = the child path's last `/`-segment (trailing slashes ignored).
12pub fn default_mount_name(child_path: &str) -> &str {
13    child_path
14        .trim_end_matches('/')
15        .rsplit('/')
16        .find(|s| !s.is_empty())
17        .unwrap_or(child_path)
18}
19
20/// First 12 characters of a change-id / state string for human display.
21///
22/// Same truncation as [`crate::approval_plan::short_state_id`].
23pub fn short_id(id: &str) -> &str {
24    &id[..id.len().min(12)]
25}
26
27/// Pure child-edge status (mirrors hosted `ChildEdgeStatus` wire enum).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ChildEdgeStatusKind {
30    /// Wire `UNSPECIFIED` (0).
31    Unspecified,
32    /// Wire `UP_TO_DATE` (1).
33    UpToDate,
34    /// Wire `FAST_FORWARDABLE` (2).
35    FastForwardable,
36    /// Wire `DIVERGED` (3).
37    Diverged,
38    /// Wire `NO_CHILD_HEAD` (4).
39    NoChildHead,
40}
41
42/// Map stable protobuf discriminant values to [`ChildEdgeStatusKind`].
43///
44/// Unknown values map to [`ChildEdgeStatusKind::Unspecified`].
45pub fn edge_status_from_i32(status: i32) -> ChildEdgeStatusKind {
46    match status {
47        1 => ChildEdgeStatusKind::UpToDate,
48        2 => ChildEdgeStatusKind::FastForwardable,
49        3 => ChildEdgeStatusKind::Diverged,
50        4 => ChildEdgeStatusKind::NoChildHead,
51        _ => ChildEdgeStatusKind::Unspecified,
52    }
53}
54
55/// Human label for a child-edge status kind.
56pub fn edge_status_label(kind: ChildEdgeStatusKind) -> &'static str {
57    match kind {
58        ChildEdgeStatusKind::UpToDate => "up-to-date",
59        ChildEdgeStatusKind::FastForwardable => "fast-forwardable",
60        ChildEdgeStatusKind::Diverged => "diverged",
61        ChildEdgeStatusKind::NoChildHead => "no-child-head",
62        ChildEdgeStatusKind::Unspecified => "unspecified",
63    }
64}
65
66/// Label from a wire `i32` status (convenience for CLI after RPC).
67pub fn edge_status_label_from_i32(status: i32) -> &'static str {
68    edge_status_label(edge_status_from_i32(status))
69}
70
71/// Attach success header line.
72pub fn spool_attach_message(child: &str, parent: &str, mount: &str) -> String {
73    format!("Attached {child} under {parent} at '{mount}'")
74}
75
76/// Detach human line from whether a mount was removed.
77pub fn spool_detach_message(mount_name: &str, parent: &str, removed: bool) -> String {
78    if removed {
79        format!("Detached '{mount_name}' from {parent}")
80    } else {
81        format!("No child mounted at '{mount_name}' under {parent} (nothing to detach).")
82    }
83}
84
85/// Empty children list line.
86pub fn spool_children_empty_message(parent: &str) -> String {
87    format!("{parent} has no child spools.")
88}
89
90/// Non-empty children header.
91pub fn spool_children_header(count: usize, parent: &str) -> String {
92    format!("{count} child spool(s) of {parent}:")
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn default_mount_name_uses_last_path_segment() {
101        assert_eq!(default_mount_name("acme/lib"), "lib");
102        assert_eq!(default_mount_name("acme/team/lib"), "lib");
103        assert_eq!(default_mount_name("acme/lib/"), "lib");
104        assert_eq!(default_mount_name("solo"), "solo");
105        assert_eq!(default_mount_name("/"), "/");
106    }
107
108    #[test]
109    fn short_id_truncates_to_twelve() {
110        assert_eq!(short_id("abcdefghijklmnop"), "abcdefghijkl");
111        assert_eq!(short_id("abc"), "abc");
112    }
113
114    #[test]
115    fn edge_status_label_covers_every_variant() {
116        assert_eq!(
117            edge_status_label(ChildEdgeStatusKind::UpToDate),
118            "up-to-date"
119        );
120        assert_eq!(
121            edge_status_label(ChildEdgeStatusKind::FastForwardable),
122            "fast-forwardable"
123        );
124        assert_eq!(edge_status_label(ChildEdgeStatusKind::Diverged), "diverged");
125        assert_eq!(
126            edge_status_label(ChildEdgeStatusKind::NoChildHead),
127            "no-child-head"
128        );
129        assert_eq!(
130            edge_status_label(ChildEdgeStatusKind::Unspecified),
131            "unspecified"
132        );
133        // Wire discriminants from hosted.proto
134        assert_eq!(edge_status_from_i32(0), ChildEdgeStatusKind::Unspecified);
135        assert_eq!(edge_status_from_i32(1), ChildEdgeStatusKind::UpToDate);
136        assert_eq!(
137            edge_status_from_i32(2),
138            ChildEdgeStatusKind::FastForwardable
139        );
140        assert_eq!(edge_status_from_i32(3), ChildEdgeStatusKind::Diverged);
141        assert_eq!(edge_status_from_i32(4), ChildEdgeStatusKind::NoChildHead);
142        assert_eq!(edge_status_from_i32(99), ChildEdgeStatusKind::Unspecified);
143        assert_eq!(edge_status_label_from_i32(1), "up-to-date");
144    }
145
146    #[test]
147    fn attach_detach_messages() {
148        assert_eq!(
149            spool_attach_message("acme/lib", "acme/app", "lib"),
150            "Attached acme/lib under acme/app at 'lib'"
151        );
152        assert!(spool_detach_message("lib", "acme/app", true).starts_with("Detached"));
153        assert!(spool_detach_message("lib", "acme/app", false).contains("nothing to detach"));
154    }
155}