1use crate::error::{GnError, Result};
2use crate::namespace::Namespace;
3use crate::note::Note;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use uuid::Uuid;
8
9pub struct NotesEngine {
10 pub repo_path: PathBuf,
11}
12
13impl NotesEngine {
14 pub fn new<P: AsRef<Path>>(repo_path: P) -> Self {
15 Self {
16 repo_path: repo_path.as_ref().to_path_buf(),
17 }
18 }
19
20 fn git_cmd(&self) -> Command {
21 let mut cmd = Command::new("git");
22 cmd.current_dir(&self.repo_path);
23 cmd
24 }
25
26 pub fn read_notes(&self, namespace: &Namespace) -> Result<Vec<Note>> {
27 let ref_path = namespace.ref_path();
28
29 let output = self.git_cmd().args(["ls-tree", "-r", &ref_path]).output()?;
30
31 if !output.status.success() {
32 return Ok(Vec::new());
34 }
35
36 let out_str = String::from_utf8(output.stdout)?;
37 let mut blob_hashes = Vec::new();
38
39 for line in out_str.lines() {
40 let parts: Vec<&str> = line.split_whitespace().collect();
41 if parts.len() >= 4 {
42 blob_hashes.push(parts[2].to_string());
43 }
44 }
45
46 if blob_hashes.is_empty() {
47 return Ok(Vec::new());
48 }
49
50 let mut cat_file_cmd = self.git_cmd();
51 cat_file_cmd
52 .args(["cat-file", "--batch"])
53 .stdin(Stdio::piped())
54 .stdout(Stdio::piped());
55
56 let mut child = cat_file_cmd.spawn()?;
57 if let Some(mut stdin) = child.stdin.take() {
58 std::thread::spawn(move || {
59 for hash in blob_hashes {
60 if writeln!(stdin, "{}", hash).is_err() {
61 break;
62 }
63 }
64 });
65 }
66
67 let cat_file_output = child.wait_with_output()?;
68 if !cat_file_output.status.success() {
69 return Ok(Vec::new());
70 }
71
72 let mut notes = Vec::new();
73 let stdout = cat_file_output.stdout;
74 let mut cursor = 0;
75
76 while cursor < stdout.len() {
77 let relative_newline = match stdout[cursor..].iter().position(|&b| b == b'\n') {
79 Some(pos) => pos,
80 None => break,
81 };
82
83 let header_line = match std::str::from_utf8(&stdout[cursor..cursor + relative_newline])
84 {
85 Ok(s) => s.trim(),
86 Err(_) => break,
87 };
88
89 cursor += relative_newline + 1;
90
91 let parts: Vec<&str> = header_line.split_whitespace().collect();
92 if parts.len() < 3 {
93 continue;
95 }
96
97 let size: usize = match parts[2].parse() {
98 Ok(s) => s,
99 Err(_) => break,
100 };
101
102 if cursor + size > stdout.len() {
103 break;
104 }
105
106 let blob_data = &stdout[cursor..cursor + size];
107 cursor += size;
108
109 if cursor < stdout.len() && stdout[cursor] == b'\n' {
111 cursor += 1;
112 }
113
114 if let Ok(blob_str) = std::str::from_utf8(blob_data) {
115 if let Ok(note) = serde_json::from_str::<Note>(blob_str) {
116 notes.push(note);
117 }
118 }
119 }
120
121 Ok(notes)
122 }
123
124 pub fn write_note(&self, note: &Note) -> Result<String> {
125 let note_json = serde_json::to_string(note)?;
126
127 let mut hash_cmd = self.git_cmd();
129 hash_cmd
130 .args(["hash-object", "-w", "--stdin"])
131 .stdin(Stdio::piped())
132 .stdout(Stdio::piped());
133
134 let mut child = hash_cmd.spawn()?;
135 if let Some(mut stdin) = child.stdin.take() {
136 stdin.write_all(note_json.as_bytes())?;
137 }
138 let hash_output = child.wait_with_output()?;
139 let blob_hash = String::from_utf8(hash_output.stdout)?.trim().to_string();
140
141 let ref_path = note.namespace.ref_path();
142
143 let tree_cmd = self.git_cmd().args(["ls-tree", &ref_path]).output()?;
145
146 let mut tree_entries = String::new();
147 if tree_cmd.status.success() {
148 tree_entries = String::from_utf8(tree_cmd.stdout)?;
149 }
150
151 let new_entry = format!("100644 blob {}\t{}\n", blob_hash, note.id);
153
154 let mut new_tree_input = tree_entries
156 .lines()
157 .filter(|line| !line.ends_with(¬e.id.to_string()))
158 .map(|line| format!("{}\n", line))
159 .collect::<String>();
160
161 new_tree_input.push_str(&new_entry);
162
163 let mut mktree_cmd = self.git_cmd();
165 mktree_cmd
166 .arg("mktree")
167 .stdin(Stdio::piped())
168 .stdout(Stdio::piped());
169
170 let mut child = mktree_cmd.spawn()?;
171 if let Some(mut stdin) = child.stdin.take() {
172 stdin.write_all(new_tree_input.as_bytes())?;
173 }
174 let mktree_output = child.wait_with_output()?;
175 let new_tree_hash = String::from_utf8(mktree_output.stdout)?.trim().to_string();
176
177 let mut commit_cmd = self.git_cmd();
179 commit_cmd.args([
180 "commit-tree",
181 &new_tree_hash,
182 "-m",
183 &format!("Update note {}", note.id),
184 ]);
185
186 let rev_parse = self
188 .git_cmd()
189 .args(["rev-parse", "-q", "--verify", &ref_path])
190 .output()?;
191 if rev_parse.status.success() {
192 let parent_hash = String::from_utf8(rev_parse.stdout)?.trim().to_string();
193 commit_cmd.args(["-p", &parent_hash]);
194 }
195
196 let commit_output = commit_cmd.output()?;
197 let commit_hash = String::from_utf8(commit_output.stdout)?.trim().to_string();
198
199 self.git_cmd()
201 .args(["update-ref", &ref_path, &commit_hash])
202 .output()?;
203
204 Ok(commit_hash)
205 }
206
207 pub fn delete_note(&self, note_id: Uuid, namespace: &Namespace) -> Result<()> {
208 let ref_path = namespace.ref_path();
209
210 let tree_cmd = self.git_cmd().args(["ls-tree", &ref_path]).output()?;
211 if !tree_cmd.status.success() {
212 return Err(GnError::NotFound(format!(
213 "Namespace {} not found",
214 ref_path
215 )));
216 }
217
218 let tree_entries = String::from_utf8(tree_cmd.stdout)?;
219 let mut new_tree_input = String::new();
220 let mut found = false;
221
222 for line in tree_entries.lines() {
223 if line.ends_with(¬e_id.to_string()) {
224 found = true;
225 } else {
226 new_tree_input.push_str(line);
227 new_tree_input.push('\n');
228 }
229 }
230
231 if !found {
232 return Err(GnError::NotFound(format!("Note {} not found", note_id)));
233 }
234
235 let mut mktree_cmd = self.git_cmd();
236 mktree_cmd
237 .arg("mktree")
238 .stdin(Stdio::piped())
239 .stdout(Stdio::piped());
240 let mut child = mktree_cmd.spawn()?;
241 if let Some(mut stdin) = child.stdin.take() {
242 stdin.write_all(new_tree_input.as_bytes())?;
243 }
244 let mktree_output = child.wait_with_output()?;
245 let new_tree_hash = String::from_utf8(mktree_output.stdout)?.trim().to_string();
246
247 let mut commit_cmd = self.git_cmd();
248 commit_cmd.args([
249 "commit-tree",
250 &new_tree_hash,
251 "-m",
252 &format!("Delete note {}", note_id),
253 ]);
254
255 let rev_parse = self.git_cmd().args(["rev-parse", &ref_path]).output()?;
256 if rev_parse.status.success() {
257 let parent_hash = String::from_utf8(rev_parse.stdout)?.trim().to_string();
258 commit_cmd.args(["-p", &parent_hash]);
259 }
260
261 let commit_output = commit_cmd.output()?;
262 let commit_hash = String::from_utf8(commit_output.stdout)?.trim().to_string();
263
264 self.git_cmd()
265 .args(["update-ref", &ref_path, &commit_hash])
266 .output()?;
267
268 Ok(())
269 }
270
271 pub fn list_notes_for_commit(&self, commit: &str, namespace: &Namespace) -> Result<Vec<Note>> {
272 let all_notes = self.read_notes(namespace)?;
273 let filtered = all_notes
274 .into_iter()
275 .filter(|n| n.commit == commit)
276 .collect();
277 Ok(filtered)
278 }
279}