1use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProjectManifest {
33 pub project: ProjectMeta,
35
36 #[serde(default)]
38 pub plugins: HashMap<String, PluginRequirement>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ProjectMeta {
44 pub name: String,
46
47 #[serde(default)]
49 pub description: Option<String>,
50
51 #[serde(default)]
53 pub vcs_adapter: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PluginRequirement {
59 #[serde(rename = "type")]
61 pub plugin_type: String,
62
63 #[serde(default = "default_version_constraint")]
66 pub version: String,
67
68 pub source: String,
74
75 #[serde(default = "default_required")]
78 pub required: bool,
79
80 #[serde(default)]
83 pub env_vars: Vec<String>,
84}
85
86fn default_version_constraint() -> String {
87 ">=0.1.0".to_string()
88}
89
90fn default_required() -> bool {
91 true
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SourceScheme {
97 Registry(String),
99 GitHub(String),
101 Path(PathBuf),
103 Url(String),
105}
106
107#[derive(Debug, thiserror::Error)]
109pub enum ManifestError {
110 #[error("project manifest not found: {path}")]
111 NotFound { path: PathBuf },
112
113 #[error("invalid project manifest at {path}: {reason}")]
114 Invalid { path: PathBuf, reason: String },
115
116 #[error("plugin '{name}': invalid source scheme '{scheme}'. Expected registry:, github:, path:, or url:")]
117 InvalidSource { name: String, scheme: String },
118
119 #[error("plugin '{name}': version constraint '{version}' is not valid. Use '>=X.Y.Z' format.")]
120 InvalidVersion { name: String, version: String },
121
122 #[error("I/O error: {0}")]
123 Io(#[from] std::io::Error),
124}
125
126impl ProjectManifest {
127 pub fn load(project_root: &Path) -> Result<Self, ManifestError> {
129 let path = project_root.join(".ta").join("project.toml");
130 Self::load_from(&path)
131 }
132
133 pub fn load_from(path: &Path) -> Result<Self, ManifestError> {
135 if !path.exists() {
136 return Err(ManifestError::NotFound {
137 path: path.to_path_buf(),
138 });
139 }
140 let content = std::fs::read_to_string(path)?;
141 let manifest: Self = toml::from_str(&content).map_err(|e| ManifestError::Invalid {
142 path: path.to_path_buf(),
143 reason: e.to_string(),
144 })?;
145 manifest.validate()?;
146 Ok(manifest)
147 }
148
149 pub fn exists(project_root: &Path) -> bool {
151 project_root.join(".ta").join("project.toml").exists()
152 }
153
154 pub fn validate(&self) -> Result<(), ManifestError> {
156 for (name, req) in &self.plugins {
157 parse_source_scheme(name, &req.source)?;
159
160 if !req.version.starts_with(">=") {
162 return Err(ManifestError::InvalidVersion {
163 name: name.clone(),
164 version: req.version.clone(),
165 });
166 }
167 let ver = req.version.trim_start_matches(">=").trim();
169 if ver.is_empty() || !ver.chars().next().unwrap_or('x').is_ascii_digit() {
170 return Err(ManifestError::InvalidVersion {
171 name: name.clone(),
172 version: req.version.clone(),
173 });
174 }
175 }
176 Ok(())
177 }
178
179 pub fn required_plugins(&self) -> Vec<&str> {
181 self.plugins
182 .iter()
183 .filter(|(_, req)| req.required)
184 .map(|(name, _)| name.as_str())
185 .collect()
186 }
187}
188
189pub fn parse_source_scheme(plugin_name: &str, source: &str) -> Result<SourceScheme, ManifestError> {
191 if let Some(name) = source.strip_prefix("registry:") {
192 Ok(SourceScheme::Registry(name.to_string()))
193 } else if let Some(repo) = source.strip_prefix("github:") {
194 Ok(SourceScheme::GitHub(repo.to_string()))
195 } else if let Some(path) = source.strip_prefix("path:") {
196 Ok(SourceScheme::Path(PathBuf::from(path)))
197 } else if let Some(url) = source.strip_prefix("url:") {
198 Ok(SourceScheme::Url(url.to_string()))
199 } else {
200 Err(ManifestError::InvalidSource {
201 name: plugin_name.to_string(),
202 scheme: source.to_string(),
203 })
204 }
205}
206
207pub fn parse_min_version(constraint: &str) -> Option<&str> {
209 constraint.strip_prefix(">=").map(|v| v.trim())
210}
211
212pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
215 let parse = |s: &str| -> Vec<u64> {
216 let base = s.split('-').next().unwrap_or(s);
218 base.split('.')
219 .filter_map(|p| p.parse::<u64>().ok())
220 .collect()
221 };
222 let a_parts = parse(a);
223 let b_parts = parse(b);
224
225 for i in 0..a_parts.len().max(b_parts.len()) {
226 let a_val = a_parts.get(i).copied().unwrap_or(0);
227 let b_val = b_parts.get(i).copied().unwrap_or(0);
228 match a_val.cmp(&b_val) {
229 std::cmp::Ordering::Equal => continue,
230 other => return other,
231 }
232 }
233 std::cmp::Ordering::Equal
234}
235
236pub fn version_satisfies(installed: &str, constraint: &str) -> bool {
238 match parse_min_version(constraint) {
239 Some(min) => compare_versions(installed, min) != std::cmp::Ordering::Less,
240 None => false, }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn parse_minimal_manifest() {
250 let toml_str = r#"
251[project]
252name = "test-project"
253
254[plugins.discord]
255type = "channel"
256source = "registry:ta-channel-discord"
257"#;
258 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
259 assert_eq!(manifest.project.name, "test-project");
260 assert_eq!(manifest.plugins.len(), 1);
261 let discord = &manifest.plugins["discord"];
262 assert_eq!(discord.plugin_type, "channel");
263 assert_eq!(discord.version, ">=0.1.0"); assert!(discord.required); assert!(discord.env_vars.is_empty());
266 }
267
268 #[test]
269 fn parse_full_manifest() {
270 let toml_str = r#"
271[project]
272name = "my-project"
273description = "A project with plugins"
274vcs_adapter = "git"
275
276[plugins.discord]
277type = "channel"
278version = ">=0.2.0"
279source = "registry:ta-channel-discord"
280env_vars = ["DISCORD_BOT_TOKEN", "DISCORD_CHANNEL_ID"]
281
282[plugins.custom]
283type = "channel"
284version = ">=0.1.0"
285source = "path:./plugins/custom"
286required = false
287"#;
288 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
289 assert_eq!(manifest.project.name, "my-project");
290 assert_eq!(
291 manifest.project.description.as_deref(),
292 Some("A project with plugins")
293 );
294 assert_eq!(manifest.project.vcs_adapter.as_deref(), Some("git"));
295 assert_eq!(manifest.plugins.len(), 2);
296
297 let discord = &manifest.plugins["discord"];
298 assert_eq!(discord.version, ">=0.2.0");
299 assert_eq!(
300 discord.env_vars,
301 vec!["DISCORD_BOT_TOKEN", "DISCORD_CHANNEL_ID"]
302 );
303 assert!(discord.required);
304
305 let custom = &manifest.plugins["custom"];
306 assert!(!custom.required);
307 }
308
309 #[test]
310 fn parse_source_schemes() {
311 assert_eq!(
312 parse_source_scheme("p", "registry:ta-channel-discord").unwrap(),
313 SourceScheme::Registry("ta-channel-discord".to_string())
314 );
315 assert_eq!(
316 parse_source_scheme("p", "github:Trusted-Autonomy/ta-channel-discord").unwrap(),
317 SourceScheme::GitHub("Trusted-Autonomy/ta-channel-discord".to_string())
318 );
319 assert_eq!(
320 parse_source_scheme("p", "path:./plugins/custom").unwrap(),
321 SourceScheme::Path(PathBuf::from("./plugins/custom"))
322 );
323 assert_eq!(
324 parse_source_scheme("p", "url:https://example.com/plugin.tar.gz").unwrap(),
325 SourceScheme::Url("https://example.com/plugin.tar.gz".to_string())
326 );
327 }
328
329 #[test]
330 fn invalid_source_scheme() {
331 let err = parse_source_scheme("test", "ftp:something").unwrap_err();
332 assert!(err.to_string().contains("invalid source scheme"));
333 }
334
335 #[test]
336 fn validate_rejects_bad_version() {
337 let toml_str = r#"
338[project]
339name = "test"
340
341[plugins.bad]
342type = "channel"
343version = "0.1.0"
344source = "registry:test"
345"#;
346 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
347 let err = manifest.validate().unwrap_err();
348 assert!(err.to_string().contains("not valid"));
349 }
350
351 #[test]
352 fn validate_accepts_good_version() {
353 let toml_str = r#"
354[project]
355name = "test"
356
357[plugins.good]
358type = "channel"
359version = ">=0.1.0"
360source = "registry:test"
361"#;
362 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
363 assert!(manifest.validate().is_ok());
364 }
365
366 #[test]
367 fn load_from_file() {
368 let dir = tempfile::tempdir().unwrap();
369 let ta_dir = dir.path().join(".ta");
370 std::fs::create_dir_all(&ta_dir).unwrap();
371 std::fs::write(
372 ta_dir.join("project.toml"),
373 r#"
374[project]
375name = "file-test"
376
377[plugins.slack]
378type = "channel"
379version = ">=0.1.0"
380source = "registry:ta-channel-slack"
381"#,
382 )
383 .unwrap();
384
385 let manifest = ProjectManifest::load(dir.path()).unwrap();
386 assert_eq!(manifest.project.name, "file-test");
387 assert!(manifest.plugins.contains_key("slack"));
388 }
389
390 #[test]
391 fn load_not_found() {
392 let err = ProjectManifest::load(Path::new("/nonexistent")).unwrap_err();
393 assert!(matches!(err, ManifestError::NotFound { .. }));
394 }
395
396 #[test]
397 fn load_invalid_toml() {
398 let dir = tempfile::tempdir().unwrap();
399 let ta_dir = dir.path().join(".ta");
400 std::fs::create_dir_all(&ta_dir).unwrap();
401 std::fs::write(ta_dir.join("project.toml"), "this is not valid {{{").unwrap();
402
403 let err = ProjectManifest::load(dir.path()).unwrap_err();
404 assert!(matches!(err, ManifestError::Invalid { .. }));
405 }
406
407 #[test]
408 fn exists_check() {
409 let dir = tempfile::tempdir().unwrap();
410 assert!(!ProjectManifest::exists(dir.path()));
411
412 let ta_dir = dir.path().join(".ta");
413 std::fs::create_dir_all(&ta_dir).unwrap();
414 std::fs::write(ta_dir.join("project.toml"), "[project]\nname = \"x\"\n").unwrap();
415 assert!(ProjectManifest::exists(dir.path()));
416 }
417
418 #[test]
419 fn required_plugins_filter() {
420 let toml_str = r#"
421[project]
422name = "test"
423
424[plugins.required1]
425type = "channel"
426source = "registry:a"
427
428[plugins.optional1]
429type = "channel"
430source = "registry:b"
431required = false
432"#;
433 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
434 let required = manifest.required_plugins();
435 assert_eq!(required.len(), 1);
436 assert!(required.contains(&"required1"));
437 }
438
439 #[test]
440 fn version_comparison() {
441 use std::cmp::Ordering;
442 assert_eq!(compare_versions("0.1.0", "0.1.0"), Ordering::Equal);
443 assert_eq!(compare_versions("0.2.0", "0.1.0"), Ordering::Greater);
444 assert_eq!(compare_versions("0.1.0", "0.2.0"), Ordering::Less);
445 assert_eq!(compare_versions("1.0.0", "0.9.9"), Ordering::Greater);
446 assert_eq!(compare_versions("0.1.0-alpha", "0.1.0"), Ordering::Equal);
447 }
448
449 #[test]
450 fn version_satisfies_check() {
451 assert!(version_satisfies("0.2.0", ">=0.1.0"));
452 assert!(version_satisfies("0.1.0", ">=0.1.0"));
453 assert!(!version_satisfies("0.0.9", ">=0.1.0"));
454 assert!(version_satisfies("1.0.0", ">=0.1.0"));
455 }
456
457 #[test]
458 fn manifest_no_plugins() {
459 let toml_str = r#"
460[project]
461name = "bare"
462"#;
463 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
464 assert!(manifest.plugins.is_empty());
465 assert!(manifest.validate().is_ok());
466 }
467
468 #[test]
469 fn manifest_error_display() {
470 let err = ManifestError::NotFound {
471 path: PathBuf::from("/some/path"),
472 };
473 assert!(err.to_string().contains("/some/path"));
474
475 let err = ManifestError::InvalidSource {
476 name: "test".into(),
477 scheme: "ftp:x".into(),
478 };
479 assert!(err.to_string().contains("ftp:x"));
480 }
481}