1use crate::hashing::normalize_line_endings;
2use crate::package_spec::parse_package_spec;
3use crate::{PrayError, PrayResult};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fs;
8use std::io::Cursor;
9use std::path::{Path, PathBuf};
10
11const DERIVED_EMBEDDING_MODEL: &str = "pray-hash-bucket-v1";
12const EMBEDDING_DIMENSIONS: usize = 16;
13const MAX_SUMMARY_PARTS: usize = 2;
14const MAX_TOKEN_COUNT: usize = 512;
15const MAX_SNIPPET_LENGTH: usize = 120;
16const STOPWORDS: &[&str] = &[
17 "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "has", "have", "in", "is",
18 "it", "its", "of", "on", "or", "package", "the", "this", "to", "was", "we", "with", "you",
19 "your",
20];
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
23pub struct RegistryDerivedMetadata {
24 #[serde(default, skip_serializing_if = "String::is_empty")]
25 pub summary: String,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub topics: Vec<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub categories: Vec<String>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub possible_effects: Vec<String>,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub possible_side_effects: Vec<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub embeddings: Vec<RegistryDerivedEmbedding>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub file_count: Option<usize>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub character_count: Option<usize>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub token_count: Option<usize>,
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
45pub struct RegistryDerivedEmbedding {
46 pub model: String,
47 pub vector: Vec<i32>,
48}
49
50pub fn derive_registry_derived_metadata_from_root(
51 root: &Path,
52) -> PrayResult<RegistryDerivedMetadata> {
53 let spec_path = find_prayspec_file(root)?;
54 let spec_text = fs::read_to_string(&spec_path)?;
55 let spec = parse_package_spec(&normalize_line_endings(&spec_text))?.canonicalized();
56 derive_registry_derived_metadata_from_spec(root, &spec)
57}
58
59pub fn derive_registry_derived_metadata_from_archive_bytes(
60 archive_bytes: &[u8],
61) -> PrayResult<RegistryDerivedMetadata> {
62 let temp_dir = unique_temp_dir("pray-derived-metadata");
63 fs::create_dir_all(&temp_dir)?;
64 let unpack_result = unpack_archive_bytes(archive_bytes, &temp_dir)
65 .and_then(|_| derive_registry_derived_metadata_from_root(&temp_dir));
66 let _ = fs::remove_dir_all(&temp_dir);
67 unpack_result
68}
69
70fn derive_registry_derived_metadata_from_spec(
71 root: &Path,
72 spec: &crate::package_spec::PackageSpec,
73) -> PrayResult<RegistryDerivedMetadata> {
74 let mut summary_candidates = Vec::new();
75 if let Some(summary) = spec.summary.as_deref() {
76 push_candidate(&mut summary_candidates, summary);
77 }
78 if let Some(description) = spec.description.as_deref() {
79 push_candidate(&mut summary_candidates, description);
80 }
81 for export in spec.exports.values() {
82 if let Some(summary) = export.summary.as_deref() {
83 push_candidate(&mut summary_candidates, summary);
84 }
85 }
86
87 let mut analyzed_text = String::new();
88 let mut sample_lines = Vec::new();
89 let mut file_count = 0usize;
90 let mut character_count = 0usize;
91 for file in &spec.files {
92 let path = root.join(file);
93 let Ok(text) = fs::read_to_string(&path) else {
94 continue;
95 };
96 let normalized = normalize_line_endings(&text);
97 if let Some(line) = first_meaningful_line(&normalized) {
98 push_candidate(&mut sample_lines, &line);
99 }
100 file_count += 1;
101 character_count += normalized.chars().count();
102 analyzed_text.push_str(&normalized);
103 analyzed_text.push('\n');
104 }
105
106 for line in &sample_lines {
107 push_candidate(&mut summary_candidates, line);
108 }
109 if summary_candidates.is_empty() {
110 for token in top_topics(&analyzed_text) {
111 push_candidate(&mut summary_candidates, &token);
112 }
113 }
114
115 let summary = build_summary(&summary_candidates);
116 let tokens = tokenize(&analyzed_text);
117 let topics = top_topics(&analyzed_text);
118 let categories = infer_categories(&summary_candidates.join(" \n"), &topics);
119 let possible_effects = infer_possible_effects(&categories);
120 let possible_side_effects = infer_possible_side_effects(&analyzed_text, &topics);
121 let embeddings = vec![RegistryDerivedEmbedding {
122 model: DERIVED_EMBEDDING_MODEL.to_string(),
123 vector: hashed_embedding(&tokens),
124 }];
125
126 Ok(RegistryDerivedMetadata {
127 summary,
128 topics,
129 categories,
130 possible_effects,
131 possible_side_effects,
132 embeddings,
133 file_count: Some(file_count),
134 character_count: Some(character_count),
135 token_count: Some(tokens.len()),
136 })
137}
138
139fn unpack_archive_bytes(archive_bytes: &[u8], root: &Path) -> PrayResult<()> {
140 let decoder = zstd::stream::read::Decoder::new(Cursor::new(archive_bytes))?;
141 let mut archive = tar::Archive::new(decoder);
142 archive.unpack(root)?;
143 Ok(())
144}
145
146fn find_prayspec_file(root: &Path) -> PrayResult<PathBuf> {
147 let mut prayspec_files = Vec::new();
148 for entry in fs::read_dir(root)? {
149 let entry = entry?;
150 let path = entry.path();
151 if path.extension().and_then(|value| value.to_str()) == Some("prayspec") {
152 prayspec_files.push(path);
153 }
154 }
155 match prayspec_files.len() {
156 1 => Ok(prayspec_files.remove(0)),
157 0 => Err(PrayError::Resolution(format!(
158 "no prayspec file found in {:?}",
159 root
160 ))),
161 _ => Err(PrayError::Resolution(format!(
162 "multiple prayspec files found in {:?}",
163 root
164 ))),
165 }
166}
167
168fn build_summary(candidates: &[String]) -> String {
169 let mut summary = Vec::new();
170 let mut seen = BTreeSet::new();
171 for candidate in candidates {
172 let normalized = candidate.trim();
173 if normalized.is_empty() {
174 continue;
175 }
176 let key = normalized.to_lowercase();
177 if seen.insert(key) {
178 summary.push(truncate(normalized, MAX_SNIPPET_LENGTH));
179 }
180 if summary.len() == MAX_SUMMARY_PARTS {
181 break;
182 }
183 }
184 if summary.is_empty() {
185 return "Package metadata".to_string();
186 }
187 summary.join(" — ")
188}
189
190fn top_topics(text: &str) -> Vec<String> {
191 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
192 for token in tokenize(text) {
193 *counts.entry(token).or_insert(0) += 1;
194 }
195 let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
196 ranked.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
197 ranked.into_iter().take(5).map(|(token, _)| token).collect()
198}
199
200fn infer_categories(summary_text: &str, topics: &[String]) -> Vec<String> {
201 let text = format!("{} {}", summary_text.to_lowercase(), topics.join(" "));
202 let mut categories = Vec::new();
203 if contains_any(
204 &text,
205 &["guide", "guidance", "readme", "documentation", "doc"],
206 ) {
207 categories.push("documentation".to_string());
208 }
209 if contains_any(&text, &["test", "testing", "fixture", "spec"]) {
210 categories.push("testing".to_string());
211 }
212 if contains_any(&text, &["auth", "security", "ssh", "key", "sign", "trust"]) {
213 categories.push("security".to_string());
214 }
215 if contains_any(
216 &text,
217 &["automation", "workflow", "render", "pipeline", "agent"],
218 ) {
219 categories.push("automation".to_string());
220 }
221 if categories.is_empty() {
222 categories.push("general".to_string());
223 }
224 categories.sort();
225 categories.dedup();
226 categories
227}
228
229fn infer_possible_effects(categories: &[String]) -> Vec<String> {
230 let mut effects = Vec::new();
231 if categories
232 .iter()
233 .any(|category| category == "documentation")
234 {
235 effects.push("clarifies usage".to_string());
236 }
237 if categories.iter().any(|category| category == "testing") {
238 effects.push("improves validation".to_string());
239 }
240 if categories.iter().any(|category| category == "security") {
241 effects.push("surfaces trust-sensitive behavior".to_string());
242 }
243 if categories.iter().any(|category| category == "automation") {
244 effects.push("reduces manual steps".to_string());
245 }
246 if effects.is_empty() {
247 effects.push("improves package discovery".to_string());
248 }
249 effects
250}
251
252fn infer_possible_side_effects(text: &str, topics: &[String]) -> Vec<String> {
253 let mut side_effects = Vec::new();
254 if contains_any(
255 text,
256 &[
257 "write",
258 "overwrite",
259 "replace",
260 "render",
261 "inject",
262 "delete",
263 ],
264 ) {
265 side_effects.push("may change generated output or managed files".to_string());
266 }
267 if contains_any(&topics.join(" "), &["publish", "release", "deploy"]) {
268 side_effects.push("may affect downstream package distribution".to_string());
269 }
270 side_effects.sort();
271 side_effects.dedup();
272 side_effects
273}
274
275fn hashed_embedding(tokens: &[String]) -> Vec<i32> {
276 let mut vector = vec![0i32; EMBEDDING_DIMENSIONS];
277 for token in tokens.iter().take(MAX_TOKEN_COUNT) {
278 let mut hasher = Sha256::new();
279 hasher.update(token.as_bytes());
280 let hash = hasher.finalize();
281 let bucket = hash[0] as usize % EMBEDDING_DIMENSIONS;
282 vector[bucket] += 1;
283 }
284 vector
285}
286
287fn tokenize(text: &str) -> Vec<String> {
288 let mut tokens = Vec::new();
289 for raw in text.split(|character: char| !character.is_alphanumeric()) {
290 let token = raw.trim().to_lowercase();
291 if token.len() < 4 || STOPWORDS.contains(&token.as_str()) {
292 continue;
293 }
294 tokens.push(token);
295 if tokens.len() == MAX_TOKEN_COUNT {
296 break;
297 }
298 }
299 tokens
300}
301
302fn contains_any(text: &str, needles: &[&str]) -> bool {
303 needles.iter().any(|needle| text.contains(needle))
304}
305
306fn push_candidate(candidates: &mut Vec<String>, candidate: &str) {
307 let trimmed = candidate.trim();
308 if trimmed.is_empty() {
309 return;
310 }
311 candidates.push(truncate(trimmed, MAX_SNIPPET_LENGTH));
312}
313
314fn first_meaningful_line(text: &str) -> Option<String> {
315 text.lines()
316 .map(str::trim)
317 .find(|line| !line.is_empty())
318 .map(|line| truncate(line, MAX_SNIPPET_LENGTH))
319}
320
321fn truncate(text: &str, limit: usize) -> String {
322 let mut shortened = String::new();
323 for character in text.chars().take(limit) {
324 shortened.push(character);
325 }
326 shortened
327}
328
329fn unique_temp_dir(prefix: &str) -> PathBuf {
330 let unique = std::time::SystemTime::now()
331 .duration_since(std::time::UNIX_EPOCH)
332 .expect("system clock before unix epoch")
333 .as_nanos();
334 std::env::temp_dir().join(format!("{prefix}-{unique}"))
335}