crates/kernel/ents-model/src/namespace.rs
namespace.rshistorycomment on this file
| 1 | //! Refname namespaces under `refs/meta/*`. |
| 2 | //! |
| 3 | //! Every builder here composes a refname and validates it through gitoxide's |
| 4 | //! own [`gix::refs::FullName`] (`arch.no-object-store-trait`'s sibling rule: |
| 5 | //! never define a parallel refname type). [`classify`] is the inverse |
| 6 | //! direction — given a refname, which entity's namespace it falls in — for |
| 7 | //! callers (the gate, `receive`) that need to route on a pushed ref without |
| 8 | //! duplicating this module's namespace table. |
| 9 | //! |
| 10 | //! Spec coverage: `meta-ref.namespace`, `meta-ref.granularity`, |
| 11 | //! `meta-ref.inbox`, plus the `refs/meta/toolchains/*` |
| 12 | //! (`model.toolchain`), `refs/meta/redactions/*` (`model.redaction`), |
| 13 | //! `refs/meta/reviews/*` (`model.review`), and `refs/meta/pins/*` |
| 14 | //! (`model.review-pin`) namespaces. |
| 15 | |
| 16 | use gix::refs::{FullName, FullNameRef}; |
| 17 | |
| 18 | use crate::member::MemberId; |
| 19 | use crate::{Error, Result}; |
| 20 | |
| 21 | fn build(name: String) -> Result<FullName> { |
| 22 | FullName::try_from(name.clone()).map_err(|source| Error::InvalidRefName { name, source }) |
| 23 | } |
| 24 | |
| 25 | /// The fixed ref for repository-global account state (`meta-ref.granularity`: |
| 26 | /// "Repository-global state with a single writer-of-record MUST instead live |
| 27 | /// on one fixed ref"). |
| 28 | pub const ACCOUNT_REF: &str = "refs/meta/account"; |
| 29 | |
| 30 | /// The fixed ref for repository-global configuration |
| 31 | /// (`meta-ref.granularity`). |
| 32 | pub const CONFIG_REF: &str = "refs/meta/config"; |
| 33 | |
| 34 | /// The ref holding the member named `id` — `refs/meta/member/<id>` |
| 35 | /// (`meta-ref.granularity`). |
| 36 | /// |
| 37 | /// # Examples |
| 38 | /// |
| 39 | /// ``` |
| 40 | /// use ents_model::{MemberId, namespace}; |
| 41 | /// |
| 42 | /// let name = namespace::member_ref(&MemberId::new("jdc")).expect("valid id"); |
| 43 | /// assert_eq!(name.as_bstr(), "refs/meta/member/jdc"); |
| 44 | /// ``` |
| 45 | // @relation(meta-ref.granularity, scope=function) |
| 46 | pub fn member_ref(id: &MemberId) -> Result<FullName> { |
| 47 | build(format!("refs/meta/member/{id}")) |
| 48 | } |
| 49 | |
| 50 | /// The ref holding the issue named `id` — `refs/meta/issues/<id>` |
| 51 | /// (`meta-ref.granularity`). |
| 52 | // @relation(meta-ref.granularity, scope=function) |
| 53 | pub fn issue_ref(id: &str) -> Result<FullName> { |
| 54 | build(format!("refs/meta/issues/{id}")) |
| 55 | } |
| 56 | |
| 57 | /// The ref holding the comment named `id` — `refs/meta/comments/<id>` |
| 58 | /// (`meta-ref.granularity`). |
| 59 | // @relation(meta-ref.granularity, scope=function) |
| 60 | pub fn comment_ref(id: &str) -> Result<FullName> { |
| 61 | build(format!("refs/meta/comments/{id}")) |
| 62 | } |
| 63 | |
| 64 | /// The ref holding one reviewer's review of one commit — |
| 65 | /// `refs/meta/reviews/<target>/<member>` (`meta-ref.granularity`, |
| 66 | /// `model.review`), where `<target>` is the oid of the first commit the |
| 67 | /// review judged and `<member>` is the reviewer's member id: a composite |
| 68 | /// natural key (`meta-ref.identity-binding`) with no minted id anywhere, |
| 69 | /// so one review thread lives per (target, reviewer) and all reviews of a |
| 70 | /// commit enumerate by ref prefix. |
| 71 | /// |
| 72 | /// # Examples |
| 73 | /// |
| 74 | /// ``` |
| 75 | /// use ents_model::{MemberId, namespace}; |
| 76 | /// |
| 77 | /// let name = namespace::review_ref("deadbeef", &MemberId::new("jdc")).expect("valid"); |
| 78 | /// assert_eq!(name.as_bstr(), "refs/meta/reviews/deadbeef/jdc"); |
| 79 | /// ``` |
| 80 | // @relation(meta-ref.granularity, model.review, meta-ref.identity-binding, scope=function) |
| 81 | pub fn review_ref(target: &str, member: &MemberId) -> Result<FullName> { |
| 82 | build(format!("refs/meta/reviews/{target}/{member}")) |
| 83 | } |
| 84 | |
| 85 | /// The retention pin for one reviewer's review of one commit — |
| 86 | /// `refs/meta/pins/reviews/<target>/<member>` (`model.review-pin`): the |
| 87 | /// entity's own canonical suffix (`reviews/<target>/<member>`) prefixed |
| 88 | /// with `pins/`, the same way `meta-ref.inbox` prefixes one, so two entity |
| 89 | /// kinds can never collide under the same pin id. |
| 90 | /// |
| 91 | /// A pin ref's commits carry the empty tree, never an entity — the sole |
| 92 | /// exception to `meta-ref.namespace`'s tree-is-the-entity shape; the |
| 93 | /// commits exist purely to keep the reviewed commit and its ancestry |
| 94 | /// reachable. Because a pin's ancestry deliberately reaches into code |
| 95 | /// history, the gate's parentless-roots walk is never applied to a pin |
| 96 | /// (`meta-ref.identity-binding`). |
| 97 | /// |
| 98 | /// # Examples |
| 99 | /// |
| 100 | /// ``` |
| 101 | /// use ents_model::{MemberId, namespace}; |
| 102 | /// |
| 103 | /// let name = namespace::review_pin_ref("deadbeef", &MemberId::new("jdc")).expect("valid"); |
| 104 | /// assert_eq!(name.as_bstr(), "refs/meta/pins/reviews/deadbeef/jdc"); |
| 105 | /// ``` |
| 106 | // @relation(model.review-pin, meta-ref.namespace, meta-ref.identity-binding, scope=function) |
| 107 | pub fn review_pin_ref(target: &str, member: &MemberId) -> Result<FullName> { |
| 108 | build(format!("refs/meta/pins/reviews/{target}/{member}")) |
| 109 | } |
| 110 | |
| 111 | /// The `(target, member)` a review or review-pin refname names, or `None` |
| 112 | /// when `name` is not a well-formed `refs/meta/reviews/<target>/<member>` |
| 113 | /// or `refs/meta/pins/reviews/<target>/<member>` ref (`model.review`). |
| 114 | /// |
| 115 | /// The gate recomputes a review's composite key from its signed content |
| 116 | /// and compares it to this parse (`meta-ref.identity-binding`, |
| 117 | /// `gate.identity-binding`), so the parser lives here next to the builder |
| 118 | /// rather than re-derived at the call site. |
| 119 | /// |
| 120 | /// # Examples |
| 121 | /// |
| 122 | /// ``` |
| 123 | /// use ents_model::{MemberId, namespace}; |
| 124 | /// |
| 125 | /// let name: gix::refs::FullName = "refs/meta/reviews/deadbeef/jdc".try_into().expect("valid"); |
| 126 | /// assert_eq!( |
| 127 | /// namespace::parse_review_ref(name.as_ref()), |
| 128 | /// Some(("deadbeef".to_owned(), MemberId::new("jdc"))), |
| 129 | /// ); |
| 130 | /// |
| 131 | /// let pin: gix::refs::FullName = "refs/meta/pins/reviews/deadbeef/jdc".try_into().expect("valid"); |
| 132 | /// assert_eq!( |
| 133 | /// namespace::parse_review_ref(pin.as_ref()), |
| 134 | /// Some(("deadbeef".to_owned(), MemberId::new("jdc"))), |
| 135 | /// ); |
| 136 | /// ``` |
| 137 | // @relation(model.review, meta-ref.identity-binding, scope=function) |
| 138 | #[must_use] |
| 139 | pub fn parse_review_ref(name: &FullNameRef) -> Option<(String, MemberId)> { |
| 140 | let path = name.as_bstr().to_string(); |
| 141 | let rest = path |
| 142 | .strip_prefix("refs/meta/reviews/") |
| 143 | .or_else(|| path.strip_prefix("refs/meta/pins/reviews/"))?; |
| 144 | let (target, member) = rest.split_once('/')?; |
| 145 | if target.is_empty() || member.is_empty() || member.contains('/') { |
| 146 | return None; |
| 147 | } |
| 148 | Some((target.to_owned(), MemberId::new(member))) |
| 149 | } |
| 150 | |
| 151 | /// The `(effect, short_oid)` a result refname names, or `None` when `name` |
| 152 | /// is not a well-formed `refs/meta/results/<effect>/<short-oid>` or |
| 153 | /// `refs/meta/self/<member>/<effect>/<short-oid>` ref |
| 154 | /// (`effect.results-writeback`, `meta-ref.inbox`). |
| 155 | /// |
| 156 | /// The gate recomputes a result's composite key from its signed tree's |
| 157 | /// effect and target fields and compares it to this parse |
| 158 | /// (`model.result-identity`, `gate.identity-binding`). |
| 159 | /// |
| 160 | /// # Examples |
| 161 | /// |
| 162 | /// ``` |
| 163 | /// use ents_model::namespace; |
| 164 | /// |
| 165 | /// let name: gix::refs::FullName = "refs/meta/results/unit/abc123".try_into().expect("valid"); |
| 166 | /// assert_eq!( |
| 167 | /// namespace::parse_result_ref(name.as_ref()), |
| 168 | /// Some(("unit".to_owned(), "abc123".to_owned())), |
| 169 | /// ); |
| 170 | /// |
| 171 | /// let self_run: gix::refs::FullName = "refs/meta/self/jdc/unit/abc123".try_into().expect("valid"); |
| 172 | /// assert_eq!( |
| 173 | /// namespace::parse_result_ref(self_run.as_ref()), |
| 174 | /// Some(("unit".to_owned(), "abc123".to_owned())), |
| 175 | /// ); |
| 176 | /// ``` |
| 177 | // @relation(model.result-identity, meta-ref.identity-binding, scope=function) |
| 178 | #[must_use] |
| 179 | pub fn parse_result_ref(name: &FullNameRef) -> Option<(String, String)> { |
| 180 | let path = name.as_bstr().to_string(); |
| 181 | let rest = path.strip_prefix("refs/meta/")?; |
| 182 | let tail = if let Some(canonical) = rest.strip_prefix("results/") { |
| 183 | canonical.to_owned() |
| 184 | } else { |
| 185 | let self_run = rest.strip_prefix("self/")?; |
| 186 | // refs/meta/self/<member>/<effect>/<short-oid>: drop the member. |
| 187 | let (_, effect_and_oid) = self_run.split_once('/')?; |
| 188 | effect_and_oid.to_owned() |
| 189 | }; |
| 190 | let (effect, short_oid) = tail.split_once('/')?; |
| 191 | if effect.is_empty() || short_oid.is_empty() || short_oid.contains('/') { |
| 192 | return None; |
| 193 | } |
| 194 | Some((effect.to_owned(), short_oid.to_owned())) |
| 195 | } |
| 196 | |
| 197 | /// The ref holding the effect named `name` — `refs/meta/effects/<name>` |
| 198 | /// (`meta-ref.granularity`, `effect.definition`). |
| 199 | // @relation(meta-ref.granularity, effect.definition, scope=function) |
| 200 | pub fn effect_ref(name: &str) -> Result<FullName> { |
| 201 | build(format!("refs/meta/effects/{name}")) |
| 202 | } |
| 203 | |
| 204 | /// The canonical ref for one effect's result on one tested commit — |
| 205 | /// `refs/meta/results/<effect>/<short_oid>` (`meta-ref.granularity`, |
| 206 | /// `effect.definition`: derived from the effect's own name, never a |
| 207 | /// stored pattern). |
| 208 | // @relation(meta-ref.granularity, effect.definition, scope=function) |
| 209 | pub fn result_ref(effect: &str, short_oid: &str) -> Result<FullName> { |
| 210 | build(format!("refs/meta/results/{effect}/{short_oid}")) |
| 211 | } |
| 212 | |
| 213 | /// The ref mirroring one effect's result that `member` produced on their own |
| 214 | /// executor — `refs/meta/self/<member>/<effect>/<short_oid>` |
| 215 | /// (`meta-ref.inbox`, `effect.self-run`). |
| 216 | /// |
| 217 | /// `self` is its own top-level namespace, a fixed segment from the spec's |
| 218 | /// namespace table, so the canonical results glob |
| 219 | /// (`refs/meta/results/<effect>/*`) and the self-run glob |
| 220 | /// (`refs/meta/self/<member>/*`) are disjoint by construction. |
| 221 | /// |
| 222 | /// # Examples |
| 223 | /// |
| 224 | /// ``` |
| 225 | /// use ents_model::{MemberId, namespace}; |
| 226 | /// |
| 227 | /// let name = namespace::self_result_ref(&MemberId::new("jdc"), "unit", "abc123") |
| 228 | /// .expect("valid segments"); |
| 229 | /// assert_eq!(name.as_bstr(), "refs/meta/self/jdc/unit/abc123"); |
| 230 | /// ``` |
| 231 | // @relation(meta-ref.inbox, scope=function) |
| 232 | pub fn self_result_ref(member: &MemberId, effect: &str, short_oid: &str) -> Result<FullName> { |
| 233 | build(format!("refs/meta/self/{member}/{effect}/{short_oid}")) |
| 234 | } |
| 235 | |
| 236 | /// The member segment of a `refs/meta/self/<member>/...` refname, or `None` |
| 237 | /// when `name` is not under the self-run namespace (`meta-ref.inbox`). |
| 238 | /// |
| 239 | /// The gate keys self-run authorization on this segment — a member may write |
| 240 | /// only their *own* self-run mirror — so it is extracted here, next to the |
| 241 | /// namespace table it belongs to, rather than re-parsed by every caller. |
| 242 | /// |
| 243 | /// # Examples |
| 244 | /// |
| 245 | /// ``` |
| 246 | /// use ents_model::{MemberId, namespace}; |
| 247 | /// |
| 248 | /// let name: gix::refs::FullName = "refs/meta/self/jdc/unit/abc123".try_into().expect("valid"); |
| 249 | /// assert_eq!(namespace::self_run_owner(name.as_ref()), Some(MemberId::new("jdc"))); |
| 250 | /// |
| 251 | /// let canonical: gix::refs::FullName = "refs/meta/results/unit/abc123".try_into().expect("valid"); |
| 252 | /// assert_eq!(namespace::self_run_owner(canonical.as_ref()), None); |
| 253 | /// ``` |
| 254 | // @relation(meta-ref.inbox, scope=function) |
| 255 | #[must_use] |
| 256 | pub fn self_run_owner(name: &FullNameRef) -> Option<MemberId> { |
| 257 | let path = name.as_bstr().to_string(); |
| 258 | let rest = path.strip_prefix("refs/meta/self/")?; |
| 259 | let (member, _) = rest.split_once('/')?; |
| 260 | Some(MemberId::new(member)) |
| 261 | } |
| 262 | |
| 263 | /// The ref holding one inbox entity authored by `member`, awaiting |
| 264 | /// adoption — `refs/meta/inbox/<member>/<id>` (`meta-ref.inbox`). |
| 265 | /// |
| 266 | /// The member segment leads, symmetric with [`self_result_ref`], so the |
| 267 | /// gate's authorization keys off the refname alone: a member — either |
| 268 | /// provenance — may write only under its own segment, and nobody, |
| 269 | /// admins included, writes into another member's inbox. |
| 270 | /// |
| 271 | /// # Examples |
| 272 | /// |
| 273 | /// ``` |
| 274 | /// use ents_model::{MemberId, namespace}; |
| 275 | /// |
| 276 | /// let name = namespace::inbox_ref(&MemberId::new("jdc"), "issue-42").expect("valid"); |
| 277 | /// assert_eq!(name.as_bstr(), "refs/meta/inbox/jdc/issue-42"); |
| 278 | /// ``` |
| 279 | // @relation(meta-ref.inbox, scope=function) |
| 280 | pub fn inbox_ref(member: &MemberId, id: &str) -> Result<FullName> { |
| 281 | build(format!("refs/meta/inbox/{member}/{id}")) |
| 282 | } |
| 283 | |
| 284 | /// The member segment of a `refs/meta/inbox/<member>/...` refname, or |
| 285 | /// `None` when `name` is not under the inbox namespace or carries no |
| 286 | /// member segment (`meta-ref.inbox`). |
| 287 | /// |
| 288 | /// Mirrors [`self_run_owner`]: the gate keys inbox authorization on this |
| 289 | /// segment, so it is extracted here, next to the namespace table. |
| 290 | /// |
| 291 | /// # Examples |
| 292 | /// |
| 293 | /// ``` |
| 294 | /// use ents_model::{MemberId, namespace}; |
| 295 | /// |
| 296 | /// let name: gix::refs::FullName = "refs/meta/inbox/jdc/issue-42".try_into().expect("valid"); |
| 297 | /// assert_eq!(namespace::inbox_owner(name.as_ref()), Some(MemberId::new("jdc"))); |
| 298 | /// |
| 299 | /// // The legacy unscoped shape has no owner to authorize. |
| 300 | /// let unscoped: gix::refs::FullName = "refs/meta/inbox/issue-42".try_into().expect("valid"); |
| 301 | /// assert_eq!(namespace::inbox_owner(unscoped.as_ref()), None); |
| 302 | /// ``` |
| 303 | // @relation(meta-ref.inbox, scope=function) |
| 304 | #[must_use] |
| 305 | pub fn inbox_owner(name: &FullNameRef) -> Option<MemberId> { |
| 306 | let path = name.as_bstr().to_string(); |
| 307 | let rest = path.strip_prefix("refs/meta/inbox/")?; |
| 308 | let (member, _) = rest.split_once('/')?; |
| 309 | Some(MemberId::new(member)) |
| 310 | } |
| 311 | |
| 312 | /// The ref holding the toolchain manifest named `name` — |
| 313 | /// `refs/meta/toolchains/<name>` (`model.toolchain`). |
| 314 | // @relation(model.toolchain, scope=function) |
| 315 | pub fn toolchain_ref(name: &str) -> Result<FullName> { |
| 316 | build(format!("refs/meta/toolchains/{name}")) |
| 317 | } |
| 318 | |
| 319 | /// The ref holding the redaction record named `id` — |
| 320 | /// `refs/meta/redactions/<id>` (`model.redaction`). |
| 321 | // @relation(model.redaction, scope=function) |
| 322 | pub fn redaction_ref(id: &str) -> Result<FullName> { |
| 323 | build(format!("refs/meta/redactions/{id}")) |
| 324 | } |
| 325 | |
| 326 | /// The ref holding one claim — `refs/meta/claims/<id>`, where `<id>` is |
| 327 | /// the claim's own genesis commit oid: the sign-then-name envelope, same |
| 328 | /// as a comment or an issue. Unlike those, this ref is append-once — the |
| 329 | /// tip IS the genesis; a changed assertion is a new claim, never an |
| 330 | /// advance. |
| 331 | /// |
| 332 | /// A claim's ledger commit carries its binding's witness commits as |
| 333 | /// parents, so its ancestry deliberately reaches into code history exactly |
| 334 | /// as a review pin's does — the gate's parentless-roots walk must never |
| 335 | /// apply to a claim, the same carve-out [`review_pin_ref`]'s doc already |
| 336 | /// describes for pins. |
| 337 | /// |
| 338 | /// # Examples |
| 339 | /// |
| 340 | /// ``` |
| 341 | /// use ents_model::namespace; |
| 342 | /// |
| 343 | /// let name = namespace::claim_ref("deadbeef").expect("valid"); |
| 344 | /// assert_eq!(name.as_bstr(), "refs/meta/claims/deadbeef"); |
| 345 | /// ``` |
| 346 | pub fn claim_ref(id: &str) -> Result<FullName> { |
| 347 | build(format!("refs/meta/claims/{id}")) |
| 348 | } |
| 349 | |
| 350 | /// Which entity namespace a `refs/meta/*` refname falls in. |
| 351 | /// |
| 352 | /// The inbox and self-run namespaces classify as their own variants even |
| 353 | /// though `meta-ref.inbox` requires them to "hold the same typed trees as |
| 354 | /// their canonical counterparts; only the refname rule differs" — that |
| 355 | /// refname rule is exactly what the gate routes on, so the distinction |
| 356 | /// belongs in this table rather than re-derived by every caller. |
| 357 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 358 | #[non_exhaustive] |
| 359 | pub enum Namespace { |
| 360 | /// `refs/meta/member/*`. |
| 361 | Member, |
| 362 | /// `refs/meta/issues/*`. |
| 363 | Issue, |
| 364 | /// `refs/meta/comments/*`. |
| 365 | Comment, |
| 366 | /// `refs/meta/reviews/*`. |
| 367 | Review, |
| 368 | /// `refs/meta/pins/*` — retention pins (`model.review-pin`, |
| 369 | /// [`review_pin_ref`]): empty-tree commits anchoring other content's |
| 370 | /// reachability, the sole exception to `meta-ref.namespace`'s |
| 371 | /// tree-is-the-entity shape. |
| 372 | Pin, |
| 373 | /// `refs/meta/effects/*`. |
| 374 | Effect, |
| 375 | /// `refs/meta/results/*` — canonical results only; a member's self-run |
| 376 | /// mirror is [`Namespace::SelfRun`], disjoint by construction |
| 377 | /// (`meta-ref.inbox`). |
| 378 | Result, |
| 379 | /// `refs/meta/self/<member>/*` — self-run result mirrors |
| 380 | /// (`meta-ref.inbox`, [`self_result_ref`]). |
| 381 | SelfRun, |
| 382 | /// `refs/meta/toolchains/*`. |
| 383 | Toolchain, |
| 384 | /// `refs/meta/redactions/*`. |
| 385 | Redaction, |
| 386 | /// `refs/meta/inbox/<member>/*` — entities awaiting adoption, |
| 387 | /// each under its author's own segment (`meta-ref.inbox`, |
| 388 | /// [`inbox_ref`], [`inbox_owner`]). |
| 389 | Inbox, |
| 390 | /// The fixed `refs/meta/account` ref. |
| 391 | Account, |
| 392 | /// The fixed `refs/meta/config` ref. |
| 393 | Config, |
| 394 | /// `refs/meta/claims/*` — one ref per claim ([`claim_ref`]), append-once: |
| 395 | /// the tip IS the genesis. A claim's ancestry reaches into code history |
| 396 | /// through its binding's witness commits, exactly as [`Namespace::Pin`]'s |
| 397 | /// does, so the all-roots walk must never apply to it either. |
| 398 | Claim, |
| 399 | /// Under `refs/meta/*`, but in no namespace this build of the vocabulary |
| 400 | /// knows. `model.extensibility` requires a stock server to carry entity |
| 401 | /// types it cannot parse, so the gate and `receive` must be able to |
| 402 | /// route an unknown meta namespace generically rather than confuse it |
| 403 | /// with a ref that is not forge state at all — which is why this is a |
| 404 | /// variant and not a `None`. |
| 405 | Unknown, |
| 406 | } |
| 407 | |
| 408 | /// Classify a `refs/meta/*` refname by which entity's namespace it falls in. |
| 409 | /// |
| 410 | /// Returns `None` only when `name` is not under `refs/meta/*` at all |
| 411 | /// (`meta-ref.namespace`: "All forge state MUST live under `refs/meta/*`"). |
| 412 | /// A refname under `refs/meta/*` whose namespace this build does not know |
| 413 | /// classifies as [`Namespace::Unknown`] instead — it is still forge state |
| 414 | /// (`model.extensibility`), just state this vocabulary cannot interpret. |
| 415 | /// |
| 416 | /// # Examples |
| 417 | /// |
| 418 | /// ``` |
| 419 | /// use ents_model::namespace::{self, Namespace}; |
| 420 | /// |
| 421 | /// let name: gix::refs::FullName = "refs/meta/issues/42".try_into().expect("valid"); |
| 422 | /// assert_eq!(namespace::classify(name.as_ref()), Some(Namespace::Issue)); |
| 423 | /// |
| 424 | /// let outside: gix::refs::FullName = "refs/heads/main".try_into().expect("valid"); |
| 425 | /// assert_eq!(namespace::classify(outside.as_ref()), None); |
| 426 | /// |
| 427 | /// let novel: gix::refs::FullName = "refs/meta/widgets/7".try_into().expect("valid"); |
| 428 | /// assert_eq!(namespace::classify(novel.as_ref()), Some(Namespace::Unknown)); |
| 429 | /// ``` |
| 430 | // @relation(meta-ref.namespace, meta-ref.granularity, model.extensibility, scope=function) |
| 431 | #[must_use] |
| 432 | pub fn classify(name: &FullNameRef) -> Option<Namespace> { |
| 433 | let path = name.as_bstr().to_string(); |
| 434 | let rest = path.strip_prefix("refs/meta/")?; |
| 435 | |
| 436 | if rest == "account" { |
| 437 | return Some(Namespace::Account); |
| 438 | } |
| 439 | if rest == "config" { |
| 440 | return Some(Namespace::Config); |
| 441 | } |
| 442 | let (segment, _) = rest.split_once('/').unwrap_or((rest, "")); |
| 443 | match segment { |
| 444 | "member" => Some(Namespace::Member), |
| 445 | "issues" => Some(Namespace::Issue), |
| 446 | "comments" => Some(Namespace::Comment), |
| 447 | "reviews" => Some(Namespace::Review), |
| 448 | "pins" => Some(Namespace::Pin), |
| 449 | "effects" => Some(Namespace::Effect), |
| 450 | "results" => Some(Namespace::Result), |
| 451 | "self" => Some(Namespace::SelfRun), |
| 452 | "toolchains" => Some(Namespace::Toolchain), |
| 453 | "redactions" => Some(Namespace::Redaction), |
| 454 | "inbox" => Some(Namespace::Inbox), |
| 455 | "claims" => Some(Namespace::Claim), |
| 456 | _ => Some(Namespace::Unknown), |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | /// Whether a `refs/meta/*` refname is under the inbox namespace — |
| 461 | /// `refs/meta/inbox/<member>/*` — per `meta-ref.inbox`. |
| 462 | /// |
| 463 | /// This is namespace membership only; which member owns the segment is |
| 464 | /// [`inbox_owner`]'s answer. A member's self-run result mirror lives |
| 465 | /// under its own top-level `refs/meta/self/*` namespace |
| 466 | /// ([`self_result_ref`]), not under the inbox, so it is deliberately not |
| 467 | /// matched here. |
| 468 | /// |
| 469 | /// # Examples |
| 470 | /// |
| 471 | /// ``` |
| 472 | /// use ents_model::namespace; |
| 473 | /// |
| 474 | /// let inbox: gix::refs::FullName = "refs/meta/inbox/jdc/issue-42".try_into().expect("valid"); |
| 475 | /// assert!(namespace::is_inbox(inbox.as_ref())); |
| 476 | /// |
| 477 | /// let canonical: gix::refs::FullName = "refs/meta/results/unit/abc123".try_into().expect("valid"); |
| 478 | /// assert!(!namespace::is_inbox(canonical.as_ref())); |
| 479 | /// ``` |
| 480 | // @relation(meta-ref.inbox, scope=function) |
| 481 | #[must_use] |
| 482 | pub fn is_inbox(name: &FullNameRef) -> bool { |
| 483 | let path = name.as_bstr().to_string(); |
| 484 | let Some(rest) = path.strip_prefix("refs/meta/") else { |
| 485 | return false; |
| 486 | }; |
| 487 | rest.starts_with("inbox/") |
| 488 | } |
| 489 | |
| 490 | #[cfg(test)] |
| 491 | mod tests { |
| 492 | #![allow(clippy::expect_used, reason = "unit test")] |
| 493 | |
| 494 | use rstest::rstest; |
| 495 | |
| 496 | use super::*; |
| 497 | |
| 498 | fn name(s: &str) -> FullName { |
| 499 | s.try_into().expect("valid refname in test table") |
| 500 | } |
| 501 | |
| 502 | #[rstest] |
| 503 | #[case::member("refs/meta/member/jdc", Some(Namespace::Member))] |
| 504 | #[case::issue("refs/meta/issues/42", Some(Namespace::Issue))] |
| 505 | #[case::comment("refs/meta/comments/abc", Some(Namespace::Comment))] |
| 506 | #[case::review("refs/meta/reviews/7", Some(Namespace::Review))] |
| 507 | #[case::pin("refs/meta/pins/reviews/7", Some(Namespace::Pin))] |
| 508 | #[case::effect("refs/meta/effects/unit", Some(Namespace::Effect))] |
| 509 | #[case::result("refs/meta/results/unit/abc123", Some(Namespace::Result))] |
| 510 | #[case::self_run("refs/meta/self/jdc/unit/abc123", Some(Namespace::SelfRun))] |
| 511 | #[case::toolchain("refs/meta/toolchains/rust-stable", Some(Namespace::Toolchain))] |
| 512 | #[case::redaction("refs/meta/redactions/abc", Some(Namespace::Redaction))] |
| 513 | #[case::inbox("refs/meta/inbox/jdc/issue-42", Some(Namespace::Inbox))] |
| 514 | #[case::claim("refs/meta/claims/deadbeef", Some(Namespace::Claim))] |
| 515 | #[case::account("refs/meta/account", Some(Namespace::Account))] |
| 516 | #[case::config("refs/meta/config", Some(Namespace::Config))] |
| 517 | #[case::outside_meta("refs/heads/main", None)] |
| 518 | #[case::unrecognized("refs/meta/index/abc", Some(Namespace::Unknown))] |
| 519 | #[case::novel_namespace("refs/meta/widgets/7", Some(Namespace::Unknown))] |
| 520 | // @relation(meta-ref.namespace, meta-ref.granularity, scope=function, role=Verifies) |
| 521 | fn classify_matches_the_namespace_table( |
| 522 | #[case] refname: &str, |
| 523 | #[case] expected: Option<Namespace>, |
| 524 | ) { |
| 525 | assert_eq!(classify(name(refname).as_ref()), expected); |
| 526 | } |
| 527 | |
| 528 | #[rstest] |
| 529 | #[case::inbox_entity("refs/meta/inbox/jdc/issue-42", true)] |
| 530 | #[case::unscoped_inbox_is_still_the_namespace("refs/meta/inbox/legacy", true)] |
| 531 | #[case::canonical_result("refs/meta/results/unit/abc123", false)] |
| 532 | #[case::self_run_mirror("refs/meta/self/jdc/unit/abc123", false)] |
| 533 | #[case::member("refs/meta/member/jdc", false)] |
| 534 | // @relation(meta-ref.inbox, scope=function, role=Verifies) |
| 535 | fn is_inbox_matches_only_inbox_namespaces(#[case] refname: &str, #[case] expected: bool) { |
| 536 | assert_eq!(is_inbox(name(refname).as_ref()), expected); |
| 537 | } |
| 538 | |
| 539 | #[rstest] |
| 540 | #[case::self_run("refs/meta/self/jdc/unit/abc123", Some("jdc"))] |
| 541 | #[case::self_run_deep_effect("refs/meta/self/worker-1/it/deadbeef", Some("worker-1"))] |
| 542 | #[case::bare_self_segment("refs/meta/self/jdc", None)] |
| 543 | #[case::canonical_result("refs/meta/results/unit/abc123", None)] |
| 544 | #[case::outside_meta("refs/heads/main", None)] |
| 545 | // @relation(meta-ref.inbox, scope=function, role=Verifies) |
| 546 | fn self_run_owner_extracts_only_the_self_namespace_member( |
| 547 | #[case] refname: &str, |
| 548 | #[case] expected: Option<&str>, |
| 549 | ) { |
| 550 | assert_eq!( |
| 551 | self_run_owner(name(refname).as_ref()), |
| 552 | expected.map(MemberId::new) |
| 553 | ); |
| 554 | } |
| 555 | |
| 556 | #[rstest] |
| 557 | #[case::scoped("refs/meta/inbox/jdc/issue-42", Some("jdc"))] |
| 558 | #[case::deep_id("refs/meta/inbox/worker-1/a/b", Some("worker-1"))] |
| 559 | #[case::unscoped_legacy("refs/meta/inbox/issue-42", None)] |
| 560 | #[case::self_run("refs/meta/self/jdc/unit/abc", None)] |
| 561 | #[case::outside_meta("refs/heads/main", None)] |
| 562 | // @relation(meta-ref.inbox, scope=function, role=Verifies) |
| 563 | fn inbox_owner_extracts_only_the_member_segment( |
| 564 | #[case] refname: &str, |
| 565 | #[case] expected: Option<&str>, |
| 566 | ) { |
| 567 | assert_eq!( |
| 568 | inbox_owner(name(refname).as_ref()), |
| 569 | expected.map(MemberId::new) |
| 570 | ); |
| 571 | } |
| 572 | |
| 573 | #[rstest] |
| 574 | // @relation(meta-ref.namespace, effect.definition, scope=function, role=Verifies) |
| 575 | fn every_builder_stays_under_refs_meta() { |
| 576 | let id = MemberId::new("jdc"); |
| 577 | let built = [ |
| 578 | member_ref(&id).expect("valid"), |
| 579 | issue_ref("42").expect("valid"), |
| 580 | comment_ref("abc").expect("valid"), |
| 581 | review_ref("deadbeef", &id).expect("valid"), |
| 582 | review_pin_ref("deadbeef", &id).expect("valid"), |
| 583 | effect_ref("unit").expect("valid"), |
| 584 | result_ref("unit", "abc123").expect("valid"), |
| 585 | self_result_ref(&id, "unit", "abc123").expect("valid"), |
| 586 | inbox_ref(&id, "issue-42").expect("valid"), |
| 587 | toolchain_ref("rust-stable").expect("valid"), |
| 588 | redaction_ref("abc").expect("valid"), |
| 589 | claim_ref("deadbeef").expect("valid"), |
| 590 | ]; |
| 591 | for name in built { |
| 592 | assert!( |
| 593 | name.as_bstr().starts_with(b"refs/meta/"), |
| 594 | "{name} must live under refs/meta/*" |
| 595 | ); |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | #[rstest] |
| 600 | // @relation(meta-ref.namespace, scope=function, role=Verifies) |
| 601 | fn invalid_component_is_rejected_not_silently_accepted() { |
| 602 | let err = issue_ref("../escape").expect_err("must reject a refname with a `..` component"); |
| 603 | assert!(matches!(err, Error::InvalidRefName { .. })); |
| 604 | } |
| 605 | } |