Skip to main content

sql_cli/sql/functions/
path.rs

1use std::path::Path;
2
3use crate::data::datatable::DataValue;
4use crate::sql::functions::{ArgCount, FunctionCategory, FunctionSignature, SqlFunction};
5use anyhow::{anyhow, Result};
6
7fn as_str(v: &DataValue) -> Option<String> {
8    match v {
9        DataValue::String(s) => Some(s.clone()),
10        DataValue::InternedString(s) => Some(s.as_ref().to_string()),
11        DataValue::Null => None,
12        _ => Some(v.to_string()),
13    }
14}
15
16/// BASENAME - last component of a path (file name with extension)
17pub struct BasenameFunction;
18
19impl SqlFunction for BasenameFunction {
20    fn signature(&self) -> FunctionSignature {
21        FunctionSignature {
22            name: "BASENAME",
23            category: FunctionCategory::String,
24            arg_count: ArgCount::Fixed(1),
25            description: "Return the last component of a path (file name with extension)",
26            returns: "STRING (or NULL if path has no file name)",
27            examples: vec![
28                "SELECT BASENAME('/home/me/src/main.rs')  -- 'main.rs'",
29                "SELECT BASENAME(path) FROM files",
30            ],
31        }
32    }
33
34    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
35        self.validate_args(args)?;
36        let Some(s) = as_str(&args[0]) else {
37            return Ok(DataValue::Null);
38        };
39        match Path::new(&s).file_name() {
40            Some(name) => Ok(DataValue::String(name.to_string_lossy().into_owned())),
41            None => Ok(DataValue::Null),
42        }
43    }
44}
45
46/// DIRNAME - everything except the last component
47pub struct DirnameFunction;
48
49impl SqlFunction for DirnameFunction {
50    fn signature(&self) -> FunctionSignature {
51        FunctionSignature {
52            name: "DIRNAME",
53            category: FunctionCategory::String,
54            arg_count: ArgCount::Fixed(1),
55            description: "Return the parent directory of a path",
56            returns: "STRING (or NULL if path has no parent)",
57            examples: vec![
58                "SELECT DIRNAME('/home/me/src/main.rs')  -- '/home/me/src'",
59                "SELECT DIRNAME(path) FROM files",
60            ],
61        }
62    }
63
64    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
65        self.validate_args(args)?;
66        let Some(s) = as_str(&args[0]) else {
67            return Ok(DataValue::Null);
68        };
69        match Path::new(&s).parent() {
70            Some(parent) => {
71                let out = parent.to_string_lossy();
72                if out.is_empty() {
73                    Ok(DataValue::Null)
74                } else {
75                    Ok(DataValue::String(out.into_owned()))
76                }
77            }
78            None => Ok(DataValue::Null),
79        }
80    }
81}
82
83/// EXTENSION - file extension (without leading dot)
84pub struct ExtensionFunction;
85
86impl SqlFunction for ExtensionFunction {
87    fn signature(&self) -> FunctionSignature {
88        FunctionSignature {
89            name: "EXTENSION",
90            category: FunctionCategory::String,
91            arg_count: ArgCount::Fixed(1),
92            description: "Return the file extension (without leading dot), or NULL if none",
93            returns: "STRING or NULL",
94            examples: vec![
95                "SELECT EXTENSION('/home/me/src/main.rs')  -- 'rs'",
96                "SELECT EXTENSION('README')                 -- NULL",
97                "SELECT EXTENSION(path) FROM files",
98            ],
99        }
100    }
101
102    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
103        self.validate_args(args)?;
104        let Some(s) = as_str(&args[0]) else {
105            return Ok(DataValue::Null);
106        };
107        match Path::new(&s).extension() {
108            Some(ext) => Ok(DataValue::String(ext.to_string_lossy().into_owned())),
109            None => Ok(DataValue::Null),
110        }
111    }
112}
113
114/// STEM - file name without extension
115pub struct StemFunction;
116
117impl SqlFunction for StemFunction {
118    fn signature(&self) -> FunctionSignature {
119        FunctionSignature {
120            name: "STEM",
121            category: FunctionCategory::String,
122            arg_count: ArgCount::Fixed(1),
123            description: "Return the file name without its extension",
124            returns: "STRING or NULL",
125            examples: vec![
126                "SELECT STEM('/home/me/src/main.rs')  -- 'main'",
127                "SELECT STEM('archive.tar.gz')         -- 'archive.tar'",
128                "SELECT STEM(path) FROM files",
129            ],
130        }
131    }
132
133    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
134        self.validate_args(args)?;
135        let Some(s) = as_str(&args[0]) else {
136            return Ok(DataValue::Null);
137        };
138        match Path::new(&s).file_stem() {
139            Some(stem) => Ok(DataValue::String(stem.to_string_lossy().into_owned())),
140            None => Ok(DataValue::Null),
141        }
142    }
143}
144
145/// PATH_DEPTH - number of components in the path
146pub struct PathDepthFunction;
147
148impl SqlFunction for PathDepthFunction {
149    fn signature(&self) -> FunctionSignature {
150        FunctionSignature {
151            name: "PATH_DEPTH",
152            category: FunctionCategory::String,
153            arg_count: ArgCount::Fixed(1),
154            description: "Return the number of components in a path",
155            returns: "INTEGER",
156            examples: vec![
157                "SELECT PATH_DEPTH('/home/me/src/main.rs')  -- 5",
158                "SELECT PATH_DEPTH('src/main.rs')            -- 2",
159            ],
160        }
161    }
162
163    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
164        self.validate_args(args)?;
165        let Some(s) = as_str(&args[0]) else {
166            return Ok(DataValue::Null);
167        };
168        let count = Path::new(&s).components().count() as i64;
169        Ok(DataValue::Integer(count))
170    }
171}
172
173/// PATH_PART - nth component (1-based; negative = from end, -1 = last)
174pub struct PathPartFunction;
175
176impl SqlFunction for PathPartFunction {
177    fn signature(&self) -> FunctionSignature {
178        FunctionSignature {
179            name: "PATH_PART",
180            category: FunctionCategory::String,
181            arg_count: ArgCount::Fixed(2),
182            description: "Return the nth component of a path (1-based; negative counts from the end, -1 = last)",
183            returns: "STRING or NULL if index out of range",
184            examples: vec![
185                "SELECT PATH_PART('/home/me/src/main.rs', 1)   -- 'home'",
186                "SELECT PATH_PART('/home/me/src/main.rs', -1)  -- 'main.rs'",
187                "SELECT PATH_PART('/home/me/src/main.rs', -2)  -- 'src'",
188            ],
189        }
190    }
191
192    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
193        self.validate_args(args)?;
194        let Some(s) = as_str(&args[0]) else {
195            return Ok(DataValue::Null);
196        };
197        let n = match &args[1] {
198            DataValue::Integer(n) => *n,
199            DataValue::Float(f) => *f as i64,
200            DataValue::Null => return Ok(DataValue::Null),
201            _ => return Err(anyhow!("PATH_PART index must be an integer")),
202        };
203
204        let parts: Vec<String> = Path::new(&s)
205            .components()
206            .map(|c| c.as_os_str().to_string_lossy().into_owned())
207            .collect();
208
209        if parts.is_empty() || n == 0 {
210            return Ok(DataValue::Null);
211        }
212
213        let idx = if n > 0 {
214            (n - 1) as usize
215        } else {
216            let from_end = (-n) as usize;
217            if from_end > parts.len() {
218                return Ok(DataValue::Null);
219            }
220            parts.len() - from_end
221        };
222
223        match parts.get(idx) {
224            Some(part) => Ok(DataValue::String(part.clone())),
225            None => Ok(DataValue::Null),
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn s(v: &str) -> DataValue {
235        DataValue::String(v.to_string())
236    }
237
238    #[test]
239    fn basename_extracts_file_name() {
240        let f = BasenameFunction;
241        assert_eq!(
242            f.evaluate(&[s("/home/me/src/main.rs")]).unwrap(),
243            s("main.rs")
244        );
245        assert_eq!(f.evaluate(&[s("main.rs")]).unwrap(), s("main.rs"));
246        assert_eq!(f.evaluate(&[DataValue::Null]).unwrap(), DataValue::Null);
247    }
248
249    #[test]
250    fn dirname_extracts_parent() {
251        let f = DirnameFunction;
252        assert_eq!(
253            f.evaluate(&[s("/home/me/src/main.rs")]).unwrap(),
254            s("/home/me/src")
255        );
256        assert_eq!(f.evaluate(&[s("main.rs")]).unwrap(), DataValue::Null);
257    }
258
259    #[test]
260    fn extension_returns_ext_or_null() {
261        let f = ExtensionFunction;
262        assert_eq!(f.evaluate(&[s("main.rs")]).unwrap(), s("rs"));
263        assert_eq!(f.evaluate(&[s("archive.tar.gz")]).unwrap(), s("gz"));
264        assert_eq!(f.evaluate(&[s("README")]).unwrap(), DataValue::Null);
265        assert_eq!(f.evaluate(&[s(".gitignore")]).unwrap(), DataValue::Null);
266    }
267
268    #[test]
269    fn stem_strips_last_extension() {
270        let f = StemFunction;
271        assert_eq!(f.evaluate(&[s("main.rs")]).unwrap(), s("main"));
272        assert_eq!(
273            f.evaluate(&[s("archive.tar.gz")]).unwrap(),
274            s("archive.tar")
275        );
276        assert_eq!(f.evaluate(&[s("README")]).unwrap(), s("README"));
277    }
278
279    #[test]
280    fn path_depth_counts_components() {
281        let f = PathDepthFunction;
282        // "/home/me/src/main.rs" => [/, home, me, src, main.rs] = 5
283        assert_eq!(
284            f.evaluate(&[s("/home/me/src/main.rs")]).unwrap(),
285            DataValue::Integer(5)
286        );
287        assert_eq!(
288            f.evaluate(&[s("src/main.rs")]).unwrap(),
289            DataValue::Integer(2)
290        );
291    }
292
293    #[test]
294    fn path_part_handles_positive_and_negative() {
295        let f = PathPartFunction;
296        let p = s("/home/me/src/main.rs");
297        assert_eq!(
298            f.evaluate(&[p.clone(), DataValue::Integer(-1)]).unwrap(),
299            s("main.rs")
300        );
301        assert_eq!(
302            f.evaluate(&[p.clone(), DataValue::Integer(-2)]).unwrap(),
303            s("src")
304        );
305        assert_eq!(
306            f.evaluate(&[p.clone(), DataValue::Integer(2)]).unwrap(),
307            s("home")
308        );
309        assert_eq!(
310            f.evaluate(&[p.clone(), DataValue::Integer(99)]).unwrap(),
311            DataValue::Null
312        );
313        assert_eq!(
314            f.evaluate(&[p, DataValue::Integer(0)]).unwrap(),
315            DataValue::Null
316        );
317    }
318}