1use crate::Oid;
2use anyhow::{Context, Result, anyhow};
3use std::{str::FromStr, sync::Arc};
4
5#[derive(Clone, Debug, Eq, Hash, PartialEq)]
6pub struct StashEntry {
7 pub index: usize,
8 pub oid: Oid,
9 pub message: String,
10 pub branch: Option<String>,
11 pub timestamp: i64,
12}
13
14#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
15pub struct GitStash {
16 pub entries: Arc<[StashEntry]>,
17}
18
19impl GitStash {
20 pub fn apply(&mut self, other: GitStash) {
21 self.entries = other.entries;
22 }
23}
24
25impl FromStr for GitStash {
26 type Err = anyhow::Error;
27
28 fn from_str(s: &str) -> Result<Self> {
29 if s.trim().is_empty() {
30 return Ok(Self::default());
31 }
32
33 let mut entries = Vec::new();
34 let mut errors = Vec::new();
35
36 for (line_num, line) in s.lines().enumerate() {
37 if line.trim().is_empty() {
38 continue;
39 }
40
41 match parse_stash_line(line) {
42 Ok(entry) => entries.push(entry),
43 Err(e) => {
44 errors.push(format!("Line {}: {}", line_num + 1, e));
45 }
46 }
47 }
48
49 // If we have some valid entries but also some errors, log the errors but continue
50 if !errors.is_empty() && !entries.is_empty() {
51 log::warn!("Failed to parse some stash entries: {}", errors.join(", "));
52 } else if !errors.is_empty() {
53 return Err(anyhow!(
54 "Failed to parse stash entries: {}",
55 errors.join(", ")
56 ));
57 }
58
59 Ok(Self {
60 entries: entries.into(),
61 })
62 }
63}
64
65/// Parse a single stash line in the format: "stash@{N}\0<oid>\0<timestamp>\0<message>"
66fn parse_stash_line(line: &str) -> Result<StashEntry> {
67 let parts: Vec<&str> = line.splitn(4, '\0').collect();
68
69 if parts.len() != 4 {
70 return Err(anyhow!(
71 "Expected 4 null-separated parts, got {}",
72 parts.len()
73 ));
74 }
75
76 let index = parse_stash_index(parts[0])
77 .with_context(|| format!("Failed to parse stash index from '{}'", parts[0]))?;
78
79 let oid = Oid::from_str(parts[1])
80 .with_context(|| format!("Failed to parse OID from '{}'", parts[1]))?;
81
82 let timestamp = parts[2]
83 .parse::<i64>()
84 .with_context(|| format!("Failed to parse timestamp from '{}'", parts[2]))?;
85
86 let (branch, message) = parse_stash_message(parts[3]);
87
88 Ok(StashEntry {
89 index,
90 oid,
91 message: message.to_string(),
92 branch: branch.map(Into::into),
93 timestamp,
94 })
95}
96
97/// Parse stash index from format "stash@{N}" where N is the index
98fn parse_stash_index(input: &str) -> Result<usize> {
99 let trimmed = input.trim();
100
101 if !trimmed.starts_with("stash@{") || !trimmed.ends_with('}') {
102 return Err(anyhow!(
103 "Invalid stash index format: expected 'stash@{{N}}'"
104 ));
105 }
106
107 let index_str = trimmed
108 .strip_prefix("stash@{")
109 .and_then(|s| s.strip_suffix('}'))
110 .ok_or_else(|| anyhow!("Failed to extract index from stash reference"))?;
111
112 index_str
113 .parse::<usize>()
114 .with_context(|| format!("Invalid stash index number: '{}'", index_str))
115}
116
117/// Parse stash message and extract branch information if present
118///
119/// Handles the following formats:
120/// - "WIP on <branch>: <message>" -> (Some(branch), message)
121/// - "On <branch>: <message>" -> (Some(branch), message)
122/// - "<message>" -> (None, message)
123fn parse_stash_message(input: &str) -> (Option<&str>, &str) {
124 // Handle "WIP on <branch>: <message>" pattern
125 if let Some(stripped) = input.strip_prefix("WIP on ")
126 && let Some(colon_pos) = stripped.find(": ")
127 {
128 let branch = &stripped[..colon_pos];
129 let message = &stripped[colon_pos + 2..];
130 if !branch.is_empty() && !message.is_empty() {
131 return (Some(branch), message);
132 }
133 }
134
135 // Handle "On <branch>: <message>" pattern
136 if let Some(stripped) = input.strip_prefix("On ")
137 && let Some(colon_pos) = stripped.find(": ")
138 {
139 let branch = &stripped[..colon_pos];
140 let message = &stripped[colon_pos + 2..];
141 if !branch.is_empty() && !message.is_empty() {
142 return (Some(branch), message);
143 }
144 }
145
146 // Fallback: treat entire input as message with no branch
147 (None, input)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn test_parse_stash_index() {
156 assert_eq!(parse_stash_index("stash@{0}").unwrap(), 0);
157 assert_eq!(parse_stash_index("stash@{42}").unwrap(), 42);
158 assert_eq!(parse_stash_index(" stash@{5} ").unwrap(), 5);
159
160 assert!(parse_stash_index("invalid").is_err());
161 assert!(parse_stash_index("stash@{not_a_number}").is_err());
162 assert!(parse_stash_index("stash@{0").is_err());
163 }
164
165 #[test]
166 fn test_parse_stash_message() {
167 // WIP format
168 let (branch, message) = parse_stash_message("WIP on main: working on feature");
169 assert_eq!(branch, Some("main"));
170 assert_eq!(message, "working on feature");
171
172 // On format
173 let (branch, message) = parse_stash_message("On feature-branch: some changes");
174 assert_eq!(branch, Some("feature-branch"));
175 assert_eq!(message, "some changes");
176
177 // No branch format
178 let (branch, message) = parse_stash_message("just a regular message");
179 assert_eq!(branch, None);
180 assert_eq!(message, "just a regular message");
181
182 // Edge cases
183 let (branch, message) = parse_stash_message("WIP on : empty message");
184 assert_eq!(branch, None);
185 assert_eq!(message, "WIP on : empty message");
186
187 let (branch, message) = parse_stash_message("On branch-name:");
188 assert_eq!(branch, None);
189 assert_eq!(message, "On branch-name:");
190 }
191
192 #[test]
193 fn test_parse_stash_line() {
194 let line = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: test commit";
195 let entry = parse_stash_line(line).unwrap();
196
197 assert_eq!(entry.index, 0);
198 assert_eq!(entry.message, "test commit");
199 assert_eq!(entry.branch, Some("main".to_string()));
200 assert_eq!(entry.timestamp, 1234567890);
201 }
202
203 #[test]
204 fn test_git_stash_from_str() {
205 let input = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: first stash\nstash@{1}\u{0000}def456\u{0000}1234567891\u{0000}On feature: second stash";
206 let stash = GitStash::from_str(input).unwrap();
207
208 assert_eq!(stash.entries.len(), 2);
209 assert_eq!(stash.entries[0].index, 0);
210 assert_eq!(stash.entries[0].branch, Some("main".to_string()));
211 assert_eq!(stash.entries[1].index, 1);
212 assert_eq!(stash.entries[1].branch, Some("feature".to_string()));
213 }
214
215 #[test]
216 fn test_git_stash_empty_input() {
217 let stash = GitStash::from_str("").unwrap();
218 assert_eq!(stash.entries.len(), 0);
219
220 let stash = GitStash::from_str(" \n \n ").unwrap();
221 assert_eq!(stash.entries.len(), 0);
222 }
223}