1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Manifest {
15 pub package: PackageSection,
17
18 #[serde(default)]
20 pub dependencies: DependenciesSection,
21
22 #[serde(default, rename = "dev-dependencies")]
24 pub dev_dependencies: BTreeMap<String, DependencySpec>,
25
26 #[serde(default)]
28 pub features: BTreeMap<String, Vec<String>>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackageSection {
34 pub name: String,
36
37 pub version: String,
39
40 #[serde(default)]
42 pub targets: Option<TargetsSection>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TargetsSection {
48 #[serde(default)]
50 pub supported: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum DependencySpec {
57 Simple(String),
59
60 Detailed(DetailedDep),
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DetailedDep {
67 pub version: Option<String>,
69
70 pub path: Option<String>,
72
73 pub registry: Option<String>,
75
76 #[serde(default)]
78 pub features: Vec<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct DependenciesSection {
89 #[serde(default)]
91 pub target: BTreeMap<String, BTreeMap<String, DependencySpec>>,
92
93 #[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 #[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 pub fn parse(s: &str) -> Result<Self, PkgError> {
143 toml::from_str(s).map_err(|e| PkgError::ManifestParse(e.to_string()))
144 }
145
146 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 #[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 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 pub fn add_dependency(&mut self, name: String, version: String) {
173 self.dependencies
174 .insert(name, DependencySpec::Simple(version));
175 }
176
177 pub fn remove_dependency(&mut self, name: &str) -> bool {
179 self.dependencies.remove(name).is_some()
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct WorkspaceManifest {
189 pub workspace: WorkspaceSection,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct WorkspaceSection {
196 #[serde(default)]
198 pub members: Vec<String>,
199
200 #[serde(default)]
202 pub dependencies: BTreeMap<String, DependencySpec>,
203}
204
205impl WorkspaceManifest {
206 pub fn parse(s: &str) -> Result<Self, PkgError> {
208 toml::from_str(s).map_err(|e| PkgError::ManifestParse(e.to_string()))
209 }
210
211 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 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 assert!(manifest.dependencies.common.contains_key("core-http"));
312
313 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 assert!(js.contains_key("core-http"));
343 assert!(js.contains_key("node-adapter"));
345 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 assert!(rust.contains_key("core-http"));
370 assert!(!rust.contains_key("node-adapter"));
372 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 assert_eq!(js["shared-lib"].version_req(), Some("^2.0"));
393
394 let rust = manifest.dependencies_for_target("rust");
395 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}