1use std::str::FromStr;
4
5use nvim_oxi::api::Buffer;
6use rootcause::prelude::ResultExt;
7use rootcause::report;
8
9#[derive(Debug)]
11#[cfg_attr(test, derive(Eq, PartialEq))]
12pub struct MruBuffer {
13 pub id: i32,
15 pub is_unlisted: bool,
17 pub name: String,
19 pub kind: BufferKind,
21}
22
23impl MruBuffer {
24 pub const fn is_term(&self) -> bool {
25 match self.kind {
26 BufferKind::Term => true,
27 BufferKind::GrugFar | BufferKind::Path | BufferKind::NoName => false,
28 }
29 }
30}
31
32impl From<&MruBuffer> for Buffer {
33 fn from(value: &MruBuffer) -> Self {
34 Self::from(value.id)
35 }
36}
37
38#[derive(Debug)]
40#[cfg_attr(test, derive(Eq, PartialEq))]
41pub enum BufferKind {
42 Term,
44 GrugFar,
46 Path,
48 NoName,
50}
51
52impl<T: AsRef<str>> From<T> for BufferKind {
53 fn from(value: T) -> Self {
54 let str = value.as_ref();
55 if str.starts_with("term://") {
56 Self::Term
57 } else if str.starts_with("Grug FAR") {
58 Self::GrugFar
59 } else if str.starts_with("[No Name]") {
60 Self::NoName
61 } else {
62 Self::Path
63 }
64 }
65}
66
67impl FromStr for MruBuffer {
74 type Err = rootcause::Report;
75
76 fn from_str(mru_buffer_line: &str) -> Result<Self, Self::Err> {
77 let mru_buffer_line = mru_buffer_line.trim();
78
79 let is_unlisted_idx = mru_buffer_line
80 .char_indices()
81 .find_map(|(idx, c)| if c.is_numeric() { None } else { Some(idx) })
82 .ok_or_else(|| report!("error finding buffer id end"))
83 .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
84
85 let id: i32 = {
86 let id = mru_buffer_line
87 .get(..is_unlisted_idx)
88 .ok_or_else(|| report!("error extracting buffer id"))
89 .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
90 id.parse()
91 .context("error parsing buffer id")
92 .attach_with(|| format!("id={id:?} mru_buffer_line={mru_buffer_line:?}"))?
93 };
94
95 let is_unlisted = mru_buffer_line
96 .get(is_unlisted_idx..=is_unlisted_idx)
97 .ok_or_else(|| report!("error extracting is_unlisted by idx"))
98 .attach_with(|| format!("idx={is_unlisted_idx} mru_buffer_line={mru_buffer_line:?}"))?
99 == "u";
100
101 let name_idx = mru_buffer_line
105 .get(is_unlisted_idx..)
106 .and_then(|s| s.find('"').map(|i| is_unlisted_idx.saturating_add(i).saturating_add(1)))
107 .ok_or_else(|| report!("error finding opening quote"))
108 .attach_with(|| format!("mru_buffer_line={mru_buffer_line:?}"))?;
109
110 let rest = mru_buffer_line
111 .get(name_idx..)
112 .ok_or_else(|| report!("error extracting name part by idx"))
113 .attach_with(|| format!("idx={name_idx} mru_buffer_line={mru_buffer_line:?}"))?;
114
115 let (name, _) = rest
116 .split_once('"')
117 .ok_or_else(|| report!("error extracting name"))
118 .attach_with(|| format!("rest={rest:?} mru_buffer_line={mru_buffer_line:?}"))?;
119
120 Ok(Self {
121 id,
122 is_unlisted,
123 name: name.to_string(),
124 kind: BufferKind::from(name),
125 })
126 }
127}
128
129pub fn get() -> Option<Vec<MruBuffer>> {
135 let Ok(mru_buffers_output) = nvim_oxi::api::call_function::<_, String>("execute", ("ls t",))
136 .inspect_err(|err| crate::notify::error(format!("error getting mru buffers | error={err:?}")))
137 else {
138 return None;
139 };
140
141 parse_mru_buffers_output(&mru_buffers_output)
142 .inspect_err(|err| {
143 crate::notify::error(format!(
144 "error parsing mru buffers output | mru_buffers_output={mru_buffers_output:?} error={err:?}"
145 ));
146 })
147 .ok()
148}
149
150fn parse_mru_buffers_output(mru_buffers_output: &str) -> rootcause::Result<Vec<MruBuffer>> {
155 if mru_buffers_output.is_empty() {
156 return Ok(vec![]);
157 }
158 let mut out = vec![];
159 for mru_buffer_line in mru_buffers_output.lines() {
160 if mru_buffer_line.is_empty() {
161 continue;
162 }
163 out.push(MruBuffer::from_str(mru_buffer_line)?);
164 }
165 Ok(out)
166}
167
168#[cfg(test)]
169mod tests {
170 use rstest::rstest;
171 use test_that::prelude::*;
172
173 use super::*;
174
175 #[rstest]
178 #[case(
179 "1u%a \"file.txt\"",
180 MruBuffer {
181 id: 1,
182 is_unlisted: true,
183 name: "file.txt".to_string(),
184 kind: BufferKind::Path,
185 }
186 )]
187 #[case(
188 "2 %a \"another.txt\"",
189 MruBuffer {
190 id: 2,
191 is_unlisted: false,
192 name: "another.txt".to_string(),
193 kind: BufferKind::Path,
194 }
195 )]
196 #[case(
197 "3 %a \"[No Name]\"",
198 MruBuffer {
199 id: 3,
200 is_unlisted: false,
201 name: "[No Name]".to_string(),
202 kind: BufferKind::NoName,
203 }
204 )]
205 #[case(
206 "4u a \"term://bash\"",
207 MruBuffer {
208 id: 4,
209 is_unlisted: true,
210 name: "term://bash".to_string(),
211 kind: BufferKind::Term,
212 }
213 )]
214 #[case(
215 "5 %a \"Grug FAR results\"",
216 MruBuffer {
217 id: 5,
218 is_unlisted: false,
219 name: "Grug FAR results".to_string(),
220 kind: BufferKind::GrugFar,
221 }
222 )]
223 #[case(
224 " 6 %a \"trimmed.txt\" ",
225 MruBuffer {
226 id: 6,
227 is_unlisted: false,
228 name: "trimmed.txt".to_string(),
229 kind: BufferKind::Path,
230 }
231 )]
232 #[case(
233 "10 #h \"multi_digit.txt\"",
234 MruBuffer {
235 id: 10,
236 is_unlisted: false,
237 name: "multi_digit.txt".to_string(),
238 kind: BufferKind::Path,
239 }
240 )]
241 #[case(
242 "7u aR \"term://~//12345:/bin/zsh\"",
243 MruBuffer {
244 id: 7,
245 is_unlisted: true,
246 name: "term://~//12345:/bin/zsh".to_string(),
247 kind: BufferKind::Term,
248 }
249 )]
250 fn from_str_when_valid_input_returns_mru_buffer(#[case] input: &str, #[case] expected: MruBuffer) {
251 assert_that!(MruBuffer::from_str(input), ok(eq(expected)));
252 }
253
254 #[rstest]
255 #[case("", "error finding buffer id end")]
256 #[case(" %a \"file.txt\"", "error parsing buffer id")]
257 #[case("au %a \"file.txt\"", "error parsing buffer id")]
258 #[case("1u%a \"file.txt", "error extracting name")]
259 #[case("1u%a file.txt", "error finding opening quote")]
260 fn test_from_str_when_invalid_input_returns_error(#[case] input: &str, #[case] expected_err_substr: &str) {
261 let result = MruBuffer::from_str(input);
262 assert_that!(
263 (result).map(|_| ()),
264 err(displays_as(contains_substring(expected_err_substr)))
265 );
266 }
267}