Skip to main content

gn_core/
note.rs

1use crate::namespace::Namespace;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub enum NoteStatus {
8    Open,
9    Resolved,
10    Approved,
11    Rejected,
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Note {
16    pub id: Uuid,             // content-addressed UUID v4
17    pub commit: String,       // git commit SHA this note is anchored to
18    pub file: Option<String>, // relative file path
19    pub line_start: Option<u32>,
20    pub line_end: Option<u32>,
21    pub body: String,   // markdown body
22    pub author: String, // "Name <email>"
23    pub timestamp: DateTime<Utc>,
24    pub namespace: Namespace,
25    pub thread_id: Option<Uuid>, // for replies — parent note id
26    pub status: NoteStatus,
27    pub tags: Vec<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub signature: Option<String>,
30}
31
32impl Note {
33    #[allow(clippy::too_many_arguments)]
34    pub fn new(
35        commit: String,
36        file: Option<String>,
37        line_start: Option<u32>,
38        line_end: Option<u32>,
39        body: String,
40        author: String,
41        namespace: Namespace,
42    ) -> Self {
43        Self {
44            id: Uuid::new_v4(),
45            commit,
46            file,
47            line_start,
48            line_end,
49            body,
50            author,
51            timestamp: Utc::now(),
52            namespace,
53            thread_id: None,
54            status: NoteStatus::Open,
55            tags: Vec::new(),
56            signature: None,
57        }
58    }
59
60    pub fn reply(parent: &Note, body: String, author: String) -> Self {
61        Self {
62            id: Uuid::new_v4(),
63            commit: parent.commit.clone(),
64            file: parent.file.clone(),
65            line_start: parent.line_start,
66            line_end: parent.line_end,
67            body,
68            author,
69            timestamp: Utc::now(),
70            namespace: parent.namespace.clone(),
71            thread_id: Some(parent.id),
72            status: NoteStatus::Open,
73            tags: Vec::new(),
74            signature: None,
75        }
76    }
77
78    /// Canonical payload string to sign and verify
79    pub fn signing_payload(&self) -> String {
80        format!(
81            "id: {}\ncommit: {}\nfile: {}\nline_start: {}\nbody: {}\nauthor: {}\ntimestamp: {}\n",
82            self.id,
83            self.commit,
84            self.file.as_deref().unwrap_or(""),
85            self.line_start.map(|l| l.to_string()).unwrap_or_default(),
86            self.body,
87            self.author,
88            self.timestamp.to_rfc3339()
89        )
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_note_new_with_all_fields() {
99        let commit = "a1b2c3d4e5f6".to_string();
100        let file = Some("src/main.rs".to_string());
101        let line_start = Some(10);
102        let line_end = Some(20);
103        let body = "This is a test note body.".to_string();
104        let author = "Tester <test@example.com>".to_string();
105        let namespace = Namespace::Comments;
106
107        let note = Note::new(
108            commit.clone(),
109            file.clone(),
110            line_start,
111            line_end,
112            body.clone(),
113            author.clone(),
114            namespace.clone(),
115        );
116
117        assert!(!note.id.is_nil());
118        assert_eq!(note.commit, commit);
119        assert_eq!(note.file, file);
120        assert_eq!(note.line_start, line_start);
121        assert_eq!(note.line_end, line_end);
122        assert_eq!(note.body, body);
123        assert_eq!(note.author, author);
124        assert_eq!(note.namespace, namespace);
125        assert_eq!(note.thread_id, None);
126        assert_eq!(note.status, NoteStatus::Open);
127        assert!(note.tags.is_empty());
128    }
129
130    #[test]
131    fn test_note_new_with_optional_fields_none() {
132        let commit = "a1b2c3d4e5f6".to_string();
133        let body = "Note without file or lines.".to_string();
134        let author = "Tester <test@example.com>".to_string();
135        let namespace = Namespace::Comments;
136
137        let note = Note::new(
138            commit.clone(),
139            None,
140            None,
141            None,
142            body.clone(),
143            author.clone(),
144            namespace.clone(),
145        );
146
147        assert!(!note.id.is_nil());
148        assert_eq!(note.file, None);
149        assert_eq!(note.line_start, None);
150        assert_eq!(note.line_end, None);
151        assert_eq!(note.status, NoteStatus::Open);
152    }
153
154    #[test]
155    fn test_note_reply() {
156        let parent = Note::new(
157            "a1b2c3d4e5f6".to_string(),
158            Some("src/lib.rs".to_string()),
159            Some(1),
160            Some(5),
161            "Parent note".to_string(),
162            "Parent Author <parent@example.com>".to_string(),
163            Namespace::Comments,
164        );
165
166        let reply_body = "This is a reply".to_string();
167        let reply_author = "Replier <replier@example.com>".to_string();
168
169        let reply = Note::reply(&parent, reply_body.clone(), reply_author.clone());
170
171        assert_ne!(reply.id, parent.id);
172        assert!(!reply.id.is_nil());
173        assert_eq!(reply.commit, parent.commit);
174        assert_eq!(reply.file, parent.file);
175        assert_eq!(reply.line_start, parent.line_start);
176        assert_eq!(reply.line_end, parent.line_end);
177        assert_eq!(reply.namespace, parent.namespace);
178        assert_eq!(reply.thread_id, Some(parent.id));
179        assert_eq!(reply.body, reply_body);
180        assert_eq!(reply.author, reply_author);
181        assert_eq!(reply.status, NoteStatus::Open);
182        assert!(reply.tags.is_empty());
183        assert_eq!(reply.signature, None);
184    }
185
186    #[test]
187    fn test_signing_payload_and_serde() {
188        let note = Note::new(
189            "a1b2c3d4e5f6".to_string(),
190            Some("src/lib.rs".to_string()),
191            Some(1),
192            Some(5),
193            "Parent note".to_string(),
194            "Parent Author <parent@example.com>".to_string(),
195            Namespace::Comments,
196        );
197
198        let payload = note.signing_payload();
199        assert!(payload.contains(&format!("id: {}", note.id)));
200        assert!(payload.contains("commit: a1b2c3d4e5f6"));
201        assert!(payload.contains("file: src/lib.rs"));
202        assert!(payload.contains("body: Parent note"));
203
204        // Check serde backward compatibility (JSON without signature deserializes with None)
205        let json = serde_json::to_string(&note).unwrap();
206        assert!(!json.contains("signature"));
207
208        let mut deserialized: Note = serde_json::from_str(&json).unwrap();
209        assert_eq!(deserialized.signature, None);
210
211        // Deserializing with signature
212        deserialized.signature = Some("-----BEGIN SSH SIGNATURE-----\ntest\n-----END SSH SIGNATURE-----".to_string());
213        let signed_json = serde_json::to_string(&deserialized).unwrap();
214        assert!(signed_json.contains("signature"));
215        let from_signed: Note = serde_json::from_str(&signed_json).unwrap();
216        assert_eq!(from_signed.signature, deserialized.signature);
217    }
218}