diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 8493cf4..d2b5445 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -216,12 +216,7 @@ fn handle_command( Commands::New { description } => { let issues_database = make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; - let mut issue = entomologist::issue::Issue::new(&issues_database.dir)?; - let r = match description { - Some(description) => issue.set_description(description), - None => issue.edit_description(), - }; - match r { + match entomologist::issue::Issue::new(&issues_database.dir, description) { Err(entomologist::issue::IssueError::EmptyDescription) => { println!("no new issue created"); return Ok(()); @@ -229,7 +224,7 @@ fn handle_command( Err(e) => { return Err(e.into()); } - Ok(()) => { + Ok(issue) => { println!("created new issue '{}'", issue.title()); return Ok(()); } @@ -332,21 +327,21 @@ fn handle_command( let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; - let mut comment = issue.new_comment()?; - let r = match description { - Some(description) => comment.set_description(description), - None => comment.edit_description(), - }; - match r { - Err(entomologist::comment::CommentError::EmptyDescription) => { + match issue.add_comment(description) { + Err(entomologist::issue::IssueError::CommentError( + entomologist::comment::CommentError::EmptyDescription, + )) => { println!("aborted new comment"); return Ok(()); } Err(e) => { return Err(e.into()); } - Ok(()) => { - println!("created new comment {}", &comment.uuid); + Ok(comment) => { + println!( + "created new comment {} on issue {}", + &comment.uuid, &issue_id + ); } } } @@ -368,31 +363,32 @@ fn handle_command( issue_id, new_assignee, } => { - let issues_database = - make_issues_database(issues_database_source, IssuesDatabaseAccess::ReadWrite)?; - let mut issues = entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; - let Some(issue) = issues.issues.get_mut(issue_id) else { + let issues = 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)); }; - match (&issue.assignee, new_assignee) { - (Some(old_assignee), Some(new_assignee)) => { - println!("issue: {}", 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 = make_issues_database( + issues_database_source, + 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)?; } - (Some(old_assignee), None) => { - println!("issue: {}", issue_id); + None => { println!("assignee: {}", old_assignee); } - (None, Some(new_assignee)) => { - println!("issue: {}", issue_id); - println!("assignee: None -> {}", new_assignee); - issue.set_assignee(new_assignee)?; - } - (None, None) => { - println!("issue: {}", issue_id); - println!("assignee: None"); - } } } } diff --git a/src/comment.rs b/src/comment.rs index c7afe44..e9c3134 100644 --- a/src/comment.rs +++ b/src/comment.rs @@ -16,6 +16,8 @@ pub struct Comment { pub enum CommentError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + EnvVarError(#[from] std::env::VarError), #[error("Failed to parse comment")] CommentParseError, #[error("Failed to run git")] @@ -61,17 +63,56 @@ impl Comment { }) } - pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> { - if description.len() == 0 { - return Err(CommentError::EmptyDescription); + /// Create a new Comment on the specified Issue. Commits. + pub fn new( + issue: &crate::issue::Issue, + description: &Option, + ) -> Result { + let mut dir = std::path::PathBuf::from(&issue.dir); + dir.push("comments"); + if !dir.exists() { + std::fs::create_dir(&dir)?; } - self.description = String::from(description); - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - crate::git::git_commit_file(&description_filename)?; - Ok(()) + + let rnd: u128 = rand::random(); + let uuid = format!("{:032x}", rnd); + dir.push(&uuid); + std::fs::create_dir(&dir)?; + + let mut comment = crate::comment::Comment { + uuid, + author: String::from(""), // this will be updated from git when we re-read this comment + timestamp: chrono::Local::now(), + description: String::from(""), // this will be set immediately below + dir: dir.clone(), + }; + + match description { + Some(description) => { + if description.len() == 0 { + return Err(CommentError::EmptyDescription); + } + comment.description = String::from(description); + let description_filename = comment.description_filename(); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + } + None => comment.edit_description_file()?, + }; + + crate::git::add(&dir)?; + if crate::git::worktree_is_dirty(&dir.to_string_lossy())? { + crate::git::commit( + &dir, + &format!( + "add comment {} on issue {}", + comment.uuid, + issue.dir.file_name().unwrap().to_string_lossy(), + ), + )?; + } + + Ok(comment) } pub fn read_description(&mut self) -> Result<(), CommentError> { @@ -82,11 +123,39 @@ impl Comment { } pub fn edit_description(&mut self) -> Result<(), CommentError> { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); + self.edit_description_file()?; + let description_filename = self.description_filename(); + 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 comment {} on issue FIXME", // FIXME: name the issue that the comment is on + self.dir.file_name().unwrap().to_string_lossy() + ), + )?; + self.read_description()?; + } + Ok(()) + } + + /// Opens the Comment's `description` file in an editor. Validates + /// the editor's exit code. Updates the Comment's internal + /// description from what the user saved in the file. + /// + /// Used by Issue::add_comment() when no description is supplied, + /// and (FIXME: in the future) used by `ent edit COMMENT`. + pub fn edit_description_file(&mut self) -> Result<(), CommentError> { + let description_filename = self.description_filename(); let exists = description_filename.exists(); - let result = std::process::Command::new("vi") - .arg(&description_filename.as_mut_os_str()) + + let editor = match std::env::var("EDITOR") { + Ok(editor) => editor, + Err(std::env::VarError::NotPresent) => String::from("vi"), + Err(e) => return Err(e.into()), + }; + let result = std::process::Command::new(editor) + .arg(&description_filename.as_os_str()) .spawn()? .wait_with_output()?; if !result.status.success() { @@ -94,9 +163,8 @@ impl Comment { println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); return Err(CommentError::EditorError); } - if description_filename.exists() && description_filename.metadata()?.len() > 0 { - crate::git::add(&description_filename)?; - } else { + + if !description_filename.exists() || description_filename.metadata()?.len() == 0 { // User saved an empty file, which means they changed their // mind and no longer want to edit the description. if exists { @@ -104,25 +172,20 @@ impl Comment { } return Err(CommentError::EmptyDescription); } - if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { - crate::git::commit( - &description_filename.parent().unwrap(), - &format!( - "new description for comment {}", - description_filename - .parent() - .unwrap() - .file_name() - .unwrap() - .to_string_lossy() - ), - )?; - self.read_description()?; - } + self.read_description()?; Ok(()) } } +// This is the private, internal API. +impl Comment { + fn description_filename(&self) -> std::path::PathBuf { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + description_filename + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/git.rs b/src/git.rs index 0ea0ee6..63cb004 100644 --- a/src/git.rs +++ b/src/git.rs @@ -233,6 +233,62 @@ pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), Git git_fetch(dir, remote)?; + // FIXME: Possible things to add: + // * `git log -p` shows diff + // * `git log --numstat` shows machine-readable diffstat + + // Show what we just fetched from the remote. + let result = std::process::Command::new("git") + .args([ + "log", + "--no-merges", + "--pretty=format:%an: %s", + &format!("{}/{}", remote, branch), + &format!("^{}", branch), + ]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", + dir + ); + 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!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!(""); + } + + // Show what we are about to push to the remote. + let result = std::process::Command::new("git") + .args([ + "log", + "--no-merges", + "--pretty=format:%an: %s", + &format!("{}", branch), + &format!("^{}/{}", remote, branch), + ]) + .current_dir(dir) + .output()?; + if !result.status.success() { + println!( + "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", + dir + ); + 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!("{}", std::str::from_utf8(&result.stdout).unwrap()); + println!(""); + } + // Merge remote branch into local. let result = std::process::Command::new("git") .args(["merge", &format!("{}/{}", remote, branch)]) diff --git a/src/issue.rs b/src/issue.rs index 3ea6afe..15d97f0 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -38,6 +38,8 @@ pub enum IssueError { #[error(transparent)] StdIoError(#[from] std::io::Error), #[error(transparent)] + EnvVarError(#[from] std::env::VarError), + #[error(transparent)] CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, @@ -87,6 +89,7 @@ impl fmt::Display for State { } } +// This is the public API of Issue. impl Issue { pub fn new_from_dir(dir: &std::path::Path) -> Result { let mut description: Option = None; @@ -158,33 +161,35 @@ impl Issue { Ok(()) } - pub fn new_comment(&mut self) -> Result { - let mut dir = std::path::PathBuf::from(&self.dir); - dir.push("comments"); - if !dir.exists() { - std::fs::create_dir(&dir)?; - } - - let rnd: u128 = rand::random(); - let uuid = format!("{:032x}", rnd); - dir.push(&uuid); - std::fs::create_dir(&dir)?; - - Ok(crate::comment::Comment { - uuid, - author: String::from("Sebastian Kuzminsky "), - timestamp: chrono::Local::now(), - description: String::from(""), // FIXME - dir, - }) + /// Add a new Comment to the Issue. Commits. + pub fn add_comment( + &mut self, + description: &Option, + ) -> Result { + let comment = crate::comment::Comment::new(self, description)?; + Ok(comment) } - pub fn new(dir: &std::path::Path) -> Result { + /// Create a new Issue in an Issues database specified by a directory. + /// The new Issue will live in a new subdirectory, named by a unique + /// Issue identifier. + /// + /// If a description string is supplied, the new Issue's description + /// will be initialized from it with no user interaction. + /// + /// If no description is supplied, the user will be prompted to + /// input one into an editor. + /// + /// On success, the new Issue with its valid description is committed + /// to the Issues database. + pub fn new(dir: &std::path::Path, description: &Option) -> Result { let mut issue_dir = std::path::PathBuf::from(dir); let rnd: u128 = rand::random(); - issue_dir.push(&format!("{:032x}", rnd)); + let issue_id = format!("{:032x}", rnd); + issue_dir.push(&issue_id); std::fs::create_dir(&issue_dir)?; - Ok(Self { + + let mut issue = Self { author: String::from(""), timestamp: chrono::Local::now(), state: State::New, @@ -192,58 +197,38 @@ impl Issue { assignee: None, description: String::from(""), // FIXME: kind of bogus to use the empty string as None comments: Vec::::new(), - dir: issue_dir, - }) - } + dir: issue_dir.clone(), + }; - pub fn set_description(&mut self, description: &str) -> Result<(), IssueError> { - if description.len() == 0 { - return Err(IssueError::EmptyDescription); - } - self.description = String::from(description); - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let mut description_file = std::fs::File::create(&description_filename)?; - write!(description_file, "{}", description)?; - crate::git::git_commit_file(&description_filename)?; - Ok(()) - } - - pub fn read_description(&mut self) -> Result<(), IssueError> { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - self.description = std::fs::read_to_string(description_filename)?; - Ok(()) - } - - pub fn edit_description(&mut self) -> Result<(), IssueError> { - let mut description_filename = std::path::PathBuf::from(&self.dir); - description_filename.push("description"); - let exists = description_filename.exists(); - let result = std::process::Command::new("vi") - .arg(&description_filename.as_mut_os_str()) - .spawn()? - .wait_with_output()?; - if !result.status.success() { - 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 { - crate::git::add(&description_filename)?; - } else { - // User saved an empty file, which means they changed their - // mind and no longer want to edit the description. - if exists { - crate::git::restore_file(&description_filename)?; + match description { + Some(description) => { + if description.len() == 0 { + return Err(IssueError::EmptyDescription); + } + issue.description = String::from(description); + let description_filename = issue.description_filename(); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; } - return Err(IssueError::EmptyDescription); - } + None => issue.edit_description_file()?, + }; + + crate::git::add(&issue_dir)?; + crate::git::commit(&issue_dir, &format!("create new issue {}", issue_id))?; + + Ok(issue) + } + + /// Interactively edit the description of an existing Issue. + pub fn edit_description(&mut self) -> Result<(), IssueError> { + self.edit_description_file()?; + let description_filename = self.description_filename(); + crate::git::add(&description_filename)?; if crate::git::worktree_is_dirty(&self.dir.to_string_lossy())? { crate::git::commit( &description_filename.parent().unwrap(), &format!( - "new description for issue {}", + "edit description of issue {}", description_filename .parent() .unwrap() @@ -252,11 +237,11 @@ impl Issue { .to_string_lossy() ), )?; - self.read_description()?; } Ok(()) } + /// Return the Issue title (first line of the description). pub fn title<'a>(&'a self) -> &'a str { match self.description.find("\n") { Some(index) => &self.description.as_str()[..index], @@ -264,12 +249,23 @@ impl Issue { } } + /// Change the State of the Issue. pub fn set_state(&mut self, new_state: State) -> Result<(), IssueError> { 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)?; - crate::git::git_commit_file(&state_filename)?; + 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 {} to {}", + self.dir.file_name().unwrap().to_string_lossy(), + new_state, + ), + )?; + } Ok(()) } @@ -281,12 +277,82 @@ impl Issue { Ok(()) } + /// Set the Assignee of an Issue. pub fn set_assignee(&mut self, new_assignee: &str) -> Result<(), IssueError> { + let old_assignee = match &self.assignee { + Some(assignee) => assignee.clone(), + None => String::from("None"), + }; let mut assignee_filename = std::path::PathBuf::from(&self.dir); assignee_filename.push("assignee"); let mut assignee_file = std::fs::File::create(&assignee_filename)?; write!(assignee_file, "{}", new_assignee)?; - crate::git::git_commit_file(&assignee_filename)?; + 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(()) + } +} + +// This is the internal/private API of Issue. +impl Issue { + fn description_filename(&self) -> std::path::PathBuf { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + description_filename + } + + /// Read the Issue's description file into the internal Issue representation. + fn read_description(&mut self) -> Result<(), IssueError> { + let description_filename = self.description_filename(); + self.description = std::fs::read_to_string(description_filename)?; + Ok(()) + } + + /// Opens the Issue's `description` file in an editor. Validates the + /// editor's exit code. Updates the Issue's internal description + /// from what the user saved in the file. + /// + /// Used by Issue::new() when no description is supplied, and also + /// used by `ent edit ISSUE`. + fn edit_description_file(&mut self) -> Result<(), IssueError> { + let description_filename = self.description_filename(); + let exists = description_filename.exists(); + let editor = match std::env::var("EDITOR") { + Ok(editor) => editor, + Err(std::env::VarError::NotPresent) => String::from("vi"), + Err(e) => return Err(e.into()), + }; + let result = std::process::Command::new(editor) + .arg(&description_filename.as_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + 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 { + // User saved an empty file, or exited without saving while + // editing a new description file. Both means they changed + // their mind and no longer want to edit the description. + if exists { + // File existed before the user emptied it, so restore + // the original. + crate::git::restore_file(&description_filename)?; + } + return Err(IssueError::EmptyDescription); + } + self.read_description()?; Ok(()) } }