Skip to main content

wdl_modules/project/
document.rs

1use thiserror::Error;
2
3use crate::Manifest;
4use crate::dependency::DependencyName;
5use crate::dependency::DependencySource;
6use crate::manifest::ManifestError;
7
8/// An error parsing or editing a `module.json` document.
9#[derive(Debug, Error)]
10pub enum ManifestDocumentError {
11    /// The `module.json` bytes failed strict manifest validation.
12    #[error("invalid module manifest")]
13    Manifest(#[from] ManifestError),
14
15    /// The `module.json` bytes failed JSON parsing or serialization.
16    #[error("invalid manifest JSON")]
17    Json(#[from] serde_json::Error),
18
19    /// The root JSON value was not an object.
20    #[error("`module.json` root must be an object")]
21    RootNotObject,
22
23    /// The `dependencies` value was present but not an object.
24    #[error("`dependencies` in `module.json` must be an object")]
25    DependenciesNotObject,
26}
27
28/// A lossless `module.json` document paired with its validated manifest view.
29///
30/// This value stays in memory until a caller writes [`Self::to_bytes`] back to
31/// the exact `module.json` path chosen by [`super::ModuleProject`]. Each edit
32/// preserves unrelated extension fields and keeps [`Self::manifest`] in step
33/// with the document; an edit that cannot be represented fails before either
34/// one changes.
35///
36/// ```rust
37/// use std::path::PathBuf;
38///
39/// use wdl_modules::dependency::DependencySource;
40/// use wdl_modules::project::ManifestDocument;
41///
42/// let mut document = ManifestDocument::parse(
43///     br#"{
44///   "name": "spellbook",
45///   "license": "MIT",
46///   "x-extra": { "kept": true }
47/// }"#,
48/// )?;
49///
50/// document.insert_dependency(
51///     "helpers",
52///     &DependencySource::LocalPath {
53///         path: PathBuf::from("../helpers"),
54///         extra: serde_json::Map::new(),
55///     },
56/// )?;
57///
58/// let serialized = String::from_utf8(document.to_bytes()?)?;
59/// assert!(serialized.contains(r#""helpers""#));
60/// let dependency_name = "helpers".parse()?;
61/// assert_eq!(
62///     document.manifest().dependencies.get(&dependency_name),
63///     Some(&DependencySource::LocalPath {
64///         path: PathBuf::from("../helpers"),
65///         extra: serde_json::Map::new(),
66///     })
67/// );
68/// # Ok::<(), Box<dyn std::error::Error>>(())
69/// ```
70#[derive(Clone, Debug)]
71pub struct ManifestDocument {
72    /// Raw JSON value preserved for lossless editing of `module.json`.
73    value: serde_json::Value,
74    /// Strictly validated manifest view derived from `value`.
75    manifest: Manifest,
76}
77
78impl ManifestDocument {
79    /// Parses raw `module.json` bytes without discarding unknown extension
80    /// fields.
81    pub fn parse(bytes: &[u8]) -> Result<Self, ManifestDocumentError> {
82        let manifest = Manifest::parse(bytes)?;
83        let value = serde_json::from_slice(bytes)?;
84        Ok(Self { value, manifest })
85    }
86
87    /// Returns the validated manifest view for the latest accepted
88    /// `module.json` bytes.
89    pub fn manifest(&self) -> &Manifest {
90        &self.manifest
91    }
92
93    /// Inserts or replaces one dependency entry in `module.json`.
94    ///
95    /// Unrelated manifest fields and dependency extension fields stay intact.
96    /// The name is parsed and the source serialized before the document
97    /// changes, so a rejected edit leaves both the document and the validated
98    /// view untouched.
99    pub fn insert_dependency(
100        &mut self,
101        name: &str,
102        source: &DependencySource,
103    ) -> Result<(), ManifestDocumentError> {
104        let name = Self::parse_dependency_name(name)?;
105        let serialized = serde_json::to_value(source)?;
106        // Dependency names treat hyphens and underscores as the same
107        // character, so `spell-book` and `spell_book` are one dependency while
108        // the JSON object is keyed by whichever spelling the author wrote.
109        // Inserting the normalized key alone would leave the author's spelling
110        // behind as a second entry for the same dependency.
111        let superseded = self
112            .matching_dependency_key(&name)
113            .filter(|existing| *existing != name.manifest())
114            .map(str::to_string);
115        let root = self
116            .value
117            .as_object_mut()
118            .ok_or(ManifestDocumentError::RootNotObject)?;
119        let dependencies = root
120            .entry("dependencies")
121            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
122            .as_object_mut()
123            .ok_or(ManifestDocumentError::DependenciesNotObject)?;
124        if let Some(superseded) = superseded {
125            dependencies.remove(&superseded);
126        }
127        dependencies.insert(name.manifest().to_string(), serialized);
128        dependencies.sort_keys();
129        // `DependencyName` compares by normalized identifier, so inserting
130        // over an equivalent key would keep the old spelling in the map while
131        // the document carries the new one.
132        self.manifest.dependencies.remove(&name);
133        self.manifest.dependencies.insert(name, source.clone());
134        Ok(())
135    }
136
137    /// Removes a dependency from `module.json` when present.
138    ///
139    /// Returns `true` when a dependency was removed, and leaves the document
140    /// untouched when the dependency is absent.
141    pub fn remove_dependency(&mut self, name: &str) -> Result<bool, ManifestDocumentError> {
142        let name = Self::parse_dependency_name(name)?;
143        let Some(existing) = self.matching_dependency_key(&name).map(str::to_string) else {
144            return Ok(false);
145        };
146        let root = self
147            .value
148            .as_object_mut()
149            .ok_or(ManifestDocumentError::RootNotObject)?;
150        let Some(dependencies) = root.get_mut("dependencies") else {
151            return Ok(false);
152        };
153        let removed = dependencies
154            .as_object_mut()
155            .ok_or(ManifestDocumentError::DependenciesNotObject)?
156            .remove(&existing)
157            .is_some();
158        if removed {
159            self.manifest.dependencies.remove(&name);
160        }
161        Ok(removed)
162    }
163
164    /// Serializes the current document as pretty-printed `module.json` bytes
165    /// with a trailing newline.
166    pub fn to_bytes(&self) -> Result<Vec<u8>, ManifestDocumentError> {
167        let mut bytes = serde_json::to_vec_pretty(&self.value)?;
168        bytes.push(b'\n');
169        Ok(bytes)
170    }
171
172    /// Parses one dependency key with manifest validation rules.
173    fn parse_dependency_name(name: &str) -> Result<DependencyName, ManifestDocumentError> {
174        name.parse().map_err(|source| {
175            ManifestDocumentError::Manifest(ManifestError::InvalidDependencyName {
176                name: name.to_string(),
177                source,
178            })
179        })
180    }
181
182    /// Finds the stored dependency key that matches `name`.
183    fn matching_dependency_key(&self, name: &DependencyName) -> Option<&str> {
184        self.manifest
185            .dependencies
186            .keys()
187            .find(|existing| *existing == name)
188            .map(DependencyName::manifest)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::dependency::DependencySource;
196
197    const MANIFEST: &str = r#"{
198      "name": "example",
199      "license": "MIT",
200      "x-extra": { "keep": true },
201      "dependencies": {
202        "zeta": { "path": "./zeta" },
203        "alpha": { "path": "./alpha", "x-source-extra": 7 }
204      }
205    }"#;
206
207    #[test]
208    fn edits_preserve_extensions_and_sort_dependencies() {
209        let mut document = ManifestDocument::parse(MANIFEST.as_bytes()).unwrap();
210        let source: DependencySource =
211            serde_json::from_str(r#"{"path":"./beta","x-source-extra":"kept"}"#).unwrap();
212
213        document.insert_dependency("beta", &source).unwrap();
214
215        let value: serde_json::Value =
216            serde_json::from_slice(&document.to_bytes().unwrap()).unwrap();
217        assert_eq!(value["x-extra"]["keep"], true);
218        assert_eq!(value["dependencies"]["alpha"]["x-source-extra"], 7);
219        assert_eq!(value["dependencies"]["beta"]["x-source-extra"], "kept");
220        assert_eq!(
221            value["dependencies"]
222                .as_object()
223                .unwrap()
224                .keys()
225                .map(String::as_str)
226                .collect::<Vec<_>>(),
227            ["alpha", "beta", "zeta"]
228        );
229    }
230
231    #[test]
232    fn failed_edit_leaves_document_unchanged() {
233        let mut document = ManifestDocument::parse(MANIFEST.as_bytes()).unwrap();
234        let before = document.to_bytes().unwrap();
235        let source: DependencySource =
236            serde_json::from_str(r#"{"path":"./bad","x-source-extra":null}"#).unwrap();
237
238        let error = document
239            .insert_dependency("not valid", &source)
240            .expect_err("an invalid dependency name must fail strict manifest parsing");
241
242        assert!(error.to_string().contains("manifest"));
243        assert_eq!(document.to_bytes().unwrap(), before);
244    }
245
246    #[test]
247    fn insert_dependency_replaces_equivalent_hyphenated_name() {
248        let manifest = r#"{
249          "name": "example",
250          "license": "MIT",
251          "dependencies": {
252            "spell-book": { "path": "./alpha" }
253          }
254        }"#;
255        let mut document = ManifestDocument::parse(manifest.as_bytes()).unwrap();
256        let replacement: DependencySource =
257            serde_json::from_str(r#"{"path":"./updated"}"#).unwrap();
258
259        document
260            .insert_dependency("spell_book", &replacement)
261            .unwrap();
262
263        let value: serde_json::Value =
264            serde_json::from_slice(&document.to_bytes().unwrap()).unwrap();
265        assert_eq!(value["dependencies"]["spell_book"]["path"], "./updated");
266        assert!(value["dependencies"]["spell-book"].is_null());
267    }
268
269    #[test]
270    fn insert_dependency_respells_the_key_in_the_manifest_view() {
271        let manifest = r#"{
272          "name": "example",
273          "license": "MIT",
274          "dependencies": {
275            "spell-book": { "path": "./alpha" }
276          }
277        }"#;
278        let mut document = ManifestDocument::parse(manifest.as_bytes()).unwrap();
279        let replacement: DependencySource =
280            serde_json::from_str(r#"{"path":"./updated"}"#).unwrap();
281
282        document
283            .insert_dependency("spell_book", &replacement)
284            .unwrap();
285
286        let spellings: Vec<_> = document
287            .manifest()
288            .dependencies
289            .keys()
290            .map(|name| name.manifest().to_string())
291            .collect();
292        assert_eq!(
293            spellings,
294            vec!["spell_book".to_string()],
295            "the manifest view must carry the spelling written to the document"
296        );
297    }
298
299    #[test]
300    fn remove_dependency_matches_equivalent_hyphenated_name() {
301        let manifest = r#"{
302          "name": "example",
303          "license": "MIT",
304          "dependencies": {
305            "spell-book": { "path": "./alpha" }
306          }
307        }"#;
308        let mut document = ManifestDocument::parse(manifest.as_bytes()).unwrap();
309
310        assert!(document.remove_dependency("spell_book").unwrap());
311
312        let value: serde_json::Value =
313            serde_json::from_slice(&document.to_bytes().unwrap()).unwrap();
314        assert!(value["dependencies"]["spell-book"].is_null());
315    }
316
317    #[test]
318    fn remove_reports_presence_and_preserves_other_fields() {
319        let mut document = ManifestDocument::parse(MANIFEST.as_bytes()).unwrap();
320        assert!(document.remove_dependency("alpha").unwrap());
321        assert!(!document.remove_dependency("missing").unwrap());
322
323        let value: serde_json::Value =
324            serde_json::from_slice(&document.to_bytes().unwrap()).unwrap();
325        assert_eq!(value["x-extra"]["keep"], true);
326        assert_eq!(value["dependencies"]["zeta"]["path"], "./zeta");
327    }
328
329    #[test]
330    fn serialization_is_pretty_and_newline_terminated() {
331        let document = ManifestDocument::parse(MANIFEST.as_bytes()).unwrap();
332        let bytes = document.to_bytes().unwrap();
333        assert!(bytes.ends_with(b"\n"));
334        assert!(bytes.windows(2).any(|window| window == b"\n "));
335    }
336}