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 is_locked: bool,
43 pub lock_reason: Option<String>,
46 pub dirty: Option<bool>,
48 pub has_untracked: Option<bool>,
50 pub ahead: Option<u32>,
52 pub behind: Option<u32>,
54 pub upstream: Option<String>,
56 pub base_ref: Option<String>,
58 pub commit: Option<Commit>,
60 pub pr: Option<Pr>,
62 pub issue: Option<IssueLink>,
64 #[serde(skip)]
73 pub has_worktree: bool,
74 #[serde(skip)]
78 pub recent_commits: Vec<Commit>,
79 #[serde(skip)]
82 pub pr_url: Option<String>,
83 #[serde(skip)]
88 pub merge_state: Option<MergeState>,
89}
90
91impl Worktree {
92 pub fn new(path: PathBuf) -> Self {
97 Worktree {
98 schema_version: SCHEMA_VERSION,
99 path,
100 branch: None,
101 slug: None,
102 is_current: false,
103 is_main: false,
104 is_missing: false,
105 is_detached: false,
106 is_locked: false,
107 lock_reason: None,
108 dirty: None,
109 has_untracked: None,
110 ahead: None,
111 behind: None,
112 upstream: None,
113 base_ref: None,
114 commit: None,
115 pr: None,
116 issue: None,
117 has_worktree: true,
118 recent_commits: Vec::new(),
119 pr_url: None,
120 merge_state: None,
121 }
122 }
123
124 pub fn to_json_line(&self) -> Result<String> {
127 Ok(serde_json::to_string(self)?)
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum MergeState {
137 Merged {
141 into: Option<String>,
144 },
145 UpstreamGone,
149 NoUpstreamLocal,
152 Tracked,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158pub struct Commit {
159 pub hash: String,
161 pub subject: String,
163 pub author: String,
165 pub timestamp: String,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171pub struct Pr {
172 pub number: u64,
174 pub state: PrState,
176 pub title: String,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct IssueLink {
187 pub number: u64,
189 pub title: String,
191 pub url: String,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
197#[serde(rename_all = "lowercase")]
198pub enum PrState {
199 Open,
201 Closed,
203 Merged,
205 Draft,
207}
208
209impl PrState {
210 pub fn as_str(self) -> &'static str {
212 match self {
213 PrState::Open => "open",
214 PrState::Closed => "closed",
215 PrState::Merged => "merged",
216 PrState::Draft => "draft",
217 }
218 }
219
220 pub fn parse(s: &str) -> Option<PrState> {
222 Some(match s {
223 "open" => PrState::Open,
224 "closed" => PrState::Closed,
225 "merged" => PrState::Merged,
226 "draft" => PrState::Draft,
227 _ => return None,
228 })
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234pub struct RemovedResult {
235 #[serde(flatten)]
237 pub worktree: Worktree,
238 pub removed: bool,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum SortKey {
245 Branch,
247 Dirty,
249 Ahead,
251 Behind,
253 Activity,
255 Path,
257}
258
259impl SortKey {
260 pub fn parse(name: &str) -> Option<SortKey> {
262 Some(match name {
263 "branch" => SortKey::Branch,
264 "dirty" => SortKey::Dirty,
265 "ahead" => SortKey::Ahead,
266 "behind" => SortKey::Behind,
267 "activity" => SortKey::Activity,
268 "path" => SortKey::Path,
269 _ => return None,
270 })
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub struct SortSpec {
277 pub key: SortKey,
279 pub descending: bool,
281}
282
283impl Default for SortSpec {
284 fn default() -> Self {
285 SortSpec {
286 key: SortKey::Branch,
287 descending: false,
288 }
289 }
290}
291
292impl SortSpec {
293 pub fn parse(value: &str) -> Result<SortSpec> {
295 let (descending, name) = match value.strip_prefix('-') {
296 Some(rest) => (true, rest),
297 None => (false, value),
298 };
299 let key = SortKey::parse(name)
300 .ok_or_else(|| Error::usage(format!("unknown sort field: {name:?}")))?;
301 Ok(SortSpec { key, descending })
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum Column {
308 Status,
310 Dirty,
312 Branch,
314 Path,
316 AheadBehind,
318 Commit,
320 Pr,
322 Issue,
325}
326
327impl Column {
328 pub const ALL: [Column; 7] = [
330 Column::Status,
331 Column::Dirty,
332 Column::Branch,
333 Column::Path,
334 Column::AheadBehind,
335 Column::Commit,
336 Column::Pr,
337 ];
338
339 pub fn parse(identifier: &str) -> Option<Column> {
341 Some(match identifier {
342 "status" => Column::Status,
343 "dirty" => Column::Dirty,
344 "branch" => Column::Branch,
345 "path" => Column::Path,
346 "ahead-behind" => Column::AheadBehind,
347 "commit" => Column::Commit,
348 "pr" => Column::Pr,
349 "issue" => Column::Issue,
350 _ => return None,
351 })
352 }
353
354 pub fn identifier(self) -> &'static str {
356 match self {
357 Column::Status => "status",
358 Column::Dirty => "dirty",
359 Column::Branch => "branch",
360 Column::Path => "path",
361 Column::AheadBehind => "ahead-behind",
362 Column::Commit => "commit",
363 Column::Pr => "pr",
364 Column::Issue => "issue",
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 const SPEC_EXAMPLE: &str = r#"{
375 "schema_version": 1,
376 "path": "/absolute/path",
377 "branch": "feature/login",
378 "slug": "feature-login",
379 "is_current": true,
380 "is_main": false,
381 "is_missing": false,
382 "is_detached": false,
383 "is_locked": false,
384 "lock_reason": null,
385 "dirty": true,
386 "has_untracked": false,
387 "ahead": 2,
388 "behind": 0,
389 "upstream": "origin/feature/login",
390 "base_ref": "main",
391 "commit": {
392 "hash": "abc1234",
393 "subject": "Add login page",
394 "author": "Alice",
395 "timestamp": "2024-01-15T10:30:00Z"
396 },
397 "pr": { "number": 42, "state": "open", "title": "Add login page" },
398 "issue": null
399 }"#;
400
401 fn spec_example_worktree() -> Worktree {
402 Worktree {
403 schema_version: 1,
404 path: PathBuf::from("/absolute/path"),
405 branch: Some("feature/login".into()),
406 slug: Some("feature-login".into()),
407 is_current: true,
408 is_main: false,
409 is_missing: false,
410 is_detached: false,
411 is_locked: false,
412 lock_reason: None,
413 dirty: Some(true),
414 has_untracked: Some(false),
415 ahead: Some(2),
416 behind: Some(0),
417 upstream: Some("origin/feature/login".into()),
418 base_ref: Some("main".into()),
419 commit: Some(Commit {
420 hash: "abc1234".into(),
421 subject: "Add login page".into(),
422 author: "Alice".into(),
423 timestamp: "2024-01-15T10:30:00Z".into(),
424 }),
425 pr: Some(Pr {
426 number: 42,
427 state: PrState::Open,
428 title: "Add login page".into(),
429 }),
430 issue: None,
431 has_worktree: true,
432 recent_commits: Vec::new(),
433 pr_url: None,
434 merge_state: None,
435 }
436 }
437
438 #[test]
439 fn issue_column_parses_but_is_not_a_default_column() {
440 assert_eq!(Column::parse("issue"), Some(Column::Issue));
441 assert_eq!(Column::Issue.identifier(), "issue");
442 assert!(!Column::ALL.contains(&Column::Issue));
445 }
446
447 #[test]
448 fn serializes_to_spec_schema() {
449 let got: serde_json::Value = serde_json::to_value(spec_example_worktree()).unwrap();
450 let want: serde_json::Value = serde_json::from_str(SPEC_EXAMPLE).unwrap();
451 assert_eq!(got, want);
452 }
453
454 #[test]
455 fn behind_zero_is_not_null() {
456 let v = serde_json::to_value(spec_example_worktree()).unwrap();
457 assert_eq!(v["behind"], serde_json::json!(0));
458 assert!(!v["behind"].is_null());
459 }
460
461 #[test]
462 fn missing_worktree_nulls_working_tree_fields() {
463 let mut wt = Worktree::new(PathBuf::from("/gone"));
464 wt.branch = Some("feature/x".into());
465 wt.slug = Some("feature-x".into());
466 wt.is_missing = true;
467 wt.base_ref = Some("main".into());
468 let v = serde_json::to_value(&wt).unwrap();
469 assert!(v["dirty"].is_null());
470 assert!(v["has_untracked"].is_null());
471 assert!(v["ahead"].is_null());
472 assert!(v["behind"].is_null());
473 assert!(v["commit"].is_null());
474 assert_eq!(v["branch"], serde_json::json!("feature/x"));
476 assert_eq!(v["base_ref"], serde_json::json!("main"));
477 assert_eq!(v["is_missing"], serde_json::json!(true));
478 }
479
480 #[test]
481 fn has_worktree_defaults_true_and_is_not_serialized() {
482 let wt = Worktree::new(PathBuf::from("/r"));
485 assert!(wt.has_worktree);
486 let v = serde_json::to_value(&wt).unwrap();
487 assert!(v.get("has_worktree").is_none());
488 }
489
490 #[test]
491 fn detached_head_has_null_branch() {
492 let mut wt = Worktree::new(PathBuf::from("/d"));
493 wt.is_detached = true;
494 let v = serde_json::to_value(&wt).unwrap();
495 assert!(v["branch"].is_null());
496 assert!(v["slug"].is_null());
497 assert_eq!(v["is_detached"], serde_json::json!(true));
498 }
499
500 #[test]
501 fn locked_worktree_serializes_lock_fields() {
502 let mut wt = Worktree::new(PathBuf::from("/l"));
503 let v = serde_json::to_value(&wt).unwrap();
504 assert_eq!(v["is_locked"], serde_json::json!(false));
505 assert!(v["lock_reason"].is_null());
506 wt.is_locked = true;
507 wt.lock_reason = Some("being used".into());
508 let v = serde_json::to_value(&wt).unwrap();
509 assert_eq!(v["is_locked"], serde_json::json!(true));
510 assert_eq!(v["lock_reason"], serde_json::json!("being used"));
511 assert_eq!(v["schema_version"], serde_json::json!(1));
513 }
514
515 #[test]
516 fn no_upstream_nulls_ahead_behind() {
517 let mut wt = Worktree::new(PathBuf::from("/n"));
518 wt.branch = Some("topic".into());
519 let v = serde_json::to_value(&wt).unwrap();
520 assert!(v["ahead"].is_null());
521 assert!(v["behind"].is_null());
522 assert!(v["upstream"].is_null());
523 assert!(v["pr"].is_null());
524 }
525
526 #[test]
527 fn pr_states_serialize_lowercase() {
528 for (state, text) in [
529 (PrState::Open, "open"),
530 (PrState::Closed, "closed"),
531 (PrState::Merged, "merged"),
532 (PrState::Draft, "draft"),
533 ] {
534 assert_eq!(
535 serde_json::to_value(state).unwrap(),
536 serde_json::json!(text)
537 );
538 assert_eq!(state.as_str(), text);
539 assert_eq!(PrState::parse(text), Some(state));
540 }
541 assert_eq!(PrState::parse("bogus"), None);
542 }
543
544 #[test]
545 fn json_line_is_single_line() {
546 let line = spec_example_worktree().to_json_line().unwrap();
547 assert!(!line.contains('\n'));
548 assert!(line.starts_with('{') && line.ends_with('}'));
549 }
550
551 #[test]
552 fn removed_result_flattens_worktree_plus_flag() {
553 let result = RemovedResult {
554 worktree: Worktree::new(PathBuf::from("/x")),
555 removed: true,
556 };
557 let v = serde_json::to_value(&result).unwrap();
558 assert_eq!(v["removed"], serde_json::json!(true));
559 assert_eq!(v["path"], serde_json::json!("/x"));
560 assert_eq!(v["schema_version"], serde_json::json!(1));
561 }
562
563 #[test]
564 fn sort_spec_parsing() {
565 assert_eq!(SortSpec::default().key, SortKey::Branch);
566 assert!(!SortSpec::default().descending);
567 assert_eq!(
568 SortSpec::parse("ahead").unwrap(),
569 SortSpec {
570 key: SortKey::Ahead,
571 descending: false
572 }
573 );
574 let desc = SortSpec::parse("-activity").unwrap();
575 assert_eq!(desc.key, SortKey::Activity);
576 assert!(desc.descending);
577 for f in ["branch", "dirty", "ahead", "behind", "activity", "path"] {
578 assert!(SortSpec::parse(f).is_ok());
579 }
580 let err = SortSpec::parse("bogus").unwrap_err();
581 assert_eq!(err.exit_code(), 2);
582 }
583
584 #[test]
585 fn column_parse_roundtrip() {
586 for col in Column::ALL {
587 assert_eq!(Column::parse(col.identifier()), Some(col));
588 }
589 assert_eq!(Column::parse("bogus"), None);
590 assert_eq!(Column::ALL.len(), 7);
591 }
592}