workspace_node_tools/
changes.rs

1#![allow(clippy::all)]
2
3//! # Changes
4//!
5//! This module is responsible for managing the changes in the monorepo.
6//! The changes are stored in a `.changes.json` file in the root of the project.
7//!
8//! # Example
9//! ```json
10//! {
11//!   "message": "chore(release): release new version",
12//!   "gitUserName": "Git Bot",
13//!   "gitUserEmail": "git.bot@domain.com",
14//!   "changes": {
15//!       "BRANCH-NAME": [{
16//!           "package": "xxx",
17//!           "releaseAs": "patch",
18//!           "deploy": ["int"]
19//!       }],
20//!   }
21//!}
22//!```
23use serde::{Deserialize, Serialize};
24use std::io::BufWriter;
25use std::{
26    collections::BTreeMap,
27    fs::File,
28    io::BufReader,
29    path::{Path, PathBuf},
30};
31
32use crate::bumps::Bump;
33
34use super::git::git_current_branch;
35use super::paths::get_project_root_path;
36
37/// Dynamic data structure to store changes
38type ChangesData = BTreeMap<String, Vec<Change>>;
39
40#[cfg(not(feature = "napi"))]
41#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
42/// Options to initialize the changes file
43pub struct ChangesOptions {
44    pub message: Option<String>,
45    pub git_user_name: Option<String>,
46    pub git_user_email: Option<String>,
47}
48
49#[cfg(feature = "napi")]
50#[napi(object)]
51#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
52pub struct ChangesOptions {
53    pub message: Option<String>,
54    pub git_user_name: Option<String>,
55    pub git_user_email: Option<String>,
56}
57
58#[cfg(not(feature = "napi"))]
59#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
60/// Data structure to store changes file
61pub struct ChangesFileData {
62    pub message: Option<String>,
63    pub git_user_name: Option<String>,
64    pub git_user_email: Option<String>,
65    pub changes: ChangesData,
66}
67
68#[cfg(feature = "napi")]
69#[napi(object)]
70#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
71pub struct ChangesFileData {
72    pub message: Option<String>,
73    pub git_user_name: Option<String>,
74    pub git_user_email: Option<String>,
75    pub changes: ChangesData,
76}
77
78#[cfg(not(feature = "napi"))]
79#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
80/// Data structure to store changes
81pub struct Changes {
82    pub changes: ChangesData,
83}
84
85#[cfg(feature = "napi")]
86#[napi(object)]
87#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
88pub struct Changes {
89    pub changes: ChangesData,
90}
91
92#[cfg(not(feature = "napi"))]
93#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
94/// Data structure to store a change
95pub struct Change {
96    pub package: String,
97    pub release_as: Bump,
98    pub deploy: Vec<String>,
99}
100
101#[cfg(feature = "napi")]
102#[napi(object)]
103#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
104pub struct Change {
105    pub package: String,
106    pub release_as: Bump,
107    pub deploy: Vec<String>,
108}
109
110/// Initialize the changes file. If the file does not exist, it will create it with the default message.
111/// If the file exists, it will return the content of the file.
112pub fn init_changes(
113    cwd: Option<String>,
114    change_options: &Option<ChangesOptions>,
115) -> ChangesFileData {
116    let ref root = match cwd {
117        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
118        None => get_project_root_path(None).unwrap(),
119    };
120
121    let root_path = Path::new(root);
122    let ref changes_path = root_path.join(String::from(".changes.json"));
123
124    if changes_path.exists() {
125        let changes_file = File::open(changes_path).unwrap();
126        let changes_reader = BufReader::new(changes_file);
127
128        let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
129        return changes;
130    } else {
131        let message = match &change_options {
132            Some(options) => match &options.message {
133                Some(msg) => msg.to_string(),
134                None => String::from("chore(release): release new version"),
135            },
136            None => String::from("chore(release): release new version"),
137        };
138
139        let username = match &change_options {
140            Some(options) => match &options.git_user_name {
141                Some(name) => name.to_string(),
142                None => String::from("Git Bot"),
143            },
144            None => String::from("Git Bot"),
145        };
146
147        let email = match &change_options {
148            Some(options) => match &options.git_user_email {
149                Some(email) => email.to_string(),
150                None => String::from("git.bot@domain.com"),
151            },
152            None => String::from("git.bot@domain.com"),
153        };
154
155        let changes = ChangesFileData {
156            message: Some(message),
157            git_user_name: Some(username),
158            git_user_email: Some(email),
159            changes: ChangesData::new(),
160        };
161
162        let changes_file = File::create(changes_path).unwrap();
163        let changes_writer = BufWriter::new(changes_file);
164
165        serde_json::to_writer_pretty(changes_writer, &changes).unwrap();
166
167        return changes;
168    }
169}
170
171/// Add a change to the changes file in the root of the project.
172pub fn add_change(change: &Change, cwd: Option<String>) -> bool {
173    let ref root = match cwd {
174        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
175        None => get_project_root_path(None).unwrap(),
176    };
177
178    let root_path = Path::new(root);
179    let ref changes_path = root_path.join(String::from(".changes.json"));
180
181    if changes_path.exists() {
182        let changes_file = File::open(changes_path).unwrap();
183        let changes_reader = BufReader::new(changes_file);
184
185        let mut changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
186
187        let current_branch = git_current_branch(Some(root.to_string()));
188
189        let branch = match current_branch {
190            Some(branch) => branch,
191            None => String::from("main"),
192        };
193
194        if changes.changes.contains_key(&branch) {
195            let branch_changes = changes.changes.get_mut(&branch).unwrap();
196
197            let pkg_already_added = branch_changes
198                .iter()
199                .any(|branch_change| branch_change.package.as_str() == change.package.as_str());
200
201            if !pkg_already_added {
202                branch_changes.push(Change {
203                    package: change.package.to_string(),
204                    release_as: change.release_as,
205                    deploy: change.deploy.to_vec(),
206                });
207            }
208        } else {
209            changes.changes.insert(
210                branch,
211                vec![Change {
212                    package: change.package.to_string(),
213                    release_as: change.release_as,
214                    deploy: change.deploy.to_vec(),
215                }],
216            );
217        }
218
219        let changes_file = File::create(changes_path).unwrap();
220        let changes_writer = BufWriter::new(changes_file);
221
222        serde_json::to_writer_pretty(changes_writer, &changes).unwrap();
223
224        return true;
225    }
226
227    false
228}
229
230/// Remove a change from the changes file in the root of the project.
231pub fn remove_change(branch_name: String, cwd: Option<String>) -> bool {
232    let ref root = match cwd {
233        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
234        None => get_project_root_path(None).unwrap(),
235    };
236
237    let root_path = Path::new(root);
238    let ref changes_path = root_path.join(String::from(".changes.json"));
239
240    if changes_path.exists() {
241        let changes_file = File::open(changes_path).unwrap();
242        let changes_reader = BufReader::new(changes_file);
243
244        let mut changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
245
246        if changes.changes.contains_key(&branch_name) {
247            changes.changes.remove(&branch_name);
248
249            let changes_file = File::create(changes_path).unwrap();
250            let changes_writer = BufWriter::new(changes_file);
251
252            serde_json::to_writer_pretty(changes_writer, &changes).unwrap();
253
254            return true;
255        }
256    }
257
258    false
259}
260
261/// Get all changes from the changes file in the root of the project.
262pub fn get_changes(cwd: Option<String>) -> Changes {
263    let ref root = match cwd {
264        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
265        None => get_project_root_path(None).unwrap(),
266    };
267
268    let root_path = Path::new(root);
269    let ref changes_path = root_path.join(String::from(".changes.json"));
270
271    if changes_path.exists() {
272        let changes_file = File::open(changes_path).unwrap();
273        let changes_reader = BufReader::new(changes_file);
274
275        let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
276
277        return Changes {
278            changes: changes.changes,
279        };
280    }
281
282    Changes {
283        changes: ChangesData::new(),
284    }
285}
286
287/// Get all changes for a specific branch from the changes file in the root of the project.
288pub fn get_change(branch: String, cwd: Option<String>) -> Vec<Change> {
289    let ref root = match cwd {
290        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
291        None => get_project_root_path(None).unwrap(),
292    };
293
294    let root_path = Path::new(root);
295    let ref changes_path = root_path.join(String::from(".changes.json"));
296
297    if changes_path.exists() {
298        let changes_file = File::open(changes_path).unwrap();
299        let changes_reader = BufReader::new(changes_file);
300
301        let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
302
303        if changes.changes.contains_key(&branch) {
304            return changes.changes.get(&branch).unwrap().to_vec();
305        } else {
306            return vec![];
307        }
308    }
309
310    vec![]
311}
312
313/// Get a change for a specific package from the changes file in the root of the project.
314pub fn get_package_change(
315    package_name: String,
316    branch: String,
317    cwd: Option<String>,
318) -> Option<Change> {
319    let ref root = match cwd {
320        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
321        None => get_project_root_path(None).unwrap(),
322    };
323
324    let root_path = Path::new(root);
325    let ref changes_path = root_path.join(String::from(".changes.json"));
326
327    if changes_path.exists() {
328        let changes_file = File::open(changes_path).unwrap();
329        let changes_reader = BufReader::new(changes_file);
330
331        let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
332
333        if changes.changes.contains_key(&branch) {
334            let branch_changes = changes.changes.get(&branch).unwrap();
335
336            let package_change = branch_changes
337                .iter()
338                .find(|change| change.package == package_name);
339
340            if let Some(change) = package_change {
341                return Some(change.clone());
342            }
343
344            return None;
345        }
346
347        return None;
348    }
349
350    None
351}
352
353/// Check if a change exists in the changes file in the root of the project.
354pub fn change_exist(branch: String, packages_name: Vec<String>, cwd: Option<String>) -> bool {
355    let ref root = match cwd {
356        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
357        None => get_project_root_path(None).unwrap(),
358    };
359
360    let root_path = Path::new(root);
361    let ref changes_path = root_path.join(String::from(".changes.json"));
362
363    if changes_path.exists() {
364        let changes_file = File::open(changes_path).unwrap();
365        let changes_reader = BufReader::new(changes_file);
366
367        let changes: ChangesFileData = serde_json::from_reader(changes_reader).unwrap();
368
369        if changes.changes.contains_key(&branch) {
370            let branch_changes = changes.changes.get(&branch).unwrap();
371
372            let existing_packages_changes = branch_changes
373                .iter()
374                .map(|change| change.package.to_string())
375                .collect::<Vec<String>>();
376
377            let package_names_diff = packages_name
378                .iter()
379                .filter_map(|p| {
380                    if existing_packages_changes.contains(&p) {
381                        None
382                    } else {
383                        Some(p.to_string())
384                    }
385                })
386                .collect::<Vec<String>>();
387
388            match package_names_diff.len() {
389                0 => return true,
390                _ => return false,
391            };
392        }
393    }
394
395    false
396}
397
398/// Check if a changes file exists in the root of the project.
399pub fn changes_file_exist(cwd: Option<String>) -> bool {
400    let ref root = match cwd {
401        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
402        None => get_project_root_path(None).unwrap(),
403    };
404
405    let root_path = Path::new(root);
406    let ref changes_path = root_path.join(String::from(".changes.json"));
407
408    changes_path.exists()
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    use crate::manager::PackageManager;
416    use crate::paths::get_project_root_path;
417    use crate::utils::create_test_monorepo;
418    use std::fs::remove_dir_all;
419
420    #[test]
421    fn test_init_changes() -> Result<(), Box<dyn std::error::Error>> {
422        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
423        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
424
425        let ref root = project_root.unwrap().to_string();
426
427        let changes_data_file = init_changes(Some(root.to_string()), &None);
428        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
429
430        assert_eq!(changes_data_file.message.is_some(), true);
431        assert_eq!(changes_path.is_file(), true);
432        remove_dir_all(&monorepo_dir)?;
433        Ok(())
434    }
435
436    #[test]
437    fn test_add_change() -> Result<(), Box<dyn std::error::Error>> {
438        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
439        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
440
441        let ref root = project_root.unwrap().to_string();
442
443        let change = Change {
444            package: String::from("test-package"),
445            release_as: Bump::Major,
446            deploy: vec![String::from("production")],
447        };
448
449        init_changes(Some(root.to_string()), &None);
450
451        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
452        let result = add_change(&change, Some(root.to_string()));
453
454        assert_eq!(result, true);
455        assert_eq!(changes_path.is_file(), true);
456        remove_dir_all(&monorepo_dir)?;
457        Ok(())
458    }
459
460    #[test]
461    fn test_duplicate_add_change() -> Result<(), Box<dyn std::error::Error>> {
462        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
463        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
464
465        let ref root = project_root.unwrap().to_string();
466
467        let change = Change {
468            package: String::from("test-package"),
469            release_as: Bump::Major,
470            deploy: vec![String::from("production")],
471        };
472
473        init_changes(Some(root.to_string()), &None);
474
475        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
476        add_change(&change, Some(root.to_string()));
477        add_change(&change, Some(root.to_string()));
478
479        let changes = get_changes(Some(root.to_string()));
480        let length = changes.changes["main"].len();
481
482        assert_eq!(length, 1);
483        assert_eq!(changes_path.is_file(), true);
484        remove_dir_all(&monorepo_dir)?;
485        Ok(())
486    }
487
488    #[test]
489    fn test_remove_change() -> Result<(), Box<dyn std::error::Error>> {
490        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
491        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
492
493        let ref root = project_root.unwrap().to_string();
494
495        let change = Change {
496            package: String::from("test-package"),
497            release_as: Bump::Major,
498            deploy: vec![String::from("production")],
499        };
500
501        init_changes(Some(root.to_string()), &None);
502
503        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
504        add_change(&change, Some(root.to_string()));
505
506        let result = remove_change(String::from("main"), Some(root.to_string()));
507
508        assert_eq!(result, true);
509        assert_eq!(changes_path.is_file(), true);
510        remove_dir_all(&monorepo_dir)?;
511        Ok(())
512    }
513
514    #[test]
515    fn test_get_changes() -> Result<(), Box<dyn std::error::Error>> {
516        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
517        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
518
519        let ref root = project_root.unwrap().to_string();
520
521        let change = Change {
522            package: String::from("test-package"),
523            release_as: Bump::Major,
524            deploy: vec![String::from("production")],
525        };
526
527        init_changes(Some(root.to_string()), &None);
528
529        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
530        add_change(&change, Some(root.to_string()));
531
532        let changes = get_changes(Some(root.to_string()));
533
534        assert_eq!(changes.changes.contains_key(&String::from("main")), true);
535        assert_eq!(changes.changes.get(&String::from("main")).unwrap().len(), 1);
536        assert_eq!(changes_path.is_file(), true);
537        remove_dir_all(&monorepo_dir)?;
538        Ok(())
539    }
540
541    #[test]
542    fn test_get_change() -> Result<(), Box<dyn std::error::Error>> {
543        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
544        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
545
546        let ref root = project_root.unwrap().to_string();
547
548        let change = Change {
549            package: String::from("test-package"),
550            release_as: Bump::Major,
551            deploy: vec![String::from("production")],
552        };
553
554        init_changes(Some(root.to_string()), &None);
555
556        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
557        add_change(&change, Some(root.to_string()));
558
559        let changes = get_change(String::from("main"), Some(root.to_string()));
560
561        assert_eq!(changes.len(), 1);
562        assert_eq!(changes_path.is_file(), true);
563        remove_dir_all(&monorepo_dir)?;
564        Ok(())
565    }
566
567    #[test]
568    fn test_change_exist() -> Result<(), Box<dyn std::error::Error>> {
569        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
570        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
571
572        let ref root = project_root.unwrap().to_string();
573
574        let change = Change {
575            package: String::from("test-package"),
576            release_as: Bump::Major,
577            deploy: vec![String::from("production")],
578        };
579
580        init_changes(Some(root.to_string()), &None);
581
582        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
583        add_change(&change, Some(root.to_string()));
584
585        let result = change_exist(
586            String::from("main"),
587            vec!["test-package".to_string()],
588            Some(root.to_string()),
589        );
590
591        assert_eq!(result, true);
592        assert_eq!(changes_path.is_file(), true);
593        remove_dir_all(&monorepo_dir)?;
594        Ok(())
595    }
596
597    #[test]
598    fn test_multiple_change_exist() {
599        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm).unwrap();
600        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf())).unwrap();
601
602        let ref root = project_root.to_string();
603
604        let change_package_a = Change {
605            package: String::from("@scope/package-a"),
606            release_as: Bump::Major,
607            deploy: vec![String::from("production")],
608        };
609
610        let change_package_b = Change {
611            package: String::from("@scope/package-b"),
612            release_as: Bump::Major,
613            deploy: vec![String::from("production")],
614        };
615
616        init_changes(Some(root.to_string()), &None);
617
618        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
619        add_change(&change_package_a, Some(root.to_string()));
620        add_change(&change_package_b, Some(root.to_string()));
621
622        let result = change_exist(
623            String::from("main"),
624            vec![
625                "@scope/package-a".to_string(),
626                "@scope/package-b".to_string(),
627            ],
628            Some(root.to_string()),
629        );
630
631        assert_eq!(result, true);
632        assert_eq!(changes_path.is_file(), true);
633        remove_dir_all(&monorepo_dir).unwrap();
634    }
635
636    #[test]
637    fn test_change_exist_with_new_package() -> Result<(), Box<dyn std::error::Error>> {
638        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
639        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
640
641        let ref root = project_root.unwrap().to_string();
642
643        let change = Change {
644            package: String::from("test-package"),
645            release_as: Bump::Major,
646            deploy: vec![String::from("production")],
647        };
648
649        init_changes(Some(root.to_string()), &None);
650
651        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
652        add_change(&change, Some(root.to_string()));
653
654        let result = change_exist(
655            String::from("main"),
656            vec!["test-package".to_string(), "@scope/package-a".to_string()],
657            Some(root.to_string()),
658        );
659
660        assert_eq!(result, false);
661        assert_eq!(changes_path.is_file(), true);
662        remove_dir_all(&monorepo_dir)?;
663        Ok(())
664    }
665
666    #[test]
667    fn test_change_exist_with_empty_packages() -> Result<(), Box<dyn std::error::Error>> {
668        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
669        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
670
671        let ref root = project_root.unwrap().to_string();
672
673        init_changes(Some(root.to_string()), &None);
674
675        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
676
677        let result = change_exist(
678            String::from("main"),
679            vec!["test-package".to_string(), "@scope/package-a".to_string()],
680            Some(root.to_string()),
681        );
682
683        assert_eq!(result, false);
684        assert_eq!(changes_path.is_file(), true);
685        remove_dir_all(&monorepo_dir)?;
686        Ok(())
687    }
688
689    #[test]
690    fn test_changes_file_exist() -> Result<(), Box<dyn std::error::Error>> {
691        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
692        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
693
694        let ref root = project_root.unwrap().to_string();
695
696        let ref changes_path = monorepo_dir.join(String::from(".changes.json"));
697        let result = changes_file_exist(Some(root.to_string()));
698
699        assert_eq!(result, false);
700        assert_eq!(changes_path.is_file(), false);
701        remove_dir_all(&monorepo_dir)?;
702        Ok(())
703    }
704}