use super::{ActivityContributor, ResearchActivity, ResearchActivityMetadata};
use crate::enrichment::Provider;
use crate::validation::{rules, Validate, ValidationReport};
use crate::{ClassificationLevel, OneOrMany};
use acorn_core::prelude::alloc::{format, String, ToString, Vec};
use acorn_core::util::{frontmatter_and_body, MarkdownSupport};
use acorn_core::validation::ValidationError;
use bon::Builder;
use core::cmp::Reverse;
use core::fmt;
use jiff::Timestamp;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_trim::{option_string_trim, string_trim};
use serde_with::skip_serializing_none;
use sophia::api::ns::Namespace;
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GraduationTarget {
Classification,
Contributors,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LogbookCategory {
Administration,
Collaboration,
Data,
#[serde(alias = "conference", alias = "tour")]
Engagement,
Funding,
#[serde(alias = "technical")]
Infrastructure,
Method,
#[serde(alias = "general")]
Other,
Personnel,
Publication,
Research,
#[serde(alias = "code")]
Software,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LogbookEntryOrigin {
#[default]
Manual,
Discovered,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LogbookError {
InvalidTimestamp(String),
InvalidWindow(String),
UnknownGraduationIdentifier(String),
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LogbookEventType {
Conference,
Exhibition,
Meeting,
Other,
Presentation,
Tour,
Training,
Workshop,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LogbookState {
Completed,
InProgress,
Upcoming,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct GraduationCandidate {
pub entry_identifier: String,
pub target: GraduationTarget,
pub value: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(transparent)]
pub struct GraduationCandidates(pub Vec<GraduationCandidate>);
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[validate(schema(function = "Logbook::is_valid", skip_on_field_errors = false))]
pub struct Logbook {
#[validate(nonempty, length(max = 1000))]
#[serde(default, deserialize_with = "option_string_trim")]
pub summary: Option<String>,
#[validate(nested, length(min = 1, max = 1000))]
pub entries: Vec<LogbookEntry>,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct LogbookDiscovery {
pub provider: Provider,
#[validate(nonempty, length(max = 2048))]
#[serde(deserialize_with = "string_trim")]
pub source: String,
#[validate(timestamp)]
#[serde(deserialize_with = "string_trim")]
pub discovered_at: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
#[builder(start_fn = init, on(LogbookCategory, into), on(LogbookEventType, into), on(LogbookState, into), on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[validate(schema(function = "LogbookEntry::is_valid", skip_on_field_errors = false))]
pub struct LogbookEntry {
#[validate(nonempty, kebabcase, length(max = 100))]
#[serde(deserialize_with = "string_trim")]
pub identifier: String,
#[validate(timestamp)]
#[serde(deserialize_with = "string_trim")]
pub timestamp: String,
pub category: LogbookCategory,
pub classification: Option<ClassificationLevel>,
pub event_type: Option<LogbookEventType>,
pub state: LogbookState,
#[builder(default)]
#[serde(default)]
pub origin: LogbookEntryOrigin,
#[validate(nested)]
pub discovery: Option<LogbookDiscovery>,
#[validate(nonempty, length(max = 500))]
#[serde(deserialize_with = "string_trim")]
pub summary: String,
#[validate(length(min = 1, max = 20), each(nonempty), each(length(max = 1000)))]
pub items: Option<Vec<String>>,
#[builder(default)]
#[serde(default)]
pub archived: bool,
}
#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct LogbookUpdates {
pub identifier: String,
pub title: String,
pub subtitle: Option<String>,
pub since: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub through: Option<String>,
pub entries: Vec<LogbookEntry>,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, Validate)]
#[validate(schema(function = "ReportingWindow::is_valid", skip_on_field_errors = false))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ReportingWindow {
#[validate(timestamp)]
pub left: Option<String>,
#[validate(timestamp)]
pub right: String,
}
impl From<&ResearchActivity> for GraduationCandidates {
fn from(activity: &ResearchActivity) -> Self {
Self(
activity
.logbook
.as_ref()
.into_iter()
.flat_map(|logbook| &logbook.entries)
.filter(|entry| !entry.archived)
.flat_map(|entry| {
let classification = entry
.classification
.as_ref()
.filter(|_| entry.category == LogbookCategory::Administration && entry.event_type == Some(LogbookEventType::Other))
.map(|classification| GraduationCandidate {
entry_identifier: entry.identifier.clone(),
target: GraduationTarget::Classification,
value: classification.to_string(),
});
let contributor = (entry.category == LogbookCategory::Personnel).then(|| GraduationCandidate {
entry_identifier: entry.identifier.clone(),
target: GraduationTarget::Contributors,
value: entry.summary.clone(),
});
[classification, contributor].into_iter().flatten()
})
.collect(),
)
}
}
impl From<ResearchActivity> for GraduationCandidates {
fn from(activity: ResearchActivity) -> Self {
Self::from(&activity)
}
}
impl GraduationCandidates {
pub fn render_note(&self) -> String {
let candidates = self
.0
.iter()
.map(|candidate| format!("- `{}` → `{:?}`: {}", candidate.entry_identifier, candidate.target, candidate.value))
.collect::<Vec<_>>()
.join("\n");
let candidates = match candidates.is_empty() {
| true => "_No graduation candidates._".to_string(),
| false => candidates,
};
format!(
"<!-- acorn:logbook-intake -->\n## ACORN logbook intake\n\n### Graduation candidates\n{candidates}\n\nUse `/acorn graduate <entry-id>...` to accept selected candidates."
)
}
}
impl Logbook {
pub fn entries_within(&self, window: &ReportingWindow) -> Result<Vec<LogbookEntry>, LogbookError> {
window.left_instant().and_then(|left| {
window.right_instant().and_then(|right| {
self.entries_all().map(|entries| {
entries
.into_iter()
.filter(|entry| {
rules::parse_timestamp(&entry.timestamp).is_ok_and(|instant| left.is_none_or(|left| instant >= left) && instant < right)
})
.collect()
})
})
})
}
pub fn entries(&self, since: &str) -> Result<Vec<LogbookEntry>, LogbookError> {
rules::parse_timestamp(since)
.map_err(|_| LogbookError::InvalidTimestamp(since.to_string()))
.and_then(|since_instant| {
self.entries_all().map(|entries| {
entries
.into_iter()
.filter(|entry| rules::parse_timestamp(&entry.timestamp).is_ok_and(|instant| instant >= since_instant))
.collect()
})
})
}
pub fn entries_all(&self) -> Result<Vec<LogbookEntry>, LogbookError> {
self.entries
.iter()
.map(|entry| {
rules::parse_timestamp(&entry.timestamp)
.map(|instant| (instant, entry))
.map_err(|_| LogbookError::InvalidTimestamp(entry.timestamp.clone()))
})
.collect::<Result<Vec<_>, _>>()
.map(|mut entries| {
entries.sort_by_key(|(instant, _)| Reverse(*instant));
entries.into_iter().map(|(_, entry)| entry.clone()).collect()
})
}
fn is_valid(&self, _context: &()) -> Result<(), ValidationReport> {
self.entries
.iter()
.enumerate()
.find_map(|(index, entry)| {
self.entries
.get(..index)
.into_iter()
.flatten()
.any(|previous| previous.identifier == entry.identifier)
.then_some((index, &entry.identifier))
})
.map(|(index, identifier)| {
let message = "Logbook entry identifiers must be unique";
let mut error = ValidationError::new("logbook_identifier").with_message(message);
error.add_param("identifier".into(), identifier);
ValidationReport::from_error(format!("entries[{index}].identifier"), error)
})
.map_or(Ok(()), Err)
}
}
impl MarkdownSupport for Logbook {
fn to_markdown(&self) -> String {
let summary = self.summary.as_ref().map(|value| format!("\n{value}\n")).unwrap_or_default();
let entries = self.entries.iter().map(MarkdownSupport::to_markdown).collect::<Vec<_>>().join("\n\n");
format!("# Logbook\n{summary}\n{entries}\n")
}
fn from_markdown(content: &str) -> Result<Self, String> {
frontmatter_and_body(content)
.0
.ok_or_else(|| "Logbook Markdown requires YAML frontmatter delimited by '---'".to_string())
.and_then(|frontmatter| Self::from_front_matter(&frontmatter))
}
}
impl From<&str> for LogbookCategory {
fn from(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
| "administration" => Self::Administration,
| "collaboration" => Self::Collaboration,
| "data" => Self::Data,
| "conference" | "engagement" | "tour" => Self::Engagement,
| "funding" => Self::Funding,
| "infrastructure" | "technical" => Self::Infrastructure,
| "method" => Self::Method,
| "personnel" => Self::Personnel,
| "publication" => Self::Publication,
| "research" => Self::Research,
| "code" | "software" => Self::Software,
| _ => Self::Other,
}
}
}
impl From<String> for LogbookCategory {
fn from(value: String) -> Self {
value.as_str().into()
}
}
impl LogbookCategory {
pub fn namespace(self) -> Namespace<&'static str> {
Namespace::new_unchecked(match self {
| Self::Administration => "https://schema.org/OrganizeAction",
| Self::Collaboration => "http://www.w3.org/ns/prov#Association",
| Self::Data => "http://www.w3.org/ns/dcat#Dataset",
| Self::Engagement => "https://schema.org/Event",
| Self::Funding => "https://schema.org/Grant",
| Self::Infrastructure => "http://www.w3.org/ns/prov#Entity",
| Self::Method => "http://www.w3.org/ns/prov#Plan",
| Self::Other => "http://www.w3.org/2004/02/skos/core#Concept",
| Self::Personnel => "http://www.w3.org/ns/prov#Person",
| Self::Publication => "https://schema.org/CreativeWork",
| Self::Research => "https://schema.org/ResearchProject",
| Self::Software => "https://schema.org/SoftwareSourceCode",
})
}
}
impl LogbookDiscovery {
pub fn identifier(&self) -> String {
self.provider.discovered_identifier(&self.source)
}
}
impl LogbookEntry {
fn is_valid(&self, _context: &()) -> Result<(), ValidationReport> {
let mut report = ValidationReport::new();
if !matches!(
(self.origin, self.discovery.as_ref()),
(LogbookEntryOrigin::Manual, None) | (LogbookEntryOrigin::Discovered, Some(_))
) {
report.add(
"discovery",
ValidationError::new("logbook_origin").with_message("logbook_origin: manual entries omit discovery; discovered entries require it"),
);
}
if self.classification.is_some() && self.category != LogbookCategory::Administration {
report.add(
"category",
ValidationError::new("logbook_classification_category")
.with_message("Classification logbook entries must use the administration category"),
);
}
if self.classification.is_some() && self.event_type != Some(LogbookEventType::Other) {
report.add(
"eventType",
ValidationError::new("logbook_classification_event").with_message("Classification logbook entries must use the other event type"),
);
}
report.finish()
}
}
impl MarkdownSupport for LogbookEntry {
fn to_markdown(&self) -> String {
let archived = if self.archived { " · archived" } else { Default::default() };
let classification = self.classification.as_ref().map(|value| format!(" · {value}")).unwrap_or_default();
let event_type = self.event_type.map(|value| format!(" · {value:?}")).unwrap_or_default();
let items = self
.items
.as_ref()
.map(|items| format!("\n{}", items.iter().map(|item| format!("- {item}")).collect::<Vec<_>>().join("\n")))
.unwrap_or_default();
format!(
"## {} — {:?}\n\n**{:?}{classification}{event_type} · {:?}{archived}**\n\n{}{}",
self.timestamp, self.state, self.category, self.origin, self.summary, items
)
}
fn from_markdown(content: &str) -> Result<Self, String> {
frontmatter_and_body(content)
.0
.ok_or_else(|| "Logbook entry Markdown requires YAML frontmatter delimited by '---'".to_string())
.and_then(|frontmatter| Self::from_front_matter(&frontmatter))
}
}
impl core::error::Error for LogbookError {}
impl fmt::Display for LogbookError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
| Self::UnknownGraduationIdentifier(identifier) => write!(formatter, "No graduation candidate has identifier '{identifier}'"),
| Self::InvalidTimestamp(value) => write!(formatter, "Invalid RFC 3339 timestamp with explicit offset: {value}"),
| Self::InvalidWindow(value) => write!(formatter, "Invalid reporting window: {value}"),
}
}
}
impl From<&str> for LogbookEventType {
fn from(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
| "conference" => Self::Conference,
| "exhibition" => Self::Exhibition,
| "meeting" => Self::Meeting,
| "presentation" => Self::Presentation,
| "tour" => Self::Tour,
| "training" => Self::Training,
| "workshop" => Self::Workshop,
| _ => Self::Other,
}
}
}
impl From<String> for LogbookEventType {
fn from(value: String) -> Self {
value.as_str().into()
}
}
impl LogbookEventType {
pub fn namespace(self) -> Namespace<&'static str> {
Namespace::new_unchecked(match self {
| Self::Conference => "http://purl.org/ontology/bibo/Conference",
| Self::Exhibition => "https://schema.org/ExhibitionEvent",
| Self::Meeting | Self::Other | Self::Presentation | Self::Tour => "https://schema.org/Event",
| Self::Training => "https://schema.org/EducationEvent",
| Self::Workshop => "http://purl.org/ontology/bibo/Workshop",
})
}
}
impl From<&str> for LogbookState {
fn from(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
| "completed" => Self::Completed,
| "upcoming" => Self::Upcoming,
| _ => Self::InProgress,
}
}
}
impl From<String> for LogbookState {
fn from(value: String) -> Self {
value.as_str().into()
}
}
impl LogbookUpdates {
pub fn archived_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.archived).count()
}
}
impl MarkdownSupport for LogbookUpdates {
fn to_markdown(&self) -> String {
let subtitle = self.subtitle.as_ref().map(|value| format!("\n_{value}_\n")).unwrap_or_default();
let entries = self.entries.iter().map(MarkdownSupport::to_markdown).collect::<Vec<_>>().join("\n\n");
let period = self.through.as_ref().map_or_else(
|| format!("Since `{}`", self.since),
|through| match self.since.as_str() {
| "full-history" => format!("Through `{through}` (exclusive)"),
| since => format!("From `{since}` through `{through}` (exclusive)"),
},
);
format!(
"# {} updates\n{subtitle}\n{period}\n\n{}\n",
self.title,
match entries.is_empty() {
| true => "_No updates in this period._".to_string(),
| false => entries,
}
)
}
}
impl ReportingWindow {
pub fn new(from: Option<&str>, through: &str) -> Result<Self, LogbookError> {
let window = Self {
left: from.map(str::to_string),
right: through.to_string(),
};
window
.validate()
.map_err(|why| LogbookError::InvalidWindow(why.to_string()))
.map(|()| window)
}
fn is_valid(&self, _context: &()) -> Result<(), ValidationReport> {
let mut report = ValidationReport::new();
let left = self.left.as_deref().map(rules::parse_timestamp).transpose();
let right = rules::parse_timestamp(&self.right);
if matches!((left, right), (Ok(Some(left)), Ok(right)) if left >= right) {
report.add(
"right",
ValidationError::new("reporting_window").with_message("The exclusive right bound must be after the inclusive left bound"),
);
}
report.finish()
}
fn left_instant(&self) -> Result<Option<Timestamp>, LogbookError> {
self.left
.as_deref()
.map(|value| rules::parse_timestamp(value).map_err(|_| LogbookError::InvalidTimestamp(value.to_string())))
.transpose()
}
fn right_instant(&self) -> Result<Timestamp, LogbookError> {
rules::parse_timestamp(&self.right).map_err(|_| LogbookError::InvalidTimestamp(self.right.clone()))
}
}
impl ResearchActivity {
pub fn graduate(self, accepted_ids: &[String]) -> Result<Self, LogbookError> {
let candidates = GraduationCandidates::from(&self).0;
accepted_ids
.iter()
.find(|identifier| {
!candidates
.iter()
.any(|candidate| candidate.entry_identifier.as_str() == identifier.as_str())
})
.map(|identifier| LogbookError::UnknownGraduationIdentifier(identifier.clone()))
.map_or_else(
|| {
let accepted = candidates
.into_iter()
.filter(|candidate| accepted_ids.contains(&candidate.entry_identifier))
.collect::<Vec<_>>();
let classification = self
.meta
.classification
.clone()
.into_iter()
.chain(
self.logbook
.as_ref()
.into_iter()
.flat_map(|logbook| &logbook.entries)
.filter(|entry| {
accepted_ids.contains(&entry.identifier)
&& entry.category == LogbookCategory::Administration
&& entry.event_type == Some(LogbookEventType::Other)
})
.filter_map(|entry| entry.classification.clone()),
)
.max();
let existing = self.meta.contributors.clone().unwrap_or_default();
let additions = accepted
.into_iter()
.filter(|candidate| candidate.target == GraduationTarget::Contributors)
.map(|candidate| candidate.value)
.filter(|name| !existing.iter().any(|contributor| contributor.name.eq_ignore_ascii_case(name)))
.map(ActivityContributor::from)
.collect::<Vec<_>>();
let contributors =
(!existing.is_empty() || !additions.is_empty()).then(|| existing.into_iter().chain(additions).collect::<OneOrMany<_>>());
Ok(Self {
meta: ResearchActivityMetadata {
classification,
contributors,
..self.meta
},
logbook: self.logbook.map(|logbook| Logbook {
entries: logbook
.entries
.into_iter()
.map(|entry| match accepted_ids.contains(&entry.identifier) {
| true => LogbookEntry {
archived: true,
state: LogbookState::Completed,
..entry
},
| false => entry,
})
.collect(),
..logbook
}),
..self
})
},
Err,
)
}
pub fn updates_since(&self, since: &str) -> Result<LogbookUpdates, LogbookError> {
self.logbook
.as_ref()
.map_or(Ok(Vec::new()), |logbook| logbook.entries(since))
.map(|entries| LogbookUpdates {
identifier: self.meta.identifier.clone(),
title: self.title.clone(),
subtitle: self.subtitle.clone(),
since: since.to_string(),
through: None,
entries,
})
}
pub fn updates_within(&self, window: &ReportingWindow) -> Result<LogbookUpdates, LogbookError> {
window
.validate()
.map_err(|why| LogbookError::InvalidWindow(why.to_string()))
.and_then(|()| {
self.logbook
.as_ref()
.map_or(Ok(Vec::new()), |logbook| logbook.entries_within(window))
.map(|entries| LogbookUpdates {
identifier: self.meta.identifier.clone(),
title: self.title.clone(),
subtitle: self.subtitle.clone(),
since: window.left.clone().unwrap_or_else(|| "full-history".to_string()),
through: Some(window.right.clone()),
entries,
})
})
}
}