Skip to main content

oxicode/store/issues/
serialize.rs

1//! Markdown + YAML frontmatter serialization for issues.
2//!
3//! See [`parse_issue`] / [`serialize_issue`] for the on-disk format.
4
5use std::hash::{Hash, Hasher};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use chrono::Utc;
10
11use crate::store::issues::types::{Issue, IssueMeta, Priority, Status};
12
13const FRONTMATTER_DELIM: &str = "---";
14
15/// Parse a markdown-with-frontmatter file into an [`Issue`].
16///
17/// Format:
18/// ```text
19/// ---
20/// <yaml>
21/// ---
22/// <markdown body>
23/// ```
24///
25/// A missing closing delimiter is treated as "the rest is body". Missing
26/// frontmatter entirely yields an empty meta (caller decides whether that's
27/// an error).
28pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
29    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
30
31    // Split off the opening delimiter.
32    // No leading frontmatter delimiter → synthesize an empty meta and treat
33    // the whole input as body.
34    let after_open = match raw.strip_prefix(FRONTMATTER_DELIM) {
35        Some(rest) => rest,
36        None => {
37            return Ok(Issue {
38                meta: empty_meta(),
39                body: raw.to_string(),
40                path,
41            });
42        }
43    };
44
45    // Robust line-based scan for the closing `---` delimiter. Everything
46    // between the opening and closing lines is YAML; everything after is body.
47    let mut yaml = String::new();
48    let mut body = String::new();
49    let mut closed = false;
50    for line in after_open.split_inclusive('\n') {
51        if !closed && line.trim_end() == FRONTMATTER_DELIM {
52            closed = true;
53            continue;
54        }
55        if !closed {
56            yaml.push_str(line);
57        } else {
58            body.push_str(line);
59        }
60    }
61
62    let meta: IssueMeta =
63        serde_yaml::from_str(&yaml).context("failed to parse issue frontmatter")?;
64    Ok(Issue { meta, body, path })
65}
66
67/// Serialize an issue back to the markdown-with-frontmatter form.
68pub fn serialize_issue(issue: &Issue) -> Result<String> {
69    let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
70    // serde_yaml emits a trailing newline; the `---` document markers are
71    // *not* added by serde_yaml, so we wrap manually.
72    let body = if issue.body.is_empty() {
73        String::new()
74    } else if issue.body.ends_with('\n') {
75        issue.body.clone()
76    } else {
77        format!("{}\n", issue.body)
78    };
79    Ok(format!(
80        "{open}\n{yaml}{close}\n{body}",
81        open = FRONTMATTER_DELIM,
82        close = FRONTMATTER_DELIM
83    ))
84}
85
86/// Compute a content hash used for optimistic concurrency (same idea as the
87/// `edit` tool's `expected_hash`). Uses the std default hasher for zero deps.
88pub fn content_hash(raw: &str) -> String {
89    let mut hasher = std::collections::hash_map::DefaultHasher::new();
90    raw.hash(&mut hasher);
91    format!("{:016x}", hasher.finish())
92}
93
94// ============================================================================
95// Project-root discovery
96// ============================================================================
97
98/// Walk up from `start` looking for a `.oxicode/` directory. Returns the path to
99/// `<root>/.oxicode/issues`. If no `.oxicode/` exists, returns `<start>/.oxicode/issues`
100/// (lazily created on first write).
101///
102/// Mirrors the walk in `Settings::find_project_settings`.
103pub fn issues_dir(start: &Path) -> PathBuf {
104    let mut dir = start.to_path_buf();
105    loop {
106        if dir.join(".oxicode").is_dir() {
107            return dir.join(".oxicode").join("issues");
108        }
109        if !dir.pop() {
110            break;
111        }
112    }
113    start.join(".oxicode").join("issues")
114}
115
116/// Filename for an issue: zero-padded 4-digit id + slugified title.
117pub fn issue_filename(id: u32, title: &str) -> String {
118    let slug = slugify(title);
119    if slug.is_empty() {
120        format!("{:04}.md", id)
121    } else {
122        format!("{:04}-{}.md", id, slug)
123    }
124}
125
126/// Construct an empty placeholder meta (used when a file has no frontmatter).
127fn empty_meta() -> IssueMeta {
128    let now = Utc::now();
129    IssueMeta {
130        id: 0,
131        title: String::new(),
132        status: Status::default(),
133        priority: Priority::default(),
134        labels: vec![],
135        assignee: None,
136        created_at: now,
137        updated_at: now,
138        closed_at: None,
139        sessions: vec![],
140        assigned_to: None,
141        github: None,
142    }
143}
144/// Slugify a title for use in a filename: lowercase, [a-z0-9-] only.
145fn slugify(s: &str) -> String {
146    let mut out = String::new();
147    let mut prev_dash = false;
148    for c in s.chars() {
149        if c.is_ascii_alphanumeric() {
150            out.push(c.to_ascii_lowercase());
151            prev_dash = false;
152        } else if !prev_dash {
153            out.push('-');
154            prev_dash = true;
155        }
156    }
157    out.trim_matches('-').to_string()
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn slugify_basic() {
166        assert_eq!(slugify("Fix Login Bug!"), "fix-login-bug");
167        assert_eq!(slugify("   spaces   "), "spaces");
168        assert_eq!(slugify("a__b"), "a-b");
169        assert_eq!(slugify(""), "");
170    }
171
172    #[test]
173    fn issue_filename_format() {
174        assert_eq!(issue_filename(12, "Fix Login"), "0012-fix-login.md");
175        assert_eq!(issue_filename(1, ""), "0001.md");
176    }
177}