crates/cli/git-ents/src/commands/comment.rs
comment.rshistorycomment on this file
| 1 | //! `git ents comment`: a thin wrapper around `ents_forge::comment`'s |
| 2 | //! business logic — this module only resolves the signer/actor identity |
| 3 | //! against [`LocalRoot`], translates a reached `Outcome` into a CLI-facing |
| 4 | //! [`Result`] (`crate::mutate::outcome_to_result`), and renders the |
| 5 | //! machine-readable listing, exactly as every other mutation command does. |
| 6 | //! Every operation is the library call itself (`lens.parity`); nothing |
| 7 | //! here re-implements one. |
| 8 | |
| 9 | use ents_forge::comment; |
| 10 | use ents_forge::comment::{Comment, ListFilter, Listed, NewComment}; |
| 11 | use ents_receive::Identity; |
| 12 | |
| 13 | use super::{actor, signer}; |
| 14 | use crate::error::Result; |
| 15 | use crate::mutate::outcome_to_result; |
| 16 | use crate::root::LocalRoot; |
| 17 | |
| 18 | /// `git ents comment list`: every comment recorded in this repository. |
| 19 | /// |
| 20 | /// # Errors |
| 21 | /// |
| 22 | /// Propagates a ref-store or object read failure. |
| 23 | pub fn list(root: &LocalRoot) -> Result<Vec<(String, Comment)>> { |
| 24 | Ok(comment::list(&root.refs, &root.objects)?) |
| 25 | } |
| 26 | |
| 27 | /// `git ents comment list [--worktree] [--state ...] [--context ...]`: |
| 28 | /// matching comments with each anchor projected onto the working tree |
| 29 | /// (with `worktree`) or `HEAD`, plus the refs whose stored tree this |
| 30 | /// build could not read back (reported after the listing, never |
| 31 | /// silently dropped). |
| 32 | /// |
| 33 | /// # Errors |
| 34 | /// |
| 35 | /// Propagates a ref-store, object read, or projection failure. |
| 36 | pub fn list_projected( |
| 37 | root: &LocalRoot, |
| 38 | worktree: bool, |
| 39 | filter: &ListFilter, |
| 40 | ) -> Result<(Vec<Listed>, Vec<ents_forge::Unreadable>)> { |
| 41 | Ok(comment::list_projected( |
| 42 | &root.refs, |
| 43 | &root.objects, |
| 44 | &root.path, |
| 45 | worktree, |
| 46 | filter, |
| 47 | )?) |
| 48 | } |
| 49 | |
| 50 | /// `git ents comment add`: create a comment about something. |
| 51 | /// |
| 52 | /// # Errors |
| 53 | /// |
| 54 | /// [`crate::error::Error::Forge`] if the comment is about nothing, its |
| 55 | /// arguments do not parse, or anchoring, serialization, or `receive` |
| 56 | /// itself fails; see [`crate::mutate::outcome_to_result`] for how a |
| 57 | /// reached refusal renders. |
| 58 | pub fn add(root: &LocalRoot, new: NewComment, key: Option<std::path::PathBuf>) -> Result<String> { |
| 59 | let signer = signer(root, key)?; |
| 60 | let identity = Identity { |
| 61 | actor: actor(&signer), |
| 62 | author: None, |
| 63 | sign: &|payload| signer.sign(payload), |
| 64 | }; |
| 65 | let (id, outcome) = comment::add( |
| 66 | &root.refs, |
| 67 | &root.objects, |
| 68 | &root.events, |
| 69 | &root.path, |
| 70 | new, |
| 71 | &identity, |
| 72 | root.mode(), |
| 73 | )?; |
| 74 | outcome_to_result(outcome, None)?; |
| 75 | Ok(id) |
| 76 | } |
| 77 | |
| 78 | /// `git ents comment reply`: a comment whose parent is `parent_id`. |
| 79 | /// |
| 80 | /// # Errors |
| 81 | /// |
| 82 | /// See [`add`]; additionally [`ents_forge::Error::NotFound`] (wrapped) |
| 83 | /// when `parent_id` names no comment. |
| 84 | pub fn reply( |
| 85 | root: &LocalRoot, |
| 86 | parent_id: &str, |
| 87 | body: String, |
| 88 | key: Option<std::path::PathBuf>, |
| 89 | ) -> Result<String> { |
| 90 | let signer = signer(root, key)?; |
| 91 | let identity = Identity { |
| 92 | actor: actor(&signer), |
| 93 | author: None, |
| 94 | sign: &|payload| signer.sign(payload), |
| 95 | }; |
| 96 | let (id, outcome) = comment::reply( |
| 97 | &root.refs, |
| 98 | &root.objects, |
| 99 | &root.events, |
| 100 | parent_id, |
| 101 | body, |
| 102 | &identity, |
| 103 | root.mode(), |
| 104 | )?; |
| 105 | outcome_to_result(outcome, None)?; |
| 106 | Ok(id) |
| 107 | } |
| 108 | |
| 109 | /// `git ents comment resolve` / `reopen`: record the state mutation on the |
| 110 | /// comment's own ref. |
| 111 | /// |
| 112 | /// # Errors |
| 113 | /// |
| 114 | /// See [`add`]. |
| 115 | pub fn set_state( |
| 116 | root: &LocalRoot, |
| 117 | id: &str, |
| 118 | resolve: bool, |
| 119 | key: Option<std::path::PathBuf>, |
| 120 | ) -> Result<()> { |
| 121 | let signer = signer(root, key)?; |
| 122 | let identity = Identity { |
| 123 | actor: actor(&signer), |
| 124 | author: None, |
| 125 | sign: &|payload| signer.sign(payload), |
| 126 | }; |
| 127 | let outcome = if resolve { |
| 128 | comment::resolve( |
| 129 | &root.refs, |
| 130 | &root.objects, |
| 131 | &root.events, |
| 132 | id, |
| 133 | &identity, |
| 134 | root.mode(), |
| 135 | Some(&signer.public_openssh()), |
| 136 | )? |
| 137 | } else { |
| 138 | comment::reopen( |
| 139 | &root.refs, |
| 140 | &root.objects, |
| 141 | &root.events, |
| 142 | id, |
| 143 | &identity, |
| 144 | root.mode(), |
| 145 | Some(&signer.public_openssh()), |
| 146 | )? |
| 147 | }; |
| 148 | outcome_to_result(outcome, None)?; |
| 149 | Ok(()) |
| 150 | } |
| 151 | |
| 152 | /// `git ents comment show`: `id`'s comment and, when anchored, its anchor |
| 153 | /// projected onto `rev` or the working tree. |
| 154 | /// |
| 155 | /// # Errors |
| 156 | /// |
| 157 | /// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) |
| 158 | /// if `id` has no comment ref. |
| 159 | pub fn show( |
| 160 | root: &LocalRoot, |
| 161 | id: &str, |
| 162 | rev: &str, |
| 163 | worktree: bool, |
| 164 | ) -> Result<( |
| 165 | Comment, |
| 166 | Option<(ents_anchor::Anchor, ents_anchor::Projection)>, |
| 167 | )> { |
| 168 | Ok(comment::show( |
| 169 | &root.refs, |
| 170 | &root.objects, |
| 171 | &root.path, |
| 172 | id, |
| 173 | rev, |
| 174 | worktree, |
| 175 | )?) |
| 176 | } |
| 177 | |
| 178 | /// One record of `git ents comment list --porcelain`'s stable |
| 179 | /// machine-readable form (`lens.parity`: id, state, projected location, |
| 180 | /// and body, sufficient for an agent to enumerate and resolve every open |
| 181 | /// comment with no editor attached): |
| 182 | /// |
| 183 | /// ```text |
| 184 | /// <id> <state> <projection> <location> |
| 185 | /// context <c> (only when the comment names one) |
| 186 | /// parent <id> (only when the comment is a reply) |
| 187 | /// \t<body line> (every body line, tab-prefixed) |
| 188 | /// ``` |
| 189 | /// |
| 190 | /// `projection` is `current`, `relocated`, `outdated`, or `deleted`, and |
| 191 | /// `-` for a comment with no anchor; `location` is `path:start-end` |
| 192 | /// (`path` alone for a whole-file anchor) and `-` when there is no anchor |
| 193 | /// or the file is gone. Records are separated by one blank line — a blank |
| 194 | /// body line renders as a lone tab, so it can never terminate a record. |
| 195 | /// This is the record grammar every other family's porcelain shares |
| 196 | /// (`lens.porcelain`, via [`ents_forge::present::record`]). |
| 197 | // @relation(lens.porcelain, scope=function) |
| 198 | #[must_use] |
| 199 | pub fn porcelain(rows: &[Listed]) -> String { |
| 200 | let mut out = String::new(); |
| 201 | for (index, row) in rows.iter().enumerate() { |
| 202 | if index > 0 { |
| 203 | out.push('\n'); |
| 204 | } |
| 205 | let (projection, location) = match (&row.projection, &row.anchor) { |
| 206 | (Some(projection), Some(anchor)) => porcelain_projection(projection, anchor), |
| 207 | _ => ("-".to_owned(), "-".to_owned()), |
| 208 | }; |
| 209 | out.push_str(&format!( |
| 210 | "{} {} {} {}\n", |
| 211 | row.id, row.comment.state, projection, location |
| 212 | )); |
| 213 | if let Some(context) = &row.comment.context { |
| 214 | out.push_str(&format!("context {context}\n")); |
| 215 | } |
| 216 | if let Some(parent) = &row.comment.parent { |
| 217 | out.push_str(&format!("parent {parent}\n")); |
| 218 | } |
| 219 | for line in row.comment.body.lines() { |
| 220 | out.push('\t'); |
| 221 | out.push_str(line); |
| 222 | out.push('\n'); |
| 223 | } |
| 224 | } |
| 225 | out |
| 226 | } |
| 227 | |
| 228 | /// The `(projection, location)` columns of one porcelain record. |
| 229 | fn porcelain_projection( |
| 230 | projection: &ents_anchor::Projection, |
| 231 | anchor: &ents_anchor::Anchor, |
| 232 | ) -> (String, String) { |
| 233 | use ents_anchor::Projection; |
| 234 | let location = match projection { |
| 235 | Projection::Current => location(&anchor.path, anchor.lines), |
| 236 | Projection::Relocated { path, lines } => location(path, *lines), |
| 237 | Projection::Outdated { path } => location(path, None), |
| 238 | Projection::Deleted => "-".to_owned(), |
| 239 | }; |
| 240 | (projection.label().to_owned(), location) |
| 241 | } |
| 242 | |
| 243 | fn location(path: &str, lines: Option<ents_anchor::LineRange>) -> String { |
| 244 | match lines { |
| 245 | Some(range) => format!("{path}:{}-{}", range.start, range.end), |
| 246 | None => path.to_owned(), |
| 247 | } |
| 248 | } |