Skip to main content

gn_core/
engine.rs

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            // If the ref doesn't exist, we just have 0 notes.
33            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            // Find end of header line
78            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                // If it's missing (e.g., "<hash> missing"), no payload bytes follow
94                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            // Consume trailing newline after payload if present
110            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        // 1. Hash object
128        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        // 2. Read existing tree or create new
144        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        // Add or update the file named by UUID
152        let new_entry = format!("100644 blob {}\t{}\n", blob_hash, note.id);
153
154        // Filter out existing entry for this note id if updating
155        let mut new_tree_input = tree_entries
156            .lines()
157            .filter(|line| !line.ends_with(&note.id.to_string()))
158            .map(|line| format!("{}\n", line))
159            .collect::<String>();
160
161        new_tree_input.push_str(&new_entry);
162
163        // 3. mktree
164        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        // 4. commit-tree
178        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        // Find parent commit if ref exists
187        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        // 5. update-ref
200        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(&note_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}