diff --git a/bin/cob/issue.rs b/bin/cob/issue.rs index a95c84a7..1b16a241 100644 --- a/bin/cob/issue.rs +++ b/bin/cob/issue.rs @@ -1,129 +1,13 @@ -use std::fmt; -use std::fmt::Write as _; - use anyhow::Result; use radicle::cob::issue::{Issue, IssueId}; use radicle::issue::cache::Issues; -use radicle::issue::State; -use radicle::prelude::Did; use radicle::storage::git::Repository; use radicle::Profile; -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Filter { - state: Option, - assigned: bool, - assignees: Vec, -} - -impl Default for Filter { - fn default() -> Self { - Self { - state: Some(State::default()), - assigned: false, - assignees: vec![], - } - } -} - -impl Filter { - pub fn with_state(mut self, state: Option) -> Self { - self.state = state; - self - } - - pub fn with_assgined(mut self, assigned: bool) -> Self { - self.assigned = assigned; - self - } - - pub fn with_assginee(mut self, assignee: Did) -> Self { - self.assignees.push(assignee); - self - } -} - -impl fmt::Display for Filter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(state) = &self.state { - write!(f, "is:{state}")?; - f.write_char(' ')?; - } - if self.assigned { - f.write_str("is:assigned")?; - f.write_char(' ')?; - } - if !self.assignees.is_empty() { - f.write_str("assignees:")?; - f.write_char('[')?; - - let mut assignees = self.assignees.iter().peekable(); - while let Some(assignee) = assignees.next() { - f.write_str(&assignee.encode())?; - - if assignees.peek().is_some() { - f.write_char(',')?; - } - } - f.write_char(']')?; - } - - Ok(()) - } -} - pub fn all(profile: &Profile, repository: &Repository) -> Result> { let cache = profile.issues(repository)?; let issues = cache.list()?; Ok(issues.flatten().collect()) } - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use anyhow::Result; - use radicle::issue; - - use super::*; - - #[test] - fn issue_filter_display_with_state_should_succeed() -> Result<()> { - let actual = Filter::default().with_state(Some(issue::State::Open)); - - assert_eq!(String::from("is:open "), actual.to_string()); - - Ok(()) - } - - #[test] - fn issue_filter_display_with_state_and_assigned_should_succeed() -> Result<()> { - let actual = Filter::default() - .with_state(Some(issue::State::Open)) - .with_assgined(true); - - assert_eq!(String::from("is:open is:assigned "), actual.to_string()); - - Ok(()) - } - - #[test] - fn issue_filter_display_with_status_and_author_should_succeed() -> Result<()> { - let actual = Filter::default() - .with_state(Some(issue::State::Open)) - .with_assginee(Did::from_str( - "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V", - )?); - - assert_eq!( - String::from( - "is:open assignees:[did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]" - ), - actual.to_string() - ); - - Ok(()) - } -} diff --git a/bin/commands/issue.rs b/bin/commands/issue.rs index 1652d779..62ae57df 100644 --- a/bin/commands/issue.rs +++ b/bin/commands/issue.rs @@ -9,7 +9,8 @@ use lazy_static::lazy_static; use radicle::cob::thread::CommentId; use radicle::identity::RepoId; -use radicle::issue::IssueId; +use radicle::issue::{IssueId, State}; +use radicle::prelude::Did; use radicle::{issue, storage, Profile}; use radicle_cli as cli; @@ -18,9 +19,10 @@ use cli::terminal::patch::Message; use cli::terminal::Context; use cli::terminal::{Args, Error, Help}; -use crate::cob; use crate::commands::tui_issue::list::IssueOperation; use crate::terminal; +use crate::ui::items::filter::DidFilter; +use crate::ui::items::issue::filter::IssueFilter; use crate::ui::TerminalInfo; lazy_static! { @@ -68,9 +70,67 @@ pub enum OperationName { Unknown, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ListFilter { + state: Option, + assigned: bool, + assignees: Vec, +} + +impl Default for ListFilter { + fn default() -> Self { + Self { + state: Some(State::default()), + assigned: false, + assignees: vec![], + } + } +} + +impl ListFilter { + pub fn with_state(mut self, state: Option) -> Self { + self.state = state; + self + } + + pub fn with_assgined(mut self, assigned: bool) -> Self { + self.assigned = assigned; + self + } + + pub fn with_assginee(mut self, assignee: Did) -> Self { + self.assignees.push(assignee); + self + } +} + +#[allow(clippy::from_over_into)] +impl Into for (Did, ListFilter) { + fn into(self) -> IssueFilter { + let (me, mut filter) = self; + let mut and = filter + .state + .map(|s| vec![IssueFilter::State(s)]) + .unwrap_or(vec![]); + + let mut assignees = filter.assigned.then_some(vec![me]).unwrap_or_default(); + assignees.append(&mut filter.assignees); + + if assignees.len() == 1 { + and.push(IssueFilter::Assignee(DidFilter::Single( + *assignees.first().unwrap(), + ))); + } else if assignees.len() > 1 { + and.push(IssueFilter::Assignee(DidFilter::Or(assignees))); + } + + IssueFilter::And(and) + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ListOptions { - filter: cob::issue::Filter, + filter: ListFilter, json: bool, } @@ -205,13 +265,14 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> { loop { let profile = ctx.profile()?; + let me = profile.did(); let rid = options.repo.unwrap_or(rid); let repository = profile.storage.repository(rid)?; let context = list::Context { profile, repository, - filter: opts.filter.clone(), + filter: (me, opts.filter.clone()).into(), search: state.search.clone(), issue: state.issue_id, comment: state.comment_id, diff --git a/bin/commands/issue/list.rs b/bin/commands/issue/list.rs index 120a56d3..22e93b85 100644 --- a/bin/commands/issue/list.rs +++ b/bin/commands/issue/list.rs @@ -33,7 +33,8 @@ use tui::{Channel, Exit}; use crate::cob::issue; use crate::settings::{self, ThemeBundle, ThemeMode}; use crate::ui::items::filter::Filter; -use crate::ui::items::issue::{Issue, IssueFilter}; +use crate::ui::items::issue::filter::IssueFilter; +use crate::ui::items::issue::Issue; use crate::ui::items::HasId; use crate::ui::{format, TerminalInfo}; @@ -98,7 +99,7 @@ const HELP: &str = r#"# Generic keybindings pub struct Context { pub profile: Profile, pub repository: Repository, - pub filter: issue::Filter, + pub filter: IssueFilter, pub search: Option, pub issue: Option, pub comment: Option, @@ -478,7 +479,6 @@ impl store::Update for App { None } Change::Comment { state } => { - log::info!("Change::Comments: {state:?}"); if let Some(item) = &self.state.preview.issue { self.state.preview.selected_comments.insert( item.id, @@ -781,23 +781,21 @@ impl App { let closed = solved + other; let filtered_counts = format!(" {}/{} ", filtered.len(), issues.len()); - let mut columns = vec![ - Column::new( - Span::raw(" Issue ".to_string()).cyan().dim().reversed(), - Constraint::Length(7), - ), - Column::new( - Span::raw(format!(" {search} ")) - .into_left_aligned_line() - .style(ui.theme().bar_on_black_style) - .cyan() - .dim(), - Constraint::Fill(1), - ), - ]; - if filter.state().is_none() { - columns.extend_from_slice(&[ + if !self.state.filter.has_state() { + [ + Column::new( + Span::raw(" Search ".to_string()).cyan().dim().reversed(), + Constraint::Length(8), + ), + Column::new( + Span::raw(format!(" {search} ")) + .into_left_aligned_line() + .style(ui.theme().bar_on_black_style) + .cyan() + .dim(), + Constraint::Fill(1), + ), Column::new( Span::raw(" ● ") .into_right_aligned_line() @@ -812,6 +810,12 @@ impl App { .into_right_aligned_line(), Constraint::Length(open.to_string().chars().count() as u16), ), + Column::new( + Span::from(" ") + .style(ui.theme().bar_on_black_style) + .into_right_aligned_line(), + Constraint::Length(1), + ), Column::new( Span::raw(" ● ") .style(ui.theme().bar_on_black_style) @@ -832,18 +836,41 @@ impl App { .into_right_aligned_line(), Constraint::Length(1), ), - ]); + Column::new( + Span::raw(filtered_counts.clone()) + .into_right_aligned_line() + .cyan() + .dim() + .reversed(), + Constraint::Length(filtered_counts.chars().count() as u16), + ), + ] + .to_vec() + } else { + [ + Column::new( + Span::raw(" Search ".to_string()).cyan().dim().reversed(), + Constraint::Length(8), + ), + Column::new( + Span::raw(format!(" {search} ")) + .into_left_aligned_line() + .style(ui.theme().bar_on_black_style) + .cyan() + .dim(), + Constraint::Fill(1), + ), + Column::new( + Span::raw(filtered_counts.clone()) + .into_right_aligned_line() + .cyan() + .dim() + .reversed(), + Constraint::Length(filtered_counts.chars().count() as u16), + ), + ] + .to_vec() } - - columns.extend_from_slice(&[Column::new( - Span::raw(filtered_counts.clone()) - .into_right_aligned_line() - .cyan() - .dim() - .reversed(), - Constraint::Length(filtered_counts.chars().count() as u16), - )]); - columns }; ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None)); diff --git a/bin/ui/items.rs b/bin/ui/items.rs index 56f0d6b9..57abc6b8 100644 --- a/bin/ui/items.rs +++ b/bin/ui/items.rs @@ -250,39 +250,3 @@ impl ToTree for CommentItem { vec![item] } } - -#[cfg(test)] -mod tests { - use anyhow::Result; - use std::str::FromStr; - - use radicle::issue::State; - - use crate::ui::items::issue::IssueFilter; - - use super::*; - - #[test] - fn issue_item_filter_from_str_should_succeed() -> Result<()> { - let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#; - let actual = IssueFilter::from_str(search)?; - - let expected = IssueFilter { - state: Some(State::Open), - authors: vec![Did::from_str( - "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx", - )?], - authored: true, - assigned: true, - assignees: vec![ - Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, - Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, - ], - search: Some("cli".to_string()), - }; - - assert_eq!(expected, actual); - - Ok(()) - } -} diff --git a/bin/ui/items/issue.rs b/bin/ui/items/issue.rs index ae455b63..33a3158e 100644 --- a/bin/ui/items/issue.rs +++ b/bin/ui/items/issue.rs @@ -1,15 +1,9 @@ use std::fmt::Debug; -use std::str::FromStr; - -use nom::bytes::complete::{tag, take}; -use nom::multi::separated_list0; -use nom::sequence::{delimited, preceded}; -use nom::{IResult, Parser}; use radicle::cob::thread::CommentId; use radicle::cob::{Label, ObjectId, Timestamp}; -use radicle::issue::{CloseReason, IssueId}; -use radicle::prelude::Did; +use radicle::issue::IssueId; + use radicle::Profile; use ratatui::style::{Style, Stylize}; @@ -21,7 +15,6 @@ use tui::ui::span; use tui::ui::ToRow; use crate::ui::format; -use crate::ui::items::filter::Filter; use crate::ui::items::{AuthorItem, CommentItem, HasId}; #[derive(Clone, Debug)] @@ -149,168 +142,306 @@ impl HasId for Issue { } } -#[derive(Clone, Default, Debug, Eq, PartialEq)] -pub(crate) struct IssueFilter { - pub(crate) state: Option, - pub(crate) authored: bool, - pub(crate) authors: Vec, - pub(crate) assigned: bool, - pub(crate) assignees: Vec, - pub(crate) search: Option, -} +pub mod filter { + use std::fmt; + use std::fmt::Debug; + use std::fmt::Write as _; + use std::str::FromStr; -impl IssueFilter { - pub fn state(&self) -> Option { - self.state + use nom::branch::alt; + use nom::bytes::complete::{tag_no_case, take_while1}; + use nom::character::complete::multispace0; + use nom::combinator::{map, value}; + use nom::multi::many0; + use nom::sequence::preceded; + use nom::IResult; + + use radicle::issue::CloseReason; + use radicle::issue::State; + + use crate::ui::items::filter; + use crate::ui::items::filter::DidFilter; + use crate::ui::items::filter::Filter; + + use super::Issue; + + #[derive(Clone, Debug, Eq, PartialEq)] + pub enum IssueFilter { + State(State), + Author(DidFilter), + Assignee(DidFilter), + Search(String), + And(Vec), + Empty, + Invalid, } -} -impl Filter for IssueFilter { - fn matches(&self, issue: &Issue) -> bool { - use fuzzy_matcher::skim::SkimMatcherV2; - use fuzzy_matcher::FuzzyMatcher; - use radicle::issue::State; - - let matcher = SkimMatcherV2::default(); - - let matches_state = match self.state { - Some(State::Closed { - reason: CloseReason::Other, - }) => matches!(issue.state, State::Closed { .. }), - Some(state) => issue.state == state, - None => true, - }; - - let matches_authored = if self.authored { - issue.author.you - } else { - true - }; - - let matches_authors = if !self.authors.is_empty() { - { - self.authors - .iter() - .any(|other| issue.author.nid == Some(**other)) - } - } else { - true - }; - - let matches_assigned = if self.assigned { - issue.assignees.iter().any(|assignee| assignee.you) - } else { - true - }; - - let matches_assignees = if !self.assignees.is_empty() { - { - self.assignees.iter().any(|other| { - issue - .assignees - .iter() - .filter_map(|author| author.nid) - .collect::>() - .contains(other) - }) - } - } else { - true - }; - - let matches_search = match &self.search { - Some(search) => match matcher.fuzzy_match(&issue.title, search) { - Some(score) => score == 0 || score > 60, - _ => false, - }, - None => true, - }; - - matches_state - && matches_authored - && matches_authors - && matches_assigned - && matches_assignees - && matches_search + impl Default for IssueFilter { + fn default() -> Self { + IssueFilter::State(State::Open) + } } -} -impl FromStr for IssueFilter { - type Err = anyhow::Error; - - fn from_str(value: &str) -> Result { - use radicle::issue::State; - - let mut state = None; - let mut search = String::new(); - let mut authored = false; - let mut authors = vec![]; - let mut assigned = false; - let mut assignees = vec![]; - - let mut authors_parser = |input| -> IResult<&str, Vec<&str>> { - preceded( - tag("authors:"), - delimited( - tag("["), - separated_list0(tag(","), take(56_usize)), - tag("]"), - ), - ) - .parse(input) - }; - - let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> { - preceded( - tag("assignees:"), - delimited( - tag("["), - separated_list0(tag(","), take(56_usize)), - tag("]"), - ), - ) - .parse(input) - }; - - let parts = value.split(' '); - for part in parts { - match part { - "is:open" => state = Some(State::Open), - "is:closed" => { - state = Some(State::Closed { - reason: CloseReason::Other, - }) - } - "is:solved" => { - state = Some(State::Closed { - reason: CloseReason::Solved, - }) - } - "is:authored" => authored = true, - "is:assigned" => assigned = true, - other => { - if let Ok((_, dids)) = assignees_parser.parse(other) { - for did in dids { - assignees.push(Did::from_str(did)?); - } - } else if let Ok((_, dids)) = authors_parser.parse(other) { - for did in dids { - authors.push(Did::from_str(did)?); - } - } else { - search.push_str(other); - } - } - } + impl IssueFilter { + pub fn is_default(&self) -> bool { + *self == IssueFilter::default() } - Ok(Self { - state, - authored, - authors, - assigned, - assignees, - search: Some(search), - }) + pub fn has_state(&self) -> bool { + match self { + IssueFilter::State(_) => true, + IssueFilter::And(filters) => { + filters.iter().any(|f| matches!(f, IssueFilter::State(_))) + } + _ => false, + } + } + } + + impl fmt::Display for IssueFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IssueFilter::State(state) => { + let state = match state { + State::Open => "open", + State::Closed { reason } => match reason { + CloseReason::Solved => "solved", + CloseReason::Other => "closed", + }, + }; + write!(f, "state={state}")?; + f.write_char(' ')?; + } + IssueFilter::Author(filter) => { + write!(f, "author={filter}")?; + f.write_char(' ')?; + } + IssueFilter::Assignee(filter) => { + write!(f, "assignee={filter}")?; + f.write_char(' ')?; + } + IssueFilter::Search(search) => { + write!(f, "{search}")?; + f.write_char(' ')?; + } + IssueFilter::And(filters) => { + let mut it = filters.iter().peekable(); + while let Some(filter) = it.next() { + write!(f, "{filter}")?; + if it.peek().is_none() { + f.write_char(' ')?; + } + } + } + IssueFilter::Empty | IssueFilter::Invalid => {} + } + + Ok(()) + } + } + + impl Filter for IssueFilter { + fn matches(&self, issue: &Issue) -> bool { + use fuzzy_matcher::skim::SkimMatcherV2; + use fuzzy_matcher::FuzzyMatcher; + + let matcher = SkimMatcherV2::default(); + + match self { + IssueFilter::State(state) => issue.state == *state, + IssueFilter::Author(author_filter) => match author_filter { + DidFilter::Single(author) => issue.author.nid == Some(**author), + DidFilter::Or(authors) => authors + .iter() + .any(|other| issue.author.nid == Some(**other)), + }, + IssueFilter::Assignee(assignee_filter) => match assignee_filter { + DidFilter::Single(assignee) => issue + .assignees + .iter() + .any(|other| other.nid == Some(**assignee)), + DidFilter::Or(assignees) => issue.assignees.iter().any(|other| { + assignees + .iter() + .any(|assignee| other.nid == Some(**assignee)) + }), + }, + IssueFilter::Search(search) => { + match matcher.fuzzy_match( + &format!( + "{} {} {}", + &issue.id.to_string(), + &issue.title, + &issue + .author + .alias + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + ), + search, + ) { + Some(score) => score == 0 || score > filter::FUZZY_MIN_SCORE, + _ => false, + } + } + IssueFilter::And(filters) => filters.iter().all(|f| f.matches(issue)), + IssueFilter::Empty => true, + IssueFilter::Invalid => false, + } + } + } + + impl FromStr for IssueFilter { + type Err = anyhow::Error; + + fn from_str(filter_exp: &str) -> Result { + use nom::Parser; + + fn parse_state(input: &str) -> IResult<&str, State> { + alt(( + value(State::Open, tag_no_case("open")), + value( + State::Closed { + reason: radicle::issue::CloseReason::Other, + }, + tag_no_case("closed"), + ), + value( + State::Closed { + reason: radicle::issue::CloseReason::Solved, + }, + tag_no_case("solved"), + ), + )) + .parse(input) + } + + fn parse_state_filter(input: &str) -> IResult<&str, IssueFilter> { + map( + preceded( + ( + tag_no_case("state"), + multispace0, + tag_no_case("="), + multispace0, + ), + parse_state, + ), + IssueFilter::State, + ) + .parse(input) + } + + fn parse_assignee_filter(input: &str) -> IResult<&str, IssueFilter> { + map( + preceded( + ( + tag_no_case("assignee"), + multispace0, + tag_no_case("="), + multispace0, + ), + alt((filter::parse_did_single, filter::parse_did_or)), + ), + IssueFilter::Assignee, + ) + .parse(input) + } + + fn parse_author_filter(input: &str) -> IResult<&str, IssueFilter> { + map( + preceded( + ( + tag_no_case("author"), + multispace0, + tag_no_case("="), + multispace0, + ), + alt((filter::parse_did_single, filter::parse_did_or)), + ), + IssueFilter::Author, + ) + .parse(input) + } + + fn parse_search_filter(input: &str) -> IResult<&str, IssueFilter> { + map( + take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'), + |s: &str| IssueFilter::Search(s.to_string()), + ) + .parse(input) + } + + fn parse_single_filter(input: &str) -> IResult<&str, IssueFilter> { + alt(( + parse_state_filter, + parse_assignee_filter, + parse_author_filter, + parse_search_filter, + )) + .parse(input) + } + + fn parse_filters(input: &str) -> IResult<&str, Vec> { + many0(preceded(multispace0, parse_single_filter)).parse(input) + } + + let parse_filter_expression = |input: &str| -> Result { + match parse_filters(input) { + Ok((remaining, filters)) => { + let remaining = remaining.trim(); + if !remaining.is_empty() { + return Err(format!("Unparsed input remaining: '{remaining}'")); + } + + if filters.is_empty() { + return Ok(IssueFilter::Empty); + } + + if filters.len() == 1 { + Ok(filters.into_iter().next().unwrap()) + } else { + Ok(IssueFilter::And(filters)) + } + } + Err(e) => Err(format!("Parse error: {e}")), + } + }; + + parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err)) + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use std::str::FromStr; + + use radicle::{issue::State, prelude::Did}; + + use crate::ui::items::{filter::DidFilter, issue::filter::IssueFilter}; + + #[test] + fn issue_item_filter_from_str_should_succeed() -> Result<()> { + let search = r#"state=open assignee=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) author=did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx cli"#; + let actual = IssueFilter::from_str(search)?; + + let expected = IssueFilter::And(vec![ + IssueFilter::State(State::Open), + IssueFilter::Assignee(DidFilter::Or(vec![ + Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, + Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, + ])), + IssueFilter::Author(DidFilter::Single(Did::from_str( + "did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx", + )?)), + IssueFilter::Search("cli".to_string()), + ]); + + assert_eq!(expected, actual); + + Ok(()) } }