use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "id", rename_all = "kebab-case")]
pub enum ActionId {
StartRecording,
StopRecording,
AddNote {
#[serde(default)]
text: Option<String>,
},
StartLiveTranscript,
StopLiveTranscript,
ReadLiveTranscript,
StartDictation,
StopDictation,
OpenLatestMeeting,
OpenLatestMeetingFromToday,
OpenMeetingsFolder,
OpenMemosFolder,
OpenAssistantWorkspace,
ShowUpcomingMeetings,
SearchTranscripts {
#[serde(default)]
query: Option<String>,
},
ResearchTopic {
#[serde(default)]
query: Option<String>,
},
FindOpenActionItems,
FindRecentDecisions,
CopyMeetingMarkdown,
CreateDebriefDraftFromCurrentMeeting,
ConfirmCurrentSpeaker {
#[serde(default)]
confirmation: Option<String>,
},
RenameCurrentMeeting {
#[serde(default, alias = "new_title", alias = "newTitle")]
new_title: Option<String>,
},
}
impl ActionId {
pub fn as_kebab(&self) -> &'static str {
match self {
ActionId::StartRecording => "start-recording",
ActionId::StopRecording => "stop-recording",
ActionId::AddNote { .. } => "add-note",
ActionId::StartLiveTranscript => "start-live-transcript",
ActionId::StopLiveTranscript => "stop-live-transcript",
ActionId::ReadLiveTranscript => "read-live-transcript",
ActionId::StartDictation => "start-dictation",
ActionId::StopDictation => "stop-dictation",
ActionId::OpenLatestMeeting => "open-latest-meeting",
ActionId::OpenLatestMeetingFromToday => "open-latest-meeting-from-today",
ActionId::OpenMeetingsFolder => "open-meetings-folder",
ActionId::OpenMemosFolder => "open-memos-folder",
ActionId::OpenAssistantWorkspace => "open-assistant-workspace",
ActionId::ShowUpcomingMeetings => "show-upcoming-meetings",
ActionId::SearchTranscripts { .. } => "search-transcripts",
ActionId::ResearchTopic { .. } => "research-topic",
ActionId::FindOpenActionItems => "find-open-action-items",
ActionId::FindRecentDecisions => "find-recent-decisions",
ActionId::CopyMeetingMarkdown => "copy-meeting-markdown",
ActionId::CreateDebriefDraftFromCurrentMeeting => {
"create-debrief-draft-from-current-meeting"
}
ActionId::ConfirmCurrentSpeaker { .. } => "confirm-current-speaker",
ActionId::RenameCurrentMeeting { .. } => "rename-current-meeting",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputKind {
None,
InlineQuery,
PromptText,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Section {
Recording,
Dictation,
Navigation,
Search,
Meeting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Visibility {
pub requires: StateFlags,
pub forbids: StateFlags,
}
impl Visibility {
pub const fn always() -> Self {
Self {
requires: StateFlags::empty(),
forbids: StateFlags::empty(),
}
}
pub const fn when_idle() -> Self {
Self {
requires: StateFlags::empty(),
forbids: StateFlags::ANY_SESSION,
}
}
pub const fn when_recording() -> Self {
Self {
requires: StateFlags::RECORDING,
forbids: StateFlags::empty(),
}
}
pub const fn when_live_transcript() -> Self {
Self {
requires: StateFlags::LIVE_TRANSCRIPT,
forbids: StateFlags::empty(),
}
}
pub const fn when_dictation() -> Self {
Self {
requires: StateFlags::DICTATION,
forbids: StateFlags::empty(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct StateFlags(u8);
impl StateFlags {
pub const RECORDING: StateFlags = StateFlags(1 << 0);
pub const LIVE_TRANSCRIPT: StateFlags = StateFlags(1 << 1);
pub const DICTATION: StateFlags = StateFlags(1 << 2);
pub const MEETING_OPEN: StateFlags = StateFlags(1 << 3);
pub const ANY_SESSION: StateFlags =
StateFlags(Self::RECORDING.0 | Self::LIVE_TRANSCRIPT.0 | Self::DICTATION.0);
pub const fn empty() -> Self {
Self(0)
}
pub const fn contains(self, other: Self) -> bool {
(self.0 & other.0) == other.0
}
pub const fn intersects(self, other: Self) -> bool {
(self.0 & other.0) != 0
}
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
#[derive(Debug, Clone, Default)]
pub struct Context {
pub flags: StateFlags,
pub current_meeting: Option<PathBuf>,
pub selected_text: Option<String>,
}
impl Context {
pub fn is_idle(&self) -> bool {
!self.flags.intersects(StateFlags::ANY_SESSION)
}
fn effective_flags(&self) -> StateFlags {
let mut f = self.flags;
if self.current_meeting.is_some() {
f = f.union(StateFlags::MEETING_OPEN);
}
f
}
}
#[derive(Debug, Clone)]
pub struct Command {
pub id: ActionId,
pub title: &'static str,
pub description: &'static str,
pub keywords: &'static [&'static str],
pub section: Section,
pub visibility: Visibility,
pub input: InputKind,
}
pub fn commands() -> Vec<Command> {
vec![
Command {
id: ActionId::StartRecording,
title: "Start recording",
description: "Begin capturing audio to a new meeting",
keywords: &["record", "capture", "meeting", "begin", "transcribe"],
section: Section::Recording,
visibility: Visibility::when_idle(),
input: InputKind::None,
},
Command {
id: ActionId::StopRecording,
title: "Stop recording",
description: "Finish the current recording and process it",
keywords: &["stop", "finish", "end"],
section: Section::Recording,
visibility: Visibility::when_recording(),
input: InputKind::None,
},
Command {
id: ActionId::AddNote { text: None },
title: "Add note to current recording",
description: "Insert a timestamped note into the active session",
keywords: &["annotate", "mark", "highlight", "remember"],
section: Section::Recording,
visibility: Visibility::when_recording(),
input: InputKind::PromptText,
},
Command {
id: ActionId::StartLiveTranscript,
title: "Start live transcript",
description: "Real-time transcription for mid-meeting AI coaching",
keywords: &["live", "realtime", "coaching", "stream"],
section: Section::Recording,
visibility: Visibility::when_idle(),
input: InputKind::None,
},
Command {
id: ActionId::StopLiveTranscript,
title: "Stop live transcript",
description: "End the live transcript session",
keywords: &["stop", "end", "live"],
section: Section::Recording,
visibility: Visibility::when_live_transcript(),
input: InputKind::None,
},
Command {
id: ActionId::ReadLiveTranscript,
title: "Read live transcript",
description: "Show the current live session's text",
keywords: &["read", "view", "show", "live"],
section: Section::Recording,
visibility: Visibility::when_live_transcript(),
input: InputKind::None,
},
Command {
id: ActionId::StartDictation,
title: "Start dictation",
description: "Speak → clipboard + daily note",
keywords: &["dictate", "speech", "voice", "type"],
section: Section::Dictation,
visibility: Visibility::when_idle(),
input: InputKind::None,
},
Command {
id: ActionId::StopDictation,
title: "Stop dictation",
description: "End the dictation session",
keywords: &["stop", "end", "dictate"],
section: Section::Dictation,
visibility: Visibility::when_dictation(),
input: InputKind::None,
},
Command {
id: ActionId::OpenLatestMeeting,
title: "Open latest meeting",
description: "Jump to the most recently processed meeting",
keywords: &["last", "recent", "open"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::OpenLatestMeetingFromToday,
title: "Open latest meeting from today",
description: "Jump to the most recent meeting recorded today",
keywords: &["today", "latest", "today's"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::ShowUpcomingMeetings,
title: "Show upcoming meetings",
description: "Calendar-aware preview of what's next",
keywords: &["calendar", "next", "upcoming", "schedule"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::OpenMeetingsFolder,
title: "Open meetings folder",
description: "Reveal ~/meetings in Finder",
keywords: &["folder", "finder", "files"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::OpenMemosFolder,
title: "Open memos folder",
description: "Reveal ~/meetings/memos in Finder",
keywords: &["memo", "voice memo", "folder", "finder"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::SearchTranscripts { query: None },
title: "Search transcripts…",
description: "Full-text search across meetings and memos",
keywords: &["find", "grep", "lookup"],
section: Section::Search,
visibility: Visibility::always(),
input: InputKind::InlineQuery,
},
Command {
id: ActionId::ResearchTopic { query: None },
title: "Research topic…",
description: "Cross-meeting research with decisions and follow-ups",
keywords: &["research", "topic", "cross-meeting"],
section: Section::Search,
visibility: Visibility::always(),
input: InputKind::InlineQuery,
},
Command {
id: ActionId::FindOpenActionItems,
title: "Find open action items",
description: "Unresolved commitments across all meetings",
keywords: &["action", "todo", "tasks", "followup"],
section: Section::Search,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::FindRecentDecisions,
title: "Find recent decisions",
description: "All recorded decisions, newest first",
keywords: &["decisions", "choices", "recent"],
section: Section::Search,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::OpenAssistantWorkspace,
title: "Open assistant workspace",
description: "Reveal the assistant's current meeting folder",
keywords: &["ai", "assistant", "chat", "claude", "workspace"],
section: Section::Navigation,
visibility: Visibility::always(),
input: InputKind::None,
},
Command {
id: ActionId::CopyMeetingMarkdown,
title: "Copy meeting markdown",
description: "Copy the current meeting's markdown to clipboard",
keywords: &["copy", "clipboard", "export"],
section: Section::Meeting,
visibility: Visibility {
requires: StateFlags::MEETING_OPEN,
forbids: StateFlags::empty(),
},
input: InputKind::None,
},
Command {
id: ActionId::CreateDebriefDraftFromCurrentMeeting,
title: "Create debrief draft",
description: "Open an editable draft from the current meeting",
keywords: &["artifact", "draft", "memo", "create", "recall"],
section: Section::Meeting,
visibility: Visibility {
requires: StateFlags::MEETING_OPEN,
forbids: StateFlags::empty(),
},
input: InputKind::None,
},
Command {
id: ActionId::ConfirmCurrentSpeaker { confirmation: None },
title: "Confirm speaker name",
description: "Type SPEAKER_0 = Alex for the open meeting",
keywords: &["speaker", "correct", "confirm", "name", "attribution"],
section: Section::Meeting,
visibility: Visibility {
requires: StateFlags::MEETING_OPEN,
forbids: StateFlags::empty(),
},
input: InputKind::PromptText,
},
Command {
id: ActionId::RenameCurrentMeeting { new_title: None },
title: "Rename current meeting",
description: "Type a new title for the open meeting",
keywords: &["rename", "title", "edit"],
section: Section::Meeting,
visibility: Visibility {
requires: StateFlags::MEETING_OPEN,
forbids: StateFlags::empty(),
},
input: InputKind::PromptText,
},
]
}
pub fn visible_commands(ctx: &Context) -> Vec<Command> {
let flags = ctx.effective_flags();
commands()
.into_iter()
.filter(|c| is_visible(c.visibility, flags))
.collect()
}
pub fn is_visible(v: Visibility, flags: StateFlags) -> bool {
flags.contains(v.requires) && !flags.intersects(v.forbids)
}
pub mod recents {
use super::ActionId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
pub const CURRENT_VERSION: u32 = 1;
pub const VISIBLE_CAP: usize = 5;
pub const STORAGE_CAP: usize = 10;
pub const BROKEN_SUFFIX: &str = ".broken";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentsFile {
pub version: u32,
#[serde(default)]
pub entries: Vec<Value>,
}
impl Default for RecentsFile {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
entries: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RecentsStore {
pub file: RecentsFile,
pub read_only: bool,
}
impl RecentsStore {
pub fn default_path() -> PathBuf {
crate::config::Config::minutes_dir().join("palette.json")
}
pub fn load(path: &Path) -> Self {
if !path.exists() {
return Self::default();
}
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
tracing::warn!("[palette/recents] could not read {}: {}", path.display(), e);
return Self::default();
}
};
match serde_json::from_str::<RecentsFile>(&raw) {
Ok(file) => {
let read_only = file.version > CURRENT_VERSION;
if read_only {
tracing::info!(
"[palette/recents] {} has version {} > current {}; treating as read-only",
path.display(),
file.version,
CURRENT_VERSION
);
}
Self { file, read_only }
}
Err(e) => {
tracing::warn!(
"[palette/recents] failed to parse {}: {}; quarantining",
path.display(),
e
);
quarantine_corrupt_file(path);
Self::default()
}
}
}
pub fn visible(&self) -> Vec<ActionId> {
self.file
.entries
.iter()
.filter_map(|v| serde_json::from_value::<ActionId>(v.clone()).ok())
.take(VISIBLE_CAP)
.collect()
}
pub fn push_and_save(
&mut self,
action: &ActionId,
path: &Path,
) -> Result<Option<Value>, std::io::Error> {
if self.read_only {
return Ok(None);
}
let entry = match serde_json::to_value(action) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[palette/recents] failed to serialize action: {}", e);
return Ok(None);
}
};
self.file.entries.retain(|existing| existing != &entry);
self.file.entries.insert(0, entry.clone());
self.file.entries = trim_entries(&self.file.entries);
self.file.version = CURRENT_VERSION;
atomic_write_json(path, &self.file)?;
Ok(Some(entry))
}
}
fn trim_entries(entries: &[Value]) -> Vec<Value> {
let mut visible_count = 0usize;
let mut out = Vec::with_capacity(entries.len().min(STORAGE_CAP));
for entry in entries {
if out.len() >= STORAGE_CAP {
break;
}
let parses = serde_json::from_value::<ActionId>(entry.clone()).is_ok();
if parses {
if visible_count >= VISIBLE_CAP {
continue;
}
visible_count += 1;
out.push(entry.clone());
} else {
out.push(entry.clone());
}
}
out
}
fn quarantine_corrupt_file(path: &Path) {
let broken = path.with_extension({
let original = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_string();
if original.is_empty() {
BROKEN_SUFFIX.trim_start_matches('.').to_string()
} else {
format!("{}{}", original, BROKEN_SUFFIX)
}
});
if let Err(e) = std::fs::rename(path, &broken) {
tracing::warn!(
"[palette/recents] could not quarantine {} to {}: {}",
path.display(),
broken.display(),
e
);
}
}
fn atomic_write_json(path: &Path, file: &RecentsFile) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(file)
.map_err(|e| std::io::Error::other(format!("serialize palette recents: {}", e)))?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
set_recents_permissions(&tmp)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(unix)]
fn set_recents_permissions(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn set_recents_permissions(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn missing_file_loads_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
let store = RecentsStore::load(&path);
assert!(store.file.entries.is_empty());
assert!(!store.read_only);
assert!(store.visible().is_empty());
}
#[test]
fn round_trips_known_action() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
let mut store = RecentsStore::load(&path);
store
.push_and_save(&ActionId::StartRecording, &path)
.unwrap();
let reloaded = RecentsStore::load(&path);
let visible = reloaded.visible();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0], ActionId::StartRecording);
}
#[test]
fn dedupes_to_most_recent_position() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
let mut store = RecentsStore::load(&path);
store
.push_and_save(&ActionId::StartRecording, &path)
.unwrap();
store
.push_and_save(&ActionId::OpenLatestMeeting, &path)
.unwrap();
store
.push_and_save(&ActionId::StartRecording, &path)
.unwrap();
let reloaded = RecentsStore::load(&path);
let visible = reloaded.visible();
assert_eq!(
visible,
vec![ActionId::StartRecording, ActionId::OpenLatestMeeting]
);
}
#[test]
fn parameterized_actions_preserve_payload() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
let mut store = RecentsStore::load(&path);
store
.push_and_save(
&ActionId::SearchTranscripts {
query: Some("pricing".into()),
},
&path,
)
.unwrap();
let reloaded = RecentsStore::load(&path);
assert_eq!(
reloaded.visible(),
vec![ActionId::SearchTranscripts {
query: Some("pricing".into()),
}]
);
}
#[test]
fn corrupt_file_quarantines_and_returns_empty_store() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
std::fs::write(&path, "this is not json").unwrap();
let store = RecentsStore::load(&path);
assert!(store.file.entries.is_empty());
assert!(!store.read_only);
assert!(!path.exists(), "corrupt file should be quarantined");
let broken = dir.path().join("palette.json.broken");
assert!(
broken.exists(),
"expected quarantine at {}",
broken.display()
);
assert_eq!(
std::fs::read_to_string(&broken).unwrap(),
"this is not json"
);
}
#[test]
fn unknown_variants_are_hidden_but_preserved() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
std::fs::write(
&path,
r#"{
"version": 1,
"entries": [
{ "id": "future-command", "weird": true },
{ "id": "start-recording" }
]
}"#,
)
.unwrap();
let store = RecentsStore::load(&path);
assert_eq!(store.visible(), vec![ActionId::StartRecording]);
assert_eq!(store.file.entries.len(), 2);
let unknown = &store.file.entries[0];
assert_eq!(
unknown.get("id").and_then(|v| v.as_str()),
Some("future-command")
);
}
#[test]
fn unknown_variants_round_trip_through_push() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
std::fs::write(
&path,
r#"{
"version": 1,
"entries": [
{ "id": "future-command", "payload": "still here" }
]
}"#,
)
.unwrap();
let mut store = RecentsStore::load(&path);
store
.push_and_save(&ActionId::StopRecording, &path)
.unwrap();
let reloaded = RecentsStore::load(&path);
assert_eq!(reloaded.file.entries.len(), 2);
let mut found_unknown = false;
for entry in &reloaded.file.entries {
if entry.get("id").and_then(|v| v.as_str()) == Some("future-command") {
assert_eq!(
entry.get("payload").and_then(|v| v.as_str()),
Some("still here")
);
found_unknown = true;
}
}
assert!(found_unknown, "unknown entry must round-trip unchanged");
assert_eq!(reloaded.visible(), vec![ActionId::StopRecording]);
}
#[test]
fn newer_version_files_are_read_only() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
std::fs::write(
&path,
r#"{
"version": 9999,
"entries": [
{ "id": "start-recording" }
]
}"#,
)
.unwrap();
let mut store = RecentsStore::load(&path);
assert!(store.read_only);
assert_eq!(store.visible(), vec![ActionId::StartRecording]);
let original = std::fs::read_to_string(&path).unwrap();
let pushed = store
.push_and_save(&ActionId::StopRecording, &path)
.unwrap();
assert!(pushed.is_none());
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "read-only file must not be overwritten");
}
#[test]
fn cap_enforces_visible_and_storage_limits() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("palette.json");
let mut entries = Vec::new();
for i in 0..7 {
entries.push(json!({ "id": format!("future-cmd-{}", i) }));
}
std::fs::write(
&path,
serde_json::to_string(&RecentsFile {
version: 1,
entries,
})
.unwrap(),
)
.unwrap();
let mut store = RecentsStore::load(&path);
for action in [
ActionId::StartRecording,
ActionId::StopRecording,
ActionId::OpenLatestMeeting,
ActionId::ShowUpcomingMeetings,
ActionId::OpenMeetingsFolder,
ActionId::OpenMemosFolder,
] {
store.push_and_save(&action, &path).unwrap();
}
let reloaded = RecentsStore::load(&path);
assert_eq!(
reloaded.file.entries.len(),
STORAGE_CAP,
"exactly STORAGE_CAP entries should remain after trim"
);
assert_eq!(
reloaded.visible().len(),
VISIBLE_CAP,
"exactly VISIBLE_CAP visible entries should remain after trim"
);
let unknown_count = reloaded
.file
.entries
.iter()
.filter(|e| serde_json::from_value::<ActionId>((*e).clone()).is_err())
.count();
assert_eq!(
unknown_count,
STORAGE_CAP - VISIBLE_CAP,
"trim should preserve exactly STORAGE_CAP - VISIBLE_CAP unknowns"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn idle_ctx() -> Context {
Context::default()
}
fn recording_ctx() -> Context {
Context {
flags: StateFlags::RECORDING,
..Context::default()
}
}
fn live_ctx() -> Context {
Context {
flags: StateFlags::LIVE_TRANSCRIPT,
..Context::default()
}
}
fn dictation_ctx() -> Context {
Context {
flags: StateFlags::DICTATION,
..Context::default()
}
}
fn meeting_open_idle_ctx() -> Context {
Context {
current_meeting: Some(PathBuf::from("/tmp/fake-meeting.md")),
..Context::default()
}
}
fn kebabs(cmds: &[Command]) -> Vec<&'static str> {
cmds.iter().map(|c| c.id.as_kebab()).collect()
}
#[test]
fn registry_has_seed_commands() {
let all = commands();
assert_eq!(
all.len(),
22,
"registry should have exactly 22 commands with backing dispatchers"
);
}
#[test]
fn all_action_ids_have_unique_kebab() {
let mut seen = std::collections::HashSet::new();
for cmd in commands() {
let kebab = cmd.id.as_kebab();
assert!(seen.insert(kebab), "duplicate kebab id: {}", kebab);
}
}
#[test]
fn all_titles_are_non_empty() {
for cmd in commands() {
assert!(!cmd.title.is_empty(), "empty title for {:?}", cmd.id);
assert!(
!cmd.description.is_empty(),
"empty description for {:?}",
cmd.id
);
}
}
#[test]
fn parameterized_commands_are_stored_in_empty_form() {
let all = commands();
let search = all
.iter()
.find(|c| matches!(c.id, ActionId::SearchTranscripts { .. }))
.unwrap();
assert_eq!(search.id, ActionId::SearchTranscripts { query: None });
assert_eq!(search.input, InputKind::InlineQuery);
let research = all
.iter()
.find(|c| matches!(c.id, ActionId::ResearchTopic { .. }))
.unwrap();
assert_eq!(research.id, ActionId::ResearchTopic { query: None });
assert_eq!(research.input, InputKind::InlineQuery);
let add_note = all
.iter()
.find(|c| matches!(c.id, ActionId::AddNote { .. }))
.unwrap();
assert_eq!(add_note.id, ActionId::AddNote { text: None });
assert_eq!(add_note.input, InputKind::PromptText);
let confirm = all
.iter()
.find(|c| matches!(c.id, ActionId::ConfirmCurrentSpeaker { .. }))
.unwrap();
assert_eq!(
confirm.id,
ActionId::ConfirmCurrentSpeaker { confirmation: None }
);
assert_eq!(confirm.input, InputKind::PromptText);
}
#[test]
fn input_kind_set_for_every_parameter_bearing_action() {
for cmd in commands() {
let parameterized = matches!(
cmd.id,
ActionId::SearchTranscripts { .. }
| ActionId::ResearchTopic { .. }
| ActionId::AddNote { .. }
| ActionId::ConfirmCurrentSpeaker { .. }
| ActionId::RenameCurrentMeeting { .. }
);
if parameterized {
assert_ne!(
cmd.input,
InputKind::None,
"parameterized command {} must not have InputKind::None",
cmd.id.as_kebab()
);
}
}
}
#[test]
fn meeting_context_commands_only_when_meeting_open() {
let idle = visible_commands(&idle_ctx());
let idle_kebabs = kebabs(&idle);
assert!(!idle_kebabs.contains(&"copy-meeting-markdown"));
assert!(!idle_kebabs.contains(&"create-debrief-draft-from-current-meeting"));
assert!(!idle_kebabs.contains(&"confirm-current-speaker"));
let meeting = visible_commands(&meeting_open_idle_ctx());
let meeting_kebabs = kebabs(&meeting);
assert!(meeting_kebabs.contains(&"copy-meeting-markdown"));
assert!(meeting_kebabs.contains(&"create-debrief-draft-from-current-meeting"));
assert!(meeting_kebabs.contains(&"confirm-current-speaker"));
}
#[test]
fn action_id_serializes_with_id_tag() {
let v = serde_json::to_value(&ActionId::StartRecording).unwrap();
assert_eq!(v, serde_json::json!({ "id": "start-recording" }));
let v = serde_json::to_value(&ActionId::AddNote {
text: Some("hello".into()),
})
.unwrap();
assert_eq!(v, serde_json::json!({ "id": "add-note", "text": "hello" }));
let v = serde_json::to_value(&ActionId::SearchTranscripts {
query: Some("pricing".into()),
})
.unwrap();
assert_eq!(
v,
serde_json::json!({ "id": "search-transcripts", "query": "pricing" })
);
let v = serde_json::to_value(&ActionId::ConfirmCurrentSpeaker {
confirmation: Some("SPEAKER_0 = Alex".into()),
})
.unwrap();
assert_eq!(
v,
serde_json::json!({ "id": "confirm-current-speaker", "confirmation": "SPEAKER_0 = Alex" })
);
}
#[test]
fn action_id_deserializes_from_id_tag() {
let id: ActionId =
serde_json::from_value(serde_json::json!({ "id": "start-recording" })).unwrap();
assert_eq!(id, ActionId::StartRecording);
let id: ActionId = serde_json::from_value(
serde_json::json!({ "id": "search-transcripts", "query": "pricing" }),
)
.unwrap();
assert_eq!(
id,
ActionId::SearchTranscripts {
query: Some("pricing".into())
}
);
let id: ActionId = serde_json::from_value(serde_json::json!({ "id": "add-note" })).unwrap();
assert_eq!(id, ActionId::AddNote { text: None });
let id: ActionId = serde_json::from_value(
serde_json::json!({ "id": "confirm-current-speaker", "confirmation": "SPEAKER_0 = Alex" }),
)
.unwrap();
assert_eq!(
id,
ActionId::ConfirmCurrentSpeaker {
confirmation: Some("SPEAKER_0 = Alex".into())
}
);
}
#[test]
fn kebab_matches_serde_tag_for_every_variant() {
for cmd in commands() {
let json = serde_json::to_value(&cmd.id).unwrap();
let serialized_id = json
.get("id")
.and_then(|v| v.as_str())
.expect("every action serializes with an id field");
assert_eq!(
serialized_id,
cmd.id.as_kebab(),
"as_kebab() drifted from serde tag for {:?}",
cmd.id
);
}
}
#[test]
fn idle_hides_stop_commands() {
let visible = visible_commands(&idle_ctx());
let ids = kebabs(&visible);
assert!(ids.contains(&"start-recording"));
assert!(ids.contains(&"start-dictation"));
assert!(ids.contains(&"start-live-transcript"));
assert!(!ids.contains(&"stop-recording"));
assert!(!ids.contains(&"stop-dictation"));
assert!(!ids.contains(&"stop-live-transcript"));
assert!(!ids.contains(&"add-note"));
}
#[test]
fn recording_swaps_start_for_stop_and_exposes_add_note() {
let visible = visible_commands(&recording_ctx());
let ids = kebabs(&visible);
assert!(!ids.contains(&"start-recording"));
assert!(ids.contains(&"stop-recording"));
assert!(ids.contains(&"add-note"));
assert!(!ids.contains(&"start-dictation"));
}
#[test]
fn live_transcript_exposes_stop_and_read() {
let visible = visible_commands(&live_ctx());
let ids = kebabs(&visible);
assert!(ids.contains(&"stop-live-transcript"));
assert!(ids.contains(&"read-live-transcript"));
assert!(!ids.contains(&"start-live-transcript"));
}
#[test]
fn dictation_exposes_stop_not_start() {
let visible = visible_commands(&dictation_ctx());
let ids = kebabs(&visible);
assert!(ids.contains(&"stop-dictation"));
assert!(!ids.contains(&"start-dictation"));
assert!(!ids.contains(&"start-recording"));
}
#[test]
fn is_idle_is_true_only_with_no_session_flags() {
assert!(idle_ctx().is_idle());
assert!(!recording_ctx().is_idle());
assert!(!live_ctx().is_idle());
assert!(!dictation_ctx().is_idle());
assert!(meeting_open_idle_ctx().is_idle());
}
#[test]
fn state_flags_union_and_contains() {
let both = StateFlags::RECORDING.union(StateFlags::MEETING_OPEN);
assert!(both.contains(StateFlags::RECORDING));
assert!(both.contains(StateFlags::MEETING_OPEN));
assert!(!both.contains(StateFlags::LIVE_TRANSCRIPT));
assert!(both.intersects(StateFlags::ANY_SESSION));
}
#[test]
fn kebab_ids_are_stable_strings() {
assert_eq!(ActionId::StartRecording.as_kebab(), "start-recording");
assert_eq!(ActionId::StopRecording.as_kebab(), "stop-recording");
assert_eq!(ActionId::StartDictation.as_kebab(), "start-dictation");
assert_eq!(ActionId::StopDictation.as_kebab(), "stop-dictation");
assert_eq!(
ActionId::SearchTranscripts { query: None }.as_kebab(),
"search-transcripts"
);
assert_eq!(
ActionId::SearchTranscripts {
query: Some("x".into())
}
.as_kebab(),
"search-transcripts"
);
assert_eq!(
ActionId::AddNote {
text: Some("hi".into())
}
.as_kebab(),
"add-note"
);
assert_eq!(
ActionId::ReadLiveTranscript.as_kebab(),
"read-live-transcript"
);
assert_eq!(
ActionId::ResearchTopic { query: None }.as_kebab(),
"research-topic"
);
assert_eq!(
ActionId::CopyMeetingMarkdown.as_kebab(),
"copy-meeting-markdown"
);
assert_eq!(
ActionId::OpenAssistantWorkspace.as_kebab(),
"open-assistant-workspace"
);
}
}