1use crate::core::{Adr, AdrConfig, Git};
7use crate::Error;
8
9pub const ADR_NOTES_REF: &str = "adr";
11pub const ARTIFACTS_NOTES_REF: &str = "adr-artifacts";
13
14#[derive(Debug)]
16pub struct NotesManager {
17 git: Git,
18 config: AdrConfig,
19}
20
21impl NotesManager {
22 #[must_use]
24 pub const fn new(git: Git, config: AdrConfig) -> Self {
25 Self { git, config }
26 }
27
28 #[must_use]
30 pub const fn git(&self) -> &Git {
31 &self.git
32 }
33
34 #[must_use]
36 pub const fn config(&self) -> &AdrConfig {
37 &self.config
38 }
39
40 pub fn list(&self) -> Result<Vec<Adr>, Error> {
46 let notes = self.git.notes_list(ADR_NOTES_REF)?;
47 let mut adrs = Vec::new();
48
49 for (note_hash, commit) in notes {
50 if let Some(content) = self.git.notes_show(ADR_NOTES_REF, &commit)? {
51 let id = self.extract_id(&content, &commit)?;
53 if let Ok(adr) = Adr::from_markdown(id, commit.clone(), &content) {
54 adrs.push(adr);
55 }
56 }
57 let _ = note_hash; }
59
60 adrs.sort_by(|a, b| a.id.cmp(&b.id));
62
63 Ok(adrs)
64 }
65
66 pub fn get(&self, id: &str) -> Result<Adr, Error> {
72 let adrs = self.list()?;
73 adrs.into_iter()
74 .find(|adr| adr.id == id)
75 .ok_or_else(|| Error::AdrNotFound { id: id.to_string() })
76 }
77
78 pub fn get_by_commit(&self, commit: &str) -> Result<Adr, Error> {
84 let content =
85 self.git
86 .notes_show(ADR_NOTES_REF, commit)?
87 .ok_or_else(|| Error::AdrNotFound {
88 id: commit.to_string(),
89 })?;
90
91 let id = self.extract_id(&content, commit)?;
92 Adr::from_markdown(id, commit.to_string(), &content)
93 }
94
95 pub fn create(&self, adr: &Adr) -> Result<(), Error> {
101 let commit = if adr.commit.is_empty() {
102 self.git.head()?
103 } else {
104 adr.commit.clone()
105 };
106
107 let content = adr.to_markdown()?;
108 self.git.notes_add(ADR_NOTES_REF, &commit, &content)?;
109
110 Ok(())
111 }
112
113 pub fn update(&self, adr: &Adr) -> Result<(), Error> {
119 let _ = self.get(&adr.id)?;
121
122 let content = adr.to_markdown()?;
123 self.git.notes_add(ADR_NOTES_REF, &adr.commit, &content)?;
124
125 Ok(())
126 }
127
128 pub fn delete(&self, id: &str) -> Result<(), Error> {
134 let adr = self.get(id)?;
135 self.git.notes_remove(ADR_NOTES_REF, &adr.commit)?;
136 Ok(())
137 }
138
139 pub fn next_number(&self) -> Result<u32, Error> {
145 let adrs = self.list()?;
146 let max_num = adrs
147 .iter()
148 .filter_map(|adr| {
149 adr.id
150 .strip_prefix(&self.config.prefix)
151 .and_then(|s| s.parse::<u32>().ok())
152 })
153 .max()
154 .unwrap_or(0);
155
156 Ok(max_num + 1)
157 }
158
159 #[must_use]
161 pub fn format_id(&self, number: u32) -> String {
162 format!(
163 "{}{:0width$}",
164 self.config.prefix,
165 number,
166 width = self.config.digits as usize
167 )
168 }
169
170 fn extract_id(&self, content: &str, commit: &str) -> Result<String, Error> {
172 if let Some(id) = Self::extract_id_from_frontmatter(content) {
174 return Ok(id);
175 }
176
177 let short = self.git.short_hash(commit)?;
179 Ok(format!("{}{}", self.config.prefix, short))
180 }
181
182 fn extract_id_from_frontmatter(content: &str) -> Option<String> {
184 #[derive(serde::Deserialize)]
186 struct FrontmatterId {
187 id: Option<String>,
188 }
189
190 let content = content.trim();
191 if !content.starts_with("---") {
192 return None;
193 }
194
195 let rest = &content[3..];
196 let end_marker = rest.find("\n---")?;
197 let yaml_content = &rest[..end_marker];
198
199 serde_yaml::from_str::<FrontmatterId>(yaml_content)
200 .ok()
201 .and_then(|fm| fm.id)
202 }
203
204 pub fn sync(&self, remote: &str, push: bool, fetch: bool) -> Result<(), Error> {
210 if fetch {
211 let _ = self.git.notes_fetch(remote, ADR_NOTES_REF);
213 let _ = self.git.notes_fetch(remote, ARTIFACTS_NOTES_REF);
214 }
215
216 if push {
217 self.git.notes_push(remote, ADR_NOTES_REF)?;
218 let _ = self.git.notes_push(remote, ARTIFACTS_NOTES_REF);
220 }
221
222 Ok(())
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::core::AdrConfig;
230 use std::process::Command as StdCommand;
231 use tempfile::TempDir;
232
233 fn setup_git_repo() -> TempDir {
234 let temp_dir = TempDir::new().expect("Failed to create temp directory");
235 let path = temp_dir.path();
236
237 StdCommand::new("git")
238 .args(["init"])
239 .current_dir(path)
240 .output()
241 .expect("Failed to init git repo");
242
243 StdCommand::new("git")
244 .args(["config", "user.email", "test@example.com"])
245 .current_dir(path)
246 .output()
247 .expect("Failed to set git user email");
248
249 StdCommand::new("git")
250 .args(["config", "user.name", "Test User"])
251 .current_dir(path)
252 .output()
253 .expect("Failed to set git user name");
254
255 std::fs::write(path.join("README.md"), "# Test Repo\n").expect("Failed to write README");
256 StdCommand::new("git")
257 .args(["add", "."])
258 .current_dir(path)
259 .output()
260 .expect("Failed to stage files");
261 StdCommand::new("git")
262 .args(["commit", "-m", "Initial commit"])
263 .current_dir(path)
264 .output()
265 .expect("Failed to create initial commit");
266
267 temp_dir
268 }
269
270 #[test]
271 fn test_format_id() {
272 let git = Git::new();
273 let config = AdrConfig::default();
274 let manager = NotesManager::new(git, config);
275
276 assert_eq!(manager.format_id(1), "ADR-0001");
277 assert_eq!(manager.format_id(42), "ADR-0042");
278 assert_eq!(manager.format_id(9999), "ADR-9999");
279 }
280
281 #[test]
282 fn test_format_id_custom_prefix() {
283 let git = Git::new();
284 let config = AdrConfig {
285 prefix: "DECISION-".to_string(),
286 digits: 3,
287 ..Default::default()
288 };
289 let manager = NotesManager::new(git, config);
290
291 assert_eq!(manager.format_id(1), "DECISION-001");
292 assert_eq!(manager.format_id(99), "DECISION-099");
293 }
294
295 #[test]
296 fn test_extract_id_from_frontmatter_with_id() {
297 let content = r#"---
298id: ADR-0001
299title: Test
300status: proposed
301---
302
303Body content
304"#;
305 let result = NotesManager::extract_id_from_frontmatter(content);
306 assert_eq!(result, Some("ADR-0001".to_string()));
307 }
308
309 #[test]
310 fn test_extract_id_from_frontmatter_without_id() {
311 let content = r#"---
312title: Test
313status: proposed
314---
315
316Body content
317"#;
318 let result = NotesManager::extract_id_from_frontmatter(content);
319 assert_eq!(result, None);
320 }
321
322 #[test]
323 fn test_extract_id_from_frontmatter_no_frontmatter() {
324 let content = "Just some plain text without frontmatter";
325 let result = NotesManager::extract_id_from_frontmatter(content);
326 assert_eq!(result, None);
327 }
328
329 #[test]
330 fn test_extract_id_from_frontmatter_invalid_yaml() {
331 let content = r#"---
332invalid: yaml: content:
333---
334"#;
335 let result = NotesManager::extract_id_from_frontmatter(content);
336 assert_eq!(result, None);
337 }
338
339 #[test]
340 fn test_notes_manager_git_accessor() {
341 let git = Git::new();
342 let config = AdrConfig::default();
343 let manager = NotesManager::new(git, config);
344
345 let _git_ref = manager.git();
347 }
348
349 #[test]
350 fn test_notes_manager_config_accessor() {
351 let git = Git::new();
352 let config = AdrConfig {
353 prefix: "TEST-".to_string(),
354 ..Default::default()
355 };
356 let manager = NotesManager::new(git, config);
357
358 assert_eq!(manager.config().prefix, "TEST-");
359 }
360
361 #[test]
362 fn test_create_with_empty_commit() {
363 let temp_dir = setup_git_repo();
364 let git = Git::with_work_dir(temp_dir.path());
365 let config = AdrConfig::default();
366 let manager = NotesManager::new(git, config);
367
368 let mut adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
370 adr.commit = String::new(); let result = manager.create(&adr);
373 assert!(result.is_ok());
374
375 let adrs = manager.list().expect("Should list ADRs");
377 assert_eq!(adrs.len(), 1);
378 assert_eq!(adrs[0].id, "ADR-0001");
379 }
380
381 #[test]
382 fn test_get_by_commit() {
383 let temp_dir = setup_git_repo();
384 let git = Git::with_work_dir(temp_dir.path());
385 let config = AdrConfig::default();
386 let manager = NotesManager::new(git.clone(), config);
387
388 let head = git.head().expect("Should get HEAD");
390
391 let adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
393 manager.create(&adr).expect("Should create ADR");
394
395 let retrieved = manager.get_by_commit(&head);
397 assert!(retrieved.is_ok());
398 let retrieved = retrieved.unwrap();
399 assert_eq!(retrieved.id, "ADR-0001");
400 assert_eq!(retrieved.frontmatter.title, "Test Decision");
401 }
402
403 #[test]
404 fn test_get_by_commit_not_found() {
405 let temp_dir = setup_git_repo();
406 let git = Git::with_work_dir(temp_dir.path());
407 let config = AdrConfig::default();
408 let manager = NotesManager::new(git.clone(), config);
409
410 let head = git.head().expect("Should get HEAD");
412
413 let result = manager.get_by_commit(&head);
415 assert!(result.is_err());
416 }
417}