#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Log {
pub title: Option<String>,
pub days: Vec<LogDay>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogDay {
pub date: String,
pub entries: Vec<LogEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LogEntry {
pub kind: Option<String>,
pub text: String,
}
impl Log {
pub fn parse(text: &str) -> Log {
let mut log = Log::default();
let mut current: Option<LogDay> = None;
for line in text.lines() {
let trimmed = line.trim_end();
let t = trimmed.trim_start();
if let Some(rest) = t.strip_prefix("## ") {
if let Some(day) = current.take() {
log.days.push(day);
}
current = Some(LogDay {
date: rest.trim().to_string(),
entries: Vec::new(),
});
} else if let Some(rest) = t.strip_prefix("# ") {
if log.title.is_none() && current.is_none() {
log.title = Some(rest.trim().to_string());
}
} else if let Some(rest) = bullet_body(t) {
if let Some(day) = current.as_mut() {
day.entries.push(parse_entry(rest));
}
}
}
if let Some(day) = current.take() {
log.days.push(day);
}
log
}
pub fn to_markdown(&self) -> String {
let mut out = String::new();
if let Some(title) = &self.title {
out.push_str(&format!("# {title}\n\n"));
}
for (i, day) in self.days.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!("## {}\n", day.date));
for entry in &day.entries {
match &entry.kind {
Some(kind) => out.push_str(&format!("* **{kind}**: {}\n", entry.text)),
None => out.push_str(&format!("* {}\n", entry.text)),
}
}
}
out
}
pub fn invalid_dates(&self) -> Vec<&str> {
self.days
.iter()
.map(|d| d.date.as_str())
.filter(|d| !is_iso_date(d))
.collect()
}
}
fn bullet_body(line: &str) -> Option<&str> {
line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))
}
fn parse_entry(body: &str) -> LogEntry {
let b = body.trim();
if let Some(rest) = b.strip_prefix("**") {
if let Some(end) = rest.find("**") {
let kind = rest[..end].trim().to_string();
let mut text = rest[end + 2..].trim_start();
text = text.strip_prefix(':').unwrap_or(text).trim_start();
return LogEntry {
kind: Some(kind),
text: text.to_string(),
};
}
}
LogEntry {
kind: None,
text: b.to_string(),
}
}
pub fn is_iso_date(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return false;
}
let digits = |range: std::ops::Range<usize>| range.clone().all(|i| bytes[i].is_ascii_digit());
if !(digits(0..4) && digits(5..7) && digits(8..10)) {
return false;
}
let month: u32 = s[5..7].parse().unwrap_or(0);
let day: u32 = s[8..10].parse().unwrap_or(0);
(1..=12).contains(&month) && (1..=31).contains(&day)
}