use crate::backend::jq;
use expr::{Context, Environment, Value};
use eyre::Result;
use regex::Regex;
use std::collections::HashSet;
pub async fn fetch_versions(
version_list_url: &str,
version_regex: Option<&str>,
version_json_path: Option<&str>,
version_expr: Option<&str>,
) -> Result<Vec<String>> {
use crate::http::HTTP;
let response = HTTP.get_text(version_list_url).await?;
let content = response.trim();
parse_version_list(content, version_regex, version_json_path, version_expr)
}
pub fn parse_version_list(
content: &str,
version_regex: Option<&str>,
version_json_path: Option<&str>,
version_expr: Option<&str>,
) -> Result<Vec<String>> {
let mut versions = Vec::new();
let trimmed = content.trim();
if let Some(expr_str) = version_expr {
versions = eval_version_expr(expr_str, trimmed)?;
}
else if let Some(json_path) = version_json_path {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
&& let Ok(extracted) = jq::extract(&json, json_path)
{
versions = extracted;
}
}
else if let Some(pattern) = version_regex {
let re = Regex::new(pattern)?;
for cap in re.captures_iter(content) {
let version = cap
.get(1)
.or_else(|| cap.get(0))
.map(|m| m.as_str().to_string());
if let Some(v) = version {
let v = v.trim();
if !v.is_empty() {
versions.push(v.to_string());
}
}
}
} else {
if trimmed.starts_with('[') || trimmed.starts_with('{') {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
versions = jq::extract_auto(&json);
}
}
}
if versions.is_empty() {
for line in trimmed.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with('#') {
let version = line.trim_start_matches('v');
versions.push(version.to_string());
}
}
}
let mut seen = HashSet::new();
versions.retain(|v| seen.insert(v.clone()));
Ok(versions)
}
fn eval_version_expr(expr_str: &str, body: &str) -> Result<Vec<String>> {
use versions::Versioning;
let mut ctx = Context::default();
ctx.insert("body".to_string(), Value::String(body.to_string()));
let mut env = Environment::new();
env.add_function("sortVersions", |c| {
if c.args.len() != 1 {
return Err("sortVersions() takes exactly one argument"
.to_string()
.into());
}
let Value::Array(arr) = &c.args[0] else {
return Err("sortVersions() takes an array as the first argument"
.to_string()
.into());
};
let mut versions: Vec<_> = arr
.iter()
.filter_map(|v| v.as_string().map(|s| s.to_string()))
.collect();
versions.sort_by_cached_key(|v| Versioning::new(v));
Ok(Value::Array(
versions.into_iter().map(Value::String).collect(),
))
});
let result = env.eval(expr_str, &ctx)?;
value_to_strings(result)
}
fn value_to_strings(value: Value) -> Result<Vec<String>> {
match value {
Value::Array(arr) => {
let mut result = Vec::new();
for v in arr {
if let Some(s) = value_as_string(&v)
&& !s.is_empty()
{
result.push(s);
}
}
Ok(result)
}
_ => {
if let Some(s) = value_as_string(&value)
&& !s.is_empty()
{
return Ok(vec![s]);
}
Ok(vec![])
}
}
}
fn value_as_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_version() {
let content = "2.0.53";
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["2.0.53"]);
}
#[test]
fn test_parse_single_version_with_v_prefix() {
let content = "v2.0.53";
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["2.0.53"]);
}
#[test]
fn test_parse_line_separated_versions() {
let content = "1.0.0\n1.1.0\n2.0.0";
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]);
}
#[test]
fn test_parse_line_separated_with_comments() {
let content = "# Latest versions\n1.0.0\n# Stable\n2.0.0";
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_json_array_of_strings() {
let content = r#"["1.0.0", "1.1.0", "2.0.0"]"#;
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]);
}
#[test]
fn test_parse_json_array_with_v_prefix() {
let content = r#"["v1.0.0", "v1.1.0", "v2.0.0"]"#;
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]);
}
#[test]
fn test_parse_json_array_of_objects_with_version() {
let content = r#"[{"version": "1.0.0"}, {"version": "2.0.0"}]"#;
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_json_array_of_objects_with_tag_name() {
let content = r#"[{"tag_name": "v1.0.0"}, {"tag_name": "v2.0.0"}]"#;
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_json_object_with_versions_array() {
let content = r#"{"versions": ["1.0.0", "2.0.0"]}"#;
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_with_regex() {
let content = "version 1.0.0\nversion 2.0.0\nother stuff";
let versions =
parse_version_list(content, Some(r"version (\d+\.\d+\.\d+)"), None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_with_json_path_array_field() {
let content = r#"{"data": {"versions": ["1.0.0", "2.0.0"]}}"#;
let versions = parse_version_list(content, None, Some(".data.versions[]"), None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_with_json_path_object_array() {
let content = r#"[{"version": "1.0.0"}, {"version": "2.0.0"}]"#;
let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_with_json_path_nested() {
let content =
r#"{"releases": [{"info": {"version": "1.0.0"}}, {"info": {"version": "2.0.0"}}]}"#;
let versions =
parse_version_list(content, None, Some(".releases[].info.version"), None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_removes_duplicates() {
let content = "1.0.0\n1.0.0\n2.0.0\n2.0.0";
let versions = parse_version_list(content, None, None, None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_empty_content() {
let content = "";
let versions = parse_version_list(content, None, None, None).unwrap();
assert!(versions.is_empty());
}
#[test]
fn test_parse_whitespace_only() {
let content = " \n\n ";
let versions = parse_version_list(content, None, None, None).unwrap();
assert!(versions.is_empty());
}
#[test]
fn test_parse_json_path_with_invalid_json_falls_back_to_text() {
let content = "1.0.0\n2.0.0";
let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0"]);
}
#[test]
fn test_parse_json_path_with_wrong_path_falls_back_to_text() {
let content = r#"{"other": "data"}"#;
let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap();
assert_eq!(versions, vec![r#"{"other": "data"}"#]);
}
#[test]
fn test_parse_flutter_json_with_filter() {
let content = r#"{
"releases": [
{"version": "3.38.7", "channel": "stable"},
{"version": "3.41.0-0.0.pre", "channel": "beta"},
{"version": "3.38.6", "channel": "stable"}
]
}"#;
let versions = parse_version_list(
content,
None,
Some(".releases[?channel=stable].version"),
None,
)
.unwrap();
assert_eq!(versions, vec!["3.38.7", "3.38.6"]);
}
#[test]
fn test_parse_with_version_expr_split() {
let content = "1.0.0\n2.0.0\n3.0.0";
let versions =
parse_version_list(content, None, None, Some(r#"split(body, "\n")"#)).unwrap();
assert_eq!(versions, vec!["1.0.0", "2.0.0", "3.0.0"]);
}
#[test]
fn test_parse_flutter_with_version_expr() {
let content = r#"{
"releases": [
{"version": "3.38.7", "channel": "stable"},
{"version": "3.41.0-0.0.pre", "channel": "beta"},
{"version": "3.38.6", "channel": "stable"},
{"version": "1.0.0", "channel": "stable"}
]
}"#;
let versions = parse_version_list(
content,
None,
None,
Some(r#"fromJSON(body).releases | filter({ #.channel == "stable" }) | map({ #.version }) | sortVersions()"#),
)
.unwrap();
assert_eq!(versions, vec!["1.0.0", "3.38.6", "3.38.7"]);
}
#[test]
fn test_parse_with_version_expr_json_keys() {
let content = r#"{"name":"sentinel","versions":{"0.1.0":{},"0.2.0":{},"1.0.0":{}}}"#;
let versions = parse_version_list(
content,
None,
None,
Some(r#"keys(fromJSON(body).versions)"#),
)
.unwrap();
assert_eq!(versions.len(), 3);
assert!(versions.contains(&"0.1.0".to_string()));
assert!(versions.contains(&"0.2.0".to_string()));
assert!(versions.contains(&"1.0.0".to_string()));
}
#[test]
fn test_parse_with_version_expr_filter_keys_with_regex() {
let content = r#"{"1.0.0":{},"1.1.0":{},"nightly":{},"latest":{},"1.2.0-rc1":{}}"#;
let versions = parse_version_list(
content,
None,
None,
Some(r#"sortVersions(filter(keys(fromJSON(body)), { # matches "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z\\.-]+)?$" }))"#),
)
.unwrap();
assert_eq!(versions, vec!["1.0.0", "1.1.0", "1.2.0-rc1"]);
}
}