1use std::path::PathBuf;
11
12use serde::Serialize;
13
14use crate::error::{Error, Result};
15
16pub const SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct Worktree {
24 pub schema_version: u32,
26 pub path: PathBuf,
28 pub branch: Option<String>,
30 pub slug: Option<String>,
32 pub is_current: bool,
34 pub is_main: bool,
36 pub is_missing: bool,
38 pub is_detached: bool,
40 pub dirty: Option<bool>,
42 pub has_untracked: Option<bool>,
44 pub ahead: Option<u32>,
46 pub behind: Option<u32>,
48 pub upstream: Option<String>,
50 pub base_ref: Option<String>,
52 pub commit: Option<Commit>,
54 pub pr: Option<Pr>,
56 #[serde(skip)]
65 pub has_worktree: bool,
66 #[serde(skip)]
70 pub recent_commits: Vec<Commit>,
71 #[serde(skip)]
74 pub pr_url: Option<String>,
75 #[serde(skip)]
80 pub merge_state: Option<MergeState>,
81}
82
83impl Worktree {
84 pub fn new(path: PathBuf) -> Self {
89 Worktree {
90 schema_version: SCHEMA_VERSION,
91 path,
92 branch: None,
93 slug: None,
94 is_current: false,
95 is_main: false,
96 is_missing: false,
97 is_detached: false,
98 dirty: None,
99 has_untracked: None,
100 ahead: None,
101 behind: None,
102 upstream: None,
103 base_ref: None,
104 commit: None,
105 pr: None,
106 has_worktree: true,
107 recent_commits: Vec::new(),
108 pr_url: None,
109 merge_state: None,
110 }
111 }
112
113 pub fn to_json_line(&self) -> Result<String> {
116 Ok(serde_json::to_string(self)?)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum MergeState {
126 Merged {
130 into: Option<String>,
133 },
134 UpstreamGone,
138 NoUpstreamLocal,
141 Tracked,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct Commit {
148 pub hash: String,
150 pub subject: String,
152 pub author: String,
154 pub timestamp: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub struct Pr {
161 pub number: u64,
163 pub state: PrState,
165 pub title: String,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171#[serde(rename_all = "lowercase")]
172pub enum PrState {
173 Open,
175 Closed,
177 Merged,
179 Draft,
181}
182
183impl PrState {
184 pub fn as_str(self) -> &'static str {
186 match self {
187 PrState::Open => "open",
188 PrState::Closed => "closed",
189 PrState::Merged => "merged",
190 PrState::Draft => "draft",
191 }
192 }
193
194 pub fn parse(s: &str) -> Option<PrState> {
196 Some(match s {
197 "open" => PrState::Open,
198 "closed" => PrState::Closed,
199 "merged" => PrState::Merged,
200 "draft" => PrState::Draft,
201 _ => return None,
202 })
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct RemovedResult {
209 #[serde(flatten)]
211 pub worktree: Worktree,
212 pub removed: bool,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SortKey {
219 Branch,
221 Dirty,
223 Ahead,
225 Behind,
227 Activity,
229 Path,
231}
232
233impl SortKey {
234 pub fn parse(name: &str) -> Option<SortKey> {
236 Some(match name {
237 "branch" => SortKey::Branch,
238 "dirty" => SortKey::Dirty,
239 "ahead" => SortKey::Ahead,
240 "behind" => SortKey::Behind,
241 "activity" => SortKey::Activity,
242 "path" => SortKey::Path,
243 _ => return None,
244 })
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct SortSpec {
251 pub key: SortKey,
253 pub descending: bool,
255}
256
257impl Default for SortSpec {
258 fn default() -> Self {
259 SortSpec {
260 key: SortKey::Branch,
261 descending: false,
262 }
263 }
264}
265
266impl SortSpec {
267 pub fn parse(value: &str) -> Result<SortSpec> {
269 let (descending, name) = match value.strip_prefix('-') {
270 Some(rest) => (true, rest),
271 None => (false, value),
272 };
273 let key = SortKey::parse(name)
274 .ok_or_else(|| Error::usage(format!("unknown sort field: {name:?}")))?;
275 Ok(SortSpec { key, descending })
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum Column {
282 Status,
284 Dirty,
286 Branch,
288 Path,
290 AheadBehind,
292 Commit,
294 Pr,
296}
297
298impl Column {
299 pub const ALL: [Column; 7] = [
301 Column::Status,
302 Column::Dirty,
303 Column::Branch,
304 Column::Path,
305 Column::AheadBehind,
306 Column::Commit,
307 Column::Pr,
308 ];
309
310 pub fn parse(identifier: &str) -> Option<Column> {
312 Some(match identifier {
313 "status" => Column::Status,
314 "dirty" => Column::Dirty,
315 "branch" => Column::Branch,
316 "path" => Column::Path,
317 "ahead-behind" => Column::AheadBehind,
318 "commit" => Column::Commit,
319 "pr" => Column::Pr,
320 _ => return None,
321 })
322 }
323
324 pub fn identifier(self) -> &'static str {
326 match self {
327 Column::Status => "status",
328 Column::Dirty => "dirty",
329 Column::Branch => "branch",
330 Column::Path => "path",
331 Column::AheadBehind => "ahead-behind",
332 Column::Commit => "commit",
333 Column::Pr => "pr",
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 const SPEC_EXAMPLE: &str = r#"{
344 "schema_version": 1,
345 "path": "/absolute/path",
346 "branch": "feature/login",
347 "slug": "feature-login",
348 "is_current": true,
349 "is_main": false,
350 "is_missing": false,
351 "is_detached": false,
352 "dirty": true,
353 "has_untracked": false,
354 "ahead": 2,
355 "behind": 0,
356 "upstream": "origin/feature/login",
357 "base_ref": "main",
358 "commit": {
359 "hash": "abc1234",
360 "subject": "Add login page",
361 "author": "Alice",
362 "timestamp": "2024-01-15T10:30:00Z"
363 },
364 "pr": { "number": 42, "state": "open", "title": "Add login page" }
365 }"#;
366
367 fn spec_example_worktree() -> Worktree {
368 Worktree {
369 schema_version: 1,
370 path: PathBuf::from("/absolute/path"),
371 branch: Some("feature/login".into()),
372 slug: Some("feature-login".into()),
373 is_current: true,
374 is_main: false,
375 is_missing: false,
376 is_detached: false,
377 dirty: Some(true),
378 has_untracked: Some(false),
379 ahead: Some(2),
380 behind: Some(0),
381 upstream: Some("origin/feature/login".into()),
382 base_ref: Some("main".into()),
383 commit: Some(Commit {
384 hash: "abc1234".into(),
385 subject: "Add login page".into(),
386 author: "Alice".into(),
387 timestamp: "2024-01-15T10:30:00Z".into(),
388 }),
389 pr: Some(Pr {
390 number: 42,
391 state: PrState::Open,
392 title: "Add login page".into(),
393 }),
394 has_worktree: true,
395 recent_commits: Vec::new(),
396 pr_url: None,
397 merge_state: None,
398 }
399 }
400
401 #[test]
402 fn serializes_to_spec_schema() {
403 let got: serde_json::Value = serde_json::to_value(spec_example_worktree()).unwrap();
404 let want: serde_json::Value = serde_json::from_str(SPEC_EXAMPLE).unwrap();
405 assert_eq!(got, want);
406 }
407
408 #[test]
409 fn behind_zero_is_not_null() {
410 let v = serde_json::to_value(spec_example_worktree()).unwrap();
411 assert_eq!(v["behind"], serde_json::json!(0));
412 assert!(!v["behind"].is_null());
413 }
414
415 #[test]
416 fn missing_worktree_nulls_working_tree_fields() {
417 let mut wt = Worktree::new(PathBuf::from("/gone"));
418 wt.branch = Some("feature/x".into());
419 wt.slug = Some("feature-x".into());
420 wt.is_missing = true;
421 wt.base_ref = Some("main".into());
422 let v = serde_json::to_value(&wt).unwrap();
423 assert!(v["dirty"].is_null());
424 assert!(v["has_untracked"].is_null());
425 assert!(v["ahead"].is_null());
426 assert!(v["behind"].is_null());
427 assert!(v["commit"].is_null());
428 assert_eq!(v["branch"], serde_json::json!("feature/x"));
430 assert_eq!(v["base_ref"], serde_json::json!("main"));
431 assert_eq!(v["is_missing"], serde_json::json!(true));
432 }
433
434 #[test]
435 fn has_worktree_defaults_true_and_is_not_serialized() {
436 let wt = Worktree::new(PathBuf::from("/r"));
439 assert!(wt.has_worktree);
440 let v = serde_json::to_value(&wt).unwrap();
441 assert!(v.get("has_worktree").is_none());
442 }
443
444 #[test]
445 fn detached_head_has_null_branch() {
446 let mut wt = Worktree::new(PathBuf::from("/d"));
447 wt.is_detached = true;
448 let v = serde_json::to_value(&wt).unwrap();
449 assert!(v["branch"].is_null());
450 assert!(v["slug"].is_null());
451 assert_eq!(v["is_detached"], serde_json::json!(true));
452 }
453
454 #[test]
455 fn no_upstream_nulls_ahead_behind() {
456 let mut wt = Worktree::new(PathBuf::from("/n"));
457 wt.branch = Some("topic".into());
458 let v = serde_json::to_value(&wt).unwrap();
459 assert!(v["ahead"].is_null());
460 assert!(v["behind"].is_null());
461 assert!(v["upstream"].is_null());
462 assert!(v["pr"].is_null());
463 }
464
465 #[test]
466 fn pr_states_serialize_lowercase() {
467 for (state, text) in [
468 (PrState::Open, "open"),
469 (PrState::Closed, "closed"),
470 (PrState::Merged, "merged"),
471 (PrState::Draft, "draft"),
472 ] {
473 assert_eq!(
474 serde_json::to_value(state).unwrap(),
475 serde_json::json!(text)
476 );
477 assert_eq!(state.as_str(), text);
478 assert_eq!(PrState::parse(text), Some(state));
479 }
480 assert_eq!(PrState::parse("bogus"), None);
481 }
482
483 #[test]
484 fn json_line_is_single_line() {
485 let line = spec_example_worktree().to_json_line().unwrap();
486 assert!(!line.contains('\n'));
487 assert!(line.starts_with('{') && line.ends_with('}'));
488 }
489
490 #[test]
491 fn removed_result_flattens_worktree_plus_flag() {
492 let result = RemovedResult {
493 worktree: Worktree::new(PathBuf::from("/x")),
494 removed: true,
495 };
496 let v = serde_json::to_value(&result).unwrap();
497 assert_eq!(v["removed"], serde_json::json!(true));
498 assert_eq!(v["path"], serde_json::json!("/x"));
499 assert_eq!(v["schema_version"], serde_json::json!(1));
500 }
501
502 #[test]
503 fn sort_spec_parsing() {
504 assert_eq!(SortSpec::default().key, SortKey::Branch);
505 assert!(!SortSpec::default().descending);
506 assert_eq!(
507 SortSpec::parse("ahead").unwrap(),
508 SortSpec {
509 key: SortKey::Ahead,
510 descending: false
511 }
512 );
513 let desc = SortSpec::parse("-activity").unwrap();
514 assert_eq!(desc.key, SortKey::Activity);
515 assert!(desc.descending);
516 for f in ["branch", "dirty", "ahead", "behind", "activity", "path"] {
517 assert!(SortSpec::parse(f).is_ok());
518 }
519 let err = SortSpec::parse("bogus").unwrap_err();
520 assert_eq!(err.exit_code(), 2);
521 }
522
523 #[test]
524 fn column_parse_roundtrip() {
525 for col in Column::ALL {
526 assert_eq!(Column::parse(col.identifier()), Some(col));
527 }
528 assert_eq!(Column::parse("bogus"), None);
529 assert_eq!(Column::ALL.len(), 7);
530 }
531}