Skip to main content

bock_pkg/
manifest.rs

1//! Parsing and manipulation of `bock.package` TOML manifest files.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::ops::{Deref, DerefMut};
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::PkgError;
11
12/// A parsed `bock.package` manifest.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Manifest {
15    /// The `[package]` section.
16    pub package: PackageSection,
17
18    /// The `[dependencies]` section, including optional target-specific deps.
19    #[serde(default)]
20    pub dependencies: DependenciesSection,
21
22    /// The `[dev-dependencies]` section.
23    #[serde(default, rename = "dev-dependencies")]
24    pub dev_dependencies: BTreeMap<String, DependencySpec>,
25
26    /// The `[features]` section.
27    #[serde(default)]
28    pub features: BTreeMap<String, Vec<String>>,
29}
30
31/// The `[package]` section of the manifest.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackageSection {
34    /// Package name.
35    pub name: String,
36
37    /// Package version (semver).
38    pub version: String,
39
40    /// Supported compilation targets.
41    #[serde(default)]
42    pub targets: Option<TargetsSection>,
43}
44
45/// The `[package.targets]` section.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TargetsSection {
48    /// List of supported target languages.
49    #[serde(default)]
50    pub supported: Vec<String>,
51}
52
53/// A dependency specification — either a simple version string or an inline table.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum DependencySpec {
57    /// A simple version requirement string, e.g. `"^1.0"`.
58    Simple(String),
59
60    /// A detailed dependency specification.
61    Detailed(DetailedDep),
62}
63
64/// A detailed dependency specification (inline table form).
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DetailedDep {
67    /// Version requirement.
68    pub version: Option<String>,
69
70    /// Path to a local dependency.
71    pub path: Option<String>,
72
73    /// Registry URL.
74    pub registry: Option<String>,
75
76    /// Optional features to enable.
77    #[serde(default)]
78    pub features: Vec<String>,
79}
80
81/// The `[dependencies]` section, supporting both common and target-specific deps.
82///
83/// Common deps are top-level entries like `foo = "^1.0"`. Target-specific deps
84/// live under `[dependencies.target.<target>]` (e.g., `[dependencies.target.js]`).
85///
86/// Implements `Deref`/`DerefMut` to the common deps map for backward compatibility.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct DependenciesSection {
89    /// Target-specific dependencies, keyed by target name.
90    #[serde(default)]
91    pub target: BTreeMap<String, BTreeMap<String, DependencySpec>>,
92
93    /// Common (target-agnostic) dependencies.
94    #[serde(flatten)]
95    pub common: BTreeMap<String, DependencySpec>,
96}
97
98impl Deref for DependenciesSection {
99    type Target = BTreeMap<String, DependencySpec>;
100
101    fn deref(&self) -> &Self::Target {
102        &self.common
103    }
104}
105
106impl DerefMut for DependenciesSection {
107    fn deref_mut(&mut self) -> &mut Self::Target {
108        &mut self.common
109    }
110}
111
112impl DependencySpec {
113    /// Returns the version requirement string, if any.
114    #[must_use]
115    pub fn version_req(&self) -> Option<&str> {
116        match self {
117            DependencySpec::Simple(v) => Some(v.as_str()),
118            DependencySpec::Detailed(d) => d.version.as_deref(),
119        }
120    }
121}
122
123impl fmt::Display for DependencySpec {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            DependencySpec::Simple(v) => write!(f, "{v}"),
127            DependencySpec::Detailed(d) => {
128                if let Some(v) = &d.version {
129                    write!(f, "{v}")
130                } else if let Some(p) = &d.path {
131                    write!(f, "path:{p}")
132                } else {
133                    write!(f, "*")
134                }
135            }
136        }
137    }
138}
139
140impl Manifest {
141    /// Parse a manifest from a TOML string.
142    pub fn parse(s: &str) -> Result<Self, PkgError> {
143        toml::from_str(s).map_err(|e| PkgError::ManifestParse(e.to_string()))
144    }
145
146    /// Read and parse a manifest from a file path.
147    pub fn from_file(path: &Path) -> Result<Self, PkgError> {
148        let content = std::fs::read_to_string(path).map_err(|e| PkgError::Io(e.to_string()))?;
149        Self::parse(&content)
150    }
151
152    /// Return all dependencies for a specific build target.
153    ///
154    /// Merges common (target-agnostic) dependencies with any deps declared
155    /// under `[dependencies.target.<target>]`. Target-specific entries
156    /// override common entries with the same name.
157    #[must_use]
158    pub fn dependencies_for_target(&self, target: &str) -> BTreeMap<String, DependencySpec> {
159        let mut deps = self.dependencies.common.clone();
160        if let Some(target_deps) = self.dependencies.target.get(target) {
161            deps.extend(target_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
162        }
163        deps
164    }
165
166    /// Serialize the manifest back to a TOML string.
167    pub fn to_toml_string(&self) -> Result<String, PkgError> {
168        toml::to_string_pretty(self).map_err(|e| PkgError::ManifestParse(e.to_string()))
169    }
170
171    /// Add a dependency to the manifest.
172    pub fn add_dependency(&mut self, name: String, version: String) {
173        self.dependencies
174            .insert(name, DependencySpec::Simple(version));
175    }
176
177    /// Remove a dependency from the manifest. Returns `true` if removed.
178    pub fn remove_dependency(&mut self, name: &str) -> bool {
179        self.dependencies.remove(name).is_some()
180    }
181}
182
183/// A workspace manifest parsed from `bock.project`.
184///
185/// Workspaces allow multiple packages to share a single repository
186/// and optionally share dependency versions.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct WorkspaceManifest {
189    /// The `[workspace]` section.
190    pub workspace: WorkspaceSection,
191}
192
193/// The `[workspace]` section of a workspace manifest.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WorkspaceSection {
196    /// Member package directories (relative paths).
197    #[serde(default)]
198    pub members: Vec<String>,
199
200    /// Shared dependency versions inherited by workspace members.
201    #[serde(default)]
202    pub dependencies: BTreeMap<String, DependencySpec>,
203}
204
205impl WorkspaceManifest {
206    /// Parse a workspace manifest from a TOML string.
207    pub fn parse(s: &str) -> Result<Self, PkgError> {
208        toml::from_str(s).map_err(|e| PkgError::ManifestParse(e.to_string()))
209    }
210
211    /// Read and parse a workspace manifest from a file path.
212    pub fn from_file(path: &Path) -> Result<Self, PkgError> {
213        let content = std::fs::read_to_string(path).map_err(|e| PkgError::Io(e.to_string()))?;
214        Self::parse(&content)
215    }
216
217    /// Serialize the workspace manifest to a TOML string.
218    pub fn to_toml_string(&self) -> Result<String, PkgError> {
219        toml::to_string_pretty(self).map_err(|e| PkgError::ManifestParse(e.to_string()))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parse_basic_manifest() {
229        let toml = r#"
230[package]
231name = "http-framework"
232version = "2.1.0"
233
234[package.targets]
235supported = ["js", "rust", "go"]
236
237[dependencies]
238core-http = "^1.0"
239
240[dev-dependencies]
241test-client = "^1.0"
242
243[features]
244default = ["json"]
245"#;
246        let manifest = Manifest::parse(toml).unwrap();
247        assert_eq!(manifest.package.name, "http-framework");
248        assert_eq!(manifest.package.version, "2.1.0");
249        assert_eq!(
250            manifest.package.targets.as_ref().unwrap().supported,
251            vec!["js", "rust", "go"]
252        );
253        assert!(manifest.dependencies.contains_key("core-http"));
254        assert!(manifest.dev_dependencies.contains_key("test-client"));
255        assert_eq!(manifest.features["default"], vec!["json"]);
256    }
257
258    #[test]
259    fn add_and_remove_dependency() {
260        let toml = r#"
261[package]
262name = "my-app"
263version = "0.1.0"
264"#;
265        let mut manifest = Manifest::parse(toml).unwrap();
266        manifest.add_dependency("foo".into(), "^1.0".into());
267        assert!(manifest.dependencies.contains_key("foo"));
268
269        assert!(manifest.remove_dependency("foo"));
270        assert!(!manifest.dependencies.contains_key("foo"));
271        assert!(!manifest.remove_dependency("nonexistent"));
272    }
273
274    #[test]
275    fn roundtrip_serialize() {
276        let toml = r#"
277[package]
278name = "test-pkg"
279version = "1.0.0"
280
281[dependencies]
282dep-a = "^2.0"
283"#;
284        let manifest = Manifest::parse(toml).unwrap();
285        let serialized = manifest.to_toml_string().unwrap();
286        let reparsed = Manifest::parse(&serialized).unwrap();
287        assert_eq!(reparsed.package.name, "test-pkg");
288        assert!(reparsed.dependencies.contains_key("dep-a"));
289    }
290
291    #[test]
292    fn parse_manifest_with_target_deps() {
293        let toml = r#"
294[package]
295name = "cross-platform"
296version = "1.0.0"
297
298[dependencies]
299core-http = "^1.0"
300
301[dependencies.target.js]
302node-adapter = "^1.0"
303dom-shim = "^2.0"
304
305[dependencies.target.rust]
306tokio-compat = "^0.3"
307"#;
308        let manifest = Manifest::parse(toml).unwrap();
309
310        // Common dep present
311        assert!(manifest.dependencies.common.contains_key("core-http"));
312
313        // Target deps present
314        assert_eq!(manifest.dependencies.target.len(), 2);
315        let js_deps = &manifest.dependencies.target["js"];
316        assert!(js_deps.contains_key("node-adapter"));
317        assert!(js_deps.contains_key("dom-shim"));
318        let rust_deps = &manifest.dependencies.target["rust"];
319        assert!(rust_deps.contains_key("tokio-compat"));
320    }
321
322    #[test]
323    fn js_deps_included_when_target_is_js() {
324        let toml = r#"
325[package]
326name = "cross-platform"
327version = "1.0.0"
328
329[dependencies]
330core-http = "^1.0"
331
332[dependencies.target.js]
333node-adapter = "^1.0"
334
335[dependencies.target.rust]
336tokio-compat = "^0.3"
337"#;
338        let manifest = Manifest::parse(toml).unwrap();
339        let js = manifest.dependencies_for_target("js");
340
341        // Common dep included
342        assert!(js.contains_key("core-http"));
343        // JS-specific dep included
344        assert!(js.contains_key("node-adapter"));
345        // Rust-specific dep excluded
346        assert!(!js.contains_key("tokio-compat"));
347    }
348
349    #[test]
350    fn js_deps_excluded_when_target_is_rust() {
351        let toml = r#"
352[package]
353name = "cross-platform"
354version = "1.0.0"
355
356[dependencies]
357core-http = "^1.0"
358
359[dependencies.target.js]
360node-adapter = "^1.0"
361
362[dependencies.target.rust]
363tokio-compat = "^0.3"
364"#;
365        let manifest = Manifest::parse(toml).unwrap();
366        let rust = manifest.dependencies_for_target("rust");
367
368        // Common dep included
369        assert!(rust.contains_key("core-http"));
370        // JS-specific dep excluded
371        assert!(!rust.contains_key("node-adapter"));
372        // Rust-specific dep included
373        assert!(rust.contains_key("tokio-compat"));
374    }
375
376    #[test]
377    fn target_dep_overrides_common_dep() {
378        let toml = r#"
379[package]
380name = "override-test"
381version = "1.0.0"
382
383[dependencies]
384shared-lib = "^1.0"
385
386[dependencies.target.js]
387shared-lib = "^2.0"
388"#;
389        let manifest = Manifest::parse(toml).unwrap();
390        let js = manifest.dependencies_for_target("js");
391        // Target-specific version overrides the common one
392        assert_eq!(js["shared-lib"].version_req(), Some("^2.0"));
393
394        let rust = manifest.dependencies_for_target("rust");
395        // Without target override, common version is used
396        assert_eq!(rust["shared-lib"].version_req(), Some("^1.0"));
397    }
398
399    #[test]
400    fn no_target_deps_returns_common_only() {
401        let toml = r#"
402[package]
403name = "simple"
404version = "1.0.0"
405
406[dependencies]
407foo = "^1.0"
408"#;
409        let manifest = Manifest::parse(toml).unwrap();
410        assert!(manifest.dependencies.target.is_empty());
411
412        let deps = manifest.dependencies_for_target("js");
413        assert_eq!(deps.len(), 1);
414        assert!(deps.contains_key("foo"));
415    }
416
417    #[test]
418    fn parse_detailed_dependency() {
419        let toml = r#"
420[package]
421name = "test"
422version = "1.0.0"
423
424[dependencies]
425local-dep = { path = "../local-dep" }
426featured = { version = "^1.0", features = ["extra"] }
427"#;
428        let manifest = Manifest::parse(toml).unwrap();
429        let local = &manifest.dependencies["local-dep"];
430        assert!(
431            matches!(local, DependencySpec::Detailed(d) if d.path.as_deref() == Some("../local-dep"))
432        );
433        let featured = &manifest.dependencies["featured"];
434        assert_eq!(featured.version_req(), Some("^1.0"));
435    }
436
437    #[test]
438    fn parse_workspace_manifest() {
439        let toml = r#"
440[workspace]
441members = ["packages/core", "packages/web"]
442
443[workspace.dependencies]
444shared-dep = "^1.0"
445logging = { version = "^2.0", features = ["color"] }
446"#;
447        let ws = WorkspaceManifest::parse(toml).unwrap();
448        assert_eq!(ws.workspace.members, vec!["packages/core", "packages/web"]);
449        assert!(ws.workspace.dependencies.contains_key("shared-dep"));
450        assert!(ws.workspace.dependencies.contains_key("logging"));
451        assert_eq!(
452            ws.workspace.dependencies["shared-dep"].version_req(),
453            Some("^1.0")
454        );
455    }
456
457    #[test]
458    fn parse_workspace_empty_members() {
459        let toml = r#"
460[workspace]
461members = []
462"#;
463        let ws = WorkspaceManifest::parse(toml).unwrap();
464        assert!(ws.workspace.members.is_empty());
465        assert!(ws.workspace.dependencies.is_empty());
466    }
467
468    #[test]
469    fn workspace_roundtrip() {
470        let toml = r#"
471[workspace]
472members = ["crates/a", "crates/b"]
473
474[workspace.dependencies]
475common = "^1.0"
476"#;
477        let ws = WorkspaceManifest::parse(toml).unwrap();
478        let serialized = ws.to_toml_string().unwrap();
479        let reparsed = WorkspaceManifest::parse(&serialized).unwrap();
480        assert_eq!(reparsed.workspace.members, vec!["crates/a", "crates/b"]);
481        assert!(reparsed.workspace.dependencies.contains_key("common"));
482    }
483}