1use serde::{Deserialize, Serialize};
27use std::path::Path;
28use std::process::Command;
29use thiserror::Error;
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct DiffHandlersConfig {
34 #[serde(default)]
36 pub handler: Vec<HandlerRule>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct HandlerRule {
42 pub pattern: String,
44 pub command: String,
46 #[serde(default)]
48 pub args: Vec<String>,
49 #[serde(default)]
51 pub description: Option<String>,
52}
53
54#[derive(Error, Debug)]
55pub enum DiffHandlerError {
56 #[error("Failed to read diff-handlers config: {0}")]
57 ConfigRead(#[from] std::io::Error),
58 #[error("Failed to parse diff-handlers config: {0}")]
59 ConfigParse(#[from] toml::de::Error),
60 #[error("No handler configured for file: {0}")]
61 NoHandler(String),
62 #[error("Failed to launch external handler: {0}")]
63 LaunchFailed(String),
64 #[error("Handler command not found: {0}")]
65 CommandNotFound(String),
66}
67
68impl DiffHandlersConfig {
69 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, DiffHandlerError> {
73 let path = path.as_ref();
74 if !path.exists() {
75 return Ok(Self::default());
76 }
77 let content = std::fs::read_to_string(path)?;
78 let config: DiffHandlersConfig = toml::from_str(&content)?;
79 Ok(config)
80 }
81
82 pub fn load_from_project<P: AsRef<Path>>(project_root: P) -> Result<Self, DiffHandlerError> {
84 let config_path = project_root.as_ref().join(".ta/diff-handlers.toml");
85 Self::load(config_path)
86 }
87
88 pub fn find_handler(&self, file_path: &str) -> Option<&HandlerRule> {
90 self.handler
91 .iter()
92 .find(|h| pattern_matches(&h.pattern, file_path))
93 }
94
95 pub fn open_file<P: AsRef<Path>>(
100 &self,
101 file_path: P,
102 fallback_to_os_default: bool,
103 ) -> Result<(), DiffHandlerError> {
104 let file_path = file_path.as_ref();
105 let file_str = file_path.to_string_lossy();
106
107 if let Some(handler) = self.find_handler(&file_str) {
108 launch_handler(handler, file_path)
109 } else if fallback_to_os_default {
110 launch_os_default(file_path)
111 } else {
112 Err(DiffHandlerError::NoHandler(file_str.to_string()))
113 }
114 }
115}
116
117fn pattern_matches(pattern: &str, path: &str) -> bool {
124 match glob::Pattern::new(pattern) {
127 Ok(glob_pattern) => glob_pattern.matches(path),
128 Err(_) => false,
129 }
130}
131
132fn launch_handler(handler: &HandlerRule, file_path: &Path) -> Result<(), DiffHandlerError> {
134 let file_str = file_path.to_string_lossy();
135
136 let args: Vec<String> = handler
138 .args
139 .iter()
140 .map(|arg| arg.replace("{file}", &file_str))
141 .collect();
142
143 let result = Command::new(&handler.command)
145 .args(&args)
146 .spawn()
147 .map_err(|e| {
148 if e.kind() == std::io::ErrorKind::NotFound {
149 DiffHandlerError::CommandNotFound(handler.command.clone())
150 } else {
151 DiffHandlerError::LaunchFailed(format!("{}: {}", handler.command, e))
152 }
153 })?;
154
155 tracing::info!(
156 "Launched {} {} with PID {:?}",
157 handler.command,
158 file_str,
159 result.id()
160 );
161
162 Ok(())
163}
164
165fn launch_os_default(file_path: &Path) -> Result<(), DiffHandlerError> {
171 let file_str = file_path.to_string_lossy();
172
173 #[cfg(target_os = "macos")]
174 let (command, args) = ("open", vec![file_str.as_ref()]);
175
176 #[cfg(target_os = "linux")]
177 let (command, args) = ("xdg-open", vec![file_str.as_ref()]);
178
179 #[cfg(target_os = "windows")]
180 let (command, args) = ("cmd", vec!["/c", "start", "", file_str.as_ref()]);
181
182 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
183 return Err(DiffHandlerError::LaunchFailed(
184 "OS default handler not supported on this platform".to_string(),
185 ));
186
187 let result = Command::new(command)
188 .args(&args)
189 .spawn()
190 .map_err(|e| DiffHandlerError::LaunchFailed(format!("OS default handler failed: {}", e)))?;
191
192 tracing::info!(
193 "Launched OS default handler for {} with PID {:?}",
194 file_str,
195 result.id()
196 );
197
198 Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use tempfile::TempDir;
205
206 #[test]
207 fn test_pattern_matching() {
208 assert!(pattern_matches("*.png", "image.png"));
209 assert!(pattern_matches("*.png", "path/to/image.png"));
210 assert!(!pattern_matches("*.png", "image.jpg"));
211
212 assert!(pattern_matches(
213 "assets/**/*.uasset",
214 "assets/models/char.uasset"
215 ));
216 assert!(pattern_matches("assets/**/*.uasset", "assets/char.uasset"));
217 assert!(!pattern_matches("assets/**/*.uasset", "models/char.uasset"));
218
219 assert!(!pattern_matches("*.{png,jpg}", "image.png")); }
224
225 #[test]
226 fn test_load_config_missing_file() {
227 let temp = TempDir::new().unwrap();
228 let config_path = temp.path().join("missing.toml");
229 let config = DiffHandlersConfig::load(&config_path).unwrap();
230 assert!(config.handler.is_empty());
231 }
232
233 #[test]
234 fn test_load_config_valid_file() {
235 let temp = TempDir::new().unwrap();
236 let config_path = temp.path().join("diff-handlers.toml");
237 std::fs::write(
238 &config_path,
239 r#"
240[[handler]]
241pattern = "*.png"
242command = "open"
243args = ["-a", "Preview", "{file}"]
244description = "Image viewer"
245
246[[handler]]
247pattern = "*.blend"
248command = "blender"
249args = ["{file}"]
250"#,
251 )
252 .unwrap();
253
254 let config = DiffHandlersConfig::load(&config_path).unwrap();
255 assert_eq!(config.handler.len(), 2);
256 assert_eq!(config.handler[0].pattern, "*.png");
257 assert_eq!(config.handler[0].command, "open");
258 assert_eq!(config.handler[1].pattern, "*.blend");
259 }
260
261 #[test]
262 fn test_find_handler() {
263 let config = DiffHandlersConfig {
264 handler: vec![
265 HandlerRule {
266 pattern: "*.png".to_string(),
267 command: "image-viewer".to_string(),
268 args: vec!["{file}".to_string()],
269 description: Some("Image".to_string()),
270 },
271 HandlerRule {
272 pattern: "assets/**/*.blend".to_string(),
273 command: "blender".to_string(),
274 args: vec!["{file}".to_string()],
275 description: None,
276 },
277 ],
278 };
279
280 let handler = config.find_handler("test.png");
281 assert!(handler.is_some());
282 assert_eq!(handler.unwrap().command, "image-viewer");
283
284 let handler = config.find_handler("assets/models/char.blend");
285 assert!(handler.is_some());
286 assert_eq!(handler.unwrap().command, "blender");
287
288 let handler = config.find_handler("test.txt");
289 assert!(handler.is_none());
290 }
291
292 #[test]
293 fn test_arg_substitution() {
294 let handler = HandlerRule {
295 pattern: "*.test".to_string(),
296 command: "test-cmd".to_string(),
297 args: vec![
298 "--input".to_string(),
299 "{file}".to_string(),
300 "--output".to_string(),
301 "{file}.out".to_string(),
302 ],
303 description: None,
304 };
305
306 let file_path = Path::new("/tmp/test.test");
307 let args: Vec<String> = handler
308 .args
309 .iter()
310 .map(|arg| arg.replace("{file}", &file_path.to_string_lossy()))
311 .collect();
312
313 assert_eq!(args[0], "--input");
314 assert_eq!(args[1], "/tmp/test.test");
315 assert_eq!(args[2], "--output");
316 assert_eq!(args[3], "/tmp/test.test.out");
317 }
318
319 }