Skip to main content

exiftool_rs/
config.rs

1//! ExifTool configuration file parser (.ExifTool_config).
2//!
3//! Supports user-defined tags and shortcuts in a simplified format.
4//! The Perl ExifTool uses Perl code in the config; we support a subset:
5//!
6//! ```text
7//! # Comment
8//! %Image::ExifTool::UserDefined = (
9//!     'Image::ExifTool::Exif::Main' => {
10//!         0xd000 => { Name => 'MyCustomTag', Writable => 'string' },
11//!     },
12//! );
13//!
14//! %Image::ExifTool::UserDefined::Shortcuts = (
15//!     MyShortcut => ['Artist', 'Copyright', 'Title'],
16//! );
17//! ```
18
19use std::path::Path;
20
21/// A user-defined tag from the config file.
22#[derive(Debug, Clone)]
23pub struct UserTag {
24    pub tag_id: u16,
25    pub name: String,
26    pub writable: Option<String>,
27    pub group: String,
28}
29
30/// A tag shortcut (group of tag names).
31#[derive(Debug, Clone)]
32pub struct Shortcut {
33    pub name: String,
34    pub tags: Vec<String>,
35}
36
37/// Parsed configuration.
38#[derive(Debug, Default)]
39pub struct Config {
40    pub user_tags: Vec<UserTag>,
41    pub shortcuts: Vec<Shortcut>,
42}
43
44impl Config {
45    /// Load configuration from the default location.
46    pub fn load_default() -> Self {
47        // Try ~/.ExifTool_config, then ./.ExifTool_config
48        let candidates = [
49            dirs_home().map(|h| h.join(".ExifTool_config")),
50            Some(std::path::PathBuf::from(".ExifTool_config")),
51        ];
52
53        for candidate in candidates.iter().flatten() {
54            if candidate.exists() {
55                if let Some(config) = Self::load(candidate) {
56                    return config;
57                }
58            }
59        }
60
61        Self::default()
62    }
63
64    /// Load and parse a config file.
65    pub fn load<P: AsRef<Path>>(path: P) -> Option<Self> {
66        let content = std::fs::read_to_string(path).ok()?;
67        Some(Self::parse(&content))
68    }
69
70    /// Parse config file content.
71    fn parse(content: &str) -> Self {
72        let mut config = Config::default();
73
74        // Remove comments
75        let lines: Vec<&str> = content
76            .lines()
77            .map(|l| l.split('#').next().unwrap_or("").trim())
78            .collect();
79        let text = lines.join("\n");
80
81        // Parse UserDefined tags
82        if let Some(start) = text.find("%Image::ExifTool::UserDefined") {
83            if let Some(paren_start) = text[start..].find('(') {
84                let block_start = start + paren_start + 1;
85                if let Some(block_end) = find_matching_paren(&text, block_start) {
86                    let block = &text[block_start..block_end];
87                    parse_user_tags(block, &mut config.user_tags);
88                }
89            }
90        }
91
92        // Parse Shortcuts
93        if let Some(start) = text.find("Shortcuts") {
94            if let Some(paren_start) = text[start..].find('(') {
95                let block_start = start + paren_start + 1;
96                if let Some(block_end) = find_matching_paren(&text, block_start) {
97                    let block = &text[block_start..block_end];
98                    parse_shortcuts(block, &mut config.shortcuts);
99                }
100            }
101        }
102
103        config
104    }
105}
106
107fn parse_user_tags(block: &str, tags: &mut Vec<UserTag>) {
108    // Look for: 0xNNNN => { Name => 'XXX' }
109    let mut pos = 0;
110    while let Some(hex_pos) = block[pos..].find("0x") {
111        let abs_pos = pos + hex_pos;
112        let rest = &block[abs_pos + 2..];
113
114        // Read hex number
115        let hex_end = rest
116            .find(|c: char| !c.is_ascii_hexdigit())
117            .unwrap_or(rest.len());
118        if let Ok(tag_id) = u16::from_str_radix(&rest[..hex_end], 16) {
119            // Find Name
120            if let Some(name_pos) = rest.find("Name") {
121                let after_name = &rest[name_pos..];
122                if let Some(name) = extract_perl_string(after_name) {
123                    tags.push(UserTag {
124                        tag_id,
125                        name: name.clone(),
126                        writable: extract_after_key(after_name, "Writable"),
127                        group: "UserDefined".to_string(),
128                    });
129                }
130            }
131        }
132
133        pos = abs_pos + hex_end + 2;
134    }
135}
136
137fn parse_shortcuts(block: &str, shortcuts: &mut Vec<Shortcut>) {
138    // Look for: ShortcutName => ['Tag1', 'Tag2', ...]
139    for line in block.lines() {
140        let line = line.trim();
141        if let Some(arrow) = line.find("=>") {
142            let name = line[..arrow]
143                .trim()
144                .trim_matches('\'')
145                .trim_matches('"')
146                .to_string();
147            let rest = &line[arrow + 2..];
148
149            // Parse array ['Tag1', 'Tag2']
150            if let Some(bracket_start) = rest.find('[') {
151                if let Some(bracket_end) = rest.find(']') {
152                    let array_content = &rest[bracket_start + 1..bracket_end];
153                    let tags: Vec<String> = array_content
154                        .split(',')
155                        .map(|s| s.trim().trim_matches('\'').trim_matches('"').to_string())
156                        .filter(|s| !s.is_empty())
157                        .collect();
158
159                    if !name.is_empty() && !tags.is_empty() {
160                        shortcuts.push(Shortcut { name, tags });
161                    }
162                }
163            }
164        }
165    }
166}
167
168fn extract_perl_string(text: &str) -> Option<String> {
169    // Find first quoted string after =>
170    let arrow = text.find("=>")?;
171    let rest = &text[arrow + 2..];
172    let rest = rest.trim();
173
174    if let Some(stripped) = rest.strip_prefix('\'') {
175        let end = stripped.find('\'')?;
176        Some(stripped[..end].to_string())
177    } else if let Some(stripped) = rest.strip_prefix('"') {
178        let end = stripped.find('"')?;
179        Some(stripped[..end].to_string())
180    } else {
181        None
182    }
183}
184
185fn extract_after_key(text: &str, key: &str) -> Option<String> {
186    let pos = text.find(key)?;
187    extract_perl_string(&text[pos..])
188}
189
190fn find_matching_paren(text: &str, start: usize) -> Option<usize> {
191    let mut depth = 1;
192    let bytes = text.as_bytes();
193    let mut i = start;
194    while i < bytes.len() && depth > 0 {
195        match bytes[i] {
196            b'(' => depth += 1,
197            b')' => {
198                depth -= 1;
199                if depth == 0 {
200                    return Some(i);
201                }
202            }
203            _ => {}
204        }
205        i += 1;
206    }
207    None
208}
209
210fn dirs_home() -> Option<std::path::PathBuf> {
211    std::env::var("HOME").ok().map(std::path::PathBuf::from)
212}