workspace_node_tools/
packages.rs

1#![allow(clippy::all)]
2
3//! #Packages module
4//!
5//! The `packages` module is used to get the list of packages available in the monorepo.
6use execute::Execute;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use std::path::Path;
12use std::path::PathBuf;
13use std::process::{Command, Stdio};
14use wax::{CandidatePath, Glob, Pattern};
15
16use super::dependency::Node;
17use super::git::get_all_files_changed_since_branch;
18use super::manager::{detect_package_manager, PackageManager};
19use super::paths::get_project_root_path;
20
21#[derive(Debug, Deserialize, Serialize)]
22/// A struct that represents a pnpm workspace.
23struct PnpmInfo {
24    pub name: String,
25    pub path: String,
26    pub private: bool,
27}
28
29#[derive(Debug, Deserialize, Serialize)]
30/// A struct that represents a yarn workspace.
31struct PkgJson {
32    pub workspaces: Vec<String>,
33}
34
35#[cfg(feature = "napi")]
36#[napi(object)]
37#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
38pub struct PackageInfo {
39    pub name: String,
40    pub private: bool,
41    pub package_json_path: String,
42    pub package_path: String,
43    pub package_relative_path: String,
44    pub pkg_json: Value,
45    pub root: bool,
46    pub version: String,
47    pub url: String,
48    pub repository_info: Option<PackageRepositoryInfo>,
49    pub changed_files: Vec<String>,
50    pub dependencies: Vec<DependencyInfo>,
51}
52
53#[cfg(not(feature = "napi"))]
54#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
55/// A struct that represents a package in the monorepo.
56pub struct PackageInfo {
57    pub name: String,
58    pub private: bool,
59    pub package_json_path: String,
60    pub package_path: String,
61    pub package_relative_path: String,
62    pub pkg_json: Value,
63    pub root: bool,
64    pub version: String,
65    pub url: String,
66    pub repository_info: Option<PackageRepositoryInfo>,
67    pub changed_files: Vec<String>,
68    pub dependencies: Vec<DependencyInfo>,
69}
70
71#[cfg(feature = "napi")]
72#[napi(object)]
73#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
74pub struct PackageRepositoryInfo {
75    pub domain: String,
76    pub orga: String,
77    pub project: String,
78}
79
80#[cfg(not(feature = "napi"))]
81#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
82/// A struct that represents the repository information of a package.
83pub struct PackageRepositoryInfo {
84    pub domain: String,
85    pub orga: String,
86    pub project: String,
87}
88
89#[cfg(feature = "napi")]
90#[napi(object)]
91#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
92pub struct DependencyInfo {
93    pub name: String,
94    pub version: String,
95}
96
97#[cfg(not(feature = "napi"))]
98#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
99pub struct DependencyInfo {
100    pub name: String,
101    pub version: String,
102}
103
104impl Node for PackageInfo {
105    type DependencyType = DependencyInfo;
106
107    fn dependencies(&self) -> &[Self::DependencyType] {
108        &self.dependencies[..]
109    }
110
111    fn matches(&self, dependency: &Self::DependencyType) -> bool {
112        let dependency_version = semver::VersionReq::parse(&dependency.version).unwrap();
113        let self_version = semver::Version::parse(&self.version).unwrap();
114
115        // Check that name is an exact match, and that the dependency
116        // requirements are fulfilled by our own version
117        self.name == dependency.name && dependency_version.matches(&self_version)
118    }
119}
120
121impl PackageInfo {
122    /// Pushes a changed file to the list of changed files.
123    pub fn push_changed_file(&mut self, file: String) {
124        self.changed_files.push(file);
125    }
126
127    /// Returns the list of changed files.
128    pub fn get_changed_files(&self) -> Vec<String> {
129        self.changed_files.to_vec()
130    }
131
132    /// Extends the list of changed files with the provided list.
133    pub fn extend_changed_files(&mut self, files: Vec<String>) {
134        let founded_files = files
135            .iter()
136            .filter(|file| file.starts_with(&self.package_path))
137            .map(|file| file.to_string())
138            .collect::<Vec<String>>();
139
140        self.changed_files.extend(founded_files);
141    }
142
143    pub fn push_dependency(&mut self, dependency: DependencyInfo) {
144        self.dependencies.push(dependency);
145    }
146
147    /// Updates the version of the package.
148    pub fn update_version(&mut self, version: String) {
149        self.version = version.to_string();
150        self.pkg_json["version"] = Value::String(version.to_string());
151    }
152
153    /// Updates a dependency version in the package.json file.
154    pub fn update_dependency_version(&mut self, dependency: String, version: String) {
155        let package_json = self.pkg_json.as_object().unwrap();
156
157        if package_json.contains_key("dependencies") {
158            let dependencies = self.pkg_json["dependencies"].as_object_mut().unwrap();
159            let has_dependency = dependencies.contains_key(&dependency);
160
161            if has_dependency {
162                dependencies.insert(dependency, Value::String(version));
163            }
164        }
165    }
166
167    /// Updates a dev dependency version in the package.json file.
168    pub fn update_dev_dependency_version(&mut self, dependency: String, version: String) {
169        let package_json = self.pkg_json.as_object().unwrap();
170
171        if package_json.contains_key("devDependencies") {
172            let dev_dependencies = self.pkg_json["devDependencies"].as_object_mut().unwrap();
173            let has_dependency = dev_dependencies.contains_key(&dependency);
174
175            if has_dependency {
176                dev_dependencies.insert(dependency, Value::String(version));
177            }
178        }
179    }
180
181    /// Write package.json file with the updated version.
182    pub fn write_package_json(&self) {
183        let package_json_file = std::fs::File::create(&self.package_json_path).unwrap();
184        let package_json_writer = std::io::BufWriter::new(package_json_file);
185
186        serde_json::to_writer_pretty(package_json_writer, &self.pkg_json).unwrap();
187    }
188}
189
190/// Returns package info domain, scope and repository name.
191fn get_package_repository_info(url: &String) -> PackageRepositoryInfo {
192    let regex = Regex::new(
193        r"(?m)((?<protocol>[a-z]+)://)((?<domain>[^/]*)/)(?<org>([^/]*)/)(?<project>(.*))(\.git)?",
194    )
195    .unwrap();
196
197    let captures = regex.captures(url).unwrap();
198    let domain = captures.name("domain").unwrap().as_str();
199    let orga = captures.name("org").unwrap().as_str();
200    let project = captures.name("project").unwrap().as_str();
201
202    PackageRepositoryInfo {
203        domain: domain.to_string().replace("/", ""),
204        orga: orga.to_string().replace("/", ""),
205        project: project.to_string().replace("/", "").replace(".git", ""),
206    }
207}
208
209/// Returns the package info of the package with the provided name.
210pub fn get_package_info(package_name: String, cwd: Option<String>) -> Option<PackageInfo> {
211    let project_root = match cwd {
212        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
213        None => get_project_root_path(None).unwrap(),
214    };
215
216    let packages = get_packages(Some(project_root));
217
218    packages
219        .into_iter()
220        .find(|package| package.name == package_name)
221}
222
223/// Get defined package manager in the monorepo
224pub fn get_monorepo_package_manager(cwd: Option<String>) -> Option<PackageManager> {
225    let project_root = match cwd {
226        Some(dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
227        None => get_project_root_path(None).unwrap(),
228    };
229
230    let path = Path::new(&project_root);
231
232    detect_package_manager(&path)
233}
234
235/// Get a list of packages available in the monorepo
236pub fn get_packages(cwd: Option<String>) -> Vec<PackageInfo> {
237    let project_root = match cwd {
238        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
239        None => get_project_root_path(None).unwrap(),
240    };
241    let package_manager = get_monorepo_package_manager(Some(project_root.to_string()));
242
243    let mut packages = match package_manager {
244        Some(PackageManager::Pnpm) => {
245            let path = Path::new(&project_root);
246            let pnpm_workspace = path.join("pnpm-workspace.yaml");
247
248            if !pnpm_workspace.as_path().exists() {
249                panic!("pnpm-workspace.yaml file not found");
250            }
251
252            let mut command = Command::new("pnpm");
253            command
254                .current_dir(&project_root)
255                .arg("list")
256                .arg("-r")
257                .arg("--depth")
258                .arg("-1")
259                .arg("--json");
260
261            command.stdout(Stdio::piped());
262            command.stderr(Stdio::piped());
263
264            let output = command.execute_output().unwrap();
265            let pnpm_info =
266                serde_json::from_slice::<Vec<PnpmInfo>>(&output.stdout.as_slice()).unwrap();
267
268            pnpm_info
269                .iter()
270                .map(|info| {
271                    let ref package_json_path = format!("{}/package.json", info.path);
272
273                    let package_json_file =
274                        std::fs::File::open(package_json_path.to_string()).unwrap();
275                    let package_json_reader = std::io::BufReader::new(package_json_file);
276                    let pkg_json: serde_json::Value =
277                        serde_json::from_reader(package_json_reader).unwrap();
278
279                    let ref version = match pkg_json.get("version") {
280                        Some(version) => {
281                            if version.is_string() {
282                                version.as_str().unwrap().to_string()
283                            } else {
284                                String::from("0.0.0")
285                            }
286                        }
287                        None => String::from("0.0.0"),
288                    };
289
290                    let ref repo_url = match pkg_json.get("repository") {
291                        Some(repository) => {
292                            if repository.is_object() {
293                                let repo = repository.as_object().unwrap();
294
295                                match repo.get("url") {
296                                    Some(url) => url.as_str().unwrap().to_string(),
297                                    None => String::from("https://github.com/my-orga/my-repo"),
298                                }
299                            } else if repository.is_string() {
300                                repository.as_str().unwrap().to_string()
301                            } else {
302                                String::from("https://github.com/my-orga/my-repo")
303                            }
304                        }
305                        None => String::from("https://github.com/my-orga/my-repo"),
306                    };
307
308                    let is_root = info.path == project_root;
309
310                    let relative_path = match is_root {
311                        true => String::from("."),
312                        false => {
313                            let mut rel =
314                                info.path.strip_prefix(&project_root).unwrap().to_string();
315                            rel.remove(0);
316                            rel
317                        }
318                    };
319
320                    let repository_info = get_package_repository_info(repo_url);
321                    let name = &info.name.to_string();
322                    let package_path = &info.path.to_string();
323
324                    PackageInfo {
325                        name: name.to_string(),
326                        private: info.private,
327                        package_json_path: package_json_path.to_string(),
328                        package_path: package_path.to_string(),
329                        package_relative_path: relative_path,
330                        pkg_json,
331                        root: is_root,
332                        version: version.to_string(),
333                        url: String::from(repo_url),
334                        repository_info: Some(repository_info),
335                        changed_files: vec![],
336                        dependencies: vec![],
337                    }
338                })
339                .filter(|pkg| !pkg.root)
340                .collect::<Vec<PackageInfo>>()
341        }
342        Some(PackageManager::Yarn) | Some(PackageManager::Npm) => {
343            let path = Path::new(&project_root);
344            let package_json = path.join("package.json");
345            let mut packages = vec![];
346
347            let package_json = std::fs::read_to_string(&package_json).unwrap();
348
349            let PkgJson { mut workspaces, .. } =
350                serde_json::from_str::<PkgJson>(&package_json).unwrap();
351
352            let globs = workspaces
353                .iter_mut()
354                .map(|workspace| {
355                    return match workspace.ends_with("/*") {
356                        true => {
357                            workspace.push_str("*/package.json");
358                            Glob::new(workspace).unwrap()
359                        }
360                        false => {
361                            workspace.push_str("/package.json");
362                            Glob::new(workspace).unwrap()
363                        }
364                    };
365                })
366                .collect::<Vec<Glob>>();
367
368            let patterns = wax::any(globs).unwrap();
369
370            let glob = Glob::new("**/package.json").unwrap();
371
372            for entry in glob
373                .walk(path)
374                .not([
375                    "**/node_modules/**",
376                    "**/src/**",
377                    "**/dist/**",
378                    "**/tests/**",
379                ])
380                .unwrap()
381            {
382                let entry = entry.unwrap();
383                let rel_path = entry
384                    .path()
385                    .strip_prefix(&path)
386                    .unwrap()
387                    .display()
388                    .to_string();
389                //rel_path.remove(0);
390
391                if patterns.is_match(CandidatePath::from(
392                    entry.path().strip_prefix(&path).unwrap(),
393                )) {
394                    let package_json_file = std::fs::File::open(&entry.path()).unwrap();
395                    let package_json_reader = std::io::BufReader::new(package_json_file);
396                    let pkg_json: serde_json::Value =
397                        serde_json::from_reader(package_json_reader).unwrap();
398
399                    let private = match pkg_json.get("private") {
400                        Some(private) => {
401                            if private.is_boolean() {
402                                private.as_bool().unwrap()
403                            } else {
404                                false
405                            }
406                        }
407                        None => false,
408                    };
409
410                    let ref version = match pkg_json.get("version") {
411                        Some(version) => {
412                            if version.is_string() {
413                                version.as_str().unwrap().to_string()
414                            } else {
415                                String::from("0.0.0")
416                            }
417                        }
418                        None => String::from("0.0.0"),
419                    };
420
421                    let ref repo_url = match pkg_json.get("repository") {
422                        Some(repository) => {
423                            if repository.is_object() {
424                                let repo = repository.as_object().unwrap();
425
426                                match repo.get("url") {
427                                    Some(url) => url.as_str().unwrap().to_string(),
428                                    None => String::from("https://github.com/my-orga/my-repo"),
429                                }
430                            } else if repository.is_string() {
431                                repository.as_str().unwrap().to_string()
432                            } else {
433                                String::from("https://github.com/my-orga/my-repo")
434                            }
435                        }
436                        None => String::from("https://github.com/my-orga/my-repo"),
437                    };
438
439                    let name = match pkg_json.get("name") {
440                        Some(name) => {
441                            if name.is_string() {
442                                name.as_str().unwrap().to_string()
443                            } else {
444                                String::from("unknown")
445                            }
446                        }
447                        None => String::from("unknown"),
448                    };
449
450                    let repository_info = get_package_repository_info(repo_url);
451
452                    let pkg_info = PackageInfo {
453                        name: name.to_string(),
454                        private,
455                        package_json_path: entry.path().to_str().unwrap().to_string(),
456                        package_path: entry.path().parent().unwrap().to_str().unwrap().to_string(),
457                        package_relative_path: rel_path
458                            .strip_suffix("/package.json")
459                            .unwrap()
460                            .to_string(),
461                        pkg_json,
462                        root: false,
463                        version: version.to_string(),
464                        url: repo_url.to_string(),
465                        repository_info: Some(repository_info),
466                        changed_files: vec![],
467                        dependencies: vec![],
468                    };
469
470                    packages.push(pkg_info);
471                }
472            }
473
474            packages
475        }
476        Some(PackageManager::Bun) => vec![],
477        None => vec![],
478    };
479
480    for pkg in packages.iter_mut() {
481        let pkg_json: serde_json::Value = serde_json::from_value(pkg.pkg_json.clone()).unwrap();
482        let package_json = pkg_json.as_object().unwrap();
483
484        if package_json.contains_key("dependencies") {
485            let deps = package_json.get("dependencies").unwrap();
486
487            if deps.is_object() {
488                let deps = deps.as_object().unwrap();
489
490                for (name, version) in deps {
491                    pkg.push_dependency(DependencyInfo {
492                        name: name.to_string(),
493                        version: version.as_str().unwrap().to_string(),
494                    });
495                }
496            }
497        }
498
499        if package_json.contains_key("devDependencies") {
500            let deps = package_json.get("devDependencies").unwrap();
501
502            if deps.is_object() {
503                let deps = deps.as_object().unwrap();
504
505                for (name, version) in deps {
506                    pkg.push_dependency(DependencyInfo {
507                        name: name.to_string(),
508                        version: version.as_str().unwrap().to_string(),
509                    });
510                }
511            }
512        }
513    }
514
515    packages
516}
517
518/// Get a list of packages that have changed since a given sha
519pub fn get_changed_packages(sha: Option<String>, cwd: Option<String>) -> Vec<PackageInfo> {
520    let root = match cwd {
521        Some(ref dir) => get_project_root_path(Some(PathBuf::from(dir))).unwrap(),
522        None => get_project_root_path(None).unwrap(),
523    };
524
525    let packages = get_packages(Some(root.to_string()));
526    let since = sha.unwrap_or(String::from("main"));
527
528    let changed_files =
529        get_all_files_changed_since_branch(&packages, &since, Some(root.to_string()));
530
531    packages
532        .iter()
533        .flat_map(|pkg| {
534            let mut pkgs = changed_files
535                .iter()
536                .filter(|file| file.starts_with(&pkg.package_path))
537                .map(|file| {
538                    let mut pkg_info: PackageInfo = pkg.to_owned();
539                    pkg_info.push_changed_file(file.to_string());
540
541                    pkg_info
542                })
543                .collect::<Vec<PackageInfo>>();
544
545            pkgs.dedup_by(|a, b| a.name == b.name);
546
547            pkgs
548        })
549        .collect::<Vec<PackageInfo>>()
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    use crate::manager::PackageManager;
557    use crate::utils::create_test_monorepo;
558    use std::fs::{remove_dir_all, File};
559    use std::io::Write;
560    use std::path::PathBuf;
561    use std::process::Command;
562
563    fn create_package_change(monorepo_dir: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
564        let js_path = monorepo_dir.join("packages/package-a/index.js");
565
566        let branch = Command::new("git")
567            .current_dir(&monorepo_dir)
568            .arg("checkout")
569            .arg("-b")
570            .arg("feat/message")
571            .stdout(Stdio::piped())
572            .spawn()
573            .expect("Git branch problem");
574
575        branch.wait_with_output()?;
576
577        let mut js_file = File::create(&js_path)?;
578        js_file
579            .write_all(r#"export const message = "hello";"#.as_bytes())
580            .unwrap();
581
582        let add = Command::new("git")
583            .current_dir(&monorepo_dir)
584            .arg("add")
585            .arg(".")
586            .stdout(Stdio::piped())
587            .spawn()
588            .expect("Git add problem");
589
590        add.wait_with_output()?;
591
592        let commit = Command::new("git")
593            .current_dir(&monorepo_dir)
594            .arg("commit")
595            .arg("-m")
596            .arg("feat: message to the world")
597            .stdout(Stdio::piped())
598            .spawn()
599            .expect("Git commit problem");
600
601        commit.wait_with_output()?;
602
603        Ok(())
604    }
605
606    #[test]
607    fn monorepo_package_manager() -> Result<(), Box<dyn std::error::Error>> {
608        let ref monorepo_dir = create_test_monorepo(&PackageManager::Pnpm)?;
609        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
610
611        let package_manager = get_monorepo_package_manager(project_root);
612
613        assert_eq!(package_manager, Some(PackageManager::Pnpm));
614        remove_dir_all(&monorepo_dir)?;
615        Ok(())
616    }
617
618    #[test]
619    fn npm_get_packages() -> Result<(), Box<dyn std::error::Error>> {
620        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
621        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
622
623        let packages = get_packages(project_root);
624
625        assert_eq!(packages.len(), 4);
626        remove_dir_all(&monorepo_dir)?;
627        Ok(())
628    }
629
630    #[test]
631    fn yarn_get_packages() -> Result<(), Box<dyn std::error::Error>> {
632        let ref monorepo_dir = create_test_monorepo(&PackageManager::Yarn)?;
633        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
634
635        let packages = get_packages(project_root);
636
637        assert_eq!(packages.len(), 4);
638        remove_dir_all(&monorepo_dir)?;
639        Ok(())
640    }
641
642    #[test]
643    fn pnpm_get_packages() -> Result<(), Box<dyn std::error::Error>> {
644        let ref monorepo_dir = create_test_monorepo(&PackageManager::Pnpm)?;
645        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
646
647        let packages = get_packages(project_root);
648
649        assert_eq!(packages.len(), 4);
650        remove_dir_all(&monorepo_dir)?;
651        Ok(())
652    }
653
654    #[test]
655    fn monorepo_get_changed_packages() -> Result<(), Box<dyn std::error::Error>> {
656        let ref monorepo_dir = create_test_monorepo(&PackageManager::Npm)?;
657        let project_root = get_project_root_path(Some(monorepo_dir.to_path_buf()));
658
659        create_package_change(monorepo_dir)?;
660
661        let packages = get_changed_packages(Some("main".to_string()), project_root);
662        let package = packages.first();
663
664        let changed_files = package.unwrap().get_changed_files();
665
666        assert_eq!(packages.len(), 1);
667        assert_eq!(changed_files.len(), 1);
668        remove_dir_all(&monorepo_dir)?;
669        Ok(())
670    }
671}