Skip to main content

ta_changeset/
diff_handlers.rs

1//! # Diff Handlers
2//!
3//! Configuration-driven external diff viewing for non-text files.
4//!
5//! Example `.ta/diff-handlers.toml`:
6//! ```toml
7//! [[handler]]
8//! pattern = "*.uasset"
9//! command = "UnrealEditor"
10//! args = ["{file}"]
11//! description = "Unreal Engine asset"
12//!
13//! [[handler]]
14//! pattern = "*.{png,jpg,jpeg}"
15//! command = "open"  # macOS
16//! args = ["-a", "Preview", "{file}"]
17//! description = "Image file"
18//!
19//! [[handler]]
20//! pattern = "*.blend"
21//! command = "blender"
22//! args = ["{file}"]
23//! description = "Blender file"
24//! ```
25
26use serde::{Deserialize, Serialize};
27use std::path::Path;
28use std::process::Command;
29use thiserror::Error;
30
31/// Configuration for external diff handlers loaded from `.ta/diff-handlers.toml`.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct DiffHandlersConfig {
34    /// List of handler rules, evaluated in order.
35    #[serde(default)]
36    pub handler: Vec<HandlerRule>,
37}
38
39/// A single handler rule mapping file patterns to external applications.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct HandlerRule {
42    /// Glob pattern for matching file paths (e.g., "*.png", "assets/**/*.blend").
43    pub pattern: String,
44    /// External command to execute (e.g., "open", "blender", "UnrealEditor").
45    pub command: String,
46    /// Arguments to pass to the command. Use `{file}` placeholder for the file path.
47    #[serde(default)]
48    pub args: Vec<String>,
49    /// Human-readable description of this handler (optional).
50    #[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    /// Load diff-handlers config from a TOML file.
70    ///
71    /// Returns a default (empty) config if the file doesn't exist.
72    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    /// Load config from the standard location (`.ta/diff-handlers.toml` in project root).
83    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    /// Find the first handler matching the given file path.
89    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    /// Open a file with the configured handler, or use OS default if no handler matches.
96    ///
97    /// - If a handler is configured for the file pattern, use it.
98    /// - Otherwise, fall back to OS default (`open` on macOS, `xdg-open` on Linux).
99    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
117/// Check if a glob pattern matches a file path.
118///
119/// Supports basic glob syntax:
120/// - `*` matches any characters except `/`
121/// - `**` matches any characters including `/`
122/// - `{a,b,c}` matches any of the alternatives
123fn pattern_matches(pattern: &str, path: &str) -> bool {
124    // Use the glob crate for pattern matching.
125    // For simplicity, we'll use a basic glob matcher that handles common patterns.
126    match glob::Pattern::new(pattern) {
127        Ok(glob_pattern) => glob_pattern.matches(path),
128        Err(_) => false,
129    }
130}
131
132/// Launch an external handler for a file.
133fn launch_handler(handler: &HandlerRule, file_path: &Path) -> Result<(), DiffHandlerError> {
134    let file_str = file_path.to_string_lossy();
135
136    // Substitute {file} placeholder in args.
137    let args: Vec<String> = handler
138        .args
139        .iter()
140        .map(|arg| arg.replace("{file}", &file_str))
141        .collect();
142
143    // Launch the command.
144    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
165/// Launch OS default application for a file.
166///
167/// - macOS: `open <file>`
168/// - Linux: `xdg-open <file>`
169/// - Windows: `start <file>` (not yet implemented)
170fn 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        // Note: glob crate doesn't support brace expansion {a,b,c}.
220        // For multiple extensions, users should create separate handler rules.
221        // Example: one rule for *.png, another for *.jpg.
222        assert!(!pattern_matches("*.{png,jpg}", "image.png")); // brace syntax not supported
223    }
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    // Note: We don't test actual command launching here because it depends on the system.
320    // Manual testing required for verifying launch behavior.
321}