Skip to main content

nex_pkg/
armory.rs

1use std::fmt;
2use std::process::Command;
3
4use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6
7use crate::config::RegistryConfig;
8
9pub const DEFAULT_REGISTRY_NAME: &str = "styrene-armory";
10pub const DEFAULT_REGISTRY_URL: &str = "https://armory.styrene.io/api/index.json";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PackageRef {
14    pub kind: String,
15    pub id: String,
16}
17
18impl PackageRef {
19    pub fn parse(value: &str) -> Result<Self> {
20        let Some((kind, id)) = value.split_once('/') else {
21            bail!("package ref must be <kind>/<id>, got '{value}'");
22        };
23        if kind.is_empty() || id.is_empty() || id.contains('/') {
24            bail!("package ref must be <kind>/<id>, got '{value}'");
25        }
26        Ok(Self {
27            kind: kind.to_string(),
28            id: id.to_string(),
29        })
30    }
31}
32
33impl fmt::Display for PackageRef {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(f, "{}/{}", self.kind, self.id)
36    }
37}
38
39#[derive(Debug, Clone, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ArmoryIndex {
42    #[serde(default)]
43    pub packages: Vec<ArmoryPackage>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ArmoryPackage {
49    pub package_ref: String,
50    #[serde(default)]
51    pub name: Option<String>,
52    #[serde(default)]
53    pub version: Option<String>,
54    #[serde(default)]
55    pub description: Option<String>,
56    #[serde(default)]
57    pub install_command: Option<String>,
58    #[serde(default)]
59    pub fallback_install_command: Option<String>,
60    #[serde(default)]
61    pub dependencies: Vec<ArmoryDependency>,
62    #[serde(default)]
63    pub optional_dependencies: Vec<ArmoryDependency>,
64    #[serde(default)]
65    pub activation: Option<ArmoryActivation>,
66    #[serde(default)]
67    pub oci_ref: Option<String>,
68    #[serde(default)]
69    pub digest: Option<String>,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ArmoryDependency {
75    #[serde(default)]
76    pub package_ref: Option<String>,
77    #[serde(default)]
78    pub r#ref: Option<String>,
79    #[serde(default)]
80    pub optional: Option<bool>,
81}
82
83impl ArmoryDependency {
84    pub fn display_ref(&self) -> Option<&str> {
85        self.package_ref.as_deref().or(self.r#ref.as_deref())
86    }
87}
88
89#[derive(Debug, Clone, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct ArmoryActivation {
92    #[serde(default)]
93    pub runtime: Option<String>,
94    #[serde(default)]
95    pub mode: Option<String>,
96}
97
98pub fn default_registry() -> RegistryConfig {
99    RegistryConfig {
100        name: DEFAULT_REGISTRY_NAME.to_string(),
101        url: DEFAULT_REGISTRY_URL.to_string(),
102        trust: Some("signed".to_string()),
103    }
104}
105
106pub fn fetch_index(registry: &RegistryConfig) -> Result<ArmoryIndex> {
107    let output = Command::new("curl")
108        .args(["-fsSL", &registry.url])
109        .output()
110        .with_context(|| format!("fetching Armory registry {}", registry.url))?;
111    if !output.status.success() {
112        bail!(
113            "failed to fetch Armory registry {}: {}",
114            registry.url,
115            crate::exec::captured_text(&output.stderr).trim()
116        );
117    }
118    parse_index(&output.stdout)
119}
120
121pub fn parse_index(bytes: &[u8]) -> Result<ArmoryIndex> {
122    serde_json::from_slice(bytes).context("parsing Armory index JSON")
123}
124
125pub fn search(index: &ArmoryIndex, query: &str) -> Vec<ArmoryPackage> {
126    let query = query.to_ascii_lowercase();
127    index
128        .packages
129        .iter()
130        .filter(|package| {
131            package.package_ref.to_ascii_lowercase().contains(&query)
132                || package
133                    .name
134                    .as_deref()
135                    .unwrap_or_default()
136                    .to_ascii_lowercase()
137                    .contains(&query)
138                || package
139                    .description
140                    .as_deref()
141                    .unwrap_or_default()
142                    .to_ascii_lowercase()
143                    .contains(&query)
144        })
145        .cloned()
146        .collect()
147}
148
149pub fn find<'a>(index: &'a ArmoryIndex, package_ref: &PackageRef) -> Option<&'a ArmoryPackage> {
150    let needle = package_ref.to_string();
151    index
152        .packages
153        .iter()
154        .find(|package| package.package_ref == needle)
155}
156
157pub fn print_search_results(registry: &RegistryConfig, results: &[ArmoryPackage]) {
158    if results.is_empty() {
159        return;
160    }
161    println!("\nArmory registry: {}", registry.name);
162    for package in results {
163        let version = package.version.as_deref().unwrap_or("unknown");
164        let description = package.description.as_deref().unwrap_or("");
165        println!(
166            "  {:<40} {:<12} {}",
167            package.package_ref, version, description
168        );
169    }
170}
171
172pub fn print_info(registry: &RegistryConfig, package: &ArmoryPackage) {
173    println!("{}", package.package_ref);
174    println!("  registry: {}", registry.name);
175    if let Some(name) = &package.name {
176        println!("  name: {name}");
177    }
178    if let Some(version) = &package.version {
179        println!("  version: {version}");
180    }
181    if let Some(description) = &package.description {
182        println!("  description: {description}");
183    }
184    if let Some(command) = &package.install_command {
185        println!("  install: {command}");
186    }
187    if let Some(command) = &package.fallback_install_command {
188        println!("  fallback: {command}");
189    }
190    if let Some(oci_ref) = &package.oci_ref {
191        println!("  oci: {oci_ref}");
192    }
193    if let Some(digest) = &package.digest {
194        println!("  digest: {digest}");
195    }
196    if !package.dependencies.is_empty() {
197        println!("  dependencies:");
198        for dep in &package.dependencies {
199            if let Some(dep_ref) = dep.display_ref() {
200                let suffix = if dep.optional.unwrap_or(false) {
201                    " (optional)"
202                } else {
203                    ""
204                };
205                println!("    - {dep_ref}{suffix}");
206            }
207        }
208    }
209    if !package.optional_dependencies.is_empty() {
210        println!("  optional dependencies:");
211        for dep in &package.optional_dependencies {
212            if let Some(dep_ref) = dep.display_ref() {
213                let suffix = if dep.optional.unwrap_or(true) {
214                    " (optional)"
215                } else {
216                    ""
217                };
218                println!("    - {dep_ref}{suffix}");
219            }
220        }
221    }
222    if let Some(activation) = &package.activation {
223        println!("  activation:");
224        if let Some(runtime) = &activation.runtime {
225            println!("    runtime: {runtime}");
226        }
227        if let Some(mode) = &activation.mode {
228            println!("    mode: {mode}");
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::{find, parse_index, search, PackageRef};
236
237    const INDEX: &[u8] = br#"{
238      "packages": [{
239        "packageRef": "profile/rust-shop",
240        "name": "Rust Shop",
241        "version": "1.0.0",
242        "description": "Rust development profile",
243        "installCommand": "nex install profile/rust-shop",
244        "activation": { "runtime": "omegon", "mode": "profile" },
245        "dependencies": [{ "packageRef": "skill/rust" }]
246      }]
247    }"#;
248
249    #[test]
250    fn parses_package_ref() {
251        let package_ref = PackageRef::parse("profile/rust-shop").expect("package ref");
252        assert_eq!(package_ref.kind, "profile");
253        assert_eq!(package_ref.id, "rust-shop");
254    }
255
256    #[test]
257    fn parses_index_and_finds_package() {
258        let index = parse_index(INDEX).expect("index");
259        let package_ref = PackageRef::parse("profile/rust-shop").expect("package ref");
260        let package = find(&index, &package_ref).expect("package");
261        assert_eq!(package.version.as_deref(), Some("1.0.0"));
262        assert_eq!(package.dependencies[0].display_ref(), Some("skill/rust"));
263    }
264
265    #[test]
266    fn searches_metadata() {
267        let index = parse_index(INDEX).expect("index");
268        let results = search(&index, "rust");
269        assert_eq!(results.len(), 1);
270    }
271}