Skip to main content

changepacks_node/
workspace.rs

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        // Initially empty
345        assert!(workspace.dependencies().is_empty());
346
347        // Add dependencies
348        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        // Adding duplicate should not increase count
357        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}