| 1 | //! Local media attachment commands. |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | |
| 5 | use crate::commands::CommandResult; |
| 6 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 7 | use crate::localization::MessageId; |
| 8 | use crate::tui::app::App; |
| 9 | |
| 10 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 11 | name: "attach", |
| 12 | aliases: &["image", "media", "fujian"], |
| 13 | usage: "/attach <path>", |
| 14 | description_id: MessageId::CmdAttachDescription, |
| 15 | }; |
| 16 | |
| 17 | pub(in crate::commands) struct AttachCmd; |
| 18 | |
| 19 | impl RegisterCommand for AttachCmd { |
| 20 | fn info() -> &'static CommandInfo { |
| 21 | &COMMAND_INFO |
| 22 | } |
| 23 | |
| 24 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 25 | attach(app, arg) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 30 | let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else { |
| 31 | return CommandResult::error("Usage: /attach <image-or-video-path>"); |
| 32 | }; |
| 33 | |
| 34 | let path = resolve_attachment_path(raw_path, &app.workspace); |
| 35 | let Ok(path) = path.canonicalize() else { |
| 36 | return CommandResult::error(format!("Attachment not found: {}", path.display())); |
| 37 | }; |
| 38 | if !path.is_file() { |
| 39 | return CommandResult::error(format!("Attachment is not a file: {}", path.display())); |
| 40 | } |
| 41 | |
| 42 | let Some(kind) = media_kind(&path) else { |
| 43 | return CommandResult::error( |
| 44 | "Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.", |
| 45 | ); |
| 46 | }; |
| 47 | |
| 48 | // Validate an image here, not only at send time. The extension check above |
| 49 | // trusts the filename; this reads the bytes, so a mislabelled, oversized or |
| 50 | // corrupt file is refused while the user is still looking at the command |
| 51 | // that caused it — rather than becoming a notice buried in a turn they have |
| 52 | // already sent. |
| 53 | if kind == "image" |
| 54 | && let Err(error) = crate::image_attach::attach_image_from_path(&path) |
| 55 | { |
| 56 | return CommandResult::error(error.to_string()); |
| 57 | } |
| 58 | |
| 59 | app.insert_media_attachment(kind, &path, None); |
| 60 | CommandResult::message(format!("Attached {kind}: {}", path.display())) |
| 61 | } |
| 62 | |
| 63 | fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf { |
| 64 | let unquoted = raw_path.trim().trim_matches('"').trim_matches('\''); |
| 65 | let path = expand_home(unquoted); |
| 66 | if path.is_absolute() { |
| 67 | path |
| 68 | } else { |
| 69 | workspace.join(path) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn expand_home(path: &str) -> PathBuf { |
| 74 | if path == "~" { |
| 75 | if let Some(home) = std::env::var_os("HOME") { |
| 76 | return PathBuf::from(home); |
| 77 | } |
| 78 | } else if let Some(rest) = path.strip_prefix("~/") |
| 79 | && let Some(home) = std::env::var_os("HOME") |
| 80 | { |
| 81 | return PathBuf::from(home).join(rest); |
| 82 | } |
| 83 | PathBuf::from(path) |
| 84 | } |
| 85 | |
| 86 | fn media_kind(path: &Path) -> Option<&'static str> { |
| 87 | let ext = path.extension()?.to_str()?.to_ascii_lowercase(); |
| 88 | match ext.as_str() { |
| 89 | "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"), |
| 90 | "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"), |
| 91 | _ => None, |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | #[cfg(test)] |
| 96 | mod tests { |
| 97 | use super::*; |
| 98 | use crate::config::Config; |
| 99 | use crate::tui::app::TuiOptions; |
| 100 | use tempfile::TempDir; |
| 101 | |
| 102 | fn app_with_workspace(tmpdir: &TempDir) -> App { |
| 103 | App::new( |
| 104 | TuiOptions { |
| 105 | use_alt_screen: false, |
| 106 | skills_dir: tmpdir.path().join("skills"), |
| 107 | memory_path: tmpdir.path().join("memory.md"), |
| 108 | notes_path: tmpdir.path().join("notes.txt"), |
| 109 | mcp_config_path: tmpdir.path().join("mcp.json"), |
| 110 | ..crate::test_support::test_tui_options(tmpdir.path()) |
| 111 | }, |
| 112 | &Config::default(), |
| 113 | ) |
| 114 | } |
| 115 | |
| 116 | /// A 1x1 PNG. `/attach` now reads the bytes, so the fixture has to be a |
| 117 | /// real image rather than a plausible filename. |
| 118 | const PNG_1X1: &[u8] = &[ |
| 119 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, |
| 120 | 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, |
| 121 | 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, |
| 122 | 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, |
| 123 | 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, |
| 124 | ]; |
| 125 | |
| 126 | #[test] |
| 127 | fn attach_inserts_image_reference() { |
| 128 | let tmpdir = TempDir::new().expect("tempdir"); |
| 129 | let image_path = tmpdir.path().join("photo.png"); |
| 130 | std::fs::write(&image_path, PNG_1X1).expect("write image fixture"); |
| 131 | let mut app = app_with_workspace(&tmpdir); |
| 132 | |
| 133 | let result = attach(&mut app, Some("photo.png")); |
| 134 | |
| 135 | assert!(result.message.expect("message").contains("Attached image")); |
| 136 | assert!(app.input.contains("[Attached image:")); |
| 137 | let canonical_path = image_path.canonicalize().expect("canonical image path"); |
| 138 | assert!(app.input.contains(&canonical_path.display().to_string())); |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn attach_rejects_a_png_that_is_not_actually_an_image() { |
| 143 | // The failure this guards against is a user attaching a file that |
| 144 | // looks right, the turn going out, and the model reporting it cannot |
| 145 | // see anything — with no clue why. |
| 146 | let tmpdir = TempDir::new().expect("tempdir"); |
| 147 | std::fs::write(tmpdir.path().join("photo.png"), b"not actually decoded") |
| 148 | .expect("write fixture"); |
| 149 | let mut app = app_with_workspace(&tmpdir); |
| 150 | |
| 151 | let result = attach(&mut app, Some("photo.png")); |
| 152 | |
| 153 | let message = result.message.expect("message"); |
| 154 | assert!( |
| 155 | message.contains("not a PNG, JPEG, GIF or WebP"), |
| 156 | "{message}" |
| 157 | ); |
| 158 | assert!( |
| 159 | app.input.is_empty(), |
| 160 | "a refused attachment must not reach the composer" |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | #[test] |
| 165 | fn attach_rejects_an_image_over_the_size_limit() { |
| 166 | let tmpdir = TempDir::new().expect("tempdir"); |
| 167 | let mut oversized = PNG_1X1.to_vec(); |
| 168 | oversized.resize(crate::image_attach::MAX_IMAGE_BYTES + 1, 0); |
| 169 | std::fs::write(tmpdir.path().join("huge.png"), &oversized).expect("write fixture"); |
| 170 | let mut app = app_with_workspace(&tmpdir); |
| 171 | |
| 172 | let result = attach(&mut app, Some("huge.png")); |
| 173 | |
| 174 | let message = result.message.expect("message"); |
| 175 | assert!(message.contains("per-image limit"), "{message}"); |
| 176 | assert!(app.input.is_empty()); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn attach_rejects_unsupported_extension() { |
| 181 | let tmpdir = TempDir::new().expect("tempdir"); |
| 182 | std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture"); |
| 183 | let mut app = app_with_workspace(&tmpdir); |
| 184 | |
| 185 | let result = attach(&mut app, Some("notes.txt")); |
| 186 | |
| 187 | assert!( |
| 188 | result |
| 189 | .message |
| 190 | .expect("message") |
| 191 | .contains("Unsupported attachment type") |
| 192 | ); |
| 193 | assert!(app.input.is_empty()); |
| 194 | } |
| 195 | } |
| 196 |