git_adr/core/
notes.rs

1//! Git notes management for ADRs.
2//!
3//! This module provides the `NotesManager` which handles CRUD operations
4//! for ADRs stored in git notes.
5
6use crate::core::{Adr, AdrConfig, Git};
7use crate::Error;
8
9/// Notes reference for ADR content.
10pub const ADR_NOTES_REF: &str = "adr";
11/// Notes reference for artifacts.
12pub const ARTIFACTS_NOTES_REF: &str = "adr-artifacts";
13
14/// Manager for ADR operations in git notes.
15#[derive(Debug)]
16pub struct NotesManager {
17    git: Git,
18    config: AdrConfig,
19}
20
21impl NotesManager {
22    /// Create a new `NotesManager`.
23    #[must_use]
24    pub const fn new(git: Git, config: AdrConfig) -> Self {
25        Self { git, config }
26    }
27
28    /// Get a reference to the Git wrapper.
29    #[must_use]
30    pub const fn git(&self) -> &Git {
31        &self.git
32    }
33
34    /// Get a reference to the configuration.
35    #[must_use]
36    pub const fn config(&self) -> &AdrConfig {
37        &self.config
38    }
39
40    /// List all ADRs.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if ADRs cannot be listed.
45    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                // Extract ADR ID from the content or generate from commit
52                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; // Used for future artifact lookup
58        }
59
60        // Sort by ID
61        adrs.sort_by(|a, b| a.id.cmp(&b.id));
62
63        Ok(adrs)
64    }
65
66    /// Get an ADR by ID.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the ADR is not found or cannot be read.
71    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    /// Get an ADR by commit hash.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the ADR is not found.
83    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    /// Create a new ADR.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the ADR cannot be created.
100    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    /// Update an existing ADR.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the ADR cannot be updated.
118    pub fn update(&self, adr: &Adr) -> Result<(), Error> {
119        // Verify ADR exists
120        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    /// Delete an ADR.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the ADR cannot be deleted.
133    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    /// Get the next available ADR number.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if ADRs cannot be listed.
144    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    /// Generate an ADR ID for the given number.
160    #[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    /// Extract ADR ID from content or generate from commit.
171    fn extract_id(&self, content: &str, commit: &str) -> Result<String, Error> {
172        // Try to parse frontmatter and get ID field
173        if let Some(id) = Self::extract_id_from_frontmatter(content) {
174            return Ok(id);
175        }
176
177        // Fall back to generating from commit short hash
178        let short = self.git.short_hash(commit)?;
179        Ok(format!("{}{}", self.config.prefix, short))
180    }
181
182    /// Extract ID from YAML frontmatter if present.
183    fn extract_id_from_frontmatter(content: &str) -> Option<String> {
184        /// Helper struct for extracting just the ID from frontmatter.
185        #[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    /// Sync notes with remote.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if sync fails.
209    pub fn sync(&self, remote: &str, push: bool, fetch: bool) -> Result<(), Error> {
210        if fetch {
211            // Fetch notes (ignore errors if ref doesn't exist on remote)
212            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            // Only push artifacts if they exist
219            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        // Just verify we can access the git reference
346        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        // Create ADR with empty commit - should use HEAD
369        let mut adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
370        adr.commit = String::new(); // Empty commit
371
372        let result = manager.create(&adr);
373        assert!(result.is_ok());
374
375        // Verify it was created
376        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        // Get HEAD commit
389        let head = git.head().expect("Should get HEAD");
390
391        // Create ADR on this commit
392        let adr = Adr::new("ADR-0001".to_string(), "Test Decision".to_string());
393        manager.create(&adr).expect("Should create ADR");
394
395        // Get by commit
396        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        // Get HEAD commit
411        let head = git.head().expect("Should get HEAD");
412
413        // Try to get by commit without creating an ADR
414        let result = manager.get_by_commit(&head);
415        assert!(result.is_err());
416    }
417}