Skip to main content

okf_core/
log.rs

1//! Parsing and building `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::document::Document;
17use crate::frontmatter::Frontmatter;
18use std::fmt::Write as _;
19
20/// A parsed `log.md`.
21#[derive(Clone, Debug, Default, PartialEq)]
22pub struct Log {
23    /// Optional frontmatter block.
24    pub frontmatter: Frontmatter,
25    /// The top-level `# ` heading text, if any.
26    pub title: Option<String>,
27    /// Date-grouped entries, in document order (the convention is newest-first).
28    pub days: Vec<LogDay>,
29}
30
31/// All entries recorded under a single date heading.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct LogDay {
34    /// The `## ` heading text (an ISO-8601 date by convention).
35    pub date: String,
36    /// The bullet entries under this date.
37    pub entries: Vec<LogEntry>,
38}
39
40/// A single log bullet.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct LogEntry {
43    /// The leading bold marker (`Update`, `Creation`, …), if present.
44    pub kind: Option<String>,
45    /// The entry prose (everything after the optional marker).
46    pub text: String,
47}
48
49impl Log {
50    /// Parses `log.md` text.
51    #[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    /// Renders the log back to markdown.
92    #[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    /// Returns the date headings that are not valid ISO-8601 `YYYY-MM-DD`
126    /// (the spec requires this form).
127    #[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    /// Returns structural log violations found in the source text.
137    ///
138    /// [`Log::parse`] intentionally remains a forgiving reader for consumers
139    /// that want to recover entries from imperfect Markdown. Conformance
140    /// validation uses this stricter pass to ensure that ignored content is
141    /// not mistaken for a valid log.
142    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            // Markdown permits an entry's prose to continue on an indented
226            // line. Unindented prose is not silently assigned to a group.
227            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
240/// Returns the text after a `*` or `-` bullet marker, if the line is a bullet.
241fn bullet_body(line: &str) -> Option<&str> {
242    line.strip_prefix("* ").or_else(|| line.strip_prefix("- "))
243}
244
245/// Parses a bullet body into an optional bold `kind` and the remaining text.
246fn 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/// Checks that a string is a valid ISO-8601 calendar date (`YYYY-MM-DD`).
266#[must_use]
267pub fn is_iso_date(s: &str) -> bool {
268    crate::date::Date::parse(s).is_some()
269}