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", ®istry.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}