1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Manifest {
7 pub project: ProjectConfig,
8 #[serde(default)]
9 pub dependencies: BTreeMap<String, DependencySpec>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ProjectConfig {
15 pub name: String,
16 pub version: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub edition: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub authors: Option<Vec<String>>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub description: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub entry: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(untagged)]
30pub enum DependencySpec {
31 Simple(String),
33 Detailed(DetailedDep),
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DetailedDep {
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub version: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub git: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub branch: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub tag: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub rev: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub path: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DepSourceKind {
57 Registry,
58 Git,
59 Path,
60}
61
62impl DependencySpec {
63 pub fn source_kind(&self) -> DepSourceKind {
65 match self {
66 DependencySpec::Simple(_) => DepSourceKind::Registry,
67 DependencySpec::Detailed(d) => {
68 if d.git.is_some() {
69 DepSourceKind::Git
70 } else if d.path.is_some() {
71 DepSourceKind::Path
72 } else {
73 DepSourceKind::Registry
74 }
75 }
76 }
77 }
78}
79
80impl Manifest {
81 pub fn from_toml(s: &str) -> Result<Self, String> {
83 toml::from_str(s).map_err(|e| format!("Failed to parse tl.toml: {e}"))
84 }
85
86 pub fn load(path: &std::path::Path) -> Result<Self, String> {
88 let content = std::fs::read_to_string(path)
89 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
90 Self::from_toml(&content)
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn parse_manifest_simple_deps() {
100 let toml = r#"
101[project]
102name = "myapp"
103version = "0.1.0"
104
105[dependencies]
106utils = "1.0"
107helpers = "^2.0"
108"#;
109 let m = Manifest::from_toml(toml).unwrap();
110 assert_eq!(m.project.name, "myapp");
111 assert_eq!(m.dependencies.len(), 2);
112 assert!(matches!(&m.dependencies["utils"], DependencySpec::Simple(v) if v == "1.0"));
113 }
114
115 #[test]
116 fn parse_manifest_detailed_deps() {
117 let toml = r#"
118[project]
119name = "myapp"
120version = "0.1.0"
121
122[dependencies]
123mylib = { path = "../mylib" }
124remote = { git = "https://github.com/user/remote.git", branch = "main" }
125versioned = { version = "1.2", git = "https://github.com/user/versioned.git", tag = "v1.2.0" }
126"#;
127 let m = Manifest::from_toml(toml).unwrap();
128 assert_eq!(m.dependencies.len(), 3);
129
130 match &m.dependencies["mylib"] {
131 DependencySpec::Detailed(d) => {
132 assert_eq!(d.path.as_deref(), Some("../mylib"));
133 assert!(d.git.is_none());
134 }
135 _ => panic!("expected Detailed"),
136 }
137
138 match &m.dependencies["remote"] {
139 DependencySpec::Detailed(d) => {
140 assert!(d.git.is_some());
141 assert_eq!(d.branch.as_deref(), Some("main"));
142 }
143 _ => panic!("expected Detailed"),
144 }
145 }
146
147 #[test]
148 fn parse_manifest_no_deps() {
149 let toml = r#"
150[project]
151name = "legacy"
152version = "0.1.0"
153"#;
154 let m = Manifest::from_toml(toml).unwrap();
155 assert!(m.dependencies.is_empty());
156 }
157
158 #[test]
159 fn source_kind_detection() {
160 assert_eq!(
161 DependencySpec::Simple("1.0".into()).source_kind(),
162 DepSourceKind::Registry
163 );
164
165 let git_dep = DependencySpec::Detailed(DetailedDep {
166 version: None,
167 git: Some("https://github.com/user/repo.git".into()),
168 branch: None,
169 tag: None,
170 rev: None,
171 path: None,
172 });
173 assert_eq!(git_dep.source_kind(), DepSourceKind::Git);
174
175 let path_dep = DependencySpec::Detailed(DetailedDep {
176 version: None,
177 git: None,
178 branch: None,
179 tag: None,
180 rev: None,
181 path: Some("../local".into()),
182 });
183 assert_eq!(path_dep.source_kind(), DepSourceKind::Path);
184 }
185
186 #[test]
187 fn manifest_with_optional_fields() {
188 let toml = r#"
189[project]
190name = "full"
191version = "1.0.0"
192edition = "2024"
193authors = ["Alice", "Bob"]
194description = "A complete project"
195entry = "src/app.tl"
196"#;
197 let m = Manifest::from_toml(toml).unwrap();
198 assert_eq!(m.project.edition.as_deref(), Some("2024"));
199 assert_eq!(m.project.authors.as_ref().unwrap().len(), 2);
200 assert_eq!(m.project.entry.as_deref(), Some("src/app.tl"));
201 }
202}