1use std::{
25 ffi::OsStr,
26 fmt,
27 io::{self, BufRead, Write},
28 path::Path,
29 process,
30};
31
32#[derive(Debug, PartialEq)]
37pub struct GitStatus {
42 pub id: String,
44 pub details: Option<GitDetails>,
46}
47
48
49
50#[derive(Debug, PartialEq)]
54pub struct GitDetails {
55 pub abspath: String,
57 pub head_commit_hash: String,
60 pub local_branch: String,
63 pub upstream_branch: String,
65 pub remote_name: String,
67 pub remote_url: String,
69 pub repository_state: String,
71 pub num_files_in_index: u32,
73 pub num_staged_changes: u32,
75 pub num_unstaged_changes: u32,
77 pub num_conflicted_changes: u32,
79 pub num_untrached_files: u32,
81 pub commits_ahead: u32,
83 pub commits_behind: u32,
85 pub num_stashes: u32,
87 pub last_tag: String,
90 pub num_unstaged_deleted: u32,
92 pub num_staged_new: u32,
94 pub num_staged_deleted: u32,
96 pub push_remote_name: String,
98 pub push_remote_url: String,
100 pub commits_ahead_push_remote: u32,
102 pub commits_behind_push_remote: u32,
104 pub num_index_skip_worktree: u32,
106 pub num_index_assume_unchanged: u32,
108}
109
110#[derive(Debug, PartialEq)]
111pub enum ResponceParseError {
113 TooShort,
115 InvalidPart,
117 ParseIntError(std::num::ParseIntError),
118}
119
120impl From<std::num::ParseIntError> for ResponceParseError {
121 fn from(e: std::num::ParseIntError) -> Self {
122 Self::ParseIntError(e)
123 }
124}
125
126macro_rules! munch {
127 ($expr:expr) => {
128 match $expr.next() {
129 Some(v) => v,
130 None => return Err($crate::ResponceParseError::TooShort),
131 }
132 };
133}
134
135impl std::str::FromStr for GitStatus {
136 type Err = ResponceParseError;
137 fn from_str(s: &str) -> Result<Self, Self::Err> {
138 GitStatus::from_str(s)
139 }
140}
141
142impl GitStatus {
143 fn from_str(s: &str) -> Result<Self, ResponceParseError> {
145 let mut parts = s.split("\x1f");
146 let id = munch!(parts);
147 let is_repo = munch!(parts);
148 match is_repo {
149 "0" => {
150 return Ok(GitStatus {
151 id: id.to_owned(),
152 details: Option::None,
153 })
154 }
155 "1" => {}
157 _ => return Err(ResponceParseError::InvalidPart),
159 }
160
161 let abspath = munch!(parts);
162 let head_commit_hash = munch!(parts);
163 let local_branch = munch!(parts);
164 let upstream_branch = munch!(parts);
165 let remote_name = munch!(parts);
166 let remote_url = munch!(parts);
167 let repository_state = munch!(parts);
168
169 let num_files_in_index: u32 = munch!(parts).parse()?;
170 let num_staged_changes: u32 = munch!(parts).parse()?;
171 let num_unstaged_changes: u32 = munch!(parts).parse()?;
172 let num_conflicted_changes: u32 = munch!(parts).parse()?;
173 let num_untrached_files: u32 = munch!(parts).parse()?;
174 let commits_ahead: u32 = munch!(parts).parse()?;
175 let commits_behind: u32 = munch!(parts).parse()?;
176 let num_stashes: u32 = munch!(parts).parse()?;
177 let last_tag = munch!(parts);
178 let num_unstaged_deleted: u32 = munch!(parts).parse()?;
179 let num_staged_new: u32 = munch!(parts).parse()?;
180 let num_staged_deleted: u32 = munch!(parts).parse()?;
181 let push_remote_name = munch!(parts);
182 let push_remote_url: &str = munch!(parts);
183 let commits_ahead_push_remote: u32 = munch!(parts).parse()?;
184 let commits_behind_push_remote: u32 = munch!(parts).parse()?;
185 let num_index_skip_worktree: u32 = munch!(parts).parse()?;
186 let num_index_assume_unchanged: u32 = munch!(parts).parse()?;
187
188 let git_part = GitDetails {
190 abspath: abspath.to_owned(),
191 head_commit_hash: head_commit_hash.to_owned(),
192 local_branch: local_branch.to_owned(),
193 upstream_branch: upstream_branch.to_owned(),
194 remote_name: remote_name.to_owned(),
195 remote_url: remote_url.to_owned(),
196 repository_state: repository_state.to_owned(),
197 num_files_in_index,
198 num_staged_changes,
199 num_unstaged_changes,
200 num_conflicted_changes,
201 num_untrached_files,
202 commits_ahead,
203 commits_behind,
204 num_stashes,
205 last_tag: last_tag.to_owned(),
206 num_unstaged_deleted,
207 num_staged_new,
208 num_staged_deleted,
209 push_remote_name: push_remote_name.to_owned(),
210 push_remote_url: push_remote_url.to_owned(),
211 commits_ahead_push_remote,
212 commits_behind_push_remote,
213 num_index_skip_worktree,
214 num_index_assume_unchanged,
215 };
216
217 Ok(GitStatus {
218 id: id.to_owned(),
219 details: Option::Some(git_part),
220 })
221 }
222}
223
224#[derive(Copy, Clone, Debug, Hash)]
229pub enum ReadIndex {
231 ReadAll = 0,
233 DontRead = 1,
235}
236
237pub struct StatusRequest {
239 pub id: String,
243 pub dir: String,
248 pub read_index: ReadIndex,
250}
251
252impl fmt::Display for StatusRequest {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 write!(
256 f,
257 "{id}\x1f{dir}\x1f{index}\x1e",
258 id = self.id,
259 dir = self.dir,
260 index = self.read_index as u8
261 )
262 }
263}
264
265pub struct SatusDaemon {
274 _proc: process::Child,
276 stdin: process::ChildStdin,
277 stdout: io::BufReader<process::ChildStdout>,
278 _stderr: process::ChildStderr,
280}
281
282impl SatusDaemon {
283 pub fn new<C: AsRef<OsStr> + Default, P: AsRef<Path>>(
290 bin_path: C,
291 run_dir: P,
292 ) -> io::Result<SatusDaemon> {
293 let mut proc = process::Command::new(bin_path)
294 .current_dir(run_dir)
295 .stdin(process::Stdio::piped())
296 .stdout(process::Stdio::piped())
297 .stderr(process::Stdio::piped())
298 .spawn()?;
299
300 let stdin = proc.stdin.take().ok_or(io::Error::new(
301 io::ErrorKind::BrokenPipe,
302 "Couldn't obtain stdin",
303 ))?;
304 let stdout = io::BufReader::new(proc.stdout.take().ok_or(
305 io::Error::new(io::ErrorKind::BrokenPipe, "Couldn't obtain stdout"),
306 )?);
307 let stderr = proc.stderr.take().ok_or(io::Error::new(
308 io::ErrorKind::BrokenPipe,
309 "Couldn't obtain stderr",
310 ))?;
311
312 Ok(SatusDaemon {
313 _proc: proc,
314 stdin,
315 stdout,
316 _stderr: stderr,
317 })
318 }
319
320 pub fn request(&mut self, r: StatusRequest) -> io::Result<GitStatus> {
325 write!(self.stdin, "{}", r)?;
326 let mut read = Vec::with_capacity(256);
327 self.stdout.read_until(0x1e, &mut read)?;
328 assert_eq!(read.last(), Some(&0x1e));
329 read.truncate(read.len().saturating_sub(1));
331
332 let read = String::from_utf8(read).unwrap();
335 let responce = GitStatus::from_str(&read).unwrap();
336 Ok(responce)
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use pretty_assertions::assert_eq;
344
345 #[test]
346 fn it_works() {
347 assert_eq!(2 + 2, 4);
348 }
349
350 #[test]
351 fn pickup_gsd() {
352 let gsd = SatusDaemon::new("./gitstatusd/usrbin/gitstatusd", ".");
353
354 assert!(gsd.is_ok());
355 }
356
357 #[test]
358 fn write_request() {
359 let req = StatusRequest {
360 id: "SomeID".to_owned(),
361 dir: "some/path".to_owned(),
362 read_index: ReadIndex::ReadAll,
363 };
364 let to_send = format!("{}", req);
365 assert_eq!(to_send, "SomeID\x1fsome/path\x1f0\x1e");
366
367 let req = StatusRequest {
368 id: "SomeOtherID".to_owned(),
369 dir: "some/other/path".to_owned(),
370 read_index: ReadIndex::DontRead,
371 };
372 let to_send = format!("{}", req);
373 assert_eq!(to_send, "SomeOtherID\x1fsome/other/path\x1f1\x1e");
374 }
375
376 #[test]
377 fn parse_responce_no_git() {
378 let resp1 = "id1\x1f0";
379 let r1p = resp1.parse();
380 assert_eq!(
381 r1p,
382 Ok(GitStatus {
383 id: "id1".to_owned(),
384 details: Option::None,
385 })
386 );
387 }
388
389 fn responce_test(s: &str, resp: Result<GitStatus, ResponceParseError>) {
390 let r_got = s.parse();
391 assert_eq!(r_got, resp);
392 }
393
394 #[test]
395 fn parse_responce_no_git_no_id() {
396 responce_test(
397 "\x1f0",
398 Ok(GitStatus {
399 id: "".to_owned(),
400 details: Option::None,
401 }),
402 );
403 }
404
405 #[test]
406 fn parse_responce_empty() {
407 responce_test("", Err(ResponceParseError::TooShort));
408 }
409
410 #[test]
411 fn parse_responce_git_full() {
412 responce_test(
413 "id\u{1f}1\u{1f}/Users/nixon/dev/rs/gitstatusd\u{1f}1c9be4fe5460a30e70de9cbf99c3ec7064296b28\u{1f}master\u{1f}\u{1f}\u{1f}\u{1f}\u{1f}7\u{1f}0\u{1f}1\u{1f}0\u{1f}1\u{1f}0\u{1f}0\u{1f}0\u{1f}\u{1f}0\u{1f}0\u{1f}0\u{1f}\u{1f}\u{1f}0\u{1f}0\u{1f}0\u{1f}0",
414 Ok(GitStatus {
415 id: "id".to_owned(),
416 details: Option::Some(GitDetails{
417 abspath: "/Users/nixon/dev/rs/gitstatusd".to_owned(),
418 head_commit_hash: "1c9be4fe5460a30e70de9cbf99c3ec7064296b28".to_owned(),
419 local_branch: "master".to_owned(),
420 upstream_branch: "".to_owned(),
421 remote_name: "".to_owned(),
422 remote_url: "".to_owned(),
423 repository_state: "".to_owned(),
424 num_files_in_index: 7,
425 num_staged_changes: 0,
426 num_unstaged_changes: 1,
427 num_conflicted_changes: 0,
428 num_untrached_files: 1,
429 commits_ahead: 0,
430 commits_behind: 0,
431 num_stashes: 0,
432 last_tag: "".to_owned(),
433 num_unstaged_deleted: 0,
434 num_staged_new: 0,
435 num_staged_deleted: 0,
436 push_remote_name: "".to_owned(),
437 push_remote_url: "".to_owned(),
438 commits_ahead_push_remote: 0,
439 commits_behind_push_remote: 0,
440 num_index_skip_worktree: 0,
441 num_index_assume_unchanged: 0,
442 })
443 })
444 );
445 }
446
447 #[test]
448 fn run_this_dir_is_git() {
449 let req = StatusRequest {
450 id: "Request1".to_owned(),
451 dir: env!("CARGO_MANIFEST_DIR").to_owned(),
452 read_index: ReadIndex::ReadAll,
453 };
454 let mut gsd =
455 SatusDaemon::new("./gitstatusd/usrbin/gitstatusd", ".").unwrap();
456 let responce = gsd.request(req).unwrap();
457 assert!(matches!(responce.details, Option::Some(_)));
458 }
459}