Skip to main content

ta_changeset/
project_manifest.rs

1// project_manifest.rs — Project manifest (.ta/project.toml) schema and parser.
2//
3// A project manifest declares the project's plugin requirements so that
4// `ta setup` can resolve, download, and install everything needed.
5//
6// Schema:
7// ```toml
8// [project]
9// name = "my-project"
10// description = "My TA-managed project"
11//
12// [plugins.discord]
13// type = "channel"
14// version = ">=0.1.0"
15// source = "registry:ta-channel-discord"
16// env_vars = ["DISCORD_BOT_TOKEN"]
17//
18// [plugins.custom-webhook]
19// type = "channel"
20// version = ">=0.2.0"
21// source = "path:./plugins/custom-webhook"
22// required = false
23// ```
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30/// Top-level project manifest parsed from `.ta/project.toml`.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ProjectManifest {
33    /// Project metadata.
34    pub project: ProjectMeta,
35
36    /// Plugin declarations keyed by plugin name.
37    #[serde(default)]
38    pub plugins: HashMap<String, PluginRequirement>,
39}
40
41/// Project metadata section.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ProjectMeta {
44    /// Human-readable project name.
45    pub name: String,
46
47    /// Optional description.
48    #[serde(default)]
49    pub description: Option<String>,
50
51    /// VCS adapter to use (e.g., "git"). Defaults to auto-detection.
52    #[serde(default)]
53    pub vcs_adapter: Option<String>,
54}
55
56/// A single plugin requirement declaration.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PluginRequirement {
59    /// Plugin type (e.g., "channel", "submit", "build").
60    #[serde(rename = "type")]
61    pub plugin_type: String,
62
63    /// Version constraint. Phase 1: `">=X.Y.Z"` (minimum version).
64    /// Phase 2 (future): full semver ranges like `">=0.1.0, <1.0.0"`.
65    #[serde(default = "default_version_constraint")]
66    pub version: String,
67
68    /// Where to get the plugin. Supported schemes:
69    /// - `registry:<name>` — download from the TA plugin registry
70    /// - `github:<owner/repo>` — download from GitHub releases
71    /// - `path:<local-path>` — build from local source
72    /// - `url:<download-url>` — direct tarball URL
73    pub source: String,
74
75    /// Whether this plugin is required for the project to function.
76    /// Default: true. Optional plugins warn but don't block.
77    #[serde(default = "default_required")]
78    pub required: bool,
79
80    /// Environment variables this plugin needs (e.g., API tokens).
81    /// `ta setup` checks these and prints instructions for missing ones.
82    #[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/// Source scheme parsed from the `source` field.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SourceScheme {
97    /// `registry:<plugin-name>` — fetch from the TA plugin registry.
98    Registry(String),
99    /// `github:<owner/repo>` — download from GitHub releases.
100    GitHub(String),
101    /// `path:<local-path>` — build from local source.
102    Path(PathBuf),
103    /// `url:<download-url>` — direct tarball download.
104    Url(String),
105}
106
107/// Errors from manifest operations.
108#[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    /// Load a project manifest from `.ta/project.toml` under the given root.
128    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    /// Load from an explicit path.
134    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    /// Check if a project manifest exists for the given root.
150    pub fn exists(project_root: &Path) -> bool {
151        project_root.join(".ta").join("project.toml").exists()
152    }
153
154    /// Validate all plugin declarations.
155    pub fn validate(&self) -> Result<(), ManifestError> {
156        for (name, req) in &self.plugins {
157            // Validate source scheme.
158            parse_source_scheme(name, &req.source)?;
159
160            // Validate version constraint (Phase 1: must start with >=).
161            if !req.version.starts_with(">=") {
162                return Err(ManifestError::InvalidVersion {
163                    name: name.clone(),
164                    version: req.version.clone(),
165                });
166            }
167            // Extract the version part after ">=" and check it looks like semver.
168            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    /// Return all required plugin names.
180    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
189/// Parse a source string into a SourceScheme.
190pub 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
207/// Parse a `>=X.Y.Z` version constraint and return the minimum version string.
208pub fn parse_min_version(constraint: &str) -> Option<&str> {
209    constraint.strip_prefix(">=").map(|v| v.trim())
210}
211
212/// Compare two semver version strings. Returns Ordering.
213/// Simple semver comparison: split on '.', compare numerically.
214pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
215    let parse = |s: &str| -> Vec<u64> {
216        // Strip pre-release suffix for comparison (e.g., "0.1.0-alpha" → "0.1.0").
217        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
236/// Check if `installed_version` satisfies the constraint (e.g., `>=0.1.0`).
237pub 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, // Invalid constraint format.
241    }
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"); // default
264        assert!(discord.required); // default
265        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}