Skip to main content

changepacks_core/
workspace.rs

1use std::{collections::HashSet, path::Path};
2
3use crate::{Language, Package, update_type::UpdateType};
4use anyhow::Result;
5use async_trait::async_trait;
6
7/// Interface for monorepo workspace roots.
8///
9/// Extends Package behavior with workspace-specific operations like updating workspace
10/// dependencies. Implemented by language-specific workspace types.
11#[async_trait]
12pub trait Workspace: std::fmt::Debug + Send + Sync {
13    fn name(&self) -> Option<&str>;
14    fn path(&self) -> &Path;
15    fn relative_path(&self) -> &Path;
16    fn version(&self) -> Option<&str>;
17    /// # Errors
18    /// Returns error if the version update operation fails.
19    async fn update_version(&mut self, update_type: UpdateType) -> Result<()>;
20    fn language(&self) -> Language;
21
22    fn dependencies(&self) -> &HashSet<String>;
23    fn add_dependency(&mut self, dependency: &str);
24
25    fn is_changed(&self) -> bool;
26    fn set_changed(&mut self, changed: bool);
27
28    /// Set the workspace name (used for fallback when name is not found in manifest).
29    /// Implementors typically get this via `impl_basic_accessors!()`.
30    fn set_name(&mut self, name: String);
31
32    /// Get the default publish command for this workspace type
33    fn default_publish_command(&self) -> String;
34
35    /// Get the default dry-run publish command for this workspace type.
36    ///
37    /// Returns `None` for ecosystems whose default publish tool does not
38    /// support a built-in dry-run mode. Users may still provide an override
39    /// via `config.publish_dry_run`.
40    fn default_dry_run_publish_command(&self) -> Option<String>;
41
42    crate::impl_shared_project_defaults!();
43
44    crate::impl_publish_flows!(crate::publish::WORKSPACE_DIR_NOT_FOUND);
45
46    crate::impl_publish_command_resolvers!();
47
48    /// Updates workspace-level dependency versions after package versions are bumped.
49    ///
50    /// This is an intentional no-op in the default implementation. Only `RustWorkspace`
51    /// overrides this method to sync `[workspace.dependencies]` path-dependency versions
52    /// with their corresponding package versions.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error only if a language override's dependency rewrite fails.
57    ///
58    /// Boxed-future shape rather than a defaulted `async fn`, for the reason
59    /// spelled out on [`ProjectFinder::should_visit_manifest`](crate::ProjectFinder::should_visit_manifest):
60    /// `#[async_trait]`'s desugaring of a *defaulted* body is not attributed by
61    /// `llvm-cov`, so the no-op would read as unexecuted forever.
62    fn update_workspace_dependencies<'life0, 'life1, 'life2, 'async_trait>(
63        &'life0 self,
64        _packages: &'life1 [&'life2 dyn Package],
65    ) -> ::core::pin::Pin<
66        ::std::boxed::Box<
67            dyn ::core::future::Future<Output = Result<()>> + ::core::marker::Send + 'async_trait,
68        >,
69    >
70    where
71        'life0: 'async_trait,
72        'life1: 'async_trait,
73        'life2: 'async_trait,
74        Self: ::core::marker::Sync + 'async_trait,
75    {
76        ::std::boxed::Box::pin(async move { Ok(()) })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::Config;
84    use crate::test_support::MockWorkspace;
85    use std::collections::BTreeMap;
86
87    // The eighteen tests pinning the shared trait defaults and the shared
88    // `UnsupportedDryRunProject` fixture are generated from one surface shared
89    // with `package.rs`; only the `Workspace`-only defaults below stay
90    // hand-written here.
91    crate::test_support::shared_project_default_tests!(
92        mock: MockWorkspace,
93        trait_name: Workspace,
94        kind: "workspace",
95        dir_not_found: "Workspace directory not found",
96        publishable_test: test_workspace_is_publishable_by_default,
97    );
98
99    #[test]
100    fn test_get_dry_run_publish_command_falls_back_to_workspace_default() {
101        let workspace =
102            MockWorkspace::with_paths(Some("test"), "/project/package.json", "package.json")
103                .with_language(Language::Node);
104        let config = Config::default();
105
106        // With no override, the trait method returns the workspace's own
107        // `default_dry_run_publish_command()` (here, the MockWorkspace stub).
108        assert_eq!(
109            workspace.get_dry_run_publish_command(&config).as_deref(),
110            Some("echo publish --dry-run")
111        );
112    }
113
114    #[test]
115    fn test_get_dry_run_publish_command_override_by_path() {
116        let workspace = MockWorkspace::with_paths(
117            Some("test"),
118            "/project/package.json",
119            "packages/core/package.json",
120        );
121        let mut publish_dry_run = BTreeMap::new();
122        publish_dry_run.insert(
123            "packages/core/package.json".to_string(),
124            "custom dry".to_string(),
125        );
126        let config = Config {
127            publish_dry_run,
128            ..Default::default()
129        };
130
131        // Per-project override wins over the workspace's own default.
132        assert_eq!(
133            workspace.get_dry_run_publish_command(&config).as_deref(),
134            Some("custom dry")
135        );
136    }
137
138    #[test]
139    fn test_get_dry_run_publish_command_override_by_language() {
140        let workspace =
141            MockWorkspace::with_paths(Some("test"), "/project/package.json", "package.json")
142                .with_language(Language::Node);
143        let mut publish_dry_run = BTreeMap::new();
144        publish_dry_run.insert(
145            "node".to_string(),
146            "npm publish --dry-run --tag next".to_string(),
147        );
148        let config = Config {
149            publish_dry_run,
150            ..Default::default()
151        };
152
153        // Per-language override wins over the workspace's own default.
154        assert_eq!(
155            workspace.get_dry_run_publish_command(&config).as_deref(),
156            Some("npm publish --dry-run --tag next")
157        );
158    }
159
160    #[tokio::test]
161    async fn test_update_workspace_dependencies_default() {
162        let workspace =
163            MockWorkspace::with_paths(Some("test"), "/project/package.json", "package.json");
164        let packages: Vec<&dyn Package> = vec![];
165
166        let result = workspace.update_workspace_dependencies(&packages).await;
167        assert!(result.is_ok());
168    }
169}