use base64::prelude::*;
use serde_json::Value;
use similar::{ChangeTag, TextDiff};
use super::json::field_str;
pub const FUNCTION_IMAGE_PREFIX: &str = "ghcr.io/railwayapp/function";
const START_COMMAND_PREFIX: &str = "./run.sh ";
pub fn is_function_image(image: &str) -> bool {
image.starts_with(FUNCTION_IMAGE_PREFIX)
}
pub fn is_function_resource(resource: &Value) -> bool {
if field_str(resource, "type") != Some("service") {
return false;
}
if field_str(resource, "kind") == Some("function") {
return true;
}
resource
.get("source")
.and_then(|source| field_str(source, "image"))
.is_some_and(is_function_image)
}
pub fn decode_function_code(start_command: &str) -> Option<String> {
let encoded = start_command.strip_prefix(START_COMMAND_PREFIX)?.trim();
if encoded.is_empty() || encoded.contains(' ') {
return None;
}
let bytes = BASE64_STANDARD.decode(encoded).ok()?;
String::from_utf8(bytes).ok()
}
#[cfg(test)]
pub fn encode_function_code(code: &str) -> String {
format!("{START_COMMAND_PREFIX}{}", BASE64_STANDARD.encode(code))
}
pub fn function_code_of(deploy: &Value) -> Option<String> {
field_str(deploy, "startCommand").and_then(decode_function_code)
}
pub fn source_code_diff_lines(before: &str, after: &str) -> (Vec<String>, usize, usize) {
let diff = TextDiff::from_lines(before, after);
let mut lines = Vec::new();
let mut insertions = 0;
let mut deletions = 0;
for (index, group) in diff.grouped_ops(3).iter().enumerate() {
if index > 0 {
lines.push("...".to_string());
}
for op in group {
for change in diff.iter_changes(op) {
let marker = match change.tag() {
ChangeTag::Delete => {
deletions += 1;
"-"
}
ChangeTag::Insert => {
insertions += 1;
"+"
}
ChangeTag::Equal => " ",
};
let text = change.value().trim_end_matches(['\n', '\r']);
lines.push(format!("{marker} {text}"));
}
}
}
(lines, insertions, deletions)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trips_source_through_the_start_command() {
let code = "export default { fetch() { return new Response(\"hi\"); } }\n";
assert_eq!(
decode_function_code(&encode_function_code(code)).as_deref(),
Some(code)
);
}
#[test]
fn rejects_commands_that_are_not_function_start_commands() {
assert_eq!(decode_function_code("npm start"), None);
assert_eq!(decode_function_code("./run.sh"), None);
assert_eq!(decode_function_code("./run.sh not base64!"), None);
assert_eq!(decode_function_code("./run.sh aGk= extra"), None);
}
#[test]
fn classifies_function_resources_by_kind_or_image() {
assert!(is_function_resource(
&json!({ "type": "service", "kind": "function" })
));
assert!(is_function_resource(&json!({
"type": "service",
"kind": "docker-image",
"source": { "type": "image", "image": "ghcr.io/railwayapp/function:1.2.3" }
})));
assert!(!is_function_resource(&json!({
"type": "service",
"kind": "docker-image",
"source": { "type": "image", "image": "ghcr.io/acme/api:1" }
})));
assert!(!is_function_resource(
&json!({ "type": "database", "kind": "function" })
));
}
#[test]
fn diff_lines_carry_markers_and_counts() {
let (lines, insertions, deletions) = source_code_diff_lines("a\nb\nc\n", "a\nB\nc\n");
assert_eq!(lines, vec![" a", "- b", "+ B", " c"]);
assert_eq!((insertions, deletions), (1, 1));
assert!(source_code_diff_lines("same\n", "same\n").0.is_empty());
}
}