1#![allow(clippy::all)]
2
3use 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)]
22struct PnpmInfo {
24 pub name: String,
25 pub path: String,
26 pub private: bool,
27}
28
29#[derive(Debug, Deserialize, Serialize)]
30struct 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)]
55pub 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)]
82pub 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 self.name == dependency.name && dependency_version.matches(&self_version)
118 }
119}
120
121impl PackageInfo {
122 pub fn push_changed_file(&mut self, file: String) {
124 self.changed_files.push(file);
125 }
126
127 pub fn get_changed_files(&self) -> Vec<String> {
129 self.changed_files.to_vec()
130 }
131
132 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 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 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 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 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
190fn 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
209pub 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
223pub 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
235pub 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 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
518pub 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}