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 set_name(&mut self, name: String) {
104 self.name = Some(name);
105 }
106
107 fn default_publish_command(&self) -> String {
108 detect_package_manager_recursive(&self.path)
109 .publish_command()
110 .to_string()
111 }
112
113 fn dependencies(&self) -> &HashSet<String> {
114 &self.dependencies
115 }
116
117 fn add_dependency(&mut self, dependency: &str) {
118 self.dependencies.insert(dependency.to_string());
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use changepacks_core::UpdateType;
126 use std::fs;
127 use tempfile::TempDir;
128 use tokio::fs::read_to_string;
129
130 #[tokio::test]
131 async fn test_node_workspace_new() {
132 let workspace = NodeWorkspace::new(
133 Some("test-workspace".to_string()),
134 Some("1.0.0".to_string()),
135 PathBuf::from("/test/package.json"),
136 PathBuf::from("test/package.json"),
137 );
138
139 assert_eq!(workspace.name(), Some("test-workspace"));
140 assert_eq!(workspace.version(), Some("1.0.0"));
141 assert_eq!(workspace.path(), PathBuf::from("/test/package.json"));
142 assert_eq!(
143 workspace.relative_path(),
144 PathBuf::from("test/package.json")
145 );
146 assert_eq!(workspace.language(), Language::Node);
147 assert!(!workspace.is_changed());
148 assert_eq!(workspace.default_publish_command(), "npm publish");
149 }
150
151 #[tokio::test]
152 async fn test_node_workspace_new_without_name_and_version() {
153 let workspace = NodeWorkspace::new(
154 None,
155 None,
156 PathBuf::from("/test/package.json"),
157 PathBuf::from("test/package.json"),
158 );
159
160 assert_eq!(workspace.name(), None);
161 assert_eq!(workspace.version(), None);
162 }
163
164 #[tokio::test]
165 async fn test_node_workspace_set_changed() {
166 let mut workspace = NodeWorkspace::new(
167 Some("test-workspace".to_string()),
168 Some("1.0.0".to_string()),
169 PathBuf::from("/test/package.json"),
170 PathBuf::from("test/package.json"),
171 );
172
173 assert!(!workspace.is_changed());
174 workspace.set_changed(true);
175 assert!(workspace.is_changed());
176 workspace.set_changed(false);
177 assert!(!workspace.is_changed());
178 }
179
180 #[tokio::test]
181 async fn test_node_workspace_update_version_with_existing_version() {
182 let temp_dir = TempDir::new().unwrap();
183 let package_json = temp_dir.path().join("package.json");
184 fs::write(
185 &package_json,
186 r#"{
187 "name": "test-workspace",
188 "version": "1.0.0",
189 "workspaces": ["packages/*"]
190}
191"#,
192 )
193 .unwrap();
194
195 let mut workspace = NodeWorkspace::new(
196 Some("test-workspace".to_string()),
197 Some("1.0.0".to_string()),
198 package_json.clone(),
199 PathBuf::from("package.json"),
200 );
201
202 workspace.update_version(UpdateType::Patch).await.unwrap();
203
204 let content = read_to_string(&package_json).await.unwrap();
205 assert!(content.contains(r#""version": "1.0.1""#));
206
207 temp_dir.close().unwrap();
208 }
209
210 #[tokio::test]
211 async fn test_node_workspace_update_version_without_version() {
212 let temp_dir = TempDir::new().unwrap();
213 let package_json = temp_dir.path().join("package.json");
214 fs::write(
215 &package_json,
216 r#"{
217 "name": "test-workspace",
218 "workspaces": ["packages/*"]
219}
220"#,
221 )
222 .unwrap();
223
224 let mut workspace = NodeWorkspace::new(
225 Some("test-workspace".to_string()),
226 None,
227 package_json.clone(),
228 PathBuf::from("package.json"),
229 );
230
231 workspace.update_version(UpdateType::Patch).await.unwrap();
232
233 let content = read_to_string(&package_json).await.unwrap();
234 assert!(content.contains(r#""version": "0.0.1""#));
235
236 temp_dir.close().unwrap();
237 }
238
239 #[tokio::test]
240 async fn test_node_workspace_update_version_minor() {
241 let temp_dir = TempDir::new().unwrap();
242 let package_json = temp_dir.path().join("package.json");
243 fs::write(
244 &package_json,
245 r#"{
246 "name": "test-workspace",
247 "version": "1.0.0",
248 "workspaces": ["packages/*"]
249}
250"#,
251 )
252 .unwrap();
253
254 let mut workspace = NodeWorkspace::new(
255 Some("test-workspace".to_string()),
256 Some("1.0.0".to_string()),
257 package_json.clone(),
258 PathBuf::from("package.json"),
259 );
260
261 workspace.update_version(UpdateType::Minor).await.unwrap();
262
263 let content = read_to_string(&package_json).await.unwrap();
264 assert!(content.contains(r#""version": "1.1.0""#));
265
266 temp_dir.close().unwrap();
267 }
268
269 #[tokio::test]
270 async fn test_node_workspace_update_version_major() {
271 let temp_dir = TempDir::new().unwrap();
272 let package_json = temp_dir.path().join("package.json");
273 fs::write(
274 &package_json,
275 r#"{
276 "name": "test-workspace",
277 "version": "1.0.0",
278 "workspaces": ["packages/*"]
279}
280"#,
281 )
282 .unwrap();
283
284 let mut workspace = NodeWorkspace::new(
285 Some("test-workspace".to_string()),
286 Some("1.0.0".to_string()),
287 package_json.clone(),
288 PathBuf::from("package.json"),
289 );
290
291 workspace.update_version(UpdateType::Major).await.unwrap();
292
293 let content = read_to_string(&package_json).await.unwrap();
294 assert!(content.contains(r#""version": "2.0.0""#));
295
296 temp_dir.close().unwrap();
297 }
298
299 #[tokio::test]
300 async fn test_node_workspace_update_version_preserves_formatting() {
301 let temp_dir = TempDir::new().unwrap();
302 let package_json = temp_dir.path().join("package.json");
303 fs::write(
304 &package_json,
305 r#"{
306 "name": "test-workspace",
307 "version": "1.0.0",
308 "workspaces": ["packages/*"],
309 "scripts": {
310 "test": "jest"
311 }
312}
313"#,
314 )
315 .unwrap();
316
317 let mut workspace = NodeWorkspace::new(
318 Some("test-workspace".to_string()),
319 Some("1.0.0".to_string()),
320 package_json.clone(),
321 PathBuf::from("package.json"),
322 );
323
324 workspace.update_version(UpdateType::Patch).await.unwrap();
325
326 let content = read_to_string(&package_json).await.unwrap();
327 assert!(content.contains(r#""version": "1.0.1""#));
328 assert!(content.contains(r#""name": "test-workspace""#));
329 assert!(content.contains(r#""workspaces""#));
330 assert!(content.contains(r#""scripts""#));
331
332 temp_dir.close().unwrap();
333 }
334
335 #[test]
336 fn test_node_workspace_dependencies() {
337 let mut workspace = NodeWorkspace::new(
338 Some("test-workspace".to_string()),
339 Some("1.0.0".to_string()),
340 PathBuf::from("/test/package.json"),
341 PathBuf::from("test/package.json"),
342 );
343
344 assert!(workspace.dependencies().is_empty());
346
347 workspace.add_dependency("core");
349 workspace.add_dependency("utils");
350
351 let deps = workspace.dependencies();
352 assert_eq!(deps.len(), 2);
353 assert!(deps.contains("core"));
354 assert!(deps.contains("utils"));
355
356 workspace.add_dependency("core");
358 assert_eq!(workspace.dependencies().len(), 2);
359 }
360
361 #[test]
362 fn test_set_name() {
363 let mut workspace = NodeWorkspace::new(
364 None,
365 Some("1.0.0".to_string()),
366 PathBuf::from("/test/package.json"),
367 PathBuf::from("package.json"),
368 );
369 assert_eq!(workspace.name(), None);
370 workspace.set_name("my-project".to_string());
371 assert_eq!(workspace.name(), Some("my-project"));
372 }
373}