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