oxicode/store/issues/
serialize.rs1use 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
15pub fn parse_issue(raw: &str, path: Option<PathBuf>) -> Result<Issue> {
29 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
30
31 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 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
67pub fn serialize_issue(issue: &Issue) -> Result<String> {
69 let yaml = serde_yaml::to_string(&issue.meta).context("failed to serialize frontmatter")?;
70 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
86pub 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
94pub 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
116pub 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
126fn 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}
144fn 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}