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