Skip to main content

okf_core/
log.rs

1//! Parsing, building, and updating `log.md` update histories.
2//!
3//! A log is a flat list of date-grouped entries, newest first:
4//!
5//! ```text
6//! # Directory Update Log
7//!
8//! ## 2026-05-22
9//! * **Update**: Added a new table reference.
10//! * **Creation**: Established the playbook.
11//! ```
12//!
13//! Date headings use ISO-8601 `YYYY-MM-DD`. The leading bold word
14//! (`**Update**`, `**Creation**`, …) is a convention, not a requirement.
15
16use 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/// A parsed `log.md`.
25#[derive(Clone, Debug, Default, PartialEq)]
26pub struct Log {
27    /// Optional frontmatter block.
28    pub frontmatter: Frontmatter,
29    /// The top-level `# ` heading text, if any.
30    pub title: Option<String>,
31    /// Date-grouped entries, in document order (the convention is newest-first).
32    pub days: Vec<LogDay>,
33}
34
35/// All entries recorded under a single date heading.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct LogDay {
38    /// The `## ` heading text (an ISO-8601 date by convention).
39    pub date: String,
40    /// The bullet entries under this date.
41    pub entries: Vec<LogEntry>,
42}
43
44/// A single log bullet.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct LogEntry {
47    /// The leading bold marker (`Update`, `Creation`, …), if present.
48    pub kind: Option<String>,
49    /// The entry prose (everything after the optional marker).
50    pub text: String,
51}
52
53impl Log {
54    /// Parses `log.md` text.
55    #[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    /// Renders the log back to markdown.
96    #[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    /// Appends a new entry under the given date heading.
130    ///
131    /// If the top (most recent) date heading matches `date`, the entry is appended
132    /// to it. Otherwise, a new date section is inserted at the top of the log.
133    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    /// Returns the date headings that are not valid ISO-8601 `YYYY-MM-DD`
155    /// (the spec requires this form).
156    #[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    /// Returns structural log violations found in the source text.
166    ///
167    /// [`Log::parse`] intentionally remains a forgiving reader for consumers
168    /// that want to recover entries from imperfect Markdown. Conformance
169    /// validation uses this stricter pass to ensure that ignored content is
170    /// not mistaken for a valid log.
171    #[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            // Markdown permits an entry's prose to continue on an indented
256            // line. Unindented prose is not silently assigned to a group.
257            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
270/// Appends an entry to `log.md` in the bundle root, creating the file if needed.
271///
272/// # Errors
273///
274/// Returns an [`io::Error`] if reading or writing `log.md` fails.
275pub 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
297/// Returns the text after a `*` or `-` bullet marker, if the line is a bullet.
298fn bullet_body(line: &str) -> Option<&str> {
299    line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))
300}
301
302/// Parses a bullet body into an optional bold `kind` and the remaining text.
303fn 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/// Checks that a string is a valid ISO-8601 calendar date (`YYYY-MM-DD`).
323#[must_use]
324pub fn is_iso_date(s: &str) -> bool {
325    crate::date::Date::parse(s).is_some()
326}