Skip to main content

alp_core/
sdk.rs

1// SPDX-License-Identifier: Apache-2.0
2//! SDK release catalogue + local readiness — a port of the IO-free parts of TS
3//! `@alp-sdk/core/sdk/service`. Network (GitHub) and filesystem effects stay in
4//! the CLI; this module parses the releases payload and inspects a local SDK
5//! path through injected predicates.
6
7use serde::Serialize;
8use serde_json::Value;
9
10/// GitHub Releases API endpoint for the `alplabai/alp-sdk` repository.
11pub 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/// A single SDK release entry parsed from the GitHub Releases API.
18#[derive(Debug, Clone, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SdkRelease {
21    /// Release tag name (e.g. `v1.5.0`).
22    pub tag: String,
23    /// ISO-8601 publish timestamp from GitHub.
24    pub published_at: String,
25    /// URL of the source tarball for this release.
26    pub tarball_url: String,
27    /// First paragraph of the release body (compact headline).
28    pub release_notes_summary: String,
29    /// Full release body (Markdown), for an expandable changelog.
30    pub release_notes: String,
31}
32
33/// Parse the GitHub Releases API payload into typed releases (mirror of
34/// `listRemoteSdkReleases`'s mapping). Errors on a non-array response.
35pub 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/// Overall readiness of a local SDK path.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum SdkReadinessState {
77    /// Loader script present and no issues found.
78    Ready,
79    /// Loader script present but some non-fatal issues exist.
80    Partial,
81    /// Loader script absent — not a valid SDK root.
82    Missing,
83}
84
85/// Result of inspecting a local SDK path for readiness.
86#[derive(Debug, Clone, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct SdkReadinessReport {
89    /// The inspected SDK root path.
90    pub sdk_path: String,
91    /// Trimmed contents of the `VERSION` file, if present and non-empty.
92    pub version: Option<String>,
93    /// Whether `scripts/alp_project.py` exists under the SDK root.
94    pub loader_script_present: bool,
95    /// Whether the `metadata/` directory exists under the SDK root.
96    pub metadata_present: bool,
97    /// Computed readiness verdict.
98    pub state: SdkReadinessState,
99    /// Human-readable descriptions of any problems found.
100    pub issues: Vec<String>,
101}
102
103/// Inspect a local SDK path (mirror of `checkSdkReadiness`). `path_exists` and
104/// `read_file` are injected so this stays pure/testable.
105pub 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
159/// Read the active-SDK pointer (`.alp/sdk-path`) under `workspace_root`, if any
160/// (mirror of `resolveActiveSdk`).
161pub 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
178/// Join a path with a relative segment, normalizing the separator to the host.
179fn 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}