Skip to main content

rs_teststand/workspace/
workspace_file.rs

1//! TestStand `WorkspaceFile` (`IWorkspaceFile`) wrapper.
2
3use super::workspace_object::WorkspaceObject;
4use crate::Error;
5use crate::dispids::workspace_file;
6use rs_teststand_sys::{Dispatch, Value};
7
8/// Safe wrapper for TestStand™ `WorkspaceFile` (`IWorkspaceFile`).
9#[derive(Debug)]
10pub struct WorkspaceFile {
11    dispatch: Box<dyn Dispatch>,
12}
13
14impl WorkspaceFile {
15    /// Creates a new `WorkspaceFile` wrapper around a COM dispatch seam.
16    pub(crate) fn new(dispatch: Box<dyn Dispatch>) -> Self {
17        Self { dispatch }
18    }
19
20    /// The workspace seen as a property-object file (`AsPropertyObjectFile`).
21    ///
22    /// A workspace is a file like any other underneath, so this is the route to
23    /// its stored data and its registered types.
24    ///
25    /// # Errors
26    /// [`Error`] if the COM call fails or returns an unexpected type.
27    pub fn as_property_object_file(&self) -> Result<crate::PropertyObjectFile, Error> {
28        Ok(crate::PropertyObjectFile::new(
29            self.dispatch
30                .call(workspace_file::AS_PROPERTY_OBJECT_FILE, &[])?
31                .into_object()?,
32        ))
33    }
34
35    /// Accesses the root workspace object (`WorkspaceFile.RootWorkspaceObject`).
36    ///
37    /// # Errors
38    /// [`Error`] if the COM call fails or returns an unexpected type.
39    pub fn root_workspace_object(&self) -> Result<WorkspaceObject, Error> {
40        let dispatch = self
41            .dispatch
42            .get(workspace_file::ROOT_WORKSPACE_OBJECT)?
43            .into_object()?;
44        Ok(WorkspaceObject::new(dispatch))
45    }
46
47    /// The sentinel the engine returns when a workspace names no provider.
48    ///
49    /// Measured against a live engine: the value comes back as this literal
50    /// string, not as a COM null, so it must be recognized by text.
51    const NO_PROVIDER: &'static str = "<None>";
52
53    /// The source code control provider this workspace names
54    /// (`WorkspaceFile.ProviderName`).
55    ///
56    /// Three states, all distinct:
57    ///
58    /// * `None`, the workspace names no provider.
59    /// * `Some("")`, defer to the system default provider.
60    /// * `Some(name)`, that named provider.
61    ///
62    /// # Errors
63    /// [`Error`] if the COM call fails or returns an unexpected type.
64    pub fn provider_name(&self) -> Result<Option<String>, Error> {
65        match self.dispatch.get(workspace_file::PROVIDER_NAME)? {
66            Value::Str(name) if name == Self::NO_PROVIDER => Ok(None),
67            Value::Str(name) => Ok(Some(name)),
68            Value::Null | Value::Empty => Ok(None),
69            other => Err(Error::UnexpectedType {
70                expected: "String or Null",
71                actual: other.kind(),
72            }),
73        }
74    }
75
76    /// Names the source code control provider (`WorkspaceFile.ProviderName`).
77    ///
78    /// Pass an empty string to defer to the system default provider.
79    ///
80    /// # Errors
81    /// [`Error`] if the COM call fails.
82    pub fn set_provider_name(&self, name: &str) -> Result<(), Error> {
83        self.dispatch
84            .put(workspace_file::PROVIDER_NAME, Value::Str(name.to_owned()))?;
85        Ok(())
86    }
87
88    /// Whether this workspace is connected to a source code control provider
89    /// (`WorkspaceFile.IsConnectedToSCProvider`).
90    ///
91    /// Connection is a side effect of the engine adopting the workspace, not
92    /// something this type performs, so a freshly opened file can report
93    /// `false` until the engine takes it as the current workspace.
94    ///
95    /// # Errors
96    /// [`Error`] if the COM call fails or returns an unexpected type.
97    pub fn is_connected_to_sc_provider(&self) -> Result<bool, Error> {
98        Ok(self
99            .dispatch
100            .get(workspace_file::IS_CONNECTED_TO_SC_PROVIDER)?
101            .as_bool()?)
102    }
103
104    /// Writes the workspace and its projects to disk if they have been modified
105    /// (`WorkspaceFile.SaveWorkspaceAndProjectFiles`).
106    ///
107    /// **This method can raise a dialog and is therefore unsafe on an
108    /// unattended host.** When there are modifications it asks the user before
109    /// writing, and a `false` return means precisely that they declined, not
110    /// that the save failed. Nothing is prompted or written when nothing has
111    /// changed. A failure to write is reported as an error naming the files,
112    /// not as `false`.
113    ///
114    /// Engine-level dialog suppression does not cover this one; it is a
115    /// deliberate confirmation, not a configurable prompt. Guard the call with a
116    /// [`Watchdog`](crate::Watchdog) if a service must make it at all.
117    ///
118    /// # Errors
119    /// [`Error`] if the COM call fails or any file cannot be written.
120    pub fn save_workspace_and_project_files(&self, options: i32) -> Result<bool, Error> {
121        Ok(self
122            .dispatch
123            .call(
124                workspace_file::SAVE_WORKSPACE_AND_PROJECT_FILES,
125                &[Value::I32(options)],
126            )?
127            .as_bool()?)
128    }
129
130    /// Finds a workspace object by lookup path (`WorkspaceFile.FindWorkspaceObject`).
131    ///
132    /// # Errors
133    /// [`Error`] if the COM call fails or returns an unexpected type.
134    pub fn find_workspace_object(&self, path: &str) -> Result<Option<WorkspaceObject>, Error> {
135        let val = self.dispatch.call(
136            workspace_file::FIND_WORKSPACE_OBJECT,
137            &[Value::Str(path.to_string())],
138        )?;
139        match val {
140            Value::Object(dispatch) => Ok(Some(WorkspaceObject::new(dispatch))),
141            Value::Null | Value::Empty => Ok(None),
142            other => Err(Error::UnexpectedType {
143                expected: "Object or Null",
144                actual: other.kind(),
145            }),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::WorkspaceFile;
153    use crate::Error;
154    use crate::dispids::workspace_file;
155    use rs_teststand_sys::{ComError, Value};
156    use std::collections::HashMap;
157
158    #[derive(Debug)]
159    struct FakeDispatch {
160        responses: HashMap<i32, Value>,
161    }
162
163    impl rs_teststand_sys::Dispatch for FakeDispatch {
164        fn get(&self, _dispid: i32) -> Result<Value, ComError> {
165            Err(ComError::hresult(0, "fake"))
166        }
167
168        fn put(&self, _dispid: i32, _value: Value) -> Result<(), ComError> {
169            Err(ComError::hresult(0, "fake"))
170        }
171
172        fn call(&self, dispid: i32, _args: &[Value]) -> Result<Value, ComError> {
173            self.responses.get(&dispid).map_or_else(
174                || Err(ComError::hresult(0, "fake: unscripted dispid")),
175                |val| match val {
176                    Value::Null => Ok(Value::Null),
177                    _ => Err(ComError::hresult(0, "fake")),
178                },
179            )
180        }
181    }
182
183    #[test]
184    fn find_workspace_object_returns_none_when_null() -> Result<(), Error> {
185        let fake = FakeDispatch {
186            responses: HashMap::from([(workspace_file::FIND_WORKSPACE_OBJECT, Value::Null)]),
187        };
188        let ws = WorkspaceFile::new(Box::new(fake));
189        let obj = ws.find_workspace_object("nonexistent")?;
190        assert!(obj.is_none());
191        Ok(())
192    }
193    /// A fake whose property reads are scripted, for the getter-backed members.
194    #[derive(Debug)]
195    struct FakeProps {
196        properties: HashMap<i32, Value>,
197    }
198
199    impl rs_teststand_sys::Dispatch for FakeProps {
200        fn get(&self, dispid: i32) -> Result<Value, ComError> {
201            match self.properties.get(&dispid) {
202                Some(Value::Str(text)) => Ok(Value::Str(text.clone())),
203                Some(Value::Bool(flag)) => Ok(Value::Bool(*flag)),
204                Some(Value::Null) => Ok(Value::Null),
205                Some(Value::I32(number)) => Ok(Value::I32(*number)),
206                _ => Err(ComError::hresult(0, "fake: unscripted dispid")),
207            }
208        }
209
210        fn put(&self, _dispid: i32, _value: Value) -> Result<(), ComError> {
211            Ok(())
212        }
213
214        fn call(&self, _dispid: i32, _args: &[Value]) -> Result<Value, ComError> {
215            Err(ComError::hresult(0, "fake: unscripted call"))
216        }
217    }
218
219    fn with_properties(pairs: Vec<(i32, Value)>) -> WorkspaceFile {
220        WorkspaceFile::new(Box::new(FakeProps {
221            properties: pairs.into_iter().collect(),
222        }))
223    }
224
225    #[test]
226    fn the_no_provider_sentinel_reads_as_none() -> Result<(), Error> {
227        // Measured on a live engine: "no provider" arrives as the literal string
228        // "<None>", not as a COM null. Returning it verbatim would push the
229        // sentinel onto every caller.
230        let file = with_properties(vec![(
231            workspace_file::PROVIDER_NAME,
232            Value::Str("<None>".to_owned()),
233        )]);
234        assert_eq!(file.provider_name()?, None);
235        Ok(())
236    }
237
238    #[test]
239    fn a_null_provider_also_reads_as_none() -> Result<(), Error> {
240        // Defensive: the sentinel is what this engine returns, but a null would
241        // mean the same thing and must not become an error.
242        let file = with_properties(vec![(workspace_file::PROVIDER_NAME, Value::Null)]);
243        assert_eq!(file.provider_name()?, None);
244        Ok(())
245    }
246
247    #[test]
248    fn an_empty_provider_name_is_not_the_same_as_none() -> Result<(), Error> {
249        // Empty means "use the system default"; none means the workspace says
250        // nothing about source control. Collapsing the two would lose that.
251        let file = with_properties(vec![(
252            workspace_file::PROVIDER_NAME,
253            Value::Str(String::new()),
254        )]);
255        assert_eq!(file.provider_name()?, Some(String::new()));
256        Ok(())
257    }
258
259    #[test]
260    fn a_named_provider_is_returned_verbatim() -> Result<(), Error> {
261        let file = with_properties(vec![(
262            workspace_file::PROVIDER_NAME,
263            Value::Str("Perforce SCM".to_owned()),
264        )]);
265        assert_eq!(file.provider_name()?, Some("Perforce SCM".to_owned()));
266        Ok(())
267    }
268
269    #[test]
270    fn provider_connection_state_is_read_as_a_flag() -> Result<(), Error> {
271        let file = with_properties(vec![(
272            workspace_file::IS_CONNECTED_TO_SC_PROVIDER,
273            Value::Bool(true),
274        )]);
275        assert!(file.is_connected_to_sc_provider()?);
276        Ok(())
277    }
278
279    #[test]
280    fn an_unexpected_provider_type_is_reported_not_swallowed() {
281        let file = with_properties(vec![(workspace_file::PROVIDER_NAME, Value::I32(7))]);
282        assert!(matches!(
283            file.provider_name(),
284            Err(Error::UnexpectedType { .. })
285        ));
286    }
287}