1use crate::document::Document;
17use crate::frontmatter::Frontmatter;
18use std::fmt::Write as _;
19
20#[derive(Clone, Debug, Default, PartialEq)]
22pub struct Log {
23 pub frontmatter: Frontmatter,
25 pub title: Option<String>,
27 pub days: Vec<LogDay>,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct LogDay {
34 pub date: String,
36 pub entries: Vec<LogEntry>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct LogEntry {
43 pub kind: Option<String>,
45 pub text: String,
47}
48
49impl Log {
50 #[must_use]
52 pub fn parse(text: &str) -> Self {
53 let (frontmatter, body) = match Document::parse(text) {
54 Ok(doc) => (doc.frontmatter, doc.body),
55 Err(_) => (Frontmatter::new(), text.to_string()),
56 };
57 let mut log = Self {
58 frontmatter,
59 title: None,
60 days: Vec::new(),
61 };
62 let mut current: Option<LogDay> = None;
63
64 for line in body.lines() {
65 let trimmed = line.trim_end();
66 let t = trimmed.trim_start();
67 if let Some(rest) = t.strip_prefix("## ") {
68 if let Some(day) = current.take() {
69 log.days.push(day);
70 }
71 current = Some(LogDay {
72 date: rest.trim().to_string(),
73 entries: Vec::new(),
74 });
75 } else if let Some(rest) = t.strip_prefix("# ") {
76 if log.title.is_none() && current.is_none() {
77 log.title = Some(rest.trim().to_string());
78 }
79 } else if let Some(rest) = bullet_body(t)
80 && let Some(day) = current.as_mut()
81 {
82 day.entries.push(parse_entry(rest));
83 }
84 }
85 if let Some(day) = current.take() {
86 log.days.push(day);
87 }
88 log
89 }
90
91 #[must_use]
93 pub fn to_markdown(&self) -> String {
94 let mut out = String::new();
95 if !self.frontmatter.is_empty() {
96 let fm_text =
97 crate::yaml::Value::Mapping(self.frontmatter.as_mapping().clone()).to_yaml_string();
98 out.push_str("---\n");
99 out.push_str(&fm_text);
100 out.push_str("---\n\n");
101 }
102 if let Some(title) = &self.title {
103 let _ = writeln!(out, "# {title}");
104 out.push('\n');
105 }
106 for (i, day) in self.days.iter().enumerate() {
107 if i > 0 {
108 out.push('\n');
109 }
110 let _ = writeln!(out, "## {}", day.date);
111 for entry in &day.entries {
112 match &entry.kind {
113 Some(kind) => {
114 let _ = writeln!(out, "* **{kind}**: {}", entry.text);
115 }
116 None => {
117 let _ = writeln!(out, "* {}", entry.text);
118 }
119 }
120 }
121 }
122 out
123 }
124
125 #[must_use]
128 pub fn invalid_dates(&self) -> Vec<&str> {
129 self.days
130 .iter()
131 .map(|d| d.date.as_str())
132 .filter(|d| !is_iso_date(d))
133 .collect()
134 }
135
136 pub fn structural_errors(&self, text: &str) -> Vec<String> {
143 let mut errors = Vec::new();
144
145 if self.days.is_empty() {
146 errors.push("log contains no date groups".to_string());
147 }
148 for day in &self.days {
149 if day.entries.is_empty() {
150 errors.push(format!("log date group {:?} has no entries", day.date));
151 }
152 }
153
154 let mut previous: Option<(&str, crate::date::Date)> = None;
155 for day in &self.days {
156 let Some(date) = crate::date::Date::parse(&day.date) else {
157 continue;
158 };
159 if let Some((previous_text, previous_date)) = previous
160 && date > previous_date
161 {
162 errors.push(format!(
163 "log date groups are not newest first: {:?} follows {:?}",
164 day.date, previous_text
165 ));
166 }
167 previous = Some((day.date.as_str(), date));
168 }
169
170 let lines: Vec<&str> = text.lines().collect();
171 let mut start_idx = 0;
172 if !lines.is_empty() && lines[0].trim() == "---" {
173 let mut end_idx = None;
174 for (i, line) in lines.iter().enumerate().skip(1) {
175 if line.trim() == "---" {
176 end_idx = Some(i);
177 break;
178 }
179 }
180 if let Some(end) = end_idx {
181 start_idx = end + 1;
182 } else {
183 errors.push("Unterminated YAML frontmatter block".to_string());
184 }
185 }
186
187 let mut saw_date = false;
188 let mut saw_title = false;
189 let mut current_has_entry = false;
190 for (line_offset, line) in lines[start_idx..].iter().enumerate() {
191 let line_index = start_idx + line_offset;
192 let trimmed = line.trim_end();
193 let t = trimmed.trim_start();
194 if t.is_empty() {
195 continue;
196 }
197 if t.starts_with("## ") {
198 saw_date = true;
199 current_has_entry = false;
200 continue;
201 }
202 if t.starts_with("# ") {
203 if saw_date || saw_title {
204 errors.push(format!(
205 "log contains non-log content at line {}",
206 line_index + 1
207 ));
208 } else {
209 saw_title = true;
210 }
211 continue;
212 }
213 if bullet_body(t).is_some() {
214 if saw_date {
215 current_has_entry = true;
216 } else {
217 errors.push(format!(
218 "log contains an entry outside a date group at line {}",
219 line_index + 1
220 ));
221 }
222 continue;
223 }
224
225 if saw_date && current_has_entry && line.chars().next().is_some_and(char::is_whitespace)
228 {
229 continue;
230 }
231 errors.push(format!(
232 "log contains non-log content at line {}",
233 line_index + 1
234 ));
235 }
236 errors
237 }
238}
239
240fn bullet_body(line: &str) -> Option<&str> {
242 line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))
243}
244
245fn parse_entry(body: &str) -> LogEntry {
247 let b = body.trim();
248 if let Some(rest) = b.strip_prefix("**")
249 && let Some(end) = rest.find("**")
250 {
251 let kind = rest[..end].trim().to_string();
252 let mut text = rest[end + 2..].trim_start();
253 text = text.strip_prefix(':').unwrap_or(text).trim_start();
254 return LogEntry {
255 kind: Some(kind),
256 text: text.to_string(),
257 };
258 }
259 LogEntry {
260 kind: None,
261 text: b.to_string(),
262 }
263}
264
265#[must_use]
267pub fn is_iso_date(s: &str) -> bool {
268 crate::date::Date::parse(s).is_some()
269}