diff --git a/apps/desktop/src/store/zustand/listener/general.ts b/apps/desktop/src/store/zustand/listener/general.ts
index b18e415863..f30e76c349 100644
--- a/apps/desktop/src/store/zustand/listener/general.ts
+++ b/apps/desktop/src/store/zustand/listener/general.ts
@@ -1,3 +1,5 @@
+import { getName } from "@tauri-apps/api/app";
+import { appDataDir } from "@tauri-apps/api/path";
import { Effect, Exit } from "effect";
import { create as mutate } from "mutative";
import type { StoreApi } from "zustand";
@@ -202,9 +204,18 @@ export const createGeneralSlice = <
}),
);
- hooksCommands
- .runEventHooks({
- beforeListeningStarted: { args: { session_id: targetSessionId } },
+ Promise.all([appDataDir(), getName().catch(() => "com.hyprnote.app")])
+ .then(([dataDirPath, appName]) => {
+ const sessionPath = `${dataDirPath}/hyprnote/sessions/${targetSessionId}`;
+ return hooksCommands.runEventHooks({
+ beforeListeningStarted: {
+ args: {
+ resource_dir: sessionPath,
+ app_hyprnote: appName,
+ app_meeting: null,
+ },
+ },
+ });
})
.catch((error) => {
console.error("[hooks] BeforeListeningStarted failed:", error);
@@ -264,9 +275,21 @@ export const createGeneralSlice = <
},
onSuccess: () => {
if (sessionId) {
- hooksCommands
- .runEventHooks({
- afterListeningStopped: { args: { session_id: sessionId } },
+ Promise.all([
+ appDataDir(),
+ getName().catch(() => "com.hyprnote.app"),
+ ])
+ .then(([dataDirPath, appName]) => {
+ const sessionPath = `${dataDirPath}/hyprnote/sessions/${sessionId}`;
+ return hooksCommands.runEventHooks({
+ afterListeningStopped: {
+ args: {
+ resource_dir: sessionPath,
+ app_hyprnote: appName,
+ app_meeting: null,
+ },
+ },
+ });
})
.catch((error) => {
console.error("[hooks] AfterListeningStopped failed:", error);
diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts
index 6f518255ea..4d59748b45 100644
--- a/apps/web/content-collections.ts
+++ b/apps/web/content-collections.ts
@@ -267,6 +267,7 @@ const hooks = defineCollection({
name: z.string(),
type_name: z.string(),
description: z.string(),
+ optional: z.boolean().default(false),
}),
)
.optional(),
diff --git a/apps/web/content/docs/hooks/afterListeningStopped.mdx b/apps/web/content/docs/hooks/afterListeningStopped.mdx
index 4cbb6eb4b8..181df807e6 100644
--- a/apps/web/content/docs/hooks/afterListeningStopped.mdx
+++ b/apps/web/content/docs/hooks/afterListeningStopped.mdx
@@ -1,8 +1,15 @@
---
name: afterListeningStopped
-description: '123'
+description: Arguments passed to hooks triggered after listening stops.
args:
-- name: session_id
- description: '345'
+- name: --resource-dir
+ description: Path to the resource directory.
type_name: string
+- name: --app-hyprnote
+ description: Application-specific Hyprnote data.
+ type_name: string
+- name: --app-meeting
+ description: Optional meeting-specific data.
+ type_name: string
+ optional: true
---
diff --git a/apps/web/content/docs/hooks/beforeListeningStarted.mdx b/apps/web/content/docs/hooks/beforeListeningStarted.mdx
index da52a0d463..3bc6c018aa 100644
--- a/apps/web/content/docs/hooks/beforeListeningStarted.mdx
+++ b/apps/web/content/docs/hooks/beforeListeningStarted.mdx
@@ -1,8 +1,15 @@
---
name: beforeListeningStarted
-description: '123'
+description: Arguments passed to hooks triggered before listening starts.
args:
-- name: session_id
- description: '345'
+- name: --resource-dir
+ description: Path to the resource directory.
type_name: string
+- name: --app-hyprnote
+ description: Application-specific Hyprnote data.
+ type_name: string
+- name: --app-meeting
+ description: Optional meeting-specific data.
+ type_name: string
+ optional: true
---
diff --git a/apps/web/src/components/hooks-list.tsx b/apps/web/src/components/hooks-list.tsx
index 003c213703..b7487de212 100644
--- a/apps/web/src/components/hooks-list.tsx
+++ b/apps/web/src/components/hooks-list.tsx
@@ -9,42 +9,63 @@ export function HooksList() {
}
return (
-
+
{hooks.map((hook) => (
-
-
- {hook.name}
-
- {hook.description && (
- {hook.description}
- )}
+
+
+
+ {hook.name}
+
+ {hook.description && (
+
+ {hook.description}
+
+ )}
+
+
{hook.args && hook.args.length > 0 && (
-
-
Arguments
-
+
+
+ Arguments
+
+
{hook.args.map((arg) => (
-
-
{arg.name}
-
- {" "}
- : {arg.type_name}
-
+
+
+ {arg.name}
+
+
+ {arg.type_name}
+ {arg.optional && (
+ <>
+
+
+ optional
+
+ >
+ )}
+
+
+
+ {arg.description}
- {arg.description && (
-
- {arg.description}
-
- )}
))}
)}
-
+
+
diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts
index 64e84a71a8..ed34b18fb6 100644
--- a/plugins/hooks/js/bindings.gen.ts
+++ b/plugins/hooks/js/bindings.gen.ts
@@ -28,33 +28,56 @@ async runEventHooks(event: HookEvent) : Promise
> {
/** user-defined types **/
/**
- * 123
+ * Arguments passed to hooks triggered after listening stops.
*/
export type AfterListeningStoppedArgs = {
/**
- * 345
+ * Path to the resource directory.
*/
-session_id: string }
+resource_dir: string;
/**
- * 123
+ * Application-specific Hyprnote data.
+ */
+app_hyprnote: string;
+/**
+ * Optional meeting-specific data.
+ */
+app_meeting?: string | null }
+/**
+ * Arguments passed to hooks triggered before listening starts.
*/
export type BeforeListeningStartedArgs = {
/**
- * 345
+ * Path to the resource directory.
+ */
+resource_dir: string;
+/**
+ * Application-specific Hyprnote data.
+ */
+app_hyprnote: string;
+/**
+ * Optional meeting-specific data.
+ */
+app_meeting?: string | null }
+/**
+ * Defines a single hook to be executed on an event.
+ */
+export type HookDefinition = {
+/**
+ * Shell command to execute when the hook is triggered.
*/
-session_id: string }
-export type HookDefinition = { command: string }
+command: string }
export type HookEvent = { afterListeningStopped: { args: AfterListeningStoppedArgs } } | { beforeListeningStarted: { args: BeforeListeningStartedArgs } }
/**
- * 123
+ * Configuration for hook execution.
*/
export type HooksConfig = {
/**
- * 345
+ * Configuration schema version.
*/
version: number;
/**
- * 678
+ * Map of event names to their associated hook definitions.
*/
hooks?: Partial<{ [key in string]: HookDefinition[] }> }
diff --git a/plugins/hooks/src/config.rs b/plugins/hooks/src/config.rs
index 036f8b2b03..86ea57bef3 100644
--- a/plugins/hooks/src/config.rs
+++ b/plugins/hooks/src/config.rs
@@ -2,18 +2,20 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
-/// 123
+/// Configuration for hook execution.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct HooksConfig {
- /// 345
+ /// Configuration schema version.
pub version: u8,
- /// 678
+ /// Map of event names to their associated hook definitions.
#[serde(default)]
pub hooks: HashMap>,
}
+/// Defines a single hook to be executed on an event.
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct HookDefinition {
+ /// Shell command to execute when the hook is triggered.
pub command: String,
}
diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs
index 35d15d4707..120c05f209 100644
--- a/plugins/hooks/src/docs.rs
+++ b/plugins/hooks/src/docs.rs
@@ -1,5 +1,6 @@
use std::collections::HashMap;
+use crate::naming::cli_flag;
use serde::{Deserialize, Serialize};
use swc_common::{sync::Lrc, FileName, SourceMap, Span};
@@ -29,6 +30,8 @@ pub struct ArgField {
pub name: String,
pub description: Option,
pub type_name: String,
+ #[serde(skip_serializing_if = "is_false")]
+ pub optional: bool,
}
#[derive(Debug, Clone)]
@@ -37,6 +40,21 @@ struct TypeDoc {
args: Vec,
}
+#[derive(Debug, Clone)]
+struct TypeInfo {
+ type_name: String,
+ optional: bool,
+}
+
+impl TypeInfo {
+ fn unknown() -> Self {
+ Self {
+ type_name: "unknown".to_string(),
+ optional: false,
+ }
+ }
+}
+
pub fn parse_hooks(source_code: &str) -> Result, String> {
let (module, fm) = parse_module(source_code)?;
let jsdoc = JsDocExtractor::new(source_code, &fm);
@@ -73,109 +91,152 @@ fn parse_module(source_code: &str) -> Result<(Module, Lrc) -> HashMap {
- let mut docs = HashMap::new();
+ exported_type_aliases(module)
+ .map(|(alias, span)| {
+ let type_name = alias.id.sym.to_string();
+ let description = jsdoc.for_span(&span);
+ let args = extract_fields(alias.type_ann.as_ref(), jsdoc);
+ (type_name, TypeDoc { description, args })
+ })
+ .collect()
+}
- for item in &module.body {
- if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item {
- if let Decl::TsTypeAlias(type_alias) = &export.decl {
- let type_name = type_alias.id.sym.to_string();
- let description = jsdoc.for_span(&export.span);
- let args = extract_fields(type_alias.type_ann.as_ref(), jsdoc);
+fn extract_hook_events(module: &Module, type_docs: &HashMap) -> Vec {
+ hook_union(module)
+ .map(|ty| hook_variants(ty, type_docs))
+ .unwrap_or_default()
+}
- docs.insert(type_name, TypeDoc { description, args });
- }
- }
+fn hook_variants(type_ann: &TsType, type_docs: &HashMap) -> Vec {
+ if let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) =
+ type_ann
+ {
+ union
+ .types
+ .iter()
+ .filter_map(|variant| hook_from_variant(variant.as_ref(), type_docs))
+ .collect()
+ } else {
+ Vec::new()
}
+}
- docs
+fn hook_from_variant(type_ann: &TsType, type_docs: &HashMap) -> Option {
+ let type_lit = type_lit_from(type_ann)?;
+ let prop = first_property(type_lit)?;
+ let hook_name = prop_name(prop)?;
+ let args_type = prop
+ .type_ann
+ .as_ref()
+ .and_then(|ty| args_type_name(&ty.type_ann))?;
+
+ let (description, args) = type_docs
+ .get(&args_type)
+ .map(|doc| (doc.description.clone(), doc.args.clone()))
+ .unwrap_or((None, Vec::new()));
+
+ Some(HookInfo {
+ name: hook_name,
+ description,
+ args,
+ })
}
-fn extract_hook_events(module: &Module, type_docs: &HashMap) -> Vec {
- for item in &module.body {
+fn extract_fields(type_ann: &TsType, jsdoc: &JsDocExtractor<'_>) -> Vec {
+ let type_lit = match type_lit_from(type_ann) {
+ Some(lit) => lit,
+ None => return Vec::new(),
+ };
+
+ type_lit
+ .members
+ .iter()
+ .filter_map(|member| {
+ if let TsTypeElement::TsPropertySignature(prop) = member {
+ let field_name = prop_name(prop)?;
+ let doc_name = cli_flag(&field_name);
+ let description = jsdoc.for_span(&prop.span);
+ let type_info = prop
+ .type_ann
+ .as_ref()
+ .map(|ta| format_type(&ta.type_ann))
+ .unwrap_or_else(TypeInfo::unknown);
+
+ Some(ArgField {
+ name: doc_name,
+ description,
+ type_name: type_info.type_name,
+ optional: prop.optional || type_info.optional,
+ })
+ } else {
+ None
+ }
+ })
+ .collect()
+}
+
+fn exported_type_aliases(module: &Module) -> impl Iterator- + '_ {
+ module.body.iter().filter_map(|item| {
if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item {
if let Decl::TsTypeAlias(type_alias) = &export.decl {
- if type_alias.id.sym == "HookEvent" {
- return extract_union_variants(&type_alias.type_ann, type_docs);
- }
+ return Some((type_alias.as_ref(), export.span));
}
}
- }
+ None
+ })
+}
- Vec::new()
+fn hook_union(module: &Module) -> Option<&TsType> {
+ exported_type_aliases(module)
+ .find(|(alias, _)| alias.id.sym.as_ref() == "HookEvent")
+ .map(|(alias, _)| alias.type_ann.as_ref())
}
-fn extract_union_variants(
- type_ann: &TsType,
- type_docs: &HashMap,
-) -> Vec {
- let mut hooks = Vec::new();
+fn args_type_name(type_ann: &TsType) -> Option {
+ let type_lit = type_lit_from(type_ann)?;
+ let prop = property_by_name(&type_lit.members, "args")?;
+ prop.type_ann
+ .as_ref()
+ .and_then(|ta| type_name_from(&ta.type_ann))
+}
- if let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) =
- type_ann
- {
- for variant in &union.types {
- if let Some(hook) = extract_variant_info(variant.as_ref(), type_docs) {
- hooks.push(hook);
- }
- }
- }
+fn property_by_name<'a>(
+ members: &'a [TsTypeElement],
+ name: &str,
+) -> Option<&'a TsPropertySignature> {
+ members.iter().find_map(|member| match member {
+ TsTypeElement::TsPropertySignature(prop) => match &*prop.key {
+ Expr::Ident(ident) if ident.sym.as_ref() == name => Some(prop),
+ _ => None,
+ },
+ _ => None,
+ })
+}
- hooks
+fn first_property(type_lit: &TsTypeLit) -> Option<&TsPropertySignature> {
+ type_lit.members.iter().find_map(|member| match member {
+ TsTypeElement::TsPropertySignature(prop) => Some(prop),
+ _ => None,
+ })
}
-fn extract_variant_info(
- type_ann: &TsType,
- type_docs: &HashMap,
-) -> Option {
- if let TsType::TsTypeLit(type_lit) = type_ann {
- for member in &type_lit.members {
- if let TsTypeElement::TsPropertySignature(prop) = member {
- if let Expr::Ident(ident) = &*prop.key {
- let hook_name = ident.sym.to_string();
-
- let args_type_name = prop
- .type_ann
- .as_ref()
- .and_then(|ta| extract_args_type_name(&ta.type_ann))?;
-
- let (description, args) = type_docs
- .get(&args_type_name)
- .map(|doc| (doc.description.clone(), doc.args.clone()))
- .unwrap_or((None, Vec::new()));
-
- return Some(HookInfo {
- name: hook_name,
- description,
- args,
- });
- }
- }
- }
+fn prop_name(prop: &TsPropertySignature) -> Option {
+ if let Expr::Ident(ident) = &*prop.key {
+ Some(ident.sym.to_string())
+ } else {
+ None
}
-
- None
}
-fn extract_args_type_name(type_ann: &TsType) -> Option {
- if let TsType::TsTypeLit(type_lit) = type_ann {
- for member in &type_lit.members {
- if let TsTypeElement::TsPropertySignature(prop) = member {
- if let Expr::Ident(ident) = &*prop.key {
- if ident.sym == "args" {
- return prop
- .type_ann
- .as_ref()
- .and_then(|ta| extract_type_name(&ta.type_ann));
- }
- }
- }
- }
+fn type_lit_from(type_ann: &TsType) -> Option<&TsTypeLit> {
+ match type_ann {
+ TsType::TsTypeLit(type_lit) => Some(type_lit),
+ TsType::TsParenthesizedType(paren) => type_lit_from(&paren.type_ann),
+ _ => None,
}
-
- None
}
-fn extract_type_name(type_ann: &TsType) -> Option {
+fn type_name_from(type_ann: &TsType) -> Option {
if let TsType::TsTypeRef(type_ref) = type_ann {
if let TsEntityName::Ident(ident) = &type_ref.type_name {
return Some(ident.sym.to_string());
@@ -184,34 +245,6 @@ fn extract_type_name(type_ann: &TsType) -> Option {
None
}
-fn extract_fields(type_ann: &TsType, jsdoc: &JsDocExtractor<'_>) -> Vec {
- let mut fields = Vec::new();
-
- if let TsType::TsTypeLit(type_lit) = type_ann {
- for member in &type_lit.members {
- if let TsTypeElement::TsPropertySignature(prop) = member {
- if let Expr::Ident(ident) = &*prop.key {
- let field_name = ident.sym.to_string();
- let description = jsdoc.for_span(&prop.span);
- let type_name = prop
- .type_ann
- .as_ref()
- .map(|ta| format_type(&ta.type_ann))
- .unwrap_or_else(|| "unknown".to_string());
-
- fields.push(ArgField {
- name: field_name,
- description,
- type_name,
- });
- }
- }
- }
- }
-
- fields
-}
-
struct JsDocExtractor<'a> {
source: &'a str,
fm: Lrc,
@@ -274,21 +307,80 @@ fn format_jsdoc_content(block: &str) -> Option {
}
}
-fn format_type(type_ann: &TsType) -> String {
+fn format_type(type_ann: &TsType) -> TypeInfo {
match type_ann {
- TsType::TsKeywordType(kw) => match kw.kind {
- TsKeywordTypeKind::TsStringKeyword => "string".to_string(),
- TsKeywordTypeKind::TsNumberKeyword => "number".to_string(),
- TsKeywordTypeKind::TsBooleanKeyword => "boolean".to_string(),
- _ => format!("{:?}", kw.kind).to_lowercase(),
- },
+ TsType::TsKeywordType(kw) => format_keyword_type(&kw.kind),
TsType::TsTypeRef(type_ref) => {
if let TsEntityName::Ident(ident) = &type_ref.type_name {
- ident.sym.to_string()
+ TypeInfo {
+ type_name: ident.sym.to_string(),
+ optional: false,
+ }
} else {
- "unknown".to_string()
+ TypeInfo::unknown()
}
}
- _ => "unknown".to_string(),
+ TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) => {
+ let mut parts = Vec::new();
+ let mut optional = false;
+
+ for ty in &union.types {
+ let ty_name = format_type(ty);
+
+ if matches!(ty_name.type_name.as_str(), "null" | "undefined" | "void") {
+ optional = true;
+ continue;
+ }
+
+ if ty_name.type_name == "unknown" {
+ continue;
+ }
+
+ if ty_name.optional {
+ optional = true;
+ }
+
+ if !parts.contains(&ty_name.type_name) {
+ parts.push(ty_name.type_name);
+ }
+ }
+
+ if parts.is_empty() {
+ TypeInfo {
+ type_name: "unknown".to_string(),
+ optional,
+ }
+ } else {
+ TypeInfo {
+ type_name: parts.join(" | "),
+ optional,
+ }
+ }
+ }
+ TsType::TsParenthesizedType(paren) => format_type(&paren.type_ann),
+ _ => TypeInfo::unknown(),
}
}
+
+fn format_keyword_type(kind: &TsKeywordTypeKind) -> TypeInfo {
+ let name = format!("{:?}", kind)
+ .trim_start_matches("Ts")
+ .trim_end_matches("Keyword")
+ .to_lowercase();
+
+ let optional = matches!(
+ kind,
+ TsKeywordTypeKind::TsNullKeyword
+ | TsKeywordTypeKind::TsUndefinedKeyword
+ | TsKeywordTypeKind::TsVoidKeyword
+ );
+
+ TypeInfo {
+ type_name: name,
+ optional,
+ }
+}
+
+fn is_false(value: &bool) -> bool {
+ !*value
+}
diff --git a/plugins/hooks/src/event.rs b/plugins/hooks/src/event.rs
index b805aed5ff..aa5a693f90 100644
--- a/plugins/hooks/src/event.rs
+++ b/plugins/hooks/src/event.rs
@@ -1,3 +1,4 @@
+use crate::naming::cli_flag;
use std::ffi::OsString;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
@@ -28,34 +29,59 @@ pub trait HookArgs {
fn to_cli_args(&self) -> Vec;
}
+fn push_cli_arg(args: &mut Vec, field_name: &str, value: &str) {
+ args.push(OsString::from(cli_flag(field_name)));
+ args.push(OsString::from(value));
+}
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
-/// 123
+/// Arguments passed to hooks triggered after listening stops.
pub struct AfterListeningStoppedArgs {
- /// 345
- pub session_id: String,
+ /// Path to the resource directory.
+ pub resource_dir: String,
+ /// Application-specific Hyprnote data.
+ pub app_hyprnote: String,
+ /// Optional meeting-specific data.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub app_meeting: Option,
}
impl HookArgs for AfterListeningStoppedArgs {
fn to_cli_args(&self) -> Vec {
- vec![
- OsString::from("--session-id"),
- OsString::from(&self.session_id),
- ]
+ let mut args = Vec::with_capacity(6);
+ push_cli_arg(&mut args, stringify!(resource_dir), &self.resource_dir);
+ push_cli_arg(&mut args, stringify!(app_hyprnote), &self.app_hyprnote);
+
+ if let Some(meeting) = &self.app_meeting {
+ push_cli_arg(&mut args, stringify!(app_meeting), meeting);
+ }
+
+ args
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)]
-/// 123
+/// Arguments passed to hooks triggered before listening starts.
pub struct BeforeListeningStartedArgs {
- /// 345
- pub session_id: String,
+ /// Path to the resource directory.
+ pub resource_dir: String,
+ /// Application-specific Hyprnote data.
+ pub app_hyprnote: String,
+ /// Optional meeting-specific data.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub app_meeting: Option,
}
impl HookArgs for BeforeListeningStartedArgs {
fn to_cli_args(&self) -> Vec {
- vec![
- OsString::from("--session-id"),
- OsString::from(&self.session_id),
- ]
+ let mut args = Vec::with_capacity(6);
+ push_cli_arg(&mut args, stringify!(resource_dir), &self.resource_dir);
+ push_cli_arg(&mut args, stringify!(app_hyprnote), &self.app_hyprnote);
+
+ if let Some(meeting) = &self.app_meeting {
+ push_cli_arg(&mut args, stringify!(app_meeting), meeting);
+ }
+
+ args
}
}
diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs
index 0eb4ccee42..4febaf6d34 100644
--- a/plugins/hooks/src/lib.rs
+++ b/plugins/hooks/src/lib.rs
@@ -3,6 +3,7 @@ mod config;
mod error;
mod event;
mod ext;
+mod naming;
mod runner;
#[cfg(test)]
diff --git a/plugins/hooks/src/naming.rs b/plugins/hooks/src/naming.rs
new file mode 100644
index 0000000000..cd6518207d
--- /dev/null
+++ b/plugins/hooks/src/naming.rs
@@ -0,0 +1,32 @@
+pub(crate) fn cli_flag(field_name: &str) -> String {
+ let mut flag = String::with_capacity(field_name.len() + 2);
+ flag.push_str("--");
+
+ for ch in field_name.chars() {
+ flag.push(if ch == '_' { '-' } else { ch });
+ }
+
+ flag
+}
+
+#[cfg(test)]
+mod tests {
+ use super::cli_flag;
+
+ #[test]
+ fn transforms_snake_case_into_cli_flag() {
+ assert_eq!(cli_flag("resource_dir"), "--resource-dir");
+ assert_eq!(cli_flag("app_hyprnote"), "--app-hyprnote");
+ assert_eq!(cli_flag("app_meeting"), "--app-meeting");
+ }
+
+ #[test]
+ fn leaves_hyphenated_names_intact() {
+ assert_eq!(cli_flag("already-hyphenated"), "--already-hyphenated");
+ }
+
+ #[test]
+ fn handles_empty_strings() {
+ assert_eq!(cli_flag(""), "--");
+ }
+}