| 1 | use std::env::VarError; |
| 2 | use std::ffi::{OsStr, OsString}; |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | use std::sync::Arc; |
| 5 | |
| 6 | use super::discovery::DiscoveryConfig; |
| 7 | use super::registry::PluginRegistry; |
| 8 | |
| 9 | /// Immutable environment inherited from the host before workspace dotenv is |
| 10 | /// loaded. Reviewed plugins may resolve only values from this snapshot. |
| 11 | #[derive(Debug, Clone, Default)] |
| 12 | pub struct HostEnvironment { |
| 13 | entries: Arc<[(OsString, OsString)]>, |
| 14 | } |
| 15 | |
| 16 | impl HostEnvironment { |
| 17 | #[must_use] |
| 18 | pub fn capture() -> Self { |
| 19 | Self { |
| 20 | entries: std::env::vars_os().collect::<Vec<_>>().into(), |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | #[cfg(test)] |
| 25 | #[must_use] |
| 26 | pub(crate) fn from_entries(entries: impl IntoIterator<Item = (OsString, OsString)>) -> Self { |
| 27 | Self { |
| 28 | entries: entries.into_iter().collect::<Vec<_>>().into(), |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | #[must_use] |
| 33 | pub fn entries(&self) -> &[(OsString, OsString)] { |
| 34 | &self.entries |
| 35 | } |
| 36 | |
| 37 | #[must_use] |
| 38 | pub fn get_os(&self, name: &OsStr) -> Option<&OsStr> { |
| 39 | self.entries |
| 40 | .iter() |
| 41 | .rev() |
| 42 | .find(|(key, _)| environment_names_equal(key, name)) |
| 43 | .map(|(_, value)| value.as_os_str()) |
| 44 | } |
| 45 | |
| 46 | pub fn var(&self, name: &str) -> Result<String, VarError> { |
| 47 | let Some(value) = self.get_os(OsStr::new(name)) else { |
| 48 | return Err(VarError::NotPresent); |
| 49 | }; |
| 50 | value |
| 51 | .to_str() |
| 52 | .map(str::to_owned) |
| 53 | .ok_or_else(|| VarError::NotUnicode(value.to_os_string())) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | fn environment_names_equal(left: &OsStr, right: &OsStr) -> bool { |
| 58 | #[cfg(windows)] |
| 59 | { |
| 60 | left.to_string_lossy() |
| 61 | .eq_ignore_ascii_case(&right.to_string_lossy()) |
| 62 | } |
| 63 | #[cfg(not(windows))] |
| 64 | { |
| 65 | left == right |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// Process-lifetime plugin discovery inputs captured before repository-local |
| 70 | /// dotenv files can affect the process environment. |
| 71 | #[derive(Debug, Clone)] |
| 72 | pub struct PluginDiscoveryContext { |
| 73 | user_plugins_dir: PathBuf, |
| 74 | state_path: PathBuf, |
| 75 | builtin_plugin_dirs: Arc<[PathBuf]>, |
| 76 | host_environment: Arc<HostEnvironment>, |
| 77 | } |
| 78 | |
| 79 | impl PluginDiscoveryContext { |
| 80 | #[must_use] |
| 81 | pub fn capture_pre_dotenv() -> Arc<Self> { |
| 82 | let user_plugins_dir = super::discovery::default_user_plugins_dir(); |
| 83 | Arc::new(Self { |
| 84 | state_path: user_plugins_dir.join("state.json"), |
| 85 | user_plugins_dir, |
| 86 | builtin_plugin_dirs: Arc::from([]), |
| 87 | host_environment: Arc::new(HostEnvironment::capture()), |
| 88 | }) |
| 89 | } |
| 90 | |
| 91 | #[cfg(test)] |
| 92 | #[must_use] |
| 93 | pub(crate) fn from_config_and_environment( |
| 94 | config: &DiscoveryConfig, |
| 95 | host_environment: HostEnvironment, |
| 96 | ) -> Arc<Self> { |
| 97 | Arc::new(Self { |
| 98 | user_plugins_dir: config.user_plugins_dir.clone(), |
| 99 | state_path: config.state_path.clone(), |
| 100 | builtin_plugin_dirs: config.builtin_plugin_dirs.clone().into(), |
| 101 | host_environment: Arc::new(host_environment), |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | #[must_use] |
| 106 | pub fn registry_for_workspace(self: &Arc<Self>, workspace: &Path) -> Arc<PluginRegistry> { |
| 107 | let config = DiscoveryConfig { |
| 108 | workspace: workspace.to_path_buf(), |
| 109 | user_plugins_dir: self.user_plugins_dir.clone(), |
| 110 | workspace_plugins_dir: super::discovery::default_workspace_plugins_dir(workspace), |
| 111 | builtin_plugin_dirs: self.builtin_plugin_dirs.to_vec(), |
| 112 | state_path: self.state_path.clone(), |
| 113 | }; |
| 114 | Arc::new(super::discovery::discover_with_context( |
| 115 | &config, |
| 116 | Arc::clone(self), |
| 117 | )) |
| 118 | } |
| 119 | |
| 120 | #[must_use] |
| 121 | pub fn host_environment(&self) -> Arc<HostEnvironment> { |
| 122 | Arc::clone(&self.host_environment) |
| 123 | } |
| 124 | |
| 125 | /// The pre-dotenv user plugins root (`~/.codewhale/plugins`). Exposed for |
| 126 | /// the install on-ramp (#5182), which must fetch into exactly the root |
| 127 | /// this context discovers from. |
| 128 | #[must_use] |
| 129 | pub fn user_plugins_dir(&self) -> &Path { |
| 130 | &self.user_plugins_dir |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | #[cfg(test)] |
| 135 | mod tests { |
| 136 | use std::ffi::OsString; |
| 137 | use std::fs; |
| 138 | |
| 139 | use super::{HostEnvironment, PluginDiscoveryContext}; |
| 140 | |
| 141 | #[test] |
| 142 | fn host_environment_is_an_immutable_value_snapshot() { |
| 143 | let snapshot = HostEnvironment::from_entries([( |
| 144 | OsString::from("PLUGIN_TOKEN"), |
| 145 | OsString::from("before"), |
| 146 | )]); |
| 147 | |
| 148 | assert_eq!(snapshot.var("PLUGIN_TOKEN").unwrap(), "before"); |
| 149 | assert!(snapshot.var("MISSING").is_err()); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn discovery_roots_are_frozen_before_later_environment_changes() { |
| 154 | let _lock = crate::test_support::lock_test_env(); |
| 155 | let temp = tempfile::tempdir().unwrap(); |
| 156 | let first_home = temp.path().join("first-home"); |
| 157 | let second_home = temp.path().join("second-home"); |
| 158 | let workspace = temp.path().join("workspace"); |
| 159 | fs::create_dir_all(&workspace).unwrap(); |
| 160 | let _first = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &first_home); |
| 161 | let context = PluginDiscoveryContext::capture_pre_dotenv(); |
| 162 | let _second = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &second_home); |
| 163 | |
| 164 | for (home, name) in [(&first_home, "before"), (&second_home, "after")] { |
| 165 | let bundle = home.join("plugins").join(name); |
| 166 | fs::create_dir_all(&bundle).unwrap(); |
| 167 | fs::write( |
| 168 | bundle.join("plugin.toml"), |
| 169 | format!("schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\n"), |
| 170 | ) |
| 171 | .unwrap(); |
| 172 | } |
| 173 | |
| 174 | let registry = context.registry_for_workspace(&workspace); |
| 175 | assert!(registry.get("before").is_some()); |
| 176 | assert!(registry.get("after").is_none()); |
| 177 | assert_eq!( |
| 178 | registry.state_path(), |
| 179 | Some(first_home.join("plugins/state.json").as_path()) |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn contextless_rediscovery_remains_empty_and_tracks_the_requested_workspace() { |
| 185 | let temp = tempfile::tempdir().unwrap(); |
| 186 | let workspace = temp.path().join("workspace"); |
| 187 | let next_workspace = temp.path().join("next-workspace"); |
| 188 | let bundle = next_workspace.join(".codewhale/plugins/ambient"); |
| 189 | fs::create_dir_all(&bundle).unwrap(); |
| 190 | fs::write( |
| 191 | bundle.join("plugin.toml"), |
| 192 | "schema_version = 1\n[plugin]\nname = \"ambient\"\nversion = \"1.0.0\"\n", |
| 193 | ) |
| 194 | .unwrap(); |
| 195 | |
| 196 | let registry = crate::plugins::PluginRegistry::empty(&workspace) |
| 197 | .rediscover_for_workspace(&next_workspace); |
| 198 | assert!(registry.is_empty()); |
| 199 | assert_eq!(registry.workspace(), next_workspace); |
| 200 | } |
| 201 | } |
| 202 |