Skip to main content

mcpls_core/config/
workspace_root.rs

1//! A configured workspace root path that is never empty.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::bridge::join_relative_root;
8
9/// A `workspace.roots` entry was empty.
10#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
11#[error("workspace.roots entries cannot be empty")]
12pub struct InvalidWorkspaceRoot;
13
14/// A non-empty `workspace.roots` entry, kept as written.
15///
16/// An empty path is `is_relative()` and joins onto a base directory as that
17/// directory, which is almost never what an empty string in a config file was
18/// meant to say, so it is rejected when the value is built or deserialized.
19///
20/// # Examples
21///
22/// ```
23/// use mcpls_core::config::ConfiguredRoot;
24///
25/// let root = ConfiguredRoot::new("/work/project").unwrap();
26/// assert_eq!(root.as_path(), std::path::Path::new("/work/project"));
27/// assert!(ConfiguredRoot::new("").is_err());
28/// ```
29#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(try_from = "PathBuf", into = "PathBuf")]
31pub struct ConfiguredRoot(PathBuf);
32
33impl ConfiguredRoot {
34    /// Builds a root from any path.
35    ///
36    /// # Errors
37    ///
38    /// Returns [`InvalidWorkspaceRoot`] if `root` is empty.
39    pub fn new(root: impl Into<PathBuf>) -> Result<Self, InvalidWorkspaceRoot> {
40        let root = root.into();
41        if root.is_empty() {
42            return Err(InvalidWorkspaceRoot);
43        }
44        Ok(Self(root))
45    }
46
47    /// The root as written.
48    #[must_use]
49    pub fn as_path(&self) -> &Path {
50        &self.0
51    }
52
53    /// This root joined onto `base`, keeping the root as written when the
54    /// join would leave nothing (a bare drive prefix onto an empty base).
55    #[must_use]
56    pub(crate) fn join_onto(&self, base: &Path) -> Self {
57        let joined = join_relative_root(base, &self.0);
58        if joined.is_empty() {
59            self.clone()
60        } else {
61            Self(joined)
62        }
63    }
64}
65
66impl TryFrom<PathBuf> for ConfiguredRoot {
67    type Error = InvalidWorkspaceRoot;
68
69    fn try_from(root: PathBuf) -> Result<Self, Self::Error> {
70        Self::new(root)
71    }
72}
73
74impl From<ConfiguredRoot> for PathBuf {
75    fn from(root: ConfiguredRoot) -> Self {
76        root.0
77    }
78}
79
80impl AsRef<Path> for ConfiguredRoot {
81    fn as_ref(&self) -> &Path {
82        self.as_path()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_rejects_empty_and_keeps_the_path_as_written() {
92        assert_eq!(ConfiguredRoot::new(""), Err(InvalidWorkspaceRoot));
93        let root = ConfiguredRoot::new("rel/dir").unwrap();
94        assert_eq!(root.as_path(), Path::new("rel/dir"));
95        assert_eq!(PathBuf::from(root), PathBuf::from("rel/dir"));
96    }
97
98    #[test]
99    fn test_deserialization_applies_the_same_rule() {
100        assert!(serde_json::from_str::<ConfiguredRoot>("\"\"").is_err());
101        let root: ConfiguredRoot = serde_json::from_str("\"a\"").unwrap();
102        assert_eq!(serde_json::to_string(&root).unwrap(), "\"a\"");
103    }
104
105    #[test]
106    fn test_join_onto_appends_the_relative_tail() {
107        let root = ConfiguredRoot::new("proj").unwrap();
108        assert_eq!(
109            root.join_onto(Path::new("base")).as_path(),
110            Path::new("base").join("proj")
111        );
112    }
113}