1use crate::date::Date;
17use crate::document::Document;
18use crate::frontmatter::Frontmatter;
19use std::fmt::Write as _;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24#[derive(Clone, Debug, Default, PartialEq)]
26pub struct Log {
27 pub frontmatter: Frontmatter,
29 pub title: Option<String>,
31 pub days: Vec<LogDay>,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct LogDay {
38 pub date: String,
40 pub entries: Vec<LogEntry>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct LogEntry {
47 pub kind: Option<String>,
49 pub text: String,
51}
52
53impl Log {
54 #[must_use]
56 pub fn parse(text: &str) -> Self {
57 let (frontmatter, body) = match Document::parse(text) {
58 Ok(doc) => (doc.frontmatter, doc.body),
59 Err(_) => (Frontmatter::new(), text.to_string()),
60 };
61 let mut log = Self {
62 frontmatter,
63 title: None,
64 days: Vec::new(),
65 };
66 let mut current: Option<LogDay> = None;
67
68 for line in body.lines() {
69 let trimmed = line.trim_end();
70 let t = trimmed.trim_start();
71 if let Some(rest) = t.strip_prefix("## ") {
72 if let Some(day) = current.take() {
73 log.days.push(day);
74 }
75 current = Some(LogDay {
76 date: rest.trim().to_string(),
77 entries: Vec::new(),
78 });
79 } else if let Some(rest) = t.strip_prefix("# ") {
80 if log.title.is_none() && current.is_none() {
81 log.title = Some(rest.trim().to_string());
82 }
83 } else if let Some(rest) = bullet_body(t)
84 && let Some(day) = current.as_mut()
85 {
86 day.entries.push(parse_entry(rest));
87 }
88 }
89 if let Some(day) = current.take() {
90 log.days.push(day);
91 }
92 log
93 }
94
95 #[must_use]
97 pub fn to_markdown(&self) -> String {
98 let mut out = String::new();
99 if !self.frontmatter.is_empty() {
100 let fm_text =
101 crate::yaml::Value::Mapping(self.frontmatter.as_mapping().clone()).to_yaml_string();
102 out.push_str("---\n");
103 out.push_str(&fm_text);
104 out.push_str("---\n\n");
105 }
106 if let Some(title) = &self.title {
107 let _ = writeln!(out, "# {title}");
108 out.push('\n');
109 }
110 for (i, day) in self.days.iter().enumerate() {
111 if i > 0 {
112 out.push('\n');
113 }
114 let _ = writeln!(out, "## {}", day.date);
115 for entry in &day.entries {
116 match &entry.kind {
117 Some(kind) => {
118 let _ = writeln!(out, "* **{kind}**: {}", entry.text);
119 }
120 None => {
121 let _ = writeln!(out, "* {}", entry.text);
122 }
123 }
124 }
125 }
126 out
127 }
128
129 pub fn append_entry(&mut self, date: &str, kind: Option<&str>, text: &str) {
134 let new_entry = LogEntry {
135 kind: kind.map(ToString::to_string),
136 text: text.to_string(),
137 };
138
139 if let Some(first_day) = self.days.first_mut()
140 && first_day.date == date
141 {
142 first_day.entries.push(new_entry);
143 } else {
144 self.days.insert(
145 0,
146 LogDay {
147 date: date.to_string(),
148 entries: vec![new_entry],
149 },
150 );
151 }
152 }
153
154 #[must_use]
157 pub fn invalid_dates(&self) -> Vec<&str> {
158 self.days
159 .iter()
160 .map(|d| d.date.as_str())
161 .filter(|d| !is_iso_date(d))
162 .collect()
163 }
164
165 #[must_use]
172 pub fn structural_errors(&self, text: &str) -> Vec<String> {
173 let mut errors = Vec::new();
174
175 if self.days.is_empty() {
176 errors.push("log contains no date groups".to_string());
177 }
178 for day in &self.days {
179 if day.entries.is_empty() {
180 errors.push(format!("log date group {:?} has no entries", day.date));
181 }
182 }
183
184 let mut previous: Option<(&str, crate::date::Date)> = None;
185 for day in &self.days {
186 let Some(date) = crate::date::Date::parse(&day.date) else {
187 continue;
188 };
189 if let Some((previous_text, previous_date)) = previous
190 && date > previous_date
191 {
192 errors.push(format!(
193 "log date groups are not newest first: {:?} follows {:?}",
194 day.date, previous_text
195 ));
196 }
197 previous = Some((day.date.as_str(), date));
198 }
199
200 let lines: Vec<&str> = text.lines().collect();
201 let mut start_idx = 0;
202 if !lines.is_empty() && lines[0].trim() == "---" {
203 let mut end_idx = None;
204 for (i, line) in lines.iter().enumerate().skip(1) {
205 if line.trim() == "---" {
206 end_idx = Some(i);
207 break;
208 }
209 }
210 if let Some(end) = end_idx {
211 start_idx = end + 1;
212 } else {
213 errors.push("Unterminated YAML frontmatter block".to_string());
214 }
215 }
216
217 let mut saw_date = false;
218 let mut saw_title = false;
219 let mut current_has_entry = false;
220 for (line_offset, line) in lines[start_idx..].iter().enumerate() {
221 let line_index = start_idx + line_offset;
222 let trimmed = line.trim_end();
223 let t = trimmed.trim_start();
224 if t.is_empty() {
225 continue;
226 }
227 if t.starts_with("## ") {
228 saw_date = true;
229 current_has_entry = false;
230 continue;
231 }
232 if t.starts_with("# ") {
233 if saw_date || saw_title {
234 errors.push(format!(
235 "log contains non-log content at line {}",
236 line_index + 1
237 ));
238 } else {
239 saw_title = true;
240 }
241 continue;
242 }
243 if bullet_body(t).is_some() {
244 if saw_date {
245 current_has_entry = true;
246 } else {
247 errors.push(format!(
248 "log contains an entry outside a date group at line {}",
249 line_index + 1
250 ));
251 }
252 continue;
253 }
254
255 if saw_date && current_has_entry && line.chars().next().is_some_and(char::is_whitespace)
258 {
259 continue;
260 }
261 errors.push(format!(
262 "log contains non-log content at line {}",
263 line_index + 1
264 ));
265 }
266 errors
267 }
268}
269
270pub fn append_log_entry(
276 bundle_root: &Path,
277 date: Date,
278 kind: &str,
279 text: &str,
280) -> io::Result<PathBuf> {
281 let log_path = bundle_root.join("log.md");
282 let mut log = if log_path.exists() {
283 let content = fs::read_to_string(&log_path)?;
284 Log::parse(&content)
285 } else {
286 Log {
287 title: Some("Update Log".to_string()),
288 ..Default::default()
289 }
290 };
291
292 log.append_entry(&date.to_string(), Some(kind), text);
293 fs::write(&log_path, log.to_markdown())?;
294 Ok(log_path)
295}
296
297fn bullet_body(line: &str) -> Option<&str> {
299 line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))
300}
301
302fn parse_entry(body: &str) -> LogEntry {
304 let b = body.trim();
305 if let Some(rest) = b.strip_prefix("**")
306 && let Some(end) = rest.find("**")
307 {
308 let kind = rest[..end].trim().to_string();
309 let mut text = rest[end + 2..].trim_start();
310 text = text.strip_prefix(':').unwrap_or(text).trim_start();
311 return LogEntry {
312 kind: Some(kind),
313 text: text.to_string(),
314 };
315 }
316 LogEntry {
317 kind: None,
318 text: b.to_string(),
319 }
320}
321
322#[must_use]
324pub fn is_iso_date(s: &str) -> bool {
325 crate::date::Date::parse(s).is_some()
326}