返回 CodeWhale
attachment.rs
根目录 / crates / tui / src / commands / groups / utility / attachment.rs
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
196 lines RUST