1use serde::Serialize;
8use serde_json::Value;
9
10pub const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/alplabai/alp-sdk/releases";
12
13const LOADER_SCRIPT_RELATIVE: &str = "scripts/alp_project.py";
14const VERSION_FILE_RELATIVE: &str = "VERSION";
15const METADATA_DIR_RELATIVE: &str = "metadata";
16
17#[derive(Debug, Clone, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SdkRelease {
21 pub tag: String,
23 pub published_at: String,
25 pub tarball_url: String,
27 pub release_notes_summary: String,
29 pub release_notes: String,
31}
32
33pub fn parse_remote_sdk_releases(raw: &Value) -> Result<Vec<SdkRelease>, String> {
36 let Some(items) = raw.as_array() else {
37 return Err("Alp SDK: unexpected response shape from GitHub Releases API.".to_string());
38 };
39
40 Ok(items
41 .iter()
42 .filter(|item| item.get("tag_name").and_then(Value::as_str).is_some())
43 .map(|item| {
44 let str_field = |key: &str| {
45 item.get(key)
46 .and_then(Value::as_str)
47 .unwrap_or("")
48 .to_string()
49 };
50 let body = item.get("body").and_then(Value::as_str).unwrap_or("");
51 SdkRelease {
52 tag: str_field("tag_name"),
53 published_at: str_field("published_at"),
54 tarball_url: str_field("tarball_url"),
55 release_notes_summary: extract_first_paragraph(body),
56 release_notes: body.trim().to_string(),
57 }
58 })
59 .collect())
60}
61
62fn extract_first_paragraph(body: &str) -> String {
63 let trimmed = body.trim();
64 if trimmed.is_empty() {
65 return String::new();
66 }
67 match trimmed.find("\n\n") {
68 Some(idx) => trimmed[..idx].trim().to_string(),
69 None => trimmed.to_string(),
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum SdkReadinessState {
77 Ready,
79 Partial,
81 Missing,
83}
84
85#[derive(Debug, Clone, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct SdkReadinessReport {
89 pub sdk_path: String,
91 pub version: Option<String>,
93 pub loader_script_present: bool,
95 pub metadata_present: bool,
97 pub state: SdkReadinessState,
99 pub issues: Vec<String>,
101}
102
103pub fn check_sdk_readiness(
106 sdk_path: &str,
107 path_exists: impl Fn(&str) -> bool,
108 read_file: impl Fn(&str) -> Option<String>,
109) -> SdkReadinessReport {
110 let mut issues = Vec::new();
111 let join = |rel: &str| join_path(sdk_path, rel);
112
113 let loader_script_present = path_exists(&join(LOADER_SCRIPT_RELATIVE));
114 if !loader_script_present {
115 issues.push(format!(
116 "scripts/alp_project.py not found — \"{sdk_path}\" is not a valid ALP SDK root."
117 ));
118 }
119
120 let metadata_present = path_exists(&join(METADATA_DIR_RELATIVE));
121 if !metadata_present {
122 issues.push("metadata/ directory is missing.".to_string());
123 }
124
125 let mut version: Option<String> = None;
126 let version_file = join(VERSION_FILE_RELATIVE);
127 if path_exists(&version_file) {
128 match read_file(&version_file) {
129 Some(contents) => {
130 let trimmed = contents.trim();
131 version = if trimmed.is_empty() {
132 None
133 } else {
134 Some(trimmed.to_string())
135 };
136 }
137 None => issues.push("VERSION file could not be read.".to_string()),
138 }
139 }
140
141 let state = if !loader_script_present {
142 SdkReadinessState::Missing
143 } else if !issues.is_empty() {
144 SdkReadinessState::Partial
145 } else {
146 SdkReadinessState::Ready
147 };
148
149 SdkReadinessReport {
150 sdk_path: sdk_path.to_string(),
151 version,
152 loader_script_present,
153 metadata_present,
154 state,
155 issues,
156 }
157}
158
159pub fn resolve_active_sdk(
162 workspace_root: &str,
163 path_exists: impl Fn(&str) -> bool,
164 read_file: impl Fn(&str) -> Option<String>,
165) -> Option<String> {
166 let pointer = join_path(workspace_root, ".alp/sdk-path");
167 if !path_exists(&pointer) {
168 return None;
169 }
170 let raw = read_file(&pointer)?;
171 let parsed: Value = serde_json::from_str(&raw).ok()?;
172 parsed
173 .get("sdkPath")
174 .and_then(Value::as_str)
175 .map(str::to_string)
176}
177
178fn join_path(base: &str, relative: &str) -> String {
180 let mut path = std::path::PathBuf::from(base);
181 for part in relative.split('/') {
182 path.push(part);
183 }
184 path.to_string_lossy().to_string()
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use serde_json::json;
191
192 #[test]
193 fn parses_releases_filtering_untagged() {
194 let raw = json!([
195 {"tag_name": "v1.5.0", "published_at": "2024-01-02T00:00:00Z", "tarball_url": "u", "body": "First line.\n\nrest"},
196 {"published_at": "x"},
197 ]);
198 let releases = parse_remote_sdk_releases(&raw).unwrap();
199 assert_eq!(releases.len(), 1);
200 assert_eq!(releases[0].tag, "v1.5.0");
201 assert_eq!(releases[0].release_notes_summary, "First line.");
202 assert_eq!(releases[0].release_notes, "First line.\n\nrest");
203 }
204
205 #[test]
206 fn non_array_response_errors() {
207 assert!(parse_remote_sdk_releases(&json!({"message": "rate limited"})).is_err());
208 }
209
210 #[test]
211 fn readiness_missing_when_no_loader() {
212 let report = check_sdk_readiness("/sdk", |_| false, |_| None);
213 assert_eq!(report.state, SdkReadinessState::Missing);
214 assert!(!report.loader_script_present);
215 }
216
217 #[test]
218 fn readiness_ready_when_complete() {
219 let report = check_sdk_readiness(
220 "/sdk",
221 |p| p.ends_with("alp_project.py") || p.ends_with("metadata") || p.ends_with("VERSION"),
222 |_| Some("v1.5.0\n".to_string()),
223 );
224 assert_eq!(report.state, SdkReadinessState::Ready);
225 assert_eq!(report.version.as_deref(), Some("v1.5.0"));
226 assert!(report.issues.is_empty());
227 }
228
229 #[test]
230 fn active_sdk_pointer_parsed() {
231 let resolved = resolve_active_sdk(
232 "/ws",
233 |p| p.ends_with("sdk-path"),
234 |_| Some("{\"sdkPath\": \"/cache/v1\"}".to_string()),
235 );
236 assert_eq!(resolved.as_deref(), Some("/cache/v1"));
237 }
238}