use std::io::{self, IsTerminal, Write};
use serde_json::{Value, json};
use super::client::Instance;
use super::program;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoveArgs {
pub owner: String,
pub project: String,
pub instance: Option<String>,
pub password: Option<String>,
pub assume_yes: bool,
}
pub fn split_reference(reference: &str, owner: &str) -> Result<(String, String), io::Error> {
let (found_owner, project) = match reference.split_once('/') {
Some((left, right)) => (left.trim(), right.trim()),
None => (owner.trim(), reference.trim()),
};
if found_owner.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"'{reference}' names no owner; write <owner>/<project>, pass `--owner <name>`, or \
run `{} use <owner>/<project>`",
program()
),
));
}
if project.is_empty() || project.contains('/') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("'{reference}' is not <owner>/<project>"),
));
}
Ok((found_owner.to_string(), project.to_string()))
}
pub async fn run(instance: &Instance, args: &RemoveArgs) -> Result<(), io::Error> {
let RemoveArgs { owner, project, .. } = args;
let listing = instance
.get_json(&format!("/api/users/{owner}/projects"))
.await?;
let empty = Vec::new();
let items = listing
.get("items")
.and_then(Value::as_array)
.unwrap_or(&empty);
let found = items
.iter()
.find(|item| item.get("project").and_then(Value::as_str) == Some(project.as_str()));
let Some(found) = found else {
let known = items
.iter()
.filter_map(|item| item.get("project").and_then(Value::as_str))
.collect::<Vec<_>>();
let suffix = if known.is_empty() {
format!("{owner} has no projects on this instance")
} else {
format!("{owner} has: {}", known.join(", "))
};
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no project '{owner}/{project}'; {suffix}"),
));
};
let title = found
.get("title")
.and_then(Value::as_str)
.unwrap_or_default();
println!("Removing {owner}/{project} from {}", instance.base_url());
if !title.is_empty() {
println!(" {title}");
}
println!();
println!("This deletes the project record and everything under it: its source,");
println!("its pipelines, its database, and the files it stores for its users.");
println!("It is irreversible, and it is not an uninstall — nothing is restored");
println!("by installing the package it came from again.");
println!();
if !args.assume_yes && !confirm(project)? {
println!("Cancelled. Nothing was removed.");
return Ok(());
}
let password = super::read_password(args.password.as_deref(), owner)?;
instance
.delete_json(
&format!("/api/users/{owner}/projects/{project}"),
&json!({ "project_name": project, "password": password }),
)
.await?;
println!("Removed {owner}/{project}.");
Ok(())
}
fn confirm(project: &str) -> Result<bool, io::Error> {
if !io::stdin().is_terminal() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"stdin is not a terminal and this removal was not confirmed; pass --yes to confirm \
it non-interactively (the password is still required, on --password or stdin)",
));
}
print!("Type '{project}' to confirm: ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
Ok(answer.trim() == project)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_name_takes_the_resolved_owner() {
let (owner, project) = split_reference("shop", "superadmin").expect("split");
assert_eq!(owner, "superadmin");
assert_eq!(project, "shop");
}
#[test]
fn a_qualified_reference_overrides_the_resolved_owner() {
let (owner, project) = split_reference("alice/shop", "superadmin").expect("split");
assert_eq!(owner, "alice");
assert_eq!(project, "shop");
}
#[test]
fn a_bare_name_with_no_owner_anywhere_names_how_to_supply_one() {
let err = split_reference("shop", "").expect_err("no owner");
assert!(err.to_string().contains("--owner"), "{err}");
}
#[test]
fn a_three_part_reference_is_refused_rather_than_truncated() {
let err = split_reference("alice/shop/extra", "superadmin").expect_err("too deep");
assert!(
err.to_string().contains("is not <owner>/<project>"),
"{err}"
);
}
}