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 pub issue: Option<IssueLink>,
58 #[serde(skip)]
67 pub has_worktree: bool,
68 #[serde(skip)]
72 pub recent_commits: Vec<Commit>,
73 #[serde(skip)]
76 pub pr_url: Option<String>,
77 #[serde(skip)]
82 pub merge_state: Option<MergeState>,
83}
84
85impl Worktree {
86 pub fn new(path: PathBuf) -> Self {
91 Worktree {
92 schema_version: SCHEMA_VERSION,
93 path,
94 branch: None,
95 slug: None,
96 is_current: false,
97 is_main: false,
98 is_missing: false,
99 is_detached: false,
100 dirty: None,
101 has_untracked: None,
102 ahead: None,
103 behind: None,
104 upstream: None,
105 base_ref: None,
106 commit: None,
107 pr: None,
108 issue: None,
109 has_worktree: true,
110 recent_commits: Vec::new(),
111 pr_url: None,
112 merge_state: None,
113 }
114 }
115
116 pub fn to_json_line(&self) -> Result<String> {
119 Ok(serde_json::to_string(self)?)
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum MergeState {
129 Merged {
133 into: Option<String>,
136 },
137 UpstreamGone,
141 NoUpstreamLocal,
144 Tracked,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct Commit {
151 pub hash: String,
153 pub subject: String,
155 pub author: String,
157 pub timestamp: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163pub struct Pr {
164 pub number: u64,
166 pub state: PrState,
168 pub title: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
178pub struct IssueLink {
179 pub number: u64,
181 pub title: String,
183 pub url: String,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189#[serde(rename_all = "lowercase")]
190pub enum PrState {
191 Open,
193 Closed,
195 Merged,
197 Draft,
199}
200
201impl PrState {
202 pub fn as_str(self) -> &'static str {
204 match self {
205 PrState::Open => "open",
206 PrState::Closed => "closed",
207 PrState::Merged => "merged",
208 PrState::Draft => "draft",
209 }
210 }
211
212 pub fn parse(s: &str) -> Option<PrState> {
214 Some(match s {
215 "open" => PrState::Open,
216 "closed" => PrState::Closed,
217 "merged" => PrState::Merged,
218 "draft" => PrState::Draft,
219 _ => return None,
220 })
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
226pub struct RemovedResult {
227 #[serde(flatten)]
229 pub worktree: Worktree,
230 pub removed: bool,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum SortKey {
237 Branch,
239 Dirty,
241 Ahead,
243 Behind,
245 Activity,
247 Path,
249}
250
251impl SortKey {
252 pub fn parse(name: &str) -> Option<SortKey> {
254 Some(match name {
255 "branch" => SortKey::Branch,
256 "dirty" => SortKey::Dirty,
257 "ahead" => SortKey::Ahead,
258 "behind" => SortKey::Behind,
259 "activity" => SortKey::Activity,
260 "path" => SortKey::Path,
261 _ => return None,
262 })
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub struct SortSpec {
269 pub key: SortKey,
271 pub descending: bool,
273}
274
275impl Default for SortSpec {
276 fn default() -> Self {
277 SortSpec {
278 key: SortKey::Branch,
279 descending: false,
280 }
281 }
282}
283
284impl SortSpec {
285 pub fn parse(value: &str) -> Result<SortSpec> {
287 let (descending, name) = match value.strip_prefix('-') {
288 Some(rest) => (true, rest),
289 None => (false, value),
290 };
291 let key = SortKey::parse(name)
292 .ok_or_else(|| Error::usage(format!("unknown sort field: {name:?}")))?;
293 Ok(SortSpec { key, descending })
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum Column {
300 Status,
302 Dirty,
304 Branch,
306 Path,
308 AheadBehind,
310 Commit,
312 Pr,
314 Issue,
317}
318
319impl Column {
320 pub const ALL: [Column; 7] = [
322 Column::Status,
323 Column::Dirty,
324 Column::Branch,
325 Column::Path,
326 Column::AheadBehind,
327 Column::Commit,
328 Column::Pr,
329 ];
330
331 pub fn parse(identifier: &str) -> Option<Column> {
333 Some(match identifier {
334 "status" => Column::Status,
335 "dirty" => Column::Dirty,
336 "branch" => Column::Branch,
337 "path" => Column::Path,
338 "ahead-behind" => Column::AheadBehind,
339 "commit" => Column::Commit,
340 "pr" => Column::Pr,
341 "issue" => Column::Issue,
342 _ => return None,
343 })
344 }
345
346 pub fn identifier(self) -> &'static str {
348 match self {
349 Column::Status => "status",
350 Column::Dirty => "dirty",
351 Column::Branch => "branch",
352 Column::Path => "path",
353 Column::AheadBehind => "ahead-behind",
354 Column::Commit => "commit",
355 Column::Pr => "pr",
356 Column::Issue => "issue",
357 }
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 const SPEC_EXAMPLE: &str = r#"{
367 "schema_version": 1,
368 "path": "/absolute/path",
369 "branch": "feature/login",
370 "slug": "feature-login",
371 "is_current": true,
372 "is_main": false,
373 "is_missing": false,
374 "is_detached": false,
375 "dirty": true,
376 "has_untracked": false,
377 "ahead": 2,
378 "behind": 0,
379 "upstream": "origin/feature/login",
380 "base_ref": "main",
381 "commit": {
382 "hash": "abc1234",
383 "subject": "Add login page",
384 "author": "Alice",
385 "timestamp": "2024-01-15T10:30:00Z"
386 },
387 "pr": { "number": 42, "state": "open", "title": "Add login page" },
388 "issue": null
389 }"#;
390
391 fn spec_example_worktree() -> Worktree {
392 Worktree {
393 schema_version: 1,
394 path: PathBuf::from("/absolute/path"),
395 branch: Some("feature/login".into()),
396 slug: Some("feature-login".into()),
397 is_current: true,
398 is_main: false,
399 is_missing: false,
400 is_detached: false,
401 dirty: Some(true),
402 has_untracked: Some(false),
403 ahead: Some(2),
404 behind: Some(0),
405 upstream: Some("origin/feature/login".into()),
406 base_ref: Some("main".into()),
407 commit: Some(Commit {
408 hash: "abc1234".into(),
409 subject: "Add login page".into(),
410 author: "Alice".into(),
411 timestamp: "2024-01-15T10:30:00Z".into(),
412 }),
413 pr: Some(Pr {
414 number: 42,
415 state: PrState::Open,
416 title: "Add login page".into(),
417 }),
418 issue: None,
419 has_worktree: true,
420 recent_commits: Vec::new(),
421 pr_url: None,
422 merge_state: None,
423 }
424 }
425
426 #[test]
427 fn issue_column_parses_but_is_not_a_default_column() {
428 assert_eq!(Column::parse("issue"), Some(Column::Issue));
429 assert_eq!(Column::Issue.identifier(), "issue");
430 assert!(!Column::ALL.contains(&Column::Issue));
433 }
434
435 #[test]
436 fn serializes_to_spec_schema() {
437 let got: serde_json::Value = serde_json::to_value(spec_example_worktree()).unwrap();
438 let want: serde_json::Value = serde_json::from_str(SPEC_EXAMPLE).unwrap();
439 assert_eq!(got, want);
440 }
441
442 #[test]
443 fn behind_zero_is_not_null() {
444 let v = serde_json::to_value(spec_example_worktree()).unwrap();
445 assert_eq!(v["behind"], serde_json::json!(0));
446 assert!(!v["behind"].is_null());
447 }
448
449 #[test]
450 fn missing_worktree_nulls_working_tree_fields() {
451 let mut wt = Worktree::new(PathBuf::from("/gone"));
452 wt.branch = Some("feature/x".into());
453 wt.slug = Some("feature-x".into());
454 wt.is_missing = true;
455 wt.base_ref = Some("main".into());
456 let v = serde_json::to_value(&wt).unwrap();
457 assert!(v["dirty"].is_null());
458 assert!(v["has_untracked"].is_null());
459 assert!(v["ahead"].is_null());
460 assert!(v["behind"].is_null());
461 assert!(v["commit"].is_null());
462 assert_eq!(v["branch"], serde_json::json!("feature/x"));
464 assert_eq!(v["base_ref"], serde_json::json!("main"));
465 assert_eq!(v["is_missing"], serde_json::json!(true));
466 }
467
468 #[test]
469 fn has_worktree_defaults_true_and_is_not_serialized() {
470 let wt = Worktree::new(PathBuf::from("/r"));
473 assert!(wt.has_worktree);
474 let v = serde_json::to_value(&wt).unwrap();
475 assert!(v.get("has_worktree").is_none());
476 }
477
478 #[test]
479 fn detached_head_has_null_branch() {
480 let mut wt = Worktree::new(PathBuf::from("/d"));
481 wt.is_detached = true;
482 let v = serde_json::to_value(&wt).unwrap();
483 assert!(v["branch"].is_null());
484 assert!(v["slug"].is_null());
485 assert_eq!(v["is_detached"], serde_json::json!(true));
486 }
487
488 #[test]
489 fn no_upstream_nulls_ahead_behind() {
490 let mut wt = Worktree::new(PathBuf::from("/n"));
491 wt.branch = Some("topic".into());
492 let v = serde_json::to_value(&wt).unwrap();
493 assert!(v["ahead"].is_null());
494 assert!(v["behind"].is_null());
495 assert!(v["upstream"].is_null());
496 assert!(v["pr"].is_null());
497 }
498
499 #[test]
500 fn pr_states_serialize_lowercase() {
501 for (state, text) in [
502 (PrState::Open, "open"),
503 (PrState::Closed, "closed"),
504 (PrState::Merged, "merged"),
505 (PrState::Draft, "draft"),
506 ] {
507 assert_eq!(
508 serde_json::to_value(state).unwrap(),
509 serde_json::json!(text)
510 );
511 assert_eq!(state.as_str(), text);
512 assert_eq!(PrState::parse(text), Some(state));
513 }
514 assert_eq!(PrState::parse("bogus"), None);
515 }
516
517 #[test]
518 fn json_line_is_single_line() {
519 let line = spec_example_worktree().to_json_line().unwrap();
520 assert!(!line.contains('\n'));
521 assert!(line.starts_with('{') && line.ends_with('}'));
522 }
523
524 #[test]
525 fn removed_result_flattens_worktree_plus_flag() {
526 let result = RemovedResult {
527 worktree: Worktree::new(PathBuf::from("/x")),
528 removed: true,
529 };
530 let v = serde_json::to_value(&result).unwrap();
531 assert_eq!(v["removed"], serde_json::json!(true));
532 assert_eq!(v["path"], serde_json::json!("/x"));
533 assert_eq!(v["schema_version"], serde_json::json!(1));
534 }
535
536 #[test]
537 fn sort_spec_parsing() {
538 assert_eq!(SortSpec::default().key, SortKey::Branch);
539 assert!(!SortSpec::default().descending);
540 assert_eq!(
541 SortSpec::parse("ahead").unwrap(),
542 SortSpec {
543 key: SortKey::Ahead,
544 descending: false
545 }
546 );
547 let desc = SortSpec::parse("-activity").unwrap();
548 assert_eq!(desc.key, SortKey::Activity);
549 assert!(desc.descending);
550 for f in ["branch", "dirty", "ahead", "behind", "activity", "path"] {
551 assert!(SortSpec::parse(f).is_ok());
552 }
553 let err = SortSpec::parse("bogus").unwrap_err();
554 assert_eq!(err.exit_code(), 2);
555 }
556
557 #[test]
558 fn column_parse_roundtrip() {
559 for col in Column::ALL {
560 assert_eq!(Column::parse(col.identifier()), Some(col));
561 }
562 assert_eq!(Column::parse("bogus"), None);
563 assert_eq!(Column::ALL.len(), 7);
564 }
565}