返回 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 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
194 lines RUST