Skip to main content

changepacks_core/
project.rs

1use std::{
2    cmp::Ordering,
3    collections::HashSet,
4    fmt::{Debug, Display},
5    path::Path,
6};
7
8use anyhow::Result;
9use colored::Colorize;
10
11use crate::{config::Config, package::Package, update_type::UpdateType, workspace::Workspace};
12
13/// Discriminated union of Package (single project) or Workspace (monorepo root).
14///
15/// Provides unified interface for operations on both package and workspace projects,
16/// delegating to the appropriate trait implementation. Workspaces sort before packages
17/// in ordering comparisons.
18#[derive(Debug)]
19pub enum Project {
20    /// Monorepo workspace root containing multiple packages
21    Workspace(Box<dyn Workspace>),
22    /// Single versioned package
23    Package(Box<dyn Package>),
24}
25
26impl Project {
27    #[must_use]
28    pub fn name(&self) -> Option<&str> {
29        match self {
30            Self::Workspace(workspace) => workspace.name(),
31            Self::Package(package) => package.name(),
32        }
33    }
34
35    #[must_use]
36    pub fn version(&self) -> Option<&str> {
37        match self {
38            Self::Workspace(workspace) => workspace.version(),
39            Self::Package(package) => package.version(),
40        }
41    }
42    #[must_use]
43    pub fn path(&self) -> &Path {
44        match self {
45            Self::Workspace(workspace) => workspace.path(),
46            Self::Package(package) => package.path(),
47        }
48    }
49
50    #[must_use]
51    pub fn relative_path(&self) -> &Path {
52        match self {
53            Self::Workspace(workspace) => workspace.relative_path(),
54            Self::Package(package) => package.relative_path(),
55        }
56    }
57
58    /// # Errors
59    /// Returns error if the underlying `update_version` call fails.
60    pub async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
61        match self {
62            Self::Workspace(workspace) => workspace.update_version(update_type).await?,
63            Self::Package(package) => package.update_version(update_type).await?,
64        }
65        Ok(())
66    }
67
68    /// # Errors
69    /// Returns error if the underlying `check_changed` call fails.
70    pub fn check_changed(&mut self, path: &Path) -> Result<()> {
71        match self {
72            Self::Workspace(workspace) => workspace.check_changed(path)?,
73            Self::Package(package) => package.check_changed(path)?,
74        }
75        Ok(())
76    }
77
78    #[must_use]
79    pub fn is_changed(&self) -> bool {
80        match self {
81            Self::Workspace(workspace) => workspace.is_changed(),
82            Self::Package(package) => package.is_changed(),
83        }
84    }
85
86    #[must_use]
87    pub fn dependencies(&self) -> &HashSet<String> {
88        match self {
89            Self::Workspace(workspace) => workspace.dependencies(),
90            Self::Package(package) => package.dependencies(),
91        }
92    }
93
94    pub fn add_dependency(&mut self, dependency: &str) {
95        match self {
96            Self::Workspace(workspace) => workspace.add_dependency(dependency),
97            Self::Package(package) => package.add_dependency(dependency),
98        }
99    }
100
101    pub fn set_name(&mut self, name: String) {
102        match self {
103            Self::Workspace(workspace) => workspace.set_name(name),
104            Self::Package(package) => package.set_name(name),
105        }
106    }
107
108    #[must_use]
109    pub fn language(&self) -> crate::Language {
110        match self {
111            Self::Workspace(workspace) => workspace.language(),
112            Self::Package(package) => package.language(),
113        }
114    }
115
116    /// # Errors
117    /// Returns error if the underlying publish call fails.
118    pub async fn publish(&self, config: &Config) -> Result<()> {
119        match self {
120            Self::Workspace(workspace) => workspace.publish(config).await,
121            Self::Package(package) => package.publish(config).await,
122        }
123    }
124}
125
126impl PartialEq for Project {
127    fn eq(&self, other: &Self) -> bool {
128        self.cmp(other) == Ordering::Equal
129    }
130}
131
132impl Eq for Project {}
133
134impl PartialOrd for Project {
135    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
136        Some(self.cmp(other))
137    }
138}
139
140impl Ord for Project {
141    fn cmp(&self, other: &Self) -> Ordering {
142        match (self, other) {
143            (Self::Workspace(_), Self::Package(_)) => Ordering::Less,
144            (Self::Package(_), Self::Workspace(_)) => Ordering::Greater,
145            (Self::Workspace(w1), Self::Workspace(w2)) => {
146                let lang_ord = w1.language().cmp(&w2.language());
147                if lang_ord != Ordering::Equal {
148                    return lang_ord;
149                }
150
151                let name1 = w1.name();
152                let name2 = w2.name();
153
154                match (name1, name2) {
155                    (Some(n1), Some(n2)) => n1.cmp(n2),
156                    (Some(_), None) => Ordering::Less,
157                    (None, Some(_)) => Ordering::Greater,
158                    (None, None) => {
159                        let v1 = w1.version().unwrap_or("");
160                        let v2 = w2.version().unwrap_or("");
161                        v1.cmp(v2)
162                    }
163                }
164            }
165            (Self::Package(p1), Self::Package(p2)) => {
166                let lang_ord = p1.language().cmp(&p2.language());
167                if lang_ord != Ordering::Equal {
168                    return lang_ord;
169                }
170                match (p1.name(), p2.name()) {
171                    (Some(n1), Some(n2)) => n1.cmp(n2),
172                    (Some(_), None) => Ordering::Less,
173                    (None, Some(_)) => Ordering::Greater,
174                    (None, None) => Ordering::Equal,
175                }
176            }
177        }
178    }
179}
180
181impl Display for Project {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::Workspace(workspace) => {
185                write!(
186                    f,
187                    "{} {} {} {} {}",
188                    format!("[Workspace - {}]", workspace.language())
189                        .bright_blue()
190                        .bold(),
191                    workspace.name().unwrap_or("noname").bright_white().bold(),
192                    format!(
193                        "({})",
194                        workspace
195                            .version()
196                            .map_or("unknown".to_string(), |v| format!("v{v}")),
197                    )
198                    .bright_green(),
199                    "-".bright_cyan(),
200                    workspace
201                        .relative_path()
202                        .display()
203                        .to_string()
204                        .bright_black()
205                )
206            }
207            Self::Package(package) => {
208                write!(
209                    f,
210                    "{} {} {} {} {}",
211                    format!("[{}]", package.language()).bright_blue().bold(),
212                    package.name().unwrap_or("noname").bright_white().bold(),
213                    format!(
214                        "({})",
215                        package
216                            .version()
217                            .map_or("unknown".to_string(), |v| format!("v{v}"))
218                    )
219                    .bright_green(),
220                    "-".bright_cyan(),
221                    package.relative_path().display().to_string().bright_black()
222                )
223            }
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::Language;
232    use async_trait::async_trait;
233    use std::path::PathBuf;
234
235    #[derive(Debug)]
236    struct MockWorkspace {
237        name: Option<String>,
238        version: Option<String>,
239        path: PathBuf,
240        relative_path: PathBuf,
241        language: Language,
242        dependencies: HashSet<String>,
243        changed: bool,
244    }
245
246    impl MockWorkspace {
247        fn new(name: Option<&str>, version: Option<&str>, language: Language) -> Self {
248            Self {
249                name: name.map(String::from),
250                version: version.map(String::from),
251                path: PathBuf::from("/test/package.json"),
252                relative_path: PathBuf::from("package.json"),
253                language,
254                dependencies: HashSet::new(),
255                changed: false,
256            }
257        }
258    }
259
260    #[async_trait]
261    impl Workspace for MockWorkspace {
262        fn name(&self) -> Option<&str> {
263            self.name.as_deref()
264        }
265        fn path(&self) -> &Path {
266            &self.path
267        }
268        fn relative_path(&self) -> &Path {
269            &self.relative_path
270        }
271        fn version(&self) -> Option<&str> {
272            self.version.as_deref()
273        }
274        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
275            Ok(())
276        }
277        fn language(&self) -> Language {
278            self.language
279        }
280        fn dependencies(&self) -> &HashSet<String> {
281            &self.dependencies
282        }
283        fn add_dependency(&mut self, dependency: &str) {
284            self.dependencies.insert(dependency.to_string());
285        }
286        fn is_changed(&self) -> bool {
287            self.changed
288        }
289        fn set_changed(&mut self, changed: bool) {
290            self.changed = changed;
291        }
292        fn default_publish_command(&self) -> String {
293            "echo publish".to_string()
294        }
295    }
296
297    #[derive(Debug)]
298    struct MockPackage {
299        name: Option<String>,
300        version: Option<String>,
301        path: PathBuf,
302        relative_path: PathBuf,
303        language: Language,
304        dependencies: HashSet<String>,
305        changed: bool,
306    }
307
308    impl MockPackage {
309        fn new(name: Option<&str>, version: Option<&str>, language: Language) -> Self {
310            Self {
311                name: name.map(String::from),
312                version: version.map(String::from),
313                path: PathBuf::from("/test/Cargo.toml"),
314                relative_path: PathBuf::from("Cargo.toml"),
315                language,
316                dependencies: HashSet::new(),
317                changed: false,
318            }
319        }
320    }
321
322    #[async_trait]
323    impl Package for MockPackage {
324        fn name(&self) -> Option<&str> {
325            self.name.as_deref()
326        }
327        fn path(&self) -> &Path {
328            &self.path
329        }
330        fn relative_path(&self) -> &Path {
331            &self.relative_path
332        }
333        fn version(&self) -> Option<&str> {
334            self.version.as_deref()
335        }
336        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
337            Ok(())
338        }
339        fn language(&self) -> Language {
340            self.language
341        }
342        fn dependencies(&self) -> &HashSet<String> {
343            &self.dependencies
344        }
345        fn add_dependency(&mut self, dependency: &str) {
346            self.dependencies.insert(dependency.to_string());
347        }
348        fn is_changed(&self) -> bool {
349            self.changed
350        }
351        fn set_changed(&mut self, changed: bool) {
352            self.changed = changed;
353        }
354        fn default_publish_command(&self) -> String {
355            "echo publish".to_string()
356        }
357    }
358
359    #[test]
360    fn test_project_workspace_name() {
361        let workspace = MockWorkspace::new(Some("test-ws"), Some("1.0.0"), Language::Node);
362        let project = Project::Workspace(Box::new(workspace));
363        assert_eq!(project.name(), Some("test-ws"));
364    }
365
366    #[test]
367    fn test_project_package_name() {
368        let package = MockPackage::new(Some("test-pkg"), Some("1.0.0"), Language::Rust);
369        let project = Project::Package(Box::new(package));
370        assert_eq!(project.name(), Some("test-pkg"));
371    }
372
373    #[test]
374    fn test_project_workspace_version() {
375        let workspace = MockWorkspace::new(Some("test"), Some("2.0.0"), Language::Node);
376        let project = Project::Workspace(Box::new(workspace));
377        assert_eq!(project.version(), Some("2.0.0"));
378    }
379
380    #[test]
381    fn test_project_package_version() {
382        let package = MockPackage::new(Some("test"), Some("3.0.0"), Language::Rust);
383        let project = Project::Package(Box::new(package));
384        assert_eq!(project.version(), Some("3.0.0"));
385    }
386
387    #[test]
388    fn test_project_workspace_path() {
389        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
390        let project = Project::Workspace(Box::new(workspace));
391        assert_eq!(project.path(), Path::new("/test/package.json"));
392    }
393
394    #[test]
395    fn test_project_package_path() {
396        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
397        let project = Project::Package(Box::new(package));
398        assert_eq!(project.path(), Path::new("/test/Cargo.toml"));
399    }
400
401    #[test]
402    fn test_project_workspace_relative_path() {
403        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
404        let project = Project::Workspace(Box::new(workspace));
405        assert_eq!(project.relative_path(), Path::new("package.json"));
406    }
407
408    #[test]
409    fn test_project_package_relative_path() {
410        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
411        let project = Project::Package(Box::new(package));
412        assert_eq!(project.relative_path(), Path::new("Cargo.toml"));
413    }
414
415    #[tokio::test]
416    async fn test_project_workspace_update_version() {
417        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
418        let mut project = Project::Workspace(Box::new(workspace));
419        let result = project.update_version(UpdateType::Minor).await;
420        assert!(result.is_ok());
421    }
422
423    #[tokio::test]
424    async fn test_project_package_update_version() {
425        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
426        let mut project = Project::Package(Box::new(package));
427        let result = project.update_version(UpdateType::Patch).await;
428        assert!(result.is_ok());
429    }
430
431    #[test]
432    fn test_project_workspace_check_changed() {
433        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
434        let mut project = Project::Workspace(Box::new(workspace));
435        let result = project.check_changed(Path::new("/test/src/index.js"));
436        assert!(result.is_ok());
437    }
438
439    #[test]
440    fn test_project_package_check_changed() {
441        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
442        let mut project = Project::Package(Box::new(package));
443        let result = project.check_changed(Path::new("/test/src/main.rs"));
444        assert!(result.is_ok());
445    }
446
447    #[test]
448    fn test_project_workspace_is_changed() {
449        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
450        workspace.changed = true;
451        let project = Project::Workspace(Box::new(workspace));
452        assert!(project.is_changed());
453    }
454
455    #[test]
456    fn test_project_package_is_changed() {
457        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
458        package.changed = true;
459        let project = Project::Package(Box::new(package));
460        assert!(project.is_changed());
461    }
462
463    #[test]
464    fn test_project_workspace_dependencies() {
465        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
466        workspace.dependencies.insert("dep1".to_string());
467        let project = Project::Workspace(Box::new(workspace));
468        assert!(project.dependencies().contains("dep1"));
469    }
470
471    #[test]
472    fn test_project_package_dependencies() {
473        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
474        package.dependencies.insert("dep2".to_string());
475        let project = Project::Package(Box::new(package));
476        assert!(project.dependencies().contains("dep2"));
477    }
478
479    #[test]
480    fn test_project_workspace_add_dependency() {
481        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
482        let mut project = Project::Workspace(Box::new(workspace));
483        project.add_dependency("new-dep");
484        assert!(project.dependencies().contains("new-dep"));
485    }
486
487    #[test]
488    fn test_project_package_add_dependency() {
489        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
490        let mut project = Project::Package(Box::new(package));
491        project.add_dependency("new-dep");
492        assert!(project.dependencies().contains("new-dep"));
493    }
494
495    #[test]
496    fn test_project_workspace_language() {
497        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Python);
498        let project = Project::Workspace(Box::new(workspace));
499        assert!(matches!(project.language(), Language::Python));
500    }
501
502    #[test]
503    fn test_project_package_language() {
504        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Dart);
505        let project = Project::Package(Box::new(package));
506        assert!(matches!(project.language(), Language::Dart));
507    }
508
509    #[tokio::test]
510    async fn test_project_workspace_publish() {
511        let temp_dir = std::env::temp_dir();
512        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
513        workspace.path = temp_dir.join("package.json");
514        let project = Project::Workspace(Box::new(workspace));
515        let config = Config::default();
516        let result = project.publish(&config).await;
517        assert!(result.is_ok());
518    }
519
520    #[tokio::test]
521    async fn test_project_package_publish() {
522        let temp_dir = std::env::temp_dir();
523        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
524        package.path = temp_dir.join("Cargo.toml");
525        let project = Project::Package(Box::new(package));
526        let config = Config::default();
527        let result = project.publish(&config).await;
528        assert!(result.is_ok());
529    }
530
531    #[test]
532    fn test_project_eq_same_workspace() {
533        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
534        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
535        let p1 = Project::Workspace(Box::new(w1));
536        let p2 = Project::Workspace(Box::new(w2));
537        assert_eq!(p1, p2);
538    }
539
540    #[test]
541    fn test_project_partial_ord() {
542        let w1 = MockWorkspace::new(Some("a"), Some("1.0.0"), Language::Node);
543        let w2 = MockWorkspace::new(Some("b"), Some("1.0.0"), Language::Node);
544        let p1 = Project::Workspace(Box::new(w1));
545        let p2 = Project::Workspace(Box::new(w2));
546        assert!(p1.partial_cmp(&p2).is_some());
547    }
548
549    #[test]
550    fn test_project_ord_workspace_before_package() {
551        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
552        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
553        let p1 = Project::Workspace(Box::new(workspace));
554        let p2 = Project::Package(Box::new(package));
555        assert!(p1 < p2);
556    }
557
558    #[test]
559    fn test_project_ord_package_after_workspace() {
560        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
561        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
562        let p1 = Project::Package(Box::new(package));
563        let p2 = Project::Workspace(Box::new(workspace));
564        assert!(p1 > p2);
565    }
566
567    #[test]
568    fn test_project_ord_workspaces_by_language() {
569        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
570        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Python);
571        let p1 = Project::Workspace(Box::new(w1));
572        let p2 = Project::Workspace(Box::new(w2));
573        assert_ne!(p1.cmp(&p2), Ordering::Equal);
574    }
575
576    #[test]
577    fn test_project_ord_workspaces_by_name() {
578        let w1 = MockWorkspace::new(Some("aaa"), Some("1.0.0"), Language::Node);
579        let w2 = MockWorkspace::new(Some("bbb"), Some("1.0.0"), Language::Node);
580        let p1 = Project::Workspace(Box::new(w1));
581        let p2 = Project::Workspace(Box::new(w2));
582        assert!(p1 < p2);
583    }
584
585    #[test]
586    fn test_project_ord_workspaces_name_some_vs_none() {
587        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
588        let w2 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
589        let p1 = Project::Workspace(Box::new(w1));
590        let p2 = Project::Workspace(Box::new(w2));
591        assert!(p1 < p2);
592    }
593
594    #[test]
595    fn test_project_ord_workspaces_name_none_vs_some() {
596        let w1 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
597        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
598        let p1 = Project::Workspace(Box::new(w1));
599        let p2 = Project::Workspace(Box::new(w2));
600        assert!(p1 > p2);
601    }
602
603    #[test]
604    fn test_project_ord_workspaces_both_none_names() {
605        let w1 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
606        let w2 = MockWorkspace::new(None, Some("2.0.0"), Language::Node);
607        let p1 = Project::Workspace(Box::new(w1));
608        let p2 = Project::Workspace(Box::new(w2));
609        assert!(p1 < p2);
610    }
611
612    #[test]
613    fn test_project_ord_packages_by_language() {
614        let pkg1 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Node);
615        let pkg2 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
616        let p1 = Project::Package(Box::new(pkg1));
617        let p2 = Project::Package(Box::new(pkg2));
618        assert_ne!(p1.cmp(&p2), Ordering::Equal);
619    }
620
621    #[test]
622    fn test_project_ord_packages_by_name() {
623        let pkg1 = MockPackage::new(Some("aaa"), Some("1.0.0"), Language::Rust);
624        let pkg2 = MockPackage::new(Some("bbb"), Some("1.0.0"), Language::Rust);
625        let p1 = Project::Package(Box::new(pkg1));
626        let p2 = Project::Package(Box::new(pkg2));
627        assert!(p1 < p2);
628    }
629
630    #[test]
631    fn test_project_ord_packages_name_some_vs_none() {
632        let pkg1 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
633        let pkg2 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
634        let p1 = Project::Package(Box::new(pkg1));
635        let p2 = Project::Package(Box::new(pkg2));
636        assert!(p1 < p2);
637    }
638
639    #[test]
640    fn test_project_ord_packages_name_none_vs_some() {
641        let pkg1 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
642        let pkg2 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
643        let p1 = Project::Package(Box::new(pkg1));
644        let p2 = Project::Package(Box::new(pkg2));
645        assert!(p1 > p2);
646    }
647
648    #[test]
649    fn test_project_ord_packages_both_none_names() {
650        let pkg1 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
651        let pkg2 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
652        let p1 = Project::Package(Box::new(pkg1));
653        let p2 = Project::Package(Box::new(pkg2));
654        assert_eq!(p1.cmp(&p2), Ordering::Equal);
655    }
656
657    #[test]
658    fn test_project_display_workspace() {
659        let workspace = MockWorkspace::new(Some("my-workspace"), Some("1.0.0"), Language::Node);
660        let project = Project::Workspace(Box::new(workspace));
661        let display = format!("{}", project);
662        assert!(display.contains("Workspace"));
663        assert!(display.contains("my-workspace"));
664        assert!(display.contains("v1.0.0"));
665    }
666
667    #[test]
668    fn test_project_display_workspace_no_name() {
669        let workspace = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
670        let project = Project::Workspace(Box::new(workspace));
671        let display = format!("{}", project);
672        assert!(display.contains("noname"));
673    }
674
675    #[test]
676    fn test_project_display_workspace_no_version() {
677        let workspace = MockWorkspace::new(Some("test"), None, Language::Node);
678        let project = Project::Workspace(Box::new(workspace));
679        let display = format!("{}", project);
680        assert!(display.contains("unknown"));
681    }
682
683    #[test]
684    fn test_project_display_package() {
685        let package = MockPackage::new(Some("my-package"), Some("2.0.0"), Language::Rust);
686        let project = Project::Package(Box::new(package));
687        let display = format!("{}", project);
688        assert!(display.contains("my-package"));
689        assert!(display.contains("v2.0.0"));
690    }
691
692    #[test]
693    fn test_project_display_package_no_name() {
694        let package = MockPackage::new(None, Some("1.0.0"), Language::Rust);
695        let project = Project::Package(Box::new(package));
696        let display = format!("{}", project);
697        assert!(display.contains("noname"));
698    }
699
700    #[test]
701    fn test_project_display_package_no_version() {
702        let package = MockPackage::new(Some("test"), None, Language::Rust);
703        let project = Project::Package(Box::new(package));
704        let display = format!("{}", project);
705        assert!(display.contains("unknown"));
706    }
707
708    #[test]
709    fn test_project_sort_stability() {
710        let make_projects = || {
711            vec![
712                Project::Package(Box::new(MockPackage::new(
713                    Some("charlie"),
714                    Some("1.0.0"),
715                    Language::Rust,
716                ))),
717                Project::Workspace(Box::new(MockWorkspace::new(
718                    Some("alpha"),
719                    Some("2.0.0"),
720                    Language::Node,
721                ))),
722                Project::Package(Box::new(MockPackage::new(
723                    Some("bravo"),
724                    Some("0.1.0"),
725                    Language::Node,
726                ))),
727                Project::Workspace(Box::new(MockWorkspace::new(
728                    Some("delta"),
729                    Some("3.0.0"),
730                    Language::Python,
731                ))),
732                Project::Package(Box::new(MockPackage::new(
733                    Some("echo"),
734                    Some("1.0.0"),
735                    Language::Dart,
736                ))),
737            ]
738        };
739
740        let mut first = make_projects();
741        first.sort();
742        let first_order: Vec<Option<&str>> = first.iter().map(|p| p.name()).collect();
743
744        let mut second = make_projects();
745        second.sort();
746        let second_order: Vec<Option<&str>> = second.iter().map(|p| p.name()).collect();
747
748        let mut third = make_projects();
749        third.sort();
750        let third_order: Vec<Option<&str>> = third.iter().map(|p| p.name()).collect();
751
752        assert_eq!(first_order, second_order);
753        assert_eq!(second_order, third_order);
754    }
755
756    #[test]
757    fn test_project_sort_mixed() {
758        let mut projects = [
759            Project::Package(Box::new(MockPackage::new(
760                Some("pkg-a"),
761                Some("1.0.0"),
762                Language::Node,
763            ))),
764            Project::Workspace(Box::new(MockWorkspace::new(
765                Some("ws-b"),
766                Some("1.0.0"),
767                Language::Node,
768            ))),
769            Project::Package(Box::new(MockPackage::new(
770                Some("pkg-c"),
771                Some("1.0.0"),
772                Language::Rust,
773            ))),
774            Project::Workspace(Box::new(MockWorkspace::new(
775                Some("ws-d"),
776                Some("1.0.0"),
777                Language::Rust,
778            ))),
779        ];
780        projects.sort();
781
782        // All workspaces must come before all packages
783        let workspace_count = projects
784            .iter()
785            .take_while(|p| matches!(p, Project::Workspace(_)))
786            .count();
787        assert_eq!(workspace_count, 2);
788
789        let package_count = projects
790            .iter()
791            .skip(workspace_count)
792            .filter(|p| matches!(p, Project::Package(_)))
793            .count();
794        assert_eq!(package_count, 2);
795    }
796
797    #[test]
798    fn test_project_set_name_workspace() {
799        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
800        let mut project = Project::Workspace(Box::new(workspace));
801        project.set_name("new-name".to_string());
802        // Mock doesn't override set_name, so default no-op applies
803        assert_eq!(project.name(), Some("test"));
804    }
805
806    #[test]
807    fn test_project_set_name_package() {
808        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
809        let mut project = Project::Package(Box::new(package));
810        project.set_name("new-name".to_string());
811        // Mock doesn't override set_name, so default no-op applies
812        assert_eq!(project.name(), Some("test"));
813    }
814
815    #[test]
816    fn test_project_cmp_is_consistent_with_eq() {
817        // Two workspaces with identical fields
818        let w1 = MockWorkspace::new(Some("same"), Some("1.0.0"), Language::Node);
819        let w2 = MockWorkspace::new(Some("same"), Some("1.0.0"), Language::Node);
820        let p1 = Project::Workspace(Box::new(w1));
821        let p2 = Project::Workspace(Box::new(w2));
822        assert_eq!(p1, p2);
823        assert_eq!(p1.cmp(&p2), Ordering::Equal);
824
825        // Two packages with identical fields
826        let pkg1 = MockPackage::new(Some("same"), Some("1.0.0"), Language::Rust);
827        let pkg2 = MockPackage::new(Some("same"), Some("1.0.0"), Language::Rust);
828        let pp1 = Project::Package(Box::new(pkg1));
829        let pp2 = Project::Package(Box::new(pkg2));
830        assert_eq!(pp1, pp2);
831        assert_eq!(pp1.cmp(&pp2), Ordering::Equal);
832    }
833}