use std::collections::BTreeSet;
use ikigai_core::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::authority::CAP_LISP;
pub const SCHEMA: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Language {
Lisp,
Sparql,
Plan,
}
impl Language {
pub const ALL: [Language; 3] = [Language::Lisp, Language::Sparql, Language::Plan];
pub fn as_str(self) -> &'static str {
match self {
Language::Lisp => "lisp",
Language::Sparql => "sparql",
Language::Plan => "plan",
}
}
pub fn parse(text: &str) -> Result<Language> {
match text.trim() {
"lisp" => Ok(Language::Lisp),
"sparql" => Ok(Language::Sparql),
"plan" => Ok(Language::Plan),
other => Err(Error::InvalidArgument {
name: "language".to_string(),
detail: format!(
"`{other}` is not a script language here; `lisp`, `sparql` or `plan`"
),
}),
}
}
pub fn evaluator(self) -> Option<&'static str> {
match self {
Language::Lisp => Some("urn:lisp:eval"),
Language::Sparql => None,
Language::Plan => Some(crate::plan::EVAL),
}
}
pub fn capability(self) -> Option<&'static str> {
match self {
Language::Lisp => Some(CAP_LISP),
Language::Sparql | Language::Plan => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum State {
Draft,
Published,
Retired,
}
impl State {
pub fn as_str(self) -> &'static str {
match self {
State::Draft => "draft",
State::Published => "published",
State::Retired => "retired",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Version {
pub language: Language,
pub requires: BTreeSet<String>,
pub budget: Option<u64>,
pub source: String,
}
impl Version {
pub fn canonical(&self) -> Vec<u8> {
let mut out = format!("ikigai-script/1\nlanguage {}\n", self.language.as_str());
for scope in &self.requires {
out.push_str("requires ");
out.push_str(scope);
out.push('\n');
}
if let Some(millis) = self.budget {
out.push_str(&format!("budget {millis}\n"));
}
out.push('\n');
out.push_str(&self.source);
out.into_bytes()
}
pub fn digest(&self) -> String {
let hash = Sha256::digest(self.canonical());
let mut out = String::with_capacity(7 + 64);
out.push_str("sha256:");
for byte in hash {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub fn document(&self, name: &str) -> VersionDocument {
let digest = self.digest();
VersionDocument {
schema: SCHEMA,
iri: crate::name::version_iri(name, &digest),
script: crate::name::script_iri(name),
version: digest,
language: self.language,
requires: self.requires.clone(),
budget: self.budget,
source: self.source.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionDocument {
pub schema: u32,
pub iri: String,
pub script: String,
pub version: String,
pub language: Language,
pub requires: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<u64>,
pub source: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
pub action: String,
pub version: String,
pub state: State,
pub public: bool,
pub principal: String,
pub at: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Head {
pub schema: u32,
pub name: String,
pub version: String,
pub state: State,
pub public: bool,
pub granted: BTreeSet<String>,
pub exclusions: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub granted_budget: Option<u64>,
pub publisher: String,
pub updated: Option<u64>,
pub history: Vec<Event>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "status")]
pub enum Outcome {
Running,
Ok,
Failed {
kind: String,
message: String,
},
}
pub const MAX_RECORDED_RESULT: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Run {
pub schema: u32,
pub name: String,
pub id: u64,
pub version: String,
pub principal: String,
pub capability: BTreeSet<String>,
pub started: Option<u64>,
pub ended: Option<u64>,
pub outcome: Outcome,
pub result: Option<String>,
pub truncated: bool,
pub trace_span: Option<u64>,
}
impl Run {
pub fn iri(&self) -> String {
crate::name::run_iri(&self.name, self.id)
}
pub fn render(&self) -> String {
let outcome = match &self.outcome {
Outcome::Running => "running".to_string(),
Outcome::Ok => "ok".to_string(),
Outcome::Failed { kind, message } => format!("failed ({kind}): {message}"),
};
let mut text = format!(
"{}\n script: {}\n version: {}\n principal: {}\n started: {}\n \
ended: {}\n outcome: {outcome}\n capability: {}\n",
self.iri(),
crate::name::script_iri(&self.name),
self.version,
self.principal,
when(self.started),
when(self.ended),
if self.capability.is_empty() {
"(none)".to_string()
} else {
self.capability
.iter()
.cloned()
.collect::<Vec<_>>()
.join(" ")
},
);
if let Some(span) = self.trace_span {
text.push_str(&format!(" trace span: {span}\n"));
}
if let Some(result) = &self.result {
text.push_str(&format!(
" result{}:\n{result}\n",
if self.truncated { " (truncated)" } else { "" }
));
}
text
}
}
pub fn when(millis: Option<u64>) -> String {
let Some(millis) = millis else {
return "unstamped (the kernel has no clock)".to_string();
};
let secs = millis / 1000;
let (days, rem) = (secs / 86_400, secs % 86_400);
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
rem / 3600,
rem % 3600 / 60,
rem % 60,
millis % 1000
)
}
pub(crate) fn truncate(mut text: String, max: usize) -> (String, bool) {
if text.len() <= max {
return (text, false);
}
let mut end = max;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
(text, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requires_and_language_are_part_of_the_content() {
let a = Version {
language: Language::Lisp,
requires: BTreeSet::new(),
budget: None,
source: "(+ 1 2)".to_string(),
};
let mut b = a.clone();
b.requires.insert("urn:cap:lisp".to_string());
assert_ne!(a.digest(), b.digest());
}
#[test]
fn a_version_without_a_budget_keeps_its_digest() {
let v = Version {
language: Language::Lisp,
requires: ["urn:cap:lisp".to_string()].into_iter().collect(),
budget: None,
source: "(+ 1 2)".to_string(),
};
assert_eq!(
v.digest(),
"sha256:ccea630c6cbeb36e3c3a57a9199934e32666e552550dd3c2d321171a493c9bf6"
);
let budgeted = Version {
budget: Some(900),
..v.clone()
};
assert!(String::from_utf8(budgeted.canonical())
.unwrap()
.contains("requires urn:cap:lisp\nbudget 900\n\n(+ 1 2)"));
}
#[test]
fn truncation_respects_characters() {
let (text, cut) = truncate("héllo".to_string(), 2);
assert_eq!((text.as_str(), cut), ("h", true));
let (text, cut) = truncate("abc".to_string(), 3);
assert_eq!((text.as_str(), cut), ("abc", false));
}
}