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 to spawn.
118    pub async fn publish(&self, config: &Config) -> Result<crate::publish::PublishOutput> {
119        match self {
120            Self::Workspace(workspace) => workspace.publish(config).await,
121            Self::Package(package) => package.publish(config).await,
122        }
123    }
124
125    /// Run the publish command in dry-run mode.
126    ///
127    /// Returns `Ok(None)` when dry-run is not supported for this project's
128    /// language and no override is configured in `config.publish_dry_run`.
129    ///
130    /// # Errors
131    /// Returns error if the underlying dry-run publish call fails to spawn.
132    pub async fn dry_run_publish(
133        &self,
134        config: &Config,
135    ) -> Result<Option<crate::publish::PublishOutput>> {
136        match self {
137            Self::Workspace(workspace) => workspace.dry_run_publish(config).await,
138            Self::Package(package) => package.dry_run_publish(config).await,
139        }
140    }
141}
142
143impl PartialEq for Project {
144    fn eq(&self, other: &Self) -> bool {
145        self.cmp(other) == Ordering::Equal
146    }
147}
148
149impl Eq for Project {}
150
151impl PartialOrd for Project {
152    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
153        Some(self.cmp(other))
154    }
155}
156
157impl Ord for Project {
158    fn cmp(&self, other: &Self) -> Ordering {
159        match (self, other) {
160            (Self::Workspace(_), Self::Package(_)) => Ordering::Less,
161            (Self::Package(_), Self::Workspace(_)) => Ordering::Greater,
162            (Self::Workspace(w1), Self::Workspace(w2)) => {
163                let lang_ord = w1.language().cmp(&w2.language());
164                if lang_ord != Ordering::Equal {
165                    return lang_ord;
166                }
167
168                let name1 = w1.name();
169                let name2 = w2.name();
170
171                match (name1, name2) {
172                    (Some(n1), Some(n2)) => n1.cmp(n2),
173                    (Some(_), None) => Ordering::Less,
174                    (None, Some(_)) => Ordering::Greater,
175                    (None, None) => {
176                        let v1 = w1.version().unwrap_or("");
177                        let v2 = w2.version().unwrap_or("");
178                        v1.cmp(v2)
179                    }
180                }
181            }
182            (Self::Package(p1), Self::Package(p2)) => {
183                let lang_ord = p1.language().cmp(&p2.language());
184                if lang_ord != Ordering::Equal {
185                    return lang_ord;
186                }
187                match (p1.name(), p2.name()) {
188                    (Some(n1), Some(n2)) => n1.cmp(n2),
189                    (Some(_), None) => Ordering::Less,
190                    (None, Some(_)) => Ordering::Greater,
191                    (None, None) => Ordering::Equal,
192                }
193            }
194        }
195    }
196}
197
198impl Display for Project {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            Self::Workspace(workspace) => {
202                write!(
203                    f,
204                    "{} {} {} {} {}",
205                    format!("[Workspace - {}]", workspace.language())
206                        .bright_blue()
207                        .bold(),
208                    workspace.name().unwrap_or("noname").bright_white().bold(),
209                    format!(
210                        "({})",
211                        workspace
212                            .version()
213                            .map_or("unknown".to_string(), |v| format!("v{v}")),
214                    )
215                    .bright_green(),
216                    "-".bright_cyan(),
217                    workspace
218                        .relative_path()
219                        .display()
220                        .to_string()
221                        .bright_black()
222                )
223            }
224            Self::Package(package) => {
225                write!(
226                    f,
227                    "{} {} {} {} {}",
228                    format!("[{}]", package.language()).bright_blue().bold(),
229                    package.name().unwrap_or("noname").bright_white().bold(),
230                    format!(
231                        "({})",
232                        package
233                            .version()
234                            .map_or("unknown".to_string(), |v| format!("v{v}"))
235                    )
236                    .bright_green(),
237                    "-".bright_cyan(),
238                    package.relative_path().display().to_string().bright_black()
239                )
240            }
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::Language;
249    use async_trait::async_trait;
250    use std::path::PathBuf;
251
252    #[derive(Debug)]
253    struct MockWorkspace {
254        name: Option<String>,
255        version: Option<String>,
256        path: PathBuf,
257        relative_path: PathBuf,
258        language: Language,
259        dependencies: HashSet<String>,
260        changed: bool,
261    }
262
263    impl MockWorkspace {
264        fn new(name: Option<&str>, version: Option<&str>, language: Language) -> Self {
265            Self {
266                name: name.map(String::from),
267                version: version.map(String::from),
268                path: PathBuf::from("/test/package.json"),
269                relative_path: PathBuf::from("package.json"),
270                language,
271                dependencies: HashSet::new(),
272                changed: false,
273            }
274        }
275    }
276
277    #[async_trait]
278    impl Workspace for MockWorkspace {
279        fn name(&self) -> Option<&str> {
280            self.name.as_deref()
281        }
282        fn path(&self) -> &Path {
283            &self.path
284        }
285        fn relative_path(&self) -> &Path {
286            &self.relative_path
287        }
288        fn version(&self) -> Option<&str> {
289            self.version.as_deref()
290        }
291        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
292            Ok(())
293        }
294        fn language(&self) -> Language {
295            self.language
296        }
297        fn dependencies(&self) -> &HashSet<String> {
298            &self.dependencies
299        }
300        fn add_dependency(&mut self, dependency: &str) {
301            self.dependencies.insert(dependency.to_string());
302        }
303        fn is_changed(&self) -> bool {
304            self.changed
305        }
306        fn set_changed(&mut self, changed: bool) {
307            self.changed = changed;
308        }
309        fn default_publish_command(&self) -> String {
310            "echo publish".to_string()
311        }
312        fn default_dry_run_publish_command(&self) -> Option<String> {
313            Some("echo publish --dry-run".to_string())
314        }
315    }
316
317    #[derive(Debug)]
318    struct MockPackage {
319        name: Option<String>,
320        version: Option<String>,
321        path: PathBuf,
322        relative_path: PathBuf,
323        language: Language,
324        dependencies: HashSet<String>,
325        changed: bool,
326    }
327
328    impl MockPackage {
329        fn new(name: Option<&str>, version: Option<&str>, language: Language) -> Self {
330            Self {
331                name: name.map(String::from),
332                version: version.map(String::from),
333                path: PathBuf::from("/test/Cargo.toml"),
334                relative_path: PathBuf::from("Cargo.toml"),
335                language,
336                dependencies: HashSet::new(),
337                changed: false,
338            }
339        }
340    }
341
342    #[async_trait]
343    impl Package for MockPackage {
344        fn name(&self) -> Option<&str> {
345            self.name.as_deref()
346        }
347        fn path(&self) -> &Path {
348            &self.path
349        }
350        fn relative_path(&self) -> &Path {
351            &self.relative_path
352        }
353        fn version(&self) -> Option<&str> {
354            self.version.as_deref()
355        }
356        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
357            Ok(())
358        }
359        fn language(&self) -> Language {
360            self.language
361        }
362        fn dependencies(&self) -> &HashSet<String> {
363            &self.dependencies
364        }
365        fn add_dependency(&mut self, dependency: &str) {
366            self.dependencies.insert(dependency.to_string());
367        }
368        fn is_changed(&self) -> bool {
369            self.changed
370        }
371        fn set_changed(&mut self, changed: bool) {
372            self.changed = changed;
373        }
374        fn default_publish_command(&self) -> String {
375            "echo publish".to_string()
376        }
377        fn default_dry_run_publish_command(&self) -> Option<String> {
378            Some("echo publish --dry-run".to_string())
379        }
380    }
381
382    #[test]
383    fn test_project_workspace_name() {
384        let workspace = MockWorkspace::new(Some("test-ws"), Some("1.0.0"), Language::Node);
385        let project = Project::Workspace(Box::new(workspace));
386        assert_eq!(project.name(), Some("test-ws"));
387    }
388
389    #[test]
390    fn test_project_package_name() {
391        let package = MockPackage::new(Some("test-pkg"), Some("1.0.0"), Language::Rust);
392        let project = Project::Package(Box::new(package));
393        assert_eq!(project.name(), Some("test-pkg"));
394    }
395
396    #[test]
397    fn test_project_workspace_version() {
398        let workspace = MockWorkspace::new(Some("test"), Some("2.0.0"), Language::Node);
399        let project = Project::Workspace(Box::new(workspace));
400        assert_eq!(project.version(), Some("2.0.0"));
401    }
402
403    #[test]
404    fn test_project_package_version() {
405        let package = MockPackage::new(Some("test"), Some("3.0.0"), Language::Rust);
406        let project = Project::Package(Box::new(package));
407        assert_eq!(project.version(), Some("3.0.0"));
408    }
409
410    #[test]
411    fn test_project_workspace_path() {
412        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
413        let project = Project::Workspace(Box::new(workspace));
414        assert_eq!(project.path(), Path::new("/test/package.json"));
415    }
416
417    #[test]
418    fn test_project_package_path() {
419        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
420        let project = Project::Package(Box::new(package));
421        assert_eq!(project.path(), Path::new("/test/Cargo.toml"));
422    }
423
424    #[test]
425    fn test_project_workspace_relative_path() {
426        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
427        let project = Project::Workspace(Box::new(workspace));
428        assert_eq!(project.relative_path(), Path::new("package.json"));
429    }
430
431    #[test]
432    fn test_project_package_relative_path() {
433        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
434        let project = Project::Package(Box::new(package));
435        assert_eq!(project.relative_path(), Path::new("Cargo.toml"));
436    }
437
438    #[tokio::test]
439    async fn test_project_workspace_update_version() {
440        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
441        let mut project = Project::Workspace(Box::new(workspace));
442        let result = project.update_version(UpdateType::Minor).await;
443        assert!(result.is_ok());
444    }
445
446    #[tokio::test]
447    async fn test_project_package_update_version() {
448        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
449        let mut project = Project::Package(Box::new(package));
450        let result = project.update_version(UpdateType::Patch).await;
451        assert!(result.is_ok());
452    }
453
454    #[test]
455    fn test_project_workspace_check_changed() {
456        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
457        let mut project = Project::Workspace(Box::new(workspace));
458        let result = project.check_changed(Path::new("/test/src/index.js"));
459        assert!(result.is_ok());
460    }
461
462    #[test]
463    fn test_project_package_check_changed() {
464        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
465        let mut project = Project::Package(Box::new(package));
466        let result = project.check_changed(Path::new("/test/src/main.rs"));
467        assert!(result.is_ok());
468    }
469
470    #[test]
471    fn test_project_workspace_is_changed() {
472        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
473        workspace.changed = true;
474        let project = Project::Workspace(Box::new(workspace));
475        assert!(project.is_changed());
476    }
477
478    #[test]
479    fn test_project_package_is_changed() {
480        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
481        package.changed = true;
482        let project = Project::Package(Box::new(package));
483        assert!(project.is_changed());
484    }
485
486    #[test]
487    fn test_project_workspace_dependencies() {
488        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
489        workspace.dependencies.insert("dep1".to_string());
490        let project = Project::Workspace(Box::new(workspace));
491        assert!(project.dependencies().contains("dep1"));
492    }
493
494    #[test]
495    fn test_project_package_dependencies() {
496        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
497        package.dependencies.insert("dep2".to_string());
498        let project = Project::Package(Box::new(package));
499        assert!(project.dependencies().contains("dep2"));
500    }
501
502    #[test]
503    fn test_project_workspace_add_dependency() {
504        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
505        let mut project = Project::Workspace(Box::new(workspace));
506        project.add_dependency("new-dep");
507        assert!(project.dependencies().contains("new-dep"));
508    }
509
510    #[test]
511    fn test_project_package_add_dependency() {
512        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
513        let mut project = Project::Package(Box::new(package));
514        project.add_dependency("new-dep");
515        assert!(project.dependencies().contains("new-dep"));
516    }
517
518    #[test]
519    fn test_project_workspace_language() {
520        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Python);
521        let project = Project::Workspace(Box::new(workspace));
522        assert!(matches!(project.language(), Language::Python));
523    }
524
525    #[test]
526    fn test_project_package_language() {
527        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Dart);
528        let project = Project::Package(Box::new(package));
529        assert!(matches!(project.language(), Language::Dart));
530    }
531
532    #[tokio::test]
533    async fn test_project_workspace_publish() {
534        let temp_dir = std::env::temp_dir();
535        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
536        workspace.path = temp_dir.join("package.json");
537        let project = Project::Workspace(Box::new(workspace));
538        let config = Config::default();
539        let output = project.publish(&config).await.unwrap();
540        assert!(output.success);
541    }
542
543    #[tokio::test]
544    async fn test_project_package_publish() {
545        let temp_dir = std::env::temp_dir();
546        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
547        package.path = temp_dir.join("Cargo.toml");
548        let project = Project::Package(Box::new(package));
549        let config = Config::default();
550        let output = project.publish(&config).await.unwrap();
551        assert!(output.success);
552    }
553
554    #[tokio::test]
555    async fn test_project_workspace_dry_run_publish() {
556        let temp_dir = std::env::temp_dir();
557        let mut workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
558        workspace.path = temp_dir.join("package.json");
559        let project = Project::Workspace(Box::new(workspace));
560        let config = Config::default();
561
562        // MockWorkspace.default_publish_command() == "echo publish" and
563        // Language::Node.dry_run_flag() == Some("--dry-run"), so the
564        // derived dry-run command is "echo publish --dry-run".
565        let output = project.dry_run_publish(&config).await.unwrap();
566        assert!(output.is_some());
567        let output = output.unwrap();
568        assert!(output.success);
569        assert!(output.stdout.contains("publish"));
570    }
571
572    #[tokio::test]
573    async fn test_project_package_dry_run_publish() {
574        let temp_dir = std::env::temp_dir();
575        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
576        package.path = temp_dir.join("Cargo.toml");
577        let project = Project::Package(Box::new(package));
578        let config = Config::default();
579
580        let output = project.dry_run_publish(&config).await.unwrap();
581        assert!(output.is_some());
582        assert!(output.unwrap().success);
583    }
584
585    #[tokio::test]
586    async fn test_project_package_dry_run_publish_propagates_language_override() {
587        let temp_dir = std::env::temp_dir();
588        let mut package = MockPackage::new(Some("test"), Some("1.0.0"), Language::CSharp);
589        package.path = temp_dir.join("Sample.csproj");
590        let project = Project::Package(Box::new(package));
591        let mut publish_dry_run = std::collections::HashMap::new();
592        publish_dry_run.insert("csharp".to_string(), "echo dry-csharp".to_string());
593        let config = Config {
594            publish_dry_run,
595            ..Config::default()
596        };
597
598        // Even for languages whose crate would return None from
599        // `default_dry_run_publish_command()`, a per-language config override
600        // still resolves to a runnable command.
601        let output = project.dry_run_publish(&config).await.unwrap();
602        assert!(output.is_some());
603        assert!(output.unwrap().success);
604    }
605
606    #[test]
607    fn test_project_eq_same_workspace() {
608        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
609        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
610        let p1 = Project::Workspace(Box::new(w1));
611        let p2 = Project::Workspace(Box::new(w2));
612        assert_eq!(p1, p2);
613    }
614
615    #[test]
616    fn test_project_partial_ord() {
617        let w1 = MockWorkspace::new(Some("a"), Some("1.0.0"), Language::Node);
618        let w2 = MockWorkspace::new(Some("b"), Some("1.0.0"), Language::Node);
619        let p1 = Project::Workspace(Box::new(w1));
620        let p2 = Project::Workspace(Box::new(w2));
621        assert!(p1.partial_cmp(&p2).is_some());
622    }
623
624    #[test]
625    fn test_project_ord_workspace_before_package() {
626        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
627        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
628        let p1 = Project::Workspace(Box::new(workspace));
629        let p2 = Project::Package(Box::new(package));
630        assert!(p1 < p2);
631    }
632
633    #[test]
634    fn test_project_ord_package_after_workspace() {
635        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
636        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
637        let p1 = Project::Package(Box::new(package));
638        let p2 = Project::Workspace(Box::new(workspace));
639        assert!(p1 > p2);
640    }
641
642    #[test]
643    fn test_project_ord_workspaces_by_language() {
644        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
645        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Python);
646        let p1 = Project::Workspace(Box::new(w1));
647        let p2 = Project::Workspace(Box::new(w2));
648        assert_ne!(p1.cmp(&p2), Ordering::Equal);
649    }
650
651    #[test]
652    fn test_project_ord_workspaces_by_name() {
653        let w1 = MockWorkspace::new(Some("aaa"), Some("1.0.0"), Language::Node);
654        let w2 = MockWorkspace::new(Some("bbb"), Some("1.0.0"), Language::Node);
655        let p1 = Project::Workspace(Box::new(w1));
656        let p2 = Project::Workspace(Box::new(w2));
657        assert!(p1 < p2);
658    }
659
660    #[test]
661    fn test_project_ord_workspaces_name_some_vs_none() {
662        let w1 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
663        let w2 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
664        let p1 = Project::Workspace(Box::new(w1));
665        let p2 = Project::Workspace(Box::new(w2));
666        assert!(p1 < p2);
667    }
668
669    #[test]
670    fn test_project_ord_workspaces_name_none_vs_some() {
671        let w1 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
672        let w2 = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
673        let p1 = Project::Workspace(Box::new(w1));
674        let p2 = Project::Workspace(Box::new(w2));
675        assert!(p1 > p2);
676    }
677
678    #[test]
679    fn test_project_ord_workspaces_both_none_names() {
680        let w1 = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
681        let w2 = MockWorkspace::new(None, Some("2.0.0"), Language::Node);
682        let p1 = Project::Workspace(Box::new(w1));
683        let p2 = Project::Workspace(Box::new(w2));
684        assert!(p1 < p2);
685    }
686
687    #[test]
688    fn test_project_ord_packages_by_language() {
689        let pkg1 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Node);
690        let pkg2 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
691        let p1 = Project::Package(Box::new(pkg1));
692        let p2 = Project::Package(Box::new(pkg2));
693        assert_ne!(p1.cmp(&p2), Ordering::Equal);
694    }
695
696    #[test]
697    fn test_project_ord_packages_by_name() {
698        let pkg1 = MockPackage::new(Some("aaa"), Some("1.0.0"), Language::Rust);
699        let pkg2 = MockPackage::new(Some("bbb"), Some("1.0.0"), Language::Rust);
700        let p1 = Project::Package(Box::new(pkg1));
701        let p2 = Project::Package(Box::new(pkg2));
702        assert!(p1 < p2);
703    }
704
705    #[test]
706    fn test_project_ord_packages_name_some_vs_none() {
707        let pkg1 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
708        let pkg2 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
709        let p1 = Project::Package(Box::new(pkg1));
710        let p2 = Project::Package(Box::new(pkg2));
711        assert!(p1 < p2);
712    }
713
714    #[test]
715    fn test_project_ord_packages_name_none_vs_some() {
716        let pkg1 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
717        let pkg2 = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
718        let p1 = Project::Package(Box::new(pkg1));
719        let p2 = Project::Package(Box::new(pkg2));
720        assert!(p1 > p2);
721    }
722
723    #[test]
724    fn test_project_ord_packages_both_none_names() {
725        let pkg1 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
726        let pkg2 = MockPackage::new(None, Some("1.0.0"), Language::Rust);
727        let p1 = Project::Package(Box::new(pkg1));
728        let p2 = Project::Package(Box::new(pkg2));
729        assert_eq!(p1.cmp(&p2), Ordering::Equal);
730    }
731
732    #[test]
733    fn test_project_display_workspace() {
734        let workspace = MockWorkspace::new(Some("my-workspace"), Some("1.0.0"), Language::Node);
735        let project = Project::Workspace(Box::new(workspace));
736        let display = format!("{}", project);
737        assert!(display.contains("Workspace"));
738        assert!(display.contains("my-workspace"));
739        assert!(display.contains("v1.0.0"));
740    }
741
742    #[test]
743    fn test_project_display_workspace_no_name() {
744        let workspace = MockWorkspace::new(None, Some("1.0.0"), Language::Node);
745        let project = Project::Workspace(Box::new(workspace));
746        let display = format!("{}", project);
747        assert!(display.contains("noname"));
748    }
749
750    #[test]
751    fn test_project_display_workspace_no_version() {
752        let workspace = MockWorkspace::new(Some("test"), None, Language::Node);
753        let project = Project::Workspace(Box::new(workspace));
754        let display = format!("{}", project);
755        assert!(display.contains("unknown"));
756    }
757
758    #[test]
759    fn test_project_display_package() {
760        let package = MockPackage::new(Some("my-package"), Some("2.0.0"), Language::Rust);
761        let project = Project::Package(Box::new(package));
762        let display = format!("{}", project);
763        assert!(display.contains("my-package"));
764        assert!(display.contains("v2.0.0"));
765    }
766
767    #[test]
768    fn test_project_display_package_no_name() {
769        let package = MockPackage::new(None, Some("1.0.0"), Language::Rust);
770        let project = Project::Package(Box::new(package));
771        let display = format!("{}", project);
772        assert!(display.contains("noname"));
773    }
774
775    #[test]
776    fn test_project_display_package_no_version() {
777        let package = MockPackage::new(Some("test"), None, Language::Rust);
778        let project = Project::Package(Box::new(package));
779        let display = format!("{}", project);
780        assert!(display.contains("unknown"));
781    }
782
783    #[test]
784    fn test_project_sort_stability() {
785        let make_projects = || {
786            vec![
787                Project::Package(Box::new(MockPackage::new(
788                    Some("charlie"),
789                    Some("1.0.0"),
790                    Language::Rust,
791                ))),
792                Project::Workspace(Box::new(MockWorkspace::new(
793                    Some("alpha"),
794                    Some("2.0.0"),
795                    Language::Node,
796                ))),
797                Project::Package(Box::new(MockPackage::new(
798                    Some("bravo"),
799                    Some("0.1.0"),
800                    Language::Node,
801                ))),
802                Project::Workspace(Box::new(MockWorkspace::new(
803                    Some("delta"),
804                    Some("3.0.0"),
805                    Language::Python,
806                ))),
807                Project::Package(Box::new(MockPackage::new(
808                    Some("echo"),
809                    Some("1.0.0"),
810                    Language::Dart,
811                ))),
812            ]
813        };
814
815        let mut first = make_projects();
816        first.sort();
817        let first_order: Vec<Option<&str>> = first.iter().map(|p| p.name()).collect();
818
819        let mut second = make_projects();
820        second.sort();
821        let second_order: Vec<Option<&str>> = second.iter().map(|p| p.name()).collect();
822
823        let mut third = make_projects();
824        third.sort();
825        let third_order: Vec<Option<&str>> = third.iter().map(|p| p.name()).collect();
826
827        assert_eq!(first_order, second_order);
828        assert_eq!(second_order, third_order);
829    }
830
831    #[test]
832    fn test_project_sort_mixed() {
833        let mut projects = [
834            Project::Package(Box::new(MockPackage::new(
835                Some("pkg-a"),
836                Some("1.0.0"),
837                Language::Node,
838            ))),
839            Project::Workspace(Box::new(MockWorkspace::new(
840                Some("ws-b"),
841                Some("1.0.0"),
842                Language::Node,
843            ))),
844            Project::Package(Box::new(MockPackage::new(
845                Some("pkg-c"),
846                Some("1.0.0"),
847                Language::Rust,
848            ))),
849            Project::Workspace(Box::new(MockWorkspace::new(
850                Some("ws-d"),
851                Some("1.0.0"),
852                Language::Rust,
853            ))),
854        ];
855        projects.sort();
856
857        // All workspaces must come before all packages
858        let workspace_count = projects
859            .iter()
860            .take_while(|p| matches!(p, Project::Workspace(_)))
861            .count();
862        assert_eq!(workspace_count, 2);
863
864        let package_count = projects
865            .iter()
866            .skip(workspace_count)
867            .filter(|p| matches!(p, Project::Package(_)))
868            .count();
869        assert_eq!(package_count, 2);
870    }
871
872    #[test]
873    fn test_project_set_name_workspace() {
874        let workspace = MockWorkspace::new(Some("test"), Some("1.0.0"), Language::Node);
875        let mut project = Project::Workspace(Box::new(workspace));
876        project.set_name("new-name".to_string());
877        // Mock doesn't override set_name, so default no-op applies
878        assert_eq!(project.name(), Some("test"));
879    }
880
881    #[test]
882    fn test_project_set_name_package() {
883        let package = MockPackage::new(Some("test"), Some("1.0.0"), Language::Rust);
884        let mut project = Project::Package(Box::new(package));
885        project.set_name("new-name".to_string());
886        // Mock doesn't override set_name, so default no-op applies
887        assert_eq!(project.name(), Some("test"));
888    }
889
890    #[test]
891    fn test_project_cmp_is_consistent_with_eq() {
892        // Two workspaces with identical fields
893        let w1 = MockWorkspace::new(Some("same"), Some("1.0.0"), Language::Node);
894        let w2 = MockWorkspace::new(Some("same"), Some("1.0.0"), Language::Node);
895        let p1 = Project::Workspace(Box::new(w1));
896        let p2 = Project::Workspace(Box::new(w2));
897        assert_eq!(p1, p2);
898        assert_eq!(p1.cmp(&p2), Ordering::Equal);
899
900        // Two packages with identical fields
901        let pkg1 = MockPackage::new(Some("same"), Some("1.0.0"), Language::Rust);
902        let pkg2 = MockPackage::new(Some("same"), Some("1.0.0"), Language::Rust);
903        let pp1 = Project::Package(Box::new(pkg1));
904        let pp2 = Project::Package(Box::new(pkg2));
905        assert_eq!(pp1, pp2);
906        assert_eq!(pp1.cmp(&pp2), Ordering::Equal);
907    }
908}