Skip to main content

serval/
utils.rs

1use crate::schema::{
2    ALL_RESOURCE_EXTENSIONS, CUSTOM_COLUMN, DEPLOYMENT_ID_COLUMN, EVENT_ID_COLUMN,
3    IMAGE_EXTENSIONS, PATH_COLUMN, RATING_COLUMN, VIDEO_EXTENSIONS, XMP_EXTENSIONS,
4    resource_extension, underlying_media_path,
5};
6use core::fmt;
7use indicatif::{ProgressBar, ProgressStyle};
8use pest_derive::Parser;
9use polars::prelude::*;
10use rayon::prelude::*;
11use std::collections::HashSet;
12use std::ffi::OsString;
13use std::fs::{File, FileTimes};
14use std::io;
15use std::str::FromStr;
16use std::{
17    env, fs,
18    path::{Path, PathBuf},
19    sync::Arc,
20};
21use walkdir::{DirEntry, WalkDir};
22use xmp_toolkit::{OpenFileOptions, XmpFile, XmpMeta};
23
24pub fn csv_projection_columns(names: &[&str]) -> Option<Arc<[PlSmallStr]>> {
25    Some(Arc::from(
26        names
27            .iter()
28            .map(|name| PlSmallStr::from(*name))
29            .collect::<Vec<_>>()
30            .into_boxed_slice(),
31    ))
32}
33
34pub fn reject_duplicate_csv_columns(df: &DataFrame) -> anyhow::Result<()> {
35    if df
36        .get_column_names()
37        .iter()
38        .any(|name| name.as_str().contains("_duplicated_"))
39    {
40        return Err(anyhow::anyhow!(
41            "Duplicated CSV columns detected. Please check the input CSV header."
42        ));
43    }
44
45    Ok(())
46}
47
48#[derive(Parser)]
49#[grammar = "filter.pest"]
50struct FilterParser;
51
52#[derive(clap::ValueEnum, Clone, Copy, Debug)]
53pub enum ResourceType {
54    Xmp,
55    Image,
56    Video,
57    Media, // Image or Video
58    All,   // All resources (for serval align)
59}
60
61impl fmt::Display for ResourceType {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{self:?}")
64    }
65}
66
67impl ResourceType {
68    fn extension(self) -> &'static [&'static str] {
69        match self {
70            ResourceType::Image => IMAGE_EXTENSIONS,
71            ResourceType::Video => VIDEO_EXTENSIONS,
72            ResourceType::Xmp => XMP_EXTENSIONS,
73            ResourceType::Media => crate::schema::MEDIA_EXTENSIONS,
74            ResourceType::All => ALL_RESOURCE_EXTENSIONS,
75        }
76    }
77
78    fn is_resource(self, path: &Path) -> bool {
79        resource_extension(path).is_some_and(|ext| self.extension().contains(&ext.as_str()))
80    }
81}
82
83#[derive(clap::ValueEnum, PartialEq, Clone, Copy, Debug)]
84pub enum TagType {
85    Species,
86    Individual,
87    Count,
88    Sex,
89    Bodypart,
90}
91
92impl fmt::Display for TagType {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{self:?}")
95    }
96}
97
98impl TagType {
99    pub fn col_name(self) -> &'static str {
100        match self {
101            TagType::Individual => "individual",
102            TagType::Species => "species",
103            TagType::Count => "count",
104            TagType::Sex => "sex",
105            TagType::Bodypart => "bodypart",
106        }
107    }
108    pub fn digikam_tag_prefix(self) -> &'static str {
109        match self {
110            TagType::Individual => "Individual/",
111            TagType::Species => "Species/",
112            TagType::Count => "Count/",
113            TagType::Sex => "Sex/",
114            TagType::Bodypart => "Bodypart/",
115        }
116    }
117    pub fn adobe_tag_prefix(self) -> &'static str {
118        match self {
119            TagType::Individual => "Individual|",
120            TagType::Species => "Species|",
121            TagType::Count => "Count|",
122            TagType::Sex => "Sex|",
123            TagType::Bodypart => "Bodypart|",
124        }
125    }
126}
127
128#[derive(clap::ValueEnum, PartialEq, Clone, Copy, Debug)]
129pub enum XmpUpdateType {
130    Species,
131    Individual,
132    Rating,
133}
134
135impl fmt::Display for XmpUpdateType {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{self:?}")
138    }
139}
140
141impl XmpUpdateType {
142    pub fn col_name(self) -> &'static str {
143        match self {
144            Self::Species => TagType::Species.col_name(),
145            Self::Individual => TagType::Individual.col_name(),
146            Self::Rating => RATING_COLUMN,
147        }
148    }
149
150    pub fn tag_type(self) -> Option<TagType> {
151        match self {
152            Self::Species => Some(TagType::Species),
153            Self::Individual => Some(TagType::Individual),
154            Self::Rating => None,
155        }
156    }
157}
158
159#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq)]
160pub enum ExtractFilterType {
161    Species,
162    Path,
163    Individual,
164    Rating,
165    Event,
166    Custom,
167    Advanced,
168}
169
170#[derive(clap::ValueEnum, Clone, Copy, Debug)]
171pub enum SubdirType {
172    Species,
173    Individual,
174    Rating,
175    Custom,
176}
177
178/// Represents a parsed filter condition
179#[derive(Debug, Clone)]
180pub struct FilterCondition {
181    pub filter_type: ExtractFilterType,
182    pub operator: FilterOperator,
183    pub value: String,
184}
185
186/// Supported filter operators
187#[derive(Debug, Clone)]
188pub enum FilterOperator {
189    Equal, // exact match
190    // Contains,        // TODO: substring match
191    GreaterEqual, // >=
192    LessEqual,    // <=
193    Greater,      // >
194    Less,         // <
195    Range(f64, f64), // min-max range
196                  // Not,             // TODO: negation wrapper
197}
198
199/// Logical operators for combining filters
200#[derive(Debug, Clone)]
201pub enum LogicalOperator {
202    And,
203    Or,
204}
205
206/// Complete filter expression tree
207#[derive(Debug, Clone)]
208pub enum FilterExpr {
209    Condition(FilterCondition),
210    Logical {
211        left: Box<FilterExpr>,
212        operator: LogicalOperator,
213        right: Box<FilterExpr>,
214    },
215    // Not(Box<FilterExpr>), // TODO, need to consider the multiple-tag case
216}
217
218impl ExtractFilterType {
219    /// Parse field aliases to filter types
220    pub fn from_alias(alias: &str) -> Option<Self> {
221        match alias.to_lowercase().as_str() {
222            "species" | "sp" | "s" => Some(Self::Species),
223            "individual" | "ind" | "i" => Some(Self::Individual),
224            "rating" | "rate" | "r" => Some(Self::Rating),
225            "path" | "p" => Some(Self::Path),
226            "event" | "e" => Some(Self::Event),
227            "custom" | "c" => Some(Self::Custom),
228            _ => None,
229        }
230    }
231}
232
233/// Parse advanced filter string into FilterExpr using pest
234pub fn parse_advanced_filter(input: &str) -> anyhow::Result<FilterExpr> {
235    use pest::Parser;
236
237    let pairs = FilterParser::parse(Rule::filter, input)
238        .map_err(|e| anyhow::anyhow!("Parse error: {e}"))?;
239
240    // Get the or_expr inside the filter rule
241    let or_expr = pairs
242        .into_iter()
243        .next()
244        .ok_or_else(|| anyhow::anyhow!("Empty parse result"))?
245        .into_inner()
246        .next()
247        .ok_or_else(|| anyhow::anyhow!("No expression found"))?;
248
249    build_expr(or_expr)
250}
251
252/// Build FilterExpr from pest Pair
253fn build_expr(pair: pest::iterators::Pair<Rule>) -> anyhow::Result<FilterExpr> {
254    match pair.as_rule() {
255        Rule::or_expr => {
256            let mut inner = pair.into_inner();
257            let mut expr = build_expr(inner.next().unwrap())?;
258
259            while let Some(next) = inner.next() {
260                if next.as_rule() == Rule::or_op {
261                    let right = build_expr(inner.next().unwrap())?;
262                    expr = FilterExpr::Logical {
263                        left: Box::new(expr),
264                        operator: LogicalOperator::Or,
265                        right: Box::new(right),
266                    };
267                }
268            }
269
270            Ok(expr)
271        }
272
273        Rule::and_expr => {
274            let mut inner = pair.into_inner();
275            let mut expr = build_expr(inner.next().unwrap())?;
276
277            while let Some(next) = inner.next() {
278                if next.as_rule() == Rule::and_op {
279                    let right = build_expr(inner.next().unwrap())?;
280                    expr = FilterExpr::Logical {
281                        left: Box::new(expr),
282                        operator: LogicalOperator::And,
283                        right: Box::new(right),
284                    };
285                }
286            }
287
288            Ok(expr)
289        }
290
291        Rule::primary => {
292            let inner = pair.into_inner().next().unwrap();
293            build_expr(inner)
294        }
295
296        Rule::paren_expr => {
297            let inner = pair.into_inner().next().unwrap();
298            build_expr(inner)
299        }
300
301        Rule::condition => {
302            let mut inner = pair.into_inner();
303            let field = inner.next().unwrap().as_str();
304            let value = inner.next().unwrap().as_str().trim(); // Trim whitespace from value
305
306            let filter_type = ExtractFilterType::from_alias(field)
307                .ok_or_else(|| anyhow::anyhow!("Unknown filter field: {field}"))?;
308
309            let (operator, cleaned_value) = parse_value_and_operator(value)?;
310
311            Ok(FilterExpr::Condition(FilterCondition {
312                filter_type,
313                operator,
314                value: cleaned_value,
315            }))
316        }
317
318        _ => Err(anyhow::anyhow!("Unexpected rule: {:?}", pair.as_rule())),
319    }
320}
321
322/// Parse value and detect operator (>=, <=, range, etc.)
323fn parse_value_and_operator(value: &str) -> anyhow::Result<(FilterOperator, String)> {
324    // Handle range syntax first (e.g., "1-5", "0.5-4.5")
325    if let Some((min_str, max_str)) = value.split_once('-')
326        && let (Ok(min), Ok(max)) = (min_str.trim().parse::<f64>(), max_str.trim().parse::<f64>())
327    {
328        return Ok((FilterOperator::Range(min, max), value.to_string()));
329    }
330
331    // Handle comparison operators
332    if let Some(stripped) = value.strip_prefix(">=") {
333        return Ok((FilterOperator::GreaterEqual, stripped.trim().to_string()));
334    }
335    if let Some(stripped) = value.strip_prefix("<=") {
336        return Ok((FilterOperator::LessEqual, stripped.trim().to_string()));
337    }
338    if let Some(stripped) = value.strip_prefix('>') {
339        return Ok((FilterOperator::Greater, stripped.trim().to_string()));
340    }
341    if let Some(stripped) = value.strip_prefix('<') {
342        return Ok((FilterOperator::Less, stripped.trim().to_string()));
343    }
344
345    // Remove quotes if present
346    let cleaned_value = if (value.starts_with('"') && value.ends_with('"'))
347        || (value.starts_with('\'') && value.ends_with('\''))
348    {
349        value[1..value.len() - 1].to_string()
350    } else {
351        value.to_string()
352    };
353
354    // Default to exact match for most fields, contains for path
355    Ok((FilterOperator::Equal, cleaned_value))
356}
357
358pub fn has_same_field_and_conditions(expr: &FilterExpr) -> bool {
359    // Detects whether any AND-combination in the expression (after distributing
360    // AND over OR) repeats a field, e.g. "sp:A and sp:B" but also
361    // "(sp:A and sp:B) or r:5" and "sp:A and (sp:B or r:5)".
362    // Returns (fields reachable in the subtree, repeated field found).
363    fn check(expr: &FilterExpr) -> (Vec<ExtractFilterType>, bool) {
364        match expr {
365            FilterExpr::Condition(cond) => (vec![cond.filter_type], false),
366            FilterExpr::Logical {
367                left,
368                operator,
369                right,
370            } => {
371                let (left_fields, left_dup) = check(left);
372                let (right_fields, right_dup) = check(right);
373                // For AND, a field reachable on both sides ends up repeated in
374                // some distributed AND-term; for OR, branches stay separate.
375                let dup = left_dup
376                    || right_dup
377                    || (matches!(operator, LogicalOperator::And)
378                        && left_fields.iter().any(|f| right_fields.contains(f)));
379                let mut fields = left_fields;
380                fields.extend(right_fields);
381                (fields, dup)
382            }
383        }
384    }
385
386    check(expr).1
387}
388
389/// Convert FilterExpr to Polars Expr
390///
391/// # Parameters
392/// * `expr` - The filter expression to convert
393/// * `use_aggregated` - If true, treats species/individual as list columns (for path-level filtering)
394pub fn filter_expr_to_polars(expr: &FilterExpr, use_aggregated: bool) -> anyhow::Result<Expr> {
395    use crate::utils::TagType;
396
397    match expr {
398        FilterExpr::Condition(condition) => {
399            let col_name = match condition.filter_type {
400                ExtractFilterType::Species => TagType::Species.col_name(),
401                ExtractFilterType::Individual => TagType::Individual.col_name(),
402                ExtractFilterType::Rating => RATING_COLUMN,
403                ExtractFilterType::Path => PATH_COLUMN,
404                ExtractFilterType::Event => EVENT_ID_COLUMN,
405                ExtractFilterType::Custom => CUSTOM_COLUMN,
406                ExtractFilterType::Advanced => {
407                    return Err(anyhow::anyhow!(
408                        "Advanced filter should not appear in conditions"
409                    ));
410                }
411            };
412
413            let base_col = col(col_name);
414
415            match &condition.operator {
416                FilterOperator::Equal => {
417                    if condition.filter_type == ExtractFilterType::Path {
418                        // Path uses contains for substring matching
419                        Ok(base_col
420                            .str()
421                            .contains_literal(lit(condition.value.clone())))
422                    } else if use_aggregated
423                        && (condition.filter_type == ExtractFilterType::Species
424                            || condition.filter_type == ExtractFilterType::Individual)
425                    {
426                        // For aggregated species/individual, check if list contains the value
427                        Ok(base_col
428                            .list()
429                            .contains(lit(condition.value.clone()), false))
430                    } else {
431                        Ok(base_col.eq(lit(condition.value.clone())))
432                    }
433                }
434                FilterOperator::Range(min, max) => {
435                    // Rating stays as scalar in both modes
436                    let numeric_col = base_col.cast(DataType::Float64);
437                    Ok(numeric_col
438                        .clone()
439                        .is_not_null()
440                        .and(numeric_col.clone().gt_eq(lit(*min)))
441                        .and(numeric_col.lt_eq(lit(*max))))
442                }
443                FilterOperator::GreaterEqual => {
444                    if let Ok(value) = condition.value.parse::<f64>() {
445                        let numeric_col = base_col.cast(DataType::Float64);
446                        Ok(numeric_col
447                            .clone()
448                            .is_not_null()
449                            .and(numeric_col.gt_eq(lit(value))))
450                    } else {
451                        Err(anyhow::anyhow!(
452                            "GreaterEqual operator requires numeric value"
453                        ))
454                    }
455                }
456                FilterOperator::LessEqual => {
457                    if let Ok(value) = condition.value.parse::<f64>() {
458                        let numeric_col = base_col.cast(DataType::Float64);
459                        Ok(numeric_col
460                            .clone()
461                            .is_not_null()
462                            .and(numeric_col.lt_eq(lit(value))))
463                    } else {
464                        Err(anyhow::anyhow!("LessEqual operator requires numeric value"))
465                    }
466                }
467                FilterOperator::Greater => {
468                    if let Ok(value) = condition.value.parse::<f64>() {
469                        let numeric_col = base_col.cast(DataType::Float64);
470                        Ok(numeric_col
471                            .clone()
472                            .is_not_null()
473                            .and(numeric_col.gt(lit(value))))
474                    } else {
475                        Err(anyhow::anyhow!("Greater operator requires numeric value"))
476                    }
477                }
478                FilterOperator::Less => {
479                    if let Ok(value) = condition.value.parse::<f64>() {
480                        let numeric_col = base_col.cast(DataType::Float64);
481                        Ok(numeric_col
482                            .clone()
483                            .is_not_null()
484                            .and(numeric_col.lt(lit(value))))
485                    } else {
486                        Err(anyhow::anyhow!("Less operator requires numeric value"))
487                    }
488                }
489            }
490        }
491        FilterExpr::Logical {
492            left,
493            operator,
494            right,
495        } => {
496            let left_expr = filter_expr_to_polars(left, use_aggregated)?;
497            let right_expr = filter_expr_to_polars(right, use_aggregated)?;
498
499            match operator {
500                LogicalOperator::And => Ok(left_expr.and(right_expr)),
501                LogicalOperator::Or => Ok(left_expr.or(right_expr)),
502            }
503        }
504    }
505}
506
507// Serval ignores
508fn is_ignored(entry: &DirEntry) -> bool {
509    entry
510        .file_name()
511        .to_str()
512        .map(|s| s.starts_with('.') || s.contains("精选")) // ignore 精选 and .dtrash
513        .unwrap_or(false)
514}
515
516// Serval bar style
517pub fn serval_pb_style() -> ProgressStyle {
518    ProgressStyle::default_bar()
519        .template(
520            "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {wide_msg}",
521        )
522        .unwrap()
523        .progress_chars("=> ")
524}
525
526pub fn configure_progress_bar(pb: &ProgressBar) {
527    pb.set_style(serval_pb_style());
528    pb.enable_steady_tick(std::time::Duration::from_secs(1));
529}
530
531/// Name of serval's own output directory, created under the working directory.
532/// Directory walkers must never treat it as camera-trap data.
533pub const SERVAL_OUTPUT_DIR: &str = "serval_output";
534
535static RUN_LOG: std::sync::OnceLock<(PathBuf, std::sync::Mutex<File>)> = std::sync::OnceLock::new();
536
537/// Best-effort creation of the run log for file-operation commands. Written to
538/// `log_dir` when the command has an output directory, otherwise to
539/// ./serval_output/logs. Per-file statuses and warnings are mirrored there,
540/// since transient bar messages leave no trace in the terminal.
541pub fn init_run_log(command: &str, log_dir: Option<&Path>) {
542    let log_dir = log_dir
543        .map(Path::to_path_buf)
544        .unwrap_or_else(|| PathBuf::from(format!("./{SERVAL_OUTPUT_DIR}/logs")));
545    let init = || -> anyhow::Result<(PathBuf, std::sync::Mutex<File>)> {
546        fs::create_dir_all(&log_dir)?;
547        let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
548        let log_path = log_dir.join(format!("serval_{command}_{timestamp}.log"));
549        let file = File::create(&log_path)?;
550        Ok((log_path, std::sync::Mutex::new(file)))
551    };
552    match init() {
553        Ok(entry) => {
554            let _ = RUN_LOG.set(entry);
555            log_line(&format!(
556                "Command: {}",
557                env::args().collect::<Vec<_>>().join(" ")
558            ));
559        }
560        Err(err) => eprintln!(
561            "Warning: failed to create run log in {}: {err}",
562            log_dir.display()
563        ),
564    }
565}
566
567pub fn run_log_path() -> Option<&'static Path> {
568    RUN_LOG.get().map(|(path, _)| path.as_path())
569}
570
571/// Append a timestamped line to the run log; no-op when no log is set up.
572pub fn log_line(message: &str) {
573    if let Some((_, log)) = RUN_LOG.get()
574        && let Ok(mut file) = log.lock()
575    {
576        use std::io::Write;
577        let timestamp = chrono::Local::now().format("%H:%M:%S");
578        let _ = writeln!(file, "[{timestamp}] {message}");
579    }
580}
581
582/// Show transient per-file status in the progress bar. When the bar is hidden
583/// (non-TTY output), print a plain line instead so logs keep the information.
584pub fn pb_status(pb: &ProgressBar, message: impl Into<String>) {
585    let message = message.into();
586    log_line(&message);
587    if pb.is_hidden() {
588        println!("{message}");
589    } else {
590        pb.set_message(message);
591    }
592}
593
594/// Prints warnings above the progress bar as they happen and, after the bar
595/// finishes, a count line so they are not overlooked.
596#[derive(Default)]
597pub struct WarningCollector {
598    count: std::sync::atomic::AtomicUsize,
599}
600
601impl WarningCollector {
602    /// Print the warning above the progress bar (or as a plain line when the
603    /// bar is hidden) and count it for the final notice.
604    pub fn warn(&self, pb: &ProgressBar, message: impl Into<String>) {
605        let message = message.into();
606        log_line(&format!("Warning: {message}"));
607        if pb.is_hidden() {
608            eprintln!("Warning: {message}");
609        } else {
610            pb.println(format!("Warning: {message}"));
611        }
612        self.count
613            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
614    }
615
616    /// Print the warning without a progress bar and count it for the final notice.
617    pub fn warn_plain(&self, message: impl Into<String>) {
618        let message = message.into();
619        log_line(&format!("Warning: {message}"));
620        eprintln!("Warning: {message}");
621        self.count
622            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
623    }
624
625    pub fn summarize(&self) {
626        let count = self.count.load(std::sync::atomic::Ordering::Relaxed);
627        if count > 0 {
628            log_line(&format!("{count} warning(s) occurred"));
629            eprintln!("{count} warning(s) occurred, see messages above.");
630        }
631    }
632}
633
634// workaround for https://github.com/rust-lang/rust/issues/42869
635// ref. https://github.com/sharkdp/fd/pull/72/files
636fn path_to_absolute(path: PathBuf) -> io::Result<PathBuf> {
637    if path.is_absolute() {
638        return Ok(path);
639    }
640    let path = path.strip_prefix(".").unwrap_or(&path);
641    env::current_dir().map(|current_dir| current_dir.join(path))
642}
643
644pub fn absolute_path(path: PathBuf) -> io::Result<PathBuf> {
645    let path_buf = path_to_absolute(path)?;
646    #[cfg(windows)]
647    let path_buf = Path::new(
648        path_buf
649            .as_path()
650            .to_string_lossy()
651            .trim_start_matches(r"\\?\"),
652    )
653    .to_path_buf();
654    Ok(path_buf)
655}
656
657pub fn path_enumerate(root_dir: PathBuf, resource_type: ResourceType) -> Vec<PathBuf> {
658    WalkDir::new(root_dir)
659        .into_iter()
660        .filter_entry(|e| !is_ignored(e))
661        .par_bridge()
662        .filter_map(Result::ok)
663        .filter(|e| resource_type.is_resource(e.path()))
664        .map(|e| e.into_path())
665        .collect()
666}
667
668/// Return a path that does not exist yet by appending "_1", "_2", ... to the
669/// file stem when the given path is already taken.
670pub fn dedup_output_path(path: PathBuf) -> PathBuf {
671    if !path.exists() {
672        return path;
673    }
674    let stem = path
675        .file_stem()
676        .map(|stem| stem.to_string_lossy().into_owned())
677        .unwrap_or_default();
678    let extension = path
679        .extension()
680        .map(|ext| ext.to_string_lossy().into_owned());
681    let mut i = 1;
682    loop {
683        let file_name = match &extension {
684            Some(ext) => format!("{stem}_{i}.{ext}"),
685            None => format!("{stem}_{i}"),
686        };
687        let candidate = path.with_file_name(file_name);
688        if !candidate.exists() {
689            return candidate;
690        }
691        i += 1;
692    }
693}
694
695pub fn resources_flatten(
696    deploy_dir: PathBuf,
697    working_dir: PathBuf,
698    resource_type: ResourceType,
699    dry_run: bool,
700    move_mode: bool,
701    prefix_deploy_id_in_name: bool,
702    keep_first_subdir: bool,
703) -> anyhow::Result<()> {
704    let deploy_id = deploy_dir
705        .file_name()
706        .ok_or_else(|| anyhow::anyhow!("Invalid deploy directory path: no filename"))?;
707
708    let base_output_dir = working_dir.join(deploy_id);
709    fs::create_dir_all(base_output_dir.clone())?;
710
711    let resource_paths = path_enumerate(deploy_dir.clone(), resource_type);
712    let num_resource = resource_paths.len();
713    println!(
714        "{} {}(s) found in {}",
715        num_resource,
716        resource_type,
717        deploy_dir.to_string_lossy()
718    );
719
720    let mut visited_path: HashSet<String> = HashSet::new();
721    let pb = if !dry_run {
722        Some(indicatif::ProgressBar::new(num_resource as u64))
723    } else {
724        None
725    };
726    if let Some(pb_ref) = &pb {
727        configure_progress_bar(pb_ref);
728    }
729    for resource in resource_paths {
730        let resource_parent = resource.parent().unwrap();
731        let relative_path = resource.strip_prefix(&deploy_dir).unwrap_or(&resource);
732        let mut relative_parts: Vec<OsString> = relative_path
733            .iter()
734            .map(|part| part.to_os_string())
735            .collect();
736        if relative_parts.is_empty() {
737            relative_parts.push("unnamed_file".into());
738        }
739
740        let mut output_dir = base_output_dir.clone();
741        if keep_first_subdir && relative_parts.len() > 1 {
742            output_dir = output_dir.join(&relative_parts[0]);
743            if !dry_run {
744                fs::create_dir_all(output_dir.clone())?;
745            }
746        }
747
748        let mut name_parts: Vec<OsString> = Vec::new();
749        if prefix_deploy_id_in_name {
750            name_parts.push(deploy_id.to_os_string());
751        }
752        name_parts.extend(relative_parts);
753        let resource_name = name_parts.join(std::ffi::OsStr::new("-"));
754
755        let output_path = output_dir.join(resource_name);
756
757        if !dry_run {
758            // Different sources can flatten to the same name; never overwrite.
759            let final_output_path = dedup_output_path(output_path.clone());
760            if final_output_path != output_path {
761                let message = format!(
762                    "Renamed to {} to avoid overwriting",
763                    final_output_path.display()
764                );
765                log_line(&message);
766                if let Some(pb_ref) = &pb {
767                    pb_ref.println(message);
768                }
769            }
770            log_line(&format!(
771                "{} {} -> {}",
772                if move_mode { "Moving" } else { "Copying" },
773                resource.display(),
774                final_output_path.display()
775            ));
776            if move_mode {
777                fs::rename(resource, final_output_path)?;
778            } else {
779                fs::copy(resource, final_output_path)?;
780            }
781            if let Some(pb_ref) = &pb {
782                pb_ref.inc(1);
783            }
784        } else if !visited_path.contains(resource_parent.to_string_lossy().as_ref()) {
785            visited_path.insert(resource_parent.to_string_lossy().to_string());
786            println!(
787                "DRYRUN sample: From {} to {}",
788                resource.display(),
789                output_path.display()
790            );
791        }
792    }
793    if let Some(pb_ref) = pb {
794        pb_ref.finish();
795    }
796    Ok(())
797}
798
799pub fn deployments_align(
800    project_dir: PathBuf,
801    output_dir: PathBuf,
802    deploy_table: PathBuf,
803    resource_type: ResourceType,
804    dry_run: bool,
805    move_mode: bool,
806    keep_first_subdir: bool,
807) -> anyhow::Result<()> {
808    let deploy_df = CsvReadOptions::default()
809        .with_columns(csv_projection_columns(&[DEPLOYMENT_ID_COLUMN]))
810        .try_into_reader_with_file_path(Some(deploy_table))?
811        .finish()?;
812    reject_duplicate_csv_columns(&deploy_df)?;
813    let deploy_df = deploy_df
814        .lazy()
815        .select([col(DEPLOYMENT_ID_COLUMN)])
816        .collect()?;
817    let deploy_array = deploy_df[DEPLOYMENT_ID_COLUMN].str()?;
818
819    let deploy_iter = deploy_array.iter();
820    let num_iter = deploy_iter.len();
821    let pb = indicatif::ProgressBar::new(num_iter as u64);
822    configure_progress_bar(&pb);
823    for deploy_id in deploy_iter {
824        let deploy_id = deploy_id
825            .ok_or_else(|| anyhow::anyhow!("Empty deploymentID found in the deployments table"))?;
826        let (_, collection_name) = deploy_id.rsplit_once('_').ok_or_else(|| {
827            anyhow::anyhow!(
828                "Invalid deploymentID '{deploy_id}': expected '<deployment_name>_<collection_name>'"
829            )
830        })?;
831        let deploy_dir = project_dir.join(collection_name).join(deploy_id);
832        let collection_output_dir = output_dir.join(collection_name);
833        resources_flatten(
834            deploy_dir,
835            collection_output_dir.clone(),
836            resource_type,
837            dry_run,
838            move_mode,
839            true,
840            keep_first_subdir,
841        )?;
842        pb.inc(1);
843    }
844    pb.finish();
845    Ok(())
846}
847
848pub fn deployments_rename(project_dir: PathBuf, dry_run: bool) -> anyhow::Result<()> {
849    // rename deployment path name to <deployment_name>_<collection_name>
850    let mut count = 0;
851    for entry in project_dir.read_dir()? {
852        let entry = entry?;
853        let path = entry.path();
854        if path.is_dir() {
855            // Skip serval's own output tree.
856            if path.file_name().and_then(|name| name.to_str()) == Some(SERVAL_OUTPUT_DIR) {
857                continue;
858            }
859            let mut collection_dir = path;
860            let original_collection_name = collection_dir
861                .file_name()
862                .and_then(|name| name.to_str())
863                .ok_or_else(|| anyhow::anyhow!("Invalid collection directory name"))?;
864            let collection_name_lower = original_collection_name.to_lowercase();
865            if original_collection_name != collection_name_lower {
866                let mut new_collection_dir = collection_dir.clone();
867                new_collection_dir.set_file_name(&collection_name_lower);
868                if dry_run {
869                    println!(
870                        "Will rename collection {original_collection_name} to {collection_name_lower}"
871                    );
872                } else {
873                    let message = format!(
874                        "Renaming collection {} to {}",
875                        collection_dir.display(),
876                        new_collection_dir.display()
877                    );
878                    log_line(&message);
879                    println!("{message}");
880                    fs::rename(&collection_dir, &new_collection_dir)?;
881                    collection_dir = new_collection_dir;
882                }
883            }
884            let collection_name = collection_dir
885                .file_name()
886                .and_then(|name| name.to_str())
887                .ok_or_else(|| anyhow::anyhow!("Invalid collection directory name"))?;
888            for deploy in collection_dir.read_dir()? {
889                let deploy_dir = deploy?.path();
890                if deploy_dir.is_file() {
891                    continue;
892                }
893                count += 1;
894                let deploy_name = deploy_dir
895                    .file_name()
896                    .and_then(|name| name.to_str())
897                    .ok_or_else(|| anyhow::anyhow!("Invalid deploy directory name"))?;
898                if !deploy_name.contains(collection_name) {
899                    if dry_run {
900                        println!(
901                            "Will rename {} to {}_{}",
902                            deploy_name,
903                            deploy_name.to_lowercase(),
904                            collection_name.to_lowercase()
905                        );
906                    } else {
907                        let mut deploy_id_dir = deploy_dir.clone();
908                        deploy_id_dir.set_file_name(format!(
909                            "{}_{}",
910                            deploy_name.to_lowercase(),
911                            collection_name.to_lowercase()
912                        ));
913                        let message = format!(
914                            "Renaming {} to {}",
915                            deploy_dir.display(),
916                            deploy_id_dir.display()
917                        );
918                        log_line(&message);
919                        println!("{message}");
920                        fs::rename(deploy_dir, deploy_id_dir)?;
921                    }
922                }
923            }
924        }
925    }
926    println!("Total directories: {count}");
927    Ok(())
928}
929
930// copy xmp files to output_dir and keep the directory structure
931pub fn copy_xmp(source_dir: PathBuf, output_dir: PathBuf) -> anyhow::Result<()> {
932    let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
933    let num_xmp = xmp_paths.len();
934    println!("{num_xmp} xmp files found");
935    let pb = indicatif::ProgressBar::new(num_xmp as u64);
936    configure_progress_bar(&pb);
937
938    for xmp in xmp_paths {
939        let mut output_path = output_dir.clone();
940        let relative_path = xmp.strip_prefix(&source_dir).unwrap();
941        output_path.push(relative_path);
942        fs::create_dir_all(output_path.parent().unwrap())?;
943        fs::copy(xmp, output_path)?;
944        pb.inc(1);
945    }
946    pb.finish();
947    Ok(())
948}
949
950/// Outcome of one item in a batch operation: performed, or skipped with a reason.
951pub enum BatchOutcome {
952    Done,
953    Skipped(String),
954}
955
956/// Print skip warnings, failure errors, and a per-outcome count summary for a
957/// batch operation.
958pub fn report_batch_results(results: Vec<anyhow::Result<BatchOutcome>>, action: &str) {
959    let mut done = 0;
960    let mut skipped = Vec::new();
961    let mut failures = Vec::new();
962    for result in results {
963        match result {
964            Ok(BatchOutcome::Done) => done += 1,
965            Ok(BatchOutcome::Skipped(reason)) => skipped.push(reason),
966            Err(err) => failures.push(err),
967        }
968    }
969    for reason in &skipped {
970        log_line(&format!("Warning: {reason}"));
971        eprintln!("Warning: {reason}");
972    }
973    for err in &failures {
974        log_line(&format!("Error: {err}"));
975        eprintln!("Error: {err}");
976    }
977    let summary = format!(
978        "{done} XMP file(s) {action}, {} skipped, {} failed",
979        skipped.len(),
980        failures.len()
981    );
982    log_line(&summary);
983    println!("{summary}");
984}
985
986// Sync XMP metadata to corresponding media files
987pub fn sync_xmp_to_media(xmp_path: &Path) -> anyhow::Result<BatchOutcome> {
988    let media_path = underlying_media_path(xmp_path);
989    if media_path == xmp_path {
990        return Ok(BatchOutcome::Skipped(format!(
991            "Skipping non-XMP file: {}",
992            xmp_path.display()
993        )));
994    }
995
996    if !media_path.exists() {
997        return Ok(BatchOutcome::Skipped(format!(
998            "Skipping {}: media file {} does not exist",
999            xmp_path.display(),
1000            media_path.display()
1001        )));
1002    }
1003
1004    let xmp_content = fs::read_to_string(xmp_path)?;
1005    let xmp_meta = XmpMeta::from_str(&xmp_content)?;
1006
1007    let mut xmp_file = XmpFile::new()?;
1008    let open_options = OpenFileOptions::default().for_update();
1009    xmp_file.open_file(media_path, open_options)?;
1010    xmp_file.put_xmp(&xmp_meta)?;
1011    xmp_file.try_close()?;
1012
1013    Ok(BatchOutcome::Done)
1014}
1015
1016pub fn sync_xmp_directory(source_dir: PathBuf) -> anyhow::Result<()> {
1017    let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
1018    let num_xmp = xmp_paths.len();
1019
1020    if num_xmp == 0 {
1021        println!("No XMP files found in {}", source_dir.display());
1022        return Ok(());
1023    }
1024
1025    println!(
1026        "Found {} XMP files to sync in {}",
1027        num_xmp,
1028        source_dir.display()
1029    );
1030
1031    let pb = indicatif::ProgressBar::new(num_xmp as u64);
1032    configure_progress_bar(&pb);
1033    pb.set_message("Syncing XMP metadata to media files...");
1034
1035    let results: Vec<anyhow::Result<BatchOutcome>> = xmp_paths
1036        .par_iter()
1037        .map(|xmp_path| {
1038            let result = sync_xmp_to_media(xmp_path);
1039            pb.inc(1);
1040            result
1041        })
1042        .collect();
1043
1044    pb.finish();
1045    report_batch_results(results, "synced");
1046
1047    Ok(())
1048}
1049
1050pub fn sync_xmp_from_csv(csv_path: PathBuf) -> anyhow::Result<()> {
1051    let df = CsvReadOptions::default()
1052        .with_columns(csv_projection_columns(&[PATH_COLUMN]))
1053        .with_ignore_errors(false)
1054        .try_into_reader_with_file_path(Some(csv_path))?
1055        .finish()?;
1056    reject_duplicate_csv_columns(&df)?;
1057
1058    let df_filtered = df
1059        .lazy()
1060        .filter(col("path").is_not_null())
1061        .filter(col("path").str().ends_with(lit(".xmp")))
1062        .select([col("path")])
1063        .unique(
1064            Some(cols(vec!["path".to_string()])),
1065            UniqueKeepStrategy::First,
1066        )
1067        .collect()?;
1068
1069    let num_files = df_filtered.height();
1070    if num_files == 0 {
1071        println!("No XMP files found in CSV");
1072        return Ok(());
1073    }
1074
1075    println!("Found {num_files} XMP files in CSV to sync");
1076
1077    let pb = indicatif::ProgressBar::new(num_files as u64);
1078    configure_progress_bar(&pb);
1079    pb.set_message("Syncing XMP files in CSV...");
1080
1081    let path_col = df_filtered.column("path")?.str()?;
1082
1083    let results: Vec<anyhow::Result<BatchOutcome>> = path_col
1084        .par_iter()
1085        .filter_map(|path| path.map(PathBuf::from))
1086        .map(|xmp_path| {
1087            let result = sync_xmp_to_media(&xmp_path);
1088            pb.inc(1);
1089            result
1090        })
1091        .collect();
1092
1093    pb.finish();
1094    report_batch_results(results, "synced");
1095
1096    Ok(())
1097}
1098
1099// Remove all XMP files recursively from a directory
1100pub fn remove_xmp_files(source_dir: PathBuf) -> anyhow::Result<()> {
1101    let xmp_paths = path_enumerate(source_dir.clone(), ResourceType::Xmp);
1102    let num_xmp = xmp_paths.len();
1103
1104    if num_xmp == 0 {
1105        println!("No XMP files found in {}", source_dir.display());
1106        return Ok(());
1107    }
1108
1109    println!("Found {} XMP files in {}", num_xmp, source_dir.display());
1110
1111    let pb = indicatif::ProgressBar::new(num_xmp as u64);
1112    configure_progress_bar(&pb);
1113    pb.set_message("Removing XMP files...");
1114
1115    let results: Vec<anyhow::Result<BatchOutcome>> = xmp_paths
1116        .par_iter()
1117        .map(|xmp_path| {
1118            let result = fs::remove_file(xmp_path)
1119                .map(|_| BatchOutcome::Done)
1120                .map_err(|e| anyhow::anyhow!("Failed to remove {}: {}", xmp_path.display(), e));
1121            pb.inc(1);
1122            result
1123        })
1124        .collect();
1125
1126    pb.finish();
1127    report_batch_results(results, "removed");
1128    Ok(())
1129}
1130
1131pub fn get_path_levels(path: String) -> Vec<String> {
1132    // Plain string splitting instead of Path::components for performance.
1133    // The first component (root/prefix) and the last one (file name) are not
1134    // selectable as deployment levels.
1135    let normalized_path = normalize_path_separators(&path);
1136    let levels: Vec<String> = normalized_path
1137        .split('/')
1138        .map(|comp| comp.to_string())
1139        .collect();
1140    if levels.len() < 2 {
1141        return Vec::new();
1142    }
1143    levels[1..levels.len() - 1].to_vec()
1144}
1145
1146fn normalize_path_separators(path: &str) -> String {
1147    path.replace('\\', "/")
1148}
1149
1150// Guess which path level is the deployment, top-down: skip the levels shared by
1151// all paths (the common prefix), then based on assumption that:
1152// the first diverging level is usually the collection or the deployment,
1153// and #deployments is usually larger than #collections.
1154pub fn detect_deployment_path_index<I, S>(paths: I) -> Option<i32>
1155where
1156    I: IntoIterator<Item = S>,
1157    S: AsRef<str>,
1158{
1159    let mut level_names: Vec<HashSet<String>> = Vec::new();
1160    let mut depth = None;
1161    for path in paths {
1162        let normalized = normalize_path_separators(path.as_ref());
1163        let components: Vec<&str> = normalized.split('/').collect();
1164        // Same exclusions as get_path_levels: root/prefix and file name.
1165        if components.len() < 3 {
1166            return None;
1167        }
1168        match depth {
1169            None => {
1170                depth = Some(components.len());
1171                level_names = vec![HashSet::new(); components.len() - 2];
1172            }
1173            // Mixed depths make a single global index ill-defined; let the user decide.
1174            Some(depth) if depth != components.len() => return None,
1175            Some(_) => {}
1176        }
1177        for (level, name) in components[1..components.len() - 1].iter().enumerate() {
1178            if !level_names[level].contains(*name) {
1179                level_names[level].insert((*name).to_string());
1180            }
1181        }
1182    }
1183    // All levels shared by every path (e.g. a single deployment): nothing to infer.
1184    let diverge_level = level_names.iter().position(|names| names.len() > 1)?;
1185    let deploy_level = if diverge_level + 1 < level_names.len()
1186        && level_names[diverge_level + 1].len() > level_names[diverge_level].len()
1187    {
1188        diverge_level + 1
1189    } else {
1190        diverge_level
1191    };
1192    // +1 converts back to the split index (level_names[0] is split component 1).
1193    (deploy_level + 1).try_into().ok()
1194}
1195
1196pub fn deployment_from_path(path: &Path, deploy_path_index: i32) -> anyhow::Result<String> {
1197    let normalized_path = normalize_path_separators(&path.to_string_lossy());
1198    normalized_path
1199        .split('/')
1200        .nth(deploy_path_index.try_into()?)
1201        .map(str::to_string)
1202        .ok_or_else(|| {
1203            anyhow::anyhow!(
1204                "Cannot extract deployment from path '{}' with index {}.",
1205                path.display(),
1206                deploy_path_index
1207            )
1208        })
1209}
1210
1211pub fn deployment_from_path_expr(path_expr: Expr, deploy_path_index: i32) -> Expr {
1212    path_expr
1213        .str()
1214        .replace_all(lit("\\"), lit("/"), true)
1215        .str()
1216        .split(lit("/"))
1217        .list()
1218        .get(lit(deploy_path_index), false)
1219}
1220
1221pub fn ignore_timezone(time: String) -> anyhow::Result<String> {
1222    let time = time.trim_end_matches('Z');
1223    // Offsets (+HH:MM / -HH:MM) and fractional seconds can only appear after the
1224    // time-of-day part, so search after 'T'/' ' to avoid cutting at date separators.
1225    let time_start = time.find(['T', ' ']).map_or(0, |i| i + 1);
1226    let tz_start = time[time_start..]
1227        .find(['+', '-', '.'])
1228        .map_or(time.len(), |i| time_start + i);
1229    Ok(time[..tz_start].to_string())
1230}
1231
1232pub fn iso_datetime_to_csv_format(time: &str) -> String {
1233    time.replace('T', " ")
1234}
1235
1236pub fn sync_modified_time(source: PathBuf, target: PathBuf) -> anyhow::Result<()> {
1237    let src = fs::metadata(source)?;
1238    let dest = File::options().write(true).open(target)?;
1239    let times = FileTimes::new()
1240        .set_accessed(src.accessed()?)
1241        .set_modified(src.modified()?);
1242    dest.set_times(times)?;
1243    Ok(())
1244}
1245
1246pub fn tags_csv_translate(
1247    source_csv: PathBuf,
1248    taglist_csv: PathBuf,
1249    output_dir: PathBuf,
1250    from: &str,
1251    to: &str,
1252) -> anyhow::Result<()> {
1253    let source_df = CsvReadOptions::default()
1254        .with_infer_schema_length(Some(0))
1255        .try_into_reader_with_file_path(Some(source_csv.clone()))?
1256        .finish()?;
1257    reject_duplicate_csv_columns(&source_df)?;
1258    let taglist_df = CsvReadOptions::default()
1259        .with_columns(csv_projection_columns(&[from, to]))
1260        .try_into_reader_with_file_path(Some(taglist_csv))?
1261        .finish()?;
1262    reject_duplicate_csv_columns(&taglist_df)?;
1263
1264    let joined = source_df.lazy().join(
1265        taglist_df.lazy(),
1266        [col(TagType::Species.col_name())],
1267        [col(from)],
1268        JoinArgs::new(JoinType::Left),
1269    );
1270
1271    let unknown = joined
1272        .clone()
1273        .filter(
1274            col(to)
1275                .is_null()
1276                .and(col(TagType::Species.col_name()).is_not_null())
1277                .and(col(TagType::Species.col_name()).neq(lit(""))),
1278        )
1279        .select([col(TagType::Species.col_name())])
1280        .unique(None, UniqueKeepStrategy::Any)
1281        .collect()?;
1282    if unknown.height() > 0 {
1283        let mut sample = Vec::new();
1284        if let Ok(col) = unknown.column(TagType::Species.col_name())
1285            && let Ok(ca) = col.str()
1286        {
1287            for v in ca.iter().flatten().take(20) {
1288                sample.push(v.to_string());
1289            }
1290        }
1291        return Err(anyhow::anyhow!(
1292            "Unknown tag(s) not found in taglist: {}",
1293            sample.join(", ")
1294        ));
1295    }
1296
1297    let mut result = joined
1298        .drop(cols([TagType::Species.col_name()]))
1299        .rename(vec![to], vec![TagType::Species.col_name()], true)
1300        // .with_column(col(to).alias("species"))
1301        .collect()?;
1302
1303    let output_csv = output_dir.join(format!(
1304        "{}_translated.csv",
1305        source_csv
1306            .file_stem()
1307            .and_then(|stem| stem.to_str())
1308            .unwrap_or("tags")
1309    ));
1310    fs::create_dir_all(output_dir.clone())?;
1311    let mut file = std::fs::File::create(&output_csv)?;
1312    CsvWriter::new(&mut file)
1313        .include_bom(true)
1314        .finish(&mut result)?;
1315
1316    println!("Saved to {}", output_csv.display());
1317    Ok(())
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323
1324    #[test]
1325    fn ignore_timezone_strips_timezone_suffixes() {
1326        let strip = |s: &str| ignore_timezone(s.to_string()).unwrap();
1327        assert_eq!(strip("2023-12-08T10:47:39+08:00"), "2023-12-08T10:47:39");
1328        assert_eq!(strip("2023-12-08T10:47:39-08:00"), "2023-12-08T10:47:39");
1329        assert_eq!(strip("2023-12-08T10:47:39Z"), "2023-12-08T10:47:39");
1330        assert_eq!(strip("2023-12-08T10:47:39"), "2023-12-08T10:47:39");
1331        assert_eq!(
1332            strip("2023-12-08T10:47:39.123+08:00"),
1333            "2023-12-08T10:47:39"
1334        );
1335        assert_eq!(strip("2023-12-08 10:47:39-0800"), "2023-12-08 10:47:39");
1336    }
1337
1338    #[test]
1339    fn detect_deployment_path_index_top_down() {
1340        // collection diverges first, deployments outnumber collections
1341        assert_eq!(
1342            detect_deployment_path_index([
1343                "project/col_a/dep1_col_a/IMG_0001.jpg",
1344                "project/col_a/dep2_col_a/IMG_0001.jpg",
1345                "project/col_b/dep3_col_b/IMG_0002.jpg",
1346            ]),
1347            Some(2)
1348        );
1349        // camera subfolders below the deployment share names -> not more distinct
1350        assert_eq!(
1351            detect_deployment_path_index([
1352                "project/col_a/dep1/100MEDIA/IMG_0001.jpg",
1353                "project/col_a/dep2/100MEDIA/IMG_0001.jpg",
1354            ]),
1355            Some(2)
1356        );
1357        // divergence at the last directory level
1358        assert_eq!(
1359            detect_deployment_path_index(["data/dep1/IMG_0001.jpg", "data/dep2/IMG_0001.jpg"]),
1360            Some(1)
1361        );
1362        // backslash paths are normalized
1363        assert_eq!(
1364            detect_deployment_path_index([
1365                r"project\col_a\dep1\IMG_0001.jpg",
1366                r"project\col_a\dep2\IMG_0001.jpg",
1367            ]),
1368            Some(2)
1369        );
1370        // single deployment: every level is common, nothing to infer
1371        assert_eq!(
1372            detect_deployment_path_index(["project/col_a/dep1/a.jpg", "project/col_a/dep1/b.jpg"]),
1373            None
1374        );
1375        // mixed depths: a single global index is ill-defined
1376        assert_eq!(
1377            detect_deployment_path_index([
1378                "project/col_a/dep1/a.jpg",
1379                "project/col_a/dep2/100MEDIA/b.jpg",
1380            ]),
1381            None
1382        );
1383        // no directory level between root and file name
1384        assert_eq!(detect_deployment_path_index(["dep1/a.jpg"]), None);
1385        assert_eq!(detect_deployment_path_index(Vec::<String>::new()), None);
1386    }
1387
1388    #[test]
1389    fn advanced_filter_detects_same_field_and_conditions() {
1390        let needs_agg =
1391            |input: &str| has_same_field_and_conditions(&parse_advanced_filter(input).unwrap());
1392        assert!(needs_agg("species:A and species:B"));
1393        assert!(needs_agg("(species:A and species:B) or rating:5"));
1394        assert!(needs_agg("species:A and (species:B or rating:5)"));
1395        assert!(needs_agg("(species:A or rating:5) and species:B"));
1396        assert!(!needs_agg("species:A or species:B"));
1397        assert!(!needs_agg("species:A and rating:4-5"));
1398        assert!(!needs_agg("(species:A or rating:5) and custom:x"));
1399    }
1400}