| 1 | //! Local media attachment commands. |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | |
| 5 | use codewhale_command_contract::facets::CommandMediaContext; |
| 6 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 7 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 8 | |
| 9 | use crate::commands::CommandResult; |
| 10 | |
| 11 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 12 | name: "attach", |
| 13 | aliases: &["image", "media", "fujian"], |
| 14 | usage: "/attach <path>", |
| 15 | description_key: "cmd_attach_description", |
| 16 | }; |
| 17 | |
| 18 | pub(in crate::commands) struct AttachCmd; |
| 19 | |
| 20 | impl RegisterCommand<CommandResult> for AttachCmd { |
| 21 | fn info() -> &'static CommandInfo { |
| 22 | &COMMAND_INFO |
| 23 | } |
| 24 | |
| 25 | fn handler() -> CommandHandler<CommandResult> { |
| 26 | CommandHandler::Contextual { |
| 27 | capabilities: CommandCapabilities::WORKSPACE.union(CommandCapabilities::MEDIA), |
| 28 | handler: attach_contextual, |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | fn attach_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 34 | let mut parts = contexts.into_parts(); |
| 35 | let Some(workspace) = parts.workspace.as_deref() else { |
| 36 | return CommandResult::error("Command capability unavailable: workspace"); |
| 37 | }; |
| 38 | let Some(media) = parts.media.as_deref_mut() else { |
| 39 | return CommandResult::error("Command capability unavailable: media"); |
| 40 | }; |
| 41 | attach(workspace.workspace(), media, arg) |
| 42 | } |
| 43 | |
| 44 | fn attach( |
| 45 | workspace: PathBuf, |
| 46 | media: &mut dyn CommandMediaContext, |
| 47 | arg: Option<&str>, |
| 48 | ) -> CommandResult { |
| 49 | let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else { |
| 50 | return CommandResult::error("Usage: /attach <image-or-video-path>"); |
| 51 | }; |
| 52 | |
| 53 | let path = resolve_attachment_path(raw_path, &workspace); |
| 54 | match media.attach_media(&path) { |
| 55 | Ok(receipt) => CommandResult::message(format!( |
| 56 | "Attached {}: {}", |
| 57 | receipt.kind, |
| 58 | receipt.path.display() |
| 59 | )), |
| 60 | Err(error) => CommandResult::error(error), |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf { |
| 65 | let unquoted = raw_path.trim().trim_matches('"').trim_matches('\''); |
| 66 | let path = expand_home(unquoted); |
| 67 | if path.is_absolute() { |
| 68 | path |
| 69 | } else { |
| 70 | workspace.join(path) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | fn expand_home(path: &str) -> PathBuf { |
| 75 | if path == "~" { |
| 76 | if let Some(home) = std::env::var_os("HOME") { |
| 77 | return PathBuf::from(home); |
| 78 | } |
| 79 | } else if let Some(rest) = path.strip_prefix("~/") |
| 80 | && let Some(home) = std::env::var_os("HOME") |
| 81 | { |
| 82 | return PathBuf::from(home).join(rest); |
| 83 | } |
| 84 | PathBuf::from(path) |
| 85 | } |
| 86 | |
| 87 | #[cfg(test)] |
| 88 | mod tests { |
| 89 | use super::*; |
| 90 | |
| 91 | struct FakeMedia; |
| 92 | impl CommandMediaContext for FakeMedia { |
| 93 | fn attach_media( |
| 94 | &mut self, |
| 95 | path: &Path, |
| 96 | ) -> Result<codewhale_command_contract::facets::MediaAttachmentReceipt, String> { |
| 97 | if path.extension().and_then(|ext| ext.to_str()) == Some("png") { |
| 98 | Ok(codewhale_command_contract::facets::MediaAttachmentReceipt { |
| 99 | kind: "image".to_string(), |
| 100 | path: path.to_path_buf(), |
| 101 | }) |
| 102 | } else if path.extension().and_then(|ext| ext.to_str()) == Some("mp4") { |
| 103 | Ok(codewhale_command_contract::facets::MediaAttachmentReceipt { |
| 104 | kind: "video".to_string(), |
| 105 | path: path.to_path_buf(), |
| 106 | }) |
| 107 | } else { |
| 108 | Err("Unsupported attachment type".to_string()) |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | fn workspace() -> PathBuf { |
| 114 | PathBuf::from("/workspace") |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn attach_resolves_relative_and_absolute_paths() { |
| 119 | let relative = resolve_attachment_path("photo.png", &workspace()); |
| 120 | assert_eq!(relative, PathBuf::from("/workspace/photo.png")); |
| 121 | |
| 122 | let absolute = resolve_attachment_path("/tmp/photo.png", &workspace()); |
| 123 | assert_eq!(absolute, PathBuf::from("/tmp/photo.png")); |
| 124 | |
| 125 | let quoted = resolve_attachment_path("\"photo.png\"", &workspace()); |
| 126 | assert_eq!(quoted, PathBuf::from("/workspace/photo.png")); |
| 127 | |
| 128 | let home = resolve_attachment_path("~/photo.png", &workspace()); |
| 129 | if let Some(home_dir) = std::env::var_os("HOME") { |
| 130 | assert_eq!(home, PathBuf::from(home_dir).join("photo.png")); |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | #[test] |
| 135 | fn attach_delegates_to_media_facet_and_composes_confirm() { |
| 136 | let result = attach(workspace(), &mut FakeMedia, Some("photo.png")); |
| 137 | assert!(result.message.expect("message").contains("Attached image")); |
| 138 | assert!(!result.is_error); |
| 139 | |
| 140 | let video = attach(workspace(), &mut FakeMedia, Some("clip.mp4")); |
| 141 | assert!(video.message.expect("message").contains("Attached video")); |
| 142 | } |
| 143 | |
| 144 | #[test] |
| 145 | fn attach_requires_a_path() { |
| 146 | let result = attach(workspace(), &mut FakeMedia, None); |
| 147 | assert!(result.is_error); |
| 148 | assert!( |
| 149 | result |
| 150 | .message |
| 151 | .as_deref() |
| 152 | .unwrap_or_default() |
| 153 | .contains("Usage: /attach"), |
| 154 | "{:?}", |
| 155 | result.message |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn attach_forwards_media_facet_error() { |
| 161 | let result = attach(workspace(), &mut FakeMedia, Some("notes.txt")); |
| 162 | assert!(result.is_error); |
| 163 | assert!( |
| 164 | result |
| 165 | .message |
| 166 | .expect("message") |
| 167 | .contains("Unsupported attachment type") |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | #[test] |
| 172 | fn handler_is_contextual() { |
| 173 | let CommandHandler::Contextual { |
| 174 | capabilities, |
| 175 | handler, |
| 176 | } = AttachCmd::handler() |
| 177 | else { |
| 178 | panic!("attach must be contextual"); |
| 179 | }; |
| 180 | assert_eq!( |
| 181 | capabilities, |
| 182 | CommandCapabilities::WORKSPACE.union(CommandCapabilities::MEDIA) |
| 183 | ); |
| 184 | let missing = handler(CommandContexts::empty(), Some("photo.png")); |
| 185 | assert!(missing.is_error); |
| 186 | assert_eq!( |
| 187 | missing.message.as_deref(), |
| 188 | Some("Error: Command capability unavailable: workspace") |
| 189 | ); |
| 190 | assert_eq!(AttachCmd::info().description_key, "cmd_attach_description"); |
| 191 | assert_eq!(AttachCmd::info().aliases, &["image", "media", "fujian"]); |
| 192 | } |
| 193 | } |
| 194 |