Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 86 additions & 17 deletions crates/minimal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,14 @@ pub struct LsArgs {
#[derive(Debug, Args)]
pub struct DestroyArgs {
/// Session identifier (UUID or session name)
pub session: String,
#[arg(required_unless_present = "all", conflicts_with = "all")]
pub session: Option<String>,
/// Destroy all sessions
#[arg(long)]
pub all: bool,
/// Skip confirmation when destroying all sessions
#[arg(long, short, requires = "all")]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user did --all, do we really need a confirmation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel a confirm is a nice to have for now, we can remove if it gets annoying but I'd say we're the ones that will be killing everything, most folks will have pets running not cattle.

pub force: bool,
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -585,20 +592,22 @@ fn ensure_daemon(global: &GlobalArgs) -> Result<(), anyhow::Error> {
.context("Failed to ensure the minimald daemon is running")
}

/// Prompt the user with a yes/no question on stderr. Defaults to yes
/// (empty input or Y/y/yes returns true; anything else returns false).
fn confirm(question: &str) -> Result<bool, anyhow::Error> {
eprint!("{question} [Y/n] ");
/// Prompt the user with a yes/no question on stderr.
fn confirm(question: &str, default: bool) -> Result<bool, anyhow::Error> {
let prompt = if default { "[Y/n]" } else { "[y/N]" };
eprint!("{question} {prompt} ");
std::io::stderr().flush().ok();

let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.context("reading stdin")?;
let trimmed = input.trim();
Ok(trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("y")
|| trimmed.eq_ignore_ascii_case("yes"))
Ok(if trimmed.is_empty() {
default
} else {
trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes")
})
}

/// List sessions via the `ListSessions` RPC.
Expand Down Expand Up @@ -859,7 +868,7 @@ fn offer_mfile_scaffold(
// "yes" — and, when a config is discovered under `.minimal/`, the init
// writer would clobber it. Only prompt on a real terminal; anywhere else
// (and on a declined prompt) carry on without scaffolding.
if !std::io::stdin().is_terminal() || !confirm("Would you like to create one?")? {
if !std::io::stdin().is_terminal() || !confirm("Would you like to create one?", true)? {
eprintln!(
"Continuing without one; the session gets a default environment. \
Run 'minimal init' to give the project its own config."
Expand Down Expand Up @@ -1267,20 +1276,80 @@ pub async fn cmd_destroy(global: &GlobalArgs, args: DestroyArgs) -> Result<(), a

let mut client = connect_daemon(global).await?;

if args.all {
return destroy_all_sessions(&mut client, args.force).await;
}

let session = args
.session
.as_deref()
.context("a session or --all is required")?;
let record = resolve_session(&mut client, session).await?;

destroy_session(&mut client, record.id, record.name.as_deref()).await
}

async fn destroy_all_sessions(
client: &mut client::Client,
force: bool,
) -> Result<(), anyhow::Error> {
use minimald_rpc::ListSessions;

let sessions = client
.oneshot_rpc::<ListSessions>(())
.await
.context("ListSessions RPC failed")?
.sessions;

if sessions.is_empty() {
println!("No active sessions.");
return Ok(());
}

if !force {
if !std::io::stdin().is_terminal() {
bail!("refusing to destroy all sessions without confirmation; pass --force")
}
if !confirm(&format!("Destroy all {} sessions?", sessions.len()), false)? {
println!("Aborted.");
return Ok(());
}
}

let session_count = sessions.len();
let mut failures = 0;
for session in sessions {
if let Err(error) = destroy_session(client, session.id, session.name.as_deref()).await {
failures += 1;
eprintln!(
"Failed to destroy session {} ({}): {error:#}",
session.id,
session.name.as_deref().unwrap_or("-")
);
}
}

if failures > 0 {
bail!("failed to destroy {failures} of {session_count} sessions")
}

Ok(())
}

async fn destroy_session(
client: &mut client::Client,
id: sessions::SessionId,
name: Option<&str>,
) -> Result<(), anyhow::Error> {
use minimald_rpc::{DestroySession, DestroySessionRequest};
let record = resolve_session(&mut client, &args.session).await?;

let resp = client
.oneshot_rpc::<DestroySession>(DestroySessionRequest { id: record.id })
.oneshot_rpc::<DestroySession>(DestroySessionRequest { id })
.await
.context("DestroySession RPC failed")?;

if resp.ok().is_some() {
println!(
"Destroyed session {} ({})",
record.id,
record.name.as_deref().unwrap_or("-")
);
println!("Destroyed session {} ({})", id, name.unwrap_or("-"));
} else {
bail!("DestroySession returned an error from the daemon");
}
Expand Down Expand Up @@ -1566,7 +1635,7 @@ fn run_init_flow(config: mctx::Config, skip_confirm: bool) -> Result<(), anyhow:
eprint!("{}", plan.content);
eprintln!("---");
eprintln!();
if !confirm("Continue?")? {
if !confirm("Continue?", true)? {
eprintln!("Aborted.");
return Ok(());
}
Expand Down
51 changes: 48 additions & 3 deletions crates/minimal/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,9 @@ async fn destroy_removes_session() {
cmd_destroy(
&args,
DestroyArgs {
session: session_id.to_string(),
session: Some(session_id.to_string()),
all: false,
force: false,
},
)
.await
Expand All @@ -284,7 +286,9 @@ async fn destroy_by_name() {
cmd_destroy(
&args,
DestroyArgs {
session: "by-name".to_string(),
session: Some("by-name".to_string()),
all: false,
force: false,
},
)
.await
Expand All @@ -297,13 +301,54 @@ async fn destroy_unknown_session_fails() {
let result = cmd_destroy(
&args,
DestroyArgs {
session: "nonexistent".to_string(),
session: Some("nonexistent".to_string()),
all: false,
force: false,
},
)
.await;
assert!(result.is_err());
}

#[tokio::test]
async fn destroy_all_removes_every_session() {
let (daemon, args) = setup().await;
let _ = create_session(&daemon, "first").await;
let _ = create_session(&daemon, "second").await;

cmd_destroy(
&args,
DestroyArgs {
session: None,
all: true,
force: true,
},
)
.await
.unwrap();

let mut client = daemon.server.connect().await;
use minimald_rpc::ListSessions;
let resp = client.call::<ListSessions>(&()).await;
assert!(resp.sessions.is_empty());
}

#[tokio::test]
async fn destroy_all_succeeds_when_there_are_no_sessions() {
let (_daemon, args) = setup().await;

cmd_destroy(
&args,
DestroyArgs {
session: None,
all: true,
force: true,
},
)
.await
.unwrap();
}

// --- stop ---

#[tokio::test]
Expand Down