use chrono::{DateTime, Datelike, Utc};
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtractedFields {
pub reporter: Option<String>,
pub description: Option<String>,
pub created: Option<DateTime<Utc>>,
}
#[must_use]
pub fn extract_fields(raw_json: Option<&str>) -> ExtractedFields {
let Some(text) = raw_json else {
return ExtractedFields::default();
};
let Ok(v) = serde_json::from_str::<Value>(text) else {
return ExtractedFields::default();
};
let fields = v.get("fields");
ExtractedFields {
reporter: extract_reporter(&v, fields),
description: extract_description(&v, fields),
created: extract_created(&v, fields),
}
}
#[must_use]
pub fn week_key(at: DateTime<Utc>) -> String {
let iso = at.iso_week();
format!("{:04}-W{:02}", iso.year(), iso.week())
}
fn extract_reporter(root: &Value, fields: Option<&Value>) -> Option<String> {
let candidates = [
fields.and_then(|f| f.get("reporter")),
fields.and_then(|f| f.get("creator")),
fields.and_then(|f| f.get("System.CreatedBy")),
root.get("user"),
root.get("creator"),
root.get("author"),
];
candidates.into_iter().flatten().find_map(person_name)
}
fn person_name(value: &Value) -> Option<String> {
if let Some(s) = value.as_str() {
return non_empty(s);
}
for key in ["displayName", "name", "login", "emailAddress"] {
if let Some(s) = value.get(key).and_then(Value::as_str) {
if let Some(found) = non_empty(s) {
return Some(found);
}
}
}
None
}
fn extract_description(root: &Value, fields: Option<&Value>) -> Option<String> {
let candidates = [
fields.and_then(|f| f.get("description")),
fields.and_then(|f| f.get("System.Description")),
root.get("description"),
root.get("body"),
];
candidates
.into_iter()
.flatten()
.find_map(description_text)
.and_then(|s| non_empty(&s))
}
fn description_text(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(strip_html_tags(s)),
Value::Object(_) | Value::Array(_) => {
let mut acc = String::new();
collect_adf_text(value, &mut acc);
Some(acc)
}
_ => None,
}
}
fn collect_adf_text(value: &Value, acc: &mut String) {
match value {
Value::Object(map) => {
for (key, child) in map {
if key == "text" {
if let Some(s) = child.as_str() {
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str(s);
continue;
}
}
collect_adf_text(child, acc);
}
}
Value::Array(items) => {
for item in items {
collect_adf_text(item, acc);
}
}
_ => {}
}
}
fn strip_html_tags(s: &str) -> String {
if !s.contains('<') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => {
in_tag = false;
out.push(' ');
}
_ if !in_tag => out.push(ch),
_ => {}
}
}
out
}
fn extract_created(root: &Value, fields: Option<&Value>) -> Option<DateTime<Utc>> {
let candidates = [
fields.and_then(|f| f.get("created")),
fields.and_then(|f| f.get("System.CreatedDate")),
root.get("created_at"),
root.get("createdAt"),
root.get("created"),
];
candidates
.into_iter()
.flatten()
.filter_map(Value::as_str)
.find_map(parse_timestamp)
}
fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.3f%z")
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn non_empty(s: &str) -> Option<String> {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}