diff --git a/Cargo.toml b/Cargo.toml index 4d2d2c5..d1fa065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,7 @@ edition = "2024" [features] default = [] log = ["dep:log", "dep:simple_logger"] - -[dev-dependencies] -pretty_assertions = "1.4.1" +# tui = ["dep:crossterm", "dep:futures", "dep:ratatui", "dep:tokio", "dep:color-eyre"] [dependencies] anyhow = "1.0.95" @@ -21,3 +19,16 @@ simple_logger = { version = "5.0.0", optional = true } tempfile = "3.20.0" thiserror = "2.0.11" toml = "0.8.19" + +crossterm = { version = "0.28.1", features = ["event-stream"] } +futures = { version = "0.3.31" } +ratatui = { version = "0.29.0" } +tokio = { version = "1.40.0", features = ["full"] } +color-eyre = { version = "0.6.3" } + + +#crossterm = { version = "0.28.1", features = ["event-stream"], optional = true } +#futures = { version = "0.3.31", optional = true } +#ratatui = { version = "0.29.0", optional = true } +#tokio = { version = "1.40.0", features = ["full"], optional = true } +#color-eyre = { version = "0.6.3", optional = true } diff --git a/Todo.md b/Todo.md new file mode 100644 index 0000000..9594c71 --- /dev/null +++ b/Todo.md @@ -0,0 +1,19 @@ +# To do + +* migrate this todo list into entomologist + +* implement user control over state transitions + +* implement `ent comment ${ISSUE} [-m ${MESSAGE}]` + - each issue dir has a `comments` subdir + - each comment is identified by a sha1-style uid + - each comment is a file or directory under the `${ISSUE}/comments` + - comments are ordered by ctime? + +* implement `ent edit ${COMMENT}` + +* implement `ent attach ${ISSUE} ${FILE}` + - each issue has its own independent namespace for attached files + - issue description & comments can reference attached files via standard md links + +* write a manpage diff --git a/install.sh b/install.sh index ba8faf0..4224e80 100755 --- a/install.sh +++ b/install.sh @@ -2,9 +2,10 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) BINFILE="${SCRIPT_DIR}/target/release/ent" +ENTUI_BINFILE="${SCRIPT_DIR}/target/release/entui" INSTALL_DIR="/usr/local/bin" cargo build --release -echo "copying ent to ${INSTALL_DIR}" -sudo cp $BINFILE $INSTALL_DIR +echo "copying ent + entui to ${INSTALL_DIR}" +sudo cp $BINFILE $ENTUI_BINFILE $INSTALL_DIR echo "ent installed to ${INSTALL_DIR}" diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index e071233..b211383 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -24,31 +24,22 @@ struct Args { enum Commands { /// List issues. List { - /// Filter strings, describes issues to include in the list. - /// Each filter string is of the form "name=condition". - /// The supported names and their matching conditions are: + /// Filter string, describes issues to include in the list. + /// The filter string is composed of chunks separated by ":". + /// Each chunk is of the form "name=condition". The supported + /// names and their matching conditions are: /// /// "state": Comma-separated list of states to list. - /// Example: "state=new,backlog". Defaults to - /// "new,backlog,blocked,inprogress". /// - /// "assignee": Comma-separated list of assignees to include in - /// the list. The empty string includes issues with no assignee. - /// Example: "assignee=seb," lists issues assigned to "seb" and - /// issues without an assignee. Defaults to include all issues. + /// "assignee": Comma-separated list of assignees to list. + /// Defaults to all assignees if not set. /// - /// "tag": Comma-separated list of tags to include, or exclude - /// if prefixed with "-". Example: "tag=bug,-docs" shows issues - /// that are tagged "bug" and not tagged "docs". Defaults to - /// including all tags and excluding none. + /// "tag": Comma-separated list of tags to include or exclude + /// (if prefixed with "-"). If omitted, defaults to including + /// all tags and excluding none. /// - /// "done-time": Time range of issue completion, in the form - /// "[START]..[END]". Includes issues that were marked Done - /// between START and END. START and END are both in RFC 3339 - /// format, e.g. "YYYY-MM-DDTHH:MM:SS[+-]HH:MM". If START - /// is omitted, defaults to the beginning of time. If END is - /// omitted, defaults to the end of time. - filter: Vec, + #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] + filter: String, }, /// Create a new issue. @@ -93,18 +84,6 @@ enum Commands { #[arg(allow_hyphen_values = true)] tag: Option, }, - - /// Get or set the `done_time` of the Issue. - DoneTime { - issue_id: String, - done_time: Option, - }, - - /// get or add a dependency to the issue - Depend { - issue_id: String, - dependency_id: Option, - }, } fn handle_command( @@ -114,14 +93,7 @@ fn handle_command( match &args.command { Commands::List { filter } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - let filter = { - let mut f = entomologist::Filter::new(); - for filter_str in filter { - f.parse(filter_str)?; - } - f - }; - + let filter = entomologist::Filter::new_from_str(filter)?; let mut uuids_by_state = std::collections::HashMap::< entomologist::issue::State, Vec<&entomologist::issue::IssueHandle>, @@ -151,19 +123,6 @@ fn handle_command( } } - if let Some(issue_done_time) = issue.done_time { - if let Some(start_done_time) = filter.start_done_time { - if start_done_time > issue_done_time { - continue; - } - } - if let Some(end_done_time) = filter.end_done_time { - if end_done_time < issue_done_time { - continue; - } - } - } - // This issue passed all the filters, include it in list. uuids_by_state .entry(issue.state.clone()) @@ -187,7 +146,7 @@ fn handle_command( these_uuids.sort_by(|a_id, b_id| { let a = issues.issues.get(*a_id).unwrap(); let b = issues.issues.get(*b_id).unwrap(); - a.creation_time.cmp(&b.creation_time) + a.timestamp.cmp(&b.timestamp) }); println!("{:?}:", state); for uuid in these_uuids { @@ -232,10 +191,8 @@ fn handle_command( } Commands::New { description } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; + let issues_database = + entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; match entomologist::issue::Issue::new(&issues_database.dir, description) { Err(entomologist::issue::IssueError::EmptyDescription) => { println!("no new issue created"); @@ -246,17 +203,14 @@ fn handle_command( } Ok(issue) => { println!("created new issue '{}'", issue.title()); - println!("ID: {}", issue.id); return Ok(()); } } } Commands::Edit { uuid } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; + let issues_database = + entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; if let Some(issue) = issues.get_mut_issue(uuid) { match issue.edit_description() { @@ -291,40 +245,32 @@ fn handle_command( Commands::Show { issue_id } => { let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(issue) = issues.get_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - println!("issue {}", issue_id); - println!("author: {}", issue.author); - if issue.tags.len() > 0 { - print!("tags: "); - let mut separator = ""; - for tag in &issue.tags { - print!("{}{}", separator, tag); - separator = ", "; + match issues.get_issue(issue_id) { + Some(issue) => { + println!("issue {}", issue_id); + println!("author: {}", issue.author); + println!("timestamp: {}", issue.timestamp); + println!("state: {:?}", issue.state); + if let Some(dependencies) = &issue.dependencies { + println!("dependencies: {:?}", dependencies); + } + if let Some(assignee) = &issue.assignee { + println!("assignee: {}", assignee); + } + println!(""); + println!("{}", issue.description); + for comment in &issue.comments { + println!(""); + println!("comment: {}", comment.uuid); + println!("author: {}", comment.author); + println!("timestamp: {}", comment.timestamp); + println!(""); + println!("{}", comment.description); + } + } + None => { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); } - println!(""); - } - println!("creation_time: {}", issue.creation_time); - if let Some(done_time) = &issue.done_time { - println!("done_time: {}", done_time); - } - println!("state: {:?}", issue.state); - if let Some(dependencies) = &issue.dependencies { - println!("dependencies: {:?}", dependencies); - } - if let Some(assignee) = &issue.assignee { - println!("assignee: {}", assignee); - } - println!(""); - println!("{}", issue.description); - for comment in &issue.comments { - println!(""); - println!("comment: {}", comment.uuid); - println!("author: {}", comment.author); - println!("creation_time: {}", comment.creation_time); - println!(""); - println!("{}", comment.description); } } @@ -333,10 +279,8 @@ fn handle_command( new_state, } => match new_state { Some(new_state) => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; + let issues_database = + entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; match issues.issues.get_mut(issue_id) { Some(issue) => { @@ -368,10 +312,8 @@ fn handle_command( issue_id, description, } => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; + let issues_database = + entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); @@ -396,13 +338,9 @@ fn handle_command( } Commands::Sync { remote } => { - if let entomologist::database::IssuesDatabaseSource::Branch(branch) = - issues_database_source - { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; + if let entomologist::database::IssuesDatabaseSource::Branch(branch) = issues_database_source { + let issues_database = + entomologist::database::make_issues_database(issues_database_source, entomologist::database::IssuesDatabaseAccess::ReadWrite)?; entomologist::git::sync(&issues_database.dir, remote, branch)?; println!("synced {:?} with {:?}", branch, remote); } else { @@ -415,151 +353,80 @@ fn handle_command( Commands::Assign { issue_id, new_assignee, - } => match new_assignee { - Some(new_assignee) => { - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.get_mut_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - let old_assignee: String = match &issue.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - issue.set_assignee(new_assignee)?; - println!("issue: {}", issue_id); - println!("assignee: {} -> {}", old_assignee, new_assignee); + } => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + let Some(original_issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + let old_assignee: String = match &original_issue.assignee { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; + println!("issue: {}", issue_id); + match new_assignee { + Some(new_assignee) => { + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = + entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + println!("assignee: {} -> {}", old_assignee, new_assignee); + issue.set_assignee(new_assignee)?; + } + None => { + println!("assignee: {}", old_assignee); + } } - None => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(original_issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - let old_assignee: String = match &original_issue.assignee { - Some(assignee) => assignee.clone(), - None => String::from("None"), - }; - println!("issue: {}", issue_id); - println!("assignee: {}", old_assignee); - } - }, + } - Commands::Tag { issue_id, tag } => match tag { - Some(tag) => { - // Add or remove tag. - if tag.len() == 0 { - return Err(anyhow::anyhow!("invalid zero-length tag")); + Commands::Tag { issue_id, tag } => { + let issues = entomologist::database::read_issues_database(issues_database_source)?; + let Some(issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match tag { + Some(tag) => { + // Add or remove tag. + let issues_database = entomologist::database::make_issues_database( + issues_database_source, + entomologist::database::IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = + entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + if tag.len() == 0 { + return Err(anyhow::anyhow!("invalid zero-length tag")); + } + if tag.chars().nth(0).unwrap() == '-' { + let tag = &tag[1..]; + issue.remove_tag(tag)?; + } else { + issue.add_tag(tag)?; + } } - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.get_mut_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - if tag.chars().nth(0).unwrap() == '-' { - let tag = &tag[1..]; - issue.remove_tag(tag)?; - } else { - issue.add_tag(tag)?; - } - } - None => { - // Just list the tags. - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - match &issue.tags.len() { - 0 => println!("no tags"), - _ => { - // Could use `format!(" {:?}", issue.tags)` - // here, but that results in `["tag1", "TAG2", - // "i-am-also-a-tag"]` and i don't want the - // double-quotes around each tag. - for tag in &issue.tags { - println!("{}", tag); + None => { + // Just list the tags. + match &issue.tags.len() { + 0 => println!("no tags"), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + for tag in &issue.tags { + println!("{}", tag); + } } } } } - }, - - Commands::DoneTime { - issue_id, - done_time, - } => match done_time { - Some(done_time) => { - // Add or remove tag. - let issues_database = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.get_mut_issue(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - let done_time = match chrono::DateTime::parse_from_rfc3339(done_time) { - Ok(done_time) => done_time.with_timezone(&chrono::Local), - Err(e) => { - eprintln!("failed to parse done-time from {}", done_time); - return Err(e.into()); - } - }; - issue.set_done_time(done_time)?; - } - None => { - let issues = entomologist::database::read_issues_database(issues_database_source)?; - let Some(issue) = issues.issues.get(issue_id) else { - return Err(anyhow::anyhow!("issue {} not found", issue_id)); - }; - match &issue.done_time { - Some(done_time) => println!("done_time: {}", done_time), - None => println!("None"), - }; - } - }, - - Commands::Depend { - issue_id, - dependency_id, - } => match dependency_id { - Some(dep_id) => { - let ent_db = entomologist::database::make_issues_database( - issues_database_source, - entomologist::database::IssuesDatabaseAccess::ReadWrite, - )?; - let mut issues = entomologist::issues::Issues::new_from_dir(&ent_db.dir)?; - if issues.issues.contains_key(dep_id) { - if let Some(issue) = issues.issues.get_mut(issue_id) { - issue.add_dependency(dep_id.clone())?; - } else { - Err(anyhow::anyhow!("issue {} not found", issue_id))?; - }; - } else { - Err(anyhow::anyhow!("dependency {} not found", dep_id))?; - }; - } - None => { - let ent_db = entomologist::database::read_issues_database(issues_database_source)?; - - let Some(issue) = ent_db.issues.get(issue_id) else { - Err(anyhow::anyhow!("issue {} not found", issue_id))? - }; - println!("DEPENDENCIES:"); - if let Some(list) = &issue.dependencies { - for dependency in list { - println!("{}", dependency); - } - } else { - println!("NONE"); - } - } - }, + } } Ok(()) @@ -573,15 +440,13 @@ fn main() -> anyhow::Result<()> { // println!("{:?}", args); let issues_database_source = match (&args.issues_dir, &args.issues_branch) { - (Some(dir), None) => { - entomologist::database::IssuesDatabaseSource::Dir(std::path::Path::new(dir)) - } + (Some(dir), None) => entomologist::database::IssuesDatabaseSource::Dir(std::path::Path::new(dir)), (None, Some(branch)) => entomologist::database::IssuesDatabaseSource::Branch(branch), (None, None) => entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"), (Some(_), Some(_)) => { return Err(anyhow::anyhow!( "don't specify both `--issues-dir` and `--issues-branch`" - )); + )) } }; diff --git a/src/bin/entui/app.rs b/src/bin/entui/app.rs new file mode 100644 index 0000000..3617e7a --- /dev/null +++ b/src/bin/entui/app.rs @@ -0,0 +1,99 @@ +use crate::event::{AppEvent, Event, EventHandler}; +use crate::components::entomologist::IssuesList; +use ratatui::{ + DefaultTerminal, + crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, +}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + EntError(#[from] crate::components::entomologist::Error) +} + +/// Application. +#[derive(Debug)] +pub struct App { + /// Is the application running? + pub running: bool, + /// Event handler. + pub events: EventHandler, + + pub issues_list: IssuesList, +} + +impl Default for App { + fn default() -> Self { + Self { + running: true, + events: EventHandler::new(), + // TODO: .unwrap() as laziness + issues_list: IssuesList::new().unwrap(), + } + } +} + +impl App { + /// Constructs a new instance of [`App`]. + pub fn new() -> Result { + Ok(Self { + running: true, + events: EventHandler::new(), + // TODO: .unwrap() as laziness + issues_list: IssuesList::new()?, + }) + } + + /// Run the application's main loop. + pub async fn run(mut self, mut terminal: DefaultTerminal) -> color_eyre::Result<()> { + while self.running { + terminal.draw(|frame| frame.render_widget(&self, frame.area()))?; + match self.events.next().await? { + Event::Tick => self.tick(), + Event::Crossterm(event) => match event { + crossterm::event::Event::Key(key_event) => self.handle_key_events(key_event)?, + _ => {} + }, + Event::App(app_event) => match app_event { + AppEvent::Quit => self.quit(), + }, + } + } + Ok(()) + } + + /// Handles the key events and updates the state of [`App`]. + pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + match key_event.code { + KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit), + KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => { + self.events.send(AppEvent::Quit) + } + KeyCode::Down => { + self.issues_list.select_next(); + } + KeyCode::Up => { + self.issues_list.select_previous(); + } + KeyCode::Enter => { + // TODO: view issue here + } + // Other handlers you could add here. + _ => {} + } + Ok(()) + } + + /// Handles the tick event of the terminal. + /// + /// The tick event is where you can update the state of your application with any logic that + /// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation. + pub fn tick(&self) {} + + /// Set running to false to quit the application. + pub fn quit(&mut self) { + self.running = false; + } +} diff --git a/src/bin/entui/components/entomologist/mod.rs b/src/bin/entui/components/entomologist/mod.rs new file mode 100644 index 0000000..76601dd --- /dev/null +++ b/src/bin/entui/components/entomologist/mod.rs @@ -0,0 +1,65 @@ +pub mod ui; + +use core::cell::RefCell; +use ratatui::widgets::ListState; +use thiserror::Error; +use entomologist::{issue::{Issue, IssueHandle, State}, issues::Issues}; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + EntIssuesError(#[from] entomologist::issues::ReadIssuesError), + #[error(transparent)] + EntDbError(#[from] entomologist::database::Error), + +} + +#[derive(Debug, Clone)] +pub struct Entry { + title: String, + id: IssueHandle, + state: String, + assignee: Option, + description: String, +} + +impl Entry { + pub fn new_from_id_issue(id: &IssueHandle, issue: &Issue) -> Self { + Entry { + title: String::from(issue.title()), + id: id.clone(), + state: issue.state.to_string(), + assignee: issue.assignee.clone(), + description: issue.description.clone(), + } + } +} + + +#[derive(Debug)] +pub struct IssuesList { + issues: Issues, + // safety: this is only accessed from the UI thread + list_state: RefCell, + selected_issue: RefCell>, +} + +impl IssuesList { + pub fn new() -> Result { + let issues_db_source = entomologist::database::IssuesDatabaseSource::Branch("entomologist-data"); + let issues = entomologist::database::read_issues_database(&issues_db_source)?; + Ok(Self { + issues, + list_state: RefCell::new(ListState::default()), + selected_issue: RefCell::new(None), + }) + } + + pub fn select_previous(&self) { + self.list_state.borrow_mut().select_previous(); + } + + pub fn select_next(&self) { + self.list_state.borrow_mut().select_next(); + } +} \ No newline at end of file diff --git a/src/bin/entui/components/entomologist/ui.rs b/src/bin/entui/components/entomologist/ui.rs new file mode 100644 index 0000000..a58b3f0 --- /dev/null +++ b/src/bin/entui/components/entomologist/ui.rs @@ -0,0 +1,81 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + widgets::{Block, BorderType, List, ListDirection, ListItem, Paragraph, StatefulWidget, Widget}, +}; + +use entomologist::issue::Issue; + +use crate::components::entomologist::{Entry, IssuesList}; + +fn generate_list_item<'a>(id: &String, issue: &Issue) -> ListItem<'a> { + let title = issue.title(); + ListItem::new(format!("{title}")) +} + +// have to do this since neither Widget nor Issue were defined in this crate +impl Widget for &Entry { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized { + let block = Block::bordered().title("PREVIEW"); + let text = format!("TITLE: {}\nID: {}\nSTATE: {}", self.title, self.id, self.state); + let text = match &self.assignee { + Some(assignee) => format!("{text}\nASSIGNEE: {}", assignee), + None => format!("{text}\nASSIGNEE: NONE") + }; + let text = format!("{text}\n\nDESCRIPTION:\n{}", self.description); + let pg = Paragraph::new(text).block(block); + + pg.render(area, buf); + } +} + +impl Widget for &IssuesList { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized { + + let issues_list: Vec = self.issues.issues.iter().map(|(id, issue)| Entry::new_from_id_issue(id, issue)).collect(); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Fill(1), Constraint::Length(20)]) + .split(area); + + + // ISSUE LIST + let issue_list_area = layout[0]; + + let issues_list_widget = self.issues.issues.iter().map(|(id, issue)| generate_list_item(id, issue)).collect::() + .block(Block::bordered().title("ISSUES")) + .style(Style::new().white()) + .highlight_style(Style::new().bg(Color::White).fg(Color::Black)) + .direction(ListDirection::TopToBottom); + + // wooooooooof :( + let state = &mut *self.list_state.borrow_mut(); + StatefulWidget::render(issues_list_widget, issue_list_area, buf, state); + + match state.selected() { + Some(index) => self.selected_issue.replace(Some(issues_list[index].clone())), + None => self.selected_issue.replace(None), + }; + + // ISSUE PREVIEW + let preview_area = layout[1]; + match &(*self.selected_issue.borrow()) { + Some(entry) => { + // .unwrap() as this should never fail and i can't handle an error + // inside this trait rn (lazy) + entry.render(preview_area, buf); + } + None => { + let text = "NO ISSUE SELECTED"; + let preview_block = Block::bordered().title("PREVIEW"); + let pg = Paragraph::new(text).block(preview_block).alignment(Alignment::Center); + pg.render(preview_area, buf); + } + } + } +} \ No newline at end of file diff --git a/src/bin/entui/components/mod.rs b/src/bin/entui/components/mod.rs new file mode 100644 index 0000000..146de96 --- /dev/null +++ b/src/bin/entui/components/mod.rs @@ -0,0 +1 @@ +pub mod entomologist; \ No newline at end of file diff --git a/src/bin/entui/event.rs b/src/bin/entui/event.rs new file mode 100644 index 0000000..071200c --- /dev/null +++ b/src/bin/entui/event.rs @@ -0,0 +1,126 @@ +use color_eyre::eyre::OptionExt; +use futures::{FutureExt, StreamExt}; +use ratatui::crossterm::event::Event as CrosstermEvent; +use std::time::Duration; +use tokio::sync::mpsc; + +/// The frequency at which tick events are emitted. +const TICK_FPS: f64 = 30.0; + +/// Representation of all possible events. +#[derive(Clone, Debug)] +pub enum Event { + /// An event that is emitted on a regular schedule. + /// + /// Use this event to run any code which has to run outside of being a direct response to a user + /// event. e.g. polling exernal systems, updating animations, or rendering the UI based on a + /// fixed frame rate. + Tick, + /// Crossterm events. + /// + /// These events are emitted by the terminal. + Crossterm(CrosstermEvent), + /// Application events. + /// + /// Use this event to emit custom events that are specific to your application. + App(AppEvent), +} + +/// Application events. +/// +/// You can extend this enum with your own custom events. +#[derive(Clone, Debug)] +pub enum AppEvent { + /// Quit the application. + Quit, +} + +/// Terminal event handler. +#[derive(Debug)] +pub struct EventHandler { + /// Event sender channel. + sender: mpsc::UnboundedSender, + /// Event receiver channel. + receiver: mpsc::UnboundedReceiver, +} + +impl EventHandler { + /// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events. + pub fn new() -> Self { + let (sender, receiver) = mpsc::unbounded_channel(); + let actor = EventTask::new(sender.clone()); + tokio::spawn(async { actor.run().await }); + Self { sender, receiver } + } + + /// Receives an event from the sender. + /// + /// This function blocks until an event is received. + /// + /// # Errors + /// + /// This function returns an error if the sender channel is disconnected. This can happen if an + /// error occurs in the event thread. In practice, this should not happen unless there is a + /// problem with the underlying terminal. + pub async fn next(&mut self) -> color_eyre::Result { + self.receiver + .recv() + .await + .ok_or_eyre("Failed to receive event") + } + + /// Queue an app event to be sent to the event receiver. + /// + /// This is useful for sending events to the event handler which will be processed by the next + /// iteration of the application's event loop. + pub fn send(&mut self, app_event: AppEvent) { + // Ignore the result as the reciever cannot be dropped while this struct still has a + // reference to it + let _ = self.sender.send(Event::App(app_event)); + } +} + +/// A thread that handles reading crossterm events and emitting tick events on a regular schedule. +struct EventTask { + /// Event sender channel. + sender: mpsc::UnboundedSender, +} + +impl EventTask { + /// Constructs a new instance of [`EventThread`]. + fn new(sender: mpsc::UnboundedSender) -> Self { + Self { sender } + } + + /// Runs the event thread. + /// + /// This function emits tick events at a fixed rate and polls for crossterm events in between. + async fn run(self) -> color_eyre::Result<()> { + let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS); + let mut reader = crossterm::event::EventStream::new(); + let mut tick = tokio::time::interval(tick_rate); + loop { + let tick_delay = tick.tick(); + let crossterm_event = reader.next().fuse(); + tokio::select! { + _ = self.sender.closed() => { + break; + } + _ = tick_delay => { + self.send(Event::Tick); + } + Some(Ok(evt)) = crossterm_event => { + self.send(Event::Crossterm(evt)); + } + }; + } + Ok(()) + } + + /// Sends an event to the receiver. + fn send(&self, event: Event) { + // Ignores the result because shutting down the app drops the receiver, which causes the send + // operation to fail. This is expected behavior and should not panic. + let _ = self.sender.send(event); + } +} diff --git a/src/bin/entui/main.rs b/src/bin/entui/main.rs new file mode 100644 index 0000000..0283e14 --- /dev/null +++ b/src/bin/entui/main.rs @@ -0,0 +1,16 @@ +use crate::app::App; + +pub mod app; +pub mod event; +pub mod ui; +pub mod components; + +#[tokio::main] +async fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + let app = App::new()?; + let terminal = ratatui::init(); + let result = app.run(terminal).await; + ratatui::restore(); + result +} diff --git a/src/bin/entui/ui.rs b/src/bin/entui/ui.rs new file mode 100644 index 0000000..8d572ff --- /dev/null +++ b/src/bin/entui/ui.rs @@ -0,0 +1,24 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect, Layout, Direction, Constraint}, + style::{Color, Stylize}, + widgets::{Block, BorderType, Paragraph, Widget}, +}; + +use crate::app::App; + +impl Widget for &App { + fn render(self, area: Rect, buf: &mut Buffer) { + // LAYOUT + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Percentage(100), + // Constraint::Percentage(50), + ]) + .split(area); + + // BLOCK 0 - ISSUE LIST + self.issues_list.render(layout[0], buf); + } +} diff --git a/src/comment.rs b/src/comment.rs index 1fa2e36..c8e26c9 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -4,7 +4,7 @@ use std::io::{IsTerminal, Write}; pub struct Comment { pub uuid: String, pub author: String, - pub creation_time: chrono::DateTime, + pub timestamp: chrono::DateTime, pub description: String, /// This is the directory that the comment lives in. Only used @@ -48,23 +48,19 @@ impl Comment { } } } - let Some(description) = description else { + if description == None { return Err(CommentError::CommentParseError); - }; - - let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(comment_dir)?; + } + let author = crate::git::git_log_oldest_author(comment_dir)?; + let timestamp = crate::git::git_log_oldest_timestamp(comment_dir)?; let dir = std::path::PathBuf::from(comment_dir); Ok(Self { - uuid: String::from( - dir.file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ), + uuid: String::from(dir.file_name().unwrap().to_string_lossy()), author, - creation_time, - description, + timestamp, + description: description.unwrap(), dir: std::path::PathBuf::from(comment_dir), }) } @@ -88,7 +84,7 @@ impl Comment { let mut comment = crate::comment::Comment { uuid, author: String::from(""), // this will be updated from git when we re-read this comment - creation_time: chrono::Local::now(), + timestamp: chrono::Local::now(), description: String::from(""), // this will be set immediately below dir: dir.clone(), }; @@ -113,11 +109,7 @@ impl Comment { &format!( "add comment {} on issue {}", comment.uuid, - issue - .dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), + issue.dir.file_name().unwrap().to_string_lossy(), ), )?; } @@ -138,15 +130,10 @@ impl Comment { crate::git::add(&description_filename)?; if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { crate::git::commit( - &description_filename - .parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, + &description_filename.parent().unwrap(), &format!( "edit comment {} on issue FIXME", // FIXME: name the issue that the comment is on - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy() + self.dir.file_name().unwrap().to_string_lossy() ), )?; self.read_description()?; @@ -178,8 +165,8 @@ impl Comment { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(CommentError::EditorError); } @@ -208,21 +195,19 @@ impl Comment { #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; #[test] fn read_comment_0() { - let comment_dir = std::path::Path::new( - "test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347", - ); + let comment_dir = + std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); let comment = Comment::new_from_dir(comment_dir).unwrap(); let expected = Comment { uuid: String::from("9055dac36045fe36545bed7ae7b49347"), author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00") .unwrap() .with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\n\nIt has multiple lines\n"), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), }; assert_eq!(comment, expected); diff --git a/src/git.rs b/src/git.rs index 6e70fa8..fe0446a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -48,8 +48,8 @@ impl Worktree { .args(["worktree", "add", &path.path().to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(Self { path }) @@ -67,8 +67,8 @@ impl Worktree { ]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(Self { path }) @@ -87,8 +87,8 @@ pub fn checkout_branch_in_worktree( .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(()) @@ -99,8 +99,8 @@ pub fn git_worktree_prune() -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(()) @@ -111,8 +111,8 @@ pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { .args(["branch", "-D", branch]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(()) @@ -139,14 +139,11 @@ pub fn worktree_is_dirty(dir: &str) -> Result { pub fn add(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["add", &file.to_string_lossy()]) - .current_dir( - file.parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, - ) + .current_dir(file.parent().unwrap()) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } return Ok(()); @@ -155,14 +152,11 @@ pub fn add(file: &std::path::Path) -> Result<(), GitError> { pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["restore", &file.to_string_lossy()]) - .current_dir( - file.parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, - ) + .current_dir(file.parent().unwrap()) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } return Ok(()); @@ -174,8 +168,8 @@ pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(()) @@ -186,18 +180,12 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { git_dir.pop(); let result = std::process::Command::new("git") - .args([ - "add", - &file - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) + .args(["add", &file.file_name().unwrap().to_string_lossy()]) .current_dir(&git_dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -207,20 +195,15 @@ pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { "-m", &format!( "update '{}' in issue {}", - file.file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - git_dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy() + file.file_name().unwrap().to_string_lossy(), + git_dir.file_name().unwrap().to_string_lossy() ), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -233,8 +216,8 @@ pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { .current_dir(dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } Ok(()) @@ -270,13 +253,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes fetched from remote {}:", remote); - println!("{}", &String::from_utf8_lossy(&result.stdout)); + println!("{}", std::str::from_utf8(&result.stdout).unwrap()); println!(""); } @@ -296,13 +279,13 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes to push to remote {}:", remote); - println!("{}", &String::from_utf8_lossy(&result.stdout)); + println!("{}", std::str::from_utf8(&result.stdout).unwrap()); println!(""); } @@ -316,8 +299,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -331,8 +314,8 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", branch ); - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -349,16 +332,13 @@ pub fn git_log_oldest_timestamp( "log", "--pretty=format:%at", "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), + &path.file_name().unwrap().to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); @@ -378,16 +358,13 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result "log", "--pretty=format:%an <%ae>", "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), + &path.file_name().unwrap().to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } let author_str = std::str::from_utf8(&result.stdout).unwrap(); @@ -395,46 +372,6 @@ pub fn git_log_oldest_author(path: &std::path::Path) -> Result Ok(String::from(author_last)) } -pub fn git_log_oldest_author_timestamp( - path: &std::path::Path, -) -> Result<(String, chrono::DateTime), GitError> { - let mut git_dir = std::path::PathBuf::from(path); - git_dir.pop(); - let result = std::process::Command::new("git") - .args([ - "log", - "--pretty=format:%at %an <%ae>", - "--", - &path - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ]) - .current_dir(&git_dir) - .output()?; - if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); - return Err(GitError::Oops); - } - - let raw_output_str = String::from_utf8_lossy(&result.stdout); - let Some(raw_output_last) = raw_output_str.split("\n").last() else { - return Err(GitError::Oops); - }; - let Some(index) = raw_output_last.find(' ') else { - return Err(GitError::Oops); - }; - let author_str = &raw_output_last[index + 1..]; - let timestamp_str = &raw_output_last[0..index]; - let timestamp_i64 = timestamp_str.parse::()?; - let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) - .unwrap() - .with_timezone(&chrono::Local); - - Ok((String::from(author_str), timestamp)) -} - pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { { let tmp_worktree = tempfile::tempdir().unwrap(); @@ -446,8 +383,8 @@ pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { .args(["worktree", "prune"]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -463,8 +400,8 @@ fn create_orphan_branch_at_path( .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -481,8 +418,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -491,8 +428,8 @@ fn create_orphan_branch_at_path( .current_dir(worktree_path) .output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(GitError::Oops); } @@ -502,7 +439,6 @@ fn create_orphan_branch_at_path( #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; #[test] fn test_worktree() { diff --git a/src/issue.rs b/src/issue.rs index 06f959f..1558578 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -20,10 +20,8 @@ pub type IssueHandle = String; #[derive(Debug, PartialEq)] pub struct Issue { - pub id: String, pub author: String, - pub creation_time: chrono::DateTime, - pub done_time: Option>, + pub timestamp: chrono::DateTime, pub tags: Vec, pub state: State, pub dependencies: Option>, @@ -44,8 +42,6 @@ pub enum IssueError { EnvVarError(#[from] std::env::VarError), #[error(transparent)] CommentError(#[from] crate::comment::CommentError), - #[error(transparent)] - ChronoParseError(#[from] chrono::format::ParseError), #[error("Failed to parse issue")] IssueParseError, #[error("Failed to parse state")] @@ -60,14 +56,6 @@ pub enum IssueError { TagNotFound(String), #[error("stdin/stdout is not a terminal")] StdioIsNotTerminal, - #[error("Failed to parse issue ID")] - IdError, - #[error("Dependency not found")] - DepNotFound, - #[error("Dependency already exists")] - DepExists, - #[error("Self-dependency not allowed")] - DepSelf, } impl FromStr for State { @@ -115,7 +103,6 @@ impl Issue { let mut comments = Vec::::new(); let mut assignee: Option = None; let mut tags = Vec::::new(); - let mut done_time: Option> = None; for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -129,13 +116,15 @@ impl Issue { assignee = Some(String::from( std::fs::read_to_string(direntry.path())?.trim(), )); - } else if file_name == "done_time" { - let raw_done_time = chrono::DateTime::<_>::parse_from_rfc3339( - std::fs::read_to_string(direntry.path())?.trim(), - )?; - done_time = Some(raw_done_time.into()); - } else if file_name == "dependencies" && direntry.metadata()?.is_dir() { - dependencies = Self::read_dependencies(&direntry.path())?; + } else if file_name == "dependencies" { + let dep_strings = std::fs::read_to_string(direntry.path())?; + let deps: Vec = dep_strings + .lines() + .map(|dep| IssueHandle::from(dep)) + .collect(); + if deps.len() > 0 { + dependencies = Some(deps); + } } else if file_name == "tags" { let contents = std::fs::read_to_string(direntry.path())?; tags = contents @@ -143,7 +132,6 @@ impl Issue { .filter(|s| s.len() > 0) .map(|tag| String::from(tag.trim())) .collect(); - tags.sort(); } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -153,32 +141,21 @@ impl Issue { } } - let Some(description) = description else { + if description == None { return Err(IssueError::IssueParseError); - }; + } - // parse the issue ID from the directory name - let id = if let Some(parsed_id) = match dir.file_name() { - Some(name) => name.to_str(), - None => Err(IssueError::IdError)?, - } { - String::from(parsed_id) - } else { - Err(IssueError::IdError)? - }; - - let (author, creation_time) = crate::git::git_log_oldest_author_timestamp(dir)?; + let author = crate::git::git_log_oldest_author(dir)?; + let timestamp = crate::git::git_log_oldest_timestamp(dir)?; Ok(Self { - id, author, - creation_time, - done_time, + timestamp, tags, state: state, dependencies, assignee, - description, + description: description.unwrap(), comments, dir: std::path::PathBuf::from(dir), }) @@ -194,30 +171,10 @@ impl Issue { comments.push(comment); } } - comments.sort_by(|a, b| a.creation_time.cmp(&b.creation_time)); + comments.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); Ok(()) } - fn read_dependencies(dir: &std::path::Path) -> Result>, IssueError> { - let mut dependencies: Option> = None; - for direntry in dir.read_dir()? { - if let Ok(direntry) = direntry { - match &mut dependencies { - Some(deps) => { - deps.push(direntry.file_name().into_string().unwrap()); - } - None => { - dependencies = Some(vec![direntry.file_name().into_string().unwrap()]); - } - } - } - } - if let Some(deps) = &mut dependencies { - deps.sort(); - } - Ok(dependencies) - } - /// Add a new Comment to the Issue. Commits. pub fn add_comment( &mut self, @@ -247,10 +204,8 @@ impl Issue { std::fs::create_dir(&issue_dir)?; let mut issue = Self { - id: String::from(&issue_id), author: String::from(""), - creation_time: chrono::Local::now(), - done_time: None, + timestamp: chrono::Local::now(), tags: Vec::::new(), state: State::New, dependencies: None, @@ -273,7 +228,8 @@ impl Issue { None => issue.edit_description_file()?, }; - issue.commit(&format!("create new issue {}", issue_id))?; + crate::git::add(&issue_dir)?; + crate::git::commit(&issue_dir, &format!("create new issue {}", issue_id))?; Ok(issue) } @@ -282,15 +238,21 @@ impl Issue { pub fn edit_description(&mut self) -> Result<(), IssueError> { self.edit_description_file()?; let description_filename = self.description_filename(); - self.commit(&format!( - "edit description of issue {}", - description_filename - .parent() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - ))?; + crate::git::add(&description_filename)?; + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &description_filename.parent().unwrap(), + &format!( + "edit description of issue {}", + description_filename + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + ), + )?; + } Ok(()) } @@ -302,25 +264,24 @@ impl Issue { } } - /// Change the State of the Issue. If the new state is `Done`, - /// set the Issue `done_time`. Commits. + /// Change the State of the Issue. pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { let old_state = self.state.clone(); let mut state_filename = std::path::PathBuf::from(&self.dir); state_filename.push("state"); let mut state_file = std::fs::File::create(&state_filename)?; write!(state_file, "{}", new_state)?; - self.commit(&format!( - "change state of issue {}, {} -> {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - old_state, - new_state, - ))?; - if new_state == State::Done { - self.set_done_time(chrono::Local::now())?; + crate::git::add(&state_filename)?; + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &self.dir, + &format!( + "change state of issue {}, {} -> {}", + self.dir.file_name().unwrap().to_string_lossy(), + old_state, + new_state, + ), + )?; } Ok(()) } @@ -333,27 +294,6 @@ impl Issue { Ok(()) } - /// Set the `done_time` of the Issue. Commits. - pub fn set_done_time( - &mut self, - done_time: chrono::DateTime, - ) -> Result<(), IssueError> { - let mut done_time_filename = std::path::PathBuf::from(&self.dir); - done_time_filename.push("done_time"); - let mut done_time_file = std::fs::File::create(&done_time_filename)?; - write!(done_time_file, "{}", done_time.to_rfc3339())?; - self.done_time = Some(done_time.clone()); - self.commit(&format!( - "set done-time of issue {} to {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - done_time, - ))?; - Ok(()) - } - /// Set the Assignee of an Issue. pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { let old_assignee = match &self.assignee { @@ -364,15 +304,18 @@ impl Issue { assignee_filename.push("assignee"); let mut assignee_file = std::fs::File::create(&assignee_filename)?; write!(assignee_file, "{}", new_assignee)?; - self.commit(&format!( - "change assignee of issue {}, {} -> {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), - old_assignee, - new_assignee, - ))?; + crate::git::add(&assignee_filename)?; + if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { + crate::git::commit( + &self.dir, + &format!( + "change assignee of issue {}, {} -> {}", + self.dir.file_name().unwrap().to_string_lossy(), + old_assignee, + new_assignee, + ), + )?; + } Ok(()) } @@ -386,10 +329,7 @@ impl Issue { self.tags.sort(); self.commit_tags(&format!( "issue {} add tag {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), + self.dir.file_name().unwrap().to_string_lossy(), tag ))?; Ok(()) @@ -404,10 +344,7 @@ impl Issue { self.tags.remove(index); self.commit_tags(&format!( "issue {} remove tag {}", - self.dir - .file_name() - .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_string_lossy(), + self.dir.file_name().unwrap().to_string_lossy(), tag ))?; Ok(()) @@ -426,46 +363,6 @@ impl Issue { } return false; } - - pub fn add_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { - if self.id == dep { - Err(IssueError::DepSelf)?; - } - match &mut self.dependencies { - Some(v) => v.push(dep.clone()), - None => self.dependencies = Some(vec![dep.clone()]), - } - let mut dir = std::path::PathBuf::from(&self.dir); - dir.push("dependencies"); - if !dir.exists() { - std::fs::create_dir(&dir)?; - } - - dir.push(dep.clone()); - - if !dir.exists() { - std::fs::File::create(&dir)?; - self.commit(&format!("add dep {} to issue {}", dep, self.id))?; - } else { - Err(IssueError::DepExists)?; - } - Ok(()) - } - - pub fn remove_dependency(&mut self, dep: IssueHandle) -> Result<(), IssueError> { - match &mut self.dependencies { - Some(v) => { - if let Some(i) = v.iter().position(|d| d == &dep) { - v.remove(i); - } else { - Err(IssueError::DepNotFound)?; - } - } - None => Err(IssueError::DepNotFound)?, - } - self.commit(&format!("remove dep {} from issue {}", dep, self.id))?; - Ok(()) - } } // This is the internal/private API of Issue. @@ -506,8 +403,8 @@ impl Issue { .spawn()? .wait_with_output()?; if !result.status.success() { - println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); - println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(IssueError::EditorError); } if !description_filename.exists() || description_filename.metadata()?.len() == 0 { @@ -532,15 +429,7 @@ impl Issue { for tag in &self.tags { writeln!(tags_file, "{}", tag)?; } - self.commit(commit_message)?; - Ok(()) - } - - fn commit(&self, commit_message: &str) -> Result<(), IssueError> { - crate::git::add(&self.dir)?; - if !crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - return Ok(()); - } + crate::git::add(&tags_filename)?; crate::git::commit(&self.dir, commit_message)?; Ok(()) } @@ -549,30 +438,25 @@ impl Issue { #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; #[test] fn read_issue_0() { - let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476/"); + let issue_dir = std::path::Path::new("test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - id: String::from("3943fc5c173fdf41c0a22251593cd476"), author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::from([ - String::from("TAG2"), - String::from("i-am-also-a-tag"), String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") ]), state: State::New, dependencies: None, assignee: None, - description: String::from( - "this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n", - ), + description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), comments: Vec::::new(), dir: std::path::PathBuf::from(issue_dir), }; @@ -581,15 +465,13 @@ mod tests { #[test] fn read_issue_1() { - let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c14/"); + let issue_dir = std::path::Path::new("test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/"); let issue = Issue::new_from_dir(issue_dir).unwrap(); let expected = Issue { - id: String::from("7792b063eef6d33e7da5dc1856750c14"), author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::new(), state: State::InProgress, dependencies: None, diff --git a/src/issues.rs b/src/issues.rs index d3c57c0..5c314ac 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -31,8 +31,8 @@ impl Issues { } } - pub fn add_issue(&mut self, issue: crate::issue::Issue) { - self.issues.insert(issue.id.clone(), issue); + pub fn add_issue(&mut self, uuid: String, issue: crate::issue::Issue) { + self.issues.insert(uuid, issue); } pub fn get_issue(&self, issue_id: &str) -> Option<&crate::issue::Issue> { @@ -56,19 +56,14 @@ impl Issues { for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { if direntry.metadata()?.is_dir() { - match crate::issue::Issue::new_from_dir(direntry.path().as_path()) { - Err(e) => { - eprintln!( - "failed to parse issue {}, skipping", - direntry.file_name().to_string_lossy() - ); - eprintln!("ignoring error: {:?}", e); - continue; + let uuid = match direntry.file_name().into_string() { + Ok(uuid) => uuid, + Err(orig_string) => { + return Err(ReadIssuesError::FilenameError(orig_string)) } - Ok(issue) => { - issues.add_issue(issue); - } - } + }; + let issue = crate::issue::Issue::new_from_dir(direntry.path().as_path())?; + issues.add_issue(uuid, issue); } else if direntry.file_name() == "config.toml" { issues.parse_config(direntry.path().as_path())?; } else { @@ -87,7 +82,6 @@ impl Issues { #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; #[test] fn read_issues_0000() { @@ -96,40 +90,40 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("7792b063eef6d33e7da5dc1856750c14"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:07-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::InProgress, - dependencies: None, - assignee: Some(String::from("beep boop")), - description: String::from("minimal"), - comments: Vec::::new(), - dir, - }); - - let uuid = String::from("3943fc5c173fdf41c0a22251593cd476"); + let uuid = String::from("7792b063eef6d33e7da5dc1856750c149ba678c6"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( + uuid, crate::issue::Issue { - id: uuid, author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:36:25-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") + .unwrap() + .with_timezone(&chrono::Local), + tags: Vec::::new(), + state: crate::issue::State::InProgress, + dependencies: None, + assignee: Some(String::from("beep boop")), + description: String::from("minimal"), + comments: Vec::::new(), + dir, + }, + ); + + let uuid = String::from("3943fc5c173fdf41c0a22251593cd476d96e6c9f"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + expected.add_issue( + uuid, + crate::issue::Issue { + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::from([ - String::from("TAG2"), - String::from("i-am-also-a-tag"), String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") ]), state: crate::issue::State::New, dependencies: None, @@ -149,30 +143,27 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); + let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:37:46-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: Some( - chrono::DateTime::parse_from_rfc3339("2025-07-15T15:15:15-06:00") + expected.add_issue( + uuid, + crate::issue::Issue { + author: String::from("Sebastian Kuzminsky "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), - ), - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles"), - comments: Vec::::new(), - dir, - }); + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles"), + comments: Vec::::new(), + dir, + }, + ); - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); let mut comment_dir = dir.clone(); @@ -184,19 +175,18 @@ mod tests { crate::comment::Comment { uuid: comment_uuid, author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:38-06:00").unwrap().with_timezone(&chrono::Local), - description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe\n\nIt has multiple lines\n"), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-07T15:26:26-06:00").unwrap().with_timezone(&chrono::Local), + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), dir: std::path::PathBuf::from(comment_dir), } ); expected.add_issue( + uuid, crate::issue::Issue { - id: uuid, author: String::from("Sebastian Kuzminsky "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T10:08:24-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -216,36 +206,36 @@ mod tests { let mut expected = Issues::new(); - let uuid = String::from("3fa5bfd93317ad25772680071d5ac325"); - let mut dir = std::path::PathBuf::from(issues_dir); - dir.push(&uuid); - expected.add_issue(crate::issue::Issue { - id: uuid, - author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:38:40-06:00") - .unwrap() - .with_timezone(&chrono::Local), - done_time: None, - tags: Vec::::new(), - state: crate::issue::State::Done, - dependencies: None, - assignee: None, - description: String::from("oh yeah we got titles\n"), - comments: Vec::::new(), - dir, - }); - - let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe"); + let uuid = String::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( + uuid, crate::issue::Issue { - id: uuid, author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:20-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") + .unwrap() + .with_timezone(&chrono::Local), + tags: Vec::::new(), + state: crate::issue::State::Done, + dependencies: None, + assignee: None, + description: String::from("oh yeah we got titles\n"), + comments: Vec::::new(), + dir, + }, + ); + + let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); + let mut dir = std::path::PathBuf::from(issues_dir); + dir.push(&uuid); + expected.add_issue( + uuid, + crate::issue::Issue { + author: String::from("sigil-03 "), + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, @@ -256,22 +246,21 @@ mod tests { }, ); - let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c"); + let uuid = String::from("a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); expected.add_issue( + uuid, crate::issue::Issue { - id: uuid, author: String::from("sigil-03 "), - creation_time: chrono::DateTime::parse_from_rfc3339("2025-07-24T08:39:02-06:00") + timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), - done_time: None, tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: Some(vec![ - crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac325"), - crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe"), + crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), + crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), assignee: None, description: String::from("issue with dependencies\n\na test has begun\nfor dependencies we seek\nintertwining life"), diff --git a/src/lib.rs b/src/lib.rs index b6245b9..17104ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,10 @@ use std::str::FromStr; pub mod comment; -pub mod database; pub mod git; pub mod issue; pub mod issues; - -use crate::issue::State; +pub mod database; #[derive(Debug, thiserror::Error)] pub enum ParseFilterError { @@ -14,8 +12,6 @@ pub enum ParseFilterError { ParseError, #[error(transparent)] IssueParseError(#[from] crate::issue::IssueError), - #[error(transparent)] - ChronoParseError(#[from] chrono::format::ParseError), } // FIXME: It's easy to imagine a full dsl for filtering issues, for now @@ -27,13 +23,12 @@ pub struct Filter<'a> { pub include_assignees: std::collections::HashSet<&'a str>, pub include_tags: std::collections::HashSet<&'a str>, pub exclude_tags: std::collections::HashSet<&'a str>, - pub start_done_time: Option>, - pub end_done_time: Option>, } impl<'a> Filter<'a> { - pub fn new() -> Filter<'a> { - Self { + pub fn new_from_str(filter_str: &'a str) -> Result, ParseFilterError> { + use crate::issue::State; + let mut f = Filter { include_states: std::collections::HashSet::::from([ State::InProgress, State::Blocked, @@ -43,75 +38,51 @@ impl<'a> Filter<'a> { include_assignees: std::collections::HashSet::<&'a str>::new(), include_tags: std::collections::HashSet::<&'a str>::new(), exclude_tags: std::collections::HashSet::<&'a str>::new(), - start_done_time: None, - end_done_time: None, - } - } + }; - pub fn parse(&mut self, filter_str: &'a str) -> Result<(), ParseFilterError> { - let tokens: Vec<&str> = filter_str.split("=").collect(); - if tokens.len() != 2 { - return Err(ParseFilterError::ParseError); - } - - match tokens[0] { - "state" => { - self.include_states.clear(); - for s in tokens[1].split(",") { - self.include_states - .insert(crate::issue::State::from_str(s)?); - } - } - - "assignee" => { - self.include_assignees.clear(); - for s in tokens[1].split(",") { - self.include_assignees.insert(s); - } - } - - "tag" => { - self.include_tags.clear(); - self.exclude_tags.clear(); - for s in tokens[1].split(",") { - if s.len() == 0 { - return Err(ParseFilterError::ParseError); - } - if s.chars().nth(0).unwrap() == '-' { - self.exclude_tags.insert(&s[1..]); - } else { - self.include_tags.insert(s); - } - } - } - - "done-time" => { - self.start_done_time = None; - self.end_done_time = None; - let times: Vec<&str> = tokens[1].split("..").collect(); - if times.len() > 2 { - return Err(ParseFilterError::ParseError); - } - if times[0].len() != 0 { - self.start_done_time = Some( - chrono::DateTime::parse_from_rfc3339(times[0])? - .with_timezone(&chrono::Local), - ); - } - if times[1].len() != 0 { - self.end_done_time = Some( - chrono::DateTime::parse_from_rfc3339(times[1])? - .with_timezone(&chrono::Local), - ); - } - } - - _ => { - println!("unknown filter string '{}'", filter_str); + for filter_chunk_str in filter_str.split(":") { + let tokens: Vec<&str> = filter_chunk_str.split("=").collect(); + if tokens.len() != 2 { return Err(ParseFilterError::ParseError); } + + match tokens[0] { + "state" => { + f.include_states.clear(); + for s in tokens[1].split(",") { + f.include_states.insert(crate::issue::State::from_str(s)?); + } + } + + "assignee" => { + f.include_assignees.clear(); + for s in tokens[1].split(",") { + f.include_assignees.insert(s); + } + } + + "tag" => { + f.include_tags.clear(); + f.exclude_tags.clear(); + for s in tokens[1].split(",") { + if s.len() == 0 { + return Err(ParseFilterError::ParseError); + } + if s.chars().nth(0).unwrap() == '-' { + f.exclude_tags.insert(&s[1..]); + } else { + f.include_tags.insert(s); + } + } + } + + _ => { + println!("unknown filter chunk '{}'", filter_chunk_str); + return Err(ParseFilterError::ParseError); + } + } } - Ok(()) + Ok(f) } } diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/description b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476/description rename to test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/description diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags similarity index 100% rename from test/0000/3943fc5c173fdf41c0a22251593cd476/tags rename to test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/assignee b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c14/assignee rename to test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/assignee diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/description b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c14/description rename to test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/description diff --git a/test/0000/7792b063eef6d33e7da5dc1856750c14/state b/test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state similarity index 100% rename from test/0000/7792b063eef6d33e7da5dc1856750c14/state rename to test/0000/7792b063eef6d33e7da5dc1856750c149ba678c6/state diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time b/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time deleted file mode 100644 index d455c4d..0000000 --- a/test/0001/3fa5bfd93317ad25772680071d5ac325/done_time +++ /dev/null @@ -1 +0,0 @@ -2025-07-15T15:15:15-06:00 diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/description b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac325/description rename to test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/description diff --git a/test/0001/3fa5bfd93317ad25772680071d5ac325/state b/test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state similarity index 100% rename from test/0001/3fa5bfd93317ad25772680071d5ac325/state rename to test/0001/3fa5bfd93317ad25772680071d5ac3259cd2384f/state diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description deleted file mode 100644 index daa3d62..0000000 --- a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/comments/9055dac36045fe36545bed7ae7b49347/description +++ /dev/null @@ -1,3 +0,0 @@ -This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe - -It has multiple lines diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description new file mode 100644 index 0000000..f9de678 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description @@ -0,0 +1,3 @@ +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 + +It has multiple lines diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe/description rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state similarity index 100% rename from test/0001/dd79c8cfb8beeacd0460429944b4ecbe/state rename to test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/description b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac325/description rename to test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/description diff --git a/test/0002/3fa5bfd93317ad25772680071d5ac325/state b/test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state similarity index 100% rename from test/0002/3fa5bfd93317ad25772680071d5ac325/state rename to test/0002/3fa5bfd93317ad25772680071d5ac3259cd2384f/state diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/3fa5bfd93317ad25772680071d5ac325 deleted file mode 100644 index e69de29..0000000 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/dependencies/dd79c8cfb8beeacd0460429944b4ecbe deleted file mode 100644 index e69de29..0000000 diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies new file mode 100644 index 0000000..71e4ee3 --- /dev/null +++ b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/dependencies @@ -0,0 +1,2 @@ +3fa5bfd93317ad25772680071d5ac3259cd2384f +dd79c8cfb8beeacd0460429944b4ecbe95a31561 \ No newline at end of file diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c/description rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/description diff --git a/test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state b/test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state similarity index 100% rename from test/0002/a85f81fc5f14cb5d4851dd445dc9744c/state rename to test/0002/a85f81fc5f14cb5d4851dd445dc9744c7f16ccc7/state diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe/description rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/description diff --git a/test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state b/test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state similarity index 100% rename from test/0002/dd79c8cfb8beeacd0460429944b4ecbe/state rename to test/0002/dd79c8cfb8beeacd0460429944b4ecbe95a31561/state diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index dd3cb2f..0000000 --- a/tools/README.md +++ /dev/null @@ -1,4 +0,0 @@ -This directory contains small helper scripts and tools that are peripheral -or tangent to the main entomologist tool. - -We make no guarantees about functionality or correctness. diff --git a/tools/done-last-week b/tools/done-last-week deleted file mode 100755 index da9ec94..0000000 --- a/tools/done-last-week +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -START=$(date --iso-8601=seconds --date='last monday - 1 week') -END=$(date --iso-8601=seconds --date='last monday') - -#echo START=${START} -#echo END=${END} - -ent list \ - state=done \ - done-time="${START}..${END}" diff --git a/tools/set-done-time b/tools/set-done-time deleted file mode 100755 index 6a29fd9..0000000 --- a/tools/set-done-time +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# -# This script finds all issues with state=Done which do not have a -# `done_time`. -# -# It sets each issue's `done_time` to the most recent time that the -# `state` was updated from the git log. -# - -set -e - -for ISSUE_ID in $(ent list state=done done-time=9999-01-01T00:00:00-06:00.. | grep ' ' | cut -f 1 -d ' '); do - echo ${ISSUE_ID} - UTIME=$(PAGER='' git log -n1 --pretty=format:%at%n entomologist-data -- ${ISSUE_ID}/state) - echo ${UTIME} - DATETIME=$(date --rfc-3339=seconds --date="@${UTIME}") - echo ${DATETIME} - ent done-time ${ISSUE_ID} "${DATETIME}" -done diff --git a/tools/time-ent b/tools/time-ent deleted file mode 100755 index 366a02c..0000000 --- a/tools/time-ent +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -# -# * Create a temporary ent issue database branch based on a specific -# commit in `entomologist-data`. -# -# * Perform some ent operations on this temporary branch and measure -# the runtime. -# -# * Clean up by deleteting the temporary branch. - -set -e -#set -x - - -# This is a commit in the `entomologist-data` branch that we're somewhat -# arbitrarily using here to time different `ent` operations. -TEST_COMMIT=a33f1165d77571d770f1a1021afe4c07360247f0 - -# This is the branch that we create from the above commit and test our -# `ent` operations on. We'll delete this branch when we're done with -# the tests. -TEST_BRANCH=$(mktemp --dry-run entomologist-data-XXXXXXXX) - - -function time_ent() { - echo timing: ent "$@" - time -p ent -b "${TEST_BRANCH}" "$@" - echo -} - - -git branch "${TEST_BRANCH}" "${TEST_COMMIT}" - -time_ent tag 7e2a3a59fb6b77403ff1035255367607 -time_ent tag 7e2a3a59fb6b77403ff1035255367607 new-tag - -time_ent assign 7e2a3a59fb6b77403ff1035255367607 -time_ent assign 7e2a3a59fb6b77403ff1035255367607 new-user - -time_ent done-time 7e2a3a59fb6b77403ff1035255367607 -time_ent done-time 7e2a3a59fb6b77403ff1035255367607 2025-04-01T01:23:45-06:00 - -git branch -D "${TEST_BRANCH}"