@opencode-cloud/core 1.0.8 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ //! Bind mount parsing and validation for container configuration.
2
+ //!
3
+ //! This module provides functionality to:
4
+ //! - Parse mount strings in Docker format (`/host:/container[:ro|rw]`)
5
+ //! - Validate mount paths (existence, type, permissions)
6
+ //! - Convert parsed mounts to Bollard's Mount type for Docker API
7
+ //! - Warn about potentially dangerous container mount points
8
+
9
+ use bollard::service::{Mount, MountTypeEnum};
10
+ use std::path::PathBuf;
11
+ use thiserror::Error;
12
+
13
+ /// Errors that can occur during mount parsing and validation.
14
+ #[derive(Debug, Error)]
15
+ pub enum MountError {
16
+ /// Mount path is relative, but must be absolute.
17
+ #[error("Mount paths must be absolute. Use: /full/path/to/dir (got: {0})")]
18
+ RelativePath(String),
19
+
20
+ /// Mount string format is invalid.
21
+ #[error("Invalid mount format. Expected: /host/path:/container/path[:ro] (got: {0})")]
22
+ InvalidFormat(String),
23
+
24
+ /// Path does not exist or cannot be accessed.
25
+ #[error("Path not found: {0} ({1})")]
26
+ PathNotFound(String, String),
27
+
28
+ /// Path exists but is not a directory.
29
+ #[error("Path is not a directory: {0}")]
30
+ NotADirectory(String),
31
+
32
+ /// Permission denied accessing path.
33
+ #[error("Cannot access path (permission denied): {0}")]
34
+ PermissionDenied(String),
35
+ }
36
+
37
+ /// A parsed bind mount specification.
38
+ #[derive(Debug, Clone, PartialEq)]
39
+ pub struct ParsedMount {
40
+ /// Host path to mount (absolute).
41
+ pub host_path: PathBuf,
42
+
43
+ /// Container path where the host path is mounted.
44
+ pub container_path: String,
45
+
46
+ /// Whether the mount is read-only.
47
+ pub read_only: bool,
48
+ }
49
+
50
+ impl ParsedMount {
51
+ /// Parse a mount string in Docker format.
52
+ ///
53
+ /// Format: `/host/path:/container/path[:ro|rw]`
54
+ ///
55
+ /// # Arguments
56
+ /// * `mount_str` - The mount specification string.
57
+ ///
58
+ /// # Returns
59
+ /// * `Ok(ParsedMount)` - Successfully parsed mount.
60
+ /// * `Err(MountError)` - Parse error.
61
+ ///
62
+ /// # Examples
63
+ /// ```
64
+ /// use opencode_cloud_core::docker::ParsedMount;
65
+ ///
66
+ /// // Read-write mount (default)
67
+ /// let mount = ParsedMount::parse("/home/user/data:/workspace/data").unwrap();
68
+ /// assert_eq!(mount.host_path.to_str().unwrap(), "/home/user/data");
69
+ /// assert_eq!(mount.container_path, "/workspace/data");
70
+ /// assert!(!mount.read_only);
71
+ ///
72
+ /// // Read-only mount
73
+ /// let mount = ParsedMount::parse("/home/user/config:/etc/app:ro").unwrap();
74
+ /// assert!(mount.read_only);
75
+ /// ```
76
+ pub fn parse(mount_str: &str) -> Result<Self, MountError> {
77
+ let parts: Vec<&str> = mount_str.split(':').collect();
78
+
79
+ match parts.len() {
80
+ 2 => {
81
+ // /host:/container (default rw)
82
+ let host_path = PathBuf::from(parts[0]);
83
+ if !host_path.is_absolute() {
84
+ return Err(MountError::RelativePath(parts[0].to_string()));
85
+ }
86
+ Ok(Self {
87
+ host_path,
88
+ container_path: parts[1].to_string(),
89
+ read_only: false,
90
+ })
91
+ }
92
+ 3 => {
93
+ // /host:/container:ro or /host:/container:rw
94
+ let host_path = PathBuf::from(parts[0]);
95
+ if !host_path.is_absolute() {
96
+ return Err(MountError::RelativePath(parts[0].to_string()));
97
+ }
98
+ let read_only = match parts[2].to_lowercase().as_str() {
99
+ "ro" => true,
100
+ "rw" => false,
101
+ _ => return Err(MountError::InvalidFormat(mount_str.to_string())),
102
+ };
103
+ Ok(Self {
104
+ host_path,
105
+ container_path: parts[1].to_string(),
106
+ read_only,
107
+ })
108
+ }
109
+ _ => Err(MountError::InvalidFormat(mount_str.to_string())),
110
+ }
111
+ }
112
+
113
+ /// Convert to a Bollard Mount for the Docker API.
114
+ ///
115
+ /// Returns a bind mount with the parsed host and container paths.
116
+ pub fn to_bollard_mount(&self) -> Mount {
117
+ Mount {
118
+ target: Some(self.container_path.clone()),
119
+ source: Some(self.host_path.to_string_lossy().to_string()),
120
+ typ: Some(MountTypeEnum::BIND),
121
+ read_only: Some(self.read_only),
122
+ ..Default::default()
123
+ }
124
+ }
125
+ }
126
+
127
+ /// Validate that a mount host path exists and is accessible.
128
+ ///
129
+ /// Checks:
130
+ /// 1. Path is absolute.
131
+ /// 2. Path exists (via canonicalize, which also resolves symlinks).
132
+ /// 3. Path is a directory.
133
+ ///
134
+ /// # Arguments
135
+ /// * `path` - The path to validate.
136
+ ///
137
+ /// # Returns
138
+ /// * `Ok(PathBuf)` - The canonical (resolved) path.
139
+ /// * `Err(MountError)` - Validation error.
140
+ pub fn validate_mount_path(path: &std::path::Path) -> Result<PathBuf, MountError> {
141
+ // Check absolute
142
+ if !path.is_absolute() {
143
+ return Err(MountError::RelativePath(path.display().to_string()));
144
+ }
145
+
146
+ // Canonicalize (resolves symlinks, checks existence)
147
+ let canonical = std::fs::canonicalize(path).map_err(|e| {
148
+ if e.kind() == std::io::ErrorKind::PermissionDenied {
149
+ MountError::PermissionDenied(path.display().to_string())
150
+ } else {
151
+ MountError::PathNotFound(path.display().to_string(), e.to_string())
152
+ }
153
+ })?;
154
+
155
+ // Check it's a directory
156
+ let metadata = std::fs::metadata(&canonical).map_err(|e| {
157
+ if e.kind() == std::io::ErrorKind::PermissionDenied {
158
+ MountError::PermissionDenied(path.display().to_string())
159
+ } else {
160
+ MountError::PathNotFound(path.display().to_string(), e.to_string())
161
+ }
162
+ })?;
163
+
164
+ if !metadata.is_dir() {
165
+ return Err(MountError::NotADirectory(path.display().to_string()));
166
+ }
167
+
168
+ Ok(canonical)
169
+ }
170
+
171
+ /// System paths that should typically not be mounted over.
172
+ const SYSTEM_PATHS: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/lib", "/var"];
173
+
174
+ /// Check if mounting to a container path might be dangerous.
175
+ ///
176
+ /// Returns a warning message if the container path is a system path,
177
+ /// or `None` if the path appears safe.
178
+ ///
179
+ /// # Arguments
180
+ /// * `container_path` - The path inside the container.
181
+ ///
182
+ /// # Returns
183
+ /// * `Some(String)` - Warning message about the system path.
184
+ /// * `None` - Path appears safe.
185
+ pub fn check_container_path_warning(container_path: &str) -> Option<String> {
186
+ for system_path in SYSTEM_PATHS {
187
+ if container_path == *system_path || container_path.starts_with(&format!("{system_path}/"))
188
+ {
189
+ return Some(format!(
190
+ "Warning: mounting to '{container_path}' may affect container system files"
191
+ ));
192
+ }
193
+ }
194
+ None
195
+ }
196
+
197
+ #[cfg(test)]
198
+ mod tests {
199
+ use super::*;
200
+
201
+ #[test]
202
+ fn parse_valid_mount_rw() {
203
+ let mount = ParsedMount::parse("/a:/b").unwrap();
204
+ assert_eq!(mount.host_path, PathBuf::from("/a"));
205
+ assert_eq!(mount.container_path, "/b");
206
+ assert!(!mount.read_only);
207
+ }
208
+
209
+ #[test]
210
+ fn parse_valid_mount_ro() {
211
+ let mount = ParsedMount::parse("/a:/b:ro").unwrap();
212
+ assert_eq!(mount.host_path, PathBuf::from("/a"));
213
+ assert_eq!(mount.container_path, "/b");
214
+ assert!(mount.read_only);
215
+ }
216
+
217
+ #[test]
218
+ fn parse_valid_mount_explicit_rw() {
219
+ let mount = ParsedMount::parse("/a:/b:rw").unwrap();
220
+ assert_eq!(mount.host_path, PathBuf::from("/a"));
221
+ assert_eq!(mount.container_path, "/b");
222
+ assert!(!mount.read_only);
223
+ }
224
+
225
+ #[test]
226
+ fn parse_valid_mount_ro_uppercase() {
227
+ let mount = ParsedMount::parse("/a:/b:RO").unwrap();
228
+ assert!(mount.read_only);
229
+ }
230
+
231
+ #[test]
232
+ fn parse_invalid_format_single_part() {
233
+ let result = ParsedMount::parse("invalid");
234
+ assert!(matches!(result, Err(MountError::InvalidFormat(_))));
235
+ }
236
+
237
+ #[test]
238
+ fn parse_invalid_format_too_many_parts() {
239
+ let result = ParsedMount::parse("/a:/b:ro:extra");
240
+ assert!(matches!(result, Err(MountError::InvalidFormat(_))));
241
+ }
242
+
243
+ #[test]
244
+ fn parse_invalid_format_bad_mode() {
245
+ let result = ParsedMount::parse("/a:/b:invalid");
246
+ assert!(matches!(result, Err(MountError::InvalidFormat(_))));
247
+ }
248
+
249
+ #[test]
250
+ fn parse_relative_path_rejected() {
251
+ let result = ParsedMount::parse("./rel:/b");
252
+ assert!(matches!(result, Err(MountError::RelativePath(_))));
253
+ }
254
+
255
+ #[test]
256
+ fn parse_relative_path_no_dot_rejected() {
257
+ let result = ParsedMount::parse("relative/path:/b");
258
+ assert!(matches!(result, Err(MountError::RelativePath(_))));
259
+ }
260
+
261
+ #[test]
262
+ fn system_path_warning_etc() {
263
+ let warning = check_container_path_warning("/etc");
264
+ assert!(warning.is_some());
265
+ assert!(warning.unwrap().contains("/etc"));
266
+ }
267
+
268
+ #[test]
269
+ fn system_path_warning_etc_subdir() {
270
+ let warning = check_container_path_warning("/etc/passwd");
271
+ assert!(warning.is_some());
272
+ }
273
+
274
+ #[test]
275
+ fn system_path_warning_usr() {
276
+ let warning = check_container_path_warning("/usr");
277
+ assert!(warning.is_some());
278
+ }
279
+
280
+ #[test]
281
+ fn system_path_warning_usr_local() {
282
+ let warning = check_container_path_warning("/usr/local");
283
+ assert!(warning.is_some());
284
+ }
285
+
286
+ #[test]
287
+ fn non_system_path_no_warning() {
288
+ let warning = check_container_path_warning("/workspace/data");
289
+ assert!(warning.is_none());
290
+ }
291
+
292
+ #[test]
293
+ fn non_system_path_home_no_warning() {
294
+ let warning = check_container_path_warning("/home/user/data");
295
+ assert!(warning.is_none());
296
+ }
297
+
298
+ #[test]
299
+ fn to_bollard_mount_structure() {
300
+ let mount = ParsedMount {
301
+ host_path: PathBuf::from("/host/path"),
302
+ container_path: "/container/path".to_string(),
303
+ read_only: true,
304
+ };
305
+ let bollard_mount = mount.to_bollard_mount();
306
+ assert_eq!(bollard_mount.target, Some("/container/path".to_string()));
307
+ assert_eq!(bollard_mount.source, Some("/host/path".to_string()));
308
+ assert_eq!(bollard_mount.typ, Some(MountTypeEnum::BIND));
309
+ assert_eq!(bollard_mount.read_only, Some(true));
310
+ }
311
+
312
+ #[test]
313
+ fn validate_mount_path_relative_rejected() {
314
+ let result = validate_mount_path(std::path::Path::new("./relative"));
315
+ assert!(matches!(result, Err(MountError::RelativePath(_))));
316
+ }
317
+
318
+ #[test]
319
+ fn validate_mount_path_nonexistent() {
320
+ let result = validate_mount_path(std::path::Path::new("/nonexistent/path/xyz123"));
321
+ assert!(matches!(result, Err(MountError::PathNotFound(_, _))));
322
+ }
323
+
324
+ #[test]
325
+ fn validate_mount_path_existing_directory() {
326
+ // Use /tmp which should exist on any Unix system
327
+ let result = validate_mount_path(std::path::Path::new("/tmp"));
328
+ assert!(result.is_ok());
329
+ }
330
+ }
@@ -48,9 +48,9 @@ fn format_elapsed(duration: Duration) -> String {
48
48
  let seconds = total_secs % 60;
49
49
 
50
50
  if hours > 0 {
51
- format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
51
+ format!("{hours:02}:{minutes:02}:{seconds:02}")
52
52
  } else {
53
- format!("{:02}:{:02}", minutes, seconds)
53
+ format!("{minutes:02}:{seconds:02}")
54
54
  }
55
55
  }
56
56
 
@@ -116,8 +116,8 @@ impl ProgressReporter {
116
116
  // Format: "[elapsed] Context · message" or "[elapsed] message"
117
117
  // Timer at the beginning for easy scanning
118
118
  match &self.context {
119
- Some(ctx) => format!("[{}] {} · {}", elapsed, ctx, clean_msg),
120
- None => format!("[{}] {}", elapsed, clean_msg),
119
+ Some(ctx) => format!("[{elapsed}] {ctx} · {clean_msg}"),
120
+ None => format!("[{elapsed}] {clean_msg}"),
121
121
  }
122
122
  }
123
123
 
@@ -0,0 +1,120 @@
1
+ //! Image state tracking for provenance information
2
+ //!
3
+ //! Tracks where the current Docker image came from (prebuilt or built)
4
+ //! and which registry it was pulled from.
5
+
6
+ use chrono::Utc;
7
+ use serde::{Deserialize, Serialize};
8
+ use std::path::PathBuf;
9
+
10
+ /// Image provenance state
11
+ #[derive(Debug, Clone, Serialize, Deserialize)]
12
+ pub struct ImageState {
13
+ /// Image version (e.g., "1.0.12")
14
+ pub version: String,
15
+ /// Source: "prebuilt" or "build"
16
+ pub source: String,
17
+ /// Registry if prebuilt: "ghcr.io" or "docker.io", None for build
18
+ #[serde(skip_serializing_if = "Option::is_none")]
19
+ pub registry: Option<String>,
20
+ /// When the image was acquired (ISO8601)
21
+ pub acquired_at: String,
22
+ }
23
+
24
+ impl ImageState {
25
+ /// Create a new ImageState for a prebuilt image
26
+ pub fn prebuilt(version: &str, registry: &str) -> Self {
27
+ Self {
28
+ version: version.to_string(),
29
+ source: "prebuilt".to_string(),
30
+ registry: Some(registry.to_string()),
31
+ acquired_at: Utc::now().to_rfc3339(),
32
+ }
33
+ }
34
+
35
+ /// Create a new ImageState for a locally built image
36
+ pub fn built(version: &str) -> Self {
37
+ Self {
38
+ version: version.to_string(),
39
+ source: "build".to_string(),
40
+ registry: None,
41
+ acquired_at: Utc::now().to_rfc3339(),
42
+ }
43
+ }
44
+ }
45
+
46
+ /// Get the path to the image state file
47
+ pub fn get_state_path() -> Option<PathBuf> {
48
+ crate::config::paths::get_data_dir().map(|p| p.join("image-state.json"))
49
+ }
50
+
51
+ /// Save image state to disk
52
+ pub fn save_state(state: &ImageState) -> anyhow::Result<()> {
53
+ let path = get_state_path().ok_or_else(|| anyhow::anyhow!("Could not determine state path"))?;
54
+
55
+ // Ensure parent directory exists
56
+ if let Some(parent) = path.parent() {
57
+ std::fs::create_dir_all(parent)?;
58
+ }
59
+
60
+ let json = serde_json::to_string_pretty(state)?;
61
+ std::fs::write(&path, json)?;
62
+ Ok(())
63
+ }
64
+
65
+ /// Load image state from disk
66
+ pub fn load_state() -> Option<ImageState> {
67
+ let path = get_state_path()?;
68
+ let content = std::fs::read_to_string(&path).ok()?;
69
+ serde_json::from_str(&content).ok()
70
+ }
71
+
72
+ /// Clear image state (e.g., after image removal)
73
+ pub fn clear_state() -> anyhow::Result<()> {
74
+ if let Some(path) = get_state_path() {
75
+ if path.exists() {
76
+ std::fs::remove_file(&path)?;
77
+ }
78
+ }
79
+ Ok(())
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::*;
85
+
86
+ #[test]
87
+ fn test_image_state_prebuilt() {
88
+ let state = ImageState::prebuilt("1.0.12", "ghcr.io");
89
+ assert_eq!(state.version, "1.0.12");
90
+ assert_eq!(state.source, "prebuilt");
91
+ assert_eq!(state.registry, Some("ghcr.io".to_string()));
92
+ assert!(!state.acquired_at.is_empty());
93
+ }
94
+
95
+ #[test]
96
+ fn test_image_state_built() {
97
+ let state = ImageState::built("1.0.12");
98
+ assert_eq!(state.version, "1.0.12");
99
+ assert_eq!(state.source, "build");
100
+ assert!(state.registry.is_none());
101
+ }
102
+
103
+ #[test]
104
+ fn test_image_state_serialize_deserialize() {
105
+ let state = ImageState::prebuilt("1.0.12", "docker.io");
106
+ let json = serde_json::to_string(&state).unwrap();
107
+ let parsed: ImageState = serde_json::from_str(&json).unwrap();
108
+ assert_eq!(state.version, parsed.version);
109
+ assert_eq!(state.source, parsed.source);
110
+ assert_eq!(state.registry, parsed.registry);
111
+ }
112
+
113
+ #[test]
114
+ fn test_get_state_path() {
115
+ let path = get_state_path();
116
+ assert!(path.is_some());
117
+ let p = path.unwrap();
118
+ assert!(p.to_string_lossy().contains("image-state.json"));
119
+ }
120
+ }
@@ -0,0 +1,156 @@
1
+ //! Docker image update and rollback operations
2
+ //!
3
+ //! This module provides functionality to update the opencode image to the latest
4
+ //! version and rollback to a previous version if needed.
5
+
6
+ use super::image::{image_exists, pull_image};
7
+ use super::progress::ProgressReporter;
8
+ use super::{DockerClient, DockerError, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT};
9
+ use bollard::image::TagImageOptions;
10
+ use tracing::debug;
11
+
12
+ /// Tag for the previous image version (used for rollback)
13
+ pub const PREVIOUS_TAG: &str = "previous";
14
+
15
+ /// Result of an update operation
16
+ #[derive(Debug, Clone, PartialEq)]
17
+ pub enum UpdateResult {
18
+ /// Update completed successfully
19
+ Success,
20
+ /// Already on the latest version
21
+ AlreadyLatest,
22
+ }
23
+
24
+ /// Tag the current image as "previous" for rollback support
25
+ ///
26
+ /// This allows users to rollback to the version they had before updating.
27
+ /// If the current image doesn't exist, this is silently skipped.
28
+ ///
29
+ /// # Arguments
30
+ /// * `client` - Docker client
31
+ pub async fn tag_current_as_previous(client: &DockerClient) -> Result<(), DockerError> {
32
+ let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
33
+ let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
34
+
35
+ debug!(
36
+ "Tagging current image {} as {}",
37
+ current_image, previous_image
38
+ );
39
+
40
+ // Check if current image exists
41
+ if !image_exists(client, IMAGE_NAME_GHCR, IMAGE_TAG_DEFAULT).await? {
42
+ debug!("Current image not found, skipping backup tag");
43
+ return Ok(());
44
+ }
45
+
46
+ // Tag current as previous
47
+ let options = TagImageOptions {
48
+ repo: IMAGE_NAME_GHCR,
49
+ tag: PREVIOUS_TAG,
50
+ };
51
+
52
+ client
53
+ .inner()
54
+ .tag_image(&current_image, Some(options))
55
+ .await
56
+ .map_err(|e| {
57
+ DockerError::Container(format!("Failed to tag current image as previous: {e}"))
58
+ })?;
59
+
60
+ debug!("Successfully tagged current image as previous");
61
+ Ok(())
62
+ }
63
+
64
+ /// Check if a previous image exists for rollback
65
+ ///
66
+ /// Returns true if a rollback is possible, false otherwise.
67
+ ///
68
+ /// # Arguments
69
+ /// * `client` - Docker client
70
+ pub async fn has_previous_image(client: &DockerClient) -> Result<bool, DockerError> {
71
+ image_exists(client, IMAGE_NAME_GHCR, PREVIOUS_TAG).await
72
+ }
73
+
74
+ /// Update the opencode image to the latest version
75
+ ///
76
+ /// This operation:
77
+ /// 1. Tags the current image as "previous" for rollback
78
+ /// 2. Pulls the latest image from the registry
79
+ ///
80
+ /// Returns UpdateResult indicating success or if already on latest.
81
+ ///
82
+ /// # Arguments
83
+ /// * `client` - Docker client
84
+ /// * `progress` - Progress reporter for user feedback
85
+ pub async fn update_image(
86
+ client: &DockerClient,
87
+ progress: &mut ProgressReporter,
88
+ ) -> Result<UpdateResult, DockerError> {
89
+ // Step 1: Tag current image as previous for rollback
90
+ progress.add_spinner("backup", "Backing up current image");
91
+ tag_current_as_previous(client).await?;
92
+ progress.finish("backup", "Current image backed up");
93
+
94
+ // Step 2: Pull latest image
95
+ progress.add_spinner("pull", "Pulling latest image");
96
+ pull_image(client, Some(IMAGE_TAG_DEFAULT), progress).await?;
97
+ progress.finish("pull", "Latest image pulled");
98
+
99
+ Ok(UpdateResult::Success)
100
+ }
101
+
102
+ /// Rollback to the previous image version
103
+ ///
104
+ /// This re-tags the "previous" image as "latest", effectively reverting
105
+ /// to the version that was active before the last update.
106
+ ///
107
+ /// Returns an error if no previous image exists.
108
+ ///
109
+ /// # Arguments
110
+ /// * `client` - Docker client
111
+ pub async fn rollback_image(client: &DockerClient) -> Result<(), DockerError> {
112
+ // Check if previous image exists
113
+ if !has_previous_image(client).await? {
114
+ return Err(DockerError::Container(
115
+ "No previous image available for rollback. Update at least once before using rollback."
116
+ .to_string(),
117
+ ));
118
+ }
119
+
120
+ let previous_image = format!("{IMAGE_NAME_GHCR}:{PREVIOUS_TAG}");
121
+ let current_image = format!("{IMAGE_NAME_GHCR}:{IMAGE_TAG_DEFAULT}");
122
+
123
+ debug!("Rolling back from {} to {}", current_image, previous_image);
124
+
125
+ // Re-tag previous as latest
126
+ let options = TagImageOptions {
127
+ repo: IMAGE_NAME_GHCR,
128
+ tag: IMAGE_TAG_DEFAULT,
129
+ };
130
+
131
+ client
132
+ .inner()
133
+ .tag_image(&previous_image, Some(options))
134
+ .await
135
+ .map_err(|e| DockerError::Container(format!("Failed to rollback image: {e}")))?;
136
+
137
+ debug!("Successfully rolled back to previous image");
138
+ Ok(())
139
+ }
140
+
141
+ #[cfg(test)]
142
+ mod tests {
143
+ use super::*;
144
+
145
+ #[test]
146
+ fn previous_tag_constant() {
147
+ assert_eq!(PREVIOUS_TAG, "previous");
148
+ }
149
+
150
+ #[test]
151
+ fn update_result_variants() {
152
+ assert_eq!(UpdateResult::Success, UpdateResult::Success);
153
+ assert_eq!(UpdateResult::AlreadyLatest, UpdateResult::AlreadyLatest);
154
+ assert_ne!(UpdateResult::Success, UpdateResult::AlreadyLatest);
155
+ }
156
+ }