1use crate::errors::LitError;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub enum IssueState {
13 Open,
14 Closed,
15}
16
17impl std::fmt::Display for IssueState {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 IssueState::Open => write!(f, "open"),
21 IssueState::Closed => write!(f, "closed"),
22 }
23 }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct IssueComment {
28 pub author: String,
29 pub body: String,
30 pub created: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Issue {
35 pub id: u64,
36 pub title: String,
37 pub body: String,
38 pub author: String,
39 pub state: IssueState,
40 pub labels: Vec<String>,
41 pub comments: Vec<IssueComment>,
42 pub created: String,
43 pub updated: String,
44}
45
46fn issues_dir(repo_root: &Path) -> std::path::PathBuf {
47 repo_root.join(".lit").join("refs").join("issues")
48}
49
50fn next_id(repo_root: &Path) -> Result<u64, LitError> {
51 let dir = issues_dir(repo_root);
52 if !dir.exists() {
53 return Ok(1);
54 }
55 let mut max_id: u64 = 0;
56 for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
57 let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
58 if let Some(stem) = entry.path().file_stem() {
59 if let Ok(id) = stem.to_string_lossy().parse::<u64>() {
60 if id > max_id {
61 max_id = id;
62 }
63 }
64 }
65 }
66 Ok(max_id + 1)
67}
68
69pub fn create_issue(
71 repo_root: &Path,
72 title: &str,
73 body: &str,
74 author: &str,
75 labels: Vec<String>,
76) -> Result<Issue, LitError> {
77 let dir = issues_dir(repo_root);
78 fs::create_dir_all(&dir)
79 .map_err(|e| LitError::io(format!("Failed to create issues dir: {}", e)))?;
80
81 let id = next_id(repo_root)?;
82 let now = chrono::Utc::now().to_rfc3339();
83 let issue = Issue {
84 id,
85 title: title.to_string(),
86 body: body.to_string(),
87 author: author.to_string(),
88 state: IssueState::Open,
89 labels,
90 comments: Vec::new(),
91 created: now.clone(),
92 updated: now,
93 };
94
95 let path = dir.join(format!("{}.json", id));
96 let json = serde_json::to_string_pretty(&issue)
97 .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
98 fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
99 Ok(issue)
100}
101
102pub fn get_issue(repo_root: &Path, id: u64) -> Result<Issue, LitError> {
104 let path = issues_dir(repo_root).join(format!("{}.json", id));
105 if !path.exists() {
106 return Err(LitError::general(format!("Issue #{} not found", id)));
107 }
108 let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
109 serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse: {}", e)))
110}
111
112pub fn list_issues(repo_root: &Path, state: Option<IssueState>) -> Result<Vec<Issue>, LitError> {
114 let dir = issues_dir(repo_root);
115 if !dir.exists() {
116 return Ok(Vec::new());
117 }
118
119 let mut issues = Vec::new();
120 for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
121 let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
122 if entry.path().extension().is_some_and(|e| e == "json") {
123 if let Ok(json) = fs::read_to_string(entry.path()) {
124 if let Ok(issue) = serde_json::from_str::<Issue>(&json) {
125 if state.as_ref().is_none_or(|s| issue.state == *s) {
126 issues.push(issue);
127 }
128 }
129 }
130 }
131 }
132
133 issues.sort_by_key(|b| std::cmp::Reverse(b.id));
134 Ok(issues)
135}
136
137pub fn close_issue(repo_root: &Path, id: u64) -> Result<Issue, LitError> {
139 let mut issue = get_issue(repo_root, id)?;
140 issue.state = IssueState::Closed;
141 issue.updated = chrono::Utc::now().to_rfc3339();
142 save_issue(repo_root, &issue)?;
143 Ok(issue)
144}
145
146pub fn comment_issue(
148 repo_root: &Path,
149 id: u64,
150 author: &str,
151 body: &str,
152) -> Result<Issue, LitError> {
153 let mut issue = get_issue(repo_root, id)?;
154 issue.comments.push(IssueComment {
155 author: author.to_string(),
156 body: body.to_string(),
157 created: chrono::Utc::now().to_rfc3339(),
158 });
159 issue.updated = chrono::Utc::now().to_rfc3339();
160 save_issue(repo_root, &issue)?;
161 Ok(issue)
162}
163
164fn save_issue(repo_root: &Path, issue: &Issue) -> Result<(), LitError> {
165 let path = issues_dir(repo_root).join(format!("{}.json", issue.id));
166 let json = serde_json::to_string_pretty(issue)
167 .map_err(|e| LitError::general(format!("Serialize: {}", e)))?;
168 fs::write(&path, json).map_err(|e| LitError::io(format!("Write: {}", e)))?;
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::path::PathBuf;
176 use std::sync::atomic::{AtomicU32, Ordering};
177
178 static COUNTER: AtomicU32 = AtomicU32::new(0);
179
180 fn tmp_dir() -> PathBuf {
181 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
182 let dir = std::env::temp_dir().join(format!("lit_issue_test_{}_{}", std::process::id(), n));
183 fs::create_dir_all(&dir).unwrap();
184 dir
185 }
186
187 #[test]
188 fn test_create_and_list_issues() {
189 let dir = tmp_dir();
190 let issue = create_issue(
191 &dir,
192 "Bug: crash on merge",
193 "Merging fails with panic",
194 "did:lit:user1",
195 vec!["bug".into()],
196 )
197 .unwrap();
198 assert_eq!(issue.id, 1);
199 assert_eq!(issue.state, IssueState::Open);
200
201 let issues = list_issues(&dir, None).unwrap();
202 assert_eq!(issues.len(), 1);
203
204 let _ = fs::remove_dir_all(&dir);
205 }
206
207 #[test]
208 fn test_close_issue() {
209 let dir = tmp_dir();
210 create_issue(&dir, "Test", "Body", "user1", vec![]).unwrap();
211 let closed = close_issue(&dir, 1).unwrap();
212 assert_eq!(closed.state, IssueState::Closed);
213
214 let open = list_issues(&dir, Some(IssueState::Open)).unwrap();
215 assert_eq!(open.len(), 0);
216
217 let _ = fs::remove_dir_all(&dir);
218 }
219
220 #[test]
221 fn test_comment_issue() {
222 let dir = tmp_dir();
223 create_issue(&dir, "Test", "Body", "user1", vec![]).unwrap();
224 let issue = comment_issue(&dir, 1, "user2", "This needs fixing").unwrap();
225 assert_eq!(issue.comments.len(), 1);
226 assert_eq!(issue.comments[0].author, "user2");
227
228 let _ = fs::remove_dir_all(&dir);
229 }
230}