crates/gunnar-ui-web/src/repo_delete.rs
//! **What the repository delete decides** (gunnar.rs 2026-10-28), on the host side of the
//! wasm boundary. Same argument as [`crate::room`].
//!
//! The owner deleted a repository "in the console" and it went on answering `git ls-remote`.
//! The console had no delete at all: the BFF's `DELETE /api/repos/{ns}/{name}` (a SOFT
//! `Repos.Delete`, signed as the person) was there and tested, the Storage page's Deleted list
//! and its Undo were there, and no page ever sent the delete. So whatever was pressed never
//! reached gunnar.
//!
//! **The owner's spec (2026-10-28), GitHub's shape:** at the bottom of the repository's
//! Settings, a red-bordered **Danger zone** with **Delete this repository**. A confirm step:
//! the person types `<namespace>/<name>`, the button stays disabled until it matches, and the
//! words say it is a soft delete that can be restored for N days — N is the server's
//! (`GET /api/repos/{ns}/{name}/deletable`, gunnar's `ListDeletedResponse.default_retention_days`).
//! On success the repository list, with "Deleted <ns>/<name>" and an Undo that calls undelete.
//! Whoever gunnar does not let delete sees the section with the server's refusal sentence,
//! never a hidden one. Who may delete is gunnar's answer (`Repos.Delete` needs Admin over the
//! repository or its namespace); nothing here decides it.
use serde::Deserialize;
/// The section's title.
pub const ZONE: &str = "Danger zone";
/// The button's words, once: the one that opens the confirm step and the one that sends.
pub const BUTTON: &str = "Delete this repository";
/// The administrator's list of deleted repositories, with its Restore (Admin → Storage).
pub const RECENTLY_DELETED: &str = "/admin/storage#deleted";
/// Where the delete goes: the repository's own route, each segment encoded.
pub fn url(namespace: &str, name: &str) -> String {
let segment = |s: &str| crate::auth::percent_encode(s).replace('/', "%2F");
format!("/api/repos/{}/{}", segment(namespace), segment(name))
}
/// May THIS session delete it, and for how many days can it be restored: the server's answer.
pub fn authority_url(namespace: &str, name: &str) -> String {
format!("{}/deletable", url(namespace, name))
}
/// The undo: `Repos.Undelete`, signed as the person.
pub fn undelete_url(namespace: &str, name: &str) -> String {
format!("{}/undelete", url(namespace, name))
}
/// Where the page goes after a delete: the repository list, told what was deleted.
pub fn after(namespace: &str, name: &str) -> String {
format!("/repos?deleted={}", crate::auth::percent_encode(&format!("{namespace}/{name}")))
}
/// The `deleted=` value back into its namespace and name.
pub fn split(path: &str) -> Option<(String, String)> {
let (namespace, name) = path.trim().split_once('/')?;
(!namespace.is_empty() && !name.is_empty() && !name.contains('/'))
.then(|| (namespace.to_owned(), name.to_owned()))
}
/// `GET /api/repos/{ns}/{name}/deletable`, as the server answers it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct Deletable {
/// gunnar lets this session's person delete it.
#[serde(default)]
pub may_delete: bool,
/// Why not, in the server's words, when `may_delete` is false.
#[serde(default)]
pub why: Option<String>,
/// How many days the bytes stay restorable; 0 = the server did not say.
#[serde(default)]
pub retention_days: u32,
}
/// What the Danger zone draws, from what the page holds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Standing {
/// `/auth/status` or the server's answer has not come: draw nothing yet.
Waiting,
/// Offered, restorable for this many days (0 = not said).
Allowed(u32),
/// Not offered, and this is why.
Refused(String),
}
impl Standing {
/// From whether `/auth/status` answered, whether anybody is signed in, and the server's
/// answer (or why it could not be read). The VisibilityControl pattern.
pub fn of(answered: bool, signed_in: bool, answer: Option<Result<&Deletable, &str>>) -> Standing {
if !answered {
return Standing::Waiting;
}
if !signed_in {
return Standing::Refused("Sign in to delete this repository.".to_owned());
}
match answer {
None => Standing::Waiting,
Some(Err(why)) => Standing::Refused(format!(
"The console could not tell whether you may delete this repository, so it does \
not offer it: {why}"
)),
Some(Ok(a)) if !a.may_delete => Standing::Refused(
a.why
.clone()
.filter(|w| !w.trim().is_empty())
.unwrap_or_else(|| "Only an admin of this repository or of its account may delete it.".to_owned()),
),
Some(Ok(a)) => Standing::Allowed(a.retention_days),
}
}
}
/// Whether the typed-back path arms the button: exactly the repository's path.
pub fn may_send(typed: &str, path: &str) -> bool {
!path.is_empty() && typed.trim() == path
}
/// "for 30 days", or the window unnamed when the server did not say.
fn window(days: u32) -> String {
match days {
0 => "until the server's retention window closes".to_owned(),
1 => "for 1 day".to_owned(),
n => format!("for {n} days"),
}
}
/// The section's one line, before the confirm step is opened.
pub fn summary(days: u32) -> String {
format!("A soft delete: it stops answering git at once and can be restored {}.", window(days))
}
/// What the delete does, said in the confirm step before it is sent.
pub fn consequence(path: &str, days: u32) -> String {
format!(
"This is a soft delete. {path} stops answering git at once (clone, fetch and push are \
refused) and leaves every list. It can be restored {}.",
window(days)
)
}
/// The notice on the repository list after a delete.
pub fn notice(path: &str) -> String {
format!("Deleted {path}")
}
/// The notice after its Undo.
pub fn restored(path: &str) -> String {
format!("Restored {path}")
}
/// The line after a refused delete, in plain words; gunnar's own sentence is shown under it.
pub fn refused(status: u16, path: &str) -> String {
match status {
403 => format!("Not deleted: only an admin of {path} or of its account may delete it."),
401 => "Not deleted: your session has ended. Sign in again and retry.".to_owned(),
404 => format!("Not deleted: Gunnar does not know {path}."),
503 => "Not deleted: Gunnar is unavailable just now. Try again in a while.".to_owned(),
0 => "Not deleted: the console could not reach Gunnar. Try again.".to_owned(),
_ => "Not deleted: Gunnar could not delete it just now. Try again in a while.".to_owned(),
}
}
/// The line after a refused Undo.
pub fn undo_refused(status: u16, path: &str) -> String {
match status {
403 => format!("Not restored: only an admin of {path} or of its account may restore it."),
401 => "Not restored: your session has ended. Sign in again and retry.".to_owned(),
404 => format!("Not restored: Gunnar holds no deleted {path} any more."),
503 => "Not restored: Gunnar is unavailable just now. Try again in a while.".to_owned(),
0 => "Not restored: the console could not reach Gunnar. Try again.".to_owned(),
_ => "Not restored: Gunnar could not restore it just now. Try again in a while.".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_delete_goes_to_the_repositorys_own_route() {
assert_eq!(url("mikronova", "tunnr-rusternetes"), "/api/repos/mikronova/tunnr-rusternetes");
assert_eq!(url("a b", "x/y"), "/api/repos/a%20b/x%2Fy");
assert_eq!(authority_url("bob", "app"), "/api/repos/bob/app/deletable");
assert_eq!(undelete_url("bob", "app"), "/api/repos/bob/app/undelete");
}
#[test]
fn only_the_exact_path_typed_back_arms_the_button() {
assert!(may_send("t/del-probe", "t/del-probe"));
assert!(may_send(" t/del-probe ", "t/del-probe"));
assert!(!may_send("t/del", "t/del-probe"));
assert!(!may_send("t/del-prob", "t/del-probe"));
assert!(!may_send("T/del-probe", "t/del-probe"));
assert!(!may_send("", "t/del-probe"));
assert!(!may_send("", ""));
}
/// ★ **The confirm step says soft delete and the server's N** (owner spec 2026-10-28).
#[test]
fn the_words_say_soft_delete_and_the_servers_days() {
let c = consequence("t/del-probe", 30);
assert!(c.contains("soft delete") && c.contains("stops answering git"), "{c}");
assert!(c.contains("restored for 30 days"), "{c}");
assert!(consequence("t/x", 7).contains("restored for 7 days"));
assert!(consequence("t/x", 1).contains("restored for 1 day."));
// No number from the server: no number made up here.
let unsaid = consequence("t/x", 0);
assert!(unsaid.contains("retention window") && !unsaid.contains("30"), "{unsaid}");
assert!(summary(30).contains("30 days"));
}
/// ★ **After the delete: the list, the notice, its Undo** (owner spec 2026-10-28).
#[test]
fn the_list_is_told_what_was_deleted_and_can_undo_it() {
assert_eq!(after("bob", "del-probe"), "/repos?deleted=bob/del-probe");
assert_eq!(after("a b", "c"), "/repos?deleted=a%20b/c");
assert_eq!(split("bob/del-probe"), Some(("bob".to_owned(), "del-probe".to_owned())));
assert_eq!(split("bob"), None);
assert_eq!(split("/x"), None);
assert_eq!(split("a/b/c"), None);
assert_eq!(notice("bob/del-probe"), "Deleted bob/del-probe");
assert_eq!(restored("bob/del-probe"), "Restored bob/del-probe");
}
/// ★ **A session gunnar does not let delete sees why, never a hidden section**
/// (owner spec 2026-10-28; the VisibilityControl pattern).
#[test]
fn the_server_decides_who_is_offered_the_delete() {
assert_eq!(Standing::of(false, true, None), Standing::Waiting);
assert_eq!(Standing::of(true, true, None), Standing::Waiting);
let ok = Deletable { may_delete: true, why: None, retention_days: 30 };
assert_eq!(Standing::of(true, true, Some(Ok(&ok))), Standing::Allowed(30));
let no = Deletable {
may_delete: false,
why: Some("Only an admin of bob/app or of its account may delete it. principal kim has no admin grant".to_owned()),
retention_days: 0,
};
match Standing::of(true, true, Some(Ok(&no))) {
Standing::Refused(why) => assert!(why.contains("kim has no admin grant"), "the server's sentence: {why}"),
other => panic!("{other:?}"),
}
match Standing::of(true, false, None) {
Standing::Refused(why) => assert!(why.contains("Sign in"), "{why}"),
other => panic!("{other:?}"),
}
match Standing::of(true, true, Some(Err("gunnar is unavailable"))) {
Standing::Refused(why) => assert!(why.contains("gunnar is unavailable"), "{why}"),
other => panic!("{other:?}"),
}
let parsed: Deletable = serde_json::from_str(r#"{"may_delete":true,"why":null,"retention_days":30}"#).unwrap();
assert_eq!(parsed, ok);
}
/// ★ **403, 404 and 503 are said inline, in plain words** (owner spec 2026-10-28).
#[test]
fn refusals_are_plain_words() {
assert!(refused(403, "t/del-probe").contains("only an admin"));
assert!(refused(404, "t/del-probe").contains("does not know t/del-probe"));
assert!(refused(503, "t/del-probe").contains("unavailable"));
assert!(undo_refused(403, "t/x").contains("Not restored"));
assert!(undo_refused(404, "t/x").contains("no deleted t/x"));
assert!(undo_refused(503, "t/x").contains("unavailable"));
}
/// ★ **The Settings tab offers the delete** (gunnar.rs 2026-10-28). Red before: no page
/// sent `DELETE /api/repos/{ns}/{name}`, so a repository could not be deleted from the
/// console at all, while the Storage page promised "a delete from this console is a SOFT
/// delete" and offered its Undo.
#[test]
fn the_settings_tab_offers_the_delete_and_sends_it() {
let page = include_str!("pages/repo.rs");
assert!(page.contains("<DeleteRepository"), "the Settings tab mounts the delete control");
assert!(page.contains("fn DeleteRepository"), "the control is on the repository page");
assert!(
page.contains("crate::api::delete::<serde_json::Value>(&crate::repo_delete::url("),
"the control sends DELETE to the repository's own route"
);
assert!(page.contains("crate::repo_delete::may_send("), "the path is typed back before it arms");
}
/// ★ **The Danger zone, for everybody signed in, with the server's answer** (owner spec
/// 2026-10-28). Red before: the delete was a plain "Delete" panel drawn only for whoever
/// may administer, with no confirm step, no number of days, and a line instead of the list.
#[test]
fn the_danger_zone_asks_the_server_and_goes_to_the_list() {
let page = include_str!("pages/repo.rs");
assert!(page.contains("class=\"panel danger-zone\""), "a red-bordered section of its own");
assert!(page.contains("crate::repo_delete::ZONE"), "titled Danger zone");
assert!(page.contains("crate::repo_delete::authority_url("), "who may, and N, are the server's");
assert!(page.contains("crate::repo_delete::Standing::of("), "the VisibilityControl pattern");
assert!(page.contains("crate::repo_delete::after("), "on success the repository list");
assert!(
!page.contains("delete_rights.get().may_administer() || session"),
"the section is not hidden from whoever may not delete"
);
let list = include_str!("pages/repos.rs");
assert!(list.contains("<DeletedNotice"), "the list says what was deleted");
assert!(list.contains("crate::repo_delete::undelete_url("), "its Undo calls undelete");
assert!(list.contains("crate::repo_delete::RECENTLY_DELETED"), "and links the administrator's list");
let storage = include_str!("pages/storage.rs");
assert!(storage.contains("id=\"deleted\""), "the list the notice links to has its anchor");
let css = include_str!("../styles/console.css");
assert!(css.contains(".panel.danger-zone"), "the zone is red-bordered");
}
}