use std::path::PathBuf;
use serde::Serialize;
use crate::error::{Error, Result};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Worktree {
pub schema_version: u32,
pub path: PathBuf,
pub branch: Option<String>,
pub slug: Option<String>,
pub is_current: bool,
pub is_main: bool,
pub is_missing: bool,
pub is_detached: bool,
pub dirty: Option<bool>,
pub has_untracked: Option<bool>,
pub ahead: Option<u32>,
pub behind: Option<u32>,
pub upstream: Option<String>,
pub base_ref: Option<String>,
pub commit: Option<Commit>,
pub pr: Option<Pr>,
#[serde(skip)]
pub has_worktree: bool,
#[serde(skip)]
pub recent_commits: Vec<Commit>,
#[serde(skip)]
pub pr_url: Option<String>,
#[serde(skip)]
pub merge_state: Option<MergeState>,
}
impl Worktree {
pub fn new(path: PathBuf) -> Self {
Worktree {
schema_version: SCHEMA_VERSION,
path,
branch: None,
slug: None,
is_current: false,
is_main: false,
is_missing: false,
is_detached: false,
dirty: None,
has_untracked: None,
ahead: None,
behind: None,
upstream: None,
base_ref: None,
commit: None,
pr: None,
has_worktree: true,
recent_commits: Vec::new(),
pr_url: None,
merge_state: None,
}
}
pub fn to_json_line(&self) -> Result<String> {
Ok(serde_json::to_string(self)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeState {
Merged {
into: Option<String>,
},
UpstreamGone,
NoUpstreamLocal,
Tracked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Commit {
pub hash: String,
pub subject: String,
pub author: String,
pub timestamp: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Pr {
pub number: u64,
pub state: PrState,
pub title: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PrState {
Open,
Closed,
Merged,
Draft,
}
impl PrState {
pub fn as_str(self) -> &'static str {
match self {
PrState::Open => "open",
PrState::Closed => "closed",
PrState::Merged => "merged",
PrState::Draft => "draft",
}
}
pub fn parse(s: &str) -> Option<PrState> {
Some(match s {
"open" => PrState::Open,
"closed" => PrState::Closed,
"merged" => PrState::Merged,
"draft" => PrState::Draft,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RemovedResult {
#[serde(flatten)]
pub worktree: Worktree,
pub removed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Branch,
Dirty,
Ahead,
Behind,
Activity,
Path,
}
impl SortKey {
pub fn parse(name: &str) -> Option<SortKey> {
Some(match name {
"branch" => SortKey::Branch,
"dirty" => SortKey::Dirty,
"ahead" => SortKey::Ahead,
"behind" => SortKey::Behind,
"activity" => SortKey::Activity,
"path" => SortKey::Path,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortSpec {
pub key: SortKey,
pub descending: bool,
}
impl Default for SortSpec {
fn default() -> Self {
SortSpec {
key: SortKey::Branch,
descending: false,
}
}
}
impl SortSpec {
pub fn parse(value: &str) -> Result<SortSpec> {
let (descending, name) = match value.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, value),
};
let key = SortKey::parse(name)
.ok_or_else(|| Error::usage(format!("unknown sort field: {name:?}")))?;
Ok(SortSpec { key, descending })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Column {
Status,
Dirty,
Branch,
Path,
AheadBehind,
Commit,
Pr,
}
impl Column {
pub const ALL: [Column; 7] = [
Column::Status,
Column::Dirty,
Column::Branch,
Column::Path,
Column::AheadBehind,
Column::Commit,
Column::Pr,
];
pub fn parse(identifier: &str) -> Option<Column> {
Some(match identifier {
"status" => Column::Status,
"dirty" => Column::Dirty,
"branch" => Column::Branch,
"path" => Column::Path,
"ahead-behind" => Column::AheadBehind,
"commit" => Column::Commit,
"pr" => Column::Pr,
_ => return None,
})
}
pub fn identifier(self) -> &'static str {
match self {
Column::Status => "status",
Column::Dirty => "dirty",
Column::Branch => "branch",
Column::Path => "path",
Column::AheadBehind => "ahead-behind",
Column::Commit => "commit",
Column::Pr => "pr",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC_EXAMPLE: &str = r#"{
"schema_version": 1,
"path": "/absolute/path",
"branch": "feature/login",
"slug": "feature-login",
"is_current": true,
"is_main": false,
"is_missing": false,
"is_detached": false,
"dirty": true,
"has_untracked": false,
"ahead": 2,
"behind": 0,
"upstream": "origin/feature/login",
"base_ref": "main",
"commit": {
"hash": "abc1234",
"subject": "Add login page",
"author": "Alice",
"timestamp": "2024-01-15T10:30:00Z"
},
"pr": { "number": 42, "state": "open", "title": "Add login page" }
}"#;
fn spec_example_worktree() -> Worktree {
Worktree {
schema_version: 1,
path: PathBuf::from("/absolute/path"),
branch: Some("feature/login".into()),
slug: Some("feature-login".into()),
is_current: true,
is_main: false,
is_missing: false,
is_detached: false,
dirty: Some(true),
has_untracked: Some(false),
ahead: Some(2),
behind: Some(0),
upstream: Some("origin/feature/login".into()),
base_ref: Some("main".into()),
commit: Some(Commit {
hash: "abc1234".into(),
subject: "Add login page".into(),
author: "Alice".into(),
timestamp: "2024-01-15T10:30:00Z".into(),
}),
pr: Some(Pr {
number: 42,
state: PrState::Open,
title: "Add login page".into(),
}),
has_worktree: true,
recent_commits: Vec::new(),
pr_url: None,
merge_state: None,
}
}
#[test]
fn serializes_to_spec_schema() {
let got: serde_json::Value = serde_json::to_value(spec_example_worktree()).unwrap();
let want: serde_json::Value = serde_json::from_str(SPEC_EXAMPLE).unwrap();
assert_eq!(got, want);
}
#[test]
fn behind_zero_is_not_null() {
let v = serde_json::to_value(spec_example_worktree()).unwrap();
assert_eq!(v["behind"], serde_json::json!(0));
assert!(!v["behind"].is_null());
}
#[test]
fn missing_worktree_nulls_working_tree_fields() {
let mut wt = Worktree::new(PathBuf::from("/gone"));
wt.branch = Some("feature/x".into());
wt.slug = Some("feature-x".into());
wt.is_missing = true;
wt.base_ref = Some("main".into());
let v = serde_json::to_value(&wt).unwrap();
assert!(v["dirty"].is_null());
assert!(v["has_untracked"].is_null());
assert!(v["ahead"].is_null());
assert!(v["behind"].is_null());
assert!(v["commit"].is_null());
assert_eq!(v["branch"], serde_json::json!("feature/x"));
assert_eq!(v["base_ref"], serde_json::json!("main"));
assert_eq!(v["is_missing"], serde_json::json!(true));
}
#[test]
fn has_worktree_defaults_true_and_is_not_serialized() {
let wt = Worktree::new(PathBuf::from("/r"));
assert!(wt.has_worktree);
let v = serde_json::to_value(&wt).unwrap();
assert!(v.get("has_worktree").is_none());
}
#[test]
fn detached_head_has_null_branch() {
let mut wt = Worktree::new(PathBuf::from("/d"));
wt.is_detached = true;
let v = serde_json::to_value(&wt).unwrap();
assert!(v["branch"].is_null());
assert!(v["slug"].is_null());
assert_eq!(v["is_detached"], serde_json::json!(true));
}
#[test]
fn no_upstream_nulls_ahead_behind() {
let mut wt = Worktree::new(PathBuf::from("/n"));
wt.branch = Some("topic".into());
let v = serde_json::to_value(&wt).unwrap();
assert!(v["ahead"].is_null());
assert!(v["behind"].is_null());
assert!(v["upstream"].is_null());
assert!(v["pr"].is_null());
}
#[test]
fn pr_states_serialize_lowercase() {
for (state, text) in [
(PrState::Open, "open"),
(PrState::Closed, "closed"),
(PrState::Merged, "merged"),
(PrState::Draft, "draft"),
] {
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::json!(text)
);
assert_eq!(state.as_str(), text);
assert_eq!(PrState::parse(text), Some(state));
}
assert_eq!(PrState::parse("bogus"), None);
}
#[test]
fn json_line_is_single_line() {
let line = spec_example_worktree().to_json_line().unwrap();
assert!(!line.contains('\n'));
assert!(line.starts_with('{') && line.ends_with('}'));
}
#[test]
fn removed_result_flattens_worktree_plus_flag() {
let result = RemovedResult {
worktree: Worktree::new(PathBuf::from("/x")),
removed: true,
};
let v = serde_json::to_value(&result).unwrap();
assert_eq!(v["removed"], serde_json::json!(true));
assert_eq!(v["path"], serde_json::json!("/x"));
assert_eq!(v["schema_version"], serde_json::json!(1));
}
#[test]
fn sort_spec_parsing() {
assert_eq!(SortSpec::default().key, SortKey::Branch);
assert!(!SortSpec::default().descending);
assert_eq!(
SortSpec::parse("ahead").unwrap(),
SortSpec {
key: SortKey::Ahead,
descending: false
}
);
let desc = SortSpec::parse("-activity").unwrap();
assert_eq!(desc.key, SortKey::Activity);
assert!(desc.descending);
for f in ["branch", "dirty", "ahead", "behind", "activity", "path"] {
assert!(SortSpec::parse(f).is_ok());
}
let err = SortSpec::parse("bogus").unwrap_err();
assert_eq!(err.exit_code(), 2);
}
#[test]
fn column_parse_roundtrip() {
for col in Column::ALL {
assert_eq!(Column::parse(col.identifier()), Some(col));
}
assert_eq!(Column::parse("bogus"), None);
assert_eq!(Column::ALL.len(), 7);
}
}