1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
4use changepacks_utils::{detect_indent, next_version};
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use tokio::fs::{read_to_string, write};
9
10use crate::detect_package_manager_recursive;
11
12#[derive(Debug)]
13pub struct NodeWorkspace {
14 path: PathBuf,
15 relative_path: PathBuf,
16 version: Option<String>,
17 name: Option<String>,
18 is_changed: bool,
19 dependencies: HashSet<String>,
20}
21
22impl NodeWorkspace {
23 #[must_use]
24 pub fn new(
25 name: Option<String>,
26 version: Option<String>,
27 path: PathBuf,
28 relative_path: PathBuf,
29 ) -> Self {
30 Self {
31 path,
32 relative_path,
33 name,
34 version,
35 is_changed: false,
36 dependencies: HashSet::new(),
37 }
38 }
39}
40
41#[async_trait]
42impl Workspace for NodeWorkspace {
43 fn name(&self) -> Option<&str> {
44 self.name.as_deref()
45 }
46
47 fn path(&self) -> &Path {
48 &self.path
49 }
50
51 fn version(&self) -> Option<&str> {
52 self.version.as_deref()
53 }
54
55 async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
56 let next_version = next_version(
57 self.version.as_ref().unwrap_or(&String::from("0.0.0")),
58 update_type,
59 )?;
60
61 let package_json_raw = read_to_string(Path::new(&self.path)).await?;
62 let indent = detect_indent(&package_json_raw);
63 let mut package_json: serde_json::Value = serde_json::from_str(&package_json_raw)?;
64 package_json["version"] = serde_json::Value::String(next_version.clone());
65 let ind = &b" ".repeat(indent);
66 let formatter = serde_json::ser::PrettyFormatter::with_indent(ind);
67 let writer = Vec::new();
68 let mut ser = serde_json::Serializer::with_formatter(writer, formatter);
69 package_json.serialize(&mut ser)?;
70 write(
71 &self.path,
72 format!(
73 "{}{}",
74 String::from_utf8(ser.into_inner())?.trim_end(),
75 if package_json_raw.ends_with('\n') {
76 "\n"
77 } else {
78 ""
79 }
80 ),
81 )
82 .await?;
83 self.version = Some(next_version);
84 Ok(())
85 }
86
87 fn language(&self) -> Language {
88 Language::Node
89 }
90
91 fn is_changed(&self) -> bool {
92 self.is_changed
93 }
94
95 fn set_changed(&mut self, changed: bool) {
96 self.is_changed = changed;
97 }
98
99 fn relative_path(&self) -> &Path {
100 &self.relative_path
101 }
102
103 fn default_publish_command(&self) -> String {
104 detect_package_manager_recursive(&self.path)
105 .publish_command()
106 .to_string()
107 }
108
109 fn dependencies(&self) -> &HashSet<String> {
110 &self.dependencies
111 }
112
113 fn add_dependency(&mut self, dependency: &str) {
114 self.dependencies.insert(dependency.to_string());
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use changepacks_core::UpdateType;
122 use std::fs;
123 use tempfile::TempDir;
124 use tokio::fs::read_to_string;
125
126 #[tokio::test]
127 async fn test_node_workspace_new() {
128 let workspace = NodeWorkspace::new(
129 Some("test-workspace".to_string()),
130 Some("1.0.0".to_string()),
131 PathBuf::from("/test/package.json"),
132 PathBuf::from("test/package.json"),
133 );
134
135 assert_eq!(workspace.name(), Some("test-workspace"));
136 assert_eq!(workspace.version(), Some("1.0.0"));
137 assert_eq!(workspace.path(), PathBuf::from("/test/package.json"));
138 assert_eq!(
139 workspace.relative_path(),
140 PathBuf::from("test/package.json")
141 );
142 assert_eq!(workspace.language(), Language::Node);
143 assert!(!workspace.is_changed());
144 assert_eq!(workspace.default_publish_command(), "npm publish");
145 }
146
147 #[tokio::test]
148 async fn test_node_workspace_new_without_name_and_version() {
149 let workspace = NodeWorkspace::new(
150 None,
151 None,
152 PathBuf::from("/test/package.json"),
153 PathBuf::from("test/package.json"),
154 );
155
156 assert_eq!(workspace.name(), None);
157 assert_eq!(workspace.version(), None);
158 }
159
160 #[tokio::test]
161 async fn test_node_workspace_set_changed() {
162 let mut workspace = NodeWorkspace::new(
163 Some("test-workspace".to_string()),
164 Some("1.0.0".to_string()),
165 PathBuf::from("/test/package.json"),
166 PathBuf::from("test/package.json"),
167 );
168
169 assert!(!workspace.is_changed());
170 workspace.set_changed(true);
171 assert!(workspace.is_changed());
172 workspace.set_changed(false);
173 assert!(!workspace.is_changed());
174 }
175
176 #[tokio::test]
177 async fn test_node_workspace_update_version_with_existing_version() {
178 let temp_dir = TempDir::new().unwrap();
179 let package_json = temp_dir.path().join("package.json");
180 fs::write(
181 &package_json,
182 r#"{
183 "name": "test-workspace",
184 "version": "1.0.0",
185 "workspaces": ["packages/*"]
186}
187"#,
188 )
189 .unwrap();
190
191 let mut workspace = NodeWorkspace::new(
192 Some("test-workspace".to_string()),
193 Some("1.0.0".to_string()),
194 package_json.clone(),
195 PathBuf::from("package.json"),
196 );
197
198 workspace.update_version(UpdateType::Patch).await.unwrap();
199
200 let content = read_to_string(&package_json).await.unwrap();
201 assert!(content.contains(r#""version": "1.0.1""#));
202
203 temp_dir.close().unwrap();
204 }
205
206 #[tokio::test]
207 async fn test_node_workspace_update_version_without_version() {
208 let temp_dir = TempDir::new().unwrap();
209 let package_json = temp_dir.path().join("package.json");
210 fs::write(
211 &package_json,
212 r#"{
213 "name": "test-workspace",
214 "workspaces": ["packages/*"]
215}
216"#,
217 )
218 .unwrap();
219
220 let mut workspace = NodeWorkspace::new(
221 Some("test-workspace".to_string()),
222 None,
223 package_json.clone(),
224 PathBuf::from("package.json"),
225 );
226
227 workspace.update_version(UpdateType::Patch).await.unwrap();
228
229 let content = read_to_string(&package_json).await.unwrap();
230 assert!(content.contains(r#""version": "0.0.1""#));
231
232 temp_dir.close().unwrap();
233 }
234
235 #[tokio::test]
236 async fn test_node_workspace_update_version_minor() {
237 let temp_dir = TempDir::new().unwrap();
238 let package_json = temp_dir.path().join("package.json");
239 fs::write(
240 &package_json,
241 r#"{
242 "name": "test-workspace",
243 "version": "1.0.0",
244 "workspaces": ["packages/*"]
245}
246"#,
247 )
248 .unwrap();
249
250 let mut workspace = NodeWorkspace::new(
251 Some("test-workspace".to_string()),
252 Some("1.0.0".to_string()),
253 package_json.clone(),
254 PathBuf::from("package.json"),
255 );
256
257 workspace.update_version(UpdateType::Minor).await.unwrap();
258
259 let content = read_to_string(&package_json).await.unwrap();
260 assert!(content.contains(r#""version": "1.1.0""#));
261
262 temp_dir.close().unwrap();
263 }
264
265 #[tokio::test]
266 async fn test_node_workspace_update_version_major() {
267 let temp_dir = TempDir::new().unwrap();
268 let package_json = temp_dir.path().join("package.json");
269 fs::write(
270 &package_json,
271 r#"{
272 "name": "test-workspace",
273 "version": "1.0.0",
274 "workspaces": ["packages/*"]
275}
276"#,
277 )
278 .unwrap();
279
280 let mut workspace = NodeWorkspace::new(
281 Some("test-workspace".to_string()),
282 Some("1.0.0".to_string()),
283 package_json.clone(),
284 PathBuf::from("package.json"),
285 );
286
287 workspace.update_version(UpdateType::Major).await.unwrap();
288
289 let content = read_to_string(&package_json).await.unwrap();
290 assert!(content.contains(r#""version": "2.0.0""#));
291
292 temp_dir.close().unwrap();
293 }
294
295 #[tokio::test]
296 async fn test_node_workspace_update_version_preserves_formatting() {
297 let temp_dir = TempDir::new().unwrap();
298 let package_json = temp_dir.path().join("package.json");
299 fs::write(
300 &package_json,
301 r#"{
302 "name": "test-workspace",
303 "version": "1.0.0",
304 "workspaces": ["packages/*"],
305 "scripts": {
306 "test": "jest"
307 }
308}
309"#,
310 )
311 .unwrap();
312
313 let mut workspace = NodeWorkspace::new(
314 Some("test-workspace".to_string()),
315 Some("1.0.0".to_string()),
316 package_json.clone(),
317 PathBuf::from("package.json"),
318 );
319
320 workspace.update_version(UpdateType::Patch).await.unwrap();
321
322 let content = read_to_string(&package_json).await.unwrap();
323 assert!(content.contains(r#""version": "1.0.1""#));
324 assert!(content.contains(r#""name": "test-workspace""#));
325 assert!(content.contains(r#""workspaces""#));
326 assert!(content.contains(r#""scripts""#));
327
328 temp_dir.close().unwrap();
329 }
330
331 #[test]
332 fn test_node_workspace_dependencies() {
333 let mut workspace = NodeWorkspace::new(
334 Some("test-workspace".to_string()),
335 Some("1.0.0".to_string()),
336 PathBuf::from("/test/package.json"),
337 PathBuf::from("test/package.json"),
338 );
339
340 assert!(workspace.dependencies().is_empty());
342
343 workspace.add_dependency("core");
345 workspace.add_dependency("utils");
346
347 let deps = workspace.dependencies();
348 assert_eq!(deps.len(), 2);
349 assert!(deps.contains("core"));
350 assert!(deps.contains("utils"));
351
352 workspace.add_dependency("core");
354 assert_eq!(workspace.dependencies().len(), 2);
355 }
356}