Skip to main content

fren_date/plan/
mod.rs

1//! Planning: walk the filesystem, compute new names, detect conflicts,
2//! produce a [`crate::RenamePlan`] vector sorted bottom-up for safe
3//! execution.
4
5mod conflict;
6mod sort;
7mod walker;
8
9pub use sort::sort_bottom_up;
10
11use crate::{
12    plan_types::ItemKind, slugify::slugify_camel_iso_with_year, FrenError, PlanOpts, RenamePlan,
13    SlugOpts,
14};
15use chrono::{Datelike, Local};
16use std::ffi::OsString;
17use std::path::Path;
18use uuid::Uuid;
19
20/// Build a [`RenamePlan`] vector for the given roots.
21///
22/// Walks each root recursively (per `opts.recursive`), computes new names
23/// via [`slugify_camel_iso_with_year`], detects within-batch and pre-existing
24/// target conflicts (under the `Abort` policy), and returns plans sorted
25/// deepest-first / files-before-dirs at the same depth.
26///
27/// Errors:
28/// - `FrenError::Io` if a root or any descendant cannot be read.
29/// - `FrenError::TargetExists` if a planned target already exists outside
30///   the batch.
31/// - `FrenError::WithinBatchCollision` if two plans target the same path.
32pub fn plan(
33    roots: &[&Path],
34    slug_opts: &SlugOpts,
35    plan_opts: &PlanOpts,
36) -> Result<Vec<RenamePlan>, FrenError> {
37    let current_year = Local::now().year();
38    plan_with_year(roots, slug_opts, plan_opts, current_year)
39}
40
41/// Variant exposing the "current year" for deterministic testing.
42pub fn plan_with_year(
43    roots: &[&Path],
44    slug_opts: &SlugOpts,
45    plan_opts: &PlanOpts,
46    current_year: i32,
47) -> Result<Vec<RenamePlan>, FrenError> {
48    let batch_id = Uuid::now_v7();
49    let mut plans = Vec::new();
50
51    for root in roots {
52        let items = walker::walk(root, plan_opts)?;
53        for item in items {
54            // Skip the root itself (we don't rename what the user explicitly
55            // pointed at - only its descendants).
56            if item.path == *root {
57                continue;
58            }
59            let new_name = compute_new_name(&item, slug_opts, current_year);
60            let old_name = item
61                .path
62                .file_name()
63                .map(OsString::from)
64                .unwrap_or_default();
65            if Some(new_name.as_os_str()) == Some(old_name.as_os_str()) {
66                // No-op; nothing to rename.
67                continue;
68            }
69            let parent = item
70                .path
71                .parent()
72                .map(Path::to_path_buf)
73                .unwrap_or_default();
74            plans.push(RenamePlan {
75                original_path: item.path.clone(),
76                parent,
77                old_name,
78                new_name,
79                depth: item.depth,
80                kind: item.kind,
81                detected_date: None,
82                batch_id,
83            });
84        }
85    }
86
87    sort_bottom_up(&mut plans);
88    conflict::check_within_batch(&plans)?;
89    if plan_opts.on_conflict == crate::ConflictPolicy::Abort {
90        conflict::check_preexisting(&plans)?;
91    }
92    Ok(plans)
93}
94
95fn compute_new_name(
96    item: &walker::DiscoveredItem,
97    slug_opts: &SlugOpts,
98    current_year: i32,
99) -> OsString {
100    let raw_name = item
101        .path
102        .file_name()
103        .map(|n| n.to_string_lossy().into_owned())
104        .unwrap_or_default();
105    match item.kind {
106        ItemKind::Dir => slugify_camel_iso_with_year(&raw_name, slug_opts, current_year).into(),
107        ItemKind::File | ItemKind::Symlink => {
108            let path = std::path::Path::new(&raw_name);
109            let stem = path
110                .file_stem()
111                .map(|s| s.to_string_lossy().into_owned())
112                .unwrap_or_default();
113            let ext = path
114                .extension()
115                .map(|e| e.to_string_lossy().into_owned())
116                .unwrap_or_default();
117            let new_stem = slugify_camel_iso_with_year(&stem, slug_opts, current_year);
118            if ext.is_empty() {
119                new_stem.into()
120            } else {
121                format!("{}.{}", new_stem, ext.to_lowercase()).into()
122            }
123        }
124    }
125}