use anyhow::anyhow;
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write as _;
use crate::pipeline::board::{TicketPhase, store as board_store};
use crate::tools::ToolAnswer;
use crate::tools::ticket::{GET_TICKET_COMMENT_MAX, GET_TICKET_DESC_MAX, GET_TICKET_LAST_N_FULL};
use crate::workspace::WorkspaceStore;
use crate::{Role, Tool, Workspace, WorkspaceStatus};
const OUTPUT_BYTE_LIMIT: usize = 24_000;
const BOARD_LINE_LIMIT: usize = 100;
const NO_WORKSPACE: &str = "No workspace is registered.";
pub(crate) struct WorkspaceTool;
#[async_trait]
impl Tool for WorkspaceTool {
fn name(&self) -> &'static str {
"workspace"
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"action": {
"type": "string",
"enum": ["list", "add", "pause", "resume", "board", "ticket", "comment"],
"description": "Which action to perform; the descriptions of `workspace`, `name`, `path`, `phase`, `ticket_id`, `full` and `content` say which action each field belongs to."
},
"workspace": {
"type": "string",
"description": "(list / board / pause / resume) The registered workspace to act on, spelled exactly as the `<registered-workspaces>` block or `list` spells it — matching is exact, never approximate. Omit it to cover every registered workspace, which for `pause` / `resume` is a fleet-wide freeze, so name the workspace whenever one was named to you."
},
"name": {
"type": "string",
"description": "(add) The new workspace's short unique name, used in ticket ids and the GUI."
},
"path": {
"type": "string",
"description": "(add) Absolute path to the software project directory to register."
},
"phase": {
"type": "string",
"description": "(board) Narrow the listing to one ticket phase (e.g. 'queued', 'in_development', 'done', 'cancelled'). Omit it to list every non-archived ticket."
},
"ticket_id": {
"type": "string",
"description": "(ticket / comment) The ticket's full id — `<workspace>-<number>`, which already carries its workspace. A bare number is refused."
},
"full": {
"type": "boolean",
"description": "(ticket) Return the ticket whole and unclipped. Default false."
},
"content": {
"type": "string",
"description": "(comment) The clarification note to leave on the ticket."
}
}),
&["action"],
)
}
fn preserve_full_output(&self) -> bool {
true
}
async fn execute(&self, _ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
Ok(self.answer(args).await?.text)
}
async fn execute_answer(
&self,
_ws: &Workspace,
args: serde_json::Value,
) -> anyhow::Result<ToolAnswer> {
self.answer(args).await
}
}
impl WorkspaceTool {
async fn answer(&self, args: serde_json::Value) -> anyhow::Result<ToolAnswer> {
match super::get_str(&args, "action")? {
"list" => Ok(ToolAnswer::done(
list_workspaces(super::get_opt_str_strict(&args, "workspace")?).await?,
)),
"add" => add_workspace(&args).await,
"pause" => pause_or_resume(write_address(&args, "pause")?, true).await,
"resume" => pause_or_resume(write_address(&args, "resume")?, false).await,
"board" => Ok(ToolAnswer::done(
list_board(
super::get_opt_str_strict(&args, "workspace")?,
super::get_opt_str_strict(&args, "phase")?,
)
.await?,
)),
"ticket" => Ok(ToolAnswer::done(
show_ticket(
super::get_str(&args, "ticket_id")?,
super::get_bool(&args, "full", false)?,
)
.await?,
)),
"comment" => Ok(ToolAnswer::done(
add_note(
super::get_str(&args, "ticket_id")?,
super::get_str(&args, "content")?,
)
.await?,
)),
other => Err(anyhow!(
"unknown action '{other}' — expected one of: list, add, pause, resume, board, ticket, comment"
)),
}
}
}
fn write_address<'a>(args: &'a serde_json::Value, action: &str) -> anyhow::Result<Option<&'a str>> {
if let Some(address) = super::get_opt_str_strict(args, "workspace")? {
return Ok(Some(address));
}
if args.as_object().is_some_and(|obj| {
obj.iter()
.any(|(key, value)| key.as_str() != "action" && !value.is_null())
}) {
return Err(anyhow!(
"usage: with no \"workspace\" this `{action}` covers every registered workspace, so \
the call may name nothing else — hint: pass \"workspace\" to act on one workspace, or \
drop the extra field to really cover every registered workspace"
));
}
Ok(None)
}
async fn registered_workspace(name: &str) -> anyhow::Result<Workspace> {
let name = name.trim();
let workspaces = crate::users::registered_workspaces().await?;
if let Some(ws) = workspaces.iter().find(|ws| ws.name == name) {
return Ok(ws.clone());
}
let registered = if workspaces.is_empty() {
"none".to_string()
} else {
workspaces
.iter()
.map(|ws| ws.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
Err(if crate::users::is_personal_workspace(name) {
anyhow!(
"'{name}' is a personal space, not a registered workspace — personal spaces have \
no pipeline and no board; registered: {registered}"
)
} else {
anyhow!("No workspace is registered under the name '{name}' — registered: {registered}")
})
}
async fn registered_ticket_id(raw: &str) -> anyhow::Result<String> {
let Ok((Some(prefix), full)) = crate::tools::ticket::parse_ticket_id(raw) else {
return Err(anyhow!(
"Ticket id '{}' is not a full '<workspace>-<number>' id — `ticket` and `comment` \
are addressed by a ticket's full id, which already carries its workspace.",
raw.trim()
));
};
registered_workspace(prefix).await?;
Ok(full.to_string())
}
async fn list_workspaces(only: Option<&str>) -> anyhow::Result<String> {
let workspaces = crate::users::registered_workspaces().await?;
if workspaces.is_empty() {
return Ok(NO_WORKSPACE.to_string());
}
let resolved =
crate::users::resolve_selected_workspace_name(crate::users::ADMIN_USER_NAME).await;
let active = resolved
.as_deref()
.filter(|name| workspaces.iter().any(|ws| &ws.name == name));
let listed = match only {
Some(name) => vec![registered_workspace(name).await?],
None => workspaces,
};
let mut listing = String::from("Registered workspaces:\n");
for ws in &listed {
let paused = if ws.paused { ", paused" } else { "" };
let maintenance = if ws.maintenance_enabled {
", maintenance on"
} else {
""
};
let active_marker = if active == Some(ws.name.as_str()) {
" [active]"
} else {
""
};
let _ = writeln!(
listing,
"- {} ({}{paused}{maintenance}): {}{active_marker}",
ws.name, ws.status, ws.path
);
}
if resolved.is_none() {
let _ = writeln!(listing, "The active workspace could not be read.");
} else if active.is_none() {
let _ = writeln!(listing, "No workspace is active.");
}
Ok(listing.trim_end().to_string())
}
async fn add_workspace(args: &serde_json::Value) -> anyhow::Result<ToolAnswer> {
let name = super::get_str(args, "name")?;
let path = super::get_str(args, "path")?;
crate::workspace::store().add(name, path).await?;
match crate::users::store()
.set_selected_workspace(crate::users::ADMIN_USER_NAME, Some(name))
.await
{
Ok(()) => Ok(ToolAnswer::done(format!(
"Registered workspace '{name}' at {path} and made it your active workspace. \
The workspace is being picked up — if the model provider is configured its first \
discovery pass will start shortly."
))),
Err(e) => Ok(ToolAnswer::partial(format!(
"Registered workspace '{name}' at {path}, but making it your active workspace \
failed: {e} — activate it from the desktop's own workspace list."
))),
}
}
enum PipelineChange {
NotReady,
Already,
Write,
}
fn pipeline_change(ws: &Workspace, paused: bool) -> PipelineChange {
if ws.status != WorkspaceStatus::Ready {
PipelineChange::NotReady
} else if ws.paused == paused {
PipelineChange::Already
} else {
PipelineChange::Write
}
}
async fn pause_or_resume(name: Option<&str>, paused: bool) -> anyhow::Result<ToolAnswer> {
match name {
Some(name) => Ok(ToolAnswer::done(
set_one_pipeline_paused(name, paused).await?,
)),
None => set_all_pipelines_paused(paused).await,
}
}
async fn set_one_pipeline_paused(name: &str, paused: bool) -> anyhow::Result<String> {
let ws = registered_workspace(name).await?;
match pipeline_change(&ws, paused) {
PipelineChange::NotReady => Err(anyhow!(
"Workspace '{}' is {} — only a ready workspace has a pipeline to pause or resume.",
ws.name,
ws.status
)),
PipelineChange::Already => Ok(if paused {
format!("The pipeline of workspace '{}' is already paused.", ws.name)
} else {
format!(
"The pipeline of workspace '{}' is not paused — nothing to resume.",
ws.name
)
}),
PipelineChange::Write => {
crate::workspace::store()
.set_paused(&ws.name, paused)
.await?;
let verb = if paused { "paused" } else { "resumed" };
Ok(format!("Pipeline {verb} for workspace '{}'.", ws.name))
}
}
}
async fn set_pipelines_paused(
store: &WorkspaceStore,
workspaces: &[Workspace],
paused: bool,
) -> anyhow::Result<ToolAnswer> {
if workspaces.is_empty() {
return Ok(ToolAnswer::done(NO_WORKSPACE));
}
let header = if paused {
"Paused every registered workspace where possible:"
} else {
"Resumed every registered workspace where possible:"
};
let mut out = format!("{header}\n");
let mut incomplete = false;
for ws in workspaces {
let line = match pipeline_change(ws, paused) {
PipelineChange::NotReady => {
incomplete = true;
format!("- {}: skipped — not ready ({})", ws.name, ws.status)
}
PipelineChange::Already if paused => format!("- {}: already paused", ws.name),
PipelineChange::Already => format!("- {}: not paused", ws.name),
PipelineChange::Write => match store.set_paused(&ws.name, paused).await {
Ok(()) if paused => format!("- {}: paused", ws.name),
Ok(()) => format!("- {}: resumed", ws.name),
Err(e) => {
incomplete = true;
format!("- {}: failed — {e}", ws.name)
}
},
};
let _ = writeln!(out, "{line}");
}
let report = out.trim_end().to_string();
Ok(if incomplete {
ToolAnswer::partial(report)
} else {
ToolAnswer::done(report)
})
}
async fn set_all_pipelines_paused(paused: bool) -> anyhow::Result<ToolAnswer> {
let workspaces = crate::users::registered_workspaces().await?;
set_pipelines_paused(crate::workspace::store(), &workspaces, paused).await
}
async fn list_board(only: Option<&str>, phase: Option<&str>) -> anyhow::Result<String> {
let workspaces = crate::users::registered_workspaces().await?;
let phase_filter = match phase {
Some(s) if s.eq_ignore_ascii_case("archived") => {
anyhow::bail!(
"Archived tickets are never listed — archiving is a one-way visibility switch that hides a ticket from every listing, and a single archived ticket stays readable with `ticket` by its id."
);
}
Some(s) => Some(s.parse::<TicketPhase>()?),
None => None,
};
let only = match only {
Some(name) => Some(registered_workspace(name).await?.name),
None => None,
};
let mut tickets = board_store().list_all_tickets(None, phase_filter).await?;
tickets.retain(|t| {
workspaces.iter().any(|ws| ws.name == t.workspace_name)
&& only.as_ref().is_none_or(|name| name == &t.workspace_name)
});
if tickets.is_empty() {
return Ok("No tickets found.".to_string());
}
let total = tickets.len();
let mut lines = Vec::new();
let mut bytes = 0;
for t in &tickets {
let mut line = t.short_display();
if !lines.is_empty()
&& (lines.len() == BOARD_LINE_LIMIT || bytes + line.len() > OUTPUT_BYTE_LIMIT)
{
break;
}
if line.len() > OUTPUT_BYTE_LIMIT {
let cut = OUTPUT_BYTE_LIMIT - "…".len();
line = format!("{}…", crate::util::truncate_bytes(&line, cut));
}
bytes += line.len() + 1;
lines.push(line);
}
let shown = lines.len();
let mut out = String::from("Tickets:\n");
for line in &lines {
let _ = writeln!(out, "{line}");
}
if shown < total {
let _ = write!(
out,
"\n(partial listing: showing the first {shown} of {total} tickets — \
narrow it with `phase`, name a single `workspace`, or read one ticket with `ticket`.)"
);
}
Ok(out)
}
async fn show_ticket(ticket_id: &str, full: bool) -> anyhow::Result<String> {
let id = registered_ticket_id(ticket_id).await?;
let Some(ticket) = board_store().get_ticket(&id).await? else {
return Err(anyhow!("Ticket {id} not found."));
};
if full {
return Ok(ticket.detailed_display());
}
let shortened = ticket.detailed_display_limited(
GET_TICKET_DESC_MAX,
GET_TICKET_COMMENT_MAX,
GET_TICKET_LAST_N_FULL,
);
if shortened.len() <= OUTPUT_BYTE_LIMIT {
return Ok(shortened);
}
Ok(format!(
"{}…\n(shortened view cut short — pass `full: true` for the ticket whole and unclipped.)",
crate::util::truncate_bytes(&shortened, OUTPUT_BYTE_LIMIT)
))
}
async fn add_note(ticket_id: &str, content: &str) -> anyhow::Result<String> {
anyhow::ensure!(
!content.trim().is_empty(),
"usage: \"content\" is empty — hint: pass the note's text to leave on the ticket"
);
let id = registered_ticket_id(ticket_id).await?;
if board_store().get_ticket_phase(&id).await?.is_none() {
return Err(anyhow!("Ticket {id} not found — no note was left."));
}
board_store()
.add_comment(&id, Role::Assistant.as_str(), content)
.await?;
Ok(format!("Note added to ticket {id} as 'assistant'."))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_args(action: &str, name: Option<&str>) -> serde_json::Value {
match name {
Some(name) => json!({ "action": action, "workspace": name }),
None => json!({ "action": action }),
}
}
#[tokio::test]
async fn refusals_are_plain_and_named() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/ws_refusals");
let tool = WorkspaceTool;
let err = tool
.execute(&ws, tool_args("pause", Some("ws_absent")))
.await
.unwrap_err()
.to_string();
assert!(err.contains("No workspace is registered"), "got: {err}");
let err = tool
.execute(&ws, tool_args("resume", Some("personal:admin")))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("personal space") && err.contains("registered:"),
"got: {err}"
);
crate::util::test::create_test_workspace("/tmp/ws_pending", "ws_pending").await;
let err = tool
.execute(&ws, tool_args("pause", Some("ws_pending")))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("pending") && err.contains("ready"),
"got: {err}"
);
assert!(
!crate::workspace::get_by_name("ws_pending")
.await
.expect("read")
.expect("exists")
.paused,
"the refusal must not pause the workspace"
);
}
#[tokio::test]
async fn pause_and_resume_a_ready_workspace() {
let _guard = crate::util::test::STORE_LOCK.lock().await;
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/ws_ready");
let tool = WorkspaceTool;
crate::util::test::create_test_workspace("/tmp/ws_ready", "ws_ready").await;
crate::workspace::store()
.set_status("ws_ready", &WorkspaceStatus::Ready)
.await
.expect("mark ready");
let reply = tool
.execute(&ws, tool_args("pause", Some("ws_ready")))
.await
.expect("pause");
assert!(
reply.contains("ws_ready") && reply.contains("paused"),
"got: {reply}"
);
assert!(
crate::workspace::get_by_name("ws_ready")
.await
.expect("read")
.expect("exists")
.paused
);
let listing = tool
.execute(&ws, tool_args("list", None))
.await
.expect("list");
assert!(
listing.contains("- ws_ready (ready, paused):"),
"got: {listing}"
);
let reply = tool
.execute(&ws, tool_args("pause", Some("ws_ready")))
.await
.expect("re-pause");
assert!(reply.contains("already paused"), "got: {reply}");
let reply = tool
.execute(&ws, tool_args("resume", Some("ws_ready")))
.await
.expect("resume");
assert!(reply.contains("resumed"), "got: {reply}");
assert!(
!crate::workspace::get_by_name("ws_ready")
.await
.expect("read")
.expect("exists")
.paused
);
assert!(
!tool
.execute(&ws, tool_args("list", None))
.await
.expect("list")
.contains("- ws_ready (ready, paused)"),
"a resumed workspace must not carry the pause marker"
);
}
#[tokio::test]
async fn pause_every_registered_workspace_skips_not_ready() {
let (store, _dir) = crate::open_test_store!(WorkspaceStore, "workspace");
let root = crate::util::test::test_root();
let ready = root.join("ws_fleet");
let pending = root.join("ws_fleet_pending");
for dir in [&ready, &pending] {
std::fs::create_dir_all(dir).expect("create workspace dir");
}
let ready = ready.to_str().expect("utf-8 path");
store.add("ws_fleet", ready).await.expect("add ws_fleet");
store
.set_status("ws_fleet", &WorkspaceStatus::Ready)
.await
.expect("mark ready");
store.set_paused("ws_fleet", false).await.expect("unpause");
store
.add("ws_fleet_pending", pending.to_str().expect("utf-8 path"))
.await
.expect("add ws_fleet_pending");
store
.set_paused("ws_fleet_pending", false)
.await
.expect("unpause ws_fleet_pending");
let workspaces = store.list().await.expect("list");
let reply = set_pipelines_paused(&store, &workspaces, true)
.await
.expect("pause all");
assert!(
reply.text.contains("- ws_fleet: paused"),
"got: {}",
reply.text
);
assert!(
reply
.text
.contains("- ws_fleet_pending: skipped — not ready (pending)"),
"got: {}",
reply.text
);
assert!(
reply.partial,
"a workspace the call left untouched makes the fleet call partial: {}",
reply.text
);
assert!(
store
.get_by_name("ws_fleet")
.await
.expect("read")
.expect("exists")
.paused
);
assert!(
!store
.get_by_name("ws_fleet_pending")
.await
.expect("read")
.expect("exists")
.paused,
"a not-ready workspace must not be paused"
);
let workspaces = store.list().await.expect("list");
let reply = set_pipelines_paused(&store, &workspaces, false)
.await
.expect("resume all");
assert!(
reply.text.contains("- ws_fleet: resumed"),
"got: {}",
reply.text
);
assert!(
reply.partial,
"the not-ready workspace is left untouched by the resume too: {}",
reply.text
);
let reply = set_pipelines_paused(&store, &[], true)
.await
.expect("empty set");
assert_eq!(reply.text, NO_WORKSPACE);
}
#[tokio::test]
async fn a_misspelled_address_never_escalates_to_the_fleet() {
let _guard = crate::util::test::STORE_LOCK.lock().await;
crate::util::test::init_test_stores().await;
let tool = WorkspaceTool;
crate::util::test::create_test_workspace("/tmp/ws_address", "ws_address").await;
crate::workspace::store()
.set_status("ws_address", &WorkspaceStatus::Ready)
.await
.expect("mark ready");
let ws = crate::workspace::test_ws("/tmp/ws_address");
for call in [
json!({ "action": "pause", "name": "ws_address" }),
json!({ "action": "resume", "path": "/tmp/ws_address" }),
json!({ "action": "resume", "ticket_id": "ws_address-1" }),
json!({ "action": "pause", "workspace_name": "ws_address" }),
crate::tools::normalize_tool_call(
"workspace",
json!({ "action": "pause", "id": "ws_address" }),
)
.1,
] {
let err = tool
.execute(&ws, call.clone())
.await
.unwrap_err()
.to_string();
assert!(err.contains("usage:"), "for {call}: {err}");
}
for value in [
json!(3),
json!(["ws_address"]),
json!({ "name": "ws_address" }),
] {
let err = tool
.execute(&ws, json!({ "action": "resume", "workspace": value }))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("usage:") && err.contains("workspace"),
"got: {err}"
);
}
assert!(
!crate::workspace::get_by_name("ws_address")
.await
.expect("read")
.expect("exists")
.paused,
"a refused address must not pause anything"
);
for args in [
json!({ "action": "list", "workspace": "ws_address" }),
json!({ "action": "list", "workspace": "ws_address", "folder": "/tmp/ws_address" }),
] {
let listing = tool.execute(&ws, args.clone()).await.expect("list");
assert!(
listing.starts_with("Registered workspaces:\n- ws_address ("),
"for {args}: {listing}"
);
}
let reply = tool
.execute(&ws, json!({ "action": "pause", "workspace": "ws_address" }))
.await
.expect("pause");
assert!(
reply.contains("Pipeline paused for workspace 'ws_address'"),
"got: {reply}"
);
}
#[tokio::test]
async fn ticket_actions_refuse_a_bare_number() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/ws_bare");
let tool = WorkspaceTool;
let err = tool
.execute(&ws, json!({ "action": "ticket", "ticket_id": "42" }))
.await
.unwrap_err()
.to_string();
assert!(err.contains("<workspace>-<number>"), "got: {err}");
let err = tool
.execute(
&ws,
json!({ "action": "comment", "ticket_id": "42", "content": "hi" }),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("<workspace>-<number>"), "got: {err}");
}
#[tokio::test]
async fn ticket_refuses_unregistered_workspace_and_missing_ticket() {
crate::util::test::init_test_stores().await;
let ws = crate::workspace::test_ws("/tmp/ws_ticket");
let tool = WorkspaceTool;
let err = tool
.execute(
&ws,
json!({ "action": "ticket", "ticket_id": "ws_ghost-1" }),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("No workspace is registered"), "got: {err}");
crate::util::test::create_test_workspace("/tmp/ws_ticket", "ws_ticket").await;
let err = tool
.execute(
&ws,
json!({ "action": "ticket", "ticket_id": "ws_ticket-9999" }),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("not found"), "got: {err}");
}
#[tokio::test]
async fn comment_refuses_a_missing_ticket() {
crate::util::test::init_test_stores().await;
let tool = WorkspaceTool;
crate::util::test::create_test_workspace("/tmp/ws_note", "ws_note").await;
let ws = crate::workspace::test_ws("/tmp/ws_note");
let err = tool
.execute(
&ws,
json!({ "action": "comment", "ticket_id": "ws_note-9999", "content": " " }),
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("usage:"), "got: {err}");
let err = tool
.execute(
&ws,
json!({ "action": "comment", "ticket_id": "ws_note-9999", "content": "hi" }),
)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("not found") && err.contains("no note"),
"got: {err}"
);
assert!(
board_store()
.get_comments("ws_note-9999")
.await
.expect("read")
.is_empty(),
"a missing ticket must gain no note"
);
}
#[tokio::test]
async fn board_refuses_archived_and_skips_unregistered_workspaces() {
crate::util::test::init_test_stores().await;
let tool = WorkspaceTool;
let ws = crate::util::test::create_test_workspace("/tmp/ws_board", "ws_board").await;
let done_id = crate::util::test::make_ticket(
board_store(),
&ws,
"workspace board done",
TicketPhase::Done,
)
.await;
let ghost = crate::workspace::test_ws_named("/tmp/ws_ghost", "ws_ghost");
let ghost_id =
crate::util::test::make_ticket(board_store(), &ghost, "ghost done", TicketPhase::Done)
.await;
let err = tool
.execute(&ws, json!({ "action": "board", "phase": "archived" }))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("Archived tickets are never listed"),
"got: {err}"
);
let listing = tool
.execute(&ws, json!({ "action": "board", "phase": "done" }))
.await
.expect("board");
assert!(
listing.contains(&done_id),
"a done ticket of a registered workspace must be listed: {listing}"
);
assert!(
!listing.contains(&ghost_id),
"a ticket of an unregistered workspace must never be listed: {listing}"
);
let listing = tool
.execute(&ws, json!({ "action": "board" }))
.await
.expect("board");
assert!(
listing.contains(&done_id),
"a done ticket must be listed without a phase filter: {listing}"
);
}
#[tokio::test]
#[serial_test::serial(gui_admin_workspace)] async fn list_marks_the_active_workspace_or_says_none() {
crate::util::test::init_test_stores().await;
let tool = WorkspaceTool;
let ws = crate::workspace::test_ws("/tmp/ws_active");
crate::users::test_util::with_test_workspaces(
&[("/tmp/ws_active", "ws_active")],
crate::users::test_util::with_admin_workspace_restored(async {
crate::users::store()
.set_selected_workspace(crate::users::ADMIN_USER_NAME, Some("ws_active"))
.await
.expect("select");
let listing = tool
.execute(&ws, tool_args("list", None))
.await
.expect("list");
assert!(listing.contains("- ws_active ("), "got: {listing}");
assert!(listing.contains("[active]"), "got: {listing}");
assert!(
!listing.contains("No workspace is active."),
"got: {listing}"
);
crate::users::store()
.set_selected_workspace(crate::users::ADMIN_USER_NAME, Some("personal:admin"))
.await
.expect("select personal");
let listing = tool
.execute(&ws, tool_args("list", None))
.await
.expect("list");
assert!(
listing.contains("No workspace is active."),
"got: {listing}"
);
assert!(!listing.contains("[active]"), "got: {listing}");
}),
)
.await;
}
}